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
+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]