From caf08e5a46f88916b5367424560ba144d9269553 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 30 May 2026 13:36:57 +0200 Subject: [PATCH] Add in-app feedback issue flow --- backend/.env.example | 7 + backend/app.py | 256 +++++++++++++++++++++++++++++- backend/feedback_config.json | 48 ++++++ docs/NOTICE.md | 4 +- src/App.tsx | 300 ++++++++++++++++++++++++++++++++++- src/feedback.test.ts | 33 ++++ src/feedback.ts | 102 ++++++++++++ src/i18n.tsx | 124 +++++++++++++++ src/styles.css | 109 +++++++++++++ vite.config.ts | 3 +- 10 files changed, 975 insertions(+), 11 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/feedback_config.json create mode 100644 src/feedback.test.ts create mode 100644 src/feedback.ts diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b64abba --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +FEEDBACK_ENABLED=true +GITEA_BASE_URL=https://git.rolfsvaag.no +GITEA_OWNER=Rolfsvaag_Datateknikk +GITEA_REPO=Arbeidspuls +GITEA_FEEDBACK_TOKEN=replace_with_agent_account_issue_token +FEEDBACK_RATE_LIMIT_PEPPER=replace_with_long_random_secret +ARBEIDSPULS_CORS_ORIGINS=https://arbeidspuls.rolfsvaag.no,http://localhost:5173,http://127.0.0.1:5173 diff --git a/backend/app.py b/backend/app.py index 78e43a9..6028325 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2,9 +2,13 @@ from __future__ import annotations import base64 import hashlib +import hmac +import json import os import re import sqlite3 +import urllib.error +import urllib.request from contextlib import contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path @@ -15,7 +19,29 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field +def load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +load_env_file(Path(os.environ.get("ARBEIDSPULS_ENV_FILE", ".env"))) + + +def default_feedback_config_path() -> Path: + if "ARBEIDSPULS_FEEDBACK_CONFIG" in os.environ: + return Path(os.environ["ARBEIDSPULS_FEEDBACK_CONFIG"]) + repo_root_path = Path("backend/feedback_config.json") + return repo_root_path if repo_root_path.exists() else Path("feedback_config.json") + + DATABASE_PATH = Path(os.environ.get("ARBEIDSPULS_SHARE_DB", "backend/share-data.sqlite3")) +FEEDBACK_CONFIG_PATH = default_feedback_config_path() EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7")) MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 MAX_CIPHERTEXT_CHARS = 3_000_000 @@ -39,6 +65,8 @@ app.add_middleware( ) rate_bucket: Dict[str, List[datetime]] = {} +feedback_rate_bucket: Dict[str, List[datetime]] = {} +feedback_config: Dict[str, Any] = {} class ShareCreateRequest(BaseModel): @@ -57,6 +85,21 @@ class ShareCreateResponse(BaseModel): expires_at: str +class FeedbackRequest(BaseModel): + feedback_type: str = Field(min_length=1, max_length=32) + areas: List[str] = Field(min_items=1, max_items=8) + message: str = Field(min_length=1, max_length=4500) + consent: bool + language: str = Field(default="nb-NO", max_length=12) + route: str = Field(default="/", max_length=180) + viewport_category: str = Field(default="unknown", max_length=24) + app_version: str = Field(default="unknown", max_length=32) + form_started_at: str = Field(default="", max_length=40) + form_completed_at: str = Field(default="", max_length=40) + company_website: str = Field(default="", max_length=200) + urgent_contact_allowed: bool = False + + @contextmanager def connect(): DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -109,7 +152,7 @@ def init_db() -> None: @app.middleware("http") async def security_headers(request: Request, call_next): response = await call_next(request) - if request.url.path.startswith("/api/share"): + if request.url.path.startswith("/api/share") or request.url.path.startswith("/api/feedback"): response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" @@ -173,8 +216,205 @@ def token_hash(confirm_token: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") +def load_feedback_config() -> None: + global feedback_config + if FEEDBACK_CONFIG_PATH.exists(): + with FEEDBACK_CONFIG_PATH.open("r", encoding="utf-8") as handle: + feedback_config = json.load(handle) + else: + feedback_config = {} + + +def feedback_enabled() -> bool: + return os.environ.get("FEEDBACK_ENABLED", "true").lower() in {"1", "true", "yes", "on"} + + +def feedback_repo_config() -> Dict[str, str]: + repo = feedback_config.get("repo", {}) + return { + "base_url": os.environ.get("GITEA_BASE_URL", repo.get("base_url", "https://git.rolfsvaag.no")).rstrip("/"), + "owner": os.environ.get("GITEA_OWNER", repo.get("owner", "Rolfsvaag_Datateknikk")), + "repo": os.environ.get("GITEA_REPO", repo.get("repo", "Arbeidspuls")), + } + + +def parse_iso(value: str) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def feedback_rate_key(request: Request) -> str: + pepper = os.environ.get("FEEDBACK_RATE_LIMIT_PEPPER", "") + if not pepper: + pepper = "missing-feedback-rate-limit-pepper" + ip = client_ip(request) + user_agent = request.headers.get("user-agent", "") + digest = hmac.new(pepper.encode("utf-8"), f"{ip}|{user_agent}".encode("utf-8"), hashlib.sha256).hexdigest() + return digest + + +def assert_feedback_rate_limit(request: Request) -> None: + config = feedback_config.get("rate_limit", {}) + key = feedback_rate_key(request) + now = utc_now() + ten_minute_cutoff = now - timedelta(minutes=10) + day_cutoff = now - timedelta(hours=24) + recent = [created for created in feedback_rate_bucket.get(key, []) if created > day_cutoff] + if len([created for created in recent if created > ten_minute_cutoff]) >= int(config.get("ten_minutes", 3)): + raise HTTPException(status_code=429, detail="Rate limit exceeded.") + if len(recent) >= int(config.get("twenty_four_hours", 10)): + raise HTTPException(status_code=429, detail="Rate limit exceeded.") + recent.append(now) + feedback_rate_bucket[key] = recent + + +def meaningful_feedback_errors(message: str) -> List[str]: + config = feedback_config.get("validation", {}) + min_chars = int(config.get("min_chars", 30)) + min_words = int(config.get("min_words", 5)) + min_letters = int(config.get("min_letters", 15)) + max_chars = int(config.get("max_chars", 4000)) + text = message.strip() + words = [word for word in re.split(r"\s+", text) if word] + letters = re.findall(r"[A-Za-zÆØÅæøå]", text) + errors = [] + if len(text) < min_chars: + errors.append("min_chars") + if len(text) > max_chars: + errors.append("max_chars") + if len(words) < min_words: + errors.append("min_words") + if len(letters) < min_letters: + errors.append("min_letters") + if re.fullmatch(r"[\d\W_]+", text, re.UNICODE): + errors.append("not_meaningful") + if re.search(r"(.)\1{11,}", text, re.UNICODE): + errors.append("repetition") + return errors + + +REDACTION_PATTERNS = [ + re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+"), + re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), + re.compile(r"\b\d{11}\b"), + re.compile(r"\b\d{8,}\b"), + re.compile(r"(?:\+?\d[\s().-]*){8,}"), +] + + +def redact_feedback(message: str) -> tuple[str, int, float]: + redactions = 0 + redacted = message + for pattern in REDACTION_PATTERNS: + redacted, count = pattern.subn("[removed]", redacted) + redactions += count + changed_chars = max(0, len(message) - len(redacted.replace("[removed]", ""))) + ratio = changed_chars / max(1, len(message)) + return redacted, redactions, ratio + + +def validate_feedback_payload(payload: FeedbackRequest) -> tuple[Dict[str, Any], List[int], str]: + if not feedback_enabled(): + raise HTTPException(status_code=503, detail="Feedback is not enabled.") + if not payload.consent: + raise HTTPException(status_code=400, detail="Consent is required.") + if payload.company_website.strip() or payload.urgent_contact_allowed: + return {"fake_success": True}, [], "" + + completed = parse_iso(payload.form_completed_at) + started = parse_iso(payload.form_started_at) + min_seconds = int(feedback_config.get("validation", {}).get("min_form_seconds", 5)) + if not completed or not started or (completed - started).total_seconds() < min_seconds: + raise HTTPException(status_code=400, detail="The form was submitted too quickly.") + + labels = feedback_config.get("labels", {}) + type_config = labels.get("types", {}).get(payload.feedback_type) + area_configs = [labels.get("areas", {}).get(area) for area in payload.areas] + if not type_config or len(payload.areas) < 1 or any(area is None for area in area_configs): + raise HTTPException(status_code=400, detail="Invalid feedback categories.") + + errors = meaningful_feedback_errors(payload.message) + if errors: + raise HTTPException(status_code=400, detail="Feedback text does not pass validation.") + + redacted, redactions, ratio = redact_feedback(payload.message.strip()) + validation = feedback_config.get("validation", {}) + if redactions > int(validation.get("reject_redactions_over", 3)) or ratio > float(validation.get("reject_redacted_ratio_over", 0.2)): + raise HTTPException(status_code=400, detail="Remove identifying or sensitive information before submitting.") + + label_ids = list(labels.get("always", [])) + label_ids.append(int(type_config["id"])) + label_ids.extend(int(area["id"]) for area in area_configs if area) + return {"type": type_config, "areas": area_configs, "redactions": redactions}, label_ids, redacted + + +def issue_title(payload: FeedbackRequest, redacted_message: str) -> str: + type_label = feedback_config.get("labels", {}).get("types", {}).get(payload.feedback_type, {}).get("en", payload.feedback_type) + excerpt = re.sub(r"\s+", " ", redacted_message).strip()[:72] + return f"{type_label}: {excerpt}" + + +def issue_body(payload: FeedbackRequest, meta: Dict[str, Any], redacted_message: str) -> str: + area_labels = ", ".join(f"{area['en']} (`{area['label']}`)" for area in meta["areas"]) + route = payload.route.split("?", 1)[0].split("#", 1)[0] or "/" + submitted_at = iso(utc_now()) + return f"""## In-app feedback + +This issue was created automatically from the Arbeidspuls in-app feedback form. + +The user confirmed that the feedback does not contain sensitive or identifiable information, and that it may be processed, stored and displayed publicly as ordinary project feedback. Obvious contact details are automatically removed when detected. + +### User feedback +{redacted_message} + +### Selected categories +- Type: {meta["type"]["en"]} (`{meta["type"]["label"]}`) +- Areas: {area_labels} + +### Non-identifying context +- App version: {payload.app_version} +- UI language: {payload.language} +- Route: {route} +- Viewport: {payload.viewport_category} +- Submitted at: {submitted_at} + +_No contact information, IP address, user agent, report data or local data is intentionally included._ +""" + + +def create_gitea_issue(title: str, body: str, label_ids: List[int]) -> Dict[str, Any]: + token = os.environ.get("GITEA_FEEDBACK_TOKEN") or os.environ.get("GITEA_FEEDBACK_ACCESS_TOKEN") + if not token: + raise HTTPException(status_code=503, detail="Feedback service is not configured.") + repo = feedback_repo_config() + url = f"{repo['base_url']}/api/v1/repos/{repo['owner']}/{repo['repo']}/issues" + data = json.dumps({"title": title, "body": body, "labels": label_ids}).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + headers={ + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=12) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise HTTPException(status_code=502, detail="Feedback service could not create issue.") from exc + except urllib.error.URLError as exc: + raise HTTPException(status_code=502, detail="Feedback service is unavailable.") from exc + + @app.on_event("startup") def startup() -> None: + load_feedback_config() init_db() cleanup_expired() @@ -240,3 +480,17 @@ def confirm_import(export_id: str, payload: ConfirmImportRequest, request: Reque raise HTTPException(status_code=403, detail="Invalid confirmation proof.") mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient") return {"status": "deleted"} + + +@app.post("/api/feedback") +def create_feedback(payload: FeedbackRequest, request: Request) -> Dict[str, Any]: + assert_feedback_rate_limit(request) + meta, label_ids, redacted_message = validate_feedback_payload(payload) + if meta.get("fake_success"): + return {"accepted": False} + issue = create_gitea_issue(issue_title(payload, redacted_message), issue_body(payload, meta, redacted_message), label_ids) + return { + "accepted": True, + "issue_number": issue.get("number"), + "issue_url": issue.get("html_url") + } diff --git a/backend/feedback_config.json b/backend/feedback_config.json new file mode 100644 index 0000000..d50bd7e --- /dev/null +++ b/backend/feedback_config.json @@ -0,0 +1,48 @@ +{ + "repo": { + "base_url": "https://git.rolfsvaag.no", + "owner": "Rolfsvaag_Datateknikk", + "repo": "Arbeidspuls" + }, + "labels": { + "always": [69, 90], + "types": { + "bug": { "id": 76, "label": "type/bug", "nb": "Feil eller problem", "en": "Bug or problem" }, + "feature": { "id": 82, "label": "type/feature", "nb": "Forslag til forbedring", "en": "Improvement suggestion" }, + "docs": { "id": 85, "label": "type/docs", "nb": "Dokumentasjon eller tekst", "en": "Documentation or text" }, + "task": { "id": 83, "label": "type/task", "nb": "Generell oppgave", "en": "General task" }, + "other": { "id": 106, "label": "type/other", "nb": "Annet", "en": "Other" } + }, + "areas": { + "reporting": { "id": 107, "label": "area/reporting", "nb": "Registrering av rapport", "en": "Report registration" }, + "followup": { "id": 108, "label": "area/followup", "nb": "Oppfølging", "en": "Follow-up" }, + "overview_graph": { "id": 99, "label": "area/graph", "nb": "Oversikt og graf", "en": "Overview and graph" }, + "advisor": { "id": 109, "label": "area/advisor", "nb": "Veiledervisning", "en": "Advisor view" }, + "import_export": { "id": 98, "label": "area/import-export", "nb": "Import, eksport og deling", "en": "Import, export and sharing" }, + "i18n": { "id": 100, "label": "area/i18n", "nb": "Språk og oversettelse", "en": "Language and translation" }, + "privacy": { "id": 97, "label": "area/privacy", "nb": "Personvern og lokal lagring", "en": "Privacy and local storage" }, + "ui": { "id": 77, "label": "area/UI", "nb": "Utseende og brukervennlighet", "en": "Layout and usability" }, + "accessibility": { "id": 88, "label": "area/accessibility", "nb": "Tilgjengelighet", "en": "Accessibility" }, + "legal": { "id": 80, "label": "area/legal", "nb": "Juridisk eller lisens", "en": "Legal or licensing" }, + "api": { "id": 87, "label": "area/API", "nb": "API", "en": "API" }, + "backend": { "id": 86, "label": "area/backend", "nb": "Backend/server", "en": "Backend/server" }, + "deployment": { "id": 89, "label": "area/deployment", "nb": "Drift og deploy", "en": "Deployment" }, + "pwa": { "id": 101, "label": "area/PWA", "nb": "Installasjon som app/PWA", "en": "Install as app/PWA" }, + "security": { "id": 79, "label": "area/security", "nb": "Sikkerhet", "en": "Security" }, + "performance": { "id": 78, "label": "area/performance", "nb": "Ytelse", "en": "Performance" } + } + }, + "validation": { + "min_chars": 30, + "min_words": 5, + "min_letters": 15, + "max_chars": 4000, + "min_form_seconds": 5, + "reject_redactions_over": 3, + "reject_redacted_ratio_over": 0.2 + }, + "rate_limit": { + "ten_minutes": 3, + "twenty_four_hours": 10 + } +} diff --git a/docs/NOTICE.md b/docs/NOTICE.md index 3e12530..09d8785 100644 --- a/docs/NOTICE.md +++ b/docs/NOTICE.md @@ -5,8 +5,8 @@ Copyright (c) 2026 Franz Rolfsvaag og Rolfsvaag Datateknikk. Rolfsvaag Datateknikk er per tidspunktet for denne teksten en uregistrert/hobbybasert virksomhet/betegnelse brukt av Franz Rolfsvaag. -Originalprosjekt: https://git.rolfsvaag.no/frarol96/Arbeidspuls/ -Offentlig demo: https://arbeidspuls.rolfsvaag.no +Originalprosjekt: https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Arbeidspuls/ +Offentlig demo: https://arbeidspuls.rolfsvaag.no Kontakt for særlisens: franz@rolfsvaag.no Arbeidspuls er gjort kildekodetilgjengelig for innsyn, læring, privat bruk, ikke-kommersiell bruk etter lisensvilkårene, testing, feilrapportering og bidrag. diff --git a/src/App.tsx b/src/App.tsx index 373c441..2b65fcf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { Github, HelpCircle, Heart, + MessageSquare, Search, Plus, RotateCcw, @@ -55,6 +56,19 @@ import { scoreBandDisclaimer } from "./scoring"; import { verificationLabel, verificationTooltip } from "./integrity"; +import { + FEEDBACK_CONSENT_DELAY_MS, + FEEDBACK_MAX_CHARS, + FEEDBACK_MIN_FORM_SECONDS, + feedbackAreas, + feedbackTypes, + isFeedbackEnabled, + validateFeedbackMessage, + validateFeedbackSelection, + viewportCategory, + type FeedbackArea, + type FeedbackType +} from "./feedback"; import { I18nProvider, translateScoreBand, @@ -154,6 +168,7 @@ function AppRoutes() { const [selectedEntryId, setSelectedEntryId] = useState(null); const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null); const [editEntry, setEditEntry] = useState(null); + const [feedbackOpen, setFeedbackOpen] = useState(false); const refresh = () => { const next = listReports(demoMode); @@ -181,9 +196,12 @@ function AppRoutes() {

{t("app.eyebrow")}

{t("app.title")}

- - {t("app.evaluator")} - +
+ setFeedbackOpen(true)} /> + + {t("app.evaluator")} + +