Files
AFFiNE-Mirror/packages/common/nbstore/src/impls/cloud/blob.ts
T
Daniel Dybing 88a2e4aa4b fix: improved error description of proxy size limits (#14016)
**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 -->
2025-11-27 12:06:07 +08:00

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);
}
}
}