package app import ( "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 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) 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, social: socialSvc, notifications: notifSvc, share: shareSvc, } } 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) }) }