Large albums opened slowly and flickered; the default album cover was also lost in the media-objects refactor. Large-album loading: - quantize signed object URLs within a window and send Cache-Control so previews are reused instead of re-fetched - publish photo.updated (not photo.created) when variants are ready, so clients update in place without reordering the grid - worker concurrency + newest-first variant priority; raise default batch Album cover: - first uploaded photo becomes the cover when none is set - clear the cover when the cover photo is trashed or moved out - "set as album cover" in the photo context menu and actions panel OpenSpec changes: fix-large-album-load, fix-album-cover
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
assets "galo"
|
|
"galo/internal/albums"
|
|
"galo/internal/auth"
|
|
"galo/internal/config"
|
|
"galo/internal/httpx"
|
|
"galo/internal/members"
|
|
"galo/internal/notifications"
|
|
"galo/internal/photos"
|
|
"galo/internal/sharelinks"
|
|
"galo/internal/social"
|
|
"galo/internal/sse"
|
|
"galo/internal/storage"
|
|
"galo/internal/store"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
repos store.Repos
|
|
ping func(ctx context.Context) error
|
|
storage storage.Backend
|
|
hub *sse.Hub
|
|
|
|
albums *albums.Service
|
|
|
|
auth *auth.Service
|
|
|
|
membersSvc *members.Service
|
|
|
|
photos *photos.Service
|
|
worker *photos.Worker
|
|
|
|
social *social.Service
|
|
|
|
notifications *notifications.Service
|
|
|
|
share *sharelinks.Service
|
|
}
|
|
|
|
// New wires the domain services over the selected metadata repositories. ping
|
|
// reports the health of the metadata backend so health stays backend-agnostic.
|
|
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, tm *auth.TokenManager, st storage.Backend, hub *sse.Hub) *Server {
|
|
authSvc := auth.NewService(repos.Auth, tm)
|
|
|
|
notifSvc := notifications.NewService(repos.Notifications, hub)
|
|
|
|
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st)
|
|
membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc)
|
|
|
|
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
|
|
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
|
|
|
|
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
|
|
|
|
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
|
|
|
|
return &Server{
|
|
cfg: cfg,
|
|
repos: repos,
|
|
ping: ping,
|
|
storage: st,
|
|
hub: hub,
|
|
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(), requestIDMiddleware(), accessLogMiddleware())
|
|
|
|
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.GET("/objects/*key", s.serveObject)
|
|
api.HEAD("/objects/*key", s.serveObject)
|
|
|
|
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.ping(ctx); err != nil {
|
|
dbStatus = "error"
|
|
}
|
|
storageLabel := s.cfg.Storage.Backend
|
|
if storageLabel == "" {
|
|
storageLabel = "s3"
|
|
}
|
|
storeStatus := "ok"
|
|
if err := s.storage.Ping(ctx); err != nil {
|
|
storeStatus = "error"
|
|
}
|
|
status := http.StatusOK
|
|
overall := "ok"
|
|
if dbStatus != "ok" || storeStatus != "ok" {
|
|
status = http.StatusServiceUnavailable
|
|
overall = "degraded"
|
|
}
|
|
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus})
|
|
}
|
|
|
|
func (s *Server) serveObject(c *gin.Context) {
|
|
key := strings.TrimPrefix(c.Param("key"), "/")
|
|
// Objects are immutable (one key maps to one blob), so successful responses
|
|
// are safe to cache. The header is dropped on error paths below.
|
|
c.Writer.Header().Set("Cache-Control", cacheControlValue(s.cfg.Storage.CacheMaxAge))
|
|
switch err := s.storage.ServeObject(c.Writer, c.Request, key); {
|
|
case err == nil:
|
|
return
|
|
case errors.Is(err, storage.ErrSignedURLInvalid):
|
|
c.Writer.Header().Del("Cache-Control")
|
|
c.Status(http.StatusForbidden)
|
|
case errors.Is(err, storage.ErrObjectNotFound):
|
|
c.Writer.Header().Del("Cache-Control")
|
|
httpx.RespondError(c, httpx.NotFound("object_not_found", "object not found"))
|
|
case errors.Is(err, storage.ErrRangeNotSatisfiable):
|
|
c.Writer.Header().Del("Cache-Control")
|
|
c.Status(http.StatusRequestedRangeNotSatisfiable)
|
|
default:
|
|
c.Writer.Header().Del("Cache-Control")
|
|
c.Status(http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// cacheControlValue renders the Cache-Control value for object responses.
|
|
// Non-positive values fall back to a one-hour freshness window.
|
|
func cacheControlValue(maxAge time.Duration) string {
|
|
if maxAge <= 0 {
|
|
maxAge = time.Hour
|
|
}
|
|
return fmt.Sprintf("private, max-age=%d", int(maxAge.Seconds()))
|
|
}
|
|
|
|
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.repos.Albums.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)
|
|
})
|
|
}
|