Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
118 lines
3.2 KiB
Go
118 lines
3.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
|
)
|
|
|
|
const presignExpiry = 15 * time.Minute
|
|
|
|
// serveFile delivers photo bytes after re-running the gallery access check.
|
|
// S3: 302 to a short-lived presigned URL; local: streamed directly.
|
|
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
|
variant := r.PathValue("variant")
|
|
if variant != "thumb" && variant != "preview" && variant != "original" {
|
|
writeError(w, http.StatusNotFound, "Not Found")
|
|
return
|
|
}
|
|
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
|
|
if err != nil {
|
|
writeStoreError(w, err, "Photo not found")
|
|
return
|
|
}
|
|
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
|
|
if err != nil {
|
|
writeStoreError(w, err, "Photo not found")
|
|
return
|
|
}
|
|
user := s.optionalUser(r)
|
|
token := r.URL.Query().Get("token")
|
|
if denial := s.authorize(r, g, user, token); denial != nil {
|
|
writeError(w, denial.status, denial.msg)
|
|
return
|
|
}
|
|
isAdmin := user != nil && user.IsAdmin()
|
|
|
|
var key, contentType, downloadName string
|
|
switch variant {
|
|
case "thumb":
|
|
key, contentType = p.ThumbKey, "image/jpeg"
|
|
case "preview":
|
|
key, contentType = p.PreviewKey, "image/jpeg"
|
|
case "original":
|
|
key, contentType = p.OriginalKey, p.ContentType
|
|
downloadName = p.OriginalFilename
|
|
if downloadName == "" {
|
|
downloadName = p.ID + extForContentType(p.ContentType)
|
|
}
|
|
}
|
|
if key == "" {
|
|
// Variant not generated yet (photo still processing).
|
|
writeError(w, http.StatusNotFound, "Not ready")
|
|
return
|
|
}
|
|
// Non-ready originals stay admin-only so uploads that fail processing
|
|
// never leak to viewers.
|
|
if p.Status != "ready" && !isAdmin {
|
|
writeError(w, http.StatusNotFound, "Not ready")
|
|
return
|
|
}
|
|
|
|
if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
|
|
http.Redirect(w, r, url, http.StatusFound)
|
|
return
|
|
} else if !errors.Is(err, storage.ErrNoPresign) {
|
|
log.Printf("Error: presign %s: %v", key, err)
|
|
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
|
return
|
|
}
|
|
|
|
reader, size, err := s.storage.Open(r.Context(), key)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
writeError(w, http.StatusNotFound, "Not Found")
|
|
return
|
|
}
|
|
log.Printf("Error: open %s: %v", key, err)
|
|
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
// Keys are immutable (new upload = new key), so private caching is safe
|
|
// even though access is checked per request.
|
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
|
if downloadName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName))
|
|
}
|
|
if _, err := io.Copy(w, reader); err != nil {
|
|
log.Printf("stream %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
func extForContentType(ct string) string {
|
|
switch ct {
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/heic":
|
|
return ".heic"
|
|
}
|
|
return ""
|
|
}
|