mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 12:45:55 +08:00
88a2e4aa4b
**Summary:** This PR improves the user feedback when encountering an HTTP 413 (_CONTENT_TOO_LARGE)_ error caused by a file size limit in the proxy / ingress controller in a self-hosted environment. **Example scenario:** A self-hosted environment serves AFFiNE through an nginx proxy, and the `client_max_body_size` variable in the configuration file is set to a smaller size (e.g. 1MB) than AFFiNE's own file size limit (typically 100MB). Previously, the user would get an error saying the file is larger than 100MB regardless of file size, as all of these cases resulted in the same internal error. With this fix, the _CONTENT_TOO_LARGE_ error is now handled separately and gives better feedback to the user that the failing upload is caused by a fault in the proxy configuration. **Screenshot of new error message** <img width="798" height="171" alt="1MB_now" src="https://github.com/user-attachments/assets/07b00cd3-ce37-4049-8674-2f3dcb916ab5" /> **Affected files:** 1. packages/common/nbstore/src/storage/errors/over-size.ts 2. packages/common/nbstore/src/impls/cloud/blob.ts I'm open to any suggestions in terms of the wording used in the message to the user. The fix has been tested with an nginx proxy. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved user-facing error messages for file upload failures. When an upload exceeds the file size limit, users now receive a clearer message indicating that the upload was stopped by the network proxy due to the size restriction, providing better understanding of why the upload was rejected. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
181 lines
4.8 KiB
TypeScript
181 lines
4.8 KiB
TypeScript
import { UserFriendlyError } from '@affine/error';
|
|
import {
|
|
deleteBlobMutation,
|
|
listBlobsQuery,
|
|
releaseDeletedBlobsMutation,
|
|
setBlobMutation,
|
|
workspaceBlobQuotaQuery,
|
|
} from '@affine/graphql';
|
|
|
|
import {
|
|
type BlobRecord,
|
|
BlobStorageBase,
|
|
OverCapacityError,
|
|
OverSizeError,
|
|
} from '../../storage';
|
|
import { HttpConnection } from './http';
|
|
|
|
interface CloudBlobStorageOptions {
|
|
serverBaseUrl: string;
|
|
id: string;
|
|
}
|
|
|
|
const SHOULD_MANUAL_REDIRECT = BUILD_CONFIG.isAndroid || BUILD_CONFIG.isIOS;
|
|
|
|
export class CloudBlobStorage extends BlobStorageBase {
|
|
static readonly identifier = 'CloudBlobStorage';
|
|
override readonly isReadonly = false;
|
|
|
|
constructor(private readonly options: CloudBlobStorageOptions) {
|
|
super();
|
|
}
|
|
|
|
readonly connection = new HttpConnection(this.options.serverBaseUrl);
|
|
|
|
override async get(key: string, signal?: AbortSignal) {
|
|
const res = await this.connection.fetch(
|
|
'/api/workspaces/' +
|
|
this.options.id +
|
|
'/blobs/' +
|
|
key +
|
|
(SHOULD_MANUAL_REDIRECT ? '?redirect=manual' : ''),
|
|
{
|
|
cache: 'default',
|
|
headers: {
|
|
'x-affine-version': BUILD_CONFIG.appVersion,
|
|
},
|
|
signal,
|
|
}
|
|
);
|
|
|
|
if (res.status === 404) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const contentType = res.headers.get('content-type');
|
|
|
|
let blob;
|
|
|
|
if (
|
|
SHOULD_MANUAL_REDIRECT &&
|
|
contentType?.startsWith('application/json')
|
|
) {
|
|
const json = await res.json();
|
|
if ('url' in json && typeof json.url === 'string') {
|
|
const res = await this.connection.fetch(json.url, {
|
|
cache: 'default',
|
|
headers: {
|
|
'x-affine-version': BUILD_CONFIG.appVersion,
|
|
},
|
|
signal,
|
|
});
|
|
|
|
blob = await res.blob();
|
|
} else {
|
|
throw new Error('Invalid blob response');
|
|
}
|
|
} else {
|
|
blob = await res.blob();
|
|
}
|
|
|
|
return {
|
|
key,
|
|
data: new Uint8Array(await blob.arrayBuffer()),
|
|
mime: blob.type,
|
|
size: blob.size,
|
|
createdAt: new Date(res.headers.get('last-modified') || Date.now()),
|
|
};
|
|
} catch (err) {
|
|
throw new Error('blob download error: ' + err);
|
|
}
|
|
}
|
|
|
|
override async set(blob: BlobRecord, signal?: AbortSignal) {
|
|
try {
|
|
const blobSizeLimit = await this.getBlobSizeLimit();
|
|
if (blob.data.byteLength > blobSizeLimit) {
|
|
throw new OverSizeError(this.humanReadableBlobSizeLimitCache);
|
|
}
|
|
await this.connection.gql({
|
|
query: setBlobMutation,
|
|
variables: {
|
|
workspaceId: this.options.id,
|
|
blob: new File([blob.data], blob.key, { type: blob.mime }),
|
|
},
|
|
context: {
|
|
signal,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const userFriendlyError = UserFriendlyError.fromAny(err);
|
|
if (userFriendlyError.is('STORAGE_QUOTA_EXCEEDED')) {
|
|
throw new OverCapacityError();
|
|
}
|
|
if (userFriendlyError.is('BLOB_QUOTA_EXCEEDED')) {
|
|
throw new OverSizeError(this.humanReadableBlobSizeLimitCache);
|
|
}
|
|
if (userFriendlyError.is('CONTENT_TOO_LARGE')) {
|
|
throw new OverSizeError(
|
|
null,
|
|
'Upload stopped by network proxy: file size exceeds the set limit.'
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
override async delete(key: string, permanently: boolean) {
|
|
await this.connection.gql({
|
|
query: deleteBlobMutation,
|
|
variables: { workspaceId: this.options.id, key, permanently },
|
|
});
|
|
}
|
|
|
|
override async release() {
|
|
await this.connection.gql({
|
|
query: releaseDeletedBlobsMutation,
|
|
variables: { workspaceId: this.options.id },
|
|
});
|
|
}
|
|
|
|
override async list() {
|
|
const res = await this.connection.gql({
|
|
query: listBlobsQuery,
|
|
variables: { workspaceId: this.options.id },
|
|
});
|
|
|
|
return res.workspace.blobs.map(blob => ({
|
|
...blob,
|
|
createdAt: new Date(blob.createdAt),
|
|
}));
|
|
}
|
|
|
|
private humanReadableBlobSizeLimitCache: string | null = null;
|
|
private blobSizeLimitCache: number | null = null;
|
|
private blobSizeLimitCacheTime = 0;
|
|
private async getBlobSizeLimit() {
|
|
// If cache time is less than 120 seconds, return the cached value directly
|
|
if (
|
|
this.blobSizeLimitCache !== null &&
|
|
Date.now() - this.blobSizeLimitCacheTime < 120 * 1000
|
|
) {
|
|
return this.blobSizeLimitCache;
|
|
}
|
|
try {
|
|
const res = await this.connection.gql({
|
|
query: workspaceBlobQuotaQuery,
|
|
variables: { id: this.options.id },
|
|
});
|
|
|
|
this.humanReadableBlobSizeLimitCache =
|
|
res.workspace.quota.humanReadable.blobLimit;
|
|
this.blobSizeLimitCache = res.workspace.quota.blobLimit;
|
|
this.blobSizeLimitCacheTime = Date.now();
|
|
return this.blobSizeLimitCache;
|
|
} catch (err) {
|
|
throw UserFriendlyError.fromAny(err);
|
|
}
|
|
}
|
|
}
|