diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9a88511a..c78b4ecb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -50,6 +50,7 @@ import { configRoutes } from "./routes/config.js"; import { docsRoutes } from "./routes/docs.js"; import { registerEnterpriseRoutes } from "./routes/enterprise/index.js"; import { registerFeatureRoutes } from "./routes/features.js"; +import { feedbackRoutes } from "./routes/feedback.js"; import { registerFetchUrlsRoute } from "./routes/fetch-urls.js"; import { filePreviewRoutes } from "./routes/file-preview.js"; import { fileRoutes } from "./routes/files.js"; @@ -438,6 +439,9 @@ await settingsRoutes(app); // Analytics config and consent routes await analyticsRoutes(app); +// Explicit customer feedback capture (respects the analytics gate) +await feedbackRoutes(app); + // Feature management routes (AI feature bundle install/uninstall) await registerFeatureRoutes(app); diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts index 65fb60f5..ba66b821 100644 --- a/apps/api/src/lib/analytics.ts +++ b/apps/api/src/lib/analytics.ts @@ -1,4 +1,4 @@ -import { ANALYTICS_BAKED } from "@snapotter/shared"; +import { ANALYTICS_BAKED, ANALYTICS_EVENTS, APP_VERSION } from "@snapotter/shared"; import { eq } from "drizzle-orm"; import type { PostHog } from "posthog-node"; import { db, schema } from "../db/index.js"; @@ -7,6 +7,43 @@ import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js"; let posthogClient: PostHog | null = null; +export interface FeedbackEventProperties { + source: "global" | "tool_result" | "failed_job" | "admin_installer"; + survey_id?: "global-feedback-v1" | "tool-result-v1" | "failed-job-v1" | "admin-install-v1"; + prompt_variant?: string; + sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other"; + feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other"; + message?: string; + contact_ok: boolean; + contact_email?: string; + contact_name?: string; + company?: string; + tool_id?: string; + job_status?: "completed" | "failed"; + install_method?: "docker" | "docker_compose" | "source" | "cloud" | "other"; + usage_type?: "personal" | "team_internal" | "business_workflow" | "education" | "evaluating"; + important_areas?: string[]; + friction_area?: + | "smooth" + | "docker" + | "environment_variables" + | "auth" + | "storage" + | "workers" + | "ai_tools" + | "docs" + | "performance" + | "other"; + error_category?: + | "validation_error" + | "upload_error" + | "processing_error" + | "timeout" + | "unsupported_format" + | "worker_unavailable" + | "unknown"; +} + export async function initAnalytics(): Promise { if (!bakedEnabled()) return; @@ -68,3 +105,54 @@ export async function trackEvent( // analytics must never throw } } + +function cleanFeedbackProperties(properties: FeedbackEventProperties): Record { + const out: Record = { + feedback_version: 1, + app_version: APP_VERSION, + source: properties.source, + contact_ok: properties.contact_ok, + }; + + const copyString = (from: keyof FeedbackEventProperties, to = from) => { + const value = properties[from]; + if (typeof value === "string" && value.length > 0) out[to] = value; + }; + + copyString("survey_id"); + copyString("prompt_variant"); + copyString("sentiment"); + copyString("feedback_type"); + copyString("message"); + copyString("contact_email"); + copyString("contact_name"); + copyString("company"); + copyString("tool_id"); + copyString("job_status"); + copyString("install_method"); + copyString("usage_type"); + copyString("friction_area"); + copyString("error_category"); + + if (properties.important_areas?.length) { + out.important_areas = properties.important_areas; + } + + return out; +} + +export async function captureFeedback( + properties: FeedbackEventProperties, + distinctId?: string, +): Promise { + try { + if (!analyticsEnabled() || !posthogClient) return; + posthogClient.capture({ + distinctId: distinctId ?? (await getInstanceId()), + event: ANALYTICS_EVENTS.FEEDBACK_SUBMITTED, + properties: cleanFeedbackProperties(properties), + }); + } catch { + // feedback capture must never throw + } +} diff --git a/apps/api/src/routes/feedback.ts b/apps/api/src/routes/feedback.ts new file mode 100644 index 00000000..90144b95 --- /dev/null +++ b/apps/api/src/routes/feedback.ts @@ -0,0 +1,173 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { captureFeedback, type FeedbackEventProperties } from "../lib/analytics.js"; +import { analyticsEnabled } from "../lib/analytics-gate.js"; +import { requireAuth } from "../plugins/auth.js"; + +const SOURCE_VALUES = ["global", "tool_result", "failed_job", "admin_installer"] as const; +const SURVEY_ID_VALUES = [ + "global-feedback-v1", + "tool-result-v1", + "failed-job-v1", + "admin-install-v1", +] as const; +const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const; +const FEEDBACK_TYPE_VALUES = [ + "bug", + "feature_request", + "confusing_ux", + "performance", + "other", +] as const; +const INSTALL_METHOD_VALUES = ["docker", "docker_compose", "source", "cloud", "other"] as const; +const USAGE_TYPE_VALUES = [ + "personal", + "team_internal", + "business_workflow", + "education", + "evaluating", +] as const; +const IMPORTANT_AREA_VALUES = [ + "images", + "pdf_docs", + "video_audio", + "batch_workflows", + "ai_tools", +] as const; +const FRICTION_AREA_VALUES = [ + "smooth", + "docker", + "environment_variables", + "auth", + "storage", + "workers", + "ai_tools", + "docs", + "performance", + "other", +] as const; +const ERROR_CATEGORY_VALUES = [ + "validation_error", + "upload_error", + "processing_error", + "timeout", + "unsupported_format", + "worker_unavailable", + "unknown", +] as const; + +const toolIdSchema = z + .string() + .trim() + .regex(/^[a-z0-9-]{1,80}$/); + +function stripUnsafeControlCharacters(value: string): string { + let out = ""; + for (const char of value) { + const code = char.charCodeAt(0); + if (char === "\n" || char === "\t" || (code >= 32 && code !== 127)) { + out += char; + } + } + return out; +} + +const optionalText = (max: number) => + z.string().trim().max(max).transform(stripUnsafeControlCharacters).optional(); + +const feedbackBodySchema = z + .object({ + source: z.enum(SOURCE_VALUES), + surveyId: z.enum(SURVEY_ID_VALUES).optional(), + promptVariant: z + .string() + .trim() + .max(80) + .regex(/^[a-z0-9_-]+$/) + .optional(), + sentiment: z.enum(SENTIMENT_VALUES).optional(), + feedbackType: z.enum(FEEDBACK_TYPE_VALUES).optional(), + message: optionalText(2000), + contactOk: z.boolean().default(false), + contactEmail: z.union([z.string().trim().email().max(320), z.literal("")]).optional(), + contactName: optionalText(120), + company: optionalText(160), + toolId: toolIdSchema.optional(), + jobStatus: z.enum(["completed", "failed"]).optional(), + installMethod: z.enum(INSTALL_METHOD_VALUES).optional(), + usageType: z.enum(USAGE_TYPE_VALUES).optional(), + importantAreas: z.array(z.enum(IMPORTANT_AREA_VALUES)).max(5).optional(), + frictionArea: z.enum(FRICTION_AREA_VALUES).optional(), + errorCategory: z.enum(ERROR_CATEGORY_VALUES).optional(), + }) + .superRefine((value, ctx) => { + const hasText = Boolean(value.message?.trim()); + const hasChoice = Boolean( + value.sentiment || value.feedbackType || value.installMethod || value.usageType, + ); + if (!hasText && !hasChoice) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Feedback must include a rating, type, or message.", + path: ["message"], + }); + } + }); + +function toPostHogProperties(body: z.infer): FeedbackEventProperties { + return { + source: body.source, + survey_id: body.surveyId, + prompt_variant: body.promptVariant, + sentiment: body.sentiment, + feedback_type: body.feedbackType, + message: body.message || undefined, + contact_ok: body.contactOk, + contact_email: body.contactOk ? body.contactEmail || undefined : undefined, + contact_name: body.contactOk ? body.contactName || undefined : undefined, + company: body.contactOk ? body.company || undefined : undefined, + tool_id: body.toolId, + job_status: body.jobStatus, + install_method: body.installMethod, + usage_type: body.usageType, + important_areas: body.importantAreas, + friction_area: body.frictionArea, + error_category: body.errorCategory, + }; +} + +export async function feedbackRoutes(app: FastifyInstance): Promise { + app.post( + "/api/v1/feedback", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request: FastifyRequest, reply: FastifyReply) => { + const user = requireAuth(request, reply); + if (!user) return; + + const parsed = feedbackBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid feedback payload", + code: "VALIDATION_ERROR", + details: parsed.error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + + if (!analyticsEnabled()) { + return reply.send({ ok: true, accepted: false }); + } + + await captureFeedback( + toPostHogProperties(parsed.data), + request.headers["x-posthog-distinct-id"] as string | undefined, + ); + + return reply.send({ ok: true, accepted: true }); + }, + ); + + app.log.info("Feedback routes registered"); +} diff --git a/apps/docs/api/rest.md b/apps/docs/api/rest.md index 80dd3ed6..483df627 100644 --- a/apps/docs/api/rest.md +++ b/apps/docs/api/rest.md @@ -534,6 +534,7 @@ Query parameters: | Method | Path | Access | Description | |--------|------|--------|-------------| | `GET` | `/api/v1/config/analytics` | Public | Get the effective analytics configuration (PostHog key, Sentry DSN, sample rate). Keys, DSN, and instance ID are blank when analytics is off, either from the compile-time bake or the instance `analyticsEnabled` setting. | +| `POST` | `/api/v1/feedback` | Auth | Submit explicit user feedback to the configured PostHog project as `feedback_submitted`. The route respects the analytics gate, rate-limits submissions, strips contact fields unless `contactOk` is true, and never accepts file contents, file names, upload paths, or raw private error text. When analytics is disabled, it returns `{ "ok": true, "accepted": false }`. | | `PUT` | `/api/v1/settings` | Admin (`settings:write`) | Set the instance-wide opt-out. Send a JSON body `{ "analyticsEnabled": "false" }` to turn analytics off for everyone, or `"true"` to turn it back on. | ## Features / AI Bundles diff --git a/apps/docs/guide/security.md b/apps/docs/guide/security.md index ba1c2025..b5f84e8a 100644 --- a/apps/docs/guide/security.md +++ b/apps/docs/guide/security.md @@ -4,7 +4,7 @@ description: Security hardening guide for SnapOtter. Container security, network # Security & Hardening -SnapOtter processes files entirely on your infrastructure. It sends anonymous, content-free product analytics and crash reports by default to help improve the project. It never sends your files, file names, file contents, OCR output, image metadata, or document text. An administrator can turn it off in one click under Settings > System > Privacy, no rebuild required. File processing always stays inside your container. +SnapOtter processes files entirely on your infrastructure. It sends anonymous, content-free product analytics and crash reports by default to help improve the project. It never sends your files, file names, file contents, OCR output, image metadata, or document text. Optional feedback is sent only after a user submits it, only when analytics is enabled, and contact fields are included only with explicit contact consent. An administrator can turn analytics and feedback capture off in one click under Settings > System > Privacy, no rebuild required. File processing always stays inside your container. The container runs as a dedicated non-root user (`snapotter`) with all Linux capabilities dropped except the minimum required set. For the full vulnerability disclosure policy and security architecture, see [SECURITY.md](https://github.com/snapotter-hq/SnapOtter/blob/main/SECURITY.md) on GitHub. diff --git a/apps/landing/src/pages/privacy.astro b/apps/landing/src/pages/privacy.astro index b9dff1c4..09b1d3f0 100644 --- a/apps/landing/src/pages/privacy.astro +++ b/apps/landing/src/pages/privacy.astro @@ -99,6 +99,12 @@ const breadcrumbJsonLd = { SNAPOTTER_ANALYTICS=off build arg as a compile-time hard-off. +
  • + Optional in-app feedback is sent only when a user submits it, and only when analytics + is enabled. Feedback appears in PostHog as feedback_submitted. + Contact details are included only after explicit contact consent. Files, file names, + upload paths, private outputs, and raw error logs are not included. +
  • diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index 5f872356..9f2e5881 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -5,8 +5,10 @@ import { Link } from "react-router-dom"; import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { formatFileSize, triggerDownload } from "@/lib/download"; +import { classifyFeedbackError } from "@/lib/feedback"; import { format } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { ToolFeedbackPrompt } from "../feedback/tool-feedback-prompt"; /** Tools whose primary output is text/data, not a downloadable file. */ const DATA_OUTPUT_TOOLS = new Set([ @@ -213,6 +215,16 @@ export function ReviewPanel({ )} + 0 ? "failed" : "completed"} + errorCategory={ + hasBatchStats && failedCount > 0 + ? classifyFeedbackError(t.toolPage.batchPartialSuccess) + : undefined + } + /> + {/* Edit settings / New file -- side by side */}
    + + +
    + + ); +} diff --git a/apps/web/src/components/feedback/feedback-dialog.tsx b/apps/web/src/components/feedback/feedback-dialog.tsx new file mode 100644 index 00000000..51fbd06c --- /dev/null +++ b/apps/web/src/components/feedback/feedback-dialog.tsx @@ -0,0 +1,520 @@ +import { CheckCircle2, MessageSquare, Send, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { + type FeedbackErrorCategory, + type FeedbackFrictionArea, + type FeedbackImportantArea, + type FeedbackInstallMethod, + type FeedbackPayload, + type FeedbackSentiment, + type FeedbackSource, + type FeedbackType, + type FeedbackUsageType, + promptVariantForSource, + submitFeedback, + surveyIdForSource, +} from "@/lib/feedback"; +import { cn } from "@/lib/utils"; + +interface FeedbackDialogProps { + open: boolean; + source: FeedbackSource; + toolId?: string; + jobStatus?: "completed" | "failed"; + errorCategory?: FeedbackErrorCategory; + initialSentiment?: FeedbackSentiment; + onClose: () => void; + onSubmitted?: () => void; +} + +const FEEDBACK_TYPES: FeedbackType[] = [ + "bug", + "feature_request", + "confusing_ux", + "performance", + "other", +]; + +const SENTIMENTS: FeedbackSentiment[] = ["great", "okay", "issue", "missing"]; +const INSTALL_METHODS: FeedbackInstallMethod[] = [ + "docker", + "docker_compose", + "source", + "cloud", + "other", +]; +const USAGE_TYPES: FeedbackUsageType[] = [ + "personal", + "team_internal", + "business_workflow", + "education", + "evaluating", +]; +const FRICTION_AREAS: FeedbackFrictionArea[] = [ + "smooth", + "docker", + "environment_variables", + "auth", + "storage", + "workers", + "ai_tools", + "docs", + "performance", + "other", +]; +const IMPORTANT_AREAS: FeedbackImportantArea[] = [ + "images", + "pdf_docs", + "video_audio", + "batch_workflows", + "ai_tools", +]; + +export function FeedbackDialog({ + open, + source, + toolId, + jobStatus, + errorCategory, + initialSentiment, + onClose, + onSubmitted, +}: FeedbackDialogProps) { + const { t } = useTranslation(); + const dialogRef = useRef(null); + const [sentiment, setSentiment] = useState(initialSentiment ?? ""); + const [feedbackType, setFeedbackType] = useState("other"); + const [message, setMessage] = useState(""); + const [contactOk, setContactOk] = useState(false); + const [contactEmail, setContactEmail] = useState(""); + const [contactName, setContactName] = useState(""); + const [company, setCompany] = useState(""); + const [installMethod, setInstallMethod] = useState("docker_compose"); + const [usageType, setUsageType] = useState("evaluating"); + const [frictionArea, setFrictionArea] = useState("smooth"); + const [importantAreas, setImportantAreas] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + + useFocusTrap(dialogRef, open); + + useEffect(() => { + if (!open) return; + setSentiment(initialSentiment ?? ""); + setFeedbackType(source === "failed_job" ? "bug" : "other"); + setMessage(""); + setContactOk(false); + setContactEmail(""); + setContactName(""); + setCompany(""); + setInstallMethod("docker_compose"); + setUsageType("evaluating"); + setFrictionArea("smooth"); + setImportantAreas([]); + setSubmitting(false); + setSubmitted(false); + setError(null); + }, [open, initialSentiment, source]); + + useEffect(() => { + if (!open) return; + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const title = useMemo(() => { + if (source === "tool_result") return t.feedback.toolDialogTitle; + if (source === "failed_job") return t.feedback.failedDialogTitle; + if (source === "admin_installer") return t.feedback.adminDialogTitle; + return t.feedback.dialogTitle; + }, [source, t.feedback]); + + const isAdminInstall = source === "admin_installer"; + const canSubmit = Boolean( + message.trim() || sentiment || feedbackType !== "other" || isAdminInstall, + ); + + function toggleImportantArea(area: FeedbackImportantArea) { + setImportantAreas((current) => + current.includes(area) ? current.filter((value) => value !== area) : [...current, area], + ); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit || submitting) return; + + setSubmitting(true); + setError(null); + const payload: FeedbackPayload = { + source, + surveyId: surveyIdForSource(source), + promptVariant: promptVariantForSource(source), + ...(sentiment ? { sentiment } : {}), + feedbackType, + message: message.trim() || undefined, + contactOk, + contactEmail: contactOk ? contactEmail.trim() || undefined : undefined, + contactName: contactOk ? contactName.trim() || undefined : undefined, + company: contactOk ? company.trim() || undefined : undefined, + toolId, + jobStatus, + errorCategory, + ...(isAdminInstall + ? { + installMethod, + usageType, + frictionArea, + importantAreas, + } + : {}), + }; + + try { + await submitFeedback(payload); + setSubmitted(true); + onSubmitted?.(); + } catch { + setError(t.feedback.submitFailed); + } finally { + setSubmitting(false); + } + } + + if (!open) return null; + + return ( +
    +