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 -->
This commit is contained in:
DarkSky
2026-07-01 22:24:10 +08:00
committed by GitHub
parent da7d438377
commit 8ebdb7452f
102 changed files with 6487 additions and 4508 deletions
@@ -6,43 +6,29 @@ import {
type BlobInputType,
BlobQuotaExceeded,
CallMetric,
Config,
type FileUpload,
OneMB,
OnEvent,
readBuffer,
type StorageProvider,
StorageProviderFactory,
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 {
public provider!: StorageProvider;
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly storageFactory: StorageProviderFactory,
private readonly rt: StorageRuntimeProvider,
private readonly quota: QuotaService
) {}
@OnEvent('config.init')
async onConfigInit() {
this.provider = this.storageFactory.create(this.config.copilot.storage);
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if (event.updates?.copilot?.storage) {
this.provider = this.storageFactory.create(this.config.copilot.storage);
}
}
@CallMetric('ai', 'blob_put')
async put(
userId: string,
@@ -52,10 +38,14 @@ export class CopilotStorage {
mimeType = 'image/png'
) {
const name = `${userId}/${workspaceId}/${key}`;
await this.provider.put(name, blob);
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,${blob.toString('base64')}`;
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}
return this.url.link(`/api/copilot/blob/${name}`);
}
@@ -66,13 +56,20 @@ export class CopilotStorage {
workspaceId: string,
key: string,
signedUrl?: boolean
) {
return this.provider.get(`${userId}/${workspaceId}/${key}`, signedUrl);
): 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.provider.delete(`${userId}/${workspaceId}/${key}`);
await this.rt.deleteObject('copilot', `${userId}/${workspaceId}/${key}`);
}
@CallMetric('ai', 'blob_upload')