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/avatar" "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 avatar *avatar.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, albumsSvc, 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) avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st) 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, avatar: avatarSvc, 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) avatarHandler := avatar.NewHandler(s.avatar) 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) // Avatars are read by tags and image loaders, which cannot send an // Authorization header, so the read route is public. Upload and removal // stay behind authentication. The URL carries an opaque per-generation // version segment, so the path is not guessable from a user id alone. api.GET("/avatars/:userID/:version", avatarHandler.Serve) 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", authHandler.UpdateMe) authed.POST("/auth/me/avatar", avatarHandler.Upload) authed.DELETE("/auth/me/avatar", avatarHandler.Remove) authed.POST("/auth/me/password", authHandler.ChangePassword) authed.POST("/auth/me/identifiers", authHandler.AddIdentifier) authed.DELETE("/auth/me", authHandler.DeleteMe) 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() // Манифест PWA отдаём с правильным типом: в системной таблице // mime расширения .webmanifest нет. if strings.HasSuffix(clean, ".webmanifest") { c.Header("Content-Type", "application/manifest+json") } 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) }) }