From ac7aefa9fc7068d0f4da90513c8877984e1708b0 Mon Sep 17 00:00:00 2001 From: Doezer Date: Sun, 31 May 2026 16:02:04 +0200 Subject: [PATCH 1/3] Add send logs button --- client/__tests__/LogsPage.remaining.test.tsx | 10 +- client/__tests__/SendLogsDialog.test.tsx | 188 ++++++++++++++ client/__tests__/send-logs.test.ts | 178 +++++++++++++ client/src/components/SendLogsDialog.tsx | 256 +++++++++++++++++++ client/src/lib/send-logs.ts | 187 ++++++++++++++ client/src/lib/support-config.ts | 14 + client/src/pages/logs.tsx | 19 +- vite.config.ts | 13 +- 8 files changed, 858 insertions(+), 7 deletions(-) create mode 100644 client/__tests__/SendLogsDialog.test.tsx create mode 100644 client/__tests__/send-logs.test.ts create mode 100644 client/src/components/SendLogsDialog.tsx create mode 100644 client/src/lib/send-logs.ts create mode 100644 client/src/lib/support-config.ts 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..8b6453de --- /dev/null +++ b/client/__tests__/SendLogsDialog.test.tsx @@ -0,0 +1,188 @@ +/** @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", gistId: "gist-123" }); + const onOpenChange = vi.fn(); + + const { rerender } = render( + + ); + + 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", "10.0.0.1"]); + expect(sendLogsMock).toHaveBeenCalledWith( + expect.objectContaining({ + logs: "user@example.com\n10.0.0.1", + appVersion: "unknown", + platform: "Windows", + }) + ); + 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", gistId: "gist-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("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..7f4d2304 --- /dev/null +++ b/client/__tests__/send-logs.test.ts @@ -0,0 +1,178 @@ +/** @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 = global.fetch; + const originalUserAgent = window.navigator.userAgent; + + beforeEach(() => { + vi.restoreAllMocks(); + supportConfigState.workerUrl = "https://support.example/workers/logs"; + supportConfigState.issuesUrl = "https://github.com/Doezer/Questarr/issues/new"; + }); + + afterEach(() => { + global.fetch = originalFetch; + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: originalUserAgent, + }); + }); + + it("scrubs common PII patterns from log text", () => { + const input = [ + "email=user@example.com", + "ipv4=192.168.1.12", + "ipv6=2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "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", () => { + expect(scrubLogLines(["user@example.com", "10.0.0.1"])).toBe("[email]\n[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", gistId: "gist-123" }), + } satisfies Partial); + global.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", + gistId: "gist-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("prefers the worker error payload when the upload fails", async () => { + global.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 () => { + global.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] Log code ABCD"); + expect(decodeURIComponent(issueUrl)).toContain("**App version:** 1.4.0"); + }); + + it("detects the current platform from the browser user agent", () => { + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }); + expect(detectPlatform()).toBe("Windows"); + + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (X11; Linux x86_64)", + }); + expect(detectPlatform()).toBe("Linux"); + + Object.defineProperty(window.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..792276e9 --- /dev/null +++ b/client/src/components/SendLogsDialog.tsx @@ -0,0 +1,256 @@ +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[]; +} + +type Step = "consent" | "sending" | "success" | "error"; + +declare const __APP_VERSION__: string; + +const APP_VERSION: string = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown"; + +export default function SendLogsDialog({ open, onOpenChange, logLines }: SendLogsDialogProps) { + 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 outcome = await sendLogs({ + logs: scrubbedLogs, + appVersion: APP_VERSION, + platform: detectPlatform(), + timestamp: new Date().toISOString(), + }); + + setResult(outcome); + setStep(outcome.ok ? "success" : "error"); + }, [logLines]); + + const handleCopyCode = useCallback(() => { + if (!result?.ok) return; + navigator.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..25ca2d99 --- /dev/null +++ b/client/src/lib/send-logs.ts @@ -0,0 +1,187 @@ +/** + * 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 ────────────────────────────────────────────────────────────── + +/** RFC-5321 local part + domain — catches most real email addresses */ +const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; + +/** 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 = /\b[A-Fa-f0-9:]{2,}\b/g; + +/** RFC-4122 UUID */ +const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; + +/** JWT (three base64url segments starting with eyJ…) */ +const JWT_RE = /eyJ[A-Za-z0-9+/=_-]+\.eyJ[A-Za-z0-9+/=_-]+\.[A-Za-z0-9+/=_-]+/g; + +/** + * Unix home dir: /home/alice/… or /Users/alice/… + * Windows home dir: C:\Users\alice\… (backslash or forward slash) + */ +const HOME_PATH_RE = /(?:\/(?:home|Users)|[A-Za-z]:\\[Uu]sers)[\\/]([^\\/\s"',:}]{1,64})/g; +const WINDOWS_USERS_SEGMENT = String.raw`\Users`; + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Replace PII in a single log string (raw NDJSON line or plain text). + * Each regex is applied independently so replacements don't interfere. + */ +export function scrubPii(text: string): string { + return text + .replace(JWT_RE, "[jwt]") // before email — JWTs contain dots + .replace(EMAIL_RE, "[email]") + .replace(IPV6_RE, (match) => (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]`; + }); +} + +/** + * 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; + + 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.", + }; + } + + let response: Response; + try { + response = await fetch(SUPPORT_WORKER_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (err) { + return { + ok: false, + status: 0, + message: err instanceof Error ? err.message : "Network error — check your connection.", + }; + } + + 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 }; +} + +// ── 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..244fecaa --- /dev/null +++ b/client/src/lib/support-config.ts @@ -0,0 +1,14 @@ +/** + * Support infrastructure configuration. + * + * WORKER_URL is the Cloudflare Worker endpoint for log collection. + * It is intentionally hardcoded so end-users of a self-hosted instance + * cannot redirect logs to a different server. + * + * After deploying the worker (`cd worker && wrangler deploy`), replace the + * placeholder below with the URL printed by Wrangler, e.g.: + * https://questarr-log-collector..workers.dev + */ +export const SUPPORT_WORKER_URL = "https://questarr-log-collector.questarr.workers.dev"; + +export const GITHUB_ISSUES_URL = "https://github.com/Doezer/Questarr/issues/new"; 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/vite.config.ts b/vite.config.ts index d46664e3..19d1a9f0 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: { + __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"; From 3e73b78c0e672c632a46046b36071fe8f67519f4 Mon Sep 17 00:00:00 2001 From: Doezer Date: Sun, 31 May 2026 16:54:07 +0200 Subject: [PATCH 2/3] update tests --- client/__tests__/SendLogsDialog.test.tsx | 4 ++-- client/__tests__/send-logs.test.ts | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/client/__tests__/SendLogsDialog.test.tsx b/client/__tests__/SendLogsDialog.test.tsx index 8b6453de..e3689f6b 100644 --- a/client/__tests__/SendLogsDialog.test.tsx +++ b/client/__tests__/SendLogsDialog.test.tsx @@ -88,7 +88,7 @@ describe("SendLogsDialog", () => { }); it("submits scrubbed logs and shows the success state", async () => { - sendLogsMock.mockResolvedValue({ ok: true, code: "ABCD", gistId: "gist-123" }); + sendLogsMock.mockResolvedValue({ ok: true, code: "ABCD", issueNumber: 123 }); const onOpenChange = vi.fn(); const { rerender } = render( @@ -154,7 +154,7 @@ describe("SendLogsDialog", () => { }); it("shows a destructive toast when copying the support code fails", async () => { - sendLogsMock.mockResolvedValue({ ok: true, code: "WXYZ", gistId: "gist-999" }); + sendLogsMock.mockResolvedValue({ ok: true, code: "WXYZ", issueNumber: 999 }); Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")), diff --git a/client/__tests__/send-logs.test.ts b/client/__tests__/send-logs.test.ts index 7f4d2304..2b133201 100644 --- a/client/__tests__/send-logs.test.ts +++ b/client/__tests__/send-logs.test.ts @@ -24,7 +24,7 @@ import { } from "../src/lib/send-logs"; describe("send-logs utilities", () => { - const originalFetch = global.fetch; + const originalFetch = globalThis.fetch; const originalUserAgent = window.navigator.userAgent; beforeEach(() => { @@ -34,7 +34,7 @@ describe("send-logs utilities", () => { }); afterEach(() => { - global.fetch = originalFetch; + globalThis.fetch = originalFetch; Object.defineProperty(window.navigator, "userAgent", { configurable: true, value: originalUserAgent, @@ -81,9 +81,9 @@ describe("send-logs utilities", () => { it("posts scrubbed logs and returns the support code on success", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ code: "ABCD", gistId: "gist-123" }), + json: async () => ({ code: "ABCD", issueNumber: 123 }), } satisfies Partial); - global.fetch = fetchMock as typeof fetch; + globalThis.fetch = fetchMock as typeof fetch; await expect( sendLogs({ @@ -95,7 +95,7 @@ describe("send-logs utilities", () => { ).resolves.toEqual({ ok: true, code: "ABCD", - gistId: "gist-123", + issueNumber: 123, }); expect(fetchMock).toHaveBeenCalledWith("https://support.example/workers/logs", { @@ -111,7 +111,7 @@ describe("send-logs utilities", () => { }); it("prefers the worker error payload when the upload fails", async () => { - global.fetch = vi.fn().mockResolvedValue({ + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({ error: "GitHub rejected the gist request." }), @@ -132,7 +132,7 @@ describe("send-logs utilities", () => { }); it("surfaces network failures", async () => { - global.fetch = vi.fn().mockRejectedValue(new Error("offline")) as typeof fetch; + globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline")) as typeof fetch; await expect( sendLogs({ @@ -152,8 +152,9 @@ describe("send-logs utilities", () => { const issueUrl = buildGitHubIssueUrl("ABCD", "1.4.0"); expect(issueUrl).toContain("https://github.com/Doezer/Questarr/issues/new?"); - expect(decodeURIComponent(issueUrl)).toContain("[Support] Log code ABCD"); + 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", () => { From 505dd66b84d23fbea7caaf0ef6f0dc892cb537cf Mon Sep 17 00:00:00 2001 From: Doezer Date: Mon, 1 Jun 2026 13:27:54 +0200 Subject: [PATCH 3/3] Fix send logs review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- client/__tests__/SendLogsDialog.test.tsx | 31 +++++- client/__tests__/send-logs.test.ts | 47 +++++++-- client/src/components/SendLogsDialog.tsx | 38 ++++++-- client/src/lib/send-logs.ts | 117 +++++++++++++++++------ client/src/lib/support-config.ts | 19 +--- server/routes.ts | 8 +- shared/support-config.ts | 3 + vite.config.ts | 2 +- 8 files changed, 202 insertions(+), 63 deletions(-) create mode 100644 shared/support-config.ts diff --git a/client/__tests__/SendLogsDialog.test.tsx b/client/__tests__/SendLogsDialog.test.tsx index e3689f6b..6478911f 100644 --- a/client/__tests__/SendLogsDialog.test.tsx +++ b/client/__tests__/SendLogsDialog.test.tsx @@ -90,12 +90,15 @@ describe("SendLogsDialog", () => { 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} /> ); @@ -103,12 +106,13 @@ describe("SendLogsDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Send logs" })); expect(await screen.findByText("Logs uploaded")).toBeInTheDocument(); - expect(scrubLogLinesMock).toHaveBeenCalledWith(["user@example.com", "10.0.0.1"]); + expect(scrubLogLinesMock).toHaveBeenCalledWith(["user@example.com", privateIp]); expect(sendLogsMock).toHaveBeenCalledWith( expect.objectContaining({ - logs: "user@example.com\n10.0.0.1", + logs: `user@example.com\n${privateIp}`, appVersion: "unknown", platform: "Windows", + timestamp: "2026-05-31T12:34:56.000Z", }) ); expect(buildGitHubIssueUrlMock).toHaveBeenCalledWith("ABCD", "unknown"); @@ -176,6 +180,27 @@ describe("SendLogsDialog", () => { }); }); + 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(); diff --git a/client/__tests__/send-logs.test.ts b/client/__tests__/send-logs.test.ts index 2b133201..1e9ed3f0 100644 --- a/client/__tests__/send-logs.test.ts +++ b/client/__tests__/send-logs.test.ts @@ -25,7 +25,7 @@ import { describe("send-logs utilities", () => { const originalFetch = globalThis.fetch; - const originalUserAgent = window.navigator.userAgent; + const originalUserAgent = globalThis.navigator.userAgent; beforeEach(() => { vi.restoreAllMocks(); @@ -35,17 +35,19 @@ describe("send-logs utilities", () => { afterEach(() => { globalThis.fetch = originalFetch; - Object.defineProperty(window.navigator, "userAgent", { + 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=192.168.1.12", - "ipv6=2001:0db8:85a3:0000:0000:8a2e:0370:7334", + `ipv4=${ipv4}`, + `ipv6=${ipv6}`, "uuid=123e4567-e89b-12d3-a456-426614174000", "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature", "unix=/home/alice/questarr/logs/app.log", @@ -58,7 +60,14 @@ describe("send-logs utilities", () => { }); it("scrubs each line before joining them", () => { - expect(scrubLogLines(["user@example.com", "10.0.0.1"])).toBe("[email]\n[ip]"); + 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 () => { @@ -110,6 +119,28 @@ describe("send-logs utilities", () => { }); }); + 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, @@ -158,19 +189,19 @@ describe("send-logs utilities", () => { }); it("detects the current platform from the browser user agent", () => { - Object.defineProperty(window.navigator, "userAgent", { + Object.defineProperty(globalThis.navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", }); expect(detectPlatform()).toBe("Windows"); - Object.defineProperty(window.navigator, "userAgent", { + Object.defineProperty(globalThis.navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (X11; Linux x86_64)", }); expect(detectPlatform()).toBe("Linux"); - Object.defineProperty(window.navigator, "userAgent", { + Object.defineProperty(globalThis.navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)", }); diff --git a/client/src/components/SendLogsDialog.tsx b/client/src/components/SendLogsDialog.tsx index 792276e9..f29a896f 100644 --- a/client/src/components/SendLogsDialog.tsx +++ b/client/src/components/SendLogsDialog.tsx @@ -23,15 +23,23 @@ interface SendLogsDialogProps { onOpenChange: (open: boolean) => void; /** Raw NDJSON lines currently visible in the log viewer */ logLines: string[]; + getCurrentDate?: () => Date; } type Step = "consent" | "sending" | "success" | "error"; -declare const __APP_VERSION__: string; +declare global { + var __APP_VERSION__: string | undefined; +} -const APP_VERSION: string = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown"; +const APP_VERSION = globalThis.__APP_VERSION__ ?? "unknown"; -export default function SendLogsDialog({ open, onOpenChange, logLines }: SendLogsDialogProps) { +export default function SendLogsDialog({ + open, + onOpenChange, + logLines, + getCurrentDate, +}: Readonly) { const { toast } = useToast(); const [step, setStep] = useState("consent"); const [result, setResult] = useState(null); @@ -53,20 +61,31 @@ export default function SendLogsDialog({ open, onOpenChange, logLines }: SendLog setStep("sending"); const scrubbedLogs = scrubLogLines(logLines); + const currentDate = getCurrentDate?.() ?? new Date(); const outcome = await sendLogs({ logs: scrubbedLogs, appVersion: APP_VERSION, platform: detectPlatform(), - timestamp: new Date().toISOString(), + timestamp: currentDate.toISOString(), }); setResult(outcome); setStep(outcome.ok ? "success" : "error"); - }, [logLines]); + }, [getCurrentDate, logLines]); const handleCopyCode = useCallback(() => { if (!result?.ok) return; - navigator.clipboard + 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` }); @@ -147,7 +166,12 @@ export default function SendLogsDialog({ open, onOpenChange, logLines }: SendLog - diff --git a/client/src/lib/send-logs.ts b/client/src/lib/send-logs.ts index 25ca2d99..966c5f48 100644 --- a/client/src/lib/send-logs.ts +++ b/client/src/lib/send-logs.ts @@ -13,14 +13,11 @@ import { SUPPORT_WORKER_URL, GITHUB_ISSUES_URL } from "./support-config"; // ── PII patterns ────────────────────────────────────────────────────────────── -/** RFC-5321 local part + domain — catches most real email addresses */ -const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; - /** 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 = /\b[A-Fa-f0-9:]{2,}\b/g; +const IPV6_RE = /(? (isIpv6(match) ? "[ip]" : match)) .replace(IPV4_RE, (match) => (isIpv4(match) ? "[ip]" : match)) .replace(UUID_RE, "[uuid]") @@ -57,6 +53,69 @@ export function scrubPii(text: string): string { }); } +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. */ @@ -84,6 +143,7 @@ function isIpv6(value: string): boolean { 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)); } @@ -120,13 +180,33 @@ export async function sendLogs(payload: SendLogsPayload): Promise = { + 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, @@ -134,27 +214,6 @@ export async function sendLogs(payload: SendLogsPayload): Promise = { - 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 }; } // ── GitHub issue URL builder ────────────────────────────────────────────────── diff --git a/client/src/lib/support-config.ts b/client/src/lib/support-config.ts index 244fecaa..905baa5f 100644 --- a/client/src/lib/support-config.ts +++ b/client/src/lib/support-config.ts @@ -1,14 +1,5 @@ -/** - * Support infrastructure configuration. - * - * WORKER_URL is the Cloudflare Worker endpoint for log collection. - * It is intentionally hardcoded so end-users of a self-hosted instance - * cannot redirect logs to a different server. - * - * After deploying the worker (`cd worker && wrangler deploy`), replace the - * placeholder below with the URL printed by Wrangler, e.g.: - * https://questarr-log-collector..workers.dev - */ -export const SUPPORT_WORKER_URL = "https://questarr-log-collector.questarr.workers.dev"; - -export const GITHUB_ISSUES_URL = "https://github.com/Doezer/Questarr/issues/new"; +export { + GITHUB_ISSUES_URL, + SUPPORT_WORKER_ORIGIN, + SUPPORT_WORKER_URL, +} from "@shared/support-config"; 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 19d1a9f0..a31d80a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,7 @@ const { version } = JSON.parse( export default defineConfig({ plugins: [react(), tailwindcss()], define: { - __APP_VERSION__: JSON.stringify(version), + "globalThis.__APP_VERSION__": JSON.stringify(version), }, resolve: { alias: {