feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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.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.DELETE("/photos/:id", photosHandler.Delete)
|
||||
authed.GET("/photos/:id/download", photosHandler.Download)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user