Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
35
apps/api/src/storage/files-config.test.ts
Normal file
35
apps/api/src/storage/files-config.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { filesIntegrationConfigFromEnv } from "./files-config.js";
|
||||
|
||||
// Synthetic non-secret account ID, used only for pure config tests; no network I/O.
|
||||
const env = {
|
||||
FILES_R2_ENDPOINT: `https://${"a".repeat(32)}.r2.cloudflarestorage.com`,
|
||||
FILES_RESERVED_BUCKETS: "occupymedia-peertube-videos,occupymedia-peertube-playlists",
|
||||
};
|
||||
|
||||
describe("files integration config", () => {
|
||||
it("keeps payload ownership separate with no disk cache or pad implementation", () => {
|
||||
const config = filesIntegrationConfigFromEnv(env);
|
||||
expect(new Set(Object.values(config.buckets)).size).toBe(3);
|
||||
expect(config).toMatchObject({ localPayloadCache: false, localMetadataAllowed: true, padsEnabled: false });
|
||||
expect(config.r2).toMatchObject({ region: "auto", forcePathStyle: true });
|
||||
});
|
||||
|
||||
it("rejects reuse of Matrix, Poster, or PeerTube buckets for files", () => {
|
||||
for (const FILES_R2_BUCKET of ["occupymedia-matrix-media", "occupymedia-media-encrypted", "occupymedia-peertube-videos", "occupymedia-peertube-playlists"]) {
|
||||
expect(() => filesIntegrationConfigFromEnv({ ...env, FILES_R2_BUCKET })).toThrow("separate");
|
||||
}
|
||||
expect(() => filesIntegrationConfigFromEnv({ ...env, MATRIX_R2_BUCKET: "shared", S3_BUCKET: "shared" })).toThrow("separate");
|
||||
});
|
||||
|
||||
it("rejects absent/non-R2 endpoints and unsafe public URLs", () => {
|
||||
expect(() => filesIntegrationConfigFromEnv({})).toThrow();
|
||||
for (const FILES_R2_ENDPOINT of ["http://localhost:9000", "https://s3.amazonaws.com", `${env.FILES_R2_ENDPOINT}/bucket`]) {
|
||||
expect(() => filesIntegrationConfigFromEnv({ FILES_R2_ENDPOINT })).toThrow();
|
||||
}
|
||||
for (const FILES_PUBLIC_URL of ["http://files.example.org", "https://user:password@files.example.org", "https://files.example.org/?token=x"]) {
|
||||
expect(() => filesIntegrationConfigFromEnv({ ...env, FILES_PUBLIC_URL })).toThrow();
|
||||
}
|
||||
expect(() => filesIntegrationConfigFromEnv({ ...env, FILES_R2_BUCKET: "" })).toThrow("bucket names");
|
||||
});
|
||||
});
|
||||
54
apps/api/src/storage/files-config.ts
Normal file
54
apps/api/src/storage/files-config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/** Opt-in foundation config. Not loaded by API startup or the existing media worker. */
|
||||
export interface FilesIntegrationConfig {
|
||||
payloadStore: "r2";
|
||||
localPayloadCache: false;
|
||||
localMetadataAllowed: true;
|
||||
r2: { endpoint: string; region: "auto"; forcePathStyle: true };
|
||||
buckets: { matrixMedia: string; filesBlobs: string; posterMedia: string };
|
||||
filesUrl: string;
|
||||
officeUrl: string;
|
||||
/** Disabled until a pad adapter can keep text/revision payloads in R2. */
|
||||
padsEnabled: false;
|
||||
}
|
||||
|
||||
type Environment = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
function httpsOrigin(value: string, name: string): string {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
||||
throw new Error(`${name} must be an HTTPS origin without credentials, path or query`);
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export function filesIntegrationConfigFromEnv(env: Environment): FilesIntegrationConfig {
|
||||
const endpoint = httpsOrigin(env.FILES_R2_ENDPOINT ?? "", "FILES_R2_ENDPOINT");
|
||||
if (!/^[a-f0-9]{32}\.r2\.cloudflarestorage\.com$/.test(new URL(endpoint).hostname)) {
|
||||
throw new Error("FILES_R2_ENDPOINT must use the account R2 S3 endpoint");
|
||||
}
|
||||
const buckets = {
|
||||
matrixMedia: env.MATRIX_R2_BUCKET ?? "occupymedia-matrix-media",
|
||||
filesBlobs: env.FILES_R2_BUCKET ?? "occupymedia-files-blobs",
|
||||
posterMedia: env.S3_BUCKET ?? "occupymedia-media-encrypted",
|
||||
};
|
||||
const names = Object.values(buckets);
|
||||
if (names.some(name => !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(name))) {
|
||||
throw new Error("Payload buckets must be valid R2 bucket names");
|
||||
}
|
||||
// Other product buckets are supplied by deployment, not provider branching here.
|
||||
const reserved = env.FILES_RESERVED_BUCKETS?.split(",").map(name => name.trim());
|
||||
if (!reserved?.length || reserved.some(name => !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(name))) {
|
||||
throw new Error("FILES_RESERVED_BUCKETS must list other products' payload buckets");
|
||||
}
|
||||
const exclusive = [...names, ...reserved];
|
||||
if (new Set(exclusive).size !== exclusive.length) {
|
||||
throw new Error("Every product must have separate payload buckets");
|
||||
}
|
||||
return {
|
||||
payloadStore: "r2", localPayloadCache: false, localMetadataAllowed: true,
|
||||
r2: { endpoint, region: "auto", forcePathStyle: true }, buckets,
|
||||
filesUrl: httpsOrigin(env.FILES_PUBLIC_URL ?? "https://files.occupymedia.cc", "FILES_PUBLIC_URL"),
|
||||
officeUrl: httpsOrigin(env.OFFICE_PUBLIC_URL ?? "https://office.occupymedia.cc", "OFFICE_PUBLIC_URL"),
|
||||
padsEnabled: false,
|
||||
};
|
||||
}
|
||||
60
apps/api/src/storage/storage.integration.test.ts
Normal file
60
apps/api/src/storage/storage.integration.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { S3Storage } from "./storage.js";
|
||||
|
||||
/**
|
||||
* Live roundtrip against any S3-compatible endpoint (S3_ENDPOINT env;
|
||||
* dev = MinIO from infra compose). Skipped without env — the pure key-scheme
|
||||
* suite in storage.test.ts always runs.
|
||||
*/
|
||||
const cfg = process.env.S3_ENDPOINT;
|
||||
|
||||
describe.skipIf(cfg === undefined)("S3 storage (live endpoint)", () => {
|
||||
let storage: S3Storage;
|
||||
|
||||
beforeAll(() => {
|
||||
storage = new S3Storage({
|
||||
endpoint: cfg,
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY ?? "tactical-media-broadcast-hub",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY ?? "tactical-media-broadcast-hub_dev",
|
||||
bucket: process.env.S3_BUCKET ?? "tactical-media-broadcast-hub-media-test",
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// bucket intentionally retained for inspection; content is content-addressed test bytes
|
||||
void storage;
|
||||
});
|
||||
|
||||
it("put → get roundtrips bytes", async () => {
|
||||
const payload = Buffer.from(`qa-roundtrip-${Date.now()}`);
|
||||
const stored = await storage.putContent(payload, "image/png");
|
||||
expect(stored.deduplicated).toBe(false);
|
||||
const back = await storage.getObject(stored.key);
|
||||
expect(back.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("identical bytes deduplicate (second put is a no-op)", async () => {
|
||||
const payload = Buffer.from("dedupe-me-identical");
|
||||
const first = await storage.putContent(payload, "video/mp4");
|
||||
const second = await storage.putContent(Buffer.from("dedupe-me-identical"), "video/mp4");
|
||||
expect(first.deduplicated).toBe(false);
|
||||
expect(second.deduplicated).toBe(true);
|
||||
expect(second.key).toBe(first.key);
|
||||
expect(await storage.hasContent(second.hash, "video/mp4")).toBe(true);
|
||||
});
|
||||
|
||||
it("same bytes, different content-type land on different keys", async () => {
|
||||
const payload = Buffer.from("multi-format");
|
||||
const asPng = await storage.putContent(payload, "image/png");
|
||||
const asMp4 = await storage.putContent(payload, "video/mp4");
|
||||
expect(asPng.key).not.toBe(asMp4.key);
|
||||
});
|
||||
|
||||
it("presigns a GET url", async () => {
|
||||
const stored = await storage.putContent(Buffer.from("presign-me"), "image/png");
|
||||
const url = await storage.presign(stored.key, 60);
|
||||
expect(url).toContain(encodeURIComponent(stored.key).split("%2F")[1] ?? stored.key.slice(7));
|
||||
expect(url.startsWith("http")).toBe(true);
|
||||
});
|
||||
});
|
||||
118
apps/api/src/storage/storage.test.ts
Normal file
118
apps/api/src/storage/storage.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const s3Calls = vi.hoisted(() => ({ sent: [] as string[] }));
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => {
|
||||
class FakeCommand {
|
||||
constructor(readonly input: unknown, readonly marker: string) {}
|
||||
}
|
||||
class S3ServiceException extends Error {
|
||||
readonly $metadata: { httpStatusCode?: number };
|
||||
constructor(message: string, status: number, name: string) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
this.$metadata = { httpStatusCode: status };
|
||||
}
|
||||
}
|
||||
return {
|
||||
CreateBucketCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Create"); } },
|
||||
HeadObjectCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Head"); } },
|
||||
PutObjectCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Put"); } },
|
||||
GetObjectCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Get"); } },
|
||||
S3ServiceException,
|
||||
S3Client: class {
|
||||
constructor(readonly config: unknown) {}
|
||||
async send(cmd: FakeCommand): Promise<unknown> {
|
||||
s3Calls.sent.push(cmd.marker);
|
||||
if (cmd.marker === "Create") throw new S3ServiceException("Access Denied", 403, "AccessDenied");
|
||||
if (cmd.marker === "Head") throw new S3ServiceException("Not Found", 404, "NotFound");
|
||||
return {};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { S3Storage, extensionFor, keyForHash, open, seal, sha256 } from "./storage.js";
|
||||
|
||||
const TEST_KEK = createHash("sha256").update("test-kek").digest();
|
||||
|
||||
describe("storage key scheme (pure)", () => {
|
||||
it("maps content types to extensions, unknown -> bin", () => {
|
||||
expect(extensionFor("image/png")).toBe("png");
|
||||
expect(extensionFor("video/mp4")).toBe("mp4");
|
||||
expect(extensionFor("application/x-weird")).toBe("bin");
|
||||
});
|
||||
|
||||
it("keys are content-addressed and deterministic", () => {
|
||||
const hash = sha256(Buffer.from("same bytes"));
|
||||
expect(keyForHash(hash, "image/png")).toBe(`sha256/${hash}.png`);
|
||||
expect(keyForHash(hash, "video/mp4")).toBe(`sha256/${hash}.mp4`);
|
||||
expect(keyForHash(sha256(Buffer.from("same bytes")), "image/png")).toBe(keyForHash(hash, "image/png"));
|
||||
expect(keyForHash(sha256(Buffer.from("other")), "image/png")).not.toBe(keyForHash(hash, "image/png"));
|
||||
});
|
||||
|
||||
it("hash is stable across buffer instances", () => {
|
||||
expect(sha256(Buffer.from("abc"))).toBe(sha256(Buffer.from("abc")));
|
||||
expect(sha256(Buffer.from("abc"))).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client-side encryption (pure)", () => {
|
||||
it("seal → open roundtrips arbitrary bytes", () => {
|
||||
const plaintext = randomBytes(1024 * 64);
|
||||
const sealed = seal(plaintext, TEST_KEK);
|
||||
expect(open(sealed, TEST_KEK).equals(plaintext)).toBe(true);
|
||||
});
|
||||
|
||||
it("ciphertext never contains plaintext (endpoint sees only sealed frame)", () => {
|
||||
const plaintext = Buffer.from("TOPSECRET-media-bytes-123");
|
||||
const sealed = seal(plaintext, TEST_KEK);
|
||||
expect(sealed.includes(plaintext)).toBe(false);
|
||||
});
|
||||
|
||||
it("fresh CEK per seal → identical plaintext, different ciphertexts (dedup still by key)", () => {
|
||||
const plaintext = Buffer.from("identical");
|
||||
const a = seal(plaintext, TEST_KEK);
|
||||
const b = seal(plaintext, TEST_KEK);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(open(a, TEST_KEK).equals(open(b, TEST_KEK))).toBe(true);
|
||||
});
|
||||
|
||||
it("tampered ciphertext fails GCM authentication", () => {
|
||||
const sealed = seal(Buffer.from("authentic"), TEST_KEK);
|
||||
const last = sealed.length - 1;
|
||||
sealed[last] = (sealed[last] ?? 0) ^ 0xff;
|
||||
expect(() => open(sealed, TEST_KEK)).toThrow();
|
||||
});
|
||||
|
||||
it("wrong KEK cannot open", () => {
|
||||
const otherKek = createHash("sha256").update("other-kek").digest();
|
||||
const sealed = seal(Buffer.from("private"), TEST_KEK);
|
||||
expect(() => open(sealed, otherKek)).toThrow();
|
||||
});
|
||||
|
||||
it("plaintext hash addressing + seal preserves dedup semantics", () => {
|
||||
const a = Buffer.from("media");
|
||||
const b = Buffer.from("media");
|
||||
expect(sha256(a)).toBe(sha256(b));
|
||||
expect(keyForHash(sha256(a), "video/mp4")).toBe(keyForHash(sha256(b), "video/mp4"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("S3Storage with object-scoped credentials", () => {
|
||||
beforeEach(() => { s3Calls.sent.length = 0; });
|
||||
|
||||
it("stores despite 403 on bucket create (pre-provisioned bucket, least-privilege token)", async () => {
|
||||
const storage = new S3Storage({
|
||||
endpoint: "https://s3.example.invalid",
|
||||
accessKeyId: "scoped-key",
|
||||
secretAccessKey: "scoped-secret",
|
||||
bucket: "pre-provisioned",
|
||||
mediaKek: TEST_KEK,
|
||||
});
|
||||
const stored = await storage.putContent(Buffer.from("scoped-token-probe"), "text/plain");
|
||||
expect(stored.deduplicated).toBe(false);
|
||||
expect(s3Calls.sent).toEqual(["Create", "Head", "Put"]);
|
||||
});
|
||||
});
|
||||
224
apps/api/src/storage/storage.ts
Normal file
224
apps/api/src/storage/storage.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
S3ServiceException,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
/**
|
||||
* S3-compatible object storage — the ONLY media I/O path (user directive:
|
||||
* all archive/grabber content lives on object storage, ENCRYPTED — user
|
||||
* directive 2026-10-06). Client-side envelope encryption: every object body
|
||||
* is AES-256-GCM sealed with a random CEK; the CEK is wrapped by the media
|
||||
* KEK (env S3_KEK). The endpoint never sees plaintext. Content addressing
|
||||
* stays on the PLAINTEXT hash (dedup survives); getObject re-verifies the
|
||||
* hash, so storage-level tampering is detected. SSE-at-rest headers are set
|
||||
* additionally for endpoints that honor them (belt-and-braces).
|
||||
*/
|
||||
|
||||
export interface StoredObject {
|
||||
key: string;
|
||||
hash: string;
|
||||
sizeBytes: number;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
putContent(buffer: Buffer, contentType: string): Promise<StoredObject & { deduplicated: boolean }>;
|
||||
getObject(key: string): Promise<Buffer>;
|
||||
hasContent(hash: string, contentType: string): Promise<boolean>;
|
||||
presign(key: string, ttlSeconds: number): Promise<string>;
|
||||
}
|
||||
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"video/quicktime": "mov",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
export function extensionFor(contentType: string): string {
|
||||
return EXTENSIONS[contentType] ?? "bin";
|
||||
}
|
||||
|
||||
export function keyForHash(hash: string, contentType: string): string {
|
||||
return `sha256/${hash}.${extensionFor(contentType)}`;
|
||||
}
|
||||
|
||||
export function sha256(buffer: Buffer): string {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
const FRAME_MAGIC = Buffer.from("SPENC1");
|
||||
|
||||
/** Seal plaintext under the KEK: returns [iv(12) | wrappedCEK | ciphertext+tag]. */
|
||||
export function seal(plaintext: Buffer, kek: Buffer): Buffer {
|
||||
const cek = randomBytes(32);
|
||||
const iv = randomBytes(12);
|
||||
const sealedCek = aesGcmSeal(kek, iv, cek);
|
||||
const cipher = createCipheriv("aes-256-gcm", cek, iv);
|
||||
const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
||||
return Buffer.concat([FRAME_MAGIC, iv, sealedCek, body]);
|
||||
}
|
||||
|
||||
/** Open a sealed frame; verifies GCM tags (CEK wrap + body) and returns plaintext. */
|
||||
export function open(sealed: Buffer, kek: Buffer): Buffer {
|
||||
if (sealed.subarray(0, FRAME_MAGIC.length).equals(FRAME_MAGIC) === false) {
|
||||
throw new Error("storage: not an encrypted frame (magic mismatch)");
|
||||
}
|
||||
let offset = FRAME_MAGIC.length;
|
||||
const iv = sealed.subarray(offset, offset + 12);
|
||||
offset += 12;
|
||||
const cekLen = 32 + 16;
|
||||
const sealedCek = sealed.subarray(offset, offset + cekLen);
|
||||
offset += cekLen;
|
||||
const body = sealed.subarray(offset);
|
||||
const cek = aesGcmOpen(kek, iv, sealedCek);
|
||||
const tag = body.subarray(body.length - 16);
|
||||
const ciphertext = body.subarray(0, body.length - 16);
|
||||
const decipher = createDecipheriv("aes-256-gcm", cek, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
function aesGcmSeal(key: Buffer, iv: Buffer, plaintext: Buffer): Buffer {
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
||||
}
|
||||
|
||||
function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer {
|
||||
const tag = sealed.subarray(sealed.length - 16);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
|
||||
}
|
||||
|
||||
function loadMediaKek(): Buffer {
|
||||
const env = process.env.S3_KEK;
|
||||
if (env !== undefined && env !== "") {
|
||||
const kek = Buffer.from(env, "base64");
|
||||
if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64");
|
||||
return kek;
|
||||
}
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("S3_KEK is required in production (media encryption is mandatory)");
|
||||
}
|
||||
return createHash("sha256").update(`dev-media-kek:${process.env.HOSTNAME ?? "local"}`).digest();
|
||||
}
|
||||
|
||||
export interface S3StorageConfig {
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
bucket?: string;
|
||||
forcePathStyle?: boolean;
|
||||
mediaKek?: Buffer;
|
||||
}
|
||||
|
||||
export function s3ConfigFromEnv(): S3StorageConfig {
|
||||
return {
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||
bucket: process.env.S3_BUCKET ?? "tactical-media-broadcast-hub-media",
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
export class S3Storage implements StorageService {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly kek: Buffer;
|
||||
private bucketReady = false;
|
||||
|
||||
constructor(config: S3StorageConfig) {
|
||||
this.bucket = config.bucket ?? "tactical-media-broadcast-hub-media";
|
||||
this.kek = config.mediaKek ?? loadMediaKek();
|
||||
this.client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region ?? "us-east-1",
|
||||
credentials:
|
||||
config.accessKeyId !== undefined && config.secretAccessKey !== undefined
|
||||
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
||||
: undefined,
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureBucket(): Promise<void> {
|
||||
if (this.bucketReady) return;
|
||||
try {
|
||||
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||
} catch (err) {
|
||||
const status = err instanceof S3ServiceException ? err.$metadata.httpStatusCode : undefined;
|
||||
const name = err instanceof S3ServiceException ? err.name : "";
|
||||
// 403: least-privilege object tokens (e.g. R2 keys scoped to existing
|
||||
// buckets) cannot create buckets. The bucket is pre-provisioned, so
|
||||
// proceed — a genuinely missing bucket fails loudly on the real operation.
|
||||
if (status !== 409 && name !== "BucketAlreadyOwnedByYou" && status !== 403) throw err;
|
||||
}
|
||||
this.bucketReady = true;
|
||||
}
|
||||
|
||||
private async headExists(key: string): Promise<boolean> {
|
||||
try {
|
||||
await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
return true;
|
||||
} catch (err) {
|
||||
const status = err instanceof S3ServiceException ? err.$metadata.httpStatusCode : undefined;
|
||||
const name = err instanceof S3ServiceException ? err.name : "";
|
||||
if (status === 404 || name === "NotFound") return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async putContent(buffer: Buffer, contentType: string): Promise<StoredObject & { deduplicated: boolean }> {
|
||||
await this.ensureBucket();
|
||||
const hash = sha256(buffer);
|
||||
const key = keyForHash(hash, contentType);
|
||||
if (await this.headExists(key)) {
|
||||
return { key, hash, sizeBytes: buffer.byteLength, contentType, deduplicated: true };
|
||||
}
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: seal(buffer, this.kek),
|
||||
ContentType: contentType,
|
||||
ServerSideEncryption: "AES256",
|
||||
}),
|
||||
);
|
||||
return { key, hash, sizeBytes: buffer.byteLength, contentType, deduplicated: false };
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<Buffer> {
|
||||
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
|
||||
const stored = Buffer.from(await res.Body.transformToByteArray());
|
||||
const plaintext = open(stored, this.kek);
|
||||
const hashFromKey = key.split("/")[1]?.split(".")[0];
|
||||
if (hashFromKey !== undefined && sha256(plaintext) !== hashFromKey) {
|
||||
throw new Error(`storage: integrity failure for ${key} (plaintext hash mismatch)`);
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
async hasContent(hash: string, contentType: string): Promise<boolean> {
|
||||
await this.ensureBucket();
|
||||
return this.headExists(keyForHash(hash, contentType));
|
||||
}
|
||||
|
||||
async presign(key: string, ttlSeconds: number): Promise<string> {
|
||||
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: ttlSeconds });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue