fix: harden workshop availability

This commit is contained in:
Thomas Camlong committed 2026-07-14 01:21:53 +02:00
1 parent 699134a576
commit 31e2e5ac7f
28 files changed
+671 -279

No files matched your search

+5 -4
View File
@@ -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
@@ -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.
+6 -2
View File
@@ -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",
+3
View File
@@ -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",
@@ -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 (
<WorkshopQueryProvider>
<WorkshopAdminContent />
</WorkshopQueryProvider>
);
}
function WorkshopAdminContent() {
const { siteConfig } = useDocusaurusContext();
const client = useMemo(
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
[siteConfig],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [users, setUsers] = useState<WorkshopUser[]>([]);
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
const [pending, setPending] = useState<PendingAction | null>(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 (
<main className={styles.page}>
<WorkshopAdminSkeleton />
</main>
);
if (!user)
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<p>Sign in with a promoted GitHub account to continue.</p>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<button
className="button button--primary"
onClick={async () => {
await client.signInWithGitHub();
await load();
setError("");
try {
setUser(await client.signInWithGitHub());
} catch (caught) {
setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable");
}
}}
>
Sign in with GitHub
@@ -87,9 +114,10 @@ export function WorkshopAdmin() {
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
</div>
</header>
{error && (
{(error || moderationQuery.isError) && (
<div className={styles.error} role="alert">
{error}
{error || "Moderation data is unavailable. Try again when Workshop reconnects."}{" "}
{moderationQuery.isError && <button onClick={() => void moderationQuery.refetch()}>Try again</button>}
</div>
)}
<section className={styles.adminSection}>
@@ -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 (
<div aria-label="Loading Workshop moderation">
<h1>Workshop moderation</h1>
<p>Loading account and moderation data…</p>
</div>
);
}
function ReasonDialog({
action,
onClose,
@@ -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 (
<WorkshopQueryProvider>
<WorkshopAppContent />
</WorkshopQueryProvider>
);
}
function WorkshopAppContent() {
const client = useWorkshop();
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">("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() {
)}
</div>
{error && (
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
<div className={styles.error} role="alert">
{error} <button onClick={() => void load()}>Try again</button>
<strong>{items.length ? "Showing saved Workshop results." : "Workshop is unavailable."}</strong>{" "}
{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.")}{" "}
<button onClick={() => void listQuery.refetch()}>Try again</button>
</div>
)}
{loading ? (
{listQuery.isPending ? (
<WorkshopSkeleton />
) : items.length ? (
<div className={styles.grid}>
@@ -192,7 +211,7 @@ export function WorkshopApp() {
<WorkshopCard client={client} item={item} key={item.id} />
))}
</div>
) : (
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
<section className={styles.empty}>
<h2>No creations found</h2>
<p>Adjust the filters, or share the first creation in this category.</p>
@@ -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 (
<WorkshopQueryProvider>
<WorkshopDetailRouteContent />
</WorkshopQueryProvider>
);
}
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<WorkshopUser | null>(null);
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(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 (
<main className={styles.page}>
<div className={styles.error} role="alert">
{error}
This creation is not saved on this device and Workshop could not be reached.{" "}
<button onClick={() => void detailQuery.refetch()}>Try again</button>
</div>
<Link to="/workshop">Back to Workshop</Link>
</main>
);
if (!submission) return <WorkshopSkeleton />;
if (!detailQuery.data) return <WorkshopSkeleton />;
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
const submission = detailQuery.data;
return (
<main className={styles.page}>
<Link to="/workshop">← Back to Workshop</Link>
@@ -329,10 +370,10 @@ export function WorkshopDetailRoute() {
</button>
<button
className="button button--secondary"
onClick={async () => {
await requireAuth();
await client.vote(submission.id, 1);
await load();
disabled={voteMutation.isPending}
onClick={() => {
setActionError("");
voteMutation.mutate();
}}
>
Upvote ({submission.upvotes})
@@ -347,10 +388,11 @@ export function WorkshopDetailRoute() {
</button>
<button
className="button button--danger"
onClick={async () => {
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm("Delete this submission permanently?")) {
await client.delete(submission.id);
history.push("/workshop");
setActionError("");
deleteMutation.mutate();
}
}}
>
@@ -364,6 +406,11 @@ export function WorkshopDetailRoute() {
</button>
)}
</div>
{actionError && (
<div className={styles.error} role="alert">
{actionError}
</div>
)}
<section>
<h2>Source</h2>
<p className={styles.sourceHint}>
@@ -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<string[]>([]);
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({
<textarea
className={styles.editor}
required
maxLength={MAX_CONTENT_LENGTH}
value={content}
onChange={(event) => setContent(event.target.value)}
/>
@@ -553,12 +606,14 @@ function SubmissionForm({
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, up to 5)
Screenshots (PNG, JPG, or WebP, {remainingScreenshotSlots} available)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
onChange={(event) =>
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
}
/>
</label>
{existing?.screenshots.map((screenshot) => (
@@ -0,0 +1,43 @@
import React, { type PropsWithChildren, useState } from "react";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
const maxAge = 1000 * 60 * 60 * 24;
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: maxAge,
networkMode: "offlineFirst",
retry: 1,
staleTime: 1000 * 60 * 5,
},
mutations: { networkMode: "online", retry: false },
},
}),
);
const [persister] = useState(() =>
createAsyncStoragePersister({
key: "homarr-workshop-website-cache-v1",
storage: window.localStorage,
}),
);
return (
<PersistQueryClientProvider
client={client}
persistOptions={{
persister,
maxAge,
buster: "workshop-v1",
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
}}
>
{children}
</PersistQueryClientProvider>
);
}