diff --git a/proto/api/v1/workspace_service.proto b/proto/api/v1/workspace_service.proto index fc30aee2a4ae1..c23b4987c4081 100644 --- a/proto/api/v1/workspace_service.proto +++ b/proto/api/v1/workspace_service.proto @@ -147,6 +147,16 @@ message WorkspaceSetting { } // The S3 config. S3Config s3_config = 4; + // The maximum size in pixels for the largest dimension of thumbnail images. + int32 thumbnail_max_size = 5; + // The JPEG quality (0-100) used when downscaling uploaded images. + int32 jpeg_quality = 6; + // The JPEG quality (0-100) used when generating thumbnails. + int32 thumbnail_jpeg_quality = 7; + // The maximum size in pixels for the largest dimension when storing images. + // Images larger than this will be downscaled before storage. + // Set to 0 to disable downscaling. + int32 image_max_size = 8; } // Memo-related workspace settings and policies. diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 2067416b62973..332ec9387fdbe 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -428,7 +428,6 @@ type GetUserRequest struct { // Supports both numeric IDs and username strings: // - users/{id} (e.g., users/101) // - users/{username} (e.g., users/steven) - // // Format: users/{id_or_username} Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Optional. The fields to return in the response. diff --git a/proto/gen/api/v1/workspace_service.pb.go b/proto/gen/api/v1/workspace_service.pb.go index e1e9ef4a02827..8638aa850ef06 100644 --- a/proto/gen/api/v1/workspace_service.pb.go +++ b/proto/gen/api/v1/workspace_service.pb.go @@ -589,7 +589,17 @@ type WorkspaceSetting_StorageSetting struct { // The max upload size in megabytes. UploadSizeLimitMb int64 `protobuf:"varint,3,opt,name=upload_size_limit_mb,json=uploadSizeLimitMb,proto3" json:"upload_size_limit_mb,omitempty"` // The S3 config. - S3Config *WorkspaceSetting_StorageSetting_S3Config `protobuf:"bytes,4,opt,name=s3_config,json=s3Config,proto3" json:"s3_config,omitempty"` + S3Config *WorkspaceSetting_StorageSetting_S3Config `protobuf:"bytes,4,opt,name=s3_config,json=s3Config,proto3" json:"s3_config,omitempty"` + // The maximum size in pixels for the largest dimension of thumbnail images. + ThumbnailMaxSize int32 `protobuf:"varint,5,opt,name=thumbnail_max_size,json=thumbnailMaxSize,proto3" json:"thumbnail_max_size,omitempty"` + // The JPEG quality (0-100) used when downscaling uploaded images. + JpegQuality int32 `protobuf:"varint,6,opt,name=jpeg_quality,json=jpegQuality,proto3" json:"jpeg_quality,omitempty"` + // The JPEG quality (0-100) used when generating thumbnails. + ThumbnailJpegQuality int32 `protobuf:"varint,7,opt,name=thumbnail_jpeg_quality,json=thumbnailJpegQuality,proto3" json:"thumbnail_jpeg_quality,omitempty"` + // The maximum size in pixels for the largest dimension when storing images. + // Images larger than this will be downscaled before storage. + // Set to 0 to disable downscaling. + ImageMaxSize int32 `protobuf:"varint,8,opt,name=image_max_size,json=imageMaxSize,proto3" json:"image_max_size,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -652,6 +662,34 @@ func (x *WorkspaceSetting_StorageSetting) GetS3Config() *WorkspaceSetting_Storag return nil } +func (x *WorkspaceSetting_StorageSetting) GetThumbnailMaxSize() int32 { + if x != nil { + return x.ThumbnailMaxSize + } + return 0 +} + +func (x *WorkspaceSetting_StorageSetting) GetJpegQuality() int32 { + if x != nil { + return x.JpegQuality + } + return 0 +} + +func (x *WorkspaceSetting_StorageSetting) GetThumbnailJpegQuality() int32 { + if x != nil { + return x.ThumbnailJpegQuality + } + return 0 +} + +func (x *WorkspaceSetting_StorageSetting) GetImageMaxSize() int32 { + if x != nil { + return x.ImageMaxSize + } + return 0 +} + // Memo-related workspace settings and policies. type WorkspaceSetting_MemoRelatedSetting struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -935,7 +973,7 @@ const file_api_v1_workspace_service_proto_rawDesc = "" + "\aversion\x18\x02 \x01(\tR\aversion\x12\x12\n" + "\x04mode\x18\x03 \x01(\tR\x04mode\x12!\n" + "\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\"\x1c\n" + - "\x1aGetWorkspaceProfileRequest\"\x97\x11\n" + + "\x1aGetWorkspaceProfileRequest\"\xc4\x12\n" + "\x10WorkspaceSetting\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12X\n" + "\x0fgeneral_setting\x18\x02 \x01(\v2-.memos.api.v1.WorkspaceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12X\n" + @@ -955,12 +993,16 @@ const file_api_v1_workspace_service_proto_rawDesc = "" + "\x05title\x18\x01 \x01(\tR\x05title\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + "\blogo_url\x18\x03 \x01(\tR\alogoUrl\x12\x16\n" + - "\x06locale\x18\x04 \x01(\tR\x06locale\x1a\xbe\x04\n" + + "\x06locale\x18\x04 \x01(\tR\x06locale\x1a\xeb\x05\n" + "\x0eStorageSetting\x12\\\n" + "\fstorage_type\x18\x01 \x01(\x0e29.memos.api.v1.WorkspaceSetting.StorageSetting.StorageTypeR\vstorageType\x12+\n" + "\x11filepath_template\x18\x02 \x01(\tR\x10filepathTemplate\x12/\n" + "\x14upload_size_limit_mb\x18\x03 \x01(\x03R\x11uploadSizeLimitMb\x12S\n" + - "\ts3_config\x18\x04 \x01(\v26.memos.api.v1.WorkspaceSetting.StorageSetting.S3ConfigR\bs3Config\x1a\xcc\x01\n" + + "\ts3_config\x18\x04 \x01(\v26.memos.api.v1.WorkspaceSetting.StorageSetting.S3ConfigR\bs3Config\x12,\n" + + "\x12thumbnail_max_size\x18\x05 \x01(\x05R\x10thumbnailMaxSize\x12!\n" + + "\fjpeg_quality\x18\x06 \x01(\x05R\vjpegQuality\x124\n" + + "\x16thumbnail_jpeg_quality\x18\a \x01(\x05R\x14thumbnailJpegQuality\x12$\n" + + "\x0eimage_max_size\x18\b \x01(\x05R\fimageMaxSize\x1a\xcc\x01\n" + "\bS3Config\x12\"\n" + "\raccess_key_id\x18\x01 \x01(\tR\vaccessKeyId\x12*\n" + "\x11access_key_secret\x18\x02 \x01(\tR\x0faccessKeySecret\x12\x1a\n" + diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 25ecf23f3eb5c..14816baa5acc4 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -15,19 +15,13 @@ paths: parameters: - name: pageSize in: query - description: |- - The maximum number of activities to return. - The service may return fewer than this value. - If unspecified, at most 100 activities will be returned. - The maximum value is 1000; values above 1000 will be coerced to 1000. + description: "The maximum number of activities to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 100 activities will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000." schema: type: integer format: int32 - name: pageToken in: query - description: |- - A page token, received from a previous `ListActivities` call. - Provide this to retrieve the subsequent page. + description: "A page token, received from a previous `ListActivities` call.\r\n Provide this to retrieve the subsequent page." schema: type: string responses: @@ -78,35 +72,23 @@ paths: parameters: - name: pageSize in: query - description: |- - Optional. The maximum number of attachments to return. - The service may return fewer than this value. - If unspecified, at most 50 attachments will be returned. - The maximum value is 1000; values above 1000 will be coerced to 1000. + description: "Optional. The maximum number of attachments to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 attachments will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000." schema: type: integer format: int32 - name: pageToken in: query - description: |- - Optional. A page token, received from a previous `ListAttachments` call. - Provide this to retrieve the subsequent page. + description: "Optional. A page token, received from a previous `ListAttachments` call.\r\n Provide this to retrieve the subsequent page." schema: type: string - name: filter in: query - description: |- - Optional. Filter to apply to the list results. - Example: "type=image/png" or "filename:*.jpg" - Supported operators: =, !=, <, <=, >, >=, : - Supported fields: filename, type, size, create_time, memo + description: "Optional. Filter to apply to the list results.\r\n Example: \"type=image/png\" or \"filename:*.jpg\"\r\n Supported operators: =, !=, <, <=, >, >=, :\r\n Supported fields: filename, type, size, create_time, memo" schema: type: string - name: orderBy in: query - description: |- - Optional. The order to sort results by. - Example: "create_time desc" or "filename asc" + description: "Optional. The order to sort results by.\r\n Example: \"create_time desc\" or \"filename asc\"" schema: type: string responses: @@ -130,9 +112,7 @@ paths: parameters: - name: attachmentId in: query - description: |- - Optional. The attachment ID to use for this attachment. - If empty, a unique ID will be generated. + description: "Optional. The attachment ID to use for this attachment.\r\n If empty, a unique ID will be generated." schema: type: string requestBody: @@ -243,9 +223,7 @@ paths: post: tags: - AuthService - description: |- - CreateSession authenticates a user and creates a new session. - Returns the authenticated user information upon successful authentication. + description: "CreateSession authenticates a user and creates a new session.\r\n Returns the authenticated user information upon successful authentication." operationId: AuthService_CreateSession requestBody: content: @@ -270,9 +248,7 @@ paths: get: tags: - AuthService - description: |- - GetCurrentSession returns the current active session information. - This method is idempotent and safe, suitable for checking current session state. + description: "GetCurrentSession returns the current active session information.\r\n This method is idempotent and safe, suitable for checking current session state." operationId: AuthService_GetCurrentSession responses: "200": @@ -290,9 +266,7 @@ paths: delete: tags: - AuthService - description: |- - DeleteSession terminates the current user session. - This is an idempotent operation that invalidates the user's authentication. + description: "DeleteSession terminates the current user session.\r\n This is an idempotent operation that invalidates the user's authentication." operationId: AuthService_DeleteSession responses: "200": @@ -331,9 +305,7 @@ paths: parameters: - name: identityProviderId in: query - description: |- - Optional. The ID to use for the identity provider, which will become the final component of the resource name. - If not provided, the system will generate one. + description: "Optional. The ID to use for the identity provider, which will become the final component of the resource name.\r\n If not provided, the system will generate one." schema: type: string requestBody: @@ -417,9 +389,7 @@ paths: type: string - name: updateMask in: query - description: |- - Required. The update mask applies to the resource. Only the top level fields of - IdentityProvider are supported. + description: "Required. The update mask applies to the resource. Only the top level fields of\r\n IdentityProvider are supported." schema: type: string format: field-mask @@ -511,9 +481,7 @@ paths: get: tags: - MarkdownService - description: |- - GetLinkMetadata returns metadata for a given link. - This is useful for generating link previews. + description: "GetLinkMetadata returns metadata for a given link.\r\n This is useful for generating link previews." operationId: MarkdownService_GetLinkMetadata parameters: - name: link @@ -538,9 +506,7 @@ paths: post: tags: - MarkdownService - description: |- - ParseMarkdown parses the given markdown content and returns a list of nodes. - This is a utility method that transforms markdown text into structured nodes. + description: "ParseMarkdown parses the given markdown content and returns a list of nodes.\r\n This is a utility method that transforms markdown text into structured nodes." operationId: MarkdownService_ParseMarkdown requestBody: content: @@ -565,9 +531,7 @@ paths: post: tags: - MarkdownService - description: |- - RestoreMarkdownNodes restores the given nodes to markdown content. - This is the inverse operation of ParseMarkdown. + description: "RestoreMarkdownNodes restores the given nodes to markdown content.\r\n This is the inverse operation of ParseMarkdown." operationId: MarkdownService_RestoreMarkdownNodes requestBody: content: @@ -592,9 +556,7 @@ paths: post: tags: - MarkdownService - description: |- - StringifyMarkdownNodes stringify the given nodes to plain text content. - This removes all markdown formatting and returns plain text. + description: "StringifyMarkdownNodes stringify the given nodes to plain text content.\r\n This removes all markdown formatting and returns plain text." operationId: MarkdownService_StringifyMarkdownNodes requestBody: content: @@ -624,26 +586,18 @@ paths: parameters: - name: pageSize in: query - description: |- - Optional. The maximum number of memos to return. - The service may return fewer than this value. - If unspecified, at most 50 memos will be returned. - The maximum value is 1000; values above 1000 will be coerced to 1000. + description: "Optional. The maximum number of memos to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 memos will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000." schema: type: integer format: int32 - name: pageToken in: query - description: |- - Optional. A page token, received from a previous `ListMemos` call. - Provide this to retrieve the subsequent page. + description: "Optional. A page token, received from a previous `ListMemos` call.\r\n Provide this to retrieve the subsequent page." schema: type: string - name: state in: query - description: |- - Optional. The state of the memos to list. - Default to `NORMAL`. Set to `ARCHIVED` to list archived memos. + description: "Optional. The state of the memos to list.\r\n Default to `NORMAL`. Set to `ARCHIVED` to list archived memos." schema: enum: - STATE_UNSPECIFIED @@ -653,20 +607,12 @@ paths: format: enum - name: orderBy in: query - description: |- - Optional. The order to sort results by. - Default to "display_time desc". - Supports comma-separated list of fields following AIP-132. - Example: "pinned desc, display_time desc" or "create_time asc" - Supported fields: pinned, display_time, create_time, update_time, name + description: "Optional. The order to sort results by.\r\n Default to \"display_time desc\".\r\n Supports comma-separated list of fields following AIP-132.\r\n Example: \"pinned desc, display_time desc\" or \"create_time asc\"\r\n Supported fields: pinned, display_time, create_time, update_time, name" schema: type: string - name: filter in: query - description: |- - Optional. Filter to apply to the list results. - Filter is a CEL expression to filter memos. - Refer to `Shortcut.filter`. + description: "Optional. Filter to apply to the list results.\r\n Filter is a CEL expression to filter memos.\r\n Refer to `Shortcut.filter`." schema: type: string - name: showDeleted @@ -695,9 +641,7 @@ paths: parameters: - name: memoId in: query - description: |- - Optional. The memo ID to use for this memo. - If empty, a unique ID will be generated. + description: "Optional. The memo ID to use for this memo.\r\n If empty, a unique ID will be generated." schema: type: string - name: validateOnly @@ -744,9 +688,7 @@ paths: type: string - name: readMask in: query - description: |- - Optional. The fields to return in the response. - If not specified, all fields are returned. + description: "Optional. The fields to return in the response.\r\n If not specified, all fields are returned." schema: type: string format: field-mask @@ -1198,28 +1140,18 @@ paths: parameters: - name: pageSize in: query - description: |- - Optional. The maximum number of users to return. - The service may return fewer than this value. - If unspecified, at most 50 users will be returned. - The maximum value is 1000; values above 1000 will be coerced to 1000. + description: "Optional. The maximum number of users to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 users will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000." schema: type: integer format: int32 - name: pageToken in: query - description: |- - Optional. A page token, received from a previous `ListUsers` call. - Provide this to retrieve the subsequent page. + description: "Optional. A page token, received from a previous `ListUsers` call.\r\n Provide this to retrieve the subsequent page." schema: type: string - name: filter in: query - description: |- - Optional. Filter to apply to the list results. - Example: "username == 'steven'" - Supported operators: == - Supported fields: username + description: "Optional. Filter to apply to the list results.\r\n Example: \"username == 'steven'\"\r\n Supported operators: ==\r\n Supported fields: username" schema: type: string - name: showDeleted @@ -1248,10 +1180,7 @@ paths: parameters: - name: userId in: query - description: |- - Optional. The user ID to use for this user. - If empty, a unique ID will be generated. - Must match the pattern [a-z0-9-]+ + description: "Optional. The user ID to use for this user.\r\n If empty, a unique ID will be generated.\r\n Must match the pattern [a-z0-9-]+" schema: type: string - name: validateOnly @@ -1261,9 +1190,7 @@ paths: type: boolean - name: requestId in: query - description: |- - Optional. An idempotency token that can be used to ensure that multiple - requests to create a user have the same result. + description: "Optional. An idempotency token that can be used to ensure that multiple\r\n requests to create a user have the same result." schema: type: string requestBody: @@ -1289,11 +1216,7 @@ paths: get: tags: - UserService - description: |- - GetUser gets a user by ID or username. - Supports both numeric IDs and username strings: - - users/{id} (e.g., users/101) - - users/{username} (e.g., users/steven) + description: "GetUser gets a user by ID or username.\r\n Supports both numeric IDs and username strings:\r\n - users/{id} (e.g., users/101)\r\n - users/{username} (e.g., users/steven)" operationId: UserService_GetUser parameters: - name: user @@ -1304,9 +1227,7 @@ paths: type: string - name: readMask in: query - description: |- - Optional. The fields to return in the response. - If not specified, all fields are returned. + description: "Optional. The fields to return in the response.\r\n If not specified, all fields are returned." schema: type: string format: field-mask @@ -1533,35 +1454,23 @@ paths: type: string - name: pageSize in: query - description: |- - Optional. The maximum number of inboxes to return. - The service may return fewer than this value. - If unspecified, at most 50 inboxes will be returned. - The maximum value is 1000; values above 1000 will be coerced to 1000. + description: "Optional. The maximum number of inboxes to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 inboxes will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000." schema: type: integer format: int32 - name: pageToken in: query - description: |- - Optional. A page token, received from a previous `ListInboxes` call. - Provide this to retrieve the subsequent page. + description: "Optional. A page token, received from a previous `ListInboxes` call.\r\n Provide this to retrieve the subsequent page." schema: type: string - name: filter in: query - description: |- - Optional. Filter to apply to the list results. - Example: "status=UNREAD" or "type=MEMO_COMMENT" - Supported operators: =, != - Supported fields: status, type, sender, create_time + description: "Optional. Filter to apply to the list results.\r\n Example: \"status=UNREAD\" or \"type=MEMO_COMMENT\"\r\n Supported operators: =, !=\r\n Supported fields: status, type, sender, create_time" schema: type: string - name: orderBy in: query - description: |- - Optional. The order to sort results by. - Example: "create_time desc" or "status asc" + description: "Optional. The order to sort results by.\r\n Example: \"create_time desc\" or \"status asc\"" schema: type: string responses: @@ -1647,19 +1556,13 @@ paths: type: string - name: pageSize in: query - description: |- - Optional. The maximum number of settings to return. - The service may return fewer than this value. - If unspecified, at most 50 settings will be returned. - The maximum value is 1000; values above 1000 will be coerced to 1000. + description: "Optional. The maximum number of settings to return.\r\n The service may return fewer than this value.\r\n If unspecified, at most 50 settings will be returned.\r\n The maximum value is 1000; values above 1000 will be coerced to 1000." schema: type: integer format: int32 - name: pageToken in: query - description: |- - Optional. A page token, received from a previous `ListUserSettings` call. - Provide this to retrieve the subsequent page. + description: "Optional. A page token, received from a previous `ListUserSettings` call.\r\n Provide this to retrieve the subsequent page." schema: type: string responses: @@ -2214,15 +2117,11 @@ components: name: readOnly: true type: string - description: |- - The name of the activity. - Format: activities/{id} + description: "The name of the activity.\r\n Format: activities/{id}" creator: readOnly: true type: string - description: |- - The name of the creator. - Format: users/{user} + description: "The name of the creator.\r\n Format: users/{user}" type: readOnly: true enum: @@ -2257,14 +2156,10 @@ components: properties: memo: type: string - description: |- - The memo name of comment. - Format: memos/{memo} + description: "The memo name of comment.\r\n Format: memos/{memo}" relatedMemo: type: string - description: |- - The name of related memo. - Format: memos/{memo} + description: "The name of related memo.\r\n Format: memos/{memo}" description: ActivityMemoCommentPayload represents the payload of a memo comment activity. ActivityPayload: type: object @@ -2281,9 +2176,7 @@ components: properties: name: type: string - description: |- - The name of the attachment. - Format: attachments/{attachment} + description: "The name of the attachment.\r\n Format: attachments/{attachment}" createTime: readOnly: true type: string @@ -2309,9 +2202,7 @@ components: description: Output only. The size of the attachment in bytes. memo: type: string - description: |- - Optional. The related memo. Refer to `Memo.name`. - Format: memos/{memo} + description: "Optional. The related memo. Refer to `Memo.name`.\r\n Format: memos/{memo}" AutoLinkNode: type: object properties: @@ -2373,14 +2264,10 @@ components: properties: username: type: string - description: |- - The username to sign in with. - Required field for password-based authentication. + description: "The username to sign in with.\r\n Required field for password-based authentication." password: type: string - description: |- - The password to sign in with. - Required field for password-based authentication. + description: "The password to sign in with.\r\n Required field for password-based authentication." description: Nested message for password-based authentication credentials. CreateSessionRequest_SSOCredentials: required: @@ -2391,20 +2278,14 @@ components: properties: idpId: type: integer - description: |- - The ID of the SSO provider. - Required field to identify the SSO provider. + description: "The ID of the SSO provider.\r\n Required field to identify the SSO provider." format: int32 code: type: string - description: |- - The authorization code from the SSO provider. - Required field for completing the SSO flow. + description: "The authorization code from the SSO provider.\r\n Required field for completing the SSO flow." redirectUri: type: string - description: |- - The redirect URI used in the SSO flow. - Required field for security validation. + description: "The redirect URI used in the SSO flow.\r\n Required field for security validation." description: Nested message for SSO authentication credentials. CreateSessionResponse: type: object @@ -2415,9 +2296,7 @@ components: description: The authenticated user information. lastAccessedAt: type: string - description: |- - Last time the session was accessed. - Used for sliding expiration calculation (last_accessed_time + 2 weeks). + description: "Last time the session was accessed.\r\n Used for sliding expiration calculation (last_accessed_time + 2 weeks)." format: date-time DeleteMemoTagRequest: required: @@ -2427,9 +2306,7 @@ components: properties: parent: type: string - description: |- - Required. The parent, who owns the tags. - Format: memos/{memo}. Use "memos/-" to delete all tags. + description: "Required. The parent, who owns the tags.\r\n Format: memos/{memo}. Use \"memos/-\" to delete all tags." tag: type: string description: Required. The tag name to delete. @@ -2480,9 +2357,7 @@ components: $ref: '#/components/schemas/User' lastAccessedAt: type: string - description: |- - Last time the session was accessed. - Used for sliding expiration calculation (last_accessed_time + 2 weeks). + description: "Last time the session was accessed.\r\n Used for sliding expiration calculation (last_accessed_time + 2 weeks)." format: date-time GoogleProtobufAny: type: object @@ -2536,9 +2411,7 @@ components: properties: name: type: string - description: |- - The resource name of the identity provider. - Format: identityProviders/{idp} + description: "The resource name of the identity provider.\r\n Format: identityProviders/{idp}" type: enum: - TYPE_UNSPECIFIED @@ -2573,21 +2446,15 @@ components: properties: name: type: string - description: |- - The resource name of the inbox. - Format: inboxes/{inbox} + description: "The resource name of the inbox.\r\n Format: inboxes/{inbox}" sender: readOnly: true type: string - description: |- - The sender of the inbox notification. - Format: users/{user} + description: "The sender of the inbox notification.\r\n Format: users/{user}" receiver: readOnly: true type: string - description: |- - The receiver of the inbox notification. - Format: users/{user} + description: "The receiver of the inbox notification.\r\n Format: users/{user}" status: enum: - STATUS_UNSPECIFIED @@ -2657,10 +2524,7 @@ components: description: The activities. nextPageToken: type: string - description: |- - A token to retrieve the next page of results. - Pass this value in the page_token field in the subsequent call to `ListActivities` - method to retrieve the next page of results. + description: "A token to retrieve the next page of results.\r\n Pass this value in the page_token field in the subsequent call to `ListActivities`\r\n method to retrieve the next page of results." ListAllUserStatsResponse: type: object properties: @@ -2679,9 +2543,7 @@ components: description: The list of attachments. nextPageToken: type: string - description: |- - A token that can be sent as `page_token` to retrieve the next page. - If this field is omitted, there are no subsequent pages. + description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages." totalSize: type: integer description: The total count of attachments (may be approximate). @@ -2704,9 +2566,7 @@ components: description: The list of inboxes. nextPageToken: type: string - description: |- - A token that can be sent as `page_token` to retrieve the next page. - If this field is omitted, there are no subsequent pages. + description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages." totalSize: type: integer description: The total count of inboxes (may be approximate). @@ -2781,9 +2641,7 @@ components: description: The list of memos. nextPageToken: type: string - description: |- - A token that can be sent as `page_token` to retrieve the next page. - If this field is omitted, there are no subsequent pages. + description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages." totalSize: type: integer description: The total count of memos (may be approximate). @@ -2847,9 +2705,7 @@ components: description: The list of user settings. nextPageToken: type: string - description: |- - A token that can be sent as `page_token` to retrieve the next page. - If this field is omitted, there are no subsequent pages. + description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages." totalSize: type: integer description: The total count of settings (may be approximate). @@ -2873,9 +2729,7 @@ components: description: The list of users. nextPageToken: type: string - description: |- - A token that can be sent as `page_token` to retrieve the next page. - If this field is omitted, there are no subsequent pages. + description: "A token that can be sent as `page_token` to retrieve the next page.\r\n If this field is omitted, there are no subsequent pages." totalSize: type: integer description: The total count of users (may be approximate). @@ -2913,9 +2767,7 @@ components: properties: name: type: string - description: |- - The resource name of the memo. - Format: memos/{memo}, memo is the user defined id or uuid. + description: "The resource name of the memo.\r\n Format: memos/{memo}, memo is the user defined id or uuid." state: enum: - STATE_UNSPECIFIED @@ -2927,9 +2779,7 @@ components: creator: readOnly: true type: string - description: |- - The name of the creator. - Format: users/{user} + description: "The name of the creator.\r\n Format: users/{user}" createTime: readOnly: true type: string @@ -2995,9 +2845,7 @@ components: parent: readOnly: true type: string - description: |- - Output only. The name of the parent memo. - Format: memos/{memo} + description: "Output only. The name of the parent memo.\r\n Format: memos/{memo}" snippet: readOnly: true type: string @@ -3035,9 +2883,7 @@ components: properties: name: type: string - description: |- - The resource name of the memo. - Format: memos/{memo} + description: "The resource name of the memo.\r\n Format: memos/{memo}" snippet: readOnly: true type: string @@ -3223,21 +3069,14 @@ components: name: readOnly: true type: string - description: |- - The resource name of the reaction. - Format: reactions/{reaction} + description: "The resource name of the reaction.\r\n Format: reactions/{reaction}" creator: readOnly: true type: string - description: |- - The resource name of the creator. - Format: users/{user} + description: "The resource name of the creator.\r\n Format: users/{user}" contentId: type: string - description: |- - The resource name of the content. - For memo reactions, this should be the memo's resource name. - Format: memos/{memo} + description: "The resource name of the content.\r\n For memo reactions, this should be the memo's resource name.\r\n Format: memos/{memo}" reactionType: type: string description: "Required. The type of reaction (e.g., \"\U0001F44D\", \"❤️\", \"\U0001F604\")." @@ -3264,9 +3103,7 @@ components: properties: parent: type: string - description: |- - Required. The parent, who owns the tags. - Format: memos/{memo}. Use "memos/-" to rename all tags. + description: "Required. The parent, who owns the tags.\r\n Format: memos/{memo}. Use \"memos/-\" to rename all tags." oldTag: type: string description: Required. The old tag name to rename. @@ -3297,9 +3134,7 @@ components: properties: name: type: string - description: |- - Required. The resource name of the memo. - Format: memos/{memo} + description: "Required. The resource name of the memo.\r\n Format: memos/{memo}" attachments: type: array items: @@ -3313,9 +3148,7 @@ components: properties: name: type: string - description: |- - Required. The resource name of the memo. - Format: memos/{memo} + description: "Required. The resource name of the memo.\r\n Format: memos/{memo}" relations: type: array items: @@ -3328,9 +3161,7 @@ components: properties: name: type: string - description: |- - The resource name of the shortcut. - Format: users/{user}/shortcuts/{shortcut} + description: "The resource name of the shortcut.\r\n Format: users/{user}/shortcuts/{shortcut}" title: type: string description: The title of the shortcut. @@ -3373,9 +3204,7 @@ components: type: string usePathStyle: type: boolean - description: |- - S3 configuration for cloud storage backend. - Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/ + description: "S3 configuration for cloud storage backend.\r\n Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/" StrikethroughNode: type: object properties: @@ -3473,9 +3302,7 @@ components: properties: name: type: string - description: |- - Required. The resource name of the memo. - Format: memos/{memo} + description: "Required. The resource name of the memo.\r\n Format: memos/{memo}" reaction: allOf: - $ref: '#/components/schemas/Reaction' @@ -3489,9 +3316,7 @@ components: properties: name: type: string - description: |- - The resource name of the user. - Format: users/{user} + description: "The resource name of the user.\r\n Format: users/{user}" role: enum: - ROLE_UNSPECIFIED @@ -3543,9 +3368,7 @@ components: properties: name: type: string - description: |- - The resource name of the access token. - Format: users/{user}/accessTokens/{access_token} + description: "The resource name of the access token.\r\n Format: users/{user}/accessTokens/{access_token}" accessToken: readOnly: true type: string @@ -3568,9 +3391,7 @@ components: properties: name: type: string - description: |- - The resource name of the session. - Format: users/{user}/sessions/{session} + description: "The resource name of the session.\r\n Format: users/{user}/sessions/{session}" sessionId: readOnly: true type: string @@ -3583,9 +3404,7 @@ components: lastAccessedTime: readOnly: true type: string - description: |- - The timestamp when the session was last accessed. - Used for sliding expiration calculation (last_accessed_time + 2 weeks). + description: "The timestamp when the session was last accessed.\r\n Used for sliding expiration calculation (last_accessed_time + 2 weeks)." format: date-time clientInfo: readOnly: true @@ -3615,10 +3434,7 @@ components: properties: name: type: string - description: |- - The name of the user setting. - Format: users/{user}/settings/{setting}, {setting} is the key for the setting. - For example, "users/123/settings/GENERAL" for general settings. + description: "The name of the user setting.\r\n Format: users/{user}/settings/{setting}, {setting} is the key for the setting.\r\n For example, \"users/123/settings/GENERAL\" for general settings." generalSetting: $ref: '#/components/schemas/UserSetting_GeneralSetting' sessionsSetting: @@ -3648,10 +3464,7 @@ components: description: The default visibility of the memo. theme: type: string - description: |- - The preferred theme of the user. - This references a CSS file in the web/public/themes/ directory. - If not set, the default theme will be used. + description: "The preferred theme of the user.\r\n This references a CSS file in the web/public/themes/ directory.\r\n If not set, the default theme will be used." description: General user settings configuration. UserSetting_SessionsSetting: type: object @@ -3676,9 +3489,7 @@ components: properties: name: type: string - description: |- - The resource name of the user whose stats these are. - Format: users/{user} + description: "The resource name of the user whose stats these are.\r\n Format: users/{user}" memoDisplayTimestamps: type: array items: @@ -3726,9 +3537,7 @@ components: properties: name: type: string - description: |- - The name of the webhook. - Format: users/{user}/webhooks/{webhook} + description: "The name of the webhook.\r\n Format: users/{user}/webhooks/{webhook}" url: type: string description: The URL to send the webhook to. @@ -3751,9 +3560,7 @@ components: properties: owner: type: string - description: |- - The name of instance owner. - Format: users/{user} + description: "The name of instance owner.\r\n Format: users/{user}" version: type: string description: Version is the current version of instance. @@ -3769,9 +3576,7 @@ components: properties: name: type: string - description: |- - The name of the workspace setting. - Format: workspace/settings/{setting} + description: "The name of the workspace setting.\r\n Format: workspace/settings/{setting}" generalSetting: $ref: '#/components/schemas/WorkspaceSetting_GeneralSetting' storageSetting: @@ -3784,9 +3589,7 @@ components: properties: theme: type: string - description: |- - theme is the name of the selected theme. - This references a CSS file in the web/public/themes/ directory. + description: "theme is the name of the selected theme.\r\n This references a CSS file in the web/public/themes/ directory." disallowUserRegistration: type: boolean description: disallow_user_registration disallows user registration. @@ -3805,10 +3608,7 @@ components: description: custom_profile is the custom profile. weekStartDayOffset: type: integer - description: |- - week_start_day_offset is the week start day offset from Sunday. - 0: Sunday, 1: Monday, 2: Tuesday, 3: Wednesday, 4: Thursday, 5: Friday, 6: Saturday - Default is Sunday. + description: "week_start_day_offset is the week start day offset from Sunday.\r\n 0: Sunday, 1: Monday, 2: Tuesday, 3: Wednesday, 4: Thursday, 5: Friday, 6: Saturday\r\n Default is Sunday." format: int32 disallowChangeUsername: type: boolean @@ -3867,9 +3667,7 @@ components: format: enum filepathTemplate: type: string - description: |- - The template of file path. - e.g. assets/{timestamp}_{filename} + description: "The template of file path.\r\n e.g. assets/{timestamp}_{filename}" uploadSizeLimitMb: type: string description: The max upload size in megabytes. @@ -3877,6 +3675,22 @@ components: allOf: - $ref: '#/components/schemas/StorageSetting_S3Config' description: The S3 config. + thumbnailMaxSize: + type: integer + description: The maximum size in pixels for the largest dimension of thumbnail images. + format: int32 + jpegQuality: + type: integer + description: The JPEG quality (0-100) used when downscaling uploaded images. + format: int32 + thumbnailJpegQuality: + type: integer + description: The JPEG quality (0-100) used when generating thumbnails. + format: int32 + imageMaxSize: + type: integer + description: "The maximum size in pixels for the largest dimension when storing images.\r\n Images larger than this will be downscaled before storage.\r\n Set to 0 to disable downscaling." + format: int32 description: Storage configuration settings for workspace attachments. tags: - name: ActivityService diff --git a/proto/gen/store/workspace_setting.pb.go b/proto/gen/store/workspace_setting.pb.go index 5c09483f54fbb..01bd655f3e389 100644 --- a/proto/gen/store/workspace_setting.pb.go +++ b/proto/gen/store/workspace_setting.pb.go @@ -509,7 +509,17 @@ type WorkspaceStorageSetting struct { // The max upload size in megabytes. UploadSizeLimitMb int64 `protobuf:"varint,3,opt,name=upload_size_limit_mb,json=uploadSizeLimitMb,proto3" json:"upload_size_limit_mb,omitempty"` // The S3 config. - S3Config *StorageS3Config `protobuf:"bytes,4,opt,name=s3_config,json=s3Config,proto3" json:"s3_config,omitempty"` + S3Config *StorageS3Config `protobuf:"bytes,4,opt,name=s3_config,json=s3Config,proto3" json:"s3_config,omitempty"` + // The maximum size in pixels for the largest dimension of thumbnail images. + ThumbnailMaxSize int32 `protobuf:"varint,5,opt,name=thumbnail_max_size,json=thumbnailMaxSize,proto3" json:"thumbnail_max_size,omitempty"` + // The JPEG quality (0-100) used when downscaling uploaded images. + JpegQuality int32 `protobuf:"varint,6,opt,name=jpeg_quality,json=jpegQuality,proto3" json:"jpeg_quality,omitempty"` + // The JPEG quality (0-100) used when generating thumbnails. + ThumbnailJpegQuality int32 `protobuf:"varint,7,opt,name=thumbnail_jpeg_quality,json=thumbnailJpegQuality,proto3" json:"thumbnail_jpeg_quality,omitempty"` + // The maximum size in pixels for the largest dimension when storing images. + // Images larger than this will be downscaled before storage. + // Set to 0 to disable downscaling. + ImageMaxSize int32 `protobuf:"varint,8,opt,name=image_max_size,json=imageMaxSize,proto3" json:"image_max_size,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -572,6 +582,34 @@ func (x *WorkspaceStorageSetting) GetS3Config() *StorageS3Config { return nil } +func (x *WorkspaceStorageSetting) GetThumbnailMaxSize() int32 { + if x != nil { + return x.ThumbnailMaxSize + } + return 0 +} + +func (x *WorkspaceStorageSetting) GetJpegQuality() int32 { + if x != nil { + return x.JpegQuality + } + return 0 +} + +func (x *WorkspaceStorageSetting) GetThumbnailJpegQuality() int32 { + if x != nil { + return x.ThumbnailJpegQuality + } + return 0 +} + +func (x *WorkspaceStorageSetting) GetImageMaxSize() int32 { + if x != nil { + return x.ImageMaxSize + } + return 0 +} + // Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/ type StorageS3Config struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -804,12 +842,16 @@ const file_store_workspace_setting_proto_rawDesc = "" + "\x05title\x18\x01 \x01(\tR\x05title\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + "\blogo_url\x18\x03 \x01(\tR\alogoUrl\x12\x16\n" + - "\x06locale\x18\x04 \x01(\tR\x06locale\"\xd5\x02\n" + + "\x06locale\x18\x04 \x01(\tR\x06locale\"\x82\x04\n" + "\x17WorkspaceStorageSetting\x12S\n" + "\fstorage_type\x18\x01 \x01(\x0e20.memos.store.WorkspaceStorageSetting.StorageTypeR\vstorageType\x12+\n" + "\x11filepath_template\x18\x02 \x01(\tR\x10filepathTemplate\x12/\n" + "\x14upload_size_limit_mb\x18\x03 \x01(\x03R\x11uploadSizeLimitMb\x129\n" + - "\ts3_config\x18\x04 \x01(\v2\x1c.memos.store.StorageS3ConfigR\bs3Config\"L\n" + + "\ts3_config\x18\x04 \x01(\v2\x1c.memos.store.StorageS3ConfigR\bs3Config\x12,\n" + + "\x12thumbnail_max_size\x18\x05 \x01(\x05R\x10thumbnailMaxSize\x12!\n" + + "\fjpeg_quality\x18\x06 \x01(\x05R\vjpegQuality\x124\n" + + "\x16thumbnail_jpeg_quality\x18\a \x01(\x05R\x14thumbnailJpegQuality\x12$\n" + + "\x0eimage_max_size\x18\b \x01(\x05R\fimageMaxSize\"L\n" + "\vStorageType\x12\x1c\n" + "\x18STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\f\n" + "\bDATABASE\x10\x01\x12\t\n" + diff --git a/proto/store/workspace_setting.proto b/proto/store/workspace_setting.proto index eb86aba5f6ff3..1bec9ca9b66f5 100644 --- a/proto/store/workspace_setting.proto +++ b/proto/store/workspace_setting.proto @@ -83,6 +83,16 @@ message WorkspaceStorageSetting { int64 upload_size_limit_mb = 3; // The S3 config. StorageS3Config s3_config = 4; + // The maximum size in pixels for the largest dimension of thumbnail images. + int32 thumbnail_max_size = 5; + // The JPEG quality (0-100) used when downscaling uploaded images. + int32 jpeg_quality = 6; + // The JPEG quality (0-100) used when generating thumbnails. + int32 thumbnail_jpeg_quality = 7; + // The maximum size in pixels for the largest dimension when storing images. + // Images larger than this will be downscaled before storage. + // Set to 0 to disable downscaling. + int32 image_max_size = 8; } // Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/ diff --git a/server/router/api/v1/attachment_service.go b/server/router/api/v1/attachment_service.go index 64f3429049bac..fec4b878c96db 100644 --- a/server/router/api/v1/attachment_service.go +++ b/server/router/api/v1/attachment_service.go @@ -5,6 +5,7 @@ import ( "context" "encoding/binary" "fmt" + "image" "io" "log/slog" "os" @@ -96,6 +97,20 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat create.Size = int64(size) create.Blob = request.Attachment.Content + // Downscale images before storing them if they are larger than the configured max dimension + // ImageMaxSize of 0 means no downscaling should be performed + if util.HasPrefixes(create.Type, SupportedThumbnailMimeTypes...) && workspaceStorageSetting.ImageMaxSize > 0 { + downscaledBlob, err := downscaleImage(create.Blob, int(workspaceStorageSetting.ImageMaxSize), int(workspaceStorageSetting.JpegQuality)) + if err != nil { + // Log the error but continue with the original image if downscaling fails + slog.Warn("failed to downscale image attachment", slog.Any("error", err), slog.String("filename", create.Filename)) + } else { + // Update the blob and size with the downscaled version + create.Blob = downscaledBlob + create.Size = int64(len(downscaledBlob)) + } + } + if err := SaveAttachmentBlob(ctx, s.Profile, s.Store, create); err != nil { return nil, status.Errorf(codes.Internal, "failed to save attachment blob: %v", err) } @@ -523,13 +538,73 @@ func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte, return attachment.Blob, nil } -const ( - // thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image. - thumbnailMaxSize = 600 -) +func downscaleImage(imageBlob []byte, maxDimension int, quality int) ([]byte, error) { + // Detect the image format before decoding + reader := bytes.NewReader(imageBlob) + _, formatName, err := image.DecodeConfig(reader) + if err != nil { + return nil, errors.Wrap(err, "failed to detect image format") + } + + // Reset reader position for actual decoding + if _, err := reader.Seek(0, 0); err != nil { + return nil, errors.Wrap(err, "failed to reset reader position") + } + + // Decode the image with auto-orientation support + img, err := imaging.Decode(reader, imaging.AutoOrientation(true)) + if err != nil { + return nil, errors.Wrap(err, "failed to decode image") + } + + // Get original dimensions + width := img.Bounds().Dx() + height := img.Bounds().Dy() + var targetWidth, targetHeight int + + // Only resize if the image is larger than maxDimension + if max(width, height) > maxDimension { + if width >= height { + // Landscape or square - constrain width, maintain aspect ratio for height + targetWidth = maxDimension + targetHeight = 0 + } else { + // Portrait - constrain height, maintain aspect ratio for width + targetWidth = 0 + targetHeight = maxDimension + } + } else { + // Do not modify small images + return imageBlob, nil + } + + // Resize the image to the calculated dimensions + resizedImage := imaging.Resize(img, targetWidth, targetHeight, imaging.Lanczos) + + // Encode the image based on the original format + var buf bytes.Buffer + if formatName == "png" { + // Preserve PNG format for PNG images + if err := imaging.Encode(&buf, resizedImage, imaging.PNG); err != nil { + return nil, errors.Wrap(err, "failed to encode PNG image") + } + } else { + // Encode as JPEG for all other formats + if err := imaging.Encode(&buf, resizedImage, imaging.JPEG, imaging.JPEGQuality(quality)); err != nil { + return nil, errors.Wrap(err, "failed to encode JPEG image") + } + } + + return buf.Bytes(), nil +} // getOrGenerateThumbnail returns the thumbnail image of the attachment. func (s *APIV1Service) getOrGenerateThumbnail(attachment *store.Attachment) ([]byte, error) { + workspaceStorageSetting, err := s.Store.GetWorkspaceStorageSetting(context.Background()) + if err != nil { + return nil, errors.Wrap(err, "failed to get workspace storage setting") + } + 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") @@ -545,39 +620,19 @@ func (s *APIV1Service) getOrGenerateThumbnail(attachment *store.Attachment) ([]b if err != nil { return nil, errors.Wrap(err, "failed to get attachment blob") } - img, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true)) + + // Downscale the image + thumbnailBlob, err := downscaleImage(blob, int(workspaceStorageSetting.ThumbnailMaxSize), int(workspaceStorageSetting.ThumbnailJpegQuality)) 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 + return nil, errors.Wrap(err, "failed to downscale image") } - // Resize the image to the calculated dimensions. - thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos) - if err := imaging.Save(thumbnailImage, filePath); err != nil { + // Save the thumbnail to disk + if err := os.WriteFile(filePath, thumbnailBlob, 0644); err != nil { return nil, errors.Wrap(err, "failed to save thumbnail file") } + + return thumbnailBlob, nil } thumbnailFile, err := os.Open(filePath) diff --git a/server/router/api/v1/workspace_service.go b/server/router/api/v1/workspace_service.go index 0af794c62d665..8f73752349ee8 100644 --- a/server/router/api/v1/workspace_service.go +++ b/server/router/api/v1/workspace_service.go @@ -211,9 +211,13 @@ func convertWorkspaceStorageSettingFromStore(settingpb *storepb.WorkspaceStorage return nil } setting := &v1pb.WorkspaceSetting_StorageSetting{ - StorageType: v1pb.WorkspaceSetting_StorageSetting_StorageType(settingpb.StorageType), - FilepathTemplate: settingpb.FilepathTemplate, - UploadSizeLimitMb: settingpb.UploadSizeLimitMb, + StorageType: v1pb.WorkspaceSetting_StorageSetting_StorageType(settingpb.StorageType), + FilepathTemplate: settingpb.FilepathTemplate, + UploadSizeLimitMb: settingpb.UploadSizeLimitMb, + ThumbnailMaxSize: settingpb.ThumbnailMaxSize, + JpegQuality: settingpb.JpegQuality, + ThumbnailJpegQuality: settingpb.ThumbnailJpegQuality, + ImageMaxSize: settingpb.ImageMaxSize, } if settingpb.S3Config != nil { setting.S3Config = &v1pb.WorkspaceSetting_StorageSetting_S3Config{ @@ -233,9 +237,13 @@ func convertWorkspaceStorageSettingToStore(setting *v1pb.WorkspaceSetting_Storag return nil } settingpb := &storepb.WorkspaceStorageSetting{ - StorageType: storepb.WorkspaceStorageSetting_StorageType(setting.StorageType), - FilepathTemplate: setting.FilepathTemplate, - UploadSizeLimitMb: setting.UploadSizeLimitMb, + StorageType: storepb.WorkspaceStorageSetting_StorageType(setting.StorageType), + FilepathTemplate: setting.FilepathTemplate, + UploadSizeLimitMb: setting.UploadSizeLimitMb, + ThumbnailMaxSize: setting.ThumbnailMaxSize, + JpegQuality: setting.JpegQuality, + ThumbnailJpegQuality: setting.ThumbnailJpegQuality, + ImageMaxSize: setting.ImageMaxSize, } if setting.S3Config != nil { settingpb.S3Config = &storepb.StorageS3Config{ diff --git a/store/workspace_setting.go b/store/workspace_setting.go index 9f9054e7cd744..8812219f89677 100644 --- a/store/workspace_setting.go +++ b/store/workspace_setting.go @@ -175,9 +175,12 @@ func (s *Store) GetWorkspaceMemoRelatedSetting(ctx context.Context) (*storepb.Wo } const ( - defaultWorkspaceStorageType = storepb.WorkspaceStorageSetting_DATABASE - defaultWorkspaceUploadSizeLimitMb = 30 - defaultWorkspaceFilepathTemplate = "assets/{timestamp}_{filename}" + defaultWorkspaceStorageType = storepb.WorkspaceStorageSetting_DATABASE + defaultWorkspaceUploadSizeLimitMb = 30 + defaultWorkspaceFilepathTemplate = "assets/{timestamp}_{filename}" + defaultThumbnailMaxSize = 600 + defaultJPEGQuality = 85 + defaultThumbnailJPEGQuality = 75 ) func (s *Store) GetWorkspaceStorageSetting(ctx context.Context) (*storepb.WorkspaceStorageSetting, error) { @@ -201,6 +204,16 @@ func (s *Store) GetWorkspaceStorageSetting(ctx context.Context) (*storepb.Worksp if workspaceStorageSetting.FilepathTemplate == "" { workspaceStorageSetting.FilepathTemplate = defaultWorkspaceFilepathTemplate } + if workspaceStorageSetting.ThumbnailMaxSize == 0 { + workspaceStorageSetting.ThumbnailMaxSize = defaultThumbnailMaxSize + } + if workspaceStorageSetting.JpegQuality == 0 { + workspaceStorageSetting.JpegQuality = defaultJPEGQuality + } + if workspaceStorageSetting.ThumbnailJpegQuality == 0 { + workspaceStorageSetting.ThumbnailJpegQuality = defaultThumbnailJPEGQuality + } + // Note: ImageMaxSize of 0 is a valid value meaning "no downscaling", so we don't apply a default s.workspaceSettingCache.Set(ctx, storepb.WorkspaceSettingKey_STORAGE.String(), &storepb.WorkspaceSetting{ Key: storepb.WorkspaceSettingKey_STORAGE, Value: &storepb.WorkspaceSetting_StorageSetting{StorageSetting: workspaceStorageSetting}, diff --git a/web/src/components/Settings/StorageSection.tsx b/web/src/components/Settings/StorageSection.tsx index 8216cb8b1822d..07ef8c4fb93d9 100644 --- a/web/src/components/Settings/StorageSection.tsx +++ b/web/src/components/Settings/StorageSection.tsx @@ -39,6 +39,19 @@ const StorageSection = observer(() => { if (workspaceStorageSetting.uploadSizeLimitMb <= 0) { return false; } + // imageMaxSize can be 0 (meaning no downscaling) or positive + if (workspaceStorageSetting.imageMaxSize < 0) { + return false; + } + if (workspaceStorageSetting.jpegQuality < 1 || workspaceStorageSetting.jpegQuality > 100) { + return false; + } + if (workspaceStorageSetting.thumbnailMaxSize <= 0) { + return false; + } + if (workspaceStorageSetting.thumbnailJpegQuality < 1 || workspaceStorageSetting.thumbnailJpegQuality > 100) { + return false; + } const origin = WorkspaceSetting_StorageSetting.fromPartial( workspaceStore.getWorkspaceSettingByKey(WorkspaceSetting_Key.STORAGE)?.storageSetting || {}, @@ -73,6 +86,54 @@ const StorageSection = observer(() => { setWorkspaceStorageSetting(update); }; + const handleThumbnailMaxSizeChanged = async (event: React.FocusEvent) => { + let num = parseInt(event.target.value); + if (Number.isNaN(num)) { + num = 0; + } + const update: WorkspaceSetting_StorageSetting = { + ...workspaceStorageSetting, + thumbnailMaxSize: num, + }; + setWorkspaceStorageSetting(update); + }; + + const handleJpegQualityChanged = async (event: React.FocusEvent) => { + let num = parseInt(event.target.value); + if (Number.isNaN(num)) { + num = 0; + } + const update: WorkspaceSetting_StorageSetting = { + ...workspaceStorageSetting, + jpegQuality: num, + }; + setWorkspaceStorageSetting(update); + }; + + const handleThumbnailJpegQualityChanged = async (event: React.FocusEvent) => { + let num = parseInt(event.target.value); + if (Number.isNaN(num)) { + num = 0; + } + const update: WorkspaceSetting_StorageSetting = { + ...workspaceStorageSetting, + thumbnailJpegQuality: num, + }; + setWorkspaceStorageSetting(update); + }; + + const handleImageMaxSizeChanged = async (event: React.FocusEvent) => { + let num = parseInt(event.target.value); + if (Number.isNaN(num)) { + num = 0; + } + const update: WorkspaceSetting_StorageSetting = { + ...workspaceStorageSetting, + imageMaxSize: num, + }; + setWorkspaceStorageSetting(update); + }; + const handleFilepathTemplateChanged = async (event: React.FocusEvent) => { const update: WorkspaceSetting_StorageSetting = { ...workspaceStorageSetting, @@ -171,7 +232,7 @@ const StorageSection = observer(() => { - + {workspaceStorageSetting.storageType !== WorkspaceSetting_StorageSetting_StorageType.DATABASE && (
@@ -240,6 +301,81 @@ const StorageSection = observer(() => {
)} + +
+
+ Maximum image size (px) + + + + + + +

+ Maximum size in pixels for the largest dimension when storing images. Images larger than this will be downscaled. Set to 0 + to disable downscaling (default: 0, no downscaling). +

+
+
+
+
+ +
+
+
+ JPEG quality + + + + + + +

+ JPEG quality (0-100) used when downscaling uploaded images. Higher values = better quality but larger file size (default: + 85). +

+
+
+
+
+ +
+
+
+ Thumbnail size (px) + + + + + + +

Maximum size in pixels for the largest dimension of thumbnail images (default: 600).

+
+
+
+
+ +
+
+
+ Thumbnail JPEG quality + + + + + + +

JPEG quality (0-100) used when generating thumbnails. Lower values save space (default: 75).

+
+
+
+
+ +