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:
@@ -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
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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\"."
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -49,6 +49,8 @@ output "karta" {
|
||||
)
|
||||
}
|
||||
|
||||
bilagor = local.bilagor
|
||||
|
||||
drift = {
|
||||
registrering_öppen = var.registrering_oppen
|
||||
interna_uppslag_tillåtna = var.tillat_interna_uppslag
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<string | null | "laddar">("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 (
|
||||
<p className="rounded border border-dashed border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#707070]">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bild({
|
||||
bilaga,
|
||||
alt,
|
||||
className,
|
||||
delningskod,
|
||||
}: {
|
||||
bilaga: Bilaga;
|
||||
alt: string;
|
||||
className?: string;
|
||||
delningskod?: string;
|
||||
}) {
|
||||
const kalla = useKalla(bilaga, delningskod);
|
||||
if (kalla === "laddar") return <Platshallare text="Hämtar bild …" />;
|
||||
// Ä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 <Platshallare text="Bilden kunde inte hämtas." />;
|
||||
return <img src={kalla} alt={alt} className={className} />;
|
||||
}
|
||||
|
||||
export function Klipp({
|
||||
bilaga,
|
||||
className,
|
||||
delningskod,
|
||||
}: {
|
||||
bilaga: Bilaga;
|
||||
className?: string;
|
||||
delningskod?: string;
|
||||
}) {
|
||||
const kalla = useKalla(bilaga, delningskod);
|
||||
if (kalla === "laddar") return <Platshallare text="Hämtar videoklipp …" />;
|
||||
if (!kalla) return <Platshallare text="Videoklippet kunde inte hämtas." />;
|
||||
return <video src={kalla} controls className={className} />;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { Arende } from "./domain";
|
||||
import { KUNDBESLUT_LABEL, handelseRubrik } from "./domain";
|
||||
import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader, videor } from "./projektioner";
|
||||
import { metodikForArende } from "./store";
|
||||
import { Bild, Klipp } from "./Bilagevisning";
|
||||
import { tidDatum, tidKlockslag } from "./format";
|
||||
import { FelsokningSkal, Panel, StorKnapp } from "./ui";
|
||||
import { IkonCheck, IkonKlocka, IkonUppdatera } from "./ikoner";
|
||||
@@ -107,10 +108,14 @@ export function DelatArendeVy({
|
||||
notis,
|
||||
redanFiltrerad = false,
|
||||
vidBeslut,
|
||||
delningskod,
|
||||
}: {
|
||||
arende: Arende;
|
||||
nu: string;
|
||||
notis: string;
|
||||
// Satt i den publika vyn: bilagor hämtas då via delningens egen väg,
|
||||
// som filtrerar på samma behörighetsnivå som händelserna.
|
||||
delningskod?: string;
|
||||
// Satt endast på den publika kundlänken: kunden kan svara på ett
|
||||
// åtgärdsförslag. Utan den är vyn helt skrivskyddad.
|
||||
vidBeslut?: (beslut: "godkant" | "avbojt", kommentar: string) => Promise<string | null>;
|
||||
@@ -205,7 +210,7 @@ export function DelatArendeVy({
|
||||
<Panel rubrik="Video">
|
||||
{klipp.map((v, i) => (
|
||||
<figure key={i} className="mb-2">
|
||||
<video src={v.dataUrl} controls className="w-full rounded border border-[#C6C6C6]" />
|
||||
<Klipp bilaga={v.bilaga} delningskod={delningskod} className="w-full rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
@@ -216,7 +221,7 @@ export function DelatArendeVy({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{bilder.map((bild, i) => (
|
||||
<figure key={i}>
|
||||
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
|
||||
<Bild bilaga={bild.bilaga} alt={bild.beskrivning} delningskod={delningskod} className="rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -62,6 +62,8 @@ describe("AI-orkestern", () => {
|
||||
|
||||
const schema = readFileSync("infra/postgres-init.sql", "utf8");
|
||||
expect(schema).toContain("create table if not exists inloggningsforsok");
|
||||
expect(schema).toContain("create table if not exists bilagor");
|
||||
expect(schema).toContain("create table if not exists bilage_innehall");
|
||||
expect(schema).toContain("token_version integer not null default 0");
|
||||
expect(schema).toContain("before update or delete on felsokning_handelser");
|
||||
expect(schema).toContain("before update or delete on felsokning_arenden");
|
||||
@@ -96,6 +98,9 @@ describe("AI-orkestern", () => {
|
||||
["/api/anvandare/{anvandarId}/avaktivera", "kontoVag"],
|
||||
["/api/anvandare/{anvandarId}/aktivera", "aktivera"],
|
||||
["/api/auth/logga-ut-alla", "/api/auth/logga-ut-alla"],
|
||||
["/api/arenden/{arendeId}/bilagor", "laddaUppVag"],
|
||||
["/api/bilagor/{bilagaId}", "bilagaVag"],
|
||||
["/api/delad/{delningskod}/bilagor/{bilagaId}", "delatBilaga"],
|
||||
];
|
||||
for (const [iSpec, iServer] of vagar) {
|
||||
expect(spec).toContain(`${iSpec}:`);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment node
|
||||
// Bilagornas två egenskaper som måste hålla: signeringen mot
|
||||
// objektlagringen är korrekt (annars fungerar inget alls), och
|
||||
// innehållsadresseringen är verkligen innehållsadresserad — hashen är
|
||||
// det som binder bilden till händelseloggen.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
innehallsHash,
|
||||
mediatypGiltig,
|
||||
signera,
|
||||
signeringsnyckel,
|
||||
skapaS3Lager,
|
||||
valjLager,
|
||||
} from "../../../services/plattform/bilagor.mjs";
|
||||
|
||||
// Referenssignaturer framtagna med botocore (AWS egen implementation) —
|
||||
// se scratchpad/sigv4-referens.py. Att vår signering ger exakt samma
|
||||
// resultat är den enda meningsfulla kontrollen: en signatur är antingen
|
||||
// bitidentisk eller värdelös.
|
||||
const REFERENS = JSON.parse(readFileSync("src/felsokning/__tests__/sigv4-referens.json", "utf8")) as {
|
||||
namn: string;
|
||||
metod: string;
|
||||
url: string;
|
||||
kropp_base64: string;
|
||||
region: string;
|
||||
tid: string;
|
||||
authorization: string;
|
||||
}[];
|
||||
|
||||
const UPPGIFTER = {
|
||||
nyckelId: "AKIDEXAMPLE",
|
||||
hemlighet: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
};
|
||||
|
||||
describe("signering mot objektlagringen", () => {
|
||||
it("ger bitidentiska signaturer med botocore", () => {
|
||||
expect(REFERENS.length).toBeGreaterThanOrEqual(3);
|
||||
for (const fall of REFERENS) {
|
||||
const huvuden = signera(
|
||||
fall.metod,
|
||||
fall.url,
|
||||
Buffer.from(fall.kropp_base64, "base64"),
|
||||
{ ...UPPGIFTER, region: fall.region },
|
||||
fall.tid,
|
||||
);
|
||||
expect(huvuden.Authorization, fall.namn).toBe(fall.authorization);
|
||||
}
|
||||
});
|
||||
|
||||
it("härleder signeringsnyckeln enligt AWS testvektor", () => {
|
||||
// Publicerad vektor ur AWS dokumentation för nyckelhärledning.
|
||||
const nyckel = signeringsnyckel("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "20150830", "us-east-1", "iam");
|
||||
expect(nyckel.toString("hex")).toBe(
|
||||
"c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9",
|
||||
);
|
||||
});
|
||||
|
||||
it("en ändrad byte i kroppen ger en annan signatur", () => {
|
||||
const bas = { ...UPPGIFTER, region: "eu-north-1" };
|
||||
const a = signera("PUT", "https://s3.exempel.se/hink/x", Buffer.from("hej"), bas, "20260803T120000Z");
|
||||
const b = signera("PUT", "https://s3.exempel.se/hink/x", Buffer.from("hej!"), bas, "20260803T120000Z");
|
||||
expect(a.Authorization).not.toBe(b.Authorization);
|
||||
expect(a["x-amz-content-sha256"]).not.toBe(b["x-amz-content-sha256"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("innehållsadressering", () => {
|
||||
it("samma innehåll ger samma nyckel, olika innehåll olika", () => {
|
||||
expect(innehallsHash(Buffer.from("samma"))).toBe(innehallsHash(Buffer.from("samma")));
|
||||
expect(innehallsHash(Buffer.from("a"))).not.toBe(innehallsHash(Buffer.from("b")));
|
||||
expect(innehallsHash(Buffer.from(""))).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("objektet läggs under sin egen hash — identiska filer lagras en gång", async () => {
|
||||
const skrivna: string[] = [];
|
||||
const lager = skapaS3Lager(
|
||||
{ endpoint: "https://s3.exempel.se", hink: "hink", region: "eu-north-1", prefix: "bilagor", ...UPPGIFTER },
|
||||
async (url: string, init: { method: string }) => {
|
||||
if (init.method === "PUT") skrivna.push(url);
|
||||
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
},
|
||||
);
|
||||
|
||||
const data = Buffer.from("ett foto");
|
||||
const hash = innehallsHash(data);
|
||||
await lager.spara(hash, data);
|
||||
await lager.spara(hash, data);
|
||||
|
||||
// Båda skrivningarna pekar på samma nyckel: lagringen skriver över
|
||||
// sig själv i stället för att lägga till en dubblett.
|
||||
expect(new Set(skrivna).size).toBe(1);
|
||||
expect(skrivna[0]).toBe(`https://s3.exempel.se/hink/bilagor/${hash.slice(0, 2)}/${hash}`);
|
||||
});
|
||||
|
||||
it("saknat objekt ger null, fel från lagringen kastas vidare", async () => {
|
||||
const svar = { status: 404, ok: false };
|
||||
const lager = skapaS3Lager(
|
||||
{ endpoint: "https://s3.exempel.se", hink: "h", region: "eu-north-1", ...UPPGIFTER },
|
||||
async () => svar,
|
||||
);
|
||||
expect(await lager.hamta("a".repeat(64))).toBeNull();
|
||||
|
||||
svar.status = 500;
|
||||
await expect(lager.hamta("a".repeat(64))).rejects.toThrow("500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("val av lager", () => {
|
||||
it("standard är databasen — fungerar utan konfiguration", () => {
|
||||
expect(valjLager({}, null).namn).toBe("databas");
|
||||
expect(valjLager({ BILAGE_LAGE: "databas" }, null).namn).toBe("databas");
|
||||
});
|
||||
|
||||
it("s3 utan fullständig konfiguration failar direkt, inte vid första uppladdningen", () => {
|
||||
expect(() => valjLager({ BILAGE_LAGE: "s3" }, null)).toThrow(/S3_ENDPOINT/);
|
||||
expect(() =>
|
||||
valjLager({ BILAGE_LAGE: "s3", S3_ENDPOINT: "https://s3.exempel.se", S3_HINK: "h" }, null),
|
||||
).toThrow(/S3_REGION/);
|
||||
expect(
|
||||
valjLager(
|
||||
{
|
||||
BILAGE_LAGE: "s3",
|
||||
S3_ENDPOINT: "https://s3.exempel.se",
|
||||
S3_HINK: "h",
|
||||
S3_REGION: "eu-north-1",
|
||||
S3_NYCKEL_ID: "a",
|
||||
S3_NYCKEL: "b",
|
||||
},
|
||||
null,
|
||||
).namn,
|
||||
).toBe("s3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("vad som får laddas upp", () => {
|
||||
it("bilder och video, inget annat", () => {
|
||||
for (const typ of ["image/jpeg", "image/png", "image/webp", "video/mp4", "video/webm", "IMAGE/JPEG"]) {
|
||||
expect(mediatypGiltig(typ), typ).toBe(true);
|
||||
}
|
||||
expect(mediatypGiltig("image/jpeg; charset=binary")).toBe(true);
|
||||
for (const typ of ["application/pdf", "text/html", "application/octet-stream", "", undefined]) {
|
||||
expect(mediatypGiltig(typ as string), String(typ)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"namn": "put-liten",
|
||||
"metod": "PUT",
|
||||
"url": "https://s3.eu-north-1.amazonaws.com/verkstad-bilagor/bilagor/ab/abcdef",
|
||||
"kropp_base64": "aGVq",
|
||||
"region": "eu-north-1",
|
||||
"tid": "20260804T111952Z",
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/eu-north-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=20ee842bb592f8af0cfe2dbf1e697693b12de1aa677812a1a12628687f4ab102"
|
||||
},
|
||||
{
|
||||
"namn": "get-tom",
|
||||
"metod": "GET",
|
||||
"url": "https://s3.eu-north-1.amazonaws.com/verkstad-bilagor/bilagor/ff/ffff",
|
||||
"kropp_base64": "",
|
||||
"region": "eu-north-1",
|
||||
"tid": "20260804T111952Z",
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/eu-north-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=7002e3616b3ab2a3a2a55927827afbe9e197eb47e4ebfe6def73b56de5c2f266"
|
||||
},
|
||||
{
|
||||
"namn": "annan-endpoint",
|
||||
"metod": "PUT",
|
||||
"url": "https://minio.exempel.se:9000/hink/verkstad-1/bilagor/0f/0f9a2b",
|
||||
"kropp_base64": "AAECYmluYXJ0",
|
||||
"region": "us-east-1",
|
||||
"tid": "20260804T111952Z",
|
||||
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=09e038fa4e0ba6b967bfd8e88dde00285f3090f7609c935eabe04c4eae68758e"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
// Bilagor på klientsidan.
|
||||
//
|
||||
// Innehållet ligger inte längre i händelsen utan hämtas separat. Det gör
|
||||
// synken lätt igen: en ärendesynk var femtonde sekund drar inte med sig
|
||||
// hela bildmassan, och kundvyn laddar bara de bilder som faktiskt visas.
|
||||
//
|
||||
// I lokalt läge (ingen inloggning) finns ingen server att ladda upp till.
|
||||
// Då används data-URL:en precis som förut — appen ska fungera utan
|
||||
// backend, och en tekniker som dokumenterar offline får inte tappa något.
|
||||
|
||||
import type { Bilaga } from "./domain";
|
||||
|
||||
// Objekt-URL:er per bilage-id. Innehållsadresserat på servern, så en
|
||||
// hämtad bilaga kan cachas obegränsat under sidans livstid.
|
||||
const cache = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function harBilaga(b: Bilaga | undefined): boolean {
|
||||
return !!b && (!!b.bilagaId || !!b.dataUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Laddar upp innehållet och returnerar referensen som ska sparas i
|
||||
* händelsen. Returnerar null i lokalt läge — då sparas data-URL:en i
|
||||
* stället.
|
||||
*/
|
||||
export async function laddaUppBilaga(
|
||||
arendeId: string,
|
||||
dataUrl: string,
|
||||
): Promise<{ bilagaId: string; bilagaHash: string } | null> {
|
||||
const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform");
|
||||
if (!plattformAktiv() || !plattformToken()) return null;
|
||||
|
||||
const svar = await fetch(dataUrl);
|
||||
const blob = await svar.blob();
|
||||
|
||||
const res = await fetch(`${PLATTFORM_URL}/api/arenden/${arendeId}/bilagor`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": blob.type || "application/octet-stream",
|
||||
Authorization: `Bearer ${plattformToken()}`,
|
||||
},
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Bilagan kunde inte sparas (${res.status}).`);
|
||||
const { id, hash } = (await res.json()) as { id: string; hash: string };
|
||||
return { bilagaId: id, bilagaHash: hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bygger det som ska in i händelsen. Lyckas uppladdningen bär händelsen
|
||||
* en referens; annars data-URL:en. Det senare är inte ett fel utan
|
||||
* lokalt läge — dokumentationen får aldrig gå förlorad för att nätet
|
||||
* ligger nere.
|
||||
*/
|
||||
export async function bilageFalt(arendeId: string, dataUrl: string): Promise<Bilaga> {
|
||||
try {
|
||||
const referens = await laddaUppBilaga(arendeId, dataUrl);
|
||||
if (referens) return referens;
|
||||
} catch {
|
||||
// Faller igenom till inbäddning nedan.
|
||||
}
|
||||
return { dataUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Var innehållet finns att hämta. `delningskod` sätts i den publika
|
||||
* vyn — där går hämtningen via delningens egen väg, som filtrerar på
|
||||
* samma nivå som händelserna.
|
||||
*/
|
||||
export async function hamtaBilaga(b: Bilaga, delningskod?: string): Promise<string | null> {
|
||||
if (b.dataUrl) return b.dataUrl;
|
||||
if (!b.bilagaId) return null;
|
||||
|
||||
const nyckel = `${delningskod ?? ""}:${b.bilagaId}`;
|
||||
const befintlig = cache.get(nyckel);
|
||||
if (befintlig) return befintlig;
|
||||
|
||||
const hamtning = (async () => {
|
||||
const { PLATTFORM_URL, plattformToken } = await import("./plattform");
|
||||
if (!PLATTFORM_URL) return null;
|
||||
|
||||
const url = delningskod
|
||||
? `${PLATTFORM_URL}/api/delad/${delningskod}/bilagor/${b.bilagaId}`
|
||||
: `${PLATTFORM_URL}/api/bilagor/${b.bilagaId}`;
|
||||
const token = plattformToken();
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: !delningskod && token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return URL.createObjectURL(await res.blob());
|
||||
})();
|
||||
|
||||
cache.set(nyckel, hamtning);
|
||||
return hamtning;
|
||||
}
|
||||
@@ -73,9 +73,23 @@ export interface ArbetsorderFalt {
|
||||
}
|
||||
|
||||
// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
|
||||
// Bilagans innehåll ligger utanför händelsen: loggen bär referensen och
|
||||
// innehållets SHA-256. Hashen står därmed i den append-only-skyddade
|
||||
// loggen och en utbytt bild går att upptäcka.
|
||||
//
|
||||
// dataUrl finns kvar och kommer alltid att finnas kvar. Loggen är
|
||||
// append-only, så händelser som skrevs när bilden låg inbäddad måste
|
||||
// gå att läsa för alltid. Lokalt läge (utan inloggning) använder den
|
||||
// också — där finns ingen server att ladda upp till.
|
||||
export interface Bilaga {
|
||||
bilagaId?: string;
|
||||
bilagaHash?: string;
|
||||
dataUrl?: string;
|
||||
}
|
||||
|
||||
export type Handelse =
|
||||
| { typ: "objekt_identifierat"; objekt: Objekt }
|
||||
| { typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[]; dataUrl?: string }
|
||||
| ({ typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[] } & Bilaga)
|
||||
| { typ: "felbeskrivning"; text: string }
|
||||
| { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
|
||||
// undantag: underlaget kunde inte tas fram — obligatorisk orsak i stället
|
||||
@@ -84,11 +98,11 @@ export type Handelse =
|
||||
| { typ: "observation"; text: string }
|
||||
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
|
||||
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
|
||||
| { typ: "foto"; beskrivning: string; dataUrl: string }
|
||||
| ({ typ: "foto"; beskrivning: string } & Bilaga)
|
||||
// Video med ljud (E3-evidens): för det som låter eller rör sig —
|
||||
// motorljud, hjullager, fjädringsrörelse. Kort och komprimerad;
|
||||
// originalfilen bevaras som evidens precis som foton.
|
||||
| { typ: "video"; beskrivning: string; dataUrl: string }
|
||||
| ({ typ: "video"; beskrivning: string } & Bilaga)
|
||||
| { typ: "kommentar"; text: string }
|
||||
| { typ: "kategori_byte"; kategori: TidKategori }
|
||||
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
|
||||
@@ -101,7 +115,7 @@ export type Handelse =
|
||||
// varför inte (kvalitetsvarning).
|
||||
| { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string }
|
||||
// Officiell mätarställning in/ut, normalt med foto av instrumentpanelen.
|
||||
| { typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; dataUrl?: string; undantag?: string }
|
||||
| ({ typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; undantag?: string } & Bilaga)
|
||||
// Symptomverifiering (SVP): kundens beskrivning är inte ett konstaterat
|
||||
// fel förrän den reproducerats — eller dokumenterats som ej
|
||||
// reproducerbar med motivering.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// funktioner av händelseloggen. De kan alltid regenereras — loggen vinner
|
||||
// om en vy och loggen skulle säga olika saker.
|
||||
|
||||
import type { Arende, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
|
||||
import type { Arende, Bilaga, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
|
||||
import { TIDKATEGORI_LABEL } from "./domain";
|
||||
import type { Metodik } from "./metodik";
|
||||
import { nastaSteg } from "./metodik";
|
||||
@@ -172,21 +172,21 @@ export function hypoteser(arende: Arende): { text: string; niva: Tillforlitlighe
|
||||
return resultat;
|
||||
}
|
||||
|
||||
export function foton(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
|
||||
export function foton(arende: Arende): { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] = [];
|
||||
for (const post of arende.handelser) {
|
||||
if (post.handelse.typ === "foto") {
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, bilaga: post.handelse, tidpunkt: post.tidpunkt });
|
||||
}
|
||||
}
|
||||
return resultat;
|
||||
}
|
||||
|
||||
export function videor(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
|
||||
export function videor(arende: Arende): { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] {
|
||||
const resultat: { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] = [];
|
||||
for (const post of arende.handelser) {
|
||||
if (post.handelse.typ === "video") {
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
|
||||
resultat.push({ beskrivning: post.handelse.beskrivning, bilaga: post.handelse, tidpunkt: post.tidpunkt });
|
||||
}
|
||||
}
|
||||
return resultat;
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||
import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||
import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner";
|
||||
import { Bild, Klipp } from "@/felsokning/Bilagevisning";
|
||||
import { bilageFalt } from "@/felsokning/bilagor";
|
||||
|
||||
const FLIKAR = [
|
||||
{ id: "guide", label: "Guide" },
|
||||
@@ -144,7 +146,20 @@ export default function ArendeSida() {
|
||||
const o = objekt(arende);
|
||||
const avslutat = arAvslutat(arende);
|
||||
const total = formateraTid(tidsfordelning(arende, nu).totalMs);
|
||||
const skicka = (h: Handelse) => laggTill(id, h);
|
||||
// Ett ställe flyttar bilagornas innehåll ut ur händelsen. Alla
|
||||
// panelerna nedan skickar som förut med en inbäddad data-URL; här
|
||||
// laddas den upp och händelsen får i stället en referens med
|
||||
// innehållets hash. Misslyckas uppladdningen — eller finns ingen
|
||||
// server, som i lokalt läge — sparas den inbäddat precis som förut.
|
||||
// Dokumentationen får aldrig gå förlorad för att nätet ligger nere.
|
||||
const skicka = (h: Handelse) => {
|
||||
if (!("dataUrl" in h) || !h.dataUrl) {
|
||||
laggTill(id, h);
|
||||
return;
|
||||
}
|
||||
const { dataUrl, ...utan } = h;
|
||||
void bilageFalt(id, dataUrl).then((bilaga) => laggTill(id, { ...utan, ...bilaga } as Handelse));
|
||||
};
|
||||
|
||||
return (
|
||||
<FelsokningSkal
|
||||
@@ -1991,10 +2006,10 @@ function LoggFlik({ arende }: { arende: Arende }) {
|
||||
<p className="text-[13px] text-[#1A1A1A]">{handelseRubrik(post)}</p>
|
||||
<p className="text-[11px] text-[#707070]">{post.anvandare}</p>
|
||||
{post.handelse.typ === "foto" && (
|
||||
<img src={post.handelse.dataUrl} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
<Bild bilaga={post.handelse} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
)}
|
||||
{post.handelse.typ === "video" && (
|
||||
<video src={post.handelse.dataUrl} controls className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
<Klipp bilaga={post.handelse} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
@@ -2385,7 +2400,7 @@ function RapportFlik({
|
||||
<Panel rubrik="Dokumenterad video">
|
||||
{klipp.map((v, i) => (
|
||||
<figure key={i} className="mb-2">
|
||||
<video src={v.dataUrl} controls className="w-full rounded border border-[#C6C6C6]" />
|
||||
<Klipp bilaga={v.bilaga} className="w-full rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
@@ -2396,7 +2411,7 @@ function RapportFlik({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{bilder.map((bild, i) => (
|
||||
<figure key={i}>
|
||||
<img src={bild.dataUrl} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
|
||||
<Bild bilaga={bild.bilaga} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
|
||||
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function PublikDelning() {
|
||||
notis={NIVA_NOTIS[niva] ?? NIVA_NOTIS.kund}
|
||||
redanFiltrerad
|
||||
vidBeslut={skickaBeslut}
|
||||
delningskod={kod}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user