6e2d794130
- @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
220 lines
6.7 KiB
TypeScript
220 lines
6.7 KiB
TypeScript
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);
|
||
}
|