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