fix: harden v2 custom widgets and Workshop (#6480)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-31 13:28:53 +02:00
1 parent e8a2c87c2d
commit 557ebe0a2f
138 files changed
+5151 -1976

No files matched your search

+12 -22
View File
@@ -25,6 +25,7 @@ import {
WORKSHOP_API_URL,
MAX_WORKSHOP_SCREENSHOTS,
WORKSHOP_REQUEST_TIMEOUT_MS,
WORKSHOP_SCHEMA_BY_TYPE,
workshopCommentSchema,
workshopReportSchema,
workshopSubmissionDetailSchema,
@@ -156,24 +157,6 @@ const requestSignal = (signal?: AbortSignal) =>
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
);
function oauthText(...values: unknown[]) {
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
}
function githubProfile(meta: Record<string, unknown> | undefined, record: { id: string }) {
const rawUser =
meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : undefined;
const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login);
const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url);
const githubUsername = oauthText(meta?.username, rawUser?.login);
return {
displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100),
avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined,
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : undefined,
};
}
const timestamp = (value: string) => (Number.isNaN(Date.parse(value)) ? 0 : Date.parse(value));
function sortListings(items: WorkshopSubmissionSummary[], sort: WorkshopListOptions["sort"]) {
@@ -274,6 +257,13 @@ function workshopError(error: unknown, fallback: string) {
/invalid|missing|not found|failed/iu.test(rawMessage)
)
return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error });
if (
(error instanceof ClientResponseError && error.status === 0) ||
/networkerror|failed to fetch|fetch failed|load failed|connection (?:refused|reset)|econn(?:refused|reset)|offline/iu.test(
rawMessage,
)
)
return new Error("Homarr Workshop seems to be unavailable right now. Try again in a moment.", { cause: error });
if (error instanceof ClientResponseError) {
const sdkMessage = error.data?.message || error.message;
const originalMessage =
@@ -349,9 +339,7 @@ export class WorkshopBackend {
provider: "github",
createData: { displayName: "GitHub user" },
});
const profile = githubProfile(auth.meta, auth.record);
const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
this.pocketBase.authStore.save(auth.token, updated);
this.pocketBase.authStore.save(auth.token, auth.record);
return this.currentUser;
} catch (error) {
throw workshopError(error, "GitHub sign-in failed");
@@ -457,6 +445,7 @@ export class WorkshopBackend {
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
data.set("author", this.currentUser?.id ?? "");
data.set("widgetSchema", WORKSHOP_SCHEMA_BY_TYPE[parsed.type]);
screenshots.forEach((file) => data.append("screenshots", file));
try {
const result = await this.pocketBase.collection("submissions").create(data);
@@ -578,7 +567,8 @@ export class WorkshopBackend {
public async toggleOutdated(id: string, outdated: boolean) {
try {
await this.pocketBase.collection("submissions").update(id, { outdated });
const current = await this.get(id);
await this.pocketBase.collection("submissions").update(id, { outdated, expectedRevision: current.revision });
return this.get(id);
} catch (error) {
throw workshopError(error, "Failed to update submission status");
+46 -5
View File
@@ -68,13 +68,17 @@ describe("WorkshopBackend", () => {
test("lets PocketBase own the complete GitHub popup flow", async () => {
mocks.authWithOAuth2.mockResolvedValue({
token: "token",
record: { id: "user-id" },
record: {
id: "user-id",
displayName: "octocat",
githubUsername: "octocat",
githubProfileUrl: "https://github.com/octocat",
},
meta: { username: "octocat" },
});
mocks.update.mockResolvedValue({ id: "user-id", displayName: "octocat" });
const client = new WorkshopBackend("https://workshop.example.com");
await client.signInWithGitHub();
const user = await client.signInWithGitHub();
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
provider: "github",
@@ -82,6 +86,8 @@ describe("WorkshopBackend", () => {
});
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
expect(mocks.update).not.toHaveBeenCalled();
expect(user).toMatchObject({ displayName: "octocat", githubUsername: "octocat" });
});
test("exposes the typed submission collection service", () => {
@@ -90,7 +96,7 @@ describe("WorkshopBackend", () => {
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
});
test("leaves schema and initial revision ownership to PocketBase", async () => {
test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => {
const client = new WorkshopBackend("https://workshop.example.com");
client.pocketBase.authStore.save("token", { id: "author-id" } as never);
mocks.create.mockResolvedValue({ id: "submission-id" });
@@ -107,7 +113,7 @@ describe("WorkshopBackend", () => {
const payload = mocks.create.mock.calls[0]?.[0] as FormData;
expect(payload.get("author")).toBe("author-id");
expect(payload.get("widgetSchema")).toBeNull();
expect(payload.get("widgetSchema")).toBe("homarr-custom-css-v1");
expect(payload.get("revision")).toBeNull();
});
@@ -150,6 +156,31 @@ describe("WorkshopBackend", () => {
expect(payload).not.toHaveProperty("widgetSchema");
});
test("uses the current revision when toggling submission status", async () => {
const client = new WorkshopBackend("https://workshop.example.com");
const current = {
...listingRecord(),
type: "customCss" as const,
content: ".dashboard { color: red; }",
revision: 4,
outdated: false,
};
const updated = { ...current, revision: 5, outdated: true };
vi.spyOn(client, "get")
.mockResolvedValueOnce(current as never)
.mockResolvedValueOnce(updated as never);
mocks.update.mockResolvedValue(updated);
await expect(client.toggleOutdated(current.id, true)).resolves.toMatchObject({
revision: 5,
outdated: true,
});
expect(mocks.update).toHaveBeenCalledWith(current.id, {
outdated: true,
expectedRevision: 4,
});
});
test("uses PocketBase filtering and pagination when the listing view is current", async () => {
mocks.getList.mockResolvedValue({
page: 1,
@@ -216,6 +247,16 @@ describe("WorkshopBackend", () => {
expect(result.items).toHaveLength(1);
expect(mocks.getFullList).toHaveBeenCalledTimes(2);
});
test("replaces low-level network errors with an actionable Workshop outage message", async () => {
mocks.getList.mockRejectedValueOnce(new Error("NetworkError when attempting to fetch resource."));
const client = new WorkshopBackend("https://workshop.example.com");
await expect(client.list({ type: "customWidget" })).rejects.toThrow(
"Homarr Workshop seems to be unavailable right now. Try again in a moment.",
);
});
});
function listingRecord() {
+30 -2
View File
@@ -2,7 +2,35 @@ import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { validateWorkshopWidget, workshopSubmissionInputSchema, workshopSubmissionSummarySchema } from "./schema";
import {
normalizeHttpUrl,
resolveHomarrUrlConfig,
validateWorkshopWidget,
workshopSubmissionInputSchema,
workshopSubmissionSummarySchema,
} from "./schema";
describe("Workshop URL configuration", () => {
test("normalizes the URL contract and derives Workshop defaults", () => {
expect(
resolveHomarrUrlConfig({
homarrWebsiteUrl: "https://docs.example.com/",
workshopApiUrl: "https://api.example.com///",
}),
).toEqual({
homarrWebsiteUrl: "https://docs.example.com",
workshopApiUrl: "https://api.example.com",
workshopWebUrl: "https://docs.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");
expect(() => normalizeHttpUrl("https://example.com/?token=secret", "WORKSHOP_API_URL")).toThrow("query string");
expect(() => normalizeHttpUrl("\nhttps://example.com", "WORKSHOP_API_URL")).toThrow("control characters");
});
});
describe("Workshop widget validation", () => {
test("accepts a canonical credential-free widget", () => {
@@ -28,7 +56,7 @@ describe("Workshop widget validation", () => {
).toBe(true);
});
test("uses canonical direct-binding validation before publication", () => {
test("validates direct bindings in the official client before publication", () => {
const widget = {
...CUSTOM_WIDGET_STARTER,
requests: {
+60 -3
View File
@@ -1,9 +1,14 @@
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_SCHEMA, customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
export const WORKSHOP_API_URL = "https://homarr.dev";
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
export const HOMARR_WEBSITE_URL = "https://homarr.dev";
export const WORKSHOP_API_URL = HOMARR_WEBSITE_URL;
export const WORKSHOP_WEB_URL = `${HOMARR_WEBSITE_URL}/workshop`;
export const WORKSHOP_CSS_SCHEMA = "homarr-custom-css-v1";
export const WORKSHOP_SCHEMA_BY_TYPE = {
customWidget: CUSTOM_WIDGET_SCHEMA,
customCss: WORKSHOP_CSS_SCHEMA,
} as const;
export const MAX_WORKSHOP_CSS_LENGTH = 16_384;
export const MAX_WORKSHOP_CONTENT_LENGTH = 1_000_000;
export const MAX_WORKSHOP_SCREENSHOTS = 5;
@@ -11,6 +16,58 @@ export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export interface HomarrUrlConfig {
homarrWebsiteUrl: string;
workshopApiUrl: string;
workshopWebUrl: string;
}
export interface HomarrUrlConfigInput {
homarrWebsiteUrl?: string;
workshopApiUrl?: string;
workshopWebUrl?: string;
}
export function normalizeHttpUrl(value: string, variableName: string): string {
if (value !== value.trim() || hasAsciiControl(value)) {
throw new Error(`${variableName} must not include surrounding whitespace or control characters`);
}
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${variableName} must be a valid absolute HTTP(S) URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`${variableName} must use HTTP or HTTPS`);
}
if (url.username || url.password) {
throw new Error(`${variableName} must not include credentials`);
}
if (url.search || url.hash) {
throw new Error(`${variableName} must not include a query string or fragment`);
}
return url.toString().replace(/\/+$/u, "");
}
function hasAsciiControl(value: string): boolean {
return [...value].some((character) => {
const code = character.codePointAt(0) ?? 0;
return code <= 31 || code === 127;
});
}
export function resolveHomarrUrlConfig(input: HomarrUrlConfigInput = {}): HomarrUrlConfig {
const homarrWebsiteUrl = normalizeHttpUrl(input.homarrWebsiteUrl ?? HOMARR_WEBSITE_URL, "HOMARR_WEBSITE_URL");
const workshopApiUrl = normalizeHttpUrl(input.workshopApiUrl ?? homarrWebsiteUrl, "WORKSHOP_API_URL");
const workshopWebUrl = normalizeHttpUrl(input.workshopWebUrl ?? `${homarrWebsiteUrl}/workshop`, "WORKSHOP_WEB_URL");
return { homarrWebsiteUrl, workshopApiUrl, workshopWebUrl };
}
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
export const workshopSubmissionTypeSchema = z.enum(["customWidget", "customCss"]);
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;