package handler import ( "context" "errors" "net/http" "strings" "img_infinite_canvas/internal/domain/design" authmodule "img_infinite_canvas/internal/modules/auth" sharingmodule "img_infinite_canvas/internal/modules/sharing" ) type bearerAuthenticator interface { UserIDFromBearer(ctx context.Context, authorization string) (string, bool) } type profileAuthenticator interface { GetProfile(ctx context.Context, userID string) (authmodule.User, error) } type shareRequestAuthorizer interface { ResolveAccess(ctx context.Context, shareID string, actor sharingmodule.Actor) (sharingmodule.Access, error) } func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers ...shareRequestAuthorizer) func(http.HandlerFunc) http.HandlerFunc { var shareAuthorizer shareRequestAuthorizer if len(shareAuthorizers) > 0 { shareAuthorizer = shareAuthorizers[0] } return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := "" authenticated := false if authenticator != nil { if tokenUserID, ok := userIDFromRequestAuth(r.Context(), r, authenticator); ok { userID = tokenUserID authenticated = true } } actor := sharingmodule.Actor{Authenticated: authenticated, UserID: userID} if authenticated && requestUsesShareAccess(r) { if profiles, ok := authenticator.(profileAuthenticator); ok { profile, err := profiles.GetProfile(r.Context(), userID) if err != nil { writeAuthRequired(w) return } actor.Email = profile.Email actor.CountryCode = profile.CountryCode actor.Phone = profile.Phone actor.Name = profile.Name actor.AvatarURL = profile.AvatarURL } } ctx := sharingmodule.ContextWithActor(r.Context(), actor) ctx = design.ContextWithUserID(ctx, userID) shareAuthorized := false shareID := strings.TrimSpace(r.Header.Get("X-Share-Id")) projectID := shareProjectID(r) if shareID != "" && r.Method == http.MethodDelete && r.URL.Path == "/api/assets" { writeShareAccessError(w, sharingmodule.ErrForbidden) return } if shareID != "" && projectID != "" && shareAuthorizer != nil { access, err := shareAuthorizer.ResolveAccess(ctx, shareID, actor) if err != nil { writeShareAccessError(w, err) return } if access.ProjectID != projectID { writeShareAccessError(w, sharingmodule.ErrNotFound) return } if !access.Allows(requiredShareCapability(r)) { writeShareAccessError(w, sharingmodule.ErrForbidden) return } ctx = sharingmodule.ContextWithAccess(ctx, access) ctx = design.ContextWithUserID(ctx, access.OwnerID) shareAuthorized = true } if requiresAuthenticatedUser(r) && !authenticated && !shareAuthorized { writeAuthRequired(w) return } next(w, r.WithContext(ctx)) } } } func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator bearerAuthenticator) (string, bool) { return authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization")) } func requiresAuthenticatedUser(r *http.Request) bool { path := r.URL.Path if strings.HasPrefix(path, "/v1/") { return true } if !strings.HasPrefix(path, "/api/") { return false } if strings.HasPrefix(path, "/api/auth/") || strings.HasPrefix(path, "/api/shares/") || path == "/api/auth/options" || path == "/api/health" || path == "/api/dashboard" { return false } return true } func writeAuthRequired(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"login required","errorCode":"auth.login_required","code":401}`)) } func requestUsesShareAccess(r *http.Request) bool { return strings.TrimSpace(r.Header.Get("X-Share-Id")) != "" || strings.HasPrefix(r.URL.Path, "/api/shares/") } func shareProjectID(r *http.Request) string { const prefix = "/api/projects/" if strings.HasPrefix(r.URL.Path, prefix) { remainder := strings.TrimPrefix(r.URL.Path, prefix) projectID, _, _ := strings.Cut(remainder, "/") projectID = strings.TrimSpace(projectID) if projectID == "" || projectID == "async" || projectID == "batch-delete" { return "" } return projectID } if r.URL.Path == "/api/generator/tasks" { if headerProjectID := strings.TrimSpace(r.Header.Get("X-Share-Project-Id")); headerProjectID != "" { return headerProjectID } return strings.TrimSpace(r.URL.Query().Get("project_id")) } return "" } func requiredShareCapability(r *http.Request) sharingmodule.Capability { path := r.URL.Path if path == "/api/generator/tasks" { return sharingmodule.CapabilityEdit } if strings.Contains(path, "/sharing") || (r.Method == http.MethodDelete && shareProjectRoot(path)) { return sharingmodule.CapabilityManage } if r.Method != http.MethodGet && r.Method != http.MethodHead { return sharingmodule.CapabilityEdit } if strings.HasSuffix(path, "/agent/ws") { return sharingmodule.CapabilityEdit } if strings.Contains(path, "/agent/") || strings.HasSuffix(path, "/history") || strings.HasSuffix(path, "/events") { return sharingmodule.CapabilityViewChat } return sharingmodule.CapabilityViewCanvas } func shareProjectRoot(path string) bool { remainder := strings.TrimPrefix(path, "/api/projects/") return remainder != path && !strings.Contains(remainder, "/") } func writeShareAccessError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "application/json; charset=utf-8") switch { case errors.Is(err, sharingmodule.ErrLoginRequired): w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"login required for edit access","errorCode":"share.login_required","code":401}`)) case errors.Is(err, sharingmodule.ErrForbidden): w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"share access forbidden","errorCode":"share.forbidden","code":403}`)) case errors.Is(err, sharingmodule.ErrNotFound): w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"share not found","errorCode":"share.not_found","code":404}`)) default: w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"share authorization failed","errorCode":"share.internal","code":500}`)) } } func LegacyUserContextMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := design.ContextWithUserID(r.Context(), "") next(w, r.WithContext(ctx)) } }