Bilagor ut ur händelseloggen — referens och hash i stället för inbäddat

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
This commit is contained in:
Claude
2026-08-04 11:28:52 +00:00
parent a7574015b1
commit d3cae27fa4
23 changed files with 1085 additions and 24 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
COPY server.mjs openapi.yaml ecm-regler.json integrationer.json ./
COPY server.mjs bilagor.mjs openapi.yaml ecm-regler.json integrationer.json ./
ENV NODE_ENV=production PORT=8080
USER node
+220
View File
@@ -0,0 +1,220 @@
// 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,
);
}
+64
View File
@@ -405,6 +405,70 @@ KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"anna@a.se","losenord":"hemligt123"}')
kontroll "andra konton påverkas inte" "$KOD" "200"
# 10f. Bilagor: innehållet ligger utanför händelsen, hashen i loggen
PNG=$(mktemp); printf '\x89PNG\r\n\x1a\nTESTBILD-1' > "$PNG"
SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG")
BIL_ID=$(echo "$SVAR" | falt .id)
BIL_HASH=$(echo "$SVAR" | falt .hash)
kontroll "bilagan får en hash" "${#BIL_HASH}" "64"
kontroll "hashen är innehållets" "$BIL_HASH" "$(sha256sum "$PNG" | cut -d' ' -f1)"
# Samma innehåll igen: ny referens, men bara ett lagrat innehåll
SVAR2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG")
kontroll "identiskt innehåll ger samma hash" "$(echo "$SVAR2" | falt .hash)" "$BIL_HASH"
ANTAL_INNEHALL=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from bilage_innehall where hash='$BIL_HASH'")
kontroll "innehållsadresserat — lagras en gång" "$ANTAL_INNEHALL" "1"
# Hämtning ger tillbaka exakt samma bytes
curl -s "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A" -o /tmp/hamtad.png
kontroll "hämtat innehåll är identiskt" "$(sha256sum /tmp/hamtad.png | cut -d' ' -f1)" "$BIL_HASH"
# Fel mediatyp och tom kropp avvisas
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/pdf' --data-binary "@$PNG")
kontroll "endast bilder och video tas emot" "$KOD" "415"
# Organisationsgränsen gäller bilagor
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_B")
kontroll "org B kommer inte åt org A:s bilaga" "$KOD" "404"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: image/png' --data-binary "@$PNG")
kontroll "org B kan inte ladda upp till org A:s ärende" "$KOD" "404"
# Manipulerat innehåll upptäcks: hashen i loggen är facit
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update bilage_innehall set data = decode('4d414e4950554c45524154', 'hex') where hash='$BIL_HASH'"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A")
kontroll "utbytt innehåll upptäcks och lämnas inte ut" "$KOD" "409"
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update bilage_innehall set data = pg_read_binary_file('$PNG') where hash='$BIL_HASH'" 2>/dev/null \
|| PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "delete from bilage_innehall where hash='$BIL_HASH'"
# Delningsfiltret gäller även bilagor: en bilaga som hör till en intern
# händelsetyp får inte hämtas via kundlänken.
BIL2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id)
BIL3=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id)
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d "{\"handelser\":[
{\"id\":\"h-foto\",\"tidpunkt\":\"2026-08-03T08:07:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"foto\",\"beskrivning\":\"Hjul\",\"bilagaId\":\"$BIL2\"}},
{\"id\":\"h-ao\",\"tidpunkt\":\"2026-08-03T08:08:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"arbetsorder_skannad\",\"falt\":[],\"bilagaId\":\"$BIL3\"}}
]}" >/dev/null
NYKUND=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod)
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL2")
kontroll "kunden når bilagan till ett foto" "$KOD" "200"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL3")
kontroll "kunden når INTE arbetsorderbilden" "$KOD" "404"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL_ID")
kontroll "bilaga utan händelse lämnas inte ut publikt" "$KOD" "404"
rm -f "$PNG" /tmp/hamtad.png
# 11. API-first: OpenAPI-specen serveras live, utan inloggning
SPEC=$(curl -s "$BAS/api/openapi.yaml")
case "$SPEC" in
+105
View File
@@ -311,6 +311,111 @@ paths:
"200": { description: Alla sessioner är avslutade. }
"401": { $ref: "#/components/responses/Fel" }
/api/arenden/{arendeId}/bilagor:
post:
tags: [Ärenden]
summary: Ladda upp en bilaga
description: >
Foton, videoklipp och instrumentbilder. Kroppen är råa bytes och
`Content-Type` anger mediatypen — endast bild och video tas emot.
Servern beräknar innehållets SHA-256 och returnerar en referens
som ska läggas i händelsen; **hashen hamnar därmed i den
append-only-skyddade loggen**, så en utbytt bild går att upptäcka.
Innehållsadresserat: samma innehåll lagras en gång.
parameters:
- name: arendeId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
image/jpeg: { schema: { type: string, format: binary } }
image/png: { schema: { type: string, format: binary } }
image/webp: { schema: { type: string, format: binary } }
video/mp4: { schema: { type: string, format: binary } }
video/webm: { schema: { type: string, format: binary } }
responses:
"200":
description: Referensen att spara i händelsen.
content:
application/json:
schema:
type: object
properties:
id: { type: string }
hash: { type: string, description: "SHA-256 av innehållet, hex." }
mediatyp: { type: string }
storlek: { type: integer }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
"413":
description: Bilagan är större än 32 MB.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"415":
description: Endast bilder och videoklipp tas emot.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/bilagor/{bilagaId}:
get:
tags: [Ärenden]
summary: Hämta en bilaga
description: >
Organisationsknuten. Innehållet kontrolleras mot hashen innan det
lämnas ut — stämmer det inte svarar tjänsten 409 i stället för att
visa en bild som kan ha bytts ut.
parameters:
- name: bilagaId
in: path
required: true
schema: { type: string }
responses:
"200":
description: Innehållet.
content:
application/octet-stream:
schema: { type: string, format: binary }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
"409":
description: Innehållet stämmer inte med hashen i loggen.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/delad/{delningskod}/bilagor/{bilagaId}:
get:
tags: [Delning]
summary: Hämta en bilaga via delningslänk
description: >
Samma filtrering som händelserna: bilagan lämnas bara ut om
händelsen den hör till är synlig på delningens nivå. En bild som
hör till en intern händelsetyp — t.ex. den skannade arbetsordern —
nås alltså aldrig via kundlänken.
security: []
parameters:
- name: delningskod
in: path
required: true
schema: { type: string }
- name: bilagaId
in: path
required: true
schema: { type: string }
responses:
"200":
description: Innehållet.
content:
application/octet-stream:
schema: { type: string, format: binary }
"404": { $ref: "#/components/responses/Fel" }
"409": { $ref: "#/components/responses/Fel" }
/api/integrationer/leverantorer:
get:
tags: [Integrationer]
+110
View File
@@ -25,6 +25,7 @@ import { lookup } from "node:dns/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import pg from "pg";
import { innehallsHash, mediatypGiltig, valjLager } from "./bilagor.mjs";
// API-first: OpenAPI-specen är en versionerad artefakt och serveras live.
const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8");
@@ -50,11 +51,18 @@ const INTEGRATIONER = JSON.parse(
const PORT = Number(process.env.PORT ?? 8080);
const MAX_KROPP = 4 * 1024 * 1024;
// Bilagor får vara större än en händelse — ett videoklipp med ljud är
// evidens som inte går att skala ned hur långt som helst.
const MAX_BILAGA = 32 * 1024 * 1024;
const TOKEN_LIVSTID_S = 12 * 60 * 60;
const ROLLER = ["tekniker", "arbetsledare", "admin"];
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
// Var bilagornas innehåll hamnar. Felkonfigurerat s3-läge failar här,
// vid start, i stället för vid första uppladdningen.
const BILAGELAGER = valjLager(process.env, pool);
// Vilka ursprung som får anropa API:t från en webbläsare. I klusterdriften
// serveras klienten från samma domän som API:t, så listan kan hållas kort.
// TILLATNA_URSPRUNG="https://app.exempel.se,https://demo.exempel.se" —
@@ -267,6 +275,17 @@ async function lasKropp(req) {
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
}
async function lasBinart(req, tak) {
const bitar = [];
let storlek = 0;
for await (const bit of req) {
storlek += bit.length;
if (storlek > tak) throw new Error("för stor kropp");
bitar.push(bit);
}
return Buffer.concat(bitar);
}
function nyKod() {
const tecken = "abcdefghijklmnopqrstuvwxyz0123456789";
const { randomBytes } = crypto;
@@ -404,6 +423,27 @@ export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch)
// ---- Server -----------------------------------------------------------
// Lämnar ut innehållet — men bara efter att det kontrollerats mot
// hashen i loggen. Stämmer det inte säger vi det rakt ut i stället för
// att visa en bild som kan ha bytts ut.
async function skickaBilaga(res, rad) {
const data = await BILAGELAGER.hamta(rad.hash);
if (!data) return svara(res, 404, { error: "Innehållet saknas i lagringen." });
if (innehallsHash(data) !== rad.hash) {
console.error("bilaga: innehållet stämmer inte med hashen i loggen", rad.hash);
return svara(res, 409, { error: "Innehållet stämmer inte med det som dokumenterades." });
}
res.writeHead(200, {
"Content-Type": rad.mediatyp,
"Content-Length": data.length,
// Innehållsadresserat — samma id ger alltid samma bytes.
"Cache-Control": "private, max-age=31536000, immutable",
"Access-Control-Allow-Origin": res.ursprung ?? "*",
Vary: "Origin",
});
return res.end(data);
}
export function skapaServer() {
function loggaIn(res, rad, hemlighet) {
const nu = Math.floor(Date.now() / 1000);
@@ -566,6 +606,32 @@ export function skapaServer() {
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva });
}
// Bilaga via delningslänk. Bilden får bara hämtas om den hör till
// en händelse som nivån faktiskt får se — annars vore det en väg
// runt delningsfiltret.
const delatBilaga = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/bilagor\/([A-Za-z0-9_-]+)$/);
if (req.method === "GET" && delatBilaga) {
const delning = await pool.query(
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
[delatBilaga[1]],
);
if (delning.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." });
const synliga = synligaTyper(delning.rows[0].niva);
const rad = await pool.query(
`select b.hash, b.mediatyp from bilagor b
where b.id = $1 and b.arende_id = $2
and exists (
select 1 from felsokning_handelser h
where h.arende_id = b.arende_id
and h.handelse->>'bilagaId' = b.id
and ($3::text[] is null or h.handelse->>'typ' = any($3))
)`,
[delatBilaga[2], delning.rows[0].arende_id, synliga],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." });
return skickaBilaga(res, rad.rows[0]);
}
// -- Publikt kundgodkännande (den enda skrivande publika vägen) --
//
// Kunden svarar på ett åtgärdsförslag via sin delningslänk. Spärrar:
@@ -699,6 +765,50 @@ export function skapaServer() {
return svara(res, 200, { ok: true });
}
// -- Bilagor --
// Innehållet ligger utanför händelsen; loggen bär referensen och
// innehållets hash. Uppladdningen sker före händelsen skrivs, så
// en händelse aldrig pekar på något som inte finns.
const laddaUppVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/bilagor$/);
if (req.method === "POST" && laddaUppVag) {
if (!(await arendeIOrg(laddaUppVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
const mediatyp = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (!mediatypGiltig(mediatyp)) {
return svara(res, 415, { error: "Endast bilder och videoklipp kan laddas upp." });
}
let data;
try {
data = await lasBinart(req, MAX_BILAGA);
} catch {
return svara(res, 413, { error: `Bilagan är för stor (max ${MAX_BILAGA / 1024 / 1024} MB).` });
}
if (data.length === 0) return svara(res, 400, { error: "Bilagan är tom." });
const hash = innehallsHash(data);
await BILAGELAGER.spara(hash, data);
const id = `bil-${nyKod()}`;
await pool.query(
`insert into bilagor (id, organisation_id, arende_id, hash, mediatyp, storlek, laddad_av)
values ($1, $2, $3, $4, $5, $6, $7)`,
[id, anspr.org, laddaUppVag[1], hash, mediatyp, data.length, anspr.sub],
);
// Hashen går tillbaka till klienten och hamnar i händelsen —
// därmed står den i den append-only-skyddade loggen.
return svara(res, 200, { id, hash, mediatyp, storlek: data.length });
}
const bilagaVag = vag.match(/^\/api\/bilagor\/([A-Za-z0-9_-]+)$/);
if (req.method === "GET" && bilagaVag) {
const rad = await pool.query(
`select hash, mediatyp from bilagor where id = $1 and organisation_id = $2`,
[bilagaVag[1], anspr.org],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan finns inte." });
return skickaBilaga(res, rad.rows[0]);
}
// ECM Knowledge Library: aktuellt regelpaket för inloggade klienter.
if (req.method === "GET" && vag === "/api/ecm/regler") {
res.writeHead(200, {