Files
ds-galo/backend/internal/app/app.go
T
iu 96268ee16c feat(photos): separate object storage from metadata layer
Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
2026-10-10 23:31:41 +03:00

311 lines
9.0 KiB
Go

package app
import (
"context"
"io/fs"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/httpx"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
type Server struct {
cfg *config.Config
pool *pgxpool.Pool
storage *storage.Client
hub *sse.Hub
albumsStore *albums.Store
albums *albums.Service
auth *auth.Service
membersSvc *members.Service
photos *photos.Service
worker *photos.Worker
social *social.Service
notifications *notifications.Service
share *sharelinks.Service
}
func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hub) *Server {
authStore := auth.NewStore(pool)
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
authSvc := auth.NewService(authStore, tm)
albumsStore := albums.NewStore(pool)
membersStore := members.NewStore(pool)
notifStore := notifications.NewStore(pool)
notifSvc := notifications.NewService(notifStore, hub)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
photosStore := photos.NewStore(pool)
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL)
socialStore := social.NewStore(pool)
socialSvc := social.NewService(socialStore, albumsSvc, photosStore, hub, notifSvc)
shareStore := sharelinks.NewStore(pool)
shareSvc := sharelinks.NewService(shareStore, albumsSvc)
return &Server{
cfg: cfg,
pool: pool,
storage: st,
hub: hub,
albumsStore: albumsStore,
albums: albumsSvc,
auth: authSvc,
membersSvc: membersSvc,
photos: photosSvc,
worker: photosWorker,
social: socialSvc,
notifications: notifSvc,
share: shareSvc,
}
}
// StartWorker runs the background reconciler until ctx is cancelled.
func (s *Server) StartWorker(ctx context.Context) {
if s.worker != nil {
s.worker.Run(ctx)
}
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
authHandler := auth.NewHandler(s.auth)
albumsHandler := albums.NewHandler(s.albums)
membersHandler := members.NewHandler(s.membersSvc)
photosHandler := photos.NewHandler(s.photos)
socialHandler := social.NewHandler(s.social)
notifHandler := notifications.NewHandler(s.notifications)
shareHandler := sharelinks.NewHandler(s.share)
api := r.Group("/api")
api.GET("/health", s.health)
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/refresh", authHandler.Refresh)
api.POST("/auth/logout", authHandler.Logout)
api.GET("/invites/:token", membersHandler.InviteInfo)
api.GET("/share/:token", s.shareAlbum)
api.GET("/share/:token/photos", s.sharePhotos)
api.GET("/share/:token/photos/:photo_id", s.sharePhoto)
api.GET("/share/:token/photos/:photo_id/download", s.sharePhotoDownload)
authed := api.Group("")
authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me)
authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE)
authed.GET("/albums", albumsHandler.List)
authed.POST("/albums", albumsHandler.Create)
authed.GET("/albums/:id", albumsHandler.Get)
authed.PATCH("/albums/:id", albumsHandler.Update)
authed.DELETE("/albums/:id", albumsHandler.Delete)
authed.GET("/albums/:id/members", membersHandler.List)
authed.POST("/albums/:id/invites", membersHandler.CreateInvite)
authed.POST("/invites/:token/accept", membersHandler.AcceptInvite)
authed.PATCH("/albums/:id/members/:user_id", membersHandler.ChangeRole)
authed.DELETE("/albums/:id/members/:user_id", membersHandler.Remove)
authed.GET("/albums/:id/photos", photosHandler.List)
authed.POST("/albums/:id/photos", photosHandler.Upload)
authed.GET("/photos/:id", photosHandler.Get)
authed.PATCH("/photos/:id", photosHandler.Rename)
authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/restore", photosHandler.Restore)
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
authed.POST("/photos/:id/tags", photosHandler.AddTag)
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
authed.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
authed.GET("/trash", photosHandler.Trash)
authed.GET("/trash/count", photosHandler.TrashCount)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
authed.GET("/photos/:id/comments", socialHandler.ListComments)
authed.POST("/photos/:id/comments", socialHandler.AddComment)
authed.DELETE("/comments/:id", socialHandler.DeleteComment)
authed.GET("/albums/:id/share", shareHandler.List)
authed.POST("/albums/:id/share", shareHandler.Create)
authed.DELETE("/albums/:id/share/:link_id", shareHandler.Revoke)
authed.GET("/notifications", notifHandler.List)
authed.POST("/notifications/:id/read", notifHandler.MarkRead)
s.registerStatic(r)
return r
}
func (s *Server) health(c *gin.Context) {
ctx := c.Request.Context()
dbStatus := "ok"
if err := s.pool.Ping(ctx); err != nil {
dbStatus = "error"
}
s3Status := "ok"
if err := s.storage.Ping(ctx); err != nil {
s3Status = "error"
}
status := http.StatusOK
overall := "ok"
if dbStatus != "ok" || s3Status != "ok" {
status = http.StatusServiceUnavailable
overall = "degraded"
}
c.JSON(status, gin.H{"status": overall, "db": dbStatus, "s3": s3Status})
}
func (s *Server) albumSSE(c *gin.Context) {
userID := auth.UserID(c)
albumID := c.Param("id")
if _, _, err := s.albums.Access(c.Request.Context(), albumID, userID); err != nil {
httpx.RespondError(c, err)
return
}
sub := s.hub.SubscribeAlbum(albumID)
defer sub.Close()
sse.Serve(c, sub, s.cfg.SSE.HeartbeatInterval)
}
func (s *Server) meSSE(c *gin.Context) {
sub := s.hub.SubscribeUser(auth.UserID(c))
defer sub.Close()
sse.Serve(c, sub, s.cfg.SSE.HeartbeatInterval)
}
func (s *Server) shareAlbum(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
album, err := s.albumsStore.GetByID(c.Request.Context(), link.AlbumID)
if err != nil {
httpx.RespondError(c, httpx.NotFound("album_not_found", "album not found"))
return
}
c.JSON(http.StatusOK, gin.H{"album": album})
}
func (s *Server) sharePhotos(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
limit := 50
var since *time.Time
list, next, err := s.photos.ListPublic(c.Request.Context(), link.AlbumID, c.Query("cursor"), since, limit)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": list, "next_cursor": next})
}
func (s *Server) sharePhoto(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
p, err := s.photos.GetPublic(c.Request.Context(), c.Param("photo_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
if p.AlbumID != link.AlbumID {
httpx.RespondError(c, httpx.NotFound("photo_not_found", "photo not found"))
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (s *Server) sharePhotoDownload(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
p, err := s.photos.GetPublic(c.Request.Context(), c.Param("photo_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
if p.AlbumID != link.AlbumID {
httpx.RespondError(c, httpx.NotFound("photo_not_found", "photo not found"))
return
}
c.JSON(http.StatusOK, gin.H{"url": p.DownloadURL})
}
func (s *Server) registerStatic(r *gin.Engine) {
sub, err := fs.Sub(assets.Static, "static")
if err != nil {
return
}
fileServer := http.FileServer(http.FS(sub))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") {
c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: httpx.ErrorBody{Code: "not_found", Message: "not found"}})
return
}
clean := strings.TrimPrefix(path, "/")
if clean != "" {
if f, err := sub.Open(clean); err == nil {
f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusNotFound, "frontend not built")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
}