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,87 @@
|
||||
package albums
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.svc.List(c.Request.Context(), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"albums": list})
|
||||
}
|
||||
|
||||
type albumRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req albumRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "title is required"))
|
||||
return
|
||||
}
|
||||
a, err := h.svc.Create(c.Request.Context(), auth.UserID(c), CreateInput(req))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"album": a})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
a, role, err := h.svc.Get(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"album": a, "role": role})
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id"`
|
||||
ClearCover bool `json:"clear_cover"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid body"))
|
||||
return
|
||||
}
|
||||
a, err := h.svc.Update(c.Request.Context(), c.Param("id"), auth.UserID(c), UpdateInput{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
CoverPhotoID: req.CoverPhotoID,
|
||||
ClearCover: req.ClearCover,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"album": a})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package albums
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/roles"
|
||||
"shared-album/internal/sse"
|
||||
"shared-album/internal/storage"
|
||||
)
|
||||
|
||||
type MemberLookup interface {
|
||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
members MemberLookup
|
||||
hub *sse.Hub
|
||||
storage *storage.Client
|
||||
}
|
||||
|
||||
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *Service {
|
||||
return &Service{store: store, members: members, hub: hub, storage: storage}
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*models.Album, error) {
|
||||
title := strings.TrimSpace(in.Title)
|
||||
if title == "" {
|
||||
return nil, httpx.BadRequest("invalid_title", "title is required")
|
||||
}
|
||||
a := &models.Album{
|
||||
ID: auth.NewID(),
|
||||
OwnerID: userID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
}
|
||||
if err := s.store.Create(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
return s.store.ListForUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||
a, err := s.store.GetByID(ctx, albumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, "", httpx.NotFound("album_not_found", "album not found")
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
if a.OwnerID == userID {
|
||||
return a, roles.Owner, nil
|
||||
}
|
||||
role, err := s.members.Role(ctx, albumID, userID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if role == "" {
|
||||
return nil, "", httpx.NotFound("album_not_found", "album not found")
|
||||
}
|
||||
return a, role, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||
return s.Access(ctx, albumID, userID)
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Title *string
|
||||
Description *string
|
||||
CoverPhotoID *string
|
||||
ClearCover bool
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateInput) (*models.Album, error) {
|
||||
a, role, err := s.Access(ctx, albumID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanEditAlbum(role) {
|
||||
return nil, httpx.Forbidden("forbidden", "only owner and admin can edit the album")
|
||||
}
|
||||
if in.Title != nil {
|
||||
title := strings.TrimSpace(*in.Title)
|
||||
if title == "" {
|
||||
return nil, httpx.BadRequest("invalid_title", "title cannot be empty")
|
||||
}
|
||||
a.Title = title
|
||||
}
|
||||
if in.Description != nil {
|
||||
a.Description = strings.TrimSpace(*in.Description)
|
||||
}
|
||||
if in.ClearCover {
|
||||
a.CoverPhotoID = nil
|
||||
} else if in.CoverPhotoID != nil {
|
||||
a.CoverPhotoID = in.CoverPhotoID
|
||||
}
|
||||
if err := s.store.Update(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.hub.Publish(albumID, "album.updated", a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
||||
a, role, err := s.Access(ctx, albumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != roles.Owner {
|
||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
||||
}
|
||||
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.storage != nil {
|
||||
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
|
||||
}
|
||||
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package albums
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("album not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, a *models.Album) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO albums (id, owner_id, title, description, cover_photo_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING created_at, updated_at`,
|
||||
a.ID, a.OwnerID, a.Title, a.Description, a.CoverPhotoID,
|
||||
).Scan(&a.CreatedAt, &a.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
|
||||
var ownerID string
|
||||
err := s.pool.QueryRow(ctx, `SELECT owner_id FROM albums WHERE id = $1`, id).Scan(&ownerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", httpx.NotFound("album_not_found", "album not found")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return ownerID, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
var a models.Album
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
|
||||
FROM albums WHERE id = $1`, id).
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at
|
||||
FROM albums a
|
||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||
ORDER BY a.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Album{}
|
||||
for rows.Next() {
|
||||
var a models.Album
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, a *models.Album) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`UPDATE albums
|
||||
SET title = $2, description = $3, cover_photo_id = $4, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING updated_at`,
|
||||
a.ID, a.Title, a.Description, a.CoverPhotoID,
|
||||
).Scan(&a.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "email, password and name are required"))
|
||||
return
|
||||
}
|
||||
u, pair, err := h.svc.Register(c.Request.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": u.Public(), "tokens": pair})
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "email and password are required"))
|
||||
return
|
||||
}
|
||||
u, pair, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Public(), "tokens": pair})
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "refresh_token is required"))
|
||||
return
|
||||
}
|
||||
pair, err := h.svc.Refresh(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tokens": pair})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.RefreshToken != "" {
|
||||
if err := h.svc.Logout(c.Request.Context(), req.RefreshToken); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
u, err := h.svc.store.GetUserByID(c.Request.Context(), UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Public()})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager {
|
||||
return &TokenManager{secret: []byte(secret), accessTTL: accessTTL, refreshTTL: refreshTTL}
|
||||
}
|
||||
|
||||
func (m *TokenManager) AccessTTL() time.Duration { return m.accessTTL }
|
||||
func (m *TokenManager) RefreshTTL() time.Duration { return m.refreshTTL }
|
||||
|
||||
func (m *TokenManager) GenerateAccess(userID, email string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secret)
|
||||
}
|
||||
|
||||
func (m *TokenManager) ParseAccess(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func NewRefreshToken() (raw string, hash string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw = base64.RawURLEncoding.EncodeToString(buf)
|
||||
return raw, HashRefreshToken(raw), nil
|
||||
}
|
||||
|
||||
func HashRefreshToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxUserID = "auth.user_id"
|
||||
ctxEmail = "auth.email"
|
||||
)
|
||||
|
||||
func (s *Service) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := extractToken(c)
|
||||
if token == "" {
|
||||
httpx.Abort(c, httpx.Unauthorized("unauthorized", "missing access token"))
|
||||
return
|
||||
}
|
||||
claims, err := s.tm.ParseAccess(token)
|
||||
if err != nil {
|
||||
httpx.Abort(c, httpx.Unauthorized("unauthorized", "invalid or expired access token"))
|
||||
return
|
||||
}
|
||||
c.Set(ctxUserID, claims.UserID)
|
||||
c.Set(ctxEmail, claims.Email)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
if t := c.Query("token"); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func UserID(c *gin.Context) string {
|
||||
if v, ok := c.Get(ctxUserID); ok {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Email(c *gin.Context) string {
|
||||
if v, ok := c.Get(ctxEmail); ok {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) ParseToken(token string) (*Claims, error) {
|
||||
return s.tm.ParseAccess(token)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
tm *TokenManager
|
||||
}
|
||||
|
||||
func NewService(store *Store, tm *TokenManager) *Service {
|
||||
return &Service{store: store, tm: tm}
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, *TokenPair, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
name = strings.TrimSpace(name)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return nil, nil, httpx.BadRequest("invalid_email", "valid email is required")
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, nil, httpx.BadRequest("invalid_name", "name is required")
|
||||
}
|
||||
|
||||
if _, err := s.store.GetUserByEmail(ctx, email); err == nil {
|
||||
return nil, nil, httpx.Conflict("email_taken", "email is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
u := &models.User{
|
||||
ID: NewID(),
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.store.CreateUser(ctx, u); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pair, err := s.issuePair(ctx, u)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, email, password string) (*models.User, *TokenPair, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
u, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !CheckPassword(u.PasswordHash, password) {
|
||||
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
|
||||
}
|
||||
pair, err := s.issuePair(ctx, u)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
hash := HashRefreshToken(refreshToken)
|
||||
t, err := s.store.GetRefreshToken(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if t.RevokedAt != nil || time.Now().After(t.ExpiresAt) {
|
||||
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
u, err := s.store.GetUserByID(ctx, t.UserID)
|
||||
if err != nil {
|
||||
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
if err := s.store.RevokeRefreshToken(ctx, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issuePair(ctx, u)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
|
||||
hash := HashRefreshToken(refreshToken)
|
||||
t, err := s.store.GetRefreshToken(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.RevokeRefreshToken(ctx, t.ID)
|
||||
}
|
||||
|
||||
func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) {
|
||||
access, err := s.tm.GenerateAccess(u.ID, u.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, hash, err := NewRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rt := &models.RefreshToken{
|
||||
ID: NewID(),
|
||||
UserID: u.ID,
|
||||
TokenHash: hash,
|
||||
ExpiresAt: time.Now().Add(s.tm.RefreshTTL()),
|
||||
}
|
||||
if err := s.store.CreateRefreshToken(ctx, rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: raw,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(s.tm.AccessTTL().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO users (id, email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
return s.scanUser(s.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
||||
FROM users WHERE lower(email) = lower($1)`, email))
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
|
||||
return s.scanUser(s.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
||||
FROM users WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
|
||||
var u models.User
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
t.ID, t.UserID, t.TokenHash, t.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) {
|
||||
var t models.RefreshToken
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
|
||||
FROM refresh_tokens WHERE token_hash = $1`, hash).
|
||||
Scan(&t.ID, &t.UserID, &t.TokenHash, &t.ExpiresAt, &t.RevokedAt, &t.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeRefreshToken(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAllUserTokens(ctx context.Context, userID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Postgres struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
func (p Postgres) DSN() string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
p.User, p.Password, p.Host, p.Port, p.Database, p.SSLMode)
|
||||
}
|
||||
|
||||
type S3 struct {
|
||||
Endpoint string
|
||||
Region string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
UsePathStyle bool
|
||||
PublicURL string
|
||||
PresignTTL time.Duration
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
JWTSecret string
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Addr string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type SSE struct {
|
||||
HeartbeatInterval time.Duration
|
||||
ClientBuffer int
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Postgres Postgres
|
||||
S3 S3
|
||||
Auth Auth
|
||||
Server Server
|
||||
SSE SSE
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
pgPort, err := strconv.Atoi(getEnv("PG_PORT", "5432"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid PG_PORT: %w", err)
|
||||
}
|
||||
|
||||
accessTTL, err := time.ParseDuration(getEnv("JWT_ACCESS_TTL", "15m"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_ACCESS_TTL: %w", err)
|
||||
}
|
||||
|
||||
refreshTTL, err := time.ParseDuration(getEnv("JWT_REFRESH_TTL", "720h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err)
|
||||
}
|
||||
|
||||
presignTTL, err := time.ParseDuration(getEnv("S3_PRESIGN_TTL", "1h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid S3_PRESIGN_TTL: %w", err)
|
||||
}
|
||||
|
||||
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SSE_HEARTBEAT_INTERVAL: %w", err)
|
||||
}
|
||||
|
||||
bufSize, err := strconv.Atoi(getEnv("SSE_CLIENT_BUFFER", "64"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SSE_CLIENT_BUFFER: %w", err)
|
||||
}
|
||||
|
||||
pathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "true"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Postgres: Postgres{
|
||||
Host: getEnv("PG_HOST", "pg.localnet"),
|
||||
Port: pgPort,
|
||||
User: getEnv("PG_USER", "album"),
|
||||
Password: getEnv("PG_PASSWORD", ""),
|
||||
Database: getEnv("PG_DATABASE", "shared_album"),
|
||||
SSLMode: getEnv("PG_SSLMODE", "disable"),
|
||||
},
|
||||
S3: S3{
|
||||
Endpoint: getEnv("S3_ENDPOINT", "http://s3.localnet:3900"),
|
||||
Region: getEnv("S3_REGION", "garage"),
|
||||
AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
||||
SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||
Bucket: getEnv("S3_BUCKET", "albums"),
|
||||
UsePathStyle: pathStyle,
|
||||
PublicURL: getEnv("S3_PUBLIC_URL", ""),
|
||||
PresignTTL: presignTTL,
|
||||
},
|
||||
Auth: Auth{
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
AccessTTL: accessTTL,
|
||||
RefreshTTL: refreshTTL,
|
||||
},
|
||||
Server: Server{
|
||||
Addr: getEnv("HTTP_ADDR", ":8080"),
|
||||
BaseURL: getEnv("APP_BASE_URL", "http://localhost:8080"),
|
||||
},
|
||||
SSE: SSE{
|
||||
HeartbeatInterval: heartbeat,
|
||||
ClientBuffer: bufSize,
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Auth.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||
}
|
||||
if cfg.Postgres.Password == "" {
|
||||
return nil, fmt.Errorf("PG_PASSWORD is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
cfg.MinConns = 1
|
||||
cfg.MaxConnLifetime = time.Hour
|
||||
cfg.HealthCheckPeriod = time.Minute
|
||||
|
||||
var pool *pgxpool.Pool
|
||||
var lastErr error
|
||||
backoff := time.Second
|
||||
for attempt := 1; attempt <= 10; attempt++ {
|
||||
pool, err = pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err == nil {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err = pool.Ping(pingCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return pool, nil
|
||||
}
|
||||
pool.Close()
|
||||
}
|
||||
lastErr = err
|
||||
slog.Warn("db connect attempt failed", "attempt", attempt, "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("connect to database after retries: %w", lastErr)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"database/sql"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// Migrate applies all up migrations from the given fs (root contains *.sql files).
|
||||
func Migrate(dsn string, migrations fs.FS) error {
|
||||
source, err := iofs.New(migrations, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrations source: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sql db: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
driver, err := pgx.WithInstance(sqlDB, &pgx.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate driver: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pgx", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate instance: %w", err)
|
||||
}
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("migrate up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
type AppError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string { return e.Message }
|
||||
|
||||
func NewError(status int, code, message string) *AppError {
|
||||
return &AppError{Status: status, Code: code, Message: message}
|
||||
}
|
||||
|
||||
func BadRequest(code, message string) *AppError {
|
||||
return NewError(http.StatusBadRequest, code, message)
|
||||
}
|
||||
|
||||
func Unauthorized(code, message string) *AppError {
|
||||
return NewError(http.StatusUnauthorized, code, message)
|
||||
}
|
||||
|
||||
func Forbidden(code, message string) *AppError {
|
||||
return NewError(http.StatusForbidden, code, message)
|
||||
}
|
||||
|
||||
func NotFound(code, message string) *AppError {
|
||||
return NewError(http.StatusNotFound, code, message)
|
||||
}
|
||||
|
||||
func Conflict(code, message string) *AppError {
|
||||
return NewError(http.StatusConflict, code, message)
|
||||
}
|
||||
|
||||
func Internal(message string) *AppError {
|
||||
return NewError(http.StatusInternalServerError, "internal_error", message)
|
||||
}
|
||||
|
||||
func Abort(c *gin.Context, err error) {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
c.AbortWithStatusJSON(appErr.Status, ErrorResponse{ErrorBody{appErr.Code, appErr.Message}})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{ErrorBody{"internal_error", "internal server error"}})
|
||||
}
|
||||
|
||||
func RespondError(c *gin.Context, err error) {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
c.JSON(appErr.Status, ErrorResponse{ErrorBody{appErr.Code, appErr.Message}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{ErrorBody{"internal_error", "internal server error"}})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package members
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": list})
|
||||
}
|
||||
|
||||
type inviteRequest struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateInvite(c *gin.Context) {
|
||||
var req inviteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "role is required"))
|
||||
return
|
||||
}
|
||||
var ttl time.Duration
|
||||
if req.ExpiresIn != "" {
|
||||
d, err := time.ParseDuration(req.ExpiresIn)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_expires_in", "invalid expires_in duration"))
|
||||
return
|
||||
}
|
||||
ttl = d
|
||||
}
|
||||
inv, err := h.svc.CreateInvite(c.Request.Context(), c.Param("id"), auth.UserID(c), CreateInviteInput{
|
||||
Role: req.Role,
|
||||
TTL: ttl,
|
||||
MaxUses: req.MaxUses,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"invite": inv})
|
||||
}
|
||||
|
||||
func (h *Handler) AcceptInvite(c *gin.Context) {
|
||||
inv, err := h.svc.Accept(c.Request.Context(), c.Param("token"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"album_id": inv.AlbumID, "role": inv.Role})
|
||||
}
|
||||
|
||||
func (h *Handler) InviteInfo(c *gin.Context) {
|
||||
inv, err := h.svc.InviteInfo(c.Request.Context(), c.Param("token"))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"invite": gin.H{
|
||||
"album_id": inv.AlbumID,
|
||||
"role": inv.Role,
|
||||
"expires_at": inv.ExpiresAt,
|
||||
}})
|
||||
}
|
||||
|
||||
type roleRequest struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) ChangeRole(c *gin.Context) {
|
||||
var req roleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "role is required"))
|
||||
return
|
||||
}
|
||||
if err := h.svc.ChangeRole(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("user_id"), req.Role); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Remove(c *gin.Context) {
|
||||
if err := h.svc.Remove(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("user_id")); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package members
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/roles"
|
||||
"shared-album/internal/sse"
|
||||
)
|
||||
|
||||
type AlbumReader interface {
|
||||
OwnerID(ctx context.Context, albumID string) (string, error)
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Create(ctx context.Context, userID, typ string, payload map[string]any) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
albums AlbumReader
|
||||
hub *sse.Hub
|
||||
notify Notifier
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
|
||||
return &Service{store: store, albums: albums, hub: hub, notify: notify}
|
||||
}
|
||||
|
||||
func (s *Service) Role(ctx context.Context, albumID, userID string) (string, error) {
|
||||
return s.store.Role(ctx, albumID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) actorRole(ctx context.Context, albumID, actorID string) (string, error) {
|
||||
ownerID, err := s.albums.OwnerID(ctx, albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ownerID == actorID {
|
||||
return roles.Owner, nil
|
||||
}
|
||||
role, err := s.store.Role(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if role == "" {
|
||||
return "", httpx.NotFound("album_not_found", "album not found")
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
type CreateInviteInput struct {
|
||||
Role string
|
||||
TTL time.Duration
|
||||
MaxUses int
|
||||
}
|
||||
|
||||
func (s *Service) CreateInvite(ctx context.Context, albumID, actorID string, in CreateInviteInput) (*models.Invite, error) {
|
||||
role, err := s.actorRole(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanInvite(role, in.Role) {
|
||||
return nil, httpx.Forbidden("forbidden", "you cannot invite with this role")
|
||||
}
|
||||
if in.TTL <= 0 {
|
||||
in.TTL = 7 * 24 * time.Hour
|
||||
}
|
||||
if in.MaxUses <= 0 {
|
||||
in.MaxUses = 1
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inv := &models.Invite{
|
||||
ID: auth.NewID(),
|
||||
AlbumID: albumID,
|
||||
Token: token,
|
||||
Role: in.Role,
|
||||
CreatedBy: actorID,
|
||||
ExpiresAt: time.Now().Add(in.TTL),
|
||||
MaxUses: in.MaxUses,
|
||||
}
|
||||
if err := s.store.CreateInvite(ctx, inv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *Service) Accept(ctx context.Context, token, userID string) (*models.Invite, error) {
|
||||
inv, err := s.store.GetInviteByToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("invite_not_found", "invite not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(inv.ExpiresAt) {
|
||||
return nil, httpx.BadRequest("invite_expired", "invite has expired")
|
||||
}
|
||||
if inv.Uses >= inv.MaxUses {
|
||||
return nil, httpx.BadRequest("invite_exhausted", "invite has no remaining uses")
|
||||
}
|
||||
if err := s.store.Add(ctx, inv.AlbumID, userID, inv.Role, &inv.CreatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.IncrementUses(ctx, inv.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.hub.Publish(inv.AlbumID, "member.joined", map[string]string{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role})
|
||||
if s.notify != nil {
|
||||
ownerID, err := s.albums.OwnerID(ctx, inv.AlbumID)
|
||||
if err == nil && ownerID != userID {
|
||||
_ = s.notify.Create(ctx, ownerID, "member.joined", map[string]any{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role})
|
||||
}
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, albumID, actorID string) ([]models.AlbumMember, error) {
|
||||
if _, err := s.actorRole(ctx, albumID, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.List(ctx, albumID)
|
||||
}
|
||||
|
||||
func (s *Service) InviteInfo(ctx context.Context, token string) (*models.Invite, error) {
|
||||
inv, err := s.store.GetInviteByToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("invite_not_found", "invite not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(inv.ExpiresAt) || inv.Uses >= inv.MaxUses {
|
||||
return nil, httpx.BadRequest("invite_inactive", "invite is expired or exhausted")
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangeRole(ctx context.Context, albumID, actorID, targetID, newRole string) error {
|
||||
actor, err := s.actorRole(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := s.store.Role(ctx, albumID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current == "" {
|
||||
if ownerID, oerr := s.albums.OwnerID(ctx, albumID); oerr == nil && ownerID == targetID {
|
||||
return httpx.Forbidden("forbidden", "cannot change the owner's role")
|
||||
}
|
||||
return httpx.NotFound("member_not_found", "member not found")
|
||||
}
|
||||
if !roles.CanChangeRole(actor, current, newRole) {
|
||||
return httpx.Forbidden("forbidden", "you cannot assign this role")
|
||||
}
|
||||
if err := s.store.UpdateRole(ctx, albumID, targetID, newRole); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("member_not_found", "member not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.hub.Publish(albumID, "member.updated", map[string]string{"album_id": albumID, "user_id": targetID, "role": newRole})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Remove(ctx context.Context, albumID, actorID, targetID string) error {
|
||||
actor, err := s.actorRole(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetRole, err := s.store.Role(ctx, albumID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if targetRole == "" {
|
||||
if ownerID, oerr := s.albums.OwnerID(ctx, albumID); oerr == nil && ownerID == targetID {
|
||||
return httpx.Forbidden("forbidden", "cannot remove the album owner")
|
||||
}
|
||||
return httpx.NotFound("member_not_found", "member not found")
|
||||
}
|
||||
if !roles.CanRemoveMember(actor, targetRole) {
|
||||
return httpx.Forbidden("forbidden", "you cannot remove this member")
|
||||
}
|
||||
if err := s.store.Remove(ctx, albumID, targetID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("member_not_found", "member not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.hub.Publish(albumID, "member.left", map[string]string{"album_id": albumID, "user_id": targetID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
raw, _, err := auth.NewRefreshToken()
|
||||
return raw, err
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package members
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Role(ctx context.Context, albumID, userID string) (string, error) {
|
||||
var role string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT role FROM album_members WHERE album_id = $1 AND user_id = $2`,
|
||||
albumID, userID).Scan(&role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context, albumID string) ([]models.AlbumMember, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT m.album_id, m.user_id, m.role, m.invited_by, m.joined_at,
|
||||
u.id, u.email, u.name, COALESCE(u.avatar_url, '')
|
||||
FROM album_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.album_id = $1
|
||||
ORDER BY m.joined_at`, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.AlbumMember{}
|
||||
for rows.Next() {
|
||||
var m models.AlbumMember
|
||||
if err := rows.Scan(&m.AlbumID, &m.UserID, &m.Role, &m.InvitedBy, &m.JoinedAt,
|
||||
&m.User.ID, &m.User.Email, &m.User.Name, &m.User.AvatarURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO album_members (album_id, user_id, role, invited_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (album_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
albumID, userID, role, invitedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRole(ctx context.Context, albumID, userID, role string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE album_members SET role = $3 WHERE album_id = $1 AND user_id = $2`,
|
||||
albumID, userID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Remove(ctx context.Context, albumID, userID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM album_members WHERE album_id = $1 AND user_id = $2`, albumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateInvite(ctx context.Context, inv *models.Invite) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO invites (id, album_id, token, role, created_by, expires_at, max_uses)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING created_at`,
|
||||
inv.ID, inv.AlbumID, inv.Token, inv.Role, inv.CreatedBy, inv.ExpiresAt, inv.MaxUses,
|
||||
).Scan(&inv.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) GetInviteByToken(ctx context.Context, token string) (*models.Invite, error) {
|
||||
var inv models.Invite
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, album_id, token, role, created_by, expires_at, max_uses, uses, created_at
|
||||
FROM invites WHERE token = $1`, token).
|
||||
Scan(&inv.ID, &inv.AlbumID, &inv.Token, &inv.Role, &inv.CreatedBy, &inv.ExpiresAt, &inv.MaxUses, &inv.Uses, &inv.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (s *Store) IncrementUses(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE invites SET uses = uses + 1 WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type PublicUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
func (u *User) Public() PublicUser {
|
||||
if u == nil {
|
||||
return PublicUser{}
|
||||
}
|
||||
return PublicUser{ID: u.ID, Email: u.Email, Name: u.Name, AvatarURL: u.AvatarURL}
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AlbumMember struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
InvitedBy *string `json:"invited_by,omitempty"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
User PublicUser `json:"user"`
|
||||
}
|
||||
|
||||
type Invite struct {
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
Token string `json:"token"`
|
||||
Role string `json:"role"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
Uses int `json:"uses"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Photo struct {
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
UploaderID string `json:"uploader_id"`
|
||||
StorageKey string `json:"-"`
|
||||
ThumbKey string `json:"-"`
|
||||
PreviewKey string `json:"-"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
MimeType string `json:"mime_type"`
|
||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
LikedByMe bool `json:"liked_by_me"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
OriginalURL string `json:"original_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
PhotoID string `json:"photo_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Text string `json:"text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
User PublicUser `json:"user"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
PayloadJSON map[string]any `json:"payload"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ShareLink struct {
|
||||
ID string `json:"id"`
|
||||
AlbumID string `json:"album_id"`
|
||||
Token string `json:"token"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ShareLink) Active(now time.Time) bool {
|
||||
return s.RevokedAt == nil && now.Before(s.ExpiresAt)
|
||||
}
|
||||
|
||||
type RefreshToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
RevokedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||
list, err := h.svc.List(c.Request.Context(), auth.UserID(c), limit)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"notifications": list})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkRead(c *gin.Context) {
|
||||
if err := h.svc.MarkRead(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/sse"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
hub *sse.Hub
|
||||
}
|
||||
|
||||
func NewService(store *Store, hub *sse.Hub) *Service {
|
||||
return &Service{store: store, hub: hub}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID, typ string, payload map[string]any) error {
|
||||
n := &models.Notification{
|
||||
ID: auth.NewID(),
|
||||
UserID: userID,
|
||||
Type: typ,
|
||||
PayloadJSON: payload,
|
||||
}
|
||||
if err := s.store.Create(ctx, n); err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.PublishUser(userID, "notification", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
|
||||
return s.store.List(ctx, userID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) MarkRead(ctx context.Context, id, userID string) error {
|
||||
if err := s.store.MarkRead(ctx, id, userID); err != nil {
|
||||
return httpx.NotFound("notification_not_found", "notification not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, n *models.Notification) error {
|
||||
payload, err := json.Marshal(n.PayloadJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO notifications (id, user_id, type, payload_json)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING created_at`,
|
||||
n.ID, n.UserID, n.Type, payload,
|
||||
).Scan(&n.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, user_id, type, payload_json, read_at, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Notification{}
|
||||
for rows.Next() {
|
||||
var n models.Notification
|
||||
var payload []byte
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &payload, &n.ReadAt, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(payload, &n.PayloadJSON)
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) MarkRead(ctx context.Context, id, userID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE notifications SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL`,
|
||||
id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
var exists bool
|
||||
if err := s.pool.QueryRow(ctx, `SELECT true FROM notifications WHERE id = $1 AND user_id = $2`, id, userID).Scan(&exists); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
const maxUploadSize = 50 << 20
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
var since *time.Time
|
||||
if raw := c.Query("since"); raw != "" {
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
since = &t
|
||||
} else if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
t := time.UnixMilli(ms)
|
||||
since = &t
|
||||
}
|
||||
}
|
||||
list, next, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c), 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 (h *Handler) Upload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "expected multipart form"))
|
||||
return
|
||||
}
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
files = form.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
httpx.RespondError(c, httpx.BadRequest("no_files", "no files provided"))
|
||||
return
|
||||
}
|
||||
|
||||
created := make([]any, 0, len(files))
|
||||
for _, fh := range files {
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxUploadSize))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
p, err := h.svc.Upload(c.Request.Context(), UploadInput{
|
||||
AlbumID: c.Param("id"),
|
||||
UserID: auth.UserID(c),
|
||||
Filename: fh.Filename,
|
||||
MimeType: fh.Header.Get("Content-Type"),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
created = append(created, p)
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"photos": created})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
p, err := h.svc.Get(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||
}
|
||||
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
)
|
||||
|
||||
type ImageMeta struct {
|
||||
Width int
|
||||
Height int
|
||||
MimeType string
|
||||
TakenAt *time.Time
|
||||
Latitude *float64
|
||||
Longitude *float64
|
||||
Thumb []byte
|
||||
Preview []byte
|
||||
}
|
||||
|
||||
func inspect(data []byte) (width, height int, ok bool) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
|
||||
x, err := exif.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
|
||||
if s, err := dt.StringVal(); err == nil {
|
||||
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
|
||||
utc := t.UTC()
|
||||
taken = &utc
|
||||
}
|
||||
}
|
||||
}
|
||||
if la, lo, err := x.LatLong(); err == nil {
|
||||
lat, lon = &la, &lo
|
||||
}
|
||||
return taken, lat, lon
|
||||
}
|
||||
|
||||
func buildVariants(data []byte) (thumb, preview []byte, err error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
|
||||
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
|
||||
|
||||
opts := webp.Options{Quality: 82}
|
||||
var thumbBuf bytes.Buffer
|
||||
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var previewBuf bytes.Buffer
|
||||
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/roles"
|
||||
"shared-album/internal/sse"
|
||||
"shared-album/internal/storage"
|
||||
)
|
||||
|
||||
type AlbumAccess interface {
|
||||
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
albums AlbumAccess
|
||||
storage *storage.Client
|
||||
hub *sse.Hub
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service {
|
||||
return &Service{store: store, albums: albums, storage: storage, hub: hub}
|
||||
}
|
||||
|
||||
type UploadInput struct {
|
||||
AlbumID string
|
||||
UserID string
|
||||
Filename string
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
|
||||
_, role, err := s.albums.Access(ctx, in.AlbumID, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanUpload(role) {
|
||||
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
|
||||
}
|
||||
|
||||
mime := in.MimeType
|
||||
if mime == "" {
|
||||
mime = http.DetectContentType(in.Data)
|
||||
}
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return nil, httpx.BadRequest("invalid_file", "file is not an image")
|
||||
}
|
||||
|
||||
width, height, ok := inspect(in.Data)
|
||||
if !ok {
|
||||
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
||||
}
|
||||
taken, lat, lon := extractEXIF(in.Data)
|
||||
|
||||
id := auth.NewID()
|
||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
storageKey := fmt.Sprintf("albums/%s/original/%s%s", in.AlbumID, id, ext)
|
||||
|
||||
if err := s.storage.Put(ctx, storageKey, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &models.Photo{
|
||||
ID: id,
|
||||
AlbumID: in.AlbumID,
|
||||
UploaderID: in.UserID,
|
||||
StorageKey: storageKey,
|
||||
OriginalFilename: in.Filename,
|
||||
Size: int64(len(in.Data)),
|
||||
Width: width,
|
||||
Height: height,
|
||||
MimeType: mime,
|
||||
TakenAt: taken,
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
}
|
||||
if err := s.store.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := in.Data
|
||||
go s.processVariants(p.ID, in.AlbumID, data)
|
||||
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) processVariants(photoID, albumID string, data []byte) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
thumb, preview, err := buildVariants(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
thumbKey := fmt.Sprintf("albums/%s/thumb/%s.webp", albumID, photoID)
|
||||
previewKey := fmt.Sprintf("albums/%s/preview/%s.webp", albumID, photoID)
|
||||
if err := s.storage.Put(ctx, thumbKey, bytes.NewReader(thumb), int64(len(thumb)), "image/webp"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/webp"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
|
||||
return
|
||||
}
|
||||
s.store.SetCover(ctx, albumID, photoID)
|
||||
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
|
||||
if err := s.attachURLs(ctx, p); err == nil {
|
||||
s.hub.Publish(albumID, "photo.created", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||
if p.StorageKey != "" {
|
||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.OriginalURL = url
|
||||
p.DownloadURL = url
|
||||
}
|
||||
if p.ThumbKey != "" {
|
||||
if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil {
|
||||
p.ThumbURL = url
|
||||
}
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil {
|
||||
p.PreviewURL = url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, albumID, userID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
|
||||
if _, _, err := s.albums.Access(ctx, albumID, userID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out, err := s.store.List(ctx, ListParams{
|
||||
AlbumID: albumID,
|
||||
Viewer: userID,
|
||||
Cursor: cursor,
|
||||
Since: since,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for i := range out {
|
||||
_ = s.attachURLs(ctx, &out[i])
|
||||
}
|
||||
next := ""
|
||||
if since == nil && len(out) == limit && limit > 0 {
|
||||
next = out[len(out)-1].ID
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
|
||||
// ListPublic lists photos for a share link (access already validated by token).
|
||||
func (s *Service) ListPublic(ctx context.Context, albumID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
|
||||
out, err := s.store.List(ctx, ListParams{
|
||||
AlbumID: albumID,
|
||||
Viewer: "",
|
||||
Cursor: cursor,
|
||||
Since: since,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for i := range out {
|
||||
_ = s.attachURLs(ctx, &out[i])
|
||||
}
|
||||
next := ""
|
||||
if since == nil && len(out) == limit && limit > 0 {
|
||||
next = out[len(out)-1].ID
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
|
||||
// GetPublic returns a single photo for a share link.
|
||||
func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, error) {
|
||||
p, err := s.store.GetByID(ctx, photoID, "")
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DownloadPublic returns a presigned original URL for a share link.
|
||||
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
|
||||
p, err := s.GetPublic(ctx, photoID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.storage.PresignGet(ctx, p.StorageKey)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Photo, error) {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) Download(ctx context.Context, photoID, userID string) (string, error) {
|
||||
p, err := s.Get(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
own := p.UploaderID == userID
|
||||
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
keys = append(keys, p.PreviewKey)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, keys...)
|
||||
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
|
||||
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("photo not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
||||
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
||||
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
||||
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)`
|
||||
|
||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
||||
size, width, height, mime_type, taken_at, latitude, longitude, caption)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING created_at`,
|
||||
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
|
||||
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption,
|
||||
).Scan(&p.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
||||
return scanPhoto(s.pool.QueryRow(ctx,
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.id = $1`, id, viewerID))
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
AlbumID string
|
||||
Viewer string
|
||||
Cursor string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
|
||||
if in.Limit <= 0 || in.Limit > 100 {
|
||||
in.Limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.album_id = $1
|
||||
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
||||
AND ($4::timestamptz IS NULL OR p.created_at > $4)
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $5`,
|
||||
in.AlbumID, in.Viewer, in.Cursor, in.Since, in.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/albums"
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/sharelinks"
|
||||
)
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Skipf("cannot connect: %v", err)
|
||||
}
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
t.Skipf("cannot ping: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func newUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) *models.User {
|
||||
t.Helper()
|
||||
u := &models.User{
|
||||
ID: auth.NewID(),
|
||||
Email: auth.NewID()[:8] + "@test.local",
|
||||
PasswordHash: "x",
|
||||
Name: name,
|
||||
}
|
||||
if err := auth.NewStore(pool).CreateUser(ctx, u); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestUserRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
store := auth.NewStore(pool)
|
||||
|
||||
u := newUser(t, ctx, pool, "Tester")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID) })
|
||||
|
||||
got, err := store.GetUserByEmail(ctx, u.Email)
|
||||
if err != nil || got.ID != u.ID {
|
||||
t.Fatalf("get by email: %v id=%v", err, got)
|
||||
}
|
||||
if _, err := store.GetUserByID(ctx, u.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
|
||||
tok := &models.RefreshToken{ID: auth.NewID(), UserID: u.ID, TokenHash: auth.HashRefreshToken("abc"), ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := store.CreateRefreshToken(ctx, tok); err != nil {
|
||||
t.Fatalf("create refresh: %v", err)
|
||||
}
|
||||
fetched, err := store.GetRefreshToken(ctx, auth.HashRefreshToken("abc"))
|
||||
if err != nil || fetched.ID != tok.ID {
|
||||
t.Fatalf("get refresh: %v", err)
|
||||
}
|
||||
if err := store.RevokeRefreshToken(ctx, tok.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
fetched, _ = store.GetRefreshToken(ctx, auth.HashRefreshToken("abc"))
|
||||
if fetched.RevokedAt == nil {
|
||||
t.Fatalf("refresh not revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
store := albums.NewStore(pool)
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
|
||||
|
||||
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Trip", Description: "d"}
|
||||
if err := store.Create(ctx, a); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetByID(ctx, a.ID)
|
||||
if err != nil || got.Title != "Trip" {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if ownerID, err := store.OwnerID(ctx, a.ID); err != nil || ownerID != owner.ID {
|
||||
t.Fatalf("owner id: %v %v", ownerID, err)
|
||||
}
|
||||
|
||||
list, err := store.ListForUser(ctx, owner.ID)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
|
||||
a.Title = "Renamed"
|
||||
if err := store.Update(ctx, a); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ = store.GetByID(ctx, a.ID)
|
||||
if got.Title != "Renamed" {
|
||||
t.Fatalf("update not applied")
|
||||
}
|
||||
|
||||
if err := store.Delete(ctx, a.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := store.GetByID(ctx, a.ID); err != albums.ErrNotFound {
|
||||
t.Fatalf("expected not found after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
member := newUser(t, ctx, pool, "Member")
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id IN ($1, $2)`, owner.ID, member.ID)
|
||||
})
|
||||
|
||||
albumStore := albums.NewStore(pool)
|
||||
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
|
||||
if err := albumStore.Create(ctx, a); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
store := members.NewStore(pool)
|
||||
if err := store.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
role, err := store.Role(ctx, a.ID, member.ID)
|
||||
if err != nil || role != "member" {
|
||||
t.Fatalf("role: %v %q", err, role)
|
||||
}
|
||||
|
||||
list, err := store.List(ctx, a.ID)
|
||||
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
|
||||
t.Fatalf("list members: %v %+v", err, list)
|
||||
}
|
||||
|
||||
if err := store.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
|
||||
t.Fatalf("update role: %v", err)
|
||||
}
|
||||
role, _ = store.Role(ctx, a.ID, member.ID)
|
||||
if role != "editor" {
|
||||
t.Fatalf("role not updated: %q", role)
|
||||
}
|
||||
|
||||
inv := &models.Invite{ID: auth.NewID(), AlbumID: a.ID, Token: auth.NewID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2}
|
||||
if err := store.CreateInvite(ctx, inv); err != nil {
|
||||
t.Fatalf("create invite: %v", err)
|
||||
}
|
||||
got, err := store.GetInviteByToken(ctx, inv.Token)
|
||||
if err != nil || got.Role != "viewer" || got.MaxUses != 2 {
|
||||
t.Fatalf("get invite: %v %+v", err, got)
|
||||
}
|
||||
if err := store.IncrementUses(ctx, inv.ID); err != nil {
|
||||
t.Fatalf("increment: %v", err)
|
||||
}
|
||||
got, _ = store.GetInviteByToken(ctx, inv.Token)
|
||||
if got.Uses != 1 {
|
||||
t.Fatalf("uses not incremented: %d", got.Uses)
|
||||
}
|
||||
|
||||
if err := store.Remove(ctx, a.ID, member.ID); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
role, _ = store.Role(ctx, a.ID, member.ID)
|
||||
if role != "" {
|
||||
t.Fatalf("member not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
|
||||
|
||||
albumsStore := albums.NewStore(pool)
|
||||
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
|
||||
if err := albumsStore.Create(ctx, a); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
store := sharelinks.NewStore(pool)
|
||||
l := &models.ShareLink{ID: auth.NewID(), AlbumID: a.ID, Token: auth.NewID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := store.Create(ctx, l); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := store.GetByToken(ctx, l.Token)
|
||||
if err != nil || !got.Active(time.Now()) {
|
||||
t.Fatalf("get token: %v", err)
|
||||
}
|
||||
links, err := store.ListByAlbum(ctx, a.ID)
|
||||
if err != nil || len(links) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(links))
|
||||
}
|
||||
if err := store.Revoke(ctx, l.ID, a.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
got, _ = store.GetByToken(ctx, l.Token)
|
||||
if got.Active(time.Now()) {
|
||||
t.Fatalf("link still active after revoke")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package roles
|
||||
|
||||
const (
|
||||
Owner = "owner"
|
||||
Admin = "admin"
|
||||
Editor = "editor"
|
||||
Member = "member"
|
||||
Viewer = "viewer"
|
||||
)
|
||||
|
||||
var rank = map[string]int{
|
||||
Viewer: 0,
|
||||
Member: 1,
|
||||
Editor: 2,
|
||||
Admin: 3,
|
||||
Owner: 4,
|
||||
}
|
||||
|
||||
func Rank(r string) int { return rank[r] }
|
||||
|
||||
func ValidMemberRole(r string) bool {
|
||||
switch r {
|
||||
case Admin, Editor, Member, Viewer:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func CanUpload(r string) bool {
|
||||
switch r {
|
||||
case Owner, Admin, Editor, Member:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func CanComment(r string) bool {
|
||||
return CanUpload(r)
|
||||
}
|
||||
|
||||
func CanDeleteAnyPhoto(r string) bool {
|
||||
return r == Owner || r == Admin
|
||||
}
|
||||
|
||||
func CanDeleteOwnPhoto(r string) bool {
|
||||
return CanUpload(r)
|
||||
}
|
||||
|
||||
func CanEditAlbum(r string) bool {
|
||||
return r == Owner || r == Admin
|
||||
}
|
||||
|
||||
func CanManageShares(r string) bool {
|
||||
return r == Owner || r == Admin
|
||||
}
|
||||
|
||||
func CanInvite(actorRole, targetRole string) bool {
|
||||
if actorRole == Editor {
|
||||
return targetRole == Member || targetRole == Viewer
|
||||
}
|
||||
if actorRole != Owner && actorRole != Admin {
|
||||
return false
|
||||
}
|
||||
if targetRole == Owner {
|
||||
return false
|
||||
}
|
||||
return Rank(targetRole) <= Rank(actorRole)
|
||||
}
|
||||
|
||||
func CanChangeRole(actorRole, targetCurrent, newRole string) bool {
|
||||
if targetCurrent == Owner || newRole == Owner {
|
||||
return false
|
||||
}
|
||||
if !ValidMemberRole(newRole) {
|
||||
return false
|
||||
}
|
||||
switch actorRole {
|
||||
case Owner:
|
||||
return true
|
||||
case Admin:
|
||||
if targetCurrent == Admin {
|
||||
return false
|
||||
}
|
||||
return newRole == Editor || newRole == Member || newRole == Viewer
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func CanRemoveMember(actorRole, targetRole string) bool {
|
||||
if actorRole != Owner && actorRole != Admin {
|
||||
return false
|
||||
}
|
||||
if targetRole == Owner || targetRole == Admin {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package sharelinks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
type createRequest struct {
|
||||
ExpiresIn string `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req createRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
var ttl time.Duration
|
||||
if req.ExpiresIn != "" {
|
||||
d, err := time.ParseDuration(req.ExpiresIn)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_expires_in", "invalid expires_in duration"))
|
||||
return
|
||||
}
|
||||
ttl = d
|
||||
}
|
||||
link, err := h.svc.Create(c.Request.Context(), c.Param("id"), auth.UserID(c), ttl)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"share_link": link})
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"share_links": list})
|
||||
}
|
||||
|
||||
func (h *Handler) Revoke(c *gin.Context) {
|
||||
if err := h.svc.Revoke(c.Request.Context(), c.Param("id"), c.Param("link_id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package sharelinks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/roles"
|
||||
)
|
||||
|
||||
type AlbumAccess interface {
|
||||
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
albums AlbumAccess
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumAccess) *Service {
|
||||
return &Service{store: store, albums: albums}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, albumID, actorID string, ttl time.Duration) (*models.ShareLink, error) {
|
||||
_, role, err := s.albums.Access(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanManageShares(role) {
|
||||
return nil, httpx.Forbidden("forbidden", "only owner and admin can manage share links")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
token, _, err := auth.NewRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &models.ShareLink{
|
||||
ID: auth.NewID(),
|
||||
AlbumID: albumID,
|
||||
Token: token,
|
||||
CreatedBy: actorID,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
if err := s.store.Create(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, albumID, actorID string) ([]models.ShareLink, error) {
|
||||
_, role, err := s.albums.Access(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanManageShares(role) {
|
||||
return nil, httpx.Forbidden("forbidden", "only owner and admin can manage share links")
|
||||
}
|
||||
return s.store.ListByAlbum(ctx, albumID)
|
||||
}
|
||||
|
||||
func (s *Service) Revoke(ctx context.Context, albumID, linkID, actorID string) error {
|
||||
_, role, err := s.albums.Access(ctx, albumID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !roles.CanManageShares(role) {
|
||||
return httpx.Forbidden("forbidden", "only owner and admin can manage share links")
|
||||
}
|
||||
if err := s.store.Revoke(ctx, linkID, albumID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("share_link_not_found", "share link not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Resolve(ctx context.Context, token string) (*models.ShareLink, error) {
|
||||
l, err := s.store.GetByToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("share_link_not_found", "share link not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !l.Active(time.Now()) {
|
||||
return nil, httpx.Forbidden("share_link_inactive", "share link is expired or revoked")
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package sharelinks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, l *models.ShareLink) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO share_links (id, album_id, token, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING created_at`,
|
||||
l.ID, l.AlbumID, l.Token, l.CreatedBy, l.ExpiresAt,
|
||||
).Scan(&l.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
|
||||
FROM share_links WHERE album_id = $1 ORDER BY created_at DESC`, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.ShareLink{}
|
||||
for rows.Next() {
|
||||
var l models.ShareLink
|
||||
if err := rows.Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &l.CreatedAt, &l.ExpiresAt, &l.RevokedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetByToken(ctx context.Context, token string) (*models.ShareLink, error) {
|
||||
var l models.ShareLink
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
|
||||
FROM share_links WHERE token = $1`, token).
|
||||
Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &l.CreatedAt, &l.ExpiresAt, &l.RevokedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *Store) Revoke(ctx context.Context, id, albumID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE share_links SET revoked_at = now() WHERE id = $1 AND album_id = $2 AND revoked_at IS NULL`,
|
||||
id, albumID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func (h *Handler) AddLike(c *gin.Context) {
|
||||
if err := h.svc.AddLike(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveLike(c *gin.Context) {
|
||||
if err := h.svc.RemoveLike(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListComments(c *gin.Context) {
|
||||
list, err := h.svc.ListComments(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comments": list})
|
||||
}
|
||||
|
||||
type commentRequest struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) AddComment(c *gin.Context) {
|
||||
var req commentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "text is required"))
|
||||
return
|
||||
}
|
||||
comment, err := h.svc.AddComment(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Text)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteComment(c *gin.Context) {
|
||||
if err := h.svc.DeleteComment(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/roles"
|
||||
"shared-album/internal/sse"
|
||||
)
|
||||
|
||||
type AlbumAccess interface {
|
||||
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
||||
}
|
||||
|
||||
type PhotoLookup interface {
|
||||
GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error)
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Create(ctx context.Context, userID, typ string, payload map[string]any) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
albums AlbumAccess
|
||||
photos PhotoLookup
|
||||
hub *sse.Hub
|
||||
notify Notifier
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
|
||||
return &Service{store: store, albums: albums, photos: photos, hub: hub, notify: notify}
|
||||
}
|
||||
|
||||
func (s *Service) requireComment(ctx context.Context, photo *models.Photo, userID string) (string, error) {
|
||||
_, role, err := s.albums.Access(ctx, photo.AlbumID, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !roles.CanComment(role) {
|
||||
return "", httpx.Forbidden("forbidden", "you do not have permission to interact with photos")
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddLike(ctx context.Context, photoID, userID string) error {
|
||||
photo, err := s.photos.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := s.requireComment(ctx, photo, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Like(ctx, photoID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.Publish(photo.AlbumID, "like.added", map[string]string{
|
||||
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID,
|
||||
})
|
||||
if s.notify != nil && photo.UploaderID != userID {
|
||||
_ = s.notify.Create(ctx, photo.UploaderID, "like.added", map[string]any{
|
||||
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveLike(ctx context.Context, photoID, userID string) error {
|
||||
photo, err := s.photos.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := s.requireComment(ctx, photo, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Unlike(ctx, photoID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.Publish(photo.AlbumID, "like.removed", map[string]string{
|
||||
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AddComment(ctx context.Context, photoID, userID, text string) (*models.Comment, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, httpx.BadRequest("invalid_text", "comment text is required")
|
||||
}
|
||||
photo, err := s.photos.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.requireComment(ctx, photo, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &models.Comment{
|
||||
ID: auth.NewID(),
|
||||
PhotoID: photoID,
|
||||
UserID: userID,
|
||||
Text: text,
|
||||
}
|
||||
if err := s.store.CreateComment(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := s.store.ListComments(ctx, photoID)
|
||||
if err == nil {
|
||||
for _, item := range list {
|
||||
if item.ID == c.ID {
|
||||
c.User = item.User
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.hub.Publish(photo.AlbumID, "comment.created", c)
|
||||
if s.notify != nil && photo.UploaderID != userID {
|
||||
_ = s.notify.Create(ctx, photo.UploaderID, "comment.created", map[string]any{
|
||||
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID, "comment_id": c.ID,
|
||||
})
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListComments(ctx context.Context, photoID, userID string) ([]models.Comment, error) {
|
||||
photo, err := s.photos.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, photo.AlbumID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListComments(ctx, photoID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteComment(ctx context.Context, commentID, userID string) error {
|
||||
c, err := s.store.GetComment(ctx, commentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("comment_not_found", "comment not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
photo, err := s.photos.GetByID(ctx, c.PhotoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, photo.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.UserID != userID && !roles.CanDeleteAnyPhoto(role) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this comment")
|
||||
}
|
||||
return s.store.DeleteComment(ctx, commentID)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Like(ctx context.Context, photoID, userID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO likes (photo_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (photo_id, user_id) DO NOTHING`, photoID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Unlike(ctx context.Context, photoID, userID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM likes WHERE photo_id = $1 AND user_id = $2`, photoID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateComment(ctx context.Context, c *models.Comment) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO comments (id, photo_id, user_id, text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING created_at, updated_at`,
|
||||
c.ID, c.PhotoID, c.UserID, c.Text,
|
||||
).Scan(&c.CreatedAt, &c.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) ListComments(ctx context.Context, photoID string) ([]models.Comment, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT c.id, c.photo_id, c.user_id, c.text, c.created_at, c.updated_at,
|
||||
u.id, u.name, COALESCE(u.avatar_url, '')
|
||||
FROM comments c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.photo_id = $1
|
||||
ORDER BY c.created_at`, photoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Comment{}
|
||||
for rows.Next() {
|
||||
var c models.Comment
|
||||
if err := rows.Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.User.ID, &c.User.Name, &c.User.AvatarURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetComment(ctx context.Context, id string) (*models.Comment, error) {
|
||||
var c models.Comment
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, photo_id, user_id, text, created_at, updated_at FROM comments WHERE id = $1`, id).
|
||||
Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteComment(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM comments WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type string
|
||||
Data any
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
ch chan Event
|
||||
closed bool
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
hub *Hub
|
||||
key string
|
||||
personal bool
|
||||
sub *subscriber
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *Subscription) Events() <-chan Event { return s.sub.ch }
|
||||
|
||||
func (s *Subscription) Close() {
|
||||
s.once.Do(func() {
|
||||
if s.personal {
|
||||
s.hub.unsubscribeUser(s.key, s.sub)
|
||||
} else {
|
||||
s.hub.unsubscribeAlbum(s.key, s.sub)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
albums map[string]map[*subscriber]struct{}
|
||||
users map[string]map[*subscriber]struct{}
|
||||
buffer int
|
||||
}
|
||||
|
||||
func NewHub(buffer int) *Hub {
|
||||
if buffer <= 0 {
|
||||
buffer = 64
|
||||
}
|
||||
return &Hub{
|
||||
albums: make(map[string]map[*subscriber]struct{}),
|
||||
users: make(map[string]map[*subscriber]struct{}),
|
||||
buffer: buffer,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) SubscribeAlbum(albumID string) *Subscription {
|
||||
sub := &subscriber{ch: make(chan Event, h.buffer)}
|
||||
h.mu.Lock()
|
||||
if h.albums[albumID] == nil {
|
||||
h.albums[albumID] = make(map[*subscriber]struct{})
|
||||
}
|
||||
h.albums[albumID][sub] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return &Subscription{hub: h, key: albumID, sub: sub}
|
||||
}
|
||||
|
||||
func (h *Hub) SubscribeUser(userID string) *Subscription {
|
||||
sub := &subscriber{ch: make(chan Event, h.buffer)}
|
||||
h.mu.Lock()
|
||||
if h.users[userID] == nil {
|
||||
h.users[userID] = make(map[*subscriber]struct{})
|
||||
}
|
||||
h.users[userID][sub] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return &Subscription{hub: h, key: userID, personal: true, sub: sub}
|
||||
}
|
||||
|
||||
func (h *Hub) Publish(albumID, eventType string, data any) {
|
||||
if albumID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
h.broadcast(h.albums[albumID], Event{Type: eventType, Data: data})
|
||||
}
|
||||
|
||||
func (h *Hub) PublishUser(userID, eventType string, data any) {
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
h.broadcast(h.users[userID], Event{Type: eventType, Data: data})
|
||||
}
|
||||
|
||||
func (h *Hub) broadcast(subs map[*subscriber]struct{}, ev Event) {
|
||||
for sub := range subs {
|
||||
if sub.closed {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case sub.ch <- ev:
|
||||
default:
|
||||
// slow client: drop it (backpressure)
|
||||
sub.closed = true
|
||||
close(sub.ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) unsubscribeAlbum(albumID string, sub *subscriber) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if subs, ok := h.albums[albumID]; ok {
|
||||
delete(subs, sub)
|
||||
if len(subs) == 0 {
|
||||
delete(h.albums, albumID)
|
||||
}
|
||||
}
|
||||
closeIfOpen(sub)
|
||||
}
|
||||
|
||||
func (h *Hub) unsubscribeUser(userID string, sub *subscriber) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if subs, ok := h.users[userID]; ok {
|
||||
delete(subs, sub)
|
||||
if len(subs) == 0 {
|
||||
delete(h.users, userID)
|
||||
}
|
||||
}
|
||||
closeIfOpen(sub)
|
||||
}
|
||||
|
||||
func closeIfOpen(sub *subscriber) {
|
||||
if !sub.closed {
|
||||
sub.closed = true
|
||||
close(sub.ch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Serve(c *gin.Context, sub *Subscription, heartbeat time.Duration) {
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(c.Writer, ": connected\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
if heartbeat <= 0 {
|
||||
heartbeat = 25 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(heartbeat)
|
||||
defer ticker.Stop()
|
||||
|
||||
ctx := c.Request.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-sub.Events():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(ev.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", ev.Type, payload)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
fmt.Fprint(c.Writer, ": heartbeat\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
|
||||
"shared-album/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
s3 *s3.Client
|
||||
presign *s3.PresignClient
|
||||
bucket string
|
||||
presignTTL time.Duration
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg config.S3) (*Client, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(cfg.Region),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
cfg.AccessKey, cfg.SecretKey, "",
|
||||
)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
})
|
||||
|
||||
return &Client{
|
||||
s3: client,
|
||||
presign: s3.NewPresignClient(client),
|
||||
bucket: cfg.Bucket,
|
||||
presignTTL: cfg.PresignTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Bucket() string { return c.bucket }
|
||||
|
||||
func (c *Client) EnsureBucket(ctx context.Context) error {
|
||||
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var nf *types.NotFound
|
||||
if !errors.As(err, &nf) && !isNoSuchBucket(err) {
|
||||
return fmt.Errorf("head bucket: %w", err)
|
||||
}
|
||||
if _, err := c.s3.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(c.bucket)}); err != nil {
|
||||
return fmt.Errorf("create bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
in := &s3.PutObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: body,
|
||||
ContentType: aws.String(contentType),
|
||||
}
|
||||
if size >= 0 {
|
||||
in.ContentLength = aws.Int64(size)
|
||||
}
|
||||
if _, err := c.s3.PutObject(ctx, in); err != nil {
|
||||
return fmt.Errorf("put object %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
objs := make([]types.ObjectIdentifier, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
objs = append(objs, types.ObjectIdentifier{Key: aws.String(k)})
|
||||
}
|
||||
if _, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete objects: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
var keys []string
|
||||
paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
})
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list objects: %w", err)
|
||||
}
|
||||
for _, o := range page.Contents {
|
||||
keys = append(keys, aws.ToString(o.Key))
|
||||
}
|
||||
}
|
||||
return c.Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) {
|
||||
out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(c.presignTTL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign %s: %w", key, err)
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
func isNoSuchBucket(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
code := apiErr.ErrorCode()
|
||||
return strings.EqualFold(code, "NoSuchBucket") || strings.EqualFold(code, "NotFound")
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user