Files
Cibello-app/packages/storage/src/index.ts
T
Sven (AAMOS AI) 6e2d794130 feat(storage,deploy): delad S3/mock-lagring, SSM .env-sync, Gemini readyz
- @app/storage med MockStorage + AwsStorage delas mellan API och worker

- Workerns readUrl() ger presignerad S3-URL i aws-läge (fixar mock-s3 404)

- sync-env-from-ssm.py hämtar /cibello/prod/* och skriver .env; first-deploy.sh kör det i prod

- /readyz returnerar 503 om app.aamos.healthCheck() misslyckas

- docs/SECRETS.md med rotations- och SSM-regler
2026-08-12 17:28:58 +07:00

220 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHmac, randomUUID } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export interface PresignedUpload {
key: string;
uploadUrl: string;
method: "PUT";
headers: Record<string, string>;
expiresAt: string;
}
export interface StorageService {
presignUpload(prefix: string, contentType: string): Promise<PresignedUpload>;
/** URL som workern/AAMOS kan läsa bilden från. */
getReadUrl(key: string): Promise<string>;
putObject(key: string, data: Buffer, contentType: string): Promise<void>;
getObject(key: string): Promise<Buffer | null>;
/** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */
deleteObject(key: string): Promise<void>;
/**
* Försök extrahera lagringsnyckeln ur en läs-URL. Returnerar null om URL:en
* inte känns igen (t.ex. extern bild). Används vid GDPR-radering så att vi
* kan radera objekt även när endast URL:en är sparad.
*/
extractKeyFromUrl(url: string): string | null;
}
const MOCK_ROOT = path.resolve(process.cwd(), ".data/s3");
export class MockStorage implements StorageService {
constructor(
private readonly baseUrl: string,
private readonly signingSecret: string,
) {}
private sign(key: string): string {
return createHmac("sha256", this.signingSecret).update(key).digest("hex").slice(0, 32);
}
async presignUpload(prefix: string, contentType: string): Promise<PresignedUpload> {
const ext = contentType.split("/")[1] ?? "bin";
const key = `${prefix}/${randomUUID()}.${ext}`;
const sig = this.sign(key);
return {
key,
uploadUrl: `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`,
method: "PUT",
headers: { "content-type": contentType },
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
};
}
async getReadUrl(key: string): Promise<string> {
return `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${this.sign(key)}`;
}
async putObject(key: string, data: Buffer, _contentType?: string): Promise<void> {
const filePath = path.join(MOCK_ROOT, key);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, data);
}
async getObject(key: string): Promise<Buffer | null> {
try {
return await readFile(path.join(MOCK_ROOT, key));
} catch {
return null;
}
}
async deleteObject(key: string): Promise<void> {
await rm(path.join(MOCK_ROOT, key), { force: true }).catch(() => {});
}
extractKeyFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const prefix = "/v1/mock-s3/";
if (!parsed.pathname.startsWith(prefix)) return null;
return decodeURIComponent(parsed.pathname.slice(prefix.length));
} catch {
return null;
}
}
verifySignature(key: string, sig: string): boolean {
return this.sign(key) === sig;
}
}
export interface AwsStorageConfig {
bucket: string;
region: string;
endpoint?: string;
accessKeyId?: string;
secretAccessKey?: string;
}
/** Riktig S3 (AWS eller S3-kompatibel via S3_ENDPOINT). */
export class AwsStorage implements StorageService {
private readonly client: S3Client;
private readonly bucket: string;
constructor(cfg: AwsStorageConfig) {
this.bucket = cfg.bucket;
this.client = new S3Client({
region: cfg.region,
...(cfg.endpoint ? { endpoint: cfg.endpoint, forcePathStyle: true } : {}),
...(cfg.accessKeyId && cfg.secretAccessKey
? { credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey } }
: {}), // annars IAM-roll/instansprofil (rekommenderat i AWS)
});
}
/** Snabb verifiering vid uppstart hellre högljutt fel än trasiga uppladdningar. */
async healthCheck(): Promise<void> {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
}
async presignUpload(prefix: string, contentType: string): Promise<PresignedUpload> {
const ext = contentType.split("/")[1] ?? "bin";
const key = `${prefix}/${randomUUID()}.${ext}`;
const uploadUrl = await getSignedUrl(
this.client,
new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }),
{ expiresIn: 15 * 60 },
);
return {
key,
uploadUrl,
method: "PUT",
headers: { "content-type": contentType },
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
};
}
async getReadUrl(key: string): Promise<string> {
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
expiresIn: 60 * 60,
});
}
async putObject(key: string, data: Buffer, contentType: string): Promise<void> {
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType }),
);
}
async getObject(key: string): Promise<Buffer | null> {
try {
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
const bytes = await res.Body?.transformToByteArray();
return bytes ? Buffer.from(bytes) : null;
} catch {
return null;
}
}
async deleteObject(key: string): Promise<void> {
try {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
} catch (err) {
const code = (err as { Code?: string }).Code;
if (code !== "NoSuchKey" && code !== "NotFound") {
throw err;
}
}
}
extractKeyFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.hostname.startsWith(`${this.bucket}.`)) {
return decodeURIComponent(parsed.pathname.replace(/^\//, ""));
}
const pathPrefix = `/${this.bucket}/`;
if (parsed.pathname.startsWith(pathPrefix)) {
return decodeURIComponent(parsed.pathname.slice(pathPrefix.length));
}
return null;
} catch {
return null;
}
}
}
/**
* Skapa rätt lagringstjänst utifrån S3_MODE. Används av API och worker.
*/
export function createStorageService(cfg: {
mode: "aws" | "mock";
baseUrl: string;
signingSecret: string;
s3Bucket: string;
s3Region: string;
s3Endpoint?: string;
s3AccessKeyId?: string;
s3SecretAccessKey?: string;
}): StorageService {
if (cfg.mode === "aws") {
return new AwsStorage({
bucket: cfg.s3Bucket,
region: cfg.s3Region,
endpoint: cfg.s3Endpoint,
accessKeyId: cfg.s3AccessKeyId,
secretAccessKey: cfg.s3SecretAccessKey,
});
}
return new MockStorage(cfg.baseUrl, cfg.signingSecret);
}