package handler import ( "encoding/base64" "encoding/json" "io" "net/http" "strconv" "strings" "github.com/elfoundation/hatch/internal/store" "github.com/go-chi/chi/v5" ) // RegisterSpaceRoutes mounts the space management routes on the given router. func (h *Handler) RegisterSpaceRoutes(r chi.Router) { r.Route("/api/spaces", func(r chi.Router) { // Public endpoints r.Post("/", h.createSpace) r.Get("/", h.listPublicSpaces) r.Get("/{slug}", h.getSpaceBySlug) r.Patch("/{slug}", h.updateSpace) r.Delete("/{slug}", h.deleteSpace) // Photo management r.Post("/{slug}/photos", h.createPhoto) r.Get("/{slug}/photos", h.listPhotos) // Face search endpoints r.Post("/{slug}/face-search/guest", h.guestFaceSearch) r.Post("/{slug}/face-search", h.authenticatedFaceSearch) }) } // createSpace handles POST /api/spaces func (h *Handler) createSpace(w http.ResponseWriter, r *http.Request) { var space store.Space if err := json.NewDecoder(r.Body).Decode(&space); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) return } // Generate slug if not provided if space.Slug == "" { space.Slug = generateSlug(space.Name) } // Validate required fields if space.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } ctx := r.Context() createdSpace, err := h.Repo.CreateSpace(ctx, &space) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create space: "+err.Error()) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdSpace) } // listPublicSpaces handles GET /api/spaces func (h *Handler) listPublicSpaces(w http.ResponseWriter, r *http.Request) { ctx := r.Context() spaces, err := h.Repo.ListPublicSpaces(ctx) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list spaces") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(spaces) } // getSpaceBySlug handles GET /api/spaces/{slug} func (h *Handler) getSpaceBySlug(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if slug == "" { writeError(w, http.StatusBadRequest, "missing slug") return } ctx := r.Context() space, err := h.Repo.GetSpaceBySlug(ctx, slug) if err != nil { writeError(w, http.StatusNotFound, "space not found") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(space) } // updateSpace handles PATCH /api/spaces/{slug} func (h *Handler) updateSpace(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if slug == "" { writeError(w, http.StatusBadRequest, "missing slug") return } ctx := r.Context() existingSpace, err := h.Repo.GetSpaceBySlug(ctx, slug) if err != nil { writeError(w, http.StatusNotFound, "space not found") return } // Update fields from request body var updates store.Space if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) return } // Apply updates if updates.Name != "" { existingSpace.Name = updates.Name } if updates.Description != "" { existingSpace.Description = updates.Description } // Note: is_public is a boolean, so we need to check if it was explicitly set // For simplicity, we'll always update it if provided in the request existingSpace.IsPublic = updates.IsPublic updatedSpace, err := h.Repo.UpdateSpace(ctx, existingSpace) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update space: "+err.Error()) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(updatedSpace) } // deleteSpace handles DELETE /api/spaces/{slug} func (h *Handler) deleteSpace(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if slug == "" { writeError(w, http.StatusBadRequest, "missing slug") return } ctx := r.Context() space, err := h.Repo.GetSpaceBySlug(ctx, slug) if err != nil { writeError(w, http.StatusNotFound, "space not found") return } if err := h.Repo.DeleteSpace(ctx, space.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete space: "+err.Error()) return } w.WriteHeader(http.StatusNoContent) } // createPhoto handles POST /api/spaces/{slug}/photos func (h *Handler) createPhoto(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if slug == "" { writeError(w, http.StatusBadRequest, "missing slug") return } ctx := r.Context() space, err := h.Repo.GetSpaceBySlug(ctx, slug) if err != nil { writeError(w, http.StatusNotFound, "space not found") return } var photo store.Photo if err := json.NewDecoder(r.Body).Decode(&photo); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) return } // Validate required fields if photo.URL == "" { writeError(w, http.StatusBadRequest, "url is required") return } photo.SpaceID = space.ID createdPhoto, err := h.Repo.CreatePhoto(ctx, &photo) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create photo: "+err.Error()) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdPhoto) } // listPhotos handles GET /api/spaces/{slug}/photos func (h *Handler) listPhotos(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if slug == "" { writeError(w, http.StatusBadRequest, "missing slug") return } ctx := r.Context() space, err := h.Repo.GetSpaceBySlug(ctx, slug) if err != nil { writeError(w, http.StatusNotFound, "space not found") return } // Parse limit parameter limitStr := r.URL.Query().Get("limit") limit := 100 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } } photos, err := h.Repo.ListPhotosBySpace(ctx, space.ID, limit) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list photos") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(photos) } // guestFaceSearch handles POST /api/spaces/{slug}/face-search/guest func (h *Handler) guestFaceSearch(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if slug == "" { writeError(w, http.StatusBadRequest, "missing slug") return } ctx := r.Context() space, err := h.Repo.GetSpaceBySlug(ctx, slug) if err != nil { writeError(w, http.StatusNotFound, "space not found") return } // Check rate limit (20 requests per 15 minutes per IP) ipAddress := getClientIP(r) allowed, err := h.Repo.CheckRateLimit(ctx, ipAddress, space.ID, 20, 15) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check rate limit") return } if !allowed { writeError(w, http.StatusTooManyRequests, "rate limit exceeded") return } // Increment rate limit if err := h.Repo.IncrementRateLimit(ctx, ipAddress, space.ID, 15); err != nil { writeError(w, http.StatusInternalServerError, "failed to increment rate limit") return } // Parse request body (should contain image data or URL) var request struct { ImageURL string `json:"imageUrl,omitempty"` ImageData string `json:"imageData,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) return } // Check if face service is available if h.FaceService == nil { writeError(w, http.StatusInternalServerError, "face search service not configured") return } // Convert base64 image data to bytes var imageBytes []byte if request.ImageData != "" { // Decode base64 image data imageBytes, err = base64.StdEncoding.DecodeString(request.ImageData) if err != nil { writeError(w, http.StatusBadRequest, "invalid image data") return } } else if request.ImageURL != "" { // Download image from URL resp, err := http.Get(request.ImageURL) if err != nil { writeError(w, http.StatusBadRequest, "failed to download image") return } defer resp.Body.Close() imageBytes, err = io.ReadAll(resp.Body) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read image") return } } else { writeError(w, http.StatusBadRequest, "image data or URL required") return } // Validate image if err := h.FaceService.ValidateImage(imageBytes); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // Search faces result, err := h.FaceService.SearchFaces(ctx, space.ID, imageBytes, 10) if err != nil { writeError(w, http.StatusInternalServerError, "face search failed: "+err.Error()) return } // Format response response := map[string]interface{}{ "status": "success", "matches": result.Matches, "photoCount": result.PhotoCount, "searchTime": result.SearchTime.Milliseconds(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // authenticatedFaceSearch handles POST /api/spaces/{slug}/face-search func (h *Handler) authenticatedFaceSearch(w http.ResponseWriter, r *http.Request) { // For now, delegate to guest face search h.guestFaceSearch(w, r) } // Helper functions func generateSlug(name string) string { // Convert to lowercase, replace spaces with hyphens, remove non-alphanumeric characters slug := strings.ToLower(name) slug = strings.ReplaceAll(slug, " ", "-") slug = strings.ReplaceAll(slug, "_", "-") // Remove non-alphanumeric characters except hyphens var result strings.Builder for _, c := range slug { if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { result.WriteRune(c) } } return result.String() } func getClientIP(r *http.Request) string { // Check X-Forwarded-For header first xForwardedFor := r.Header.Get("X-Forwarded-For") if xForwardedFor != "" { // X-Forwarded-For may contain multiple IPs, take the first one parts := strings.Split(xForwardedFor, ",") return strings.TrimSpace(parts[0]) } // Check X-Real-IP header xRealIP := r.Header.Get("X-Real-IP") if xRealIP != "" { return xRealIP } // Fall back to RemoteAddr parts := strings.Split(r.RemoteAddr, ":") if len(parts) > 1 { return strings.Join(parts[:len(parts)-1], ":") } return r.RemoteAddr }