Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d4dbcbb2 | |||
| aff270fa44 | |||
| 40c91cec55 | |||
| 591b3d2fe3 | |||
| f4545ff107 | |||
| 3b79f12ec0 | |||
| 4645107ea1 | |||
| fa2acca858 | |||
| f069fccd96 | |||
| 9ea939ccf6 | |||
| 945df7ef8a | |||
| a6680b1c05 | |||
| eb2eb00d96 |
@@ -11,6 +11,11 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=720h
|
||||
|
||||
# Initial administrator, created on first startup if it does not yet exist.
|
||||
# Changing the password later (via the API) is preserved across restarts.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-before-first-run
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -22,6 +27,9 @@ DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disabl
|
||||
FILES_PATH=/data/files
|
||||
THUMBS_CACHE_PATH=/data/thumbs
|
||||
|
||||
# Maximum accepted upload size in bytes (default 500 MiB).
|
||||
MAX_UPLOAD_BYTES=524288000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thumbnails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
@@ -77,7 +79,7 @@ func main() {
|
||||
cfg.JWTAccessTTL,
|
||||
cfg.JWTRefreshTTL,
|
||||
)
|
||||
aclSvc := service.NewACLService(aclRepo)
|
||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||
auditSvc := service.NewAuditService(auditRepo)
|
||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
||||
@@ -92,12 +94,18 @@ func main() {
|
||||
transactor,
|
||||
cfg.ImportPath,
|
||||
)
|
||||
userSvc := service.NewUserService(userRepo, auditSvc)
|
||||
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
|
||||
|
||||
// Bootstrap the initial administrator (idempotent).
|
||||
if err := userSvc.EnsureAdmin(context.Background(), cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||
slog.Error("failed to bootstrap admin user", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handlers
|
||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||
authHandler := handler.NewAuthHandler(authSvc)
|
||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
|
||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
||||
@@ -111,8 +119,17 @@ func main() {
|
||||
userHandler, aclHandler, auditHandler,
|
||||
)
|
||||
|
||||
// ReadHeaderTimeout bounds slow-header (Slowloris) attacks; body read/write
|
||||
// are left unbounded so large file uploads and downloads can stream.
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,17 @@ type Config struct {
|
||||
JWTAccessTTL time.Duration
|
||||
JWTRefreshTTL time.Duration
|
||||
|
||||
// Initial admin bootstrap (applied on startup if the user does not exist)
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
|
||||
// Database
|
||||
DatabaseURL string
|
||||
|
||||
// Storage
|
||||
FilesPath string
|
||||
ThumbsCachePath string
|
||||
MaxUploadBytes int64 // reject uploads larger than this (bytes)
|
||||
|
||||
// Thumbnails
|
||||
ThumbWidth int
|
||||
@@ -81,16 +86,33 @@ func Load() (*Config, error) {
|
||||
return n
|
||||
}
|
||||
|
||||
parseInt64 := func(key string, def int64) int64 {
|
||||
raw := os.Getenv(key)
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
|
||||
JWTSecret: requireStr("JWT_SECRET"),
|
||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
||||
|
||||
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||||
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
||||
|
||||
DatabaseURL: requireStr("DATABASE_URL"),
|
||||
|
||||
FilesPath: requireStr("FILES_PATH"),
|
||||
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
|
||||
MaxUploadBytes: parseInt64("MAX_UPLOAD_BYTES", 500<<20), // 500 MiB
|
||||
|
||||
ThumbWidth: parseInt("THUMB_WIDTH", 160),
|
||||
ThumbHeight: parseInt("THUMB_HEIGHT", 160),
|
||||
|
||||
@@ -74,6 +74,28 @@ func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Se
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) GetByID(ctx context.Context, id int) (*domain.Session, error) {
|
||||
const sql = `
|
||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
|
||||
FROM activity.sessions
|
||||
WHERE id = $1 AND is_active = true`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SessionRepo.GetByID: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("SessionRepo.GetByID scan: %w", err)
|
||||
}
|
||||
s := toSession(row)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
|
||||
const sql = `
|
||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
|
||||
|
||||
@@ -80,7 +80,8 @@ func (h *ACLHandler) GetPermissions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID)
|
||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
||||
perms, err := h.aclSvc.GetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
@@ -124,13 +125,14 @@ func (h *ACLHandler) SetPermissions(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.aclSvc.SetPermissions(c.Request.Context(), objectTypeID, objectID, perms); err != nil {
|
||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
||||
if err := h.aclSvc.SetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID, perms); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-read to return the stored permissions (with UserName denormalized).
|
||||
stored, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID)
|
||||
stored, err := h.aclSvc.GetPermissions(c.Request.Context(), userID, isAdmin, objectTypeID, objectID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -21,11 +22,33 @@ import (
|
||||
type FileHandler struct {
|
||||
fileSvc *service.FileService
|
||||
tagSvc *service.TagService
|
||||
maxUploadBytes int64
|
||||
}
|
||||
|
||||
// NewFileHandler creates a FileHandler.
|
||||
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService) *FileHandler {
|
||||
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc}
|
||||
// NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
|
||||
// uploaded or replacement file.
|
||||
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, maxUploadBytes int64) *FileHandler {
|
||||
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, maxUploadBytes: maxUploadBytes}
|
||||
}
|
||||
|
||||
// formFileLimited reads the "file" multipart field while bounding how many bytes
|
||||
// are read from the request body, then rejects files larger than the configured
|
||||
// cap. The body limit guards against a dishonest Content-Length; the Size check
|
||||
// gives a clear rejection for an honestly-declared oversized file.
|
||||
func (h *FileHandler) formFileLimited(c *gin.Context) (*multipart.FileHeader, bool) {
|
||||
// Allow a little slack above the file cap for multipart framing overhead.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20))
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return nil, false
|
||||
}
|
||||
if fh.Size > h.maxUploadBytes {
|
||||
respondError(c, domain.ErrValidation)
|
||||
return nil, false
|
||||
}
|
||||
return fh, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -186,9 +209,8 @@ func (h *FileHandler) List(c *gin.Context) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *FileHandler) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
fh, ok := h.formFileLimited(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -378,9 +400,8 @@ func (h *FileHandler) ReplaceContent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
respondError(c, domain.ErrValidation)
|
||||
fh, ok := h.formFileLimited(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -613,6 +634,12 @@ func (h *FileHandler) CommonTags(c *gin.Context) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *FileHandler) Import(c *gin.Context) {
|
||||
// Server-side directory import reads arbitrary paths on the host; restrict
|
||||
// it to administrators.
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
||||
}
|
||||
token := strings.TrimPrefix(raw, "Bearer ")
|
||||
|
||||
claims, err := m.authSvc.ParseAccessToken(token)
|
||||
claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, errorBody{
|
||||
Code: domain.ErrUnauthorized.Code(),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// rateLimiter is a process-local, fixed-window per-key request limiter used to
|
||||
// throttle unauthenticated endpoints (login, refresh) against brute force. It
|
||||
// is best-effort: counts live in memory and reset on restart.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]*rateWindow
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
type rateWindow struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
// newRateLimiter allows up to limit requests per key within each window.
|
||||
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
counts: make(map[string]*rateWindow),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
// allow records a request for key and reports whether it is within the limit.
|
||||
func (rl *rateLimiter) allow(key string) bool {
|
||||
now := time.Now()
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
// Opportunistically prune expired entries so the map cannot grow without
|
||||
// bound under a flood of distinct client IPs.
|
||||
if len(rl.counts) > 10000 {
|
||||
for k, w := range rl.counts {
|
||||
if now.After(w.reset) {
|
||||
delete(rl.counts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w, ok := rl.counts[key]
|
||||
if !ok || now.After(w.reset) {
|
||||
rl.counts[key] = &rateWindow{count: 1, reset: now.Add(rl.window)}
|
||||
return true
|
||||
}
|
||||
if w.count >= rl.limit {
|
||||
return false
|
||||
}
|
||||
w.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// Middleware throttles requests by client IP, returning 429 when over the limit.
|
||||
func (rl *rateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !rl.allow(c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, errorBody{
|
||||
Code: "rate_limited",
|
||||
Message: "too many requests, please try again later",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,24 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// securityHeaders sets conservative response headers on every response: prevent
|
||||
// MIME sniffing of served file content, forbid framing, and suppress the
|
||||
// Referer header on outbound navigations.
|
||||
func securityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// NewRouter builds and returns a configured Gin engine.
|
||||
func NewRouter(
|
||||
auth *AuthMiddleware,
|
||||
@@ -19,7 +33,7 @@ func NewRouter(
|
||||
auditHandler *AuditHandler,
|
||||
) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(gin.Logger(), gin.Recovery(), securityHeaders())
|
||||
|
||||
// Health check — no auth required.
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -33,8 +47,10 @@ func NewRouter(
|
||||
// -------------------------------------------------------------------------
|
||||
authGroup := v1.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/refresh", authHandler.Refresh)
|
||||
// Throttle credential endpoints per client IP to slow brute force.
|
||||
authLimiter := newRateLimiter(10, time.Minute).Middleware()
|
||||
authGroup.POST("/login", authLimiter, authHandler.Login)
|
||||
authGroup.POST("/refresh", authLimiter, authHandler.Refresh)
|
||||
|
||||
protected := authGroup.Group("", auth.Handle())
|
||||
{
|
||||
|
||||
@@ -430,6 +430,11 @@ func (h *TagHandler) FileListTags(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.fileSvc.AuthorizeView(c.Request.Context(), fileID); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := h.tagSvc.ListFileTags(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
@@ -465,6 +470,11 @@ func (h *TagHandler) FileSetTags(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := h.tagSvc.SetFileTags(c.Request.Context(), fileID, tagIDs)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
@@ -491,6 +501,11 @@ func (h *TagHandler) FileAddTag(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := h.tagSvc.AddFileTag(c.Request.Context(), fileID, tagID)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
@@ -517,6 +532,11 @@ func (h *TagHandler) FileRemoveTag(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.fileSvc.AuthorizeEdit(c.Request.Context(), fileID); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tagSvc.RemoveFileTag(c.Request.Context(), fileID, tagID); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
|
||||
@@ -125,18 +125,22 @@ func setupSuite(t *testing.T) *harness {
|
||||
|
||||
// --- Services ------------------------------------------------------------
|
||||
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
|
||||
aclSvc := service.NewACLService(aclRepo)
|
||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||
auditSvc := service.NewAuditService(auditRepo)
|
||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
||||
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
|
||||
userSvc := service.NewUserService(userRepo, auditSvc)
|
||||
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
|
||||
|
||||
// Bootstrap the admin account the suite logs in with (replaces the old
|
||||
// hardcoded seed credentials).
|
||||
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
|
||||
|
||||
// --- Handlers ------------------------------------------------------------
|
||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||
authHandler := handler.NewAuthHandler(authSvc)
|
||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
|
||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
||||
@@ -685,6 +689,145 @@ func TestTagAutoRule(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security regression tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// loginPair logs in and returns the full access/refresh token pair.
|
||||
func (h *harness) loginPair(name, password string) (access, refresh string) {
|
||||
h.t.Helper()
|
||||
resp := h.doJSON("POST", "/auth/login", map[string]string{"name": name, "password": password}, "")
|
||||
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: %s", resp)
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
resp.decode(h.t, &out)
|
||||
require.NotEmpty(h.t, out.AccessToken)
|
||||
require.NotEmpty(h.t, out.RefreshToken)
|
||||
return out.AccessToken, out.RefreshToken
|
||||
}
|
||||
|
||||
// TestRefreshTokenFlow verifies that refresh tokens work (regression for the
|
||||
// stored-hash mismatch that made /refresh always 401), that a refresh token is
|
||||
// rejected as a bearer access token, and that rotating a session revokes the
|
||||
// pre-rotation access token.
|
||||
func TestRefreshTokenFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
h := setupSuite(t)
|
||||
|
||||
access, refresh := h.loginPair("admin", "admin")
|
||||
|
||||
// A refresh token must not be accepted as a bearer access token.
|
||||
resp := h.doJSON("GET", "/users/me", nil, refresh)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
|
||||
|
||||
// Refreshing yields a working new pair.
|
||||
resp = h.doJSON("POST", "/auth/refresh", map[string]string{"refresh_token": refresh}, "")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var pair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
resp.decode(t, &pair)
|
||||
require.NotEmpty(t, pair.AccessToken)
|
||||
|
||||
resp = h.doJSON("GET", "/users/me", nil, pair.AccessToken)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
|
||||
// The pre-rotation access token is now revoked (its session was rotated away).
|
||||
resp = h.doJSON("GET", "/users/me", nil, access)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// TestNonOwnerAccessControl verifies that a non-owner, non-admin user cannot
|
||||
// read or change another user's object ACL, cannot view or tag another user's
|
||||
// private file, and cannot trigger a server-side import.
|
||||
func TestNonOwnerAccessControl(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
h := setupSuite(t)
|
||||
adminToken := h.login("admin", "admin")
|
||||
|
||||
mkUser := func(name, pass string) {
|
||||
resp := h.doJSON("POST", "/users", map[string]any{
|
||||
"name": name, "password": pass, "can_create": true,
|
||||
}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
}
|
||||
mkUser("alice", "alicepass")
|
||||
mkUser("bob", "bobpass")
|
||||
|
||||
aliceToken := h.login("alice", "alicepass")
|
||||
bobToken := h.login("bob", "bobpass")
|
||||
|
||||
// Alice uploads a private file.
|
||||
file := h.uploadJPEG(aliceToken, "secret.jpg")
|
||||
fileID := file["id"].(string)
|
||||
|
||||
// Bob cannot read the file's ACL...
|
||||
resp := h.doJSON("GET", "/acl/file/"+fileID, nil, bobToken)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||
|
||||
// ...nor grant himself access.
|
||||
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
|
||||
"permissions": []map[string]any{{"user_id": 2, "can_view": true, "can_edit": true}},
|
||||
}, bobToken)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||
|
||||
// ...and still cannot view it.
|
||||
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||
|
||||
// Bob cannot list or modify tags on Alice's private file.
|
||||
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, bobToken)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||
|
||||
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
||||
"tag_ids": []string{"11111111-1111-1111-1111-111111111111"},
|
||||
}, bobToken)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||
|
||||
// A non-admin cannot trigger a server-side import.
|
||||
resp = h.doJSON("POST", "/files/import", map[string]any{}, bobToken)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||
|
||||
// The owner can still manage her own file's ACL.
|
||||
resp = h.doJSON("GET", "/acl/file/"+fileID, nil, aliceToken)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// TestBlockRevokesActiveSessions verifies that blocking a user immediately
|
||||
// invalidates their outstanding access tokens.
|
||||
func TestBlockRevokesActiveSessions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
h := setupSuite(t)
|
||||
adminToken := h.login("admin", "admin")
|
||||
|
||||
resp := h.doJSON("POST", "/users", map[string]any{"name": "dave", "password": "davepass"}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var dave map[string]any
|
||||
resp.decode(t, &dave)
|
||||
daveID := dave["id"].(float64)
|
||||
|
||||
daveToken := h.login("dave", "davepass")
|
||||
resp = h.doJSON("GET", "/users/me", nil, daveToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
|
||||
// Block dave.
|
||||
resp = h.doJSON("PATCH", fmt.Sprintf("/users/%.0f", daveID), map[string]any{"is_blocked": true}, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
|
||||
// Dave's previously-issued access token is now rejected.
|
||||
resp = h.doJSON("GET", "/users/me", nil, daveToken)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -132,6 +132,9 @@ type UserRepo interface {
|
||||
type SessionRepo interface {
|
||||
// ListByUser returns all active sessions for a user.
|
||||
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
|
||||
// GetByID returns an active session by its ID, or ErrNotFound if it does not
|
||||
// exist or has been deactivated.
|
||||
GetByID(ctx context.Context, id int) (*domain.Session, error)
|
||||
// GetByTokenHash looks up a session by the hashed refresh token.
|
||||
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
|
||||
Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
|
||||
|
||||
@@ -21,6 +21,10 @@ type FileStorage interface {
|
||||
// Delete removes the file content from storage.
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
// InvalidateCache removes any cached thumbnail/preview for the file so they
|
||||
// are regenerated from the current content on next request.
|
||||
InvalidateCache(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
||||
// if the thumbnail has not been generated yet.
|
||||
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
||||
|
||||
@@ -13,10 +13,31 @@ import (
|
||||
// ACLService handles access control checks and permission management.
|
||||
type ACLService struct {
|
||||
aclRepo port.ACLRepo
|
||||
files port.FileRepo
|
||||
tags port.TagRepo
|
||||
categories port.CategoryRepo
|
||||
pools port.PoolRepo
|
||||
tx port.Transactor
|
||||
}
|
||||
|
||||
func NewACLService(aclRepo port.ACLRepo) *ACLService {
|
||||
return &ACLService{aclRepo: aclRepo}
|
||||
// NewACLService creates an ACLService. The object repositories are used to
|
||||
// resolve an object's owner when authorizing permission management.
|
||||
func NewACLService(
|
||||
aclRepo port.ACLRepo,
|
||||
files port.FileRepo,
|
||||
tags port.TagRepo,
|
||||
categories port.CategoryRepo,
|
||||
pools port.PoolRepo,
|
||||
tx port.Transactor,
|
||||
) *ACLService {
|
||||
return &ACLService{
|
||||
aclRepo: aclRepo,
|
||||
files: files,
|
||||
tags: tags,
|
||||
categories: categories,
|
||||
pools: pools,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// CanView returns true if the user may view the object.
|
||||
@@ -70,12 +91,86 @@ func (s *ACLService) CanEdit(
|
||||
return perm.CanEdit, nil
|
||||
}
|
||||
|
||||
// GetPermissions returns all explicit ACL entries for an object.
|
||||
func (s *ACLService) GetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
||||
// GetPermissions returns all explicit ACL entries for an object. Only the
|
||||
// object's owner or an admin may inspect its permission list.
|
||||
func (s *ACLService) GetPermissions(
|
||||
ctx context.Context,
|
||||
userID int16, isAdmin bool,
|
||||
objectTypeID int16, objectID uuid.UUID,
|
||||
) ([]domain.Permission, error) {
|
||||
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.aclRepo.List(ctx, objectTypeID, objectID)
|
||||
}
|
||||
|
||||
// SetPermissions replaces all ACL entries for an object (full replace semantics).
|
||||
func (s *ACLService) SetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
||||
// Only the object's owner or an admin may change its permissions. The replace is
|
||||
// performed atomically inside a single transaction.
|
||||
func (s *ACLService) SetPermissions(
|
||||
ctx context.Context,
|
||||
userID int16, isAdmin bool,
|
||||
objectTypeID int16, objectID uuid.UUID,
|
||||
perms []domain.Permission,
|
||||
) error {
|
||||
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
|
||||
})
|
||||
}
|
||||
|
||||
// authorizeManage returns nil if the user may manage the object's ACL
|
||||
// (admin or owner), ErrForbidden otherwise, or a propagated lookup error
|
||||
// (including ErrNotFound when the object does not exist).
|
||||
func (s *ACLService) authorizeManage(
|
||||
ctx context.Context,
|
||||
userID int16, isAdmin bool,
|
||||
objectTypeID int16, objectID uuid.UUID,
|
||||
) error {
|
||||
if isAdmin {
|
||||
return nil
|
||||
}
|
||||
owner, err := s.objectOwner(ctx, objectTypeID, objectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != userID {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// objectOwner resolves the creator ID of the object identified by
|
||||
// (objectTypeID, objectID). Returns ErrNotFound if the object does not exist.
|
||||
func (s *ACLService) objectOwner(ctx context.Context, objectTypeID int16, objectID uuid.UUID) (int16, error) {
|
||||
switch objectTypeID {
|
||||
case fileObjectTypeID:
|
||||
obj, err := s.files.GetByID(ctx, objectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return obj.CreatorID, nil
|
||||
case tagObjectTypeID:
|
||||
obj, err := s.tags.GetByID(ctx, objectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return obj.CreatorID, nil
|
||||
case categoryObjectTypeID:
|
||||
obj, err := s.categories.GetByID(ctx, objectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return obj.CreatorID, nil
|
||||
case poolObjectTypeID:
|
||||
obj, err := s.pools.GetByID(ctx, objectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return obj.CreatorID, nil
|
||||
default:
|
||||
return 0, domain.ErrValidation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
@@ -14,12 +15,25 @@ import (
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
// Token types distinguish short-lived access tokens from long-lived refresh
|
||||
// tokens so the two cannot be substituted for one another.
|
||||
const (
|
||||
tokenTypeAccess = "access"
|
||||
tokenTypeRefresh = "refresh"
|
||||
)
|
||||
|
||||
// dummyPasswordHash is a valid bcrypt hash used to equalise the cost of a login
|
||||
// attempt against a non-existent user, preventing username enumeration via
|
||||
// response timing. It is the hash of a random string no one knows.
|
||||
const dummyPasswordHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
||||
|
||||
// Claims is the JWT payload for both access and refresh tokens.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID int16 `json:"uid"`
|
||||
IsAdmin bool `json:"adm"`
|
||||
SessionID int `json:"sid"`
|
||||
TokenType string `json:"typ"`
|
||||
}
|
||||
|
||||
// TokenPair holds an issued access/refresh token pair with the access TTL.
|
||||
@@ -59,8 +73,16 @@ func NewAuthService(
|
||||
func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) {
|
||||
user, err := s.users.GetByName(ctx, name)
|
||||
if err != nil {
|
||||
// Return ErrUnauthorized regardless of whether the user exists,
|
||||
// to avoid username enumeration.
|
||||
// Compare against a dummy hash so a missing user costs the same as a
|
||||
// wrong password, and return ErrUnauthorized either way to avoid
|
||||
// username enumeration.
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(password))
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
// Verify the password before disclosing anything about account state, so a
|
||||
// caller cannot distinguish "blocked" from "wrong password".
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
@@ -68,48 +90,7 @@ func (s *AuthService) Login(ctx context.Context, name, password, userAgent strin
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if s.refreshTTL > 0 {
|
||||
t := time.Now().Add(s.refreshTTL)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
// Issue the refresh token first so we can store its hash.
|
||||
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
session, err := s.sessions.Create(ctx, &domain.Session{
|
||||
TokenHash: hashToken(refreshToken),
|
||||
UserID: user.ID,
|
||||
UserAgent: userAgent,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
|
||||
// Re-issue the refresh token with the real session ID now that we have it.
|
||||
refreshToken, err = s.issueToken(user.ID, user.IsAdmin, session.ID, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
||||
}, nil
|
||||
return s.issuePair(ctx, user, userAgent)
|
||||
}
|
||||
|
||||
// Logout deactivates the session identified by sessionID.
|
||||
@@ -124,7 +105,7 @@ func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
|
||||
// the old session.
|
||||
func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) {
|
||||
claims, err := s.parseToken(refreshToken)
|
||||
if err != nil {
|
||||
if err != nil || claims.TokenType != tokenTypeRefresh {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
@@ -152,19 +133,30 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent strin
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
return s.issuePair(ctx, user, userAgent)
|
||||
}
|
||||
|
||||
// issuePair creates a session and the access/refresh token pair for user.
|
||||
//
|
||||
// The refresh token is issued first and its hash is stored as the session's
|
||||
// identity; the refresh token is located on /refresh purely by that hash, so it
|
||||
// carries no session ID. The access token then embeds the real session ID so it
|
||||
// can be revoked on logout. Because the stored hash is the hash of the token
|
||||
// actually returned, /refresh works (unlike the previous re-issue approach).
|
||||
func (s *AuthService) issuePair(ctx context.Context, user *domain.User, userAgent string) (*TokenPair, error) {
|
||||
var expiresAt *time.Time
|
||||
if s.refreshTTL > 0 {
|
||||
t := time.Now().Add(s.refreshTTL)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
newRefresh, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
||||
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL, tokenTypeRefresh)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
newSession, err := s.sessions.Create(ctx, &domain.Session{
|
||||
TokenHash: hashToken(newRefresh),
|
||||
session, err := s.sessions.Create(ctx, &domain.Session{
|
||||
TokenHash: hashToken(refreshToken),
|
||||
UserID: user.ID,
|
||||
UserAgent: userAgent,
|
||||
ExpiresAt: expiresAt,
|
||||
@@ -173,19 +165,14 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent strin
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.accessTTL)
|
||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL, tokenTypeAccess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
|
||||
newRefresh, err = s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefresh,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
@@ -227,26 +214,43 @@ func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAd
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseAccessToken parses and validates an access token, returning its claims.
|
||||
func (s *AuthService) ParseAccessToken(tokenStr string) (*Claims, error) {
|
||||
// ValidateAccessToken parses and validates an access token, returning its
|
||||
// claims. A refresh token is rejected (wrong type), and the token's session
|
||||
// must still be active — so logout, session termination, an admin block, or a
|
||||
// refresh rotation revoke any outstanding access tokens immediately rather than
|
||||
// only at expiry.
|
||||
func (s *AuthService) ValidateAccessToken(ctx context.Context, tokenStr string) (*Claims, error) {
|
||||
claims, err := s.parseToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
if claims.TokenType != tokenTypeAccess {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
if _, err := s.sessions.GetByID(ctx, claims.SessionID); err != nil {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// issueToken signs a JWT with the given parameters.
|
||||
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration) (string, error) {
|
||||
// issueToken signs a JWT with the given parameters. A random JWT ID guarantees
|
||||
// uniqueness even for tokens minted within the same second.
|
||||
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration, tokenType string) (string, error) {
|
||||
jti, err := randomJTI()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
UserID: userID,
|
||||
IsAdmin: isAdmin,
|
||||
SessionID: sessionID,
|
||||
TokenType: tokenType,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(s.secret)
|
||||
@@ -280,3 +284,12 @@ func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// randomJTI returns a 128-bit random hex string for use as a JWT ID.
|
||||
func randomJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate jti: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
@@ -344,6 +345,7 @@ func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
||||
return err
|
||||
}
|
||||
_ = s.storage.Delete(ctx, id)
|
||||
_ = s.storage.InvalidateCache(ctx, id)
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
||||
@@ -382,6 +384,8 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
||||
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
||||
}
|
||||
// Drop stale thumbnail/preview so they regenerate from the new content.
|
||||
_ = s.storage.InvalidateCache(ctx, id)
|
||||
|
||||
patch := &domain.File{
|
||||
MIMEType: mime.Name,
|
||||
@@ -407,6 +411,32 @@ func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*
|
||||
return s.files.List(ctx, params)
|
||||
}
|
||||
|
||||
// AuthorizeView ensures the caller may view the file. Returns ErrNotFound if the
|
||||
// file does not exist or ErrForbidden if the caller lacks view access.
|
||||
func (s *FileService) AuthorizeView(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.Get(ctx, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AuthorizeEdit ensures the caller may edit the file. Returns ErrNotFound if the
|
||||
// file does not exist or ErrForbidden if the caller lacks edit access.
|
||||
func (s *FileService) AuthorizeEdit(ctx context.Context, id uuid.UUID) error {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content / thumbnail / preview streaming
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -468,14 +498,21 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
|
||||
// Import scans a server-side directory and uploads all supported files.
|
||||
// If path is empty, the configured default import path is used.
|
||||
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
||||
dir := path
|
||||
if dir == "" {
|
||||
dir = s.importPath
|
||||
}
|
||||
if dir == "" {
|
||||
if s.importPath == "" {
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
|
||||
dir := s.importPath
|
||||
if path != "" {
|
||||
// Confine caller-supplied paths to the configured import directory so a
|
||||
// directory-traversal value cannot read arbitrary host files.
|
||||
confined, err := confineToBase(s.importPath, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir = confined
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
||||
@@ -550,6 +587,28 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// confineToBase resolves target and verifies it does not escape base (after
|
||||
// cleaning and resolving "..") so a caller cannot read files outside the
|
||||
// configured import directory. Returns the cleaned absolute path on success.
|
||||
func confineToBase(base, target string) (string, error) {
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return "", domain.ErrValidation
|
||||
}
|
||||
absTarget, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return "", domain.ErrValidation
|
||||
}
|
||||
rel, err := filepath.Rel(absBase, absTarget)
|
||||
if err != nil {
|
||||
return "", domain.ErrValidation
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", domain.ErrForbidden
|
||||
}
|
||||
return absTarget, nil
|
||||
}
|
||||
|
||||
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
|
||||
// representation and the DateTimeOriginal (if present). Both may be nil.
|
||||
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -13,12 +14,43 @@ import (
|
||||
// UserService handles user CRUD and profile management.
|
||||
type UserService struct {
|
||||
users port.UserRepo
|
||||
sessions port.SessionRepo
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
// NewUserService creates a UserService.
|
||||
func NewUserService(users port.UserRepo, audit *AuditService) *UserService {
|
||||
return &UserService{users: users, audit: audit}
|
||||
func NewUserService(users port.UserRepo, sessions port.SessionRepo, audit *AuditService) *UserService {
|
||||
return &UserService{users: users, sessions: sessions, audit: audit}
|
||||
}
|
||||
|
||||
// EnsureAdmin creates the initial administrator account if it does not already
|
||||
// exist. It is idempotent and never overwrites an existing user's password, so
|
||||
// an operator who has changed the admin password keeps it across restarts.
|
||||
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
|
||||
if username == "" || password == "" {
|
||||
return fmt.Errorf("EnsureAdmin: username and password must be set")
|
||||
}
|
||||
|
||||
if _, err := s.users.GetByName(ctx, username); err == nil {
|
||||
return nil // already exists
|
||||
} else if !errors.Is(err, domain.ErrNotFound) {
|
||||
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnsureAdmin: hash: %w", err)
|
||||
}
|
||||
_, err = s.users.Create(ctx, &domain.User{
|
||||
Name: username,
|
||||
Password: string(hash),
|
||||
IsAdmin: true,
|
||||
CanCreate: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnsureAdmin: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,11 +167,15 @@ func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminPa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log block/unblock specifically.
|
||||
// Log block/unblock specifically, and revoke all sessions on block so the
|
||||
// user's outstanding access tokens stop working immediately.
|
||||
if p.IsBlocked != nil {
|
||||
action := "user_unblock"
|
||||
if *p.IsBlocked {
|
||||
action = "user_block"
|
||||
if err := s.sessions.DeleteByUserID(ctx, id); err != nil {
|
||||
return nil, fmt.Errorf("UserService.UpdateAdmin revoke sessions: %w", err)
|
||||
}
|
||||
}
|
||||
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/google/uuid"
|
||||
@@ -108,6 +109,17 @@ func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateCache removes the cached thumbnail and preview for id, if present,
|
||||
// so they are regenerated from the current file content on the next request.
|
||||
func (s *DiskStorage) InvalidateCache(_ context.Context, id uuid.UUID) error {
|
||||
for _, p := range []string{s.thumbCachePath(id), s.previewCachePath(id)} {
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("storage.InvalidateCache remove %q: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Thumbnail returns a JPEG that fits within the configured max width×height
|
||||
// (never upscaled, never cropped). Generated on first call and cached.
|
||||
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
||||
@@ -149,11 +161,11 @@ func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePat
|
||||
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
|
||||
}
|
||||
|
||||
// 1. Try still-image decode (JPEG/PNG/GIF).
|
||||
// 1. Try still-image decode (JPEG/PNG/GIF), rejecting decompression bombs.
|
||||
// 2. Try video frame extraction via ffmpeg.
|
||||
// 3. Fall back to placeholder.
|
||||
var img image.Image
|
||||
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil {
|
||||
if decoded, err := decodeImageLimited(srcPath); err == nil {
|
||||
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
|
||||
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
|
||||
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
|
||||
@@ -206,12 +218,44 @@ func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// maxDecodePixels caps the pixel count of an image we are willing to decode
|
||||
// into memory, bounding the cost of a decompression bomb (a tiny file that
|
||||
// expands to an enormous raster). 64 Mpx is ~ an 8192×8192 image.
|
||||
const maxDecodePixels = 64 << 20
|
||||
|
||||
// decodeImageLimited decodes the image at path after first inspecting its header
|
||||
// dimensions via image.DecodeConfig (which does not allocate the raster), and
|
||||
// refuses images whose pixel count exceeds maxDecodePixels.
|
||||
func decodeImageLimited(path string) (image.Image, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||
return nil, fmt.Errorf("image too large to decode: %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return imaging.Decode(f, imaging.AutoOrientation(true))
|
||||
}
|
||||
|
||||
// extractVideoFrame uses ffmpeg to extract a single frame from a video file.
|
||||
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
|
||||
// as PNG. If the video is shorter than 1 s the seek is silently ignored by
|
||||
// ffmpeg and the first available frame is returned instead.
|
||||
// Returns an error if ffmpeg is not installed or produces no output.
|
||||
// Returns an error if ffmpeg is not installed or produces no output. The run is
|
||||
// bounded by a timeout so a malformed file cannot hang the request indefinitely.
|
||||
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-ss", "1", // fast input seek; ignored gracefully on short files
|
||||
|
||||
@@ -38,12 +38,12 @@ INSERT INTO activity.action_types (name) VALUES
|
||||
-- Sessions
|
||||
('session_terminate');
|
||||
|
||||
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
||||
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
||||
-- The initial administrator is created at application startup from the
|
||||
-- ADMIN_USERNAME / ADMIN_PASSWORD environment variables (see UserService.
|
||||
-- EnsureAdmin), so no default credentials are seeded here.
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DELETE FROM core.users WHERE name = 'admin';
|
||||
DELETE FROM activity.action_types;
|
||||
DELETE FROM core.object_types;
|
||||
DELETE FROM core.mime_types;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
|
||||
then_tag_id: thenTagId,
|
||||
is_active: true,
|
||||
apply_to_existing: false,
|
||||
apply_to_existing: $appSettings.tagRuleApplyToExisting,
|
||||
});
|
||||
onRulesChange([...rules, rule]);
|
||||
search = '';
|
||||
|
||||
@@ -262,8 +262,8 @@
|
||||
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<span class="toggle-label">Apply activated tag rules to existing files</span>
|
||||
<p class="hint-text">When a tag rule is activated, automatically add the implied tag to all files that already have the source tag.</p>
|
||||
<span class="toggle-label">Apply new tag rules to existing files</span>
|
||||
<p class="hint-text">When a tag rule is created or activated, automatically add the implied tag to all files that already have the source tag.</p>
|
||||
</div>
|
||||
<button
|
||||
class="toggle"
|
||||
|
||||
Reference in New Issue
Block a user