feat: Files payloads move from oCIS WebDAV to R2 object storage

The Files tab was the only payload path not on object storage, violating the
2026-10-06 directive (object storage is the ONLY media I/O path) — and oCIS's
WebDAV surface is currently 500ing server-side, which made every upload fail.

Uploads now seal + dedupe into R2 via StorageService (SPENC1, content-
addressed); downloads read R2 when storage_key is set, falling back to oCIS
for legacy rows. Share-into-library copies oCIS Drive sources into R2 and
records the real sha256 instead of a placeholder. Migration 041 adds
chat_media_archive.storage_key.
This commit is contained in:
glass beadian 2026-10-15 22:11:07 +00:00
parent 5808e89dde
commit 18afff5e49
4 changed files with 114 additions and 72 deletions

View file

@ -31,10 +31,8 @@ import {
mimeClassOf,
noLibraryTargetMessage,
openRoomNoticeContent,
suffixedName,
wantsLibrary,
wantsOpenRoom,
MAX_COLLISION_ATTEMPTS,
type CollectiveRoomPoster,
type CollectiveShareInput,
type CollectiveShareItem,
@ -42,7 +40,8 @@ import {
type CollectiveShareResult,
type ShareErrorCode,
} from "./collective-share.js";
import { OcisNotFoundError, OcisPreconditionFailedError, OcisWebdavClient, ocisConfigFromEnv, type FetchLike } from "./ocis-webdav.js";
import { OcisWebdavClient, ocisConfigFromEnv, type FetchLike } from "./ocis-webdav.js";
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
interface CollectiveRow {
id: string;
@ -72,9 +71,24 @@ export class CollectiveShareService {
constructor(
@Optional() @Inject(COLLECTIVE_ROOM_POSTER) private readonly poster?: CollectiveRoomPoster,
@Optional() @Inject(COLLECTIVE_SHARE_DEPENDENCIES) private readonly deps?: { fetcher?: FetchLike },
@Optional() @Inject(COLLECTIVE_SHARE_DEPENDENCIES) private readonly deps?: { fetcher?: FetchLike; storage?: StorageService },
) {}
private store: StorageService | null = null;
/** R2 object storage — the library payload store (041). */
private storage(): StorageService {
if (this.store === null) {
this.store = this.deps?.storage ?? new S3Storage(s3ConfigFromEnv());
}
return this.store;
}
private libraryUrlFor(resourceId: string): string {
const base = (process.env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, "");
return base === "" ? resourceId : `${base}/f/${encodeURIComponent(resourceId)}`;
}
/** Lazily bound: the oCIS env may be absent in dev/test contexts. */
private webdav(): OcisWebdavClient {
if (this.client === null) {
@ -265,63 +279,50 @@ export class CollectiveShareService {
};
}
const baseName = safeFileName(segments[segments.length - 1] ?? "attachment");
let copied: Awaited<ReturnType<OcisWebdavClient["copyIntoGroup"]>> | null = null;
let finalName = baseName;
for (let attempt = 0; attempt <= MAX_COLLISION_ATTEMPTS; attempt += 1) {
const candidate = suffixedName(baseName, attempt);
try {
copied = await this.webdav().copyIntoGroup(collective.id, segments, folder, candidate);
finalName = candidate;
break;
} catch (err) {
if (err instanceof OcisPreconditionFailedError) continue; // try the next suffix
if (err instanceof OcisNotFoundError) {
return {
title: titleFromPath(item.path),
error: {
code: "source-not-found",
message: collective.language === "es"
? `No se encontró el archivo de origen (${item.path}).`
: `The source file was not found (${item.path}).`,
},
};
}
return {
title: titleFromPath(item.path),
error: {
code: "copy-failed",
message: collective.language === "es"
? "La copia en la biblioteca del colectivo falló."
: "Copying into the collective's library failed.",
},
};
}
}
if (copied === null) {
const name = safeFileName(segments[segments.length - 1] ?? "attachment");
// oCIS stays the Drive source; the library destination is R2 (041).
const source = await this.webdav().download(segments.join("/")).catch(() => null);
if (source === null || source.stream === null) {
return {
title: titleFromPath(item.path),
error: {
code: "source-not-found",
message: collective.language === "es"
? `No se encontró el archivo de origen (${item.path}).`
: `The source file was not found (${item.path}).`,
},
};
}
const chunks: Buffer[] = [];
for await (const chunk of source.stream as unknown as AsyncIterable<unknown>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const bytes = Buffer.concat(chunks);
const stored = await this.storage().putContent(bytes, source.contentType ?? "application/octet-stream").catch(() => null);
if (stored === null) {
return {
title: name,
error: {
code: "copy-failed",
message: collective.language === "es"
? `Ya existe un archivo llamado ${baseName} (y hasta 100 variantes); renómbralo primero.`
: `A file named ${baseName} already exists (plus 100 variants); rename it first.`,
? "La copia en la biblioteca del colectivo falló."
: "Copying into the collective's library failed.",
},
};
}
const indexed = await this.indexCopy(workspaceId, collective, actor, {
folder,
name: finalName,
resourceId: copied.resourceId,
version: copied.version,
sizeBytes: copied.sizeBytes,
contentType: copied.contentType,
name,
storageKey: stored.key,
sha256: stored.hash,
sizeBytes: stored.sizeBytes,
contentType: source.contentType ?? "application/octet-stream",
sourcePath: item.path,
senderMxid,
});
if ("error" in indexed) return { title: finalName, error: indexed.error };
return { title: finalName, result: indexed.result };
if ("error" in indexed) return { title: name, error: indexed.error };
return { title: name, result: indexed.result };
}
/**
@ -336,8 +337,8 @@ export class CollectiveShareService {
copy: {
folder: readonly string[];
name: string;
resourceId: string;
version: string | null;
storageKey: string;
sha256: string;
sizeBytes: number;
contentType: string;
sourcePath: string;
@ -345,7 +346,7 @@ export class CollectiveShareService {
},
): Promise<{ result: { itemId: string; name: string; url: string } } | { error: { code: ShareErrorCode; message: string } }> {
const mime = copy.contentType === "" ? "application/octet-stream" : copy.contentType;
const libraryUrl = this.webdav().libraryUrl(copy.resourceId);
const libraryUrl = this.libraryUrlFor(copy.storageKey);
const folderPath = copy.folder.length === 0 ? "" : `${copy.folder.join("/")}/`;
const origin = {
kind: "upload",
@ -357,8 +358,8 @@ export class CollectiveShareService {
`INSERT INTO chat_media_archive
(workspace_id, group_id, room_id, event_id, origin, sender_mxid, matrix_event_json,
original_name, safe_name, mime, mime_class, byte_size, sha256, ocis_resource_id,
ocis_resource_version, created_at, indexed_at)
VALUES ($1,$2,NULL,NULL,'upload',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now())
ocis_resource_version, created_at, indexed_at, storage_key)
VALUES ($1,$2,NULL,NULL,'upload',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now(),$13)
RETURNING id`,
[
workspaceId,
@ -370,9 +371,10 @@ export class CollectiveShareService {
mime,
mimeClassOf(mime),
copy.sizeBytes,
digestPlaceholder(copy.resourceId),
copy.resourceId,
copy.version,
copy.sha256,
copy.storageKey,
null,
copy.storageKey,
],
);
return row.rows[0]?.id;