From 31e2e5ac7f2dd2d10a88a2d9d8c8ba1cfd275721 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 13 Jul 2026 20:32:36 +0200 Subject: [PATCH] fix: harden workshop availability --- .env.example | 2 +- PRODUCT.md | 33 +++ apps/docs/docs/management/backup.mdx | 9 +- apps/docs/docs/management/workshop/index.mdx | 2 + apps/docs/docusaurus.config.ts | 8 +- apps/docs/package.json | 3 + .../src/components/workshop/WorkshopAdmin.tsx | 89 +++++--- .../src/components/workshop/WorkshopPage.tsx | 161 ++++++++++----- .../workshop/WorkshopQueryProvider.tsx | 43 ++++ .../custom-widgets/_custom-widget-actions.tsx | 2 +- .../app/[locale]/manage/integrations/page.tsx | 193 ++++++++++-------- .../nextjs/src/app/api/backup/import/route.ts | 4 +- .../components/workshop/workshop-browser.tsx | 185 +++++++++++------ .../workshop/workshop-query-provider.tsx | 51 +++++ apps/workshop/Dockerfile | 4 +- apps/workshop/README.md | 6 +- apps/workshop/docker-compose.yml | 2 +- apps/workshop/entrypoint.sh | 8 +- apps/workshop/pb_hooks/workshop-utils.js | 7 +- .../pb_migrations/1783900000_workshop_v2.js | 37 +++- apps/workshop/tests/run-integration.sh | 2 +- apps/workshop/tests/workshop.integration.mjs | 24 ++- audit.md | 4 +- packages/definitions/src/integration.ts | 3 + packages/translation/src/lang/en.json | 17 ++ packages/workshop/src/client.ts | 11 +- pnpm-lock.yaml | 9 + scripts/run.sh | 31 ++- 28 files changed, 671 insertions(+), 279 deletions(-) create mode 100644 PRODUCT.md create mode 100644 apps/docs/src/components/workshop/WorkshopQueryProvider.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-query-provider.tsx diff --git a/.env.example b/.env.example index 5efbdf549..64c335c87 100644 --- a/.env.example +++ b/.env.example @@ -78,4 +78,4 @@ GITHUB_CLIENT_SECRET= PB_SUPERUSER_EMAIL= PB_SUPERUSER_PASSWORD= PB_EXPOSE_PORT=8090 -PB_ALLOWED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 +PB_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575 diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..3cd47bfa5 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +Homarr serves people who organize and operate self-hosted services, from individual home-lab users to administrators managing shared instances. Workshop also serves community creators who publish and install custom widgets and CSS themes. Users need the core dashboard to remain useful even when optional community services are offline. + +## Product Purpose + +Homarr provides a dependable dashboard for discovering, organizing, and operating applications and integrations. Workshop adds a central community catalog where users can inspect, export, publish, report, and install creations with clear source visibility and explicit confirmation. + +## Brand Personality + +Clear, dependable, and community-minded. Interface copy should be direct and practical, with enough context for users to recover from failures without exposing internal implementation details. + +## Anti-references + +Avoid fragmented feature surfaces, generic failure messages, decorative dashboard patterns, unexpected network dependencies, and optional services that can crash or block core Homarr workflows. + +## Design Principles + +- Keep core Homarr workflows available when optional services fail. +- Explain failures in user terms and always offer a useful recovery action. +- Use native Homarr patterns and shared contracts instead of feature-specific interaction systems. +- Make community content inspectable and require explicit confirmation before installation. +- Keep operational behavior deterministic for contributors and instance administrators. + +## Accessibility & Inclusion + +Meet WCAG 2.1 AA for contrast, focus, semantics, and keyboard operation. Respect reduced-motion preferences, preserve useful layouts at 200% zoom and on mobile, and ensure status changes and recovery actions are understandable without relying on color alone. diff --git a/apps/docs/docs/management/backup.mdx b/apps/docs/docs/management/backup.mdx index 558afb82e..1e297ba84 100644 --- a/apps/docs/docs/management/backup.mdx +++ b/apps/docs/docs/management/backup.mdx @@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing: -| File | Description | -| -------------------- | ------------------------------------------------------------------- | -| `db.sqlite` | Full SQLite database with all entities | -| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key | +| File | Description | +| --------------- | ------------------------------------------------------------------- | +| `db.sqlite` | Full SQLite database with all entities | +| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key | The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database) @@ -54,6 +54,7 @@ Type `I understand` to acknowledge that restoring will replace your current data The backup ZIP is sent to the server, validated, and applied. After a successful restore: - **The Next.js server restarts automatically** to reload the new database state +- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created - Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts - The page reloads once the server is ready diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 956e516fb..0906496da 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -19,6 +19,8 @@ Workshop entries are created by community members. Review widget endpoints, temp In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions. +Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns. + - Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally. - CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**. - The public website never executes submitted widget templates or CSS. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index e35182bff..abeafee4a 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -3,6 +3,11 @@ import type { Config } from "@docusaurus/types"; import { themes as prismThemes } from "prism-react-renderer"; const a11yEmoji = require("@fec/remark-a11y-emoji"); +const workshopApiUrl = + process.env.NODE_ENV !== "production" + ? (process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev") + : "https://workshop.homarr.dev"; + const config: Config = { title: "Homarr documentation", tagline: "A simple yet powerful dashboard for your server.", @@ -17,8 +22,7 @@ const config: Config = { locales: ["en"], }, customFields: { - workshopApiUrl: - process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev", + workshopApiUrl, }, onBrokenLinks: "throw", onBrokenAnchors: "throw", diff --git a/apps/docs/package.json b/apps/docs/package.json index 9be4f01cc..50b9596cc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -34,6 +34,9 @@ "@signalwire/docusaurus-plugin-llms-txt": "^1.2.2", "@tabler/icons-react": "catalog:", "@tailwindcss/postcss": "^4.3.2", + "@tanstack/query-async-storage-persister": "catalog:", + "@tanstack/react-query": "catalog:", + "@tanstack/react-query-persist-client": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", "docusaurus-plugin-image-zoom": "^3.0.1", diff --git a/apps/docs/src/components/workshop/WorkshopAdmin.tsx b/apps/docs/src/components/workshop/WorkshopAdmin.tsx index 9e5e230c2..c63697b35 100644 --- a/apps/docs/src/components/workshop/WorkshopAdmin.tsx +++ b/apps/docs/src/components/workshop/WorkshopAdmin.tsx @@ -1,16 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { useQuery } from "@tanstack/react-query"; import { WORKSHOP_API_URL, WorkshopClient, type WorkshopAccountState, - type WorkshopModerationAction, - type WorkshopReport, type WorkshopRole, type WorkshopUser, } from "@homarr/workshop"; +import { WorkshopQueryProvider } from "./WorkshopQueryProvider"; import styles from "./workshop.module.css"; type PendingAction = @@ -19,50 +19,77 @@ type PendingAction = | { kind: "role"; id: string; role: WorkshopRole }; export function WorkshopAdmin() { + return ( + + + + ); +} + +function WorkshopAdminContent() { const { siteConfig } = useDocusaurusContext(); const client = useMemo( () => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL), [siteConfig], ); const [user, setUser] = useState(null); - const [reports, setReports] = useState([]); - const [users, setUsers] = useState([]); - const [actions, setActions] = useState([]); const [pending, setPending] = useState(null); const [error, setError] = useState(""); + const [authLoading, setAuthLoading] = useState(true); - const load = useCallback(async () => { - const current = await client.refreshAuth(); - setUser(current); - if (!current || (current.role !== "moderator" && current.role !== "admin")) return; - try { - const [nextReports, nextUsers, nextActions] = await Promise.all([ + const canModerate = user?.role === "moderator" || user?.role === "admin"; + const moderationQuery = useQuery({ + queryKey: ["workshop", "moderation"], + queryFn: async () => { + const [reports, users, actions] = await Promise.all([ client.listReports(), client.listUsers(), client.listModerationActions(), ]); - setReports(nextReports); - setUsers(nextUsers); - setActions(nextActions); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Could not load moderation data"); - } - }, [client]); + return { reports, users, actions }; + }, + enabled: canModerate, + }); + const reports = moderationQuery.data?.reports ?? []; + const users = moderationQuery.data?.users ?? []; + const actions = moderationQuery.data?.actions ?? []; + useEffect(() => client.subscribeToAuth(setUser), [client]); useEffect(() => { - void load(); - }, [load]); + void client + .refreshAuth() + .then(setUser) + .catch((caught: unknown) => + setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"), + ) + .finally(() => setAuthLoading(false)); + }, [client]); + if (authLoading) + return ( +
+ +
+ ); if (!user) return (

Workshop moderation

Sign in with a promoted GitHub account to continue.

+ {error && ( +
+ {error} +
+ )} } )}
@@ -235,7 +263,7 @@ export function WorkshopAdmin() { else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason); else await client.updateRole(pending.id, pending.role, reason); setPending(null); - await load(); + await moderationQuery.refetch(); }} /> )} @@ -243,6 +271,15 @@ export function WorkshopAdmin() { ); } +function WorkshopAdminSkeleton() { + return ( +
+

Workshop moderation

+

Loading account and moderation data…

+
+ ); +} + function ReasonDialog({ action, onClose, diff --git a/apps/docs/src/components/workshop/WorkshopPage.tsx b/apps/docs/src/components/workshop/WorkshopPage.tsx index c013ca027..43821dc26 100644 --- a/apps/docs/src/components/workshop/WorkshopPage.tsx +++ b/apps/docs/src/components/workshop/WorkshopPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Link from "@docusaurus/Link"; import { useHistory, useLocation } from "@docusaurus/router"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; @@ -13,6 +13,8 @@ import { } from "@tabler/icons-react"; import { + MAX_CONTENT_LENGTH, + MAX_SCREENSHOTS, WORKSHOP_API_URL, WorkshopClient, type WorkshopSubmissionDetail, @@ -22,7 +24,9 @@ import { type WorkshopUser, workshopExportFilename, } from "@homarr/workshop"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { WorkshopQueryProvider } from "./WorkshopQueryProvider"; import styles from "./workshop.module.css"; const useWorkshop = () => { @@ -43,45 +47,55 @@ const saveContent = (submission: WorkshopSubmissionDetail) => { }; export function WorkshopApp() { + return ( + + + + ); +} + +function WorkshopAppContent() { const client = useWorkshop(); + const queryClient = useQueryClient(); const [user, setUser] = useState(null); - const [items, setItems] = useState([]); const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); const [type, setType] = useState("all"); const [sort, setSort] = useState<"top" | "newest">("top"); const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); const [mine, setMine] = useState(false); - const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showForm, setShowForm] = useState(false); useEffect(() => client.subscribeToAuth(setUser), [client]); - - const load = useCallback(async () => { - setLoading(true); - setError(""); - try { - await client.refreshAuth(); - const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined }); - setItems(result.items); - setTotalPages(result.totalPages); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Workshop is unavailable"); - } finally { - setLoading(false); - } - }, [client, mine, page, search, sort, type]); - useEffect(() => { - const timeout = window.setTimeout(() => void load(), 250); + void client.refreshAuth().catch(() => undefined); + }, [client]); + useEffect(() => { + const timeout = window.setTimeout(() => setDebouncedSearch(search), 250); return () => window.clearTimeout(timeout); - }, [load]); + }, [search]); + + const listQuery = useQuery({ + queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null], + queryFn: ({ signal }) => + client.list({ + page, + type, + sort, + search: debouncedSearch, + author: mine ? user?.id : undefined, + signal, + }), + placeholderData: keepPreviousData, + }); + const items = listQuery.data?.items ?? []; + const totalPages = listQuery.data?.totalPages ?? 1; const signIn = async () => { try { await client.signInWithGitHub(); - await load(); + await queryClient.invalidateQueries({ queryKey: ["workshop"] }); } catch (caught) { setError(caught instanceof Error ? caught.message : "Sign-in failed"); } @@ -179,12 +193,17 @@ export function WorkshopApp() { )} - {error && ( + {(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
- {error} + {items.length ? "Showing saved Workshop results." : "Workshop is unavailable."}{" "} + {error || + (items.length + ? "Some details and actions may be unavailable until the service reconnects." + : "Homarr and the documentation remain available while the community service is offline.")}{" "} +
)} - {loading ? ( + {listQuery.isPending ? ( ) : items.length ? (
@@ -192,7 +211,7 @@ export function WorkshopApp() { ))}
- ) : ( + ) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (

No creations found

Adjust the filters, or share the first creation in this category.

@@ -218,7 +237,7 @@ export function WorkshopApp() { onClose={() => setShowForm(false)} onSaved={async () => { setShowForm(false); - await load(); + await queryClient.invalidateQueries({ queryKey: ["workshop"] }); }} /> )} @@ -258,42 +277,64 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop } export function WorkshopDetailRoute() { + return ( + + + + ); +} + +function WorkshopDetailRouteContent() { const client = useWorkshop(); + const queryClient = useQueryClient(); const location = useLocation(); const id = location.pathname.split("/").filter(Boolean).at(-1) ?? ""; const history = useHistory(); const [user, setUser] = useState(null); - const [submission, setSubmission] = useState(null); - const [error, setError] = useState(""); const [reporting, setReporting] = useState(false); const [editing, setEditing] = useState(false); const [moderating, setModerating] = useState(false); + const [actionError, setActionError] = useState(""); - const load = useCallback(async () => { - try { - setSubmission(await client.get(id)); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Submission not found"); - } - }, [client, id]); + const detailQuery = useQuery({ + queryKey: ["workshop", "detail", id], + queryFn: ({ signal }) => client.get(id, signal), + enabled: id.length > 0, + }); + const voteMutation = useMutation({ + mutationKey: ["workshop", "vote", id], + mutationFn: async () => { + await (client.currentUser ?? client.signInWithGitHub()); + return client.vote(id, 1); + }, + onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }), + onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."), + }); + const deleteMutation = useMutation({ + mutationKey: ["workshop", "delete", id], + mutationFn: () => client.delete(id), + onSuccess: () => history.push("/workshop"), + onError: (caught) => + setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."), + }); useEffect(() => client.subscribeToAuth(setUser), [client]); useEffect(() => { void client.refreshAuth(); - void load(); - }, [client, load]); + }, [client]); - if (error) + if (detailQuery.isError) return (
- {error} + This creation is not saved on this device and Workshop could not be reached.{" "} +
Back to Workshop
); - if (!submission) return ; + if (!detailQuery.data) return ; - const requireAuth = async () => client.currentUser ?? client.signInWithGitHub(); + const submission = detailQuery.data; return (
← Back to Workshop @@ -329,10 +370,10 @@ export function WorkshopDetailRoute() { )} + {actionError && ( +
+ {actionError} +
+ )}

Source

@@ -388,7 +435,7 @@ export function WorkshopDetailRoute() { onClose={() => setEditing(false)} onSaved={async () => { setEditing(false); - await load(); + await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }); }} /> )} @@ -484,6 +531,11 @@ function SubmissionForm({ const [removedScreenshots, setRemovedScreenshots] = useState([]); const [error, setError] = useState(""); const [pending, setPending] = useState(false); + const retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length; + const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount); + useEffect(() => { + setScreenshots((current) => current.slice(0, remainingScreenshotSlots)); + }, [remainingScreenshotSlots]); const submit = async (event: React.FormEvent) => { event.preventDefault(); setPending(true); @@ -544,6 +596,7 @@ function SubmissionForm({