feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "email, password and name are required"))
|
||||
return
|
||||
}
|
||||
u, pair, err := h.svc.Register(c.Request.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": u.Public(), "tokens": pair})
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "email and password are required"))
|
||||
return
|
||||
}
|
||||
u, pair, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Public(), "tokens": pair})
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "refresh_token is required"))
|
||||
return
|
||||
}
|
||||
pair, err := h.svc.Refresh(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tokens": pair})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.RefreshToken != "" {
|
||||
if err := h.svc.Logout(c.Request.Context(), req.RefreshToken); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
u, err := h.svc.store.GetUserByID(c.Request.Context(), UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Public()})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager {
|
||||
return &TokenManager{secret: []byte(secret), accessTTL: accessTTL, refreshTTL: refreshTTL}
|
||||
}
|
||||
|
||||
func (m *TokenManager) AccessTTL() time.Duration { return m.accessTTL }
|
||||
func (m *TokenManager) RefreshTTL() time.Duration { return m.refreshTTL }
|
||||
|
||||
func (m *TokenManager) GenerateAccess(userID, email string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secret)
|
||||
}
|
||||
|
||||
func (m *TokenManager) ParseAccess(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func NewRefreshToken() (raw string, hash string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw = base64.RawURLEncoding.EncodeToString(buf)
|
||||
return raw, HashRefreshToken(raw), nil
|
||||
}
|
||||
|
||||
func HashRefreshToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxUserID = "auth.user_id"
|
||||
ctxEmail = "auth.email"
|
||||
)
|
||||
|
||||
func (s *Service) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := extractToken(c)
|
||||
if token == "" {
|
||||
httpx.Abort(c, httpx.Unauthorized("unauthorized", "missing access token"))
|
||||
return
|
||||
}
|
||||
claims, err := s.tm.ParseAccess(token)
|
||||
if err != nil {
|
||||
httpx.Abort(c, httpx.Unauthorized("unauthorized", "invalid or expired access token"))
|
||||
return
|
||||
}
|
||||
c.Set(ctxUserID, claims.UserID)
|
||||
c.Set(ctxEmail, claims.Email)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
if t := c.Query("token"); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func UserID(c *gin.Context) string {
|
||||
if v, ok := c.Get(ctxUserID); ok {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Email(c *gin.Context) string {
|
||||
if v, ok := c.Get(ctxEmail); ok {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) ParseToken(token string) (*Claims, error) {
|
||||
return s.tm.ParseAccess(token)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
tm *TokenManager
|
||||
}
|
||||
|
||||
func NewService(store *Store, tm *TokenManager) *Service {
|
||||
return &Service{store: store, tm: tm}
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, *TokenPair, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
name = strings.TrimSpace(name)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return nil, nil, httpx.BadRequest("invalid_email", "valid email is required")
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, nil, httpx.BadRequest("invalid_name", "name is required")
|
||||
}
|
||||
|
||||
if _, err := s.store.GetUserByEmail(ctx, email); err == nil {
|
||||
return nil, nil, httpx.Conflict("email_taken", "email is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
u := &models.User{
|
||||
ID: NewID(),
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.store.CreateUser(ctx, u); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pair, err := s.issuePair(ctx, u)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, email, password string) (*models.User, *TokenPair, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
u, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !CheckPassword(u.PasswordHash, password) {
|
||||
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
|
||||
}
|
||||
pair, err := s.issuePair(ctx, u)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
hash := HashRefreshToken(refreshToken)
|
||||
t, err := s.store.GetRefreshToken(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if t.RevokedAt != nil || time.Now().After(t.ExpiresAt) {
|
||||
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
u, err := s.store.GetUserByID(ctx, t.UserID)
|
||||
if err != nil {
|
||||
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
if err := s.store.RevokeRefreshToken(ctx, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issuePair(ctx, u)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
|
||||
hash := HashRefreshToken(refreshToken)
|
||||
t, err := s.store.GetRefreshToken(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.RevokeRefreshToken(ctx, t.ID)
|
||||
}
|
||||
|
||||
func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) {
|
||||
access, err := s.tm.GenerateAccess(u.ID, u.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, hash, err := NewRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rt := &models.RefreshToken{
|
||||
ID: NewID(),
|
||||
UserID: u.ID,
|
||||
TokenHash: hash,
|
||||
ExpiresAt: time.Now().Add(s.tm.RefreshTTL()),
|
||||
}
|
||||
if err := s.store.CreateRefreshToken(ctx, rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: raw,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(s.tm.AccessTTL().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO users (id, email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
return s.scanUser(s.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
||||
FROM users WHERE lower(email) = lower($1)`, email))
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
|
||||
return s.scanUser(s.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
||||
FROM users WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
|
||||
var u models.User
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
t.ID, t.UserID, t.TokenHash, t.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) {
|
||||
var t models.RefreshToken
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
|
||||
FROM refresh_tokens WHERE token_hash = $1`, hash).
|
||||
Scan(&t.ID, &t.UserID, &t.TokenHash, &t.ExpiresAt, &t.RevokedAt, &t.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeRefreshToken(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAllUserTokens(ctx context.Context, userID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
Reference in New Issue
Block a user