d3cae27fa4
Foton, video och instrumentbilder låg som data-URL:er inne i händelserna. Det drabbade allt som läser loggen: synken drog med hela bildmassan var femtonde sekund, kundvyn likaså, och en säkerhetskopia av loggen var i praktiken en kopia av alla foton. Nu ligger innehållet utanför händelsen och loggen bär en referens med innehållets SHA-256. Det försvagar inte bevisvärdet utan stärker det: hashen står i den append-only-skyddade loggen, så en bild som bytts ut går att upptäcka. Tidigare låg bilden i loggen och måste helt enkelt tros på. Innehållet kontrolleras mot hashen varje gång det lämnas ut — stämmer det inte svarar tjänsten 409 i stället för att visa bilden. Innehållsadresserat, så samma foto som dokumenteras två gånger lagras en gång. Två lägen: databas (bytea, fungerar överallt utan konfiguration) och s3 (AWS, MinIO, Ceph). Signeringen är egen — SigV4 för PUT och GET — i stället för molnleverantörens SDK, eftersom två operationer inte motiverar tiotals megabyte beroenden i en bild som annars bara har pg-drivrutinen. Den korsverifieras bit för bit mot botocore i testerna. Det avslöjade en riktig bugg direkt: host-huvudet saknade portnummer, vilket hade fungerat mot AWS men avvisats av all självhostad S3. Kodningen av objektnycklar kanoniseras medvetet inte. S3 följer andra URL-regler än övriga AWS-tjänster och en felgissad regel ger signaturer som ser rimliga ut men avvisas. I stället begränsas hink och prefix till tecken som aldrig behöver kodas — då finns ingen regel att gissa fel på. Delningsgränsen gäller även bilagor: en bilaga lämnas bara ut via en delningslänk om händelsen den hör till är synlig på den nivån, så den skannade arbetsordern nås aldrig via kundlänken. Uppladdningen sker på ett enda ställe — sidans egna skicka() flyttar innehållet innan händelsen skrivs, så ingen panel behövde ändras. Misslyckas det, eller saknas server som i lokalt läge, bäddas det in precis som förut. Dokumentationen får aldrig gå förlorad för att nätet ligger nere, och äldre händelser med inbäddad data-URL fortsätter fungera för alltid eftersom loggen är append-only. Verifierat: 96 vitest-tester (varav 9 nya för signering och innehållsadressering), typkontroll, eslint, OpenAPI-validering, terraform fmt och referenskontroll, samt integrationstest mot riktig Postgres med 13 nya kontroller — bland annat att ett manipulerat innehåll upptäcks och inte lämnas ut, och att kundlänken når fotot men inte arbetsordern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
221 lines
7.6 KiB
JavaScript
221 lines
7.6 KiB
JavaScript
// Bilagor — foton, videoklipp och instrumentbilder.
|
|
//
|
|
// Tidigare låg de som data-URL:er inne i händelserna. Det gjorde
|
|
// händelseloggen tung på ett sätt som drabbade allt som läser den:
|
|
// synken drog med hela bildmassan var femtonde sekund, kundvyn likaså,
|
|
// och en säkerhetskopia av loggen var i praktiken en kopia av alla foton.
|
|
//
|
|
// Nu ligger innehållet utanför händelsen och loggen bär en referens med
|
|
// SHA-256 av innehållet. Det är inte en försvagning av bevisvärdet utan
|
|
// en förstärkning: hashen står i den append-only-skyddade loggen, så en
|
|
// bild som bytts ut går att upptäcka. Tidigare låg bilden i loggen och
|
|
// måste helt enkelt tros på.
|
|
//
|
|
// Innehållsadressering ger dedup på köpet — samma foto som dokumenteras
|
|
// två gånger lagras en gång.
|
|
//
|
|
// Två lager, valda med BILAGE_LAGE:
|
|
// databas bytea i en egen tabell (standard, fungerar överallt)
|
|
// s3 S3-kompatibel objektlagring — loggen och bilderna växer
|
|
// då oberoende av varandra
|
|
|
|
import { createHash, createHmac } from "node:crypto";
|
|
|
|
export function innehallsHash(buffert) {
|
|
return createHash("sha256").update(buffert).digest("hex");
|
|
}
|
|
|
|
// Vad som får laddas upp. Listan är avsiktligt kort: det här är
|
|
// dokumentation av ett fordon, inte en filserver.
|
|
export const TILLATNA_MEDIATYPER = [
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/webp",
|
|
"video/mp4",
|
|
"video/webm",
|
|
"video/quicktime",
|
|
];
|
|
|
|
export function mediatypGiltig(typ) {
|
|
return TILLATNA_MEDIATYPER.includes((typ ?? "").split(";")[0].trim().toLowerCase());
|
|
}
|
|
|
|
// ---- AWS Signature Version 4 -------------------------------------------
|
|
//
|
|
// Egen implementation i stället för molnleverantörens SDK: tjänsten
|
|
// behöver två operationer (PUT och GET av ett objekt) och SDK:t hade
|
|
// dragit in tiotals megabyte beroenden i en bild som annars bara har
|
|
// pg-drivrutinen. Signeringen korsverifieras mot botocore i testerna.
|
|
|
|
const hmac = (nyckel, data) => createHmac("sha256", nyckel).update(data, "utf8").digest();
|
|
|
|
export function signeringsnyckel(hemlighet, datum, region, tjanst) {
|
|
const d = hmac(`AWS4${hemlighet}`, datum);
|
|
const r = hmac(d, region);
|
|
const t = hmac(r, tjanst);
|
|
return hmac(t, "aws4_request");
|
|
}
|
|
|
|
// Sökvägen kanoniseras INTE här, och det är ett medvetet val.
|
|
//
|
|
// S3 följer andra URL-kodningsregler än övriga AWS-tjänster, och en
|
|
// felgissad regel ger signaturer som ser rimliga ut men avvisas. I
|
|
// stället begränsas det som kan hamna i en nyckel (se namnGiltigt) till
|
|
// tecken som aldrig behöver kodas: hink och prefix är [a-z0-9.-/] och
|
|
// resten av nyckeln är hexadecimal ur innehållshashen. Då finns ingen
|
|
// kodningsfråga att gissa fel på.
|
|
export const NAMN_MONSTER = /^[a-z0-9][a-z0-9./-]*$/;
|
|
|
|
export function namnGiltigt(namn) {
|
|
return typeof namn === "string" && namn.length <= 128 && NAMN_MONSTER.test(namn) && !namn.includes("..");
|
|
}
|
|
|
|
/**
|
|
* Bygger Authorization-huvudet för en S3-förfrågan.
|
|
*
|
|
* @param metod HTTP-metod, t.ex. "PUT"
|
|
* @param url fullständig URL till objektet
|
|
* @param nyttolast kroppen (Buffer) — tom Buffer för GET
|
|
* @param uppgifter { nyckelId, hemlighet, region }
|
|
* @param tidsstampel ISO-basic, t.ex. "20260803T120000Z"
|
|
*/
|
|
export function signera(metod, url, nyttolast, uppgifter, tidsstampel) {
|
|
// host, inte hostname: vid en icke-standardport måste porten med i
|
|
// huvudet, annars avvisar självhostad S3 (MinIO, Ceph) signaturen.
|
|
const { host, pathname, search } = new URL(url);
|
|
const datum = tidsstampel.slice(0, 8);
|
|
const nyttolastHash = innehallsHash(nyttolast);
|
|
|
|
const huvuden = {
|
|
host,
|
|
"x-amz-content-sha256": nyttolastHash,
|
|
"x-amz-date": tidsstampel,
|
|
};
|
|
const signerade = Object.keys(huvuden).sort();
|
|
const kanoniskaHuvuden = signerade.map((n) => `${n}:${huvuden[n].trim()}\n`).join("");
|
|
const signeradeNamn = signerade.join(";");
|
|
|
|
// Frågesträngen ska vara sorterad och kodad. Vi använder inga
|
|
// parametrar i dag, men kanoniseringen måste ändå vara rätt.
|
|
const parametrar = [...new URLSearchParams(search).entries()]
|
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
.map(([n, v]) => `${encodeURIComponent(n)}=${encodeURIComponent(v)}`)
|
|
.join("&");
|
|
|
|
const kanoniskForfragan = [
|
|
metod,
|
|
pathname,
|
|
parametrar,
|
|
kanoniskaHuvuden,
|
|
signeradeNamn,
|
|
nyttolastHash,
|
|
].join("\n");
|
|
|
|
const omfang = `${datum}/${uppgifter.region}/s3/aws4_request`;
|
|
const attSignera = [
|
|
"AWS4-HMAC-SHA256",
|
|
tidsstampel,
|
|
omfang,
|
|
innehallsHash(Buffer.from(kanoniskForfragan, "utf8")),
|
|
].join("\n");
|
|
|
|
const signatur = createHmac("sha256", signeringsnyckel(uppgifter.hemlighet, datum, uppgifter.region, "s3"))
|
|
.update(attSignera, "utf8")
|
|
.digest("hex");
|
|
|
|
return {
|
|
...huvuden,
|
|
Authorization:
|
|
`AWS4-HMAC-SHA256 Credential=${uppgifter.nyckelId}/${omfang}, ` +
|
|
`SignedHeaders=${signeradeNamn}, Signature=${signatur}`,
|
|
};
|
|
}
|
|
|
|
// ---- Lager --------------------------------------------------------------
|
|
|
|
// Innehållsadresserat: nyckeln ÄR hashen. Två identiska filer blir en.
|
|
function objektnyckel(prefix, hash) {
|
|
const rent = prefix.replace(/^\/+|\/+$/g, "");
|
|
return `${rent ? `${rent}/` : ""}${hash.slice(0, 2)}/${hash}`;
|
|
}
|
|
|
|
export function skapaS3Lager(konfig, hamtare = fetch, nu = () => new Date()) {
|
|
const prefix = (konfig.prefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
if (!namnGiltigt(konfig.hink) || (prefix !== "" && !namnGiltigt(prefix))) {
|
|
throw new Error(
|
|
"S3_HINK och S3_PREFIX får bara innehålla a-z, 0-9, punkt, bindestreck och snedstreck.",
|
|
);
|
|
}
|
|
const bas = konfig.endpoint.replace(/\/$/, "");
|
|
|
|
const url = (hash) => `${bas}/${konfig.hink}/${objektnyckel(prefix, hash)}`;
|
|
const stampel = () => nu().toISOString().replace(/[-:]|\.\d{3}/g, "");
|
|
|
|
return {
|
|
namn: "s3",
|
|
|
|
async spara(hash, data) {
|
|
const mal = url(hash);
|
|
const svar = await hamtare(mal, {
|
|
method: "PUT",
|
|
headers: signera("PUT", mal, data, konfig, stampel()),
|
|
body: data,
|
|
});
|
|
if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid skrivning.`);
|
|
},
|
|
|
|
async hamta(hash) {
|
|
const mal = url(hash);
|
|
const svar = await hamtare(mal, {
|
|
method: "GET",
|
|
headers: signera("GET", mal, Buffer.alloc(0), konfig, stampel()),
|
|
});
|
|
if (svar.status === 404) return null;
|
|
if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid läsning.`);
|
|
return Buffer.from(await svar.arrayBuffer());
|
|
},
|
|
};
|
|
}
|
|
|
|
export function skapaDatabasLager(pool) {
|
|
return {
|
|
namn: "databas",
|
|
|
|
async spara(hash, data) {
|
|
// Innehållsadresserat: finns hashen redan är filen redan sparad.
|
|
await pool.query(
|
|
`insert into bilage_innehall (hash, data) values ($1, $2) on conflict (hash) do nothing`,
|
|
[hash, data],
|
|
);
|
|
},
|
|
|
|
async hamta(hash) {
|
|
const rad = await pool.query(`select data from bilage_innehall where hash = $1`, [hash]);
|
|
return rad.rowCount === 0 ? null : rad.rows[0].data;
|
|
},
|
|
};
|
|
}
|
|
|
|
// Väljer lager ur miljön. Saknas något som s3-läget kräver failar vi
|
|
// hellre vid start än vid första uppladdningen.
|
|
export function valjLager(env, pool, hamtare = fetch) {
|
|
if ((env.BILAGE_LAGE ?? "databas") !== "s3") return skapaDatabasLager(pool);
|
|
|
|
const saknas = ["S3_ENDPOINT", "S3_HINK", "S3_REGION", "S3_NYCKEL_ID", "S3_NYCKEL"].filter((n) => !env[n]);
|
|
if (saknas.length > 0) {
|
|
throw new Error(`BILAGE_LAGE=s3 kräver ${saknas.join(", ")}.`);
|
|
}
|
|
|
|
return skapaS3Lager(
|
|
{
|
|
endpoint: env.S3_ENDPOINT,
|
|
hink: env.S3_HINK,
|
|
region: env.S3_REGION,
|
|
prefix: env.S3_PREFIX ?? "bilagor",
|
|
nyckelId: env.S3_NYCKEL_ID,
|
|
hemlighet: env.S3_NYCKEL,
|
|
},
|
|
hamtare,
|
|
);
|
|
}
|