383 lines
10 KiB
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
|
|
} |