diff --git a/server/router/api/v1/attachment_service.go b/server/router/api/v1/attachment_service.go index c56c8684..02e8504e 100644 --- a/server/router/api/v1/attachment_service.go +++ b/server/router/api/v1/attachment_service.go @@ -26,8 +26,6 @@ const ( // This is unrelated to maximum upload size limit, which is now set through system setting. MaxUploadBufferSizeBytes = 32 << 20 MebiByte = 1024 * 1024 - // ThumbnailCacheFolder is the folder name where the thumbnail images are stored. - ThumbnailCacheFolder = ".thumbnail_cache" // defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping. // Quality 95 maintains visual quality while ensuring metadata is removed. @@ -36,11 +34,6 @@ const ( maxImagePixels = 50_000_000 ) -var SupportedThumbnailMimeTypes = []string{ - "image/png", - "image/jpeg", -} - // exifCapableImageTypes defines image formats that may contain EXIF metadata. // These formats will have their EXIF metadata stripped on upload for privacy. var exifCapableImageTypes = map[string]bool{ diff --git a/server/router/fileserver/README.md b/server/router/fileserver/README.md index 0b87ef10..1c4a4209 100644 --- a/server/router/fileserver/README.md +++ b/server/router/fileserver/README.md @@ -1,304 +1,46 @@ # Fileserver Package -## Overview +The `fileserver` package serves binary content (attachments, avatars) over plain HTTP instead of gRPC, so that HTTP range requests work — required for Safari video/audio playback ([RFC 9110 §14](https://www.rfc-editor.org/rfc/rfc9110#section-14)). Metadata stays on the gRPC API; only bytes are served here. -The `fileserver` package handles all binary file serving for Memos using native HTTP handlers. It was created to replace gRPC-based binary serving, which had limitations with HTTP range requests (required for Safari video/audio playback). - -## Responsibilities - -- Serve attachment binary files (images, videos, audio, documents) -- Serve user avatar images -- Handle HTTP range requests for video/audio streaming -- Authenticate requests using JWT tokens or Personal Access Tokens -- Check permissions for private content -- Generate and serve image thumbnails -- Prevent XSS attacks on uploaded content -- Support S3 external storage - -## Architecture - -### Design Principles - -1. **Separation of Concerns**: Binary files via HTTP, metadata via gRPC -2. **DRY**: Imports auth constants from `api/v1` package (single source of truth) -3. **Security First**: Authentication, authorization, and XSS prevention -4. **Performance**: Native HTTP streaming with proper caching headers - -### Package Structure +## Endpoints +```text +GET /file/attachments/:uid[/:filename] # attachment binary + ?thumbnail=true # JPEG thumbnail for supported image types + ?motion=true # embedded motion-photo video clip + ?share_token={uid} # access via a memo share link +GET /file/users/:identifier/avatar # user avatar (by username) ``` -fileserver/ -├── fileserver.go # Main service and HTTP handlers -├── README.md # This file -└── fileserver_test.go # Tests (to be added) -``` - -## API Endpoints - -### 1. Attachment Binary -``` -GET /file/attachments/:uid/:filename[?thumbnail=true] -``` - -**Parameters:** -- `uid` - Attachment unique identifier -- `filename` - Original filename -- `thumbnail` (optional) - Return thumbnail for images - -**Authentication:** Required for non-public memos - -**Response:** -- `200 OK` - File content with proper Content-Type -- `206 Partial Content` - For range requests (video/audio) -- `401 Unauthorized` - Authentication required -- `403 Forbidden` - User not authorized -- `404 Not Found` - Attachment not found - -**Headers:** -- `Content-Type` - MIME type of the file -- `Cache-Control: public, max-age=3600` -- `Accept-Ranges: bytes` - For video/audio -- `Content-Range` - For partial responses (206) - -### 2. User Avatar -``` -GET /file/users/:identifier/avatar -``` - -**Parameters:** -- `identifier` - User ID (e.g., `1`) or username (e.g., `steven`) - -**Authentication:** Not required (avatars are public) - -**Response:** -- `200 OK` - Avatar image (PNG/JPEG) -- `404 Not Found` - User not found or no avatar set - -**Headers:** -- `Content-Type` - image/png or image/jpeg -- `Cache-Control: public, max-age=3600` ## Authentication -### Supported Methods +Handled by `server/auth` (see [authenticator.go](../../auth/authenticator.go) and [token.go](../../auth/token.go) for the constants and token formats). Priority: `Authorization: Bearer` header (access token or personal access token) first, then the refresh token cookie. See `getCurrentUser` in [fileserver.go](fileserver.go). -The fileserver supports the following authentication methods: +## Authorization -1. **JWT Access Token** (`Authorization: Bearer {token}`) - - Short-lived tokens (15 minutes) for API access - - Stateless validation using JWT signature - - Extracts user ID from token claims +Attachment access follows memo visibility, evaluated by `server/access.CheckMemoRead`: -2. **Personal Access Token (PAT)** (`Authorization: Bearer {pat}`) - - Long-lived tokens for programmatic access - - Validates against database for revocation - - Prefixed with specific identifier +- Public memo: no auth required (when the instance allows anonymous access) +- Protected memo: any authenticated user +- Private memo: creator only +- Valid `share_token`: grants access to that memo's attachments +- Unlinked attachment (no memo): creator or admin only -### Authentication Flow +Avatars are public on instances that allow anonymous access; private instances require authentication. -``` -Request → getCurrentUser() - ├─→ Try Session Cookie - │ ├─→ Parse cookie value - │ ├─→ Get user from DB - │ ├─→ Validate session - │ └─→ Return user (if valid) - │ - └─→ Try JWT Token - ├─→ Parse Authorization header - ├─→ Verify JWT signature - ├─→ Get user from DB - ├─→ Validate token in access tokens list - └─→ Return user (if valid) -``` +## Serving behavior -### Permission Model - -**Attachments:** -- Unlinked: Public (no auth required) -- Public memo: Public (no auth required) -- Protected memo: Requires authentication -- Private memo: Creator only - -**Avatars:** -- Always public (no auth required) - -## Key Functions - -### HTTP Handlers - -#### `serveAttachmentFile(c echo.Context) error` -Main handler for attachment binary serving. - -**Flow:** -1. Extract UID from URL parameter -2. Fetch attachment from database -3. Check permissions (memo visibility) -4. Get binary blob (local file, S3, or database) -5. Handle thumbnail request (if applicable) -6. Set security headers (XSS prevention) -7. Serve with range request support (video/audio) - -#### `serveUserAvatar(c echo.Context) error` -Main handler for user avatar serving. - -**Flow:** -1. Extract identifier (ID or username) from URL -2. Lookup user in database -3. Check if avatar exists -4. Decode base64 data URI -5. Serve with proper content type and caching - -### Authentication - -#### `getCurrentUser(ctx, c) (*store.User, error)` -Authenticates request using session cookie or JWT token. - -#### `authenticateBySession(ctx, cookie) (*store.User, error)` -Validates session cookie and returns authenticated user. - -#### `authenticateByJWT(ctx, token) (*store.User, error)` -Validates JWT access token and returns authenticated user. - -### Permission Checks - -#### `checkAttachmentPermission(ctx, c, attachment) error` -Validates user has permission to access attachment based on memo visibility. - -### File Operations - -#### `getAttachmentBlob(attachment) ([]byte, error)` -Retrieves binary content from local storage, S3, or database. - -#### `getOrGenerateThumbnail(ctx, attachment) ([]byte, error)` -Returns cached thumbnail or generates new one (with semaphore limiting). - -### Utilities - -#### `getUserByIdentifier(ctx, identifier) (*store.User, error)` -Finds user by ID (int) or username (string). - -#### `extractImageInfo(dataURI) (type, base64, error)` -Parses data URI to extract MIME type and base64 data. - -## Dependencies - -### External Packages -- `github.com/labstack/echo/v5` - HTTP router and middleware -- `github.com/golang-jwt/jwt/v5` - JWT parsing and validation -- `github.com/disintegration/imaging` - Image thumbnail generation -- `golang.org/x/sync/semaphore` - Concurrency control for thumbnails - -### Internal Packages -- `server/auth` - Authentication utilities -- `store` - Database operations -- `internal/profile` - Server configuration -- `internal/storage/s3` - S3 storage client - -## Configuration - -### Constants - -Auth-related constants are imported from `server/auth`: -- `auth.RefreshTokenCookieName` - "memos_refresh" -- `auth.PersonalAccessTokenPrefix` - PAT identifier prefix - -Package-specific constants: -- `ThumbnailCacheFolder` - ".thumbnail_cache" -- `thumbnailMaxSize` - 600px -- `SupportedThumbnailMimeTypes` - ["image/png", "image/jpeg"] - -## Error Handling - -All handlers return Echo HTTP errors with appropriate status codes: - -```go -// Bad request -echo.NewHTTPError(http.StatusBadRequest, "message") - -// Unauthorized (no auth) -echo.NewHTTPError(http.StatusUnauthorized, "message") - -// Forbidden (auth but no permission) -echo.NewHTTPError(http.StatusForbidden, "message") - -// Not found -echo.NewHTTPError(http.StatusNotFound, "message") - -// Internal error -echo.NewHTTPError(http.StatusInternalServerError, "message").SetInternal(err) -``` - -## Security Considerations - -### 1. XSS Prevention -SVG and HTML files are served as `application/octet-stream` to prevent script execution: - -```go -if contentType == "image/svg+xml" || - contentType == "text/html" || - contentType == "application/xhtml+xml" { - contentType = "application/octet-stream" -} -``` - -### 2. Authentication -Private content requires valid JWT access token or Personal Access Token. - -### 3. Authorization -Memo visibility rules enforced before serving attachments. - -### 4. Input Validation -- Attachment UID validated from database -- User identifier validated (ID or username) -- Range requests validated before processing - -## Performance Optimizations - -### 1. Thumbnail Caching -Thumbnails cached on disk to avoid regeneration: -- Cache location: `{data_dir}/.thumbnail_cache/` -- Filename: `{attachment_id}{extension}` -- Semaphore limits concurrent generation (max 3) - -### 2. HTTP Range Requests -Video/audio files use `http.ServeContent()` for efficient streaming: -- Automatic range parsing -- Efficient memory usage (streaming, not loading full file) -- Safari-compatible partial content responses - -### 3. Caching Headers -All responses include cache headers: -``` -Cache-Control: public, max-age=3600 -``` - -### 4. S3 External Links -S3 files served via presigned URLs (no server download). +- **Video/audio** are streamed with range-request support (`http.ServeFile` / `http.ServeContent`); S3-backed media redirects to a presigned URL. +- **Thumbnails** are generated at max 600px, cached in `{data_dir}/.thumbnail_cache/`, with a semaphore capping concurrent generation. Images with HDR/wide-gamut metadata are served as originals, since re-encoding would strip it. +- **Motion photos** have their embedded video extracted and cached in `{data_dir}/.motion_cache/`. +- **XSS prevention**: script-capable MIME types are rewritten to `application/octet-stream`, non-media files get `Content-Disposition: attachment`, and all responses carry `X-Content-Type-Options: nosniff` plus a restrictive `Content-Security-Policy`. +- **Caching**: public attachments get `public, no-cache`; private ones `private, no-store`; avatars and thumbnails `public, max-age=3600`. ## Testing -### Unit Tests (To Add) -See SAFARI_FIX.md for recommended test coverage. +Unit tests live in [fileserver_test.go](fileserver_test.go), covering permission checks, streaming, thumbnails, and metadata detection. Manual checks: -### Manual Testing ```bash -# Test attachment curl "http://localhost:8081/file/attachments/{uid}/file.jpg" - -# Test avatar by username -curl "http://localhost:8081/file/users/steven/avatar" - -# Test range request curl -H "Range: bytes=0-999" "http://localhost:8081/file/attachments/{uid}/video.mp4" ``` - -## Future Improvements - -See SAFARI_FIX.md section "Future Improvements" for planned enhancements. - -## Related Documentation - -- [SAFARI_FIX.md](../../../SAFARI_FIX.md) - Full migration guide -- [server/router/api/v1/auth.go](../api/v1/auth.go) - Auth constants source of truth -- [RFC 7233](https://tools.ietf.org/html/rfc7233) - HTTP Range Requests spec diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go index 2c60a855..82d0a462 100644 --- a/server/router/fileserver/fileserver.go +++ b/server/router/fileserver/fileserver.go @@ -29,11 +29,11 @@ import ( // Constants for file serving configuration. const ( - // ThumbnailCacheFolder is the folder name where thumbnail images are stored. - ThumbnailCacheFolder = ".thumbnail_cache" + // thumbnailCacheFolder is the folder name where thumbnail images are stored. + thumbnailCacheFolder = ".thumbnail_cache" - // MotionCacheFolder is the folder name where extracted motion clips are stored. - MotionCacheFolder = ".motion_cache" + // motionCacheFolder is the folder name where extracted motion clips are stored. + motionCacheFolder = ".motion_cache" // thumbnailMaxSize is the maximum dimension (width or height) for thumbnails. thumbnailMaxSize = 600 @@ -85,16 +85,6 @@ var avatarAllowedTypes = map[string]bool{ "image/heif": true, } -// SupportedThumbnailMimeTypes is the exported list of thumbnail-supported MIME types. -var SupportedThumbnailMimeTypes = []string{ - "image/png", - "image/jpeg", - "image/jpg", - "image/heic", - "image/heif", - "image/webp", -} - var errUseOriginalForThumbnail = errors.New("serve original image instead of metadata-stripping thumbnail") // dataURIRegex parses data URI format: data:image/png;base64,iVBORw0KGgo... @@ -163,7 +153,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { return s.serveMotionClip(c, attachment) } - contentType := s.sanitizeContentType(attachment.Type) + contentType := sanitizeContentType(attachment.Type) // Stream video/audio to avoid loading entire file into memory. if isMediaType(attachment.Type) { @@ -191,7 +181,7 @@ func (s *FileServerService) serveUserAvatar(c *echo.Context) error { identifier := c.Param("identifier") - user, err := s.getUserByUsername(ctx, identifier) + user, err := s.Store.GetUser(ctx, &store.FindUser{Username: &identifier}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user").Wrap(err) } @@ -202,7 +192,7 @@ func (s *FileServerService) serveUserAvatar(c *echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, "avatar not found") } - imageType, imageData, err := s.parseDataURI(user.AvatarURL) + imageType, imageData, err := parseDataURI(user.AvatarURL) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse avatar data").Wrap(err) } @@ -212,7 +202,6 @@ func (s *FileServerService) serveUserAvatar(c *echo.Context) error { } setSecurityHeaders(c) - c.Response().Header().Set(echo.HeaderContentType, imageType) c.Response().Header().Set(echo.HeaderCacheControl, cacheMaxAge) return c.Blob(http.StatusOK, imageType, imageData) @@ -229,11 +218,7 @@ func (s *FileServerService) serveMediaStream(c *echo.Context, attachment *store. 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").Wrap(err) - } - http.ServeFile(c.Response(), c.Request(), filePath) + http.ServeFile(c.Response(), c.Request(), s.resolveLocalPath(attachment.Reference)) return nil case storepb.AttachmentStorageType_S3: @@ -276,11 +261,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A 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").Wrap(err) - } - http.ServeFile(c.Response(), c.Request(), filePath) + http.ServeFile(c.Response(), c.Request(), s.resolveLocalPath(attachment.Reference)) return nil case storepb.AttachmentStorageType_S3: reader, err := s.getAttachmentReader(c.Request().Context(), attachment) @@ -299,28 +280,25 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A // ============================================================================= // 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(context.Background(), attachment) - - default: - return attachment.Blob, nil +func (s *FileServerService) getAttachmentBlob(ctx context.Context, attachment *store.Attachment) ([]byte, error) { + reader, err := s.getAttachmentReader(ctx, attachment) + if err != nil { + return nil, err } + defer reader.Close() + + blob, err := io.ReadAll(reader) + if err != nil { + return nil, errors.Wrap(err, "failed to read attachment content") + } + return blob, nil } // getAttachmentReader returns a reader for streaming attachment content. func (s *FileServerService) getAttachmentReader(ctx context.Context, 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) + file, err := os.Open(s.resolveLocalPath(attachment.Reference)) if err != nil { if os.IsNotExist(err) { return nil, errors.Wrap(err, "file not found") @@ -346,49 +324,12 @@ func (s *FileServerService) getAttachmentReader(ctx context.Context, attachment } // resolveLocalPath converts a storage reference to an absolute file path. -func (s *FileServerService) resolveLocalPath(reference string) (string, error) { +func (s *FileServerService) resolveLocalPath(reference string) string { 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 -} - -// downloadFromS3 downloads the entire object from S3. -func (s *FileServerService) downloadFromS3(ctx context.Context, attachment *store.Attachment) ([]byte, error) { - driver, s3Object, err := s.Store.ResolveAttachmentS3Driver(ctx, attachment) - if err != nil { - return nil, err - } - - blob, err := driver.GetObject(ctx, s3Object.Key) - if err != nil { - return nil, errors.Wrap(err, "failed to download from S3") - } - return blob, nil + return filePath } // getS3PresignedURL generates a presigned URL for direct S3 access. @@ -418,7 +359,7 @@ func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachme } // Fast path: return cached thumbnail if exists. - if blob, err := s.readCachedThumbnail(thumbnailPath); err == nil { + if blob, err := os.ReadFile(thumbnailPath); err == nil { return blob, nil } @@ -437,7 +378,7 @@ func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachme defer s.thumbnailSemaphore.Release(1) // Double-check after acquiring semaphore (another goroutine may have generated it). - if blob, err := s.readCachedThumbnail(thumbnailPath); err == nil { + if blob, err := os.ReadFile(thumbnailPath); err == nil { return blob, nil } @@ -446,7 +387,7 @@ func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachme // 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) + 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") } @@ -474,14 +415,10 @@ func (s *FileServerService) shouldUseOriginalForThumbnail(ctx context.Context, a return false, errors.Wrap(err, "failed to read image metadata probe") } - return hasThumbnailSensitiveMetadata(attachment.Type, probe), nil + return hasThumbnailSensitiveMetadata(probe), nil } -func hasThumbnailSensitiveMetadata(mimeType string, data []byte) bool { - if mimeType == "image/heic" || mimeType == "image/heif" { - return true - } - +func hasThumbnailSensitiveMetadata(data []byte) bool { for _, marker := range [][]byte{ []byte("ICC_PROFILE"), []byte("iCCP"), @@ -520,16 +457,6 @@ func hasThumbnailSensitiveMetadata(mimeType string, data []byte) bool { return false } -// 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(ctx context.Context, attachment *store.Attachment, thumbnailPath string) ([]byte, error) { reader, err := s.getAttachmentReader(ctx, attachment) @@ -548,17 +475,15 @@ func (s *FileServerService) generateThumbnail(ctx context.Context, attachment *s thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos) - output, err := os.Create(thumbnailPath) - if err != nil { - return nil, errors.Wrap(err, "failed to create thumbnail file") + var buf bytes.Buffer + if err := imaging.Encode(&buf, thumbnailImage, imaging.JPEG, imaging.JPEGQuality(90)); err != nil { + return nil, errors.Wrap(err, "failed to encode thumbnail") } - defer output.Close() - - if err := imaging.Encode(output, thumbnailImage, imaging.JPEG, imaging.JPEGQuality(90)); err != nil { + if err := os.WriteFile(thumbnailPath, buf.Bytes(), 0644); err != nil { return nil, errors.Wrap(err, "failed to save thumbnail") } - return s.readCachedThumbnail(thumbnailPath) + return buf.Bytes(), nil } // calculateThumbnailDimensions calculates the target dimensions for a thumbnail. @@ -592,17 +517,17 @@ func (s *FileServerService) serveMotionClip(c *echo.Context, attachment *store.A return nil } -func (s *FileServerService) getOrExtractMotionClip(_ context.Context, attachment *store.Attachment) ([]byte, error) { +func (s *FileServerService) getOrExtractMotionClip(ctx context.Context, attachment *store.Attachment) ([]byte, error) { motionPath, err := s.getMotionPath(attachment) if err != nil { return nil, err } - if blob, err := s.readCachedThumbnail(motionPath); err == nil { + if blob, err := os.ReadFile(motionPath); err == nil { return blob, nil } - blob, err := s.getAttachmentBlob(attachment) + blob, err := s.getAttachmentBlob(ctx, attachment) if err != nil { return nil, err } @@ -620,7 +545,7 @@ func (s *FileServerService) getOrExtractMotionClip(_ context.Context, attachment } func (s *FileServerService) getMotionPath(attachment *store.Attachment) (string, error) { - cacheFolder := filepath.Join(s.Profile.Data, MotionCacheFolder) + cacheFolder := filepath.Join(s.Profile.Data, motionCacheFolder) if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil { return "", errors.Wrap(err, "failed to create motion cache folder") } @@ -712,17 +637,12 @@ func (s *FileServerService) getCurrentUser(ctx context.Context, c *echo.Context) return s.authenticator.AuthenticateToUser(ctx, authHeader, cookieHeader) } -// getUserByUsername finds a user by username only. -func (s *FileServerService) getUserByUsername(ctx context.Context, identifier string) (*store.User, error) { - return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier}) -} - // ============================================================================= // Helper Functions // ============================================================================= // sanitizeContentType converts potentially dangerous MIME types to safe alternatives. -func (*FileServerService) sanitizeContentType(mimeType string) string { +func sanitizeContentType(mimeType string) string { contentType := mimeType if strings.HasPrefix(contentType, "text/") { contentType += "; charset=utf-8" @@ -735,7 +655,7 @@ func (*FileServerService) sanitizeContentType(mimeType string) string { } // parseDataURI extracts MIME type and decoded data from a data URI. -func (*FileServerService) parseDataURI(dataURI string) (string, []byte, error) { +func parseDataURI(dataURI string) (string, []byte, error) { matches := dataURIRegex.FindStringSubmatch(dataURI) if len(matches) != 3 { return "", nil, errors.New("invalid data URI format") diff --git a/server/router/fileserver/fileserver_test.go b/server/router/fileserver/fileserver_test.go index 2491be58..64fc4ef6 100644 --- a/server/router/fileserver/fileserver_test.go +++ b/server/router/fileserver/fileserver_test.go @@ -597,46 +597,35 @@ func TestServeAttachmentFile_ThumbnailWithSensitiveMetadataServesOriginal(t *tes func TestHasThumbnailSensitiveMetadata(t *testing.T) { tests := []struct { - name string - mimeType string - data []byte - want bool + name string + data []byte + want bool }{ { - name: "jpeg hdr gain map", - mimeType: "image/jpeg", - data: []byte("xmp hdrgm:Version=\"1.0\""), - want: true, + name: "jpeg hdr gain map", + data: []byte("xmp hdrgm:Version=\"1.0\""), + want: true, }, { - name: "jpeg icc profile", - mimeType: "image/jpeg", - data: []byte("ICC_PROFILE"), - want: true, + name: "jpeg icc profile", + data: []byte("ICC_PROFILE"), + want: true, }, { - name: "png cicp chunk", - mimeType: "image/png", - data: []byte("cICP"), - want: true, + name: "png cicp chunk", + data: []byte("cICP"), + want: true, }, { - name: "heic", - mimeType: "image/heic", - data: nil, - want: true, - }, - { - name: "plain jpeg", - mimeType: "image/jpeg", - data: []byte("plain image data"), - want: false, + name: "plain jpeg", + data: []byte("plain image data"), + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, hasThumbnailSensitiveMetadata(tt.mimeType, tt.data)) + require.Equal(t, tt.want, hasThumbnailSensitiveMetadata(tt.data)) }) } }