From 86fab0cf4ceb195a58a0a6addb46f6929c8902b0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sat, 31 Jan 2026 22:01:28 +0800 Subject: [PATCH] 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 --- server/router/fileserver/fileserver.go | 833 +++++++++++++------------ 1 file changed, 446 insertions(+), 387 deletions(-) diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go index c49d92e3a..bc7581829 100644 --- a/server/router/fileserver/fileserver.go +++ b/server/router/fileserver/fileserver.go @@ -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[^;]+);base64,(?P.+)`) + // 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.+);base64,(?P.+)`) - 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 }