fix(fileserver): use streaming for video/audio to prevent memory exhaustion

- Add serveMediaStream() to stream video/audio without loading into memory
- Use http.ServeFile for local files (zero-copy, handles range requests)
- Redirect to S3 presigned URLs for S3-stored media files
- Refactor for better maintainability:
  - Extract constants and pre-compile lookup maps
  - Consolidate duplicated S3 client creation logic
  - Split authentication into focused helper methods
  - Group code by responsibility with section comments
  - Add setSecurityHeaders() and setMediaHeaders() helpers
This commit is contained in:
Johnny 2026-01-31 22:01:28 +08:00
parent 27de96d440
commit 86fab0cf4c
1 changed files with 446 additions and 387 deletions

View File

@ -26,13 +26,55 @@ import (
"github.com/usememos/memos/store"
)
// Constants for file serving configuration.
const (
// ThumbnailCacheFolder is the folder name where the thumbnail images are stored.
// ThumbnailCacheFolder is the folder name where thumbnail images are stored.
ThumbnailCacheFolder = ".thumbnail_cache"
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
// thumbnailMaxSize is the maximum dimension (width or height) for thumbnails.
thumbnailMaxSize = 600
// maxConcurrentThumbnails limits concurrent thumbnail generation to prevent memory exhaustion.
maxConcurrentThumbnails = 3
// cacheMaxAge is the max-age value for Cache-Control headers (1 hour).
cacheMaxAge = "public, max-age=3600"
)
// xssUnsafeTypes contains MIME types that could execute scripts if served directly.
// These are served as application/octet-stream to prevent XSS attacks.
var xssUnsafeTypes = map[string]bool{
"text/html": true,
"text/javascript": true,
"application/javascript": true,
"application/x-javascript": true,
"text/xml": true,
"application/xml": true,
"application/xhtml+xml": true,
"image/svg+xml": true,
}
// thumbnailSupportedTypes contains image MIME types that support thumbnail generation.
var thumbnailSupportedTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/heic": true,
"image/heif": true,
"image/webp": true,
}
// avatarAllowedTypes contains MIME types allowed for user avatars.
var avatarAllowedTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/gif": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
}
// SupportedThumbnailMimeTypes is the exported list of thumbnail-supported MIME types.
var SupportedThumbnailMimeTypes = []string{
"image/png",
"image/jpeg",
@ -41,15 +83,16 @@ var SupportedThumbnailMimeTypes = []string{
"image/webp",
}
// dataURIRegex parses data URI format: data:image/png;base64,iVBORw0KGgo...
var dataURIRegex = regexp.MustCompile(`^data:(?P<type>[^;]+);base64,(?P<base64>.+)`)
// FileServerService handles HTTP file serving with proper range request support.
// This service bypasses gRPC-Gateway to use native HTTP serving via http.ServeContent(),
// which is required for Safari video/audio playback.
type FileServerService struct {
Profile *profile.Profile
Store *store.Store
authenticator *auth.Authenticator
// thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion
// thumbnailSemaphore limits concurrent thumbnail generation.
thumbnailSemaphore *semaphore.Weighted
}
@ -59,29 +102,27 @@ func NewFileServerService(profile *profile.Profile, store *store.Store, secret s
Profile: profile,
Store: store,
authenticator: auth.NewAuthenticator(store, secret),
thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations
thumbnailSemaphore: semaphore.NewWeighted(maxConcurrentThumbnails),
}
}
// RegisterRoutes registers HTTP file serving routes.
func (s *FileServerService) RegisterRoutes(echoServer *echo.Echo) {
fileGroup := echoServer.Group("/file")
// Serve attachment binary files
fileGroup.GET("/attachments/:uid/:filename", s.serveAttachmentFile)
// Serve user avatar images
fileGroup.GET("/users/:identifier/avatar", s.serveUserAvatar)
}
// =============================================================================
// HTTP Handlers
// =============================================================================
// serveAttachmentFile serves attachment binary content using native HTTP.
// This properly handles range requests required by Safari for video/audio playback.
func (s *FileServerService) serveAttachmentFile(c echo.Context) error {
ctx := c.Request().Context()
uid := c.Param("uid")
thumbnail := c.QueryParam("thumbnail") == "true"
wantThumbnail := c.QueryParam("thumbnail") == "true"
// Get attachment from database
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
UID: &uid,
GetBlob: true,
@ -93,96 +134,25 @@ func (s *FileServerService) serveAttachmentFile(c echo.Context) error {
return echo.NewHTTPError(http.StatusNotFound, "attachment not found")
}
// Check permissions - verify memo visibility if attachment belongs to a memo
if err := s.checkAttachmentPermission(ctx, c, attachment); err != nil {
return err
}
// Get the binary content
blob, err := s.getAttachmentBlob(attachment)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get attachment blob").SetInternal(err)
contentType := s.sanitizeContentType(attachment.Type)
// Stream video/audio to avoid loading entire file into memory.
if isMediaType(attachment.Type) {
return s.serveMediaStream(c, attachment, contentType)
}
// Handle thumbnail requests for images
if thumbnail && s.isImageType(attachment.Type) {
thumbnailBlob, err := s.getOrGenerateThumbnail(ctx, attachment)
if err != nil {
// Log warning but fall back to original image
c.Logger().Warnf("failed to get thumbnail: %v", err)
} else {
blob = thumbnailBlob
}
}
// Determine content type
contentType := attachment.Type
if strings.HasPrefix(contentType, "text/") {
contentType += "; charset=utf-8"
}
// Prevent XSS attacks by serving potentially unsafe files as octet-stream
unsafeTypes := []string{
"text/html",
"text/javascript",
"application/javascript",
"application/x-javascript",
"text/xml",
"application/xml",
"application/xhtml+xml",
"image/svg+xml",
}
for _, unsafeType := range unsafeTypes {
if strings.EqualFold(contentType, unsafeType) {
contentType = "application/octet-stream"
break
}
}
// Set common headers
c.Response().Header().Set("Content-Type", contentType)
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
// Prevent MIME-type sniffing which could lead to XSS
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
// Defense-in-depth: prevent embedding in frames and restrict content loading
c.Response().Header().Set("X-Frame-Options", "DENY")
c.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline';")
// Support HDR/wide color gamut display for capable browsers
if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
c.Response().Header().Set("Color-Gamut", "srgb, p3, rec2020")
}
// Force download for non-media files to prevent XSS execution
if !strings.HasPrefix(contentType, "image/") &&
!strings.HasPrefix(contentType, "video/") &&
!strings.HasPrefix(contentType, "audio/") &&
contentType != "application/pdf" {
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", attachment.Filename))
}
// For video/audio: Use http.ServeContent for automatic range request support
// This is critical for Safari which REQUIRES range request support
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
// ServeContent automatically handles:
// - Range request parsing
// - HTTP 206 Partial Content responses
// - Content-Range headers
// - Accept-Ranges: bytes header
modTime := time.Unix(attachment.UpdatedTs, 0)
http.ServeContent(c.Response(), c.Request(), attachment.Filename, modTime, bytes.NewReader(blob))
return nil
}
// For other files: Simple blob response
return c.Blob(http.StatusOK, contentType, blob)
return s.serveStaticFile(c, attachment, contentType, wantThumbnail)
}
// serveUserAvatar serves user avatar images.
// Supports both user ID and username as identifier.
func (s *FileServerService) serveUserAvatar(c echo.Context) error {
ctx := c.Request().Context()
identifier := c.Param("identifier")
// Try to find user by ID or username
user, err := s.getUserByIdentifier(ctx, identifier)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user").SetInternal(err)
@ -194,79 +164,316 @@ func (s *FileServerService) serveUserAvatar(c echo.Context) error {
return echo.NewHTTPError(http.StatusNotFound, "avatar not found")
}
// Extract image info from data URI
imageType, base64Data, err := s.extractImageInfo(user.AvatarURL)
imageType, imageData, err := s.parseDataURI(user.AvatarURL)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to extract image info").SetInternal(err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse avatar data").SetInternal(err)
}
// Validate avatar MIME type to prevent XSS
// Supports standard formats and HDR-capable formats
allowedAvatarTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/gif": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
}
if !allowedAvatarTypes[imageType] {
if !avatarAllowedTypes[imageType] {
return echo.NewHTTPError(http.StatusBadRequest, "invalid avatar image type")
}
// Decode base64 data
imageData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to decode image data").SetInternal(err)
}
// Set cache headers for avatars
c.Response().Header().Set("Content-Type", imageType)
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
// Defense-in-depth: prevent embedding in frames
c.Response().Header().Set("X-Frame-Options", "DENY")
c.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline';")
setSecurityHeaders(c)
c.Response().Header().Set(echo.HeaderContentType, imageType)
c.Response().Header().Set(echo.HeaderCacheControl, cacheMaxAge)
return c.Blob(http.StatusOK, imageType, imageData)
}
// getUserByIdentifier finds a user by either ID or username.
func (s *FileServerService) getUserByIdentifier(ctx context.Context, identifier string) (*store.User, error) {
// Try to parse as ID first
if userID, err := util.ConvertStringToInt32(identifier); err == nil {
return s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
}
// =============================================================================
// File Serving Methods
// =============================================================================
// Otherwise, treat as username
return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier})
// serveMediaStream serves video/audio files using streaming to avoid memory exhaustion.
func (s *FileServerService) serveMediaStream(c echo.Context, attachment *store.Attachment, contentType string) error {
setSecurityHeaders(c)
setMediaHeaders(c, contentType, attachment.Type)
switch attachment.StorageType {
case storepb.AttachmentStorageType_LOCAL:
filePath, err := s.resolveLocalPath(attachment.Reference)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve file path").SetInternal(err)
}
http.ServeFile(c.Response(), c.Request(), filePath)
return nil
case storepb.AttachmentStorageType_S3:
presignURL, err := s.getS3PresignedURL(c.Request().Context(), attachment)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate presigned URL").SetInternal(err)
}
return c.Redirect(http.StatusTemporaryRedirect, presignURL)
default:
// Database storage fallback.
modTime := time.Unix(attachment.UpdatedTs, 0)
http.ServeContent(c.Response(), c.Request(), attachment.Filename, modTime, bytes.NewReader(attachment.Blob))
return nil
}
}
// extractImageInfo extracts image type and base64 data from a data URI.
// Data URI format: data:image/png;base64,iVBORw0KGgo...
func (*FileServerService) extractImageInfo(dataURI string) (string, string, error) {
dataURIRegex := regexp.MustCompile(`^data:(?P<type>.+);base64,(?P<base64>.+)`)
matches := dataURIRegex.FindStringSubmatch(dataURI)
if len(matches) != 3 {
return "", "", errors.New("invalid data URI format")
// serveStaticFile serves non-streaming files (images, documents, etc.).
func (s *FileServerService) serveStaticFile(c echo.Context, attachment *store.Attachment, contentType string, wantThumbnail bool) error {
blob, err := s.getAttachmentBlob(attachment)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get attachment blob").SetInternal(err)
}
imageType := matches[1]
base64Data := matches[2]
return imageType, base64Data, nil
// Generate thumbnail for supported image types.
if wantThumbnail && thumbnailSupportedTypes[attachment.Type] {
if thumbnailBlob, err := s.getOrGenerateThumbnail(c.Request().Context(), attachment); err != nil {
c.Logger().Warnf("failed to get thumbnail: %v", err)
} else {
blob = thumbnailBlob
}
}
setSecurityHeaders(c)
setMediaHeaders(c, contentType, attachment.Type)
// Force download for non-media files to prevent XSS execution.
if !strings.HasPrefix(contentType, "image/") && contentType != "application/pdf" {
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%q", attachment.Filename))
}
return c.Blob(http.StatusOK, contentType, blob)
}
// =============================================================================
// Storage Operations
// =============================================================================
// getAttachmentBlob retrieves the binary content of an attachment from storage.
func (s *FileServerService) getAttachmentBlob(attachment *store.Attachment) ([]byte, error) {
switch attachment.StorageType {
case storepb.AttachmentStorageType_LOCAL:
return s.readLocalFile(attachment.Reference)
case storepb.AttachmentStorageType_S3:
return s.downloadFromS3(attachment)
default:
return attachment.Blob, nil
}
}
// getAttachmentReader returns a reader for streaming attachment content.
func (s *FileServerService) getAttachmentReader(attachment *store.Attachment) (io.ReadCloser, error) {
switch attachment.StorageType {
case storepb.AttachmentStorageType_LOCAL:
filePath, err := s.resolveLocalPath(attachment.Reference)
if err != nil {
return nil, err
}
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open file")
}
return file, nil
case storepb.AttachmentStorageType_S3:
s3Client, s3Object, err := s.createS3Client(attachment)
if err != nil {
return nil, err
}
reader, err := s3Client.GetObjectStream(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to stream from S3")
}
return reader, nil
default:
return io.NopCloser(bytes.NewReader(attachment.Blob)), nil
}
}
// resolveLocalPath converts a storage reference to an absolute file path.
func (s *FileServerService) resolveLocalPath(reference string) (string, error) {
filePath := filepath.FromSlash(reference)
if !filepath.IsAbs(filePath) {
filePath = filepath.Join(s.Profile.Data, filePath)
}
return filePath, nil
}
// readLocalFile reads the entire contents of a local file.
func (s *FileServerService) readLocalFile(reference string) ([]byte, error) {
filePath, err := s.resolveLocalPath(reference)
if err != nil {
return nil, err
}
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open file")
}
defer file.Close()
blob, err := io.ReadAll(file)
if err != nil {
return nil, errors.Wrap(err, "failed to read file")
}
return blob, nil
}
// createS3Client creates an S3 client from attachment payload.
func (*FileServerService) createS3Client(attachment *store.Attachment) (*s3.Client, *storepb.AttachmentPayload_S3Object, error) {
if attachment.Payload == nil {
return nil, nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, nil, errors.New("S3 object key is missing")
}
client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to create S3 client")
}
return client, s3Object, nil
}
// downloadFromS3 downloads the entire object from S3.
func (s *FileServerService) downloadFromS3(attachment *store.Attachment) ([]byte, error) {
client, s3Object, err := s.createS3Client(attachment)
if err != nil {
return nil, err
}
blob, err := client.GetObject(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to download from S3")
}
return blob, nil
}
// getS3PresignedURL generates a presigned URL for direct S3 access.
func (s *FileServerService) getS3PresignedURL(ctx context.Context, attachment *store.Attachment) (string, error) {
client, s3Object, err := s.createS3Client(attachment)
if err != nil {
return "", err
}
url, err := client.PresignGetObject(ctx, s3Object.Key)
if err != nil {
return "", errors.Wrap(err, "failed to presign URL")
}
return url, nil
}
// =============================================================================
// Thumbnail Generation
// =============================================================================
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
// Uses semaphore to limit concurrent thumbnail generation and prevent memory exhaustion.
func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachment *store.Attachment) ([]byte, error) {
thumbnailPath, err := s.getThumbnailPath(attachment)
if err != nil {
return nil, err
}
// Fast path: return cached thumbnail if exists.
if blob, err := s.readCachedThumbnail(thumbnailPath); err == nil {
return blob, nil
}
// Acquire semaphore to limit concurrent generation.
if err := s.thumbnailSemaphore.Acquire(ctx, 1); err != nil {
return nil, errors.Wrap(err, "failed to acquire semaphore")
}
defer s.thumbnailSemaphore.Release(1)
// Double-check after acquiring semaphore (another goroutine may have generated it).
if blob, err := s.readCachedThumbnail(thumbnailPath); err == nil {
return blob, nil
}
return s.generateThumbnail(attachment, thumbnailPath)
}
// getThumbnailPath returns the file path for a cached thumbnail.
func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (string, error) {
cacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create thumbnail cache folder")
}
filename := fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename))
return filepath.Join(cacheFolder, filename), nil
}
// readCachedThumbnail reads a thumbnail from the cache directory.
func (*FileServerService) readCachedThumbnail(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return io.ReadAll(file)
}
// generateThumbnail creates a new thumbnail and saves it to disk.
func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thumbnailPath string) ([]byte, error) {
reader, err := s.getAttachmentReader(attachment)
if err != nil {
return nil, errors.Wrap(err, "failed to get attachment reader")
}
defer reader.Close()
img, err := imaging.Decode(reader, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode image")
}
width, height := img.Bounds().Dx(), img.Bounds().Dy()
thumbnailWidth, thumbnailHeight := calculateThumbnailDimensions(width, height)
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
if err := imaging.Save(thumbnailImage, thumbnailPath); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail")
}
return s.readCachedThumbnail(thumbnailPath)
}
// calculateThumbnailDimensions calculates the target dimensions for a thumbnail.
// The largest dimension is constrained to thumbnailMaxSize while maintaining aspect ratio.
// Small images are not enlarged.
func calculateThumbnailDimensions(width, height int) (int, int) {
if max(width, height) <= thumbnailMaxSize {
return width, height
}
if width >= height {
return thumbnailMaxSize, 0 // Landscape: constrain width.
}
return 0, thumbnailMaxSize // Portrait: constrain height.
}
// =============================================================================
// Authentication & Authorization
// =============================================================================
// checkAttachmentPermission verifies the user has permission to access the attachment.
func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c echo.Context, attachment *store.Attachment) error {
// If attachment is not linked to a memo, allow access
if attachment.MemoID == nil {
return nil
return nil // Unlinked attachments are accessible.
}
// Check memo visibility
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: attachment.MemoID,
})
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: attachment.MemoID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find memo").SetInternal(err)
}
@ -274,12 +481,10 @@ func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c ech
return echo.NewHTTPError(http.StatusNotFound, "memo not found")
}
// Public memos are accessible to everyone
if memo.Visibility == store.Public {
return nil
}
// For non-public memos, check authentication
user, err := s.getCurrentUser(ctx, c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get current user").SetInternal(err)
@ -288,7 +493,6 @@ func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c ech
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized access")
}
// Private memos can only be accessed by the creator
if memo.Visibility == store.Private && user.ID != attachment.CreatorID {
return echo.NewHTTPError(http.StatusForbidden, "forbidden access")
}
@ -296,270 +500,125 @@ func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c ech
return nil
}
// getCurrentUser retrieves the current authenticated user from the Echo context.
// getCurrentUser retrieves the current authenticated user from the request.
// Authentication priority: Bearer token (Access Token V2 or PAT) > Refresh token cookie.
// Uses the shared Authenticator for consistent authentication logic.
func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context) (*store.User, error) {
// Try Bearer token authentication first
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
token := auth.ExtractBearerToken(authHeader)
if token != "" {
// Try Access Token V2 (stateless)
if !strings.HasPrefix(token, auth.PersonalAccessTokenPrefix) {
claims, err := s.authenticator.AuthenticateByAccessTokenV2(token)
if err == nil && claims != nil {
// Get user from claims
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &claims.UserID})
if err == nil && user != nil {
return user, nil
}
}
}
// Try PAT
if strings.HasPrefix(token, auth.PersonalAccessTokenPrefix) {
user, _, err := s.authenticator.AuthenticateByPAT(ctx, token)
if err == nil && user != nil {
return user, nil
}
}
// Try Bearer token authentication.
if authHeader := c.Request().Header.Get(echo.HeaderAuthorization); authHeader != "" {
if user, err := s.authenticateByBearerToken(ctx, authHeader); err == nil && user != nil {
return user, nil
}
}
// Fallback: Try refresh token cookie authentication
// This allows protected attachments to load even when access token has expired,
// as long as the user has a valid refresh token cookie.
cookieHeader := c.Request().Header.Get("Cookie")
if cookieHeader != "" {
refreshToken := auth.ExtractRefreshTokenFromCookie(cookieHeader)
if refreshToken != "" {
user, _, err := s.authenticator.AuthenticateByRefreshToken(ctx, refreshToken)
if err == nil && user != nil {
return user, nil
}
// Fallback: Try refresh token cookie.
if cookieHeader := c.Request().Header.Get("Cookie"); cookieHeader != "" {
if user, err := s.authenticateByRefreshToken(ctx, cookieHeader); err == nil && user != nil {
return user, nil
}
}
// No valid authentication found
return nil, nil
}
// isImageType checks if the mime type is an image that supports thumbnails.
// Supports standard formats (PNG, JPEG) and HDR-capable formats (HEIC, HEIF, WebP).
func (*FileServerService) isImageType(mimeType string) bool {
supportedTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/heic": true,
"image/heif": true,
"image/webp": true,
// authenticateByBearerToken authenticates using Authorization header.
func (s *FileServerService) authenticateByBearerToken(ctx context.Context, authHeader string) (*store.User, error) {
token := auth.ExtractBearerToken(authHeader)
if token == "" {
return nil, nil
}
return supportedTypes[mimeType]
// Try Access Token V2 (stateless JWT).
if !strings.HasPrefix(token, auth.PersonalAccessTokenPrefix) {
claims, err := s.authenticator.AuthenticateByAccessTokenV2(token)
if err == nil && claims != nil {
return s.Store.GetUser(ctx, &store.FindUser{ID: &claims.UserID})
}
}
// Try Personal Access Token (stateful).
if strings.HasPrefix(token, auth.PersonalAccessTokenPrefix) {
user, _, err := s.authenticator.AuthenticateByPAT(ctx, token)
if err == nil {
return user, nil
}
}
return nil, nil
}
// getAttachmentReader returns a reader for the attachment content.
func (s *FileServerService) getAttachmentReader(attachment *store.Attachment) (io.ReadCloser, error) {
// For local storage, read the file from the local disk.
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
attachmentPath := filepath.FromSlash(attachment.Reference)
if !filepath.IsAbs(attachmentPath) {
attachmentPath = filepath.Join(s.Profile.Data, attachmentPath)
}
file, err := os.Open(attachmentPath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open the file")
}
return file, nil
// authenticateByRefreshToken authenticates using refresh token cookie.
func (s *FileServerService) authenticateByRefreshToken(ctx context.Context, cookieHeader string) (*store.User, error) {
refreshToken := auth.ExtractRefreshTokenFromCookie(cookieHeader)
if refreshToken == "" {
return nil, nil
}
// For S3 storage, download the file from S3.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if attachment.Payload == nil {
return nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, errors.New("S3 object key is missing")
}
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, errors.Wrap(err, "failed to create S3 client")
}
reader, err := s3Client.GetObjectStream(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to get object from S3")
}
return reader, nil
}
// For database storage, return the blob from the database.
return io.NopCloser(bytes.NewReader(attachment.Blob)), nil
user, _, err := s.authenticator.AuthenticateByRefreshToken(ctx, refreshToken)
return user, err
}
// getAttachmentBlob retrieves the binary content of an attachment from storage.
func (s *FileServerService) getAttachmentBlob(attachment *store.Attachment) ([]byte, error) {
// For local storage, read the file from the local disk.
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
attachmentPath := filepath.FromSlash(attachment.Reference)
if !filepath.IsAbs(attachmentPath) {
attachmentPath = filepath.Join(s.Profile.Data, attachmentPath)
}
file, err := os.Open(attachmentPath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open the file")
}
defer file.Close()
blob, err := io.ReadAll(file)
if err != nil {
return nil, errors.Wrap(err, "failed to read the file")
}
return blob, nil
// getUserByIdentifier finds a user by either ID or username.
func (s *FileServerService) getUserByIdentifier(ctx context.Context, identifier string) (*store.User, error) {
if userID, err := util.ConvertStringToInt32(identifier); err == nil {
return s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
}
// For S3 storage, download the file from S3.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if attachment.Payload == nil {
return nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, errors.New("S3 object key is missing")
}
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, errors.Wrap(err, "failed to create S3 client")
}
blob, err := s3Client.GetObject(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to get object from S3")
}
return blob, nil
}
// For database storage, return the blob from the database.
return attachment.Blob, nil
return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier})
}
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
// Uses semaphore to limit concurrent thumbnail generation and prevent memory exhaustion.
func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachment *store.Attachment) ([]byte, error) {
thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail cache folder")
}
filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename)))
// =============================================================================
// Helper Functions
// =============================================================================
// Check if thumbnail already exists
if _, err := os.Stat(filePath); err == nil {
// Thumbnail exists, read and return it
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
blob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return blob, nil
} else if !os.IsNotExist(err) {
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
// sanitizeContentType converts potentially dangerous MIME types to safe alternatives.
func (*FileServerService) sanitizeContentType(mimeType string) string {
contentType := mimeType
if strings.HasPrefix(contentType, "text/") {
contentType += "; charset=utf-8"
}
// Thumbnail doesn't exist, acquire semaphore to limit concurrent generation
if err := s.thumbnailSemaphore.Acquire(ctx, 1); err != nil {
return nil, errors.Wrap(err, "failed to acquire thumbnail generation semaphore")
// Normalize for case-insensitive lookup.
if xssUnsafeTypes[strings.ToLower(mimeType)] {
return "application/octet-stream"
}
return contentType
}
// parseDataURI extracts MIME type and decoded data from a data URI.
func (*FileServerService) parseDataURI(dataURI string) (string, []byte, error) {
matches := dataURIRegex.FindStringSubmatch(dataURI)
if len(matches) != 3 {
return "", nil, errors.New("invalid data URI format")
}
imageType := matches[1]
imageData, err := base64.StdEncoding.DecodeString(matches[2])
if err != nil {
return "", nil, errors.Wrap(err, "failed to decode base64 data")
}
return imageType, imageData, nil
}
// isMediaType checks if the MIME type is video or audio.
func isMediaType(mimeType string) bool {
return strings.HasPrefix(mimeType, "video/") || strings.HasPrefix(mimeType, "audio/")
}
// setSecurityHeaders sets common security headers for all responses.
func setSecurityHeaders(c echo.Context) {
h := c.Response().Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline';")
}
// setMediaHeaders sets headers for media file responses.
func setMediaHeaders(c echo.Context, contentType, originalType string) {
h := c.Response().Header()
h.Set(echo.HeaderContentType, contentType)
h.Set(echo.HeaderCacheControl, cacheMaxAge)
// Support HDR/wide color gamut for images and videos.
if strings.HasPrefix(originalType, "image/") || strings.HasPrefix(originalType, "video/") {
h.Set("Color-Gamut", "srgb, p3, rec2020")
}
defer s.thumbnailSemaphore.Release(1)
// Double-check if thumbnail was created while waiting for semaphore
if _, err := os.Stat(filePath); err == nil {
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
blob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return blob, nil
}
// Generate the thumbnail
reader, err := s.getAttachmentReader(attachment)
if err != nil {
return nil, errors.Wrap(err, "failed to get attachment reader")
}
defer reader.Close()
// Decode image - this is memory intensive
img, err := imaging.Decode(reader, imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}
// The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally.
// Small images are not enlarged.
width := img.Bounds().Dx()
height := img.Bounds().Dy()
var thumbnailWidth, thumbnailHeight int
// Only resize if the image is larger than thumbnailMaxSize
if max(width, height) > thumbnailMaxSize {
if width >= height {
// Landscape or square - constrain width, maintain aspect ratio for height
thumbnailWidth = thumbnailMaxSize
thumbnailHeight = 0
} else {
// Portrait - constrain height, maintain aspect ratio for width
thumbnailWidth = 0
thumbnailHeight = thumbnailMaxSize
}
} else {
// Keep original dimensions for small images
thumbnailWidth = width
thumbnailHeight = height
}
// Resize the image to the calculated dimensions.
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
// Save thumbnail to disk
if err := imaging.Save(thumbnailImage, filePath); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail file")
}
// Read the saved thumbnail and return it
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
thumbnailBlob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return thumbnailBlob, nil
}