Permanently deleting a photo whose album was deleted failed because authorization resolved through the album. Fall back to uploader ownership when the photo is albumless. Add a bulk DELETE /trash that removes every trashed photo the caller may delete (uncapped listing) and returns the deleted count, with an «Очистить корзину» action on the trash screen.
361 lines
11 KiB
Go
361 lines
11 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"
|
|
"galo/internal/video"
|
|
)
|
|
|
|
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
|
|
|
|
videoTools *video.Toolchain
|
|
|
|
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)
|
|
|
|
videoTools := video.Detect(cfg.Video)
|
|
photosSvc.SetUploadLimits(cfg.Upload.ImageMaxBytes, cfg.Upload.VideoMaxBytes)
|
|
photosSvc.SetVideoEnabled(videoTools.Available())
|
|
photosSvc.SetTempDir(cfg.Video.TempDir)
|
|
photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency)
|
|
|
|
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,
|
|
videoTools: videoTools,
|
|
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.DELETE("/trash", photosHandler.EmptyTrash)
|
|
|
|
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"
|
|
}
|
|
// Video tooling is optional: its absence does not degrade the service, but
|
|
// it is reported so operators can see why video upload is disabled.
|
|
videoStatus := "ok"
|
|
if !s.videoTools.Available() {
|
|
videoStatus = "unavailable"
|
|
}
|
|
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, "video": videoStatus})
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|