feat: improve workshop publishing flow

This commit is contained in:
Thomas Camlong committed 2026-08-14 23:16:39 +02:00
1 parent 168091134d
commit b491d45fdc
19 files changed
+254 -82

No files matched your search

+4
View File
@@ -67,6 +67,9 @@ HOMARR_WEBSITE_URL=https://homarr.dev
# PocketBase API used by Homarr and by the standalone documentation site.
# Defaults to HOMARR_WEBSITE_URL.
# WORKSHOP_API_URL=https://homarr.dev
# Public Workshop website used by links in Homarr, Custom Widgets, onboarding,
# and assistant context. Defaults to HOMARR_WEBSITE_URL/workshop.
# WORKSHOP_WEB_URL=https://homarr.dev/workshop
# Documentation and PocketBase deployment settings.
# WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
@@ -75,6 +78,7 @@ HOMARR_WEBSITE_URL=https://homarr.dev
# Local Workshop stack:
# HOMARR_WEBSITE_URL=http://127.0.0.1:3003
# WORKSHOP_API_URL=http://127.0.0.1:8090
# WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
# WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
# Optional: GITHUB_CLIENT_ID=
# Optional: GITHUB_CLIENT_SECRET=
@@ -25,16 +25,17 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
:::
| Environment Variable | Description | Possible values | Default |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | -------------------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
| Environment Variable | Description | Possible values | Default |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop website used by links in Homarr, Custom Widgets, onboarding, and assistant context. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
Workshop service operators can find its server-only configuration in the
[Workshop operator guide](../../workshop/operator#environment-variables).
@@ -109,7 +109,7 @@ to be saved before server requests run.
## Import and Workshop
The install flow always links to the corresponding public Workshop entry under `HOMARR_WEBSITE_URL/workshop`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
The install flow always links to the corresponding public Workshop entry under the configured Workshop origin. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
+4 -3
View File
@@ -47,13 +47,14 @@ Workshop moderator promotion, logs, tests, backup, restore, stopping, and intent
## URL testing
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr derives the public
Workshop page by appending `/workshop` to `HOMARR_WEBSITE_URL`, so these values do not rely on `NEXT_PUBLIC_*` build
variables. Test a separate API origin before release:
Docusaurus reads `HOMARR_WEBSITE_URL`, `WORKSHOP_API_URL`, and `WORKSHOP_WEB_URL` when it starts or builds. Homarr uses
`WORKSHOP_WEB_URL` for public Workshop links while API and documentation origins remain independent. These values do
not rely on `NEXT_PUBLIC_*` build variables. Test separate origins before release:
```sh
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
WORKSHOP_API_URL=http://127.0.0.1:8090 \
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
pnpm dev:docs
```
+5 -7
View File
@@ -32,14 +32,12 @@ Homarr instance and are never included in a Workshop submission.
## Share your work
1. Sign in to the Workshop with GitHub.
2. Select **Share yours**.
3. Choose **Custom Widget** or **Custom CSS**.
4. Add the source, a short description, screenshots, and a changelog.
5. Review and publish the submission.
For a saved Custom Widget, open **Management → Custom Widgets**, use its action menu, and select **Publish to
Workshop**. Review the public listing details and source URLs, then publish the credential-free export. Custom CSS and
manual submissions can be shared directly from the Workshop website with **Share yours**.
Export Custom Widget JSON from **Management → Custom Widgets → Export**. You can edit a submission later without
changing its Workshop URL.
Publishing creates the listing immediately. To update or delete it, review reports, or mark it outdated, open the
Workshop website, sign in, and select **Yours**. Editing a submission does not change its Workshop URL.
## Voting, comments, and reports
+14 -12
View File
@@ -37,21 +37,23 @@ store duplicate profile metadata or avatar files. Configure SMTP for comment, re
The public URL contract and Workshop-only server configuration are:
| Variable | Purpose | Default |
| -------------------------------------- | ------------------------------------------------- | ------------------------------ |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
| Variable | Purpose | Default |
| -------------------------------------- | --------------------------------------------- | ------------------------------ |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop links in Homarr | `HOMARR_WEBSITE_URL/workshop` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one
release; `WORKSHOP_API_URL` takes precedence. The combined Workshop image writes the public URLs into its browser
release; `WORKSHOP_API_URL` takes precedence for API calls and `WORKSHOP_WEB_URL` takes precedence for public Workshop
links. The combined Workshop image writes the public URLs into its browser
runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the
container after changing these values, without rebuilding it.
@@ -48,6 +48,7 @@ export default async function InitPage() {
? resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
})
: { workshopApiUrl: "", workshopWebUrl: "" };
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
+1
View File
@@ -121,6 +121,7 @@ export default async function Layout(props: {
const publicUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const StackedProvider = composeWrappers([
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Button } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { Alert, Button, Stack } from "@mantine/core";
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
@@ -17,6 +17,7 @@ export default async function WorkshopPage() {
const { workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
return (
<ManagePageLayout
@@ -34,7 +35,12 @@ export default async function WorkshopPage() {
</Button>
}
>
<WorkshopInstaller />
<Stack gap="lg">
<Alert icon={<IconInfoCircle size={18} />} title={t("manageSubmissionsTitle")} color="blue">
{t("manageSubmissionsDescription")}
</Alert>
<WorkshopInstaller />
</Stack>
</ManagePageLayout>
);
}
@@ -35,6 +35,7 @@ describe("assistant chat input", () => {
clientContext: { pathname: "/en/boards/Home/settings", timeZone: "Europe/Paris" },
currentTime: new Date("2026-08-13T08:00:00.000Z"),
userName: "Alex",
workshopWebUrl: "https://community.example.com/workshop",
entities: [
{ id: "board-1", type: "board", label: "Home", description: "Home board" },
{ id: "app-1", type: "app", label: "Jellyfin", description: "Media" },
@@ -51,6 +52,7 @@ describe("assistant chat input", () => {
expect(context).toContain('"currentTimeUtc":"2026-08-13T08:00:00.000Z"');
expect(context).toContain('"currentUser":"Alex"');
expect(context).toContain('"workshopWebUrl":"https://community.example.com/workshop"');
expect(context).toContain('"currentBoard":{"id":"board-1","name":"Home"}');
expect(context).toContain('"availableResources":{"boards":1,"apps":1,"integrations":1,"widgets":1}');
expect(context).toContain('"explicitMentions":[{"type":"app","id":"app-1","label":"Jellyfin"');
@@ -59,12 +59,14 @@ export const buildAssistantRequestContext = ({
entities,
messages,
userName,
workshopWebUrl,
}: {
clientContext?: AssistantClientContext;
currentTime: Date;
entities: AssistantContextEntity[];
messages: { role: "user" | "assistant"; parts: unknown[] }[];
userName: string | null | undefined;
workshopWebUrl?: string;
}) => {
const boards = entities.filter((entity) => entity.type === "board");
const homeBoard = boards.find((board) => board.description === "Home board");
@@ -88,6 +90,7 @@ export const buildAssistantRequestContext = ({
currentTimeUtc: currentTime.toISOString(),
userTimeZone: clientContext?.timeZone ?? "UTC",
currentUser: userName?.trim() || "Signed-in user",
workshopWebUrl: workshopWebUrl ?? null,
currentPage: clientContext?.pathname ?? null,
currentBoard: currentBoard ? { id: currentBoard.id, name: currentBoard.label } : null,
homeBoard: homeBoard ? { id: homeBoard.id, name: homeBoard.label } : null,
@@ -24,6 +24,7 @@ import {
assistantProviderRequiresApiKey,
assistantReasoningModes,
} from "@homarr/definitions";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
import { env as appEnv } from "~/env";
@@ -461,6 +462,11 @@ export async function POST(request: Request) {
entities: contextEntities,
messages: parsed.data.messages,
userName: session.user.name,
workshopWebUrl: resolveHomarrUrlConfig({
homarrWebsiteUrl: appEnv.HOMARR_WEBSITE_URL,
workshopApiUrl: appEnv.WORKSHOP_API_URL,
workshopWebUrl: appEnv.WORKSHOP_WEB_URL,
}).workshopWebUrl,
});
if (modelLookup.error !== null) {
logger.warn("Assistant model discovery failed before the response started", {
@@ -17,6 +17,15 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
});
it("uses the runtime Workshop web URL independently from the API origin", () => {
document.head.innerHTML = [
'<meta name="homarr-workshop-api-url" content="https://api.example.com/">',
'<meta name="homarr-workshop-web-url" content="https://community.example.com/workshop">',
].join("");
expect(getWorkshopWebUrl()).toBe("https://community.example.com/workshop");
});
it("derives the Homarr provider endpoint from the runtime Workshop API", () => {
document.head.innerHTML = '<meta name="homarr-workshop-api-url" content="https://api.preview.example/">';
@@ -1,7 +1,24 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
import {
Alert,
Box,
Button,
Checkbox,
Divider,
FileInput,
Group,
Modal,
Paper,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconBuildingStore, IconCheck, IconExternalLink, IconPhoto, IconShieldCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
@@ -10,7 +27,7 @@ import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
@@ -34,7 +51,7 @@ export function WorkshopPublishModal({
const [screenshots, setScreenshots] = useState<File[]>([]);
const createSubmission = useWorkshopCreateMutation(client);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
@@ -51,7 +68,7 @@ export function WorkshopPublishModal({
setDescription("");
setScreenshots([]);
setError(null);
setPublished(false);
setPublishedSubmissionId(null);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
@@ -64,11 +81,12 @@ export function WorkshopPublishModal({
throw new Error(t("publish.invalidScreenshot"));
}
const inspectedDefinition = definition.data;
let submissionId: string | null = null;
const result = await publishWorkshopDefinition({
inspectedDefinition,
refetchDefinition: async () => (await definition.refetch()).data,
publish: (content) =>
createSubmission.mutateAsync({
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
input: {
type: "customWidget",
title,
@@ -76,7 +94,9 @@ export function WorkshopPublishModal({
content,
},
screenshots,
}),
});
submissionId = submission.id;
},
});
if (result === "unavailable") throw new Error(t("publish.error"));
if (result === "changed") {
@@ -84,19 +104,72 @@ export function WorkshopPublishModal({
setError(t("publish.definitionChanged"));
return;
}
setPublished(true);
if (!submissionId) throw new Error(t("publish.error"));
setPublishedSubmissionId(submissionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
<Stack>
{published ? (
<Alert color="green">{t("publish.success")}</Alert>
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg" radius="lg">
<Stack gap="lg">
{publishedSubmissionId ? (
<Stack align="center" gap="lg" py="md">
<ThemeIcon size={64} radius="xl" color="green" variant="light">
<IconCheck size={32} stroke={2} />
</ThemeIcon>
<Box ta="center">
<Title order={3}>{t("publish.successTitle")}</Title>
<Text c="dimmed" size="sm" maw={480} mt={6}>
{t("publish.success")}
</Text>
</Box>
<Paper withBorder radius="md" p="md" w="100%">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="blue" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("publish.manageTitle")}
</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.manageDescription")}
</Text>
</Box>
</Group>
</Paper>
<Group justify="center">
<Button variant="default" onClick={onClose}>
{t("publish.done")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(publishedSubmissionId)}
target="_blank"
rel="noopener noreferrer"
rightSection={<IconExternalLink size={16} />}
>
{t("publish.viewSubmission")}
</Button>
</Group>
</Stack>
) : (
<>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!user && (
<Alert color="blue">
<Group justify="space-between">
@@ -118,28 +191,45 @@ export function WorkshopPublishModal({
</Group>
</Alert>
)}
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={3}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
/>
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600} size="sm">
{t("publish.listingDetails")}
</Text>
<Text c="dimmed" size="xs" mt={2}>
{t("publish.listingDetailsDescription")}
</Text>
</Box>
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={4}
autosize
maxRows={7}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
placeholder={t("publish.screenshotsPlaceholder")}
leftSection={<IconPhoto size={16} />}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
clearable
/>
</Stack>
</Paper>
{privateSourceNames.length > 0 && (
<Alert color="yellow">
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
sources: privateSourceNames.join(", "),
})}
@@ -153,19 +243,30 @@ export function WorkshopPublishModal({
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<Button
loading={createSubmission.isPending || definition.isFetching}
disabled={
!user ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
<Divider />
<Group justify="space-between" align="center">
<Text c="dimmed" size="xs" maw={360}>
{t("publish.publicationNote")}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onClose}>
{t("publish.cancel")}
</Button>
<Button
loading={createSubmission.isPending || definition.isFetching}
disabled={
!user ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</Group>
</Group>
</>
)}
</Stack>
+1
View File
@@ -23,6 +23,7 @@ export const env = createEnv({
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
WORKSHOP_WEB_URL: publicHttpUrl("WORKSHOP_WEB_URL").optional(),
},
experimental__runtimeEnv: process.env,
});
+2
View File
@@ -23,6 +23,7 @@ export const env = createEnv({
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
WORKSHOP_WEB_URL: publicHttpUrl("WORKSHOP_WEB_URL").optional(),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
@@ -30,5 +31,6 @@ export const env = createEnv({
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
WORKSHOP_WEB_URL: process.env.WORKSHOP_WEB_URL,
},
});
@@ -23,8 +23,11 @@ const logger = createLogger({ module: "custom-widget:workshop" });
const workshopUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const workshop = new WorkshopBackend(workshopUrls.workshopApiUrl);
const getWorkshopSubmissionUrl = (submissionId: string) =>
`${workshopUrls.workshopWebUrl}/${encodeURIComponent(submissionId)}`;
function throwWorkshopUnavailable(
message: string,
@@ -94,6 +97,7 @@ export const workshopProcedures = {
.filter((item) => item.widgetSchema === CUSTOM_WIDGET_SCHEMA)
.map((item) => ({
id: item.id,
url: getWorkshopSubmissionUrl(item.id),
title: item.title,
description: item.description,
authorName: item.authorName,
@@ -117,6 +121,7 @@ export const workshopProcedures = {
return {
submission: {
id: submission.id,
url: getWorkshopSubmissionUrl(submission.id),
title: submission.title,
description: submission.description,
authorName: submission.authorName,
+15
View File
@@ -1385,6 +1385,8 @@
"title": "Workshop",
"importFromWorkshop": "Import from Workshop",
"openCommunity": "Open Workshop",
"manageSubmissionsTitle": "Manage submissions on Workshop",
"manageSubmissionsDescription": "Publishing starts in Homarr, but updates, reports, outdated status, and deletion are managed directly on the Workshop website. Sign in there and select Yours to find your submissions.",
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
@@ -1445,16 +1447,29 @@
"voteError": "The vote could not be saved.",
"publish": {
"title": "Publish to Workshop",
"introTitle": "Share this widget with the community",
"introDescription": "Homarr exports a credential-free copy of the current widget. Add clear listing details so people know what it does before installing it.",
"listingDetails": "Workshop listing",
"listingDetailsDescription": "These details appear publicly alongside the exported widget definition.",
"success": "Published immediately. Community members can now install and report this widget.",
"successTitle": "Your widget is live",
"manageTitle": "Continue on Workshop",
"manageDescription": "Open the submission to update its listing, review reports, mark it outdated, or delete it. Submission management is handled directly on the Workshop website.",
"viewSubmission": "View and manage submission",
"done": "Done",
"signInHint": "Sign in to publish under your Workshop identity.",
"titleField": "Title",
"descriptionField": "Description",
"screenshots": "Screenshots",
"screenshotsDescription": "Up to five PNG, JPEG, or WebP images",
"screenshotsPlaceholder": "Choose screenshots",
"invalidScreenshot": "Use PNG, JPEG, or WebP images no larger than 5 MB each.",
"privateSourceWarning": "This widget contains private source URLs for: {sources}. Replace personal hosts or IP addresses with safe service placeholders before publishing.",
"reviewSourcesTitle": "Review private source URLs",
"privateSourceConfirmation": "I reviewed these source URLs and they do not expose private infrastructure details",
"action": "Publish now",
"cancel": "Cancel",
"publicationNote": "Publishing is immediate. You can manage the submission afterward on Workshop.",
"definitionChanged": "The widget changed while publication was being prepared. Review its private source URLs again, then retry.",
"error": "Publication failed"
},
+14
View File
@@ -32,6 +32,20 @@ describe("Workshop URL configuration", () => {
});
});
test("uses an explicit Workshop web URL for public Workshop links", () => {
expect(
resolveHomarrUrlConfig({
homarrWebsiteUrl: "https://homarr.example.com",
workshopApiUrl: "https://api.example.com",
workshopWebUrl: "https://community.example.com/workshop",
}),
).toEqual({
homarrWebsiteUrl: "https://homarr.example.com",
workshopApiUrl: "https://api.example.com",
workshopWebUrl: "https://community.example.com/workshop",
});
});
test("rejects unsafe or ambiguous public URLs", () => {
expect(() => normalizeHttpUrl("file:///tmp/workshop", "WORKSHOP_API_URL")).toThrow("HTTP or HTTPS");
expect(() => normalizeHttpUrl("https://user:secret@example.com", "WORKSHOP_API_URL")).toThrow("credentials");