From 978b67f2c29b4e6ee7d44a0ba4b70683ea502415 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Fri, 29 May 2026 17:50:22 +0200 Subject: [PATCH] release 1.2.0 secure email sharing --- .gitignore | 3 + README.en.md | 26 ++- README.md | 26 ++- backend/app.py | 201 ++++++++++++++++++++++ backend/requirements.txt | 2 + docs/TERMS.en.md | 4 +- docs/TERMS.md | 10 +- package-lock.json | 4 +- package.json | 2 +- public/service-worker.js | 2 +- src/App.tsx | 357 ++++++++++++++++++++++++++++++++++++--- src/graph.test.ts | 14 +- src/i18n.tsx | 110 ++++++++++-- src/secureShare.test.ts | 22 +++ src/secureShare.ts | 114 +++++++++++++ src/storage.test.ts | 38 ++++- src/storage.ts | 46 ++++- src/styles.css | 85 ++++++++++ vite.config.ts | 7 +- 19 files changed, 1009 insertions(+), 64 deletions(-) create mode 100644 backend/app.py create mode 100644 backend/requirements.txt create mode 100644 src/secureShare.test.ts create mode 100644 src/secureShare.ts diff --git a/.gitignore b/.gitignore index 6cc27d3..8d98727 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ temp/ .env.local test-data*.json *.local +backend/*.sqlite3 +backend/*.sqlite3-* +backend/.venv/ diff --git a/README.en.md b/README.en.md index 4ea3cdd..abe9a29 100644 --- a/README.en.md +++ b/README.en.md @@ -25,9 +25,9 @@ It is especially relevant for: - advisors, case workers or professionals reviewing reports - environments where data minimization and local storage matter -Arbeidspuls is built as a static web app/PWA. It can normally be hosted as ordinary static files. +Arbeidspuls is built as a web app/PWA that can normally be hosted as ordinary static files. Secure email sharing is an optional add-on that requires a small FastAPI backend. -It normally does not require: +The basic local-only flow normally does not require: - a server database - login @@ -35,7 +35,7 @@ It normally does not require: - a production Node process - central storage of sensitive reports -The user owns their data locally in the browser and actively shares it by exporting a JSON file. +The user owns their data locally in the browser and actively shares it by exporting a JSON file or creating an encrypted one-time link. --- @@ -213,7 +213,7 @@ https://arbeidspuls.rolfsvaag.no/evaluator 3. Choose the JSON file 4. Give the user a name or internal identifier -The data is stored locally in the advisor's browser. Arbeidspuls does not send the reports to a central server. +The data is stored locally in the advisor's browser after import. If the user shares by email link, the server only handles a temporary encrypted copy it cannot read. ### Review reports @@ -256,23 +256,26 @@ This is a technical check. It is not a legal guarantee and does not prove the me ## Privacy and local storage -Arbeidspuls stores data locally in the browser on the device being used. +Arbeidspuls stores data locally in the browser on the device being used by default. Normal JSON export/import remains local-only. + +If the user chooses **Send by email**, the selected export is encrypted in the browser before it is uploaded to the Arbeidspuls server as a temporary one-time share. The server does not receive the decryption key and cannot read the report data. The key only exists in the URL fragment in the share link. This means: - reports are not automatically sent to a server -- the standard app uses no central database +- normal JSON export/import uses no central database - data remains on the user's device/browser - clearing browser data may delete reports - changing device or browser requires export/import +- encrypted email shares are deleted after the first successful import or automatically after 7 days -The user must export JSON to share data with an advisor or move data to another device. +After deletion, only minimal share metadata is retained: export ID, creation time, deletion time and deletion reason. --- ## Technical overview -Arbeidspuls is a static React/Vite app with local browser storage. +Arbeidspuls is a React/Vite app with local browser storage and an optional FastAPI backend for encrypted one-time shares. Typical production build: @@ -289,6 +292,10 @@ For an SPA, the web server should fall back to `index.html`, for example in Ngin location / { try_files $uri $uri/ /index.html; } + +location /api/share/ { + proxy_pass http://127.0.0.1:8000/api/share/; +} ``` ### Technology @@ -301,11 +308,12 @@ location / { - local browser storage - JSON import/export - local advisor view +- optional FastAPI/SQLite backend for encrypted one-time sharing - Norwegian/English UI ### Production -Arbeidspuls normally only requires static hosting. Running a Node server in production is not required. +Arbeidspuls can still run as a purely static app if secure email sharing is not used. The public demo with **Send by email** requires the FastAPI backend, HTTPS, SPA fallback for `/s` and a reverse proxy from `/api/share` to the backend. --- diff --git a/README.md b/README.md index d4a0e60..ba49157 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Tjenesten er særlig relevant for: - veiledere, rådgivere eller fagpersoner som skal gjennomgå rapporter - miljøer der dataminimering og lokal lagring er viktig -Arbeidspuls er bygget som en statisk webapp/PWA. Det betyr at den normalt kan hostes som vanlige statiske filer. +Arbeidspuls er bygget som en webapp/PWA som normalt kan hostes som vanlige statiske filer. Sikker e-postdeling er et valgfritt tillegg som krever en liten FastAPI-backend. -Den krever normalt ikke: +Den grunnleggende local-only-flyten krever normalt ikke: - serverdatabase - innlogging @@ -35,7 +35,7 @@ Den krever normalt ikke: - Node-prosess i produksjon - sentral lagring av sensitive rapporter -Brukeren eier dataene sine lokalt i nettleseren og deler dem aktivt ved å eksportere en JSON-fil. +Brukeren eier dataene sine lokalt i nettleseren og deler dem aktivt ved å eksportere en JSON-fil eller opprette en kryptert engangslenke. --- @@ -213,7 +213,7 @@ https://arbeidspuls.rolfsvaag.no/evaluator 3. Velg JSON-filen 4. Gi brukeren et navn eller en intern identifikator -Dataene lagres lokalt i veilederens nettleser. Arbeidspuls sender ikke rapportene til en sentral server. +Dataene lagres lokalt i veilederens nettleser etter import. Hvis brukeren deler via e-postlenke, håndterer serveren bare en midlertidig kryptert kopi som den ikke kan lese. ### Gjennomgå rapporter @@ -256,23 +256,26 @@ Dette er en teknisk kontroll. Det er ikke en juridisk garanti og sier ikke noe o ## Personvern og lokal lagring -Arbeidspuls lagrer data lokalt i nettleseren på enheten som brukes. +Arbeidspuls lagrer data lokalt i nettleseren på enheten som brukes som hovedregel. Vanlig JSON-eksport/import er fortsatt local-only. + +Hvis brukeren velger **Send via e-post**, krypteres valgt eksport i nettleseren før den lastes opp til Arbeidspuls-serveren som en midlertidig engangsdeling. Serveren får ikke dekrypteringsnøkkelen og kan ikke lese rapportdataene. Nøkkelen ligger bare i URL-fragmentet i delingslenken. Det betyr: - rapporter sendes ikke automatisk til en server -- standardappen bruker ingen sentral database +- vanlig JSON-eksport/import bruker ingen sentral database - data blir liggende på brukerens enhet/nettleser - sletting av nettleserdata kan slette rapportene - bytte av enhet eller nettleser krever eksport/import +- krypterte e-postdelinger slettes etter første vellykkede import eller automatisk etter 7 dager -Brukeren må selv eksportere JSON dersom data skal deles med veileder eller tas med til en annen enhet. +Etter sletting beholdes kun minimal metadata for delingen: eksport-id, opprettelsestidspunkt, slettetidspunkt og slettegrunn. --- ## Teknisk oversikt -Arbeidspuls er en statisk React/Vite-app med lokal nettleserlagring. +Arbeidspuls er en React/Vite-app med lokal nettleserlagring og en valgfri FastAPI-backend for krypterte engangsdelinger. Typisk produksjonsbygg: @@ -289,6 +292,10 @@ For en SPA bør webserveren ha fallback til `index.html`, for eksempel i Nginx: location / { try_files $uri $uri/ /index.html; } + +location /api/share/ { + proxy_pass http://127.0.0.1:8000/api/share/; +} ``` ### Teknologi @@ -301,11 +308,12 @@ location / { - lokal lagring i nettleser - JSON import/eksport - lokal veiledervisning +- valgfri FastAPI/SQLite-backend for kryptert engangsdeling - norsk/engelsk UI ### Produksjon -Arbeidspuls krever normalt bare statisk hosting. Det er ikke nødvendig å kjøre en Node-server i produksjon. +Arbeidspuls kan fortsatt kjøres som ren statisk app hvis sikker e-postdeling ikke brukes. Offentlig demo med **Send via e-post** krever FastAPI-backend, HTTPS, SPA fallback for `/s` og reverse proxy fra `/api/share` til backend. --- diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..300afde --- /dev/null +++ b/backend/app.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import base64 +import os +import re +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +DATABASE_PATH = Path(os.environ.get("ARBEIDSPULS_SHARE_DB", "backend/share-data.sqlite3")) +EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7")) +MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 +RATE_LIMIT_PER_HOUR = 10 +ALLOWED_ORIGINS = [ + "https://arbeidspuls.rolfsvaag.no", + "http://localhost:5173", + "http://127.0.0.1:5173", +] + +app = FastAPI(title="Arbeidspuls secure share API") +app.add_middleware( + CORSMiddleware, + allow_origins=[origin.strip() for origin in os.environ.get("ARBEIDSPULS_CORS_ORIGINS", ",".join(ALLOWED_ORIGINS)).split(",")], + allow_credentials=False, + allow_methods=["POST", "GET", "OPTIONS"], + allow_headers=["Content-Type"], +) + +rate_bucket: dict[str, list[datetime]] = {} + + +class ShareCreateRequest(BaseModel): + ciphertext: str = Field(min_length=1) + iv: str = Field(min_length=1) + share_schema: str = Field(alias="schema", min_length=1, max_length=80) + + +class ShareCreateResponse(BaseModel): + export_id: str + expires_at: str + + +@contextmanager +def connect(): + DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(DATABASE_PATH) + db.row_factory = sqlite3.Row + try: + yield db + db.commit() + finally: + db.close() + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def iso(value: datetime) -> str: + return value.isoformat().replace("+00:00", "Z") + + +def init_db() -> None: + with connect() as db: + db.execute( + """ + CREATE TABLE IF NOT EXISTS active_shares ( + export_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ciphertext TEXT NOT NULL, + iv TEXT NOT NULL, + schema TEXT NOT NULL + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS share_audit ( + export_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + deleted_at TEXT NOT NULL, + delete_reason TEXT NOT NULL + ) + """ + ) + + +def decoded_size(value: str) -> int: + if not re.fullmatch(r"[A-Za-z0-9_-]+", value): + raise HTTPException(status_code=400, detail="Invalid base64url payload.") + padded = value.replace("-", "+").replace("_", "/") + "=" * (-len(value) % 4) + try: + return len(base64.b64decode(padded, validate=True)) + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid base64url payload.") from exc + + +def cleanup_expired() -> None: + now = iso(utc_now()) + with connect() as db: + rows = db.execute("SELECT export_id, created_at FROM active_shares WHERE expires_at <= ?", (now,)).fetchall() + for row in rows: + mark_deleted(db, row["export_id"], row["created_at"], "expired") + + +def mark_deleted(db: sqlite3.Connection, export_id: str, created_at: str, reason: str) -> None: + db.execute("DELETE FROM active_shares WHERE export_id = ?", (export_id,)) + db.execute( + """ + INSERT OR REPLACE INTO share_audit (export_id, created_at, deleted_at, delete_reason) + VALUES (?, ?, ?, ?) + """, + (export_id, created_at, iso(utc_now()), reason), + ) + + +def client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def assert_rate_limit(request: Request) -> None: + ip = client_ip(request) + cutoff = utc_now() - timedelta(hours=1) + recent = [created for created in rate_bucket.get(ip, []) if created > cutoff] + if len(recent) >= RATE_LIMIT_PER_HOUR: + raise HTTPException(status_code=429, detail="Rate limit exceeded.") + recent.append(utc_now()) + rate_bucket[ip] = recent + + +@app.on_event("startup") +def startup() -> None: + init_db() + cleanup_expired() + + +@app.post("/api/share", response_model=ShareCreateResponse) +def create_share(payload: ShareCreateRequest, request: Request) -> dict[str, str]: + cleanup_expired() + assert_rate_limit(request) + if decoded_size(payload.ciphertext) > MAX_PAYLOAD_BYTES: + raise HTTPException(status_code=413, detail="Payload is too large.") + if decoded_size(payload.iv) != 12: + raise HTTPException(status_code=400, detail="Invalid IV size.") + + now = utc_now() + export_id = str(uuid4()) + expires_at = now + timedelta(days=EXPIRY_DAYS) + with connect() as db: + db.execute( + """ + INSERT INTO active_shares (export_id, created_at, expires_at, ciphertext, iv, schema) + VALUES (?, ?, ?, ?, ?, ?) + """, + (export_id, iso(now), iso(expires_at), payload.ciphertext, payload.iv, payload.share_schema), + ) + return {"export_id": export_id, "expires_at": iso(expires_at)} + + +@app.get("/api/share/{export_id}") +def get_share(export_id: str) -> dict[str, Any]: + cleanup_expired() + with connect() as db: + row = db.execute("SELECT * FROM active_shares WHERE export_id = ?", (export_id,)).fetchone() + if row: + return { + "status": "available", + "export_id": row["export_id"], + "ciphertext": row["ciphertext"], + "iv": row["iv"], + "schema": row["schema"], + "expires_at": row["expires_at"], + } + audit = db.execute("SELECT delete_reason FROM share_audit WHERE export_id = ?", (export_id,)).fetchone() + if audit and audit["delete_reason"] == "expired": + return {"status": "expired", "export_id": export_id} + if audit: + return {"status": "deleted", "export_id": export_id} + raise HTTPException(status_code=404, detail="Share not found.") + + +@app.post("/api/share/{export_id}/confirm-import") +def confirm_import(export_id: str) -> dict[str, str]: + cleanup_expired() + with connect() as db: + row = db.execute("SELECT export_id, created_at FROM active_shares WHERE export_id = ?", (export_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Share is not available.") + mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient") + return {"status": "deleted"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3fb50f0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 diff --git a/docs/TERMS.en.md b/docs/TERMS.en.md index a0e4c0c..0ef94b4 100644 --- a/docs/TERMS.en.md +++ b/docs/TERMS.en.md @@ -15,8 +15,10 @@ Summary: - The demo is provided as-is and as-available. - Availability, functionality and data persistence are not guaranteed. - Reports are normally stored locally in the user's browser/device. +- If the user chooses Send by email, the selected export is encrypted in the browser before temporary upload to the Arbeidspuls server. The server does not receive the decryption key and cannot read the report data. +- Encrypted one-time shares are deleted after the first successful import or automatically after 7 days. After deletion, only minimal metadata is retained: export ID, creation time, deletion time and deletion reason. - Users are responsible for exporting/backing up their own data. -- Sharing with an advisor requires exporting and sending a JSON file. +- Sharing with an advisor can be done by exporting and sending a JSON file, or by creating an encrypted one-time link with Send by email. - Organizations, public bodies, healthcare providers, companies and professional actors may not make the public demo part of their ordinary workflow without a special license. - Arbeidspuls must not be used as the sole basis for medical, legal, financial, public or employment-related decisions. diff --git a/docs/TERMS.md b/docs/TERMS.md index f99a288..682d5c8 100644 --- a/docs/TERMS.md +++ b/docs/TERMS.md @@ -46,6 +46,8 @@ Tjenesten kan endres, begrenses, oppdateres eller avsluttes uten forvarsel. Arbeidspuls lagrer normalt rapporter lokalt i nettleseren på enheten du bruker. +Hvis du velger **Send via e-post**, krypteres valgt eksport i nettleseren før den lastes opp til Arbeidspuls-serveren som en midlertidig engangsdeling. Serveren får ikke dekrypteringsnøkkelen og kan ikke lese rapportdataene. + Det betyr at: - rapporter ikke automatisk sendes til en sentral server; @@ -54,17 +56,19 @@ Det betyr at: - bytte av enhet eller nettleser krever eksport/import; - du selv er ansvarlig for backup hvis rapportene er viktige. +Krypterte engangsdelinger slettes etter første vellykkede import hos mottaker eller automatisk etter 7 dager. Etter sletting beholdes kun minimal metadata: eksport-id, opprettelsestidspunkt, slettetidspunkt og slettegrunn. + Eksporter JSON-fil jevnlig dersom du vil ta vare på dataene. --- ## 4. Deling med veileder -Hvis en veileder eller annen person skal gjennomgå rapportene dine, må du eksportere en JSON-fil og dele den med vedkommende. +Hvis en veileder eller annen person skal gjennomgå rapportene dine, kan du eksportere en JSON-fil og dele den med vedkommende, eller bruke Send via e-post for å opprette en kryptert engangslenke. -JSON-filen kan inneholde helse- og funksjonsrelaterte opplysninger. Del den bare med personer som faktisk skal ha tilgang. +JSON-filen eller engangslenken kan inneholde tilgang til helse- og funksjonsrelaterte opplysninger. Del den bare med personer som faktisk skal ha tilgang. -Du er selv ansvarlig for hvordan du deler eksporterte filer. +Du er selv ansvarlig for hvordan du deler eksporterte filer og engangslenker. --- diff --git a/package-lock.json b/package-lock.json index 1dcbd99..974934b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arbeidspuls", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arbeidspuls", - "version": "1.1.2", + "version": "1.2.0", "dependencies": { "lucide-react": "^0.475.0", "react": "^19.0.0", diff --git a/package.json b/package.json index 26ace04..97b0012 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arbeidspuls", - "version": "1.1.2", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/public/service-worker.js b/public/service-worker.js index 3f50d4f..e8533dc 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "arbeidspuls-v7"; +const CACHE_NAME = "arbeidspuls-v8"; const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"]; self.addEventListener("install", (event) => { diff --git a/src/App.tsx b/src/App.tsx index ef0361b..c6ab2d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,11 +62,20 @@ import { translateVerificationStatus, useI18n } from "./i18n"; +import { + confirmSecureShareImport, + createSecureShare, + decryptSecureSharePayload, + fetchSecureShare, + parseShareRequestFromLocation +} from "./secureShare"; import { createFollowup, createReport, deleteAllReports, deleteReport, + assertValidExportRange, + buildExportFilename, ensureDemoData, exportReports, getLatestWorkReport, @@ -80,6 +89,7 @@ import { updateFollowup, updateReport } from "./storage"; +import type { ExportRange } from "./storage"; import type { Accommodation, DelayedFollowup, @@ -105,7 +115,7 @@ type LineVisibility = "normal" | "dim" | "hidden"; type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string }; const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period"; -const APP_VERSION = "1.1"; +const APP_VERSION = "1.2"; const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[]; const limitationOptions = Object.keys(limitationLabels) as Limitation[]; @@ -131,6 +141,9 @@ function App() { function AppRoutes() { const { t } = useI18n(); + if (window.location.pathname.startsWith("/s")) { + return ; + } if (window.location.pathname.startsWith("/evaluator") || window.location.pathname.startsWith("/veileder")) { return ; } @@ -271,7 +284,15 @@ function NavButton({ ); } -function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoModeChange: (enabled: boolean) => void }) { +function AppFooter({ + demoMode, + onDemoModeChange, + showDemoToggle = true +}: { + demoMode: boolean; + onDemoModeChange: (enabled: boolean) => void; + showDemoToggle?: boolean; +}) { const { language, setLanguage, t } = useI18n(); const baseYear = 2026; const currentYear = new Date().getFullYear(); @@ -307,9 +328,11 @@ function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoMo