feat(storage): serve media through backend and add local FS backend

- introduce storage.Backend with S3 and local file-system implementations
- deliver all blobs via signed GET/HEAD /api/objects/<key> (Range, Content-Type)
- stream S3 objects through the backend; remove presigned URL path
- share shared HMAC Signer between backends
- remove S3_PUBLIC_URL; rename S3_PRESIGN_TTL to OBJECT_URL_TTL
- make storage label in health dynamic; nginx proxy_buffering off for objects
This commit is contained in:
iu
2026-10-11 09:15:03 +03:00
parent 7ae9bcf305
commit ec8d907565
28 changed files with 1714 additions and 65 deletions
+29 -6
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"io/fs"
"net/http"
"strings"
@@ -27,7 +28,7 @@ import (
type Server struct {
cfg *config.Config
pool *pgxpool.Pool
storage *storage.Client
storage storage.Backend
hub *sse.Hub
albumsStore *albums.Store
@@ -47,7 +48,7 @@ type Server struct {
share *sharelinks.Service
}
func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hub) *Server {
func New(cfg *config.Config, pool *pgxpool.Pool, st storage.Backend, 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)
@@ -109,6 +110,8 @@ func (s *Server) Router() *gin.Engine {
api := r.Group("/api")
api.GET("/health", s.health)
api.GET("/objects/*key", s.serveObject)
api.HEAD("/objects/*key", s.serveObject)
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
@@ -183,17 +186,37 @@ func (s *Server) health(c *gin.Context) {
if err := s.pool.Ping(ctx); err != nil {
dbStatus = "error"
}
s3Status := "ok"
storageLabel := s.cfg.Storage.Backend
if storageLabel == "" {
storageLabel = "s3"
}
storeStatus := "ok"
if err := s.storage.Ping(ctx); err != nil {
s3Status = "error"
storeStatus = "error"
}
status := http.StatusOK
overall := "ok"
if dbStatus != "ok" || s3Status != "ok" {
if dbStatus != "ok" || storeStatus != "ok" {
status = http.StatusServiceUnavailable
overall = "degraded"
}
c.JSON(status, gin.H{"status": overall, "db": dbStatus, "s3": s3Status})
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus})
}
func (s *Server) serveObject(c *gin.Context) {
key := strings.TrimPrefix(c.Param("key"), "/")
switch err := s.storage.ServeObject(c.Writer, c.Request, key); {
case err == nil:
return
case errors.Is(err, storage.ErrSignedURLInvalid):
c.Status(http.StatusForbidden)
case errors.Is(err, storage.ErrObjectNotFound):
httpx.RespondError(c, httpx.NotFound("object_not_found", "object not found"))
case errors.Is(err, storage.ErrRangeNotSatisfiable):
c.Status(http.StatusRequestedRangeNotSatisfiable)
default:
c.Status(http.StatusInternalServerError)
}
}
func (s *Server) albumSSE(c *gin.Context) {