hatch-surf/internal/handler/spaces.go

383 lines
10 KiB
Go

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
}