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:
iu
2026-10-10 12:50:33 +03:00
commit 2b2b342492
105 changed files with 12494 additions and 0 deletions
+87
View File
@@ -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)
}
+135
View File
@@ -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
}
+100
View File
@@ -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
}
+286
View File
@@ -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)
})
}
+93
View File
@@ -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()})
}
+82
View File
@@ -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()
}
+61
View File
@@ -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)
}
+15
View File
@@ -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
}
+147
View File
@@ -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
}
+94
View File
@@ -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
}
+144
View File
@@ -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
}
+48
View File
@@ -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)
}
+41
View File
@@ -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
}
+71
View File
@@ -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"}})
}
+106
View File
@@ -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)
}
+205
View File
@@ -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
}
+121
View File
@@ -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
}
+126
View File
@@ -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)
}
+44
View File
@@ -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
}
+80
View File
@@ -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
}
+111
View File
@@ -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)
}
+69
View File
@@ -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
}
+284
View File
@@ -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
}
+127
View File
@@ -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)
}
+224
View File
@@ -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")
}
}
+98
View File
@@ -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
}
+58
View File
@@ -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)
}
+95
View File
@@ -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
}
+76
View File
@@ -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
}
+67
View File
@@ -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)
}
+171
View File
@@ -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)
}
+91
View File
@@ -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
}
+139
View File
@@ -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)
}
}
+53
View File
@@ -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()
}
}
}
+143
View File
@@ -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
}