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/.
224 lines
8.3 KiB
TypeScript
224 lines
8.3 KiB
TypeScript
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 });
|
|
}
|
|
}
|