Files
AFFiNE/packages/backend/server/src/plugins/copilot/storage.ts
DarkSky 8ebdb7452f feat(server): impl storage runtime (#15181)
#### PR Dependency Tree


* **PR #15181** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an additional storage backend option: asset-pack based storage
(provider for avatar, blob, and copilot).
* Introduced a dedicated storage runtime with provider capability
reporting and expanded object operations (put/head/get/list/delete),
including presigned and multipart flows where supported.
* Cloudflare R2 `jurisdiction` now uses an explicit default when
omitted.
* **Bug Fixes**
  * Broadened avatar access to allow both fs and asset-pack providers.
* Improved workspace blob upload completion validation and handling when
stored objects are missing or mismatched.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-01 22:24:10 +08:00

105 lines
2.8 KiB
TypeScript

import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import {
type BlobInputType,
BlobQuotaExceeded,
CallMetric,
type FileUpload,
OneMB,
readBuffer,
toBuffer,
URLHelper,
} from '../../base';
import { QuotaService } from '../../core/quota';
import {
type StorageRuntimeGetObjectResult,
StorageRuntimeProvider,
} from '../../core/storage-runtime';
import { fetchRemoteAttachment } from '../../native';
const REMOTE_BLOB_MAX_BYTES = 20 * OneMB;
@Injectable()
export class CopilotStorage {
constructor(
private readonly url: URLHelper,
private readonly rt: StorageRuntimeProvider,
private readonly quota: QuotaService
) {}
@CallMetric('ai', 'blob_put')
async put(
userId: string,
workspaceId: string,
key: string,
blob: BlobInputType,
mimeType = 'image/png'
) {
const name = `${userId}/${workspaceId}/${key}`;
const buffer = await toBuffer(blob);
await this.rt.putObject('copilot', name, buffer, {
contentType: mimeType,
contentLength: buffer.length,
});
if (!env.prod) {
// return image base64url for dev environment
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}
return this.url.link(`/api/copilot/blob/${name}`);
}
@CallMetric('ai', 'blob_get')
async get(
userId: string,
workspaceId: string,
key: string,
signedUrl?: boolean
): Promise<StorageRuntimeGetObjectResult> {
const name = `${userId}/${workspaceId}/${key}`;
if (signedUrl) {
const presigned = await this.rt.presignGet('copilot', name);
if (presigned) {
return { redirectUrl: presigned.url };
}
}
return this.rt.getObject('copilot', name);
}
@CallMetric('ai', 'blob_delete')
async delete(userId: string, workspaceId: string, key: string) {
await this.rt.deleteObject('copilot', `${userId}/${workspaceId}/${key}`);
}
@CallMetric('ai', 'blob_upload')
async handleUpload(userId: string, blob: FileUpload) {
const checkExceeded = await this.quota.getUserQuotaCalculator(userId);
if (checkExceeded(0)) {
throw new BlobQuotaExceeded();
}
const buffer = await readBuffer(blob.createReadStream(), checkExceeded);
return {
buffer,
filename: blob.filename,
};
}
@CallMetric('ai', 'blob_proxy_remote_url')
async handleRemoteLink(userId: string, workspaceId: string, link: string) {
const { body, mimeType } = await fetchRemoteAttachment({
url: link,
maxBytes: REMOTE_BLOB_MAX_BYTES,
expectedContentTypePrefix: 'image/',
maxImageHeight: 4096,
maxImageWidth: 4096,
});
const buffer = Buffer.from(body);
const filename = createHash('sha256').update(buffer).digest('base64url');
return this.put(userId, workspaceId, filename, buffer, mimeType);
}
}