From d3cae27fa4e0f510bd8f404561d019f0992f0d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:28:52 +0000 Subject: [PATCH] =?UTF-8?q?Bilagor=20ut=20ur=20h=C3=A4ndelseloggen=20?= =?UTF-8?q?=E2=80=94=20referens=20och=20hash=20i=20st=C3=A4llet=20f=C3=B6r?= =?UTF-8?q?=20inb=C3=A4ddat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- docs/DRIFT.md | 36 +++ docs/MVP.md | 1 + infra/postgres-init.sql | 26 +++ infra/terraform/20-databas.tf | 5 + infra/terraform/30-plattform.tf | 38 ++++ infra/terraform/karta.tf | 28 ++- infra/terraform/outputs.tf | 2 + infra/terraform/variables.tf | 56 +++++ services/plattform/Dockerfile | 2 +- services/plattform/bilagor.mjs | 220 +++++++++++++++++++ services/plattform/integrationstest.sh | 64 ++++++ services/plattform/openapi.yaml | 105 +++++++++ services/plattform/server.mjs | 110 ++++++++++ src/felsokning/Bilagevisning.tsx | 69 ++++++ src/felsokning/DelatArendeVy.tsx | 9 +- src/felsokning/__tests__/ai.test.ts | 5 + src/felsokning/__tests__/bilagor.test.ts | 146 ++++++++++++ src/felsokning/__tests__/sigv4-referens.json | 29 +++ src/felsokning/bilagor.ts | 96 ++++++++ src/felsokning/domain.ts | 22 +- src/felsokning/projektioner.ts | 14 +- src/pages/felsokning/ArendeSida.tsx | 25 ++- src/pages/felsokning/PublikDelning.tsx | 1 + 23 files changed, 1085 insertions(+), 24 deletions(-) create mode 100644 services/plattform/bilagor.mjs create mode 100644 src/felsokning/Bilagevisning.tsx create mode 100644 src/felsokning/__tests__/bilagor.test.ts create mode 100644 src/felsokning/__tests__/sigv4-referens.json create mode 100644 src/felsokning/bilagor.ts diff --git a/docs/DRIFT.md b/docs/DRIFT.md index 55f4a46..b04d241 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -108,6 +108,42 @@ upp dess CRD redan vid plan. I `extern` läge kör ni `infra/postgres-init.sql` mot databasen själva; det är samma fil som integrationstestet kör. +## Bilagor + +Foton, videoklipp och instrumentbilder låg tidigare 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 stärker bevisvärdet i stället för att försvaga +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. + +| `bilage_lage` | Var innehållet ligger | Använd när | +| --- | --- | --- | +| `databas` (standard) | `bilage_innehall` (bytea) | Fungerar överallt utan konfiguration; bilderna följer med databasens säkerhetskopior | +| `s3` | S3-kompatibel objektlagring (AWS, MinIO, Ceph) | Loggen och bilderna ska växa oberoende av varandra | + +Signeringen mot objektlagringen är egen (SigV4 för PUT och GET) i stället +för molnleverantörens SDK — två operationer motiverar inte tiotals +megabyte beroenden. Den korsverifieras mot botocore i testerna, bit för +bit. + +**Delningsgränsen gäller även bilagor.** En bilaga kan bara hämtas via en +delningslänk om händelsen den hör till är synlig på den nivån; den +skannade arbetsordern nås alltså aldrig via kundlänken. + +Äldre händelser med inbäddad data-URL fortsätter att fungera och kommer +alltid att göra det — loggen är append-only. Lokalt läge, utan +inloggning, bäddar också in: det finns ingen server att ladda upp till, +och dokumentationen får inte gå förlorad för att nätet ligger nere. + ## Åtkomst: spärr och återkallelse En giltig JWT-signatur räcker inte. Varje autentiserat anrop slår upp diff --git a/docs/MVP.md b/docs/MVP.md index f1109dd..50a754d 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -55,6 +55,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. | | Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). | +| Bilagor | ✅ Foton, video och instrumentbilder ligger **utanför händelsen**; loggen bär en referens med innehållets SHA-256. Det stärker bevisvärdet: hashen står i den append-only-skyddade loggen, så en utbytt bild går att upptäcka — och innehållet kontrolleras mot hashen varje gång det lämnas ut (409 i stället för att visa bilden). Innehållsadresserat, så samma foto lagras en gång. Två lägen: `databas` (bytea, fungerar överallt) och `s3` (AWS/MinIO/Ceph) med egen SigV4-signering som korsverifieras bit för bit mot botocore i testerna. Delningsgränsen gäller även bilagor — den skannade arbetsordern nås aldrig via kundlänken. Äldre händelser med inbäddad data-URL fortsätter fungera för alltid, och lokalt läge bäddar in som förut så dokumentation aldrig går förlorad utan nät. | | Åtkomstkontroll | ✅ **Återkallelse är omedelbar**: varje autentiserat anrop kontrollerar att kontot är aktivt och att token-versionen stämmer, i stället för att en avstängning börjar gälla när token går ut. Administratören stänger av och öppnar konton i användarlistan (kan inte stänga av sig själv, aldrig över organisationsgränsen), och var och en kan logga ut på alla enheter när en telefon tappats bort. **Takt-begränsning på inloggning** ligger i databasen och håller därför bakom flera repliker: 10 försök per konto och 30 per källadress inom 15 minuter, och spärren gäller kontot även vid rätt lösenord. Samtliga gränser verifierade i integrationstestet. | | Infrastruktur som kod | ✅ `infra/terraform` är systemets definition ([README](../infra/terraform/README.md)): `karta.tf` beskriver hela systemet en gång som data — tjänster, portar, routing, hemligheter per tjänst, dataflöden och gränser — och `terraform output karta` skriver ut samma sak i klartext. Namnrymden är stängd med nätverkspolicyer (bara ingress→tjänster, plattform→postgres, HTTPS ut utom privata nät), Postgres kör med säkerhetskontext, hemligheter kan genereras eller komma från en secrets-hanterare. **Databasen har tre lägen** och `databas_lage` saknar standardvärde med flit — valet avgör om det finns säkerhetskopiering: `extern` (managerad Postgres, leverantörens PITR — rekommenderat i produktion), `cnpg` (CloudNativePG i klustret: basbackup 02:30 + WAL-arkivering + failover) och `inbyggd` (en volym, ingen backup, spärrad av en precondition när miljön är produktion). Driftsättning är ett eget CI-flöde som startas för hand med en bildtagg mot en miljö med godkännandekrav, kör plan/apply, skriver ut kartan och rökkontrollerar. Kustomize-/Argo CD-vägen är borttagen. | | Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | diff --git a/infra/postgres-init.sql b/infra/postgres-init.sql index 99b3c8f..a4841b6 100644 --- a/infra/postgres-init.sql +++ b/infra/postgres-init.sql @@ -79,6 +79,32 @@ create table if not exists felsokning_arenden ( create index if not exists felsokning_arenden_org_idx on felsokning_arenden (organisation_id, skapad desc); +-- Bilagor: foton, video och instrumentbilder. Innehållet ligger utanför +-- händelsen; loggen bär en referens och innehållets SHA-256. Hashen står +-- i den append-only-skyddade loggen, så en utbytt bild går att upptäcka — +-- starkare bevisvärde än när bilden låg inbäddad och måste tros på. +create table if not exists bilagor ( + id text primary key, + organisation_id uuid not null references organisationer(id), + arende_id text not null, + hash text not null, + mediatyp text not null, + storlek integer not null, + laddad_av uuid references anvandare(id), + skapad timestamptz not null default now() +); +create index if not exists bilagor_arende_idx on bilagor (arende_id); +create index if not exists bilagor_hash_idx on bilagor (hash); + +-- Själva bytesen när BILAGE_LAGE=databas. Innehållsadresserat: samma +-- foto som dokumenteras två gånger lagras en gång. I s3-läget är den +-- här tabellen tom och innehållet ligger i objektlagringen. +create table if not exists bilage_innehall ( + hash text primary key, + data bytea not null, + skapad timestamptz not null default now() +); + create table if not exists felsokning_handelser ( id text primary key, arende_id text not null references felsokning_arenden(id), diff --git a/infra/terraform/20-databas.tf b/infra/terraform/20-databas.tf index 96f868a..dbd0708 100644 --- a/infra/terraform/20-databas.tf +++ b/infra/terraform/20-databas.tf @@ -50,6 +50,11 @@ resource "terraform_data" "databaskontroll" { error_message = "databas_lage = \"cnpg\" kräver backup_nyckel_id och backup_nyckel." } + precondition { + condition = var.bilage_lage != "s3" || (var.s3.endpoint != "" && var.s3.hink != "" && var.s3.region != "" && var.s3_nyckel_id != "" && var.s3_nyckel != "") + error_message = "bilage_lage = \"s3\" kräver s3.endpoint, s3.hink, s3.region, s3_nyckel_id och s3_nyckel." + } + precondition { condition = !local.inbyggd_databas || var.miljo != "produktion" error_message = "databas_lage = \"inbyggd\" saknar säkerhetskopiering och kan inte användas med miljo = \"produktion\". Välj \"extern\" eller \"cnpg\"." diff --git a/infra/terraform/30-plattform.tf b/infra/terraform/30-plattform.tf index 23ab066..a11e6ec 100644 --- a/infra/terraform/30-plattform.tf +++ b/infra/terraform/30-plattform.tf @@ -111,6 +111,44 @@ resource "kubernetes_deployment_v1" "plattform" { value = var.tillat_interna_uppslag ? "true" : "false" } + # Bilagornas innehåll: i databasen eller i objektlagring. + env { + name = "BILAGE_LAGE" + value = var.bilage_lage + } + + dynamic "env" { + for_each = var.bilage_lage == "s3" ? { + S3_ENDPOINT = var.s3.endpoint + S3_HINK = var.s3.hink + S3_REGION = var.s3.region + S3_PREFIX = var.s3.prefix + } : {} + + content { + name = env.key + value = env.value + } + } + + dynamic "env" { + for_each = var.bilage_lage == "s3" ? { + S3_NYCKEL_ID = "s3-nyckel-id" + S3_NYCKEL = "s3-nyckel" + } : {} + + content { + name = env.key + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = env.value + } + } + } + } + resources { requests = { cpu = "100m", memory = "128Mi" } limits = { cpu = "1", memory = "512Mi" } diff --git a/infra/terraform/karta.tf b/infra/terraform/karta.tf index 4f52831..04eb372 100644 --- a/infra/terraform/karta.tf +++ b/infra/terraform/karta.tf @@ -34,10 +34,16 @@ locals { utat = ["webbläsaren anropar plattform och orkester direkt över ingressen"] } plattform = { - roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar." - bild = "${var.register}/${local.namn}-plattform:${var.bildtagg}" - hemligt = ["jwt-hemlighet", "postgres-losenord", "integration-nyckel"] - utat = ["postgres:5432", "kundernas leverantörer över internet (spärrat mot privata nät)"] + roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar." + bild = "${var.register}/${local.namn}-plattform:${var.bildtagg}" + hemligt = concat( + ["jwt-hemlighet", "postgres-losenord", "integration-nyckel"], + var.bilage_lage == "s3" ? ["s3-nyckel-id", "s3-nyckel"] : [], + ) + utat = concat( + ["postgres:5432", "kundernas leverantörer över internet (spärrat mot privata nät)"], + var.bilage_lage == "s3" ? ["objektlagringen för bilagor"] : [], + ) } orkester = { roll = "AI-orkestern. Routar per uppgift till Claude, äger systemprompt och svarsschema. Verifierar plattformens JWT." @@ -82,6 +88,8 @@ locals { var.integration_nyckel, random_id.integration_nyckel.hex, ) + "s3-nyckel-id" = var.s3_nyckel_id + "s3-nyckel" = var.s3_nyckel } # ---- Dataflöden ----------------------------------------------------- @@ -96,11 +104,22 @@ locals { "Plattform → kundens leverantör VIN/regnr ut, fordonsuppgifter in", ] + bilagor = { + lage = var.bilage_lage + var = var.bilage_lage == "s3" ? "${var.s3.endpoint}/${var.s3.hink}/${var.s3.prefix}" : "tabellen bilage_innehall" + hur = join(" ", [ + "Innehållet ligger utanför händelsen; loggen bär referensen och innehållets SHA-256.", + "Innehållsadresserat, så samma foto lagras en gång.", + "Hashen kontrolleras vid utlämning — en utbytt bild lämnas inte ut.", + ]) + } + granser = [ "Organisationsgränsen: varje fråga mot ärendedata filtreras på organisation_id i SQL:en, inte i klienten.", "Delningsgränsen: tillåtelselista över händelsetyper per nivå (kund/partner/intern) — nya typer är interna tills de aktivt släpps fram.", "Hemlighetsgränsen: Claude-nyckeln och kundernas leverantörsnycklar finns bara serversidan. Klienten ser maskerade värden.", "Historikgränsen: append-only i både API och databas (triggers). Ingen roll kan ändra eller radera en händelse.", + "Bilagegränsen: en bilaga kan bara hämtas via en delningslänk om händelsen den hör till är synlig på den nivån.", ] # ---- Det som medvetet inte ingår ------------------------------------ @@ -108,7 +127,6 @@ locals { avgransningar = concat( var.databas_lage == "inbyggd" ? ["INGEN SÄKERHETSKOPIERING — läget inbyggd har en volym och inget mer. Endast prov och demo."] : [], [ - "Objektlagring. Foton och video ligger som data-URL:er i händelseloggen, vilket gör volymen stor och tung att säkerhetskopiera.", "Observability. Ingen metrikexport, ingen tracing — bara containerloggar.", "Takt-begränsning på inloggning. Endast den publika beslutsvägen är begränsad, och bara per pod.", "Återkallelse av utfärdade JWT. En token gäller sin livstid ut även om användaren tas bort.", diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index c83f4aa..43d52d0 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -49,6 +49,8 @@ output "karta" { ) } + bilagor = local.bilagor + drift = { registrering_öppen = var.registrering_oppen interna_uppslag_tillåtna = var.tillat_interna_uppslag diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 799015a..e630df4 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -242,6 +242,62 @@ variable "databas_instanser" { default = 3 } +# ---- Bilagor ----------------------------------------------------------- + +variable "bilage_lage" { + description = <<-TEXT + Var foton och videoklipp lagras. + + "databas" bytea i en egen tabell. Fungerar överallt och kräver + ingen konfiguration, men bilderna följer med databasens + säkerhetskopior och gör dem stora. + + "s3" S3-kompatibel objektlagring (AWS, MinIO, Ceph). Loggen + och bilderna växer oberoende av varandra. Kräver + s3-uppgifterna nedan. + + Innehållet är innehållsadresserat i båda lägena: samma foto lagras + en gång, och hashen står i händelseloggen så en utbytt bild går att + upptäcka. + TEXT + type = string + default = "databas" + + validation { + condition = contains(["databas", "s3"], var.bilage_lage) + error_message = "bilage_lage måste vara \"databas\" eller \"s3\"." + } +} + +variable "s3" { + description = "Objektlagring för bilagor. Används bara när bilage_lage = \"s3\"." + type = object({ + endpoint = string + hink = string + region = string + prefix = optional(string, "bilagor") + }) + default = { + endpoint = "" + hink = "" + region = "" + } +} + +variable "s3_nyckel_id" { + description = "Åtkomstnyckel till bilagornas objektlagring." + type = string + sensitive = true + default = "" +} + +variable "s3_nyckel" { + description = "Hemlig nyckel till bilagornas objektlagring." + type = string + sensitive = true + default = "" +} + variable "databas_storlek" { description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen." type = string diff --git a/services/plattform/Dockerfile b/services/plattform/Dockerfile index da6bd88..8a9b967 100644 --- a/services/plattform/Dockerfile +++ b/services/plattform/Dockerfile @@ -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 diff --git a/services/plattform/bilagor.mjs b/services/plattform/bilagor.mjs new file mode 100644 index 0000000..8b5a6e5 --- /dev/null +++ b/services/plattform/bilagor.mjs @@ -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, + ); +} diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 908582f..98c9551 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -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 diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index c674b97..2969251 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -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] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index ccc5392..8581683 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -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, { diff --git a/src/felsokning/Bilagevisning.tsx b/src/felsokning/Bilagevisning.tsx new file mode 100644 index 0000000..37616fe --- /dev/null +++ b/src/felsokning/Bilagevisning.tsx @@ -0,0 +1,69 @@ +// Visar en bilaga oavsett om innehållet ligger inbäddat i händelsen +// (äldre ärenden, lokalt läge) eller hämtas som referens. + +import { useEffect, useState } from "react"; +import type { Bilaga } from "./domain"; +import { hamtaBilaga } from "./bilagor"; + +function useKalla(bilaga: Bilaga, delningskod?: string): string | null | "laddar" { + const [kalla, setKalla] = useState("laddar"); + const id = bilaga.bilagaId ?? bilaga.dataUrl?.slice(0, 64) ?? ""; + + useEffect(() => { + let aktuell = true; + setKalla("laddar"); + hamtaBilaga(bilaga, delningskod) + .then((url) => aktuell && setKalla(url)) + .catch(() => aktuell && setKalla(null)); + return () => { + aktuell = false; + }; + // Bilagan identifieras av sitt id — objektet självt byts vid varje + // omrendering utan att innehållet ändrats. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, delningskod]); + + return kalla; +} + +function Platshallare({ text }: { text: string }) { + return ( +

+ {text} +

+ ); +} + +export function Bild({ + bilaga, + alt, + className, + delningskod, +}: { + bilaga: Bilaga; + alt: string; + className?: string; + delningskod?: string; +}) { + const kalla = useKalla(bilaga, delningskod); + if (kalla === "laddar") return ; + // Ärligt om att bilden inte gick att hämta, i stället för en trasig + // bildikon som lämnar teknikern i tvivel om vad som dokumenterats. + if (!kalla) return ; + return {alt}; +} + +export function Klipp({ + bilaga, + className, + delningskod, +}: { + bilaga: Bilaga; + className?: string; + delningskod?: string; +}) { + const kalla = useKalla(bilaga, delningskod); + if (kalla === "laddar") return ; + if (!kalla) return ; + return