feat(backend): file-scoped content tokens for media URLs
Opening an original by URL (?access_token=) baked in the 15-minute access token, so a long video opened in a new tab stopped streaming once that token expired mid-playback: the access token can't be refreshed in an already-opened tab, and its next Range request 401'd. Add a content token: a signed, single-file capability (typ=content, fid claim) with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no session id, so it survives refresh rotation and outlives the short access TTL. POST /files/:id/content-token mints one after the same view-ACL check content serving does; GET /files/:id/content now runs under content-aware auth that accepts either a normal access token or a content token scoped to that file. View permission is still enforced against the token's user, so the token only changes when a file may be read by URL, never which files. It's a bearer capability for that one file until expiry, hence the bounded, configurable TTL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,13 +22,14 @@ import (
|
||||
type FileHandler struct {
|
||||
fileSvc *service.FileService
|
||||
tagSvc *service.TagService
|
||||
authSvc *service.AuthService
|
||||
maxUploadBytes int64
|
||||
}
|
||||
|
||||
// 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}
|
||||
// uploaded or replacement file. authSvc mints content tokens for media URLs.
|
||||
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, authSvc *service.AuthService, maxUploadBytes int64) *FileHandler {
|
||||
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, authSvc: authSvc, maxUploadBytes: maxUploadBytes}
|
||||
}
|
||||
|
||||
// formFileLimited reads the "file" multipart field while bounding how many bytes
|
||||
@@ -383,6 +384,38 @@ func (h *FileHandler) SoftDelete(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /files/:id/content-token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateContentToken mints a short-lived, single-file capability token the
|
||||
// client can put in a content URL's access_token query parameter to open or
|
||||
// stream the original by link (e.g. a long video in a new tab) without the URL
|
||||
// dying when the 15-minute access token expires. It first enforces view
|
||||
// permission via fileSvc.Get, so a token is only issued for a file the caller
|
||||
// may actually read.
|
||||
func (h *FileHandler) CreateContentToken(c *gin.Context) {
|
||||
id, ok := parseFileID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Authorize (and confirm existence) the same way content serving does.
|
||||
if _, err := h.fileSvc.Get(c.Request.Context(), id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
||||
token, expiresIn, err := h.authSvc.GenerateContentToken(id.String(), userID, isAdmin)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "expires_in": expiresIn})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /files/:id/content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user