@ -26,13 +26,55 @@ import (
"github.com/usememos/memos/store"
)
// Constants for file serving configuration.
const (
// ThumbnailCacheFolder is the folder name where th e th umbnail images are stored.
// ThumbnailCacheFolder is the folder name where th umbnail 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"
wan tT humbnail := 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 )
}
// 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
}
}
contentType := s . sanitizeContentType ( attachment . Type )
// 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
// Stream video/audio to avoid loading entire file into memory.
if isMediaType ( attachment . Type ) {
return s . serveMediaStream ( c , attachment , contentType )
}
// 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,372 +164,461 @@ 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 } )
}
// 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" )
}
imageType := matches [ 1 ]
base64Data := matches [ 2 ]
return imageType , base64Data , nil
}
// 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 )
// 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 {
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
}
// Check memo visibility
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 )
}
if memo == nil {
return echo . NewHTTPError ( http . StatusNotFound , "memo not found" )
}
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 )
// Public memos are accessible to everyone
if memo . Visibility == store . Public {
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
}
}
// For non-public memos, check authentication
user , err := s . getCurrentUser ( ctx , c )
// 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 current user ") . SetInternal ( err )
return echo . NewHTTPError ( http . StatusInternalServerError , "failed to get attachment blob ") . SetInternal ( err )
}
if user == nil {
return echo . NewHTTPError ( http . StatusUnauthorized , "unauthorized access" )
// 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
}
}
// 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" )
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 nil
return c . Blob ( http . StatusOK , contentType , blob )
}
// getCurrentUser retrieves the current authenticated user from the Echo context.
// 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
}
}
}
// =============================================================================
// Storage Operations
// =============================================================================
// Try PAT
if strings . HasPrefix ( token , auth . PersonalAccessTokenPrefix ) {
user , _ , err := s . authenticator . AuthenticateByPAT ( ctx , token )
if 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
}
}
}
// 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 )
// No valid authentication found
return nil , nil
}
case storepb . AttachmentStorageType_S3 :
return s . downloadFromS3 ( attachment )
// 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 ,
default :
return attachment . Blob , nil
}
return supportedTypes [ mimeType ]
}
// getAttachmentReader returns a reader for the attachment content.
// getAttachmentReader returns a reader for streaming 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 . FromSlas h( attachment . Reference )
if ! filepath . IsAbs ( attachmentPath ) {
attachmentPath = filepath . Join ( s . Profile . Data , attachmentPath )
switch attachment . StorageType {
case storepb . AttachmentStorageType_LOCAL :
filePath , err := s . resolveLocalPath ( attachment . Reference )
if err != nil {
return nil , err
}
file , err := os . Open ( attachmentPath )
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 the file")
return nil , errors . Wrap ( err , "failed to open file")
}
return file , 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 )
case storepb . AttachmentStorageType_S3 :
s3Client , s3Object , err := s . createS3Client ( attachment )
if err != nil {
return nil , err ors. Wrap ( err , "failed to create S3 client" )
return nil , err
}
reader , err := s3Client . GetObjectStream ( context . Background ( ) , s3Object . Key )
if err != nil {
return nil , errors . Wrap ( err , "failed to get object from S3")
return nil , errors . Wrap ( err , "failed to stream from S3" )
}
return reader , nil
default :
return io . NopCloser ( bytes . NewReader ( attachment . Blob ) ) , nil
}
// For database storage, return the blob from the database.
return io . NopCloser ( bytes . NewReader ( attachment . Blob ) ) , nil
}
// 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 )
}
// 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
}
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
// 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
}
// 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" )
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 := s3Client . GetObject ( context . Background ( ) , s3Object . Key )
if err != nil {
return nil , errors . Wrap ( err , "failed to get object from S3" )
}
return blob , nil
blob , err := io . ReadAll ( file )
if err != nil {
return nil , errors . Wrap ( err , "failed to read file" )
}
// For database storage, return the blob from the database.
return attachment . Blob , nil
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 ) {
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" )
thumbnail Path, err := s . getThumbnailPath ( attachment )
if err != nil {
return nil , err
}
filePath := filepath . Join ( thumbnailCacheFolder , fmt . Sprintf ( "%d%s" , attachment . ID , filepath . Ext ( attachment . Filename ) ) )
// 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" )
}
// Fast path: return cached thumbnail if exists.
if blob , err := s . readCachedThumbnail ( thumbnailPath ) ; err == nil {
return blob , nil
} else if ! os . IsNotExist ( err ) {
return nil , errors . Wrap ( err , "failed to check thumbnail image stat" )
}
// Thumbnail doesn't exist, acquire semaphore to limit concurrent generation
// 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" )
return nil , errors . Wrap ( err , "failed to acquire semaphore" )
}
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" )
}
// Double-check after acquiring semaphore (another goroutine may have generated it).
if blob , err := s . readCachedThumbnail ( thumbnailPath ) ; err == nil {
return blob , nil
}
// Generate the thumbnail
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 ( )
// 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" )
return nil , errors . Wrap ( err , "failed to decode 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
width , height := img . Bounds ( ) . Dx ( ) , img . Bounds ( ) . Dy ( )
thumbnailWidth , thumbnailHeight := calculateThumbnailDimensions ( width , height )
// 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
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" )
}
// Resize the image to the calculated dimensions.
thumbnailImage := imaging . Resize ( img , thumbnailWidth , thumbnailHeight , imaging . Lanczos )
return s . readCachedThumbnail ( thumbnailPath )
}
// Save thumbnail to disk
if err := imaging . Save ( thumbnailImage , filePath ) ; err != nil {
return nil , errors . Wrap ( err , "failed to save thumbnail file" )
// 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
// =============================================================================
// Read the saved thumbnail and return it
thumbnailFile , err := os . Open ( filePath )
// 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 . MemoID == nil {
return nil // Unlinked attachments are accessible.
}
memo , err := s . Store . GetMemo ( ctx , & store . FindMemo { ID : attachment . MemoID } )
if err != nil {
return nil , errors . Wrap ( err , "failed to open thumbnail file" )
return echo . NewHTTPError ( http . StatusInternalServerError , "failed to find memo" ) . SetInternal ( err )
}
if memo == nil {
return echo . NewHTTPError ( http . StatusNotFound , "memo not found" )
}
if memo . Visibility == store . Public {
return nil
}
defer thumbnailFile . Close ( )
thumbnailBlob , err := io . ReadAll ( thumbnailFile )
user, err := s . getCurrentUser ( ctx , c )
if err != nil {
return nil , errors . Wrap ( err , "failed to read thumbnail file" )
return echo . NewHTTPError ( http . StatusInternalServerError , "failed to get current user" ) . SetInternal ( err )
}
if user == nil {
return echo . NewHTTPError ( http . StatusUnauthorized , "unauthorized access" )
}
if memo . Visibility == store . Private && user . ID != attachment . CreatorID {
return echo . NewHTTPError ( http . StatusForbidden , "forbidden access" )
}
return nil
}
// getCurrentUser retrieves the current authenticated user from the request.
// Authentication priority: Bearer token (Access Token V2 or PAT) > Refresh token cookie.
func ( s * FileServerService ) getCurrentUser ( ctx context . Context , c echo . Context ) ( * store . User , error ) {
// 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.
if cookieHeader := c . Request ( ) . Header . Get ( "Cookie" ) ; cookieHeader != "" {
if user , err := s . authenticateByRefreshToken ( ctx , cookieHeader ) ; err == nil && user != nil {
return user , nil
}
}
return nil , nil
}
// 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
}
// 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
}
// 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
}
user , _ , err := s . authenticator . AuthenticateByRefreshToken ( ctx , refreshToken )
return user , err
}
// 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 } )
}
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 {
contentType := mimeType
if strings . HasPrefix ( contentType , "text/" ) {
contentType += "; charset=utf-8"
}
// 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" )
}
return thumbnailBlob , nil
}