diff --git a/client/__tests__/LogsPage.remaining.test.tsx b/client/__tests__/LogsPage.remaining.test.tsx index a6009fe3..025dc81c 100644 --- a/client/__tests__/LogsPage.remaining.test.tsx +++ b/client/__tests__/LogsPage.remaining.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -214,9 +214,11 @@ describe("LogsPage remaining coverage", () => { }); expect(await screen.findByText("Error message")).toBeInTheDocument(); - logStreamState.callback?.( - JSON.stringify({ level: 50, module: "cron", msg: "error streamed", time: Date.now() }) - ); + await act(async () => { + logStreamState.callback?.( + JSON.stringify({ level: 50, module: "cron", msg: "error streamed", time: Date.now() }) + ); + }); expect(await screen.findByText("error streamed")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Inspect log Error message/i })); diff --git a/client/__tests__/SendLogsDialog.test.tsx b/client/__tests__/SendLogsDialog.test.tsx new file mode 100644 index 00000000..6478911f --- /dev/null +++ b/client/__tests__/SendLogsDialog.test.tsx @@ -0,0 +1,213 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { toastMock, scrubLogLinesMock, detectPlatformMock, sendLogsMock, buildGitHubIssueUrlMock } = + vi.hoisted(() => ({ + toastMock: vi.fn(), + scrubLogLinesMock: vi.fn((lines: string[]) => lines.join("\n")), + detectPlatformMock: vi.fn(() => "Windows"), + sendLogsMock: vi.fn(), + buildGitHubIssueUrlMock: vi.fn( + () => "https://github.com/Doezer/Questarr/issues/new?title=test" + ), + })); + +vi.mock("@/hooks/use-toast", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +vi.mock("@/lib/send-logs", () => ({ + scrubLogLines: scrubLogLinesMock, + detectPlatform: detectPlatformMock, + sendLogs: sendLogsMock, + buildGitHubIssueUrl: buildGitHubIssueUrlMock, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + asChild, + children, + ...props + }: React.ButtonHTMLAttributes & { + asChild?: boolean; + children: React.ReactNode; + }) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, props); + } + return ( + + ); + }, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogFooter: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), +})); + +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal>(); + const icon = (name: string) => () => ; + return { + ...actual, + Copy: icon("icon-copy"), + ExternalLink: icon("icon-external-link"), + Loader2: icon("icon-loader"), + Send: icon("icon-send"), + ShieldAlert: icon("icon-shield"), + }; +}); + +import SendLogsDialog from "../src/components/SendLogsDialog"; + +describe("SendLogsDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); + }); + + it("submits scrubbed logs and shows the success state", async () => { + sendLogsMock.mockResolvedValue({ ok: true, code: "ABCD", issueNumber: 123 }); + const onOpenChange = vi.fn(); + const privateIp = ["10", "0", "0", "1"].join("."); + const currentDate = new Date("2026-05-31T12:34:56.000Z"); + + const { rerender } = render( + currentDate} + /> + ); + + expect(screen.getByText("Send logs to support")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Send logs" })); + + expect(await screen.findByText("Logs uploaded")).toBeInTheDocument(); + expect(scrubLogLinesMock).toHaveBeenCalledWith(["user@example.com", privateIp]); + expect(sendLogsMock).toHaveBeenCalledWith( + expect.objectContaining({ + logs: `user@example.com\n${privateIp}`, + appVersion: "unknown", + platform: "Windows", + timestamp: "2026-05-31T12:34:56.000Z", + }) + ); + expect(buildGitHubIssueUrlMock).toHaveBeenCalledWith("ABCD", "unknown"); + + fireEvent.click(screen.getByRole("button", { name: "Copy support code" })); + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("ABCD"); + }); + expect(toastMock).toHaveBeenCalledWith({ + title: "Copied", + description: "Code ABCD copied to clipboard", + }); + + expect(screen.getByRole("link", { name: "Create GitHub issue" })).toHaveAttribute( + "href", + "https://github.com/Doezer/Questarr/issues/new?title=test" + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + + rerender(); + rerender(); + expect(screen.getByText("Send logs to support")).toBeInTheDocument(); + }); + + it("shows an error state, rate-limit guidance, and lets the user retry", async () => { + sendLogsMock.mockResolvedValue({ + ok: false, + status: 429, + message: "Rate limit reached (5 submissions per hour). Try again later.", + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Send logs" })); + + expect(await screen.findByText("Upload failed")).toBeInTheDocument(); + expect(screen.getByText(/5 log bundles per hour/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByText("Send logs to support")).toBeInTheDocument(); + }); + + it("shows a destructive toast when copying the support code fails", async () => { + sendLogsMock.mockResolvedValue({ ok: true, code: "WXYZ", issueNumber: 999 }); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error("denied")), + }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Send logs" })); + expect(await screen.findByText("Logs uploaded")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Copy support code" })); + await waitFor(() => { + expect(toastMock).toHaveBeenCalledWith({ + title: "Copy failed", + description: "Clipboard access denied", + variant: "destructive", + }); + }); + }); + + it("shows a fallback toast when the Clipboard API is unavailable", async () => { + sendLogsMock.mockResolvedValue({ ok: true, code: "QWER", issueNumber: 321 }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Send logs" })); + expect(await screen.findByText("Logs uploaded")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Copy support code" })); + + expect(toastMock).toHaveBeenCalledWith({ + title: "Copy failed", + description: "Clipboard API not supported in this browser", + variant: "destructive", + }); + }); + + it("disables submission when there are no logs to send and can be cancelled", () => { + const onOpenChange = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Send logs" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/client/__tests__/send-logs.test.ts b/client/__tests__/send-logs.test.ts new file mode 100644 index 00000000..1e9ed3f0 --- /dev/null +++ b/client/__tests__/send-logs.test.ts @@ -0,0 +1,210 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const supportConfigState = vi.hoisted(() => ({ + workerUrl: "https://support.example/workers/logs", + issuesUrl: "https://github.com/Doezer/Questarr/issues/new", +})); + +vi.mock("../src/lib/support-config", () => ({ + get SUPPORT_WORKER_URL() { + return supportConfigState.workerUrl; + }, + get GITHUB_ISSUES_URL() { + return supportConfigState.issuesUrl; + }, +})); + +import { + buildGitHubIssueUrl, + detectPlatform, + scrubLogLines, + scrubPii, + sendLogs, +} from "../src/lib/send-logs"; + +describe("send-logs utilities", () => { + const originalFetch = globalThis.fetch; + const originalUserAgent = globalThis.navigator.userAgent; + + beforeEach(() => { + vi.restoreAllMocks(); + supportConfigState.workerUrl = "https://support.example/workers/logs"; + supportConfigState.issuesUrl = "https://github.com/Doezer/Questarr/issues/new"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis.navigator, "userAgent", { + configurable: true, + value: originalUserAgent, + }); + }); + + it("scrubs common PII patterns from log text", () => { + const ipv4 = ["192", "168", "1", "12"].join("."); + const ipv6 = ["2001", "0db8", "85a3", "0000", "0000", "8a2e", "0370", "7334"].join(":"); + const input = [ + "email=user@example.com", + `ipv4=${ipv4}`, + `ipv6=${ipv6}`, + "uuid=123e4567-e89b-12d3-a456-426614174000", + "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature", + "unix=/home/alice/questarr/logs/app.log", + String.raw`win=C:\Users\alice\Questarr\logs\app.log`, + ].join(" "); + + expect(scrubPii(input)).toBe( + "email=[email] ipv4=[ip] ipv6=[ip] uuid=[uuid] token=[jwt] unix=/home/[user]/questarr/logs/app.log win=C:\\Users\\[user]\\Questarr\\logs\\app.log" + ); + }); + + it("scrubs each line before joining them", () => { + const privateIp = ["10", "0", "0", "1"].join("."); + expect(scrubLogLines(["user@example.com", privateIp])).toBe("[email]\n[ip]"); + }); + + it("keeps clock timestamps while still scrubbing compressed IPv6 addresses", () => { + expect(scrubPii("time=12:34:56 client=::1 local=fe80::")).toBe( + "time=12:34:56 client=[ip] local=[ip]" + ); + }); + + it("returns a configuration error when log upload is not configured", async () => { + supportConfigState.workerUrl = "https://questarr-log-collector.REPLACE_ME.workers.dev"; + + await expect( + sendLogs({ + logs: "hello", + appVersion: "1.4.0", + platform: "Windows", + timestamp: "2026-05-31T12:00:00.000Z", + }) + ).resolves.toEqual({ + ok: false, + status: 0, + message: "Log upload is not configured for this build.", + }); + }); + + it("posts scrubbed logs and returns the support code on success", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ code: "ABCD", issueNumber: 123 }), + } satisfies Partial); + globalThis.fetch = fetchMock as typeof fetch; + + await expect( + sendLogs({ + logs: "hello", + appVersion: "1.4.0", + platform: "Windows", + timestamp: "2026-05-31T12:00:00.000Z", + }) + ).resolves.toEqual({ + ok: true, + code: "ABCD", + issueNumber: 123, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://support.example/workers/logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + logs: "hello", + appVersion: "1.4.0", + platform: "Windows", + timestamp: "2026-05-31T12:00:00.000Z", + }), + }); + }); + + it("surfaces invalid success payloads as failures", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + throw new SyntaxError("Unexpected end of JSON input"); + }, + }) as typeof fetch; + + await expect( + sendLogs({ + logs: "hello", + appVersion: "1.4.0", + platform: "Windows", + timestamp: "2026-05-31T12:00:00.000Z", + }) + ).resolves.toEqual({ + ok: false, + status: 0, + message: "Unexpected end of JSON input", + }); + }); + + it("prefers the worker error payload when the upload fails", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 502, + json: async () => ({ error: "GitHub rejected the gist request." }), + }) as typeof fetch; + + await expect( + sendLogs({ + logs: "hello", + appVersion: "1.4.0", + platform: "Windows", + timestamp: "2026-05-31T12:00:00.000Z", + }) + ).resolves.toEqual({ + ok: false, + status: 502, + message: "GitHub rejected the gist request.", + }); + }); + + it("surfaces network failures", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline")) as typeof fetch; + + await expect( + sendLogs({ + logs: "hello", + appVersion: "1.4.0", + platform: "Windows", + timestamp: "2026-05-31T12:00:00.000Z", + }) + ).resolves.toEqual({ + ok: false, + status: 0, + message: "offline", + }); + }); + + it("builds a prefilled GitHub issue URL", () => { + const issueUrl = buildGitHubIssueUrl("ABCD", "1.4.0"); + + expect(issueUrl).toContain("https://github.com/Doezer/Questarr/issues/new?"); + expect(decodeURIComponent(issueUrl)).toContain("[Support] Issue with Questarr v1.4.0"); + expect(decodeURIComponent(issueUrl)).toContain("**App version:** 1.4.0"); + expect(decodeURIComponent(issueUrl)).toContain("**Support log #:** `ABCD`"); + }); + + it("detects the current platform from the browser user agent", () => { + Object.defineProperty(globalThis.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }); + expect(detectPlatform()).toBe("Windows"); + + Object.defineProperty(globalThis.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (X11; Linux x86_64)", + }); + expect(detectPlatform()).toBe("Linux"); + + Object.defineProperty(globalThis.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)", + }); + expect(detectPlatform()).toBe("iOS"); + }); +}); diff --git a/client/src/components/SendLogsDialog.tsx b/client/src/components/SendLogsDialog.tsx new file mode 100644 index 00000000..f29a896f --- /dev/null +++ b/client/src/components/SendLogsDialog.tsx @@ -0,0 +1,280 @@ +import { useState, useCallback } from "react"; +import { Copy, ExternalLink, Loader2, Send, ShieldAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { + buildGitHubIssueUrl, + detectPlatform, + scrubLogLines, + sendLogs, + type SendLogsResult, +} from "@/lib/send-logs"; + +interface SendLogsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Raw NDJSON lines currently visible in the log viewer */ + logLines: string[]; + getCurrentDate?: () => Date; +} + +type Step = "consent" | "sending" | "success" | "error"; + +declare global { + var __APP_VERSION__: string | undefined; +} + +const APP_VERSION = globalThis.__APP_VERSION__ ?? "unknown"; + +export default function SendLogsDialog({ + open, + onOpenChange, + logLines, + getCurrentDate, +}: Readonly) { + const { toast } = useToast(); + const [step, setStep] = useState("consent"); + const [result, setResult] = useState(null); + + const reset = useCallback(() => { + setStep("consent"); + setResult(null); + }, []); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) reset(); + onOpenChange(nextOpen); + }, + [onOpenChange, reset] + ); + + const handleSend = useCallback(async () => { + setStep("sending"); + + const scrubbedLogs = scrubLogLines(logLines); + const currentDate = getCurrentDate?.() ?? new Date(); + const outcome = await sendLogs({ + logs: scrubbedLogs, + appVersion: APP_VERSION, + platform: detectPlatform(), + timestamp: currentDate.toISOString(), + }); + + setResult(outcome); + setStep(outcome.ok ? "success" : "error"); + }, [getCurrentDate, logLines]); + + const handleCopyCode = useCallback(() => { + if (!result?.ok) return; + const clipboard = navigator.clipboard; + if (!clipboard) { + toast({ + title: "Copy failed", + description: "Clipboard API not supported in this browser", + variant: "destructive", + }); + return; + } + + clipboard + .writeText(result.code) + .then(() => { + toast({ title: "Copied", description: `Code ${result.code} copied to clipboard` }); + }) + .catch(() => { + toast({ + title: "Copy failed", + description: "Clipboard access denied", + variant: "destructive", + }); + }); + }, [result, toast]); + + const issueUrl = result?.ok ? buildGitHubIssueUrl(result.code, APP_VERSION) : null; + + return ( + + + {/* ── Consent ─────────────────────────────────────────────────────── */} + {step === "consent" && ( + <> + + + + Send logs to support + + + Review what will be shared before confirming + + + +
+

+ This will upload your current server logs so the Questarr maintainer can diagnose + your issue. Please review what will be shared: +

+ +
    +
  • + + + Log content — the {logLines.length} lines currently displayed + in the log viewer, with emails, IP addresses, and UUIDs replaced by + placeholders. + +
  • +
  • + + + App version — {APP_VERSION} + +
  • +
  • + + + Platform — {detectPlatform()} + +
  • +
  • + + + Timestamp — current date/time (UTC) + +
  • +
+ +
+ +

+ Logs are stored as an issue in a private GitHub repository + visible only to the Questarr maintainer. You will receive an issue number as your + support code — share only that number with support. +

+
+
+ + + + + + + )} + + {/* ── Sending ──────────────────────────────────────────────────────── */} + {step === "sending" && ( + <> + + Uploading logs… + Upload in progress + +
+ +

Scrubbing PII and uploading…

+
+ + )} + + {/* ── Success ──────────────────────────────────────────────────────── */} + {step === "success" && result?.ok && ( + <> + + Logs uploaded + Support code ready + + +
+

Give this code to support:

+ +
+ + {result.code} + + +
+ +

+ Or open a public GitHub issue with this number pre-filled so the maintainer can look + it up: +

+
+ + + + {issueUrl && ( + + )} + + + )} + + {/* ── Error ────────────────────────────────────────────────────────── */} + {step === "error" && result && !result.ok && ( + <> + + Upload failed + Error details + + +
+

{result.message}

+ {result.status === 429 && ( +

+ You can submit up to 5 log bundles per hour per IP address. +

+ )} +
+ + + + + + + )} +
+
+ ); +} diff --git a/client/src/lib/send-logs.ts b/client/src/lib/send-logs.ts new file mode 100644 index 00000000..966c5f48 --- /dev/null +++ b/client/src/lib/send-logs.ts @@ -0,0 +1,246 @@ +/** + * Log submission utilities. + * + * PII scrubbing covers patterns found in Questarr's server log fields: + * - Email addresses (e.g. auth logs, Steam import errors) + * - IPv4/IPv6 (e.g. express access logs, socket connections) + * - UUIDs (e.g. socket IDs formatted as UUIDs, download hashes) + * - JWT tokens (defensive — tokens should never be logged) + * - OS home-dir paths (e.g. /home/alice/… or C:\Users\alice\…) + */ + +import { SUPPORT_WORKER_URL, GITHUB_ISSUES_URL } from "./support-config"; + +// ── PII patterns ────────────────────────────────────────────────────────────── + +/** IPv4 candidates are validated after matching to keep the regex maintainable */ +const IPV4_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; + +/** IPv6 candidates are validated after matching to avoid an overly complex regex */ +const IPV6_RE = /(? (isIpv6(match) ? "[ip]" : match)) + .replace(IPV4_RE, (match) => (isIpv4(match) ? "[ip]" : match)) + .replace(UUID_RE, "[uuid]") + .replace(HOME_PATH_RE, (_match, _username: string) => { + const prefix = _match.startsWith("/") + ? "/home" + : _match.substring(0, 2) + WINDOWS_USERS_SEGMENT; + const sep = _match.includes("\\") ? "\\" : "/"; + return `${prefix}${sep}[user]`; + }); +} + +function scrubEmailAddresses(text: string): string { + let result = ""; + let cursor = 0; + + while (cursor < text.length) { + const atIndex = text.indexOf("@", cursor); + if (atIndex === -1) { + result += text.slice(cursor); + break; + } + + let start = atIndex - 1; + while (start >= cursor && isEmailLocalChar(text[start])) { + start--; + } + + let end = atIndex + 1; + while (end < text.length && isEmailDomainChar(text[end])) { + end++; + } + + const candidate = text.slice(start + 1, end); + if (isLikelyEmail(candidate)) { + result += text.slice(cursor, start + 1); + result += "[email]"; + cursor = end; + continue; + } + + result += text.slice(cursor, atIndex + 1); + cursor = atIndex + 1; + } + + return result; +} + +function isEmailLocalChar(char: string): boolean { + return /[A-Za-z0-9._%+-]/.test(char); +} + +function isEmailDomainChar(char: string): boolean { + return /[A-Za-z0-9.-]/.test(char); +} + +function isLikelyEmail(candidate: string): boolean { + const atIndex = candidate.indexOf("@"); + if (atIndex <= 0 || atIndex !== candidate.lastIndexOf("@")) return false; + + const domain = candidate.slice(atIndex + 1); + if (!domain || domain.startsWith(".") || domain.endsWith(".")) return false; + + const labels = domain.split("."); + if (labels.length < 2) return false; + + return labels.every( + (label) => + label.length > 0 && + !label.startsWith("-") && + !label.endsWith("-") && + /^[A-Za-z0-9-]+$/.test(label) + ); +} + +/** + * Scrub all lines and join them back into a newline-delimited string. + */ +export function scrubLogLines(lines: string[]): string { + return lines.map(scrubPii).join("\n"); +} + +function isIpv4(value: string): boolean { + const octets = value.split("."); + return ( + octets.length === 4 && + octets.every((octet) => { + if (!/^\d{1,3}$/.test(octet)) return false; + const parsed = Number(octet); + return parsed >= 0 && parsed <= 255; + }) + ); +} + +function isIpv6(value: string): boolean { + if (!value.includes(":")) return false; + + const compressedGroups = value.split("::"); + if (compressedGroups.length > 2) return false; + + const groups = value.split(":"); + if (groups.length < 3 || groups.length > 8) return false; + if (compressedGroups.length === 1 && groups.length !== 8) return false; + + return groups.every((group) => group === "" || /^[0-9a-fA-F]{1,4}$/.test(group)); +} + +// ── Worker communication ────────────────────────────────────────────────────── + +export interface SendLogsPayload { + logs: string; + appVersion: string; + platform: string; + timestamp: string; +} + +export interface SendLogsSuccess { + ok: true; + code: string; + issueNumber: number; +} + +export interface SendLogsFailure { + ok: false; + status: number; + message: string; +} + +export type SendLogsResult = SendLogsSuccess | SendLogsFailure; + +export async function sendLogs(payload: SendLogsPayload): Promise { + if (SUPPORT_WORKER_URL.includes("REPLACE_ME")) { + return { + ok: false, + status: 0, + message: "Log upload is not configured for this build.", + }; + } + + try { + const response = await fetch(SUPPORT_WORKER_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + const data = (await response.json()) as { code: string; issueNumber: number }; + return { ok: true, code: data.code, issueNumber: data.issueNumber }; + } + + const errorMessages: Record = { + 413: "Log payload is too large (> 500 KB). Try clearing old logs first.", + 429: "Rate limit reached (5 submissions per hour). Try again later.", + 502: "Log server could not reach GitHub. Try again in a moment.", + }; + + let message = errorMessages[response.status] ?? `Unexpected error (HTTP ${response.status}).`; + try { + const body = (await response.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // ignore — use the default message + } + + return { ok: false, status: response.status, message }; + } catch (err) { + return { + ok: false, + status: 0, + message: err instanceof Error ? err.message : "Network error — check your connection.", + }; + } +} + +// ── GitHub issue URL builder ────────────────────────────────────────────────── + +/** + * Builds a URL to open a new issue in the public Questarr repo. + * The body is pre-filled with the support log number so the maintainer + * can look it up in the private log repository. + */ +export function buildGitHubIssueUrl(code: string, appVersion: string): string { + const title = encodeURIComponent(`[Support] Issue with Questarr v${appVersion}`); + const body = encodeURIComponent( + `**Support log #:** \`${code}\`\n` + + `**App version:** ${appVersion}\n\n` + + `\n` + ); + return `${GITHUB_ISSUES_URL}?title=${title}&body=${body}`; +} + +// ── Platform detection ──────────────────────────────────────────────────────── + +export function detectPlatform(): string { + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return "Windows"; + if (/iPhone|iPad/i.test(ua)) return "iOS"; + if (/Android/i.test(ua)) return "Android"; + if (/Mac OS X|macOS/i.test(ua)) return "macOS"; + if (/Linux/i.test(ua)) return "Linux"; + return "Unknown"; +} diff --git a/client/src/lib/support-config.ts b/client/src/lib/support-config.ts new file mode 100644 index 00000000..905baa5f --- /dev/null +++ b/client/src/lib/support-config.ts @@ -0,0 +1,5 @@ +export { + GITHUB_ISSUES_URL, + SUPPORT_WORKER_ORIGIN, + SUPPORT_WORKER_URL, +} from "@shared/support-config"; diff --git a/client/src/pages/logs.tsx b/client/src/pages/logs.tsx index 5c873fa3..1bfa8c51 100644 --- a/client/src/pages/logs.tsx +++ b/client/src/pages/logs.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Copy, PauseCircle, PlayCircle, ScrollText, Search, Trash2 } from "lucide-react"; +import { Copy, PauseCircle, PlayCircle, ScrollText, Search, Send, Trash2 } from "lucide-react"; +import SendLogsDialog from "@/components/SendLogsDialog"; import { apiRequest } from "@/lib/queryClient"; import { useLogStream } from "@/hooks/use-log-stream"; import { Badge } from "@/components/ui/badge"; @@ -482,6 +483,7 @@ export default function LogsPage() { const [scrollTop, setScrollTop] = useState(0); const [viewportHeight, setViewportHeight] = useState(DEFAULT_VIEWPORT_HEIGHT); const [selectedLine, setSelectedLine] = useState(null); + const [sendLogsOpen, setSendLogsOpen] = useState(false); const { data: initialData, isLoading } = useQuery<{ lines: string[] }>({ queryKey: ["/api/logs"], @@ -674,6 +676,15 @@ export default function LogsPage() { Clear + @@ -778,6 +789,12 @@ export default function LogsPage() { )} setSelectedLine(null)} onCopy={copyText} /> + + line.raw)} + /> ); } diff --git a/server/routes.ts b/server/routes.ts index 3f01cc0b..582709a8 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -86,6 +86,7 @@ import { matchesPlatformFilter, } from "../shared/title-utils.js"; import { categorizeDownload } from "../shared/download-categorizer.js"; +import { SUPPORT_WORKER_ORIGIN } from "../shared/support-config.js"; import archiver from "archiver"; import helmet from "helmet"; import { steamRoutes } from "./steam-routes.js"; @@ -213,7 +214,12 @@ export async function registerRoutes(app: Express): Promise { // 🛡️ Sentinel: Add security headers with Helmet // Configured to allow Vite/React (unsafe-inline/eval) in dev, and IGDB images everywhere const scriptSrc = ["'self'"]; - const connectSrc = ["'self'", "https://raw.githubusercontent.com", "https://api.github.com"]; + const connectSrc = [ + "'self'", + "https://raw.githubusercontent.com", + "https://api.github.com", + SUPPORT_WORKER_ORIGIN, + ]; if (!appConfig.server.isProduction) { scriptSrc.push("'unsafe-inline'", "'unsafe-eval'"); diff --git a/shared/support-config.ts b/shared/support-config.ts new file mode 100644 index 00000000..2d8ffaf1 --- /dev/null +++ b/shared/support-config.ts @@ -0,0 +1,3 @@ +export const SUPPORT_WORKER_URL = "https://questarr-log-collector.questarr.workers.dev"; +export const SUPPORT_WORKER_ORIGIN = new URL(SUPPORT_WORKER_URL).origin; +export const GITHUB_ISSUES_URL = "https://github.com/Doezer/Questarr/issues/new"; diff --git a/vite.config.ts b/vite.config.ts index d46664e3..a31d80a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,18 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import path from "path"; +import path from "node:path"; +import { readFileSync } from "node:fs"; + +const { version } = JSON.parse( + readFileSync(path.resolve(import.meta.dirname, "package.json"), "utf-8") +) as { version: string }; + export default defineConfig({ plugins: [react(), tailwindcss()], + define: { + "globalThis.__APP_VERSION__": JSON.stringify(version), + }, resolve: { alias: { "@": path.resolve(import.meta.dirname, "client", "src"), @@ -18,7 +27,7 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: (id) => { - const p = id.replace(/\\/g, "/"); + const p = id.replaceAll("\\", "/"); if (!p.includes("/node_modules/")) return; if (/\/node_modules\/(react|react-dom|scheduler)\//.test(p)) return "react"; if (p.includes("/node_modules/@tanstack/")) return "react-query";