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
+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
}