fix: harden v2 custom widgets and Workshop (#6480)
This commit is contained in:
1 parent
e8a2c87c2d
commit
557ebe0a2f
138 files changed
+5151
-1976
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user