fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WorkshopClient } from "./client";
|
||||
|
||||
describe("Workshop deletion", () => {
|
||||
const cases: Array<{
|
||||
label: string;
|
||||
action: (client: WorkshopClient) => Promise<void>;
|
||||
collection: string;
|
||||
id: string;
|
||||
}> = [
|
||||
{
|
||||
label: "submission",
|
||||
action: (client) => client.delete("submission-id"),
|
||||
collection: "submissions",
|
||||
id: "submission-id",
|
||||
},
|
||||
{ label: "report", action: (client) => client.dismissReport("report-id"), collection: "reports", id: "report-id" },
|
||||
];
|
||||
|
||||
test.each(cases)("deletes $label records through PocketBase", async ({ action, collection: collectionName, id }) => {
|
||||
const client = new WorkshopClient("https://workshop.example.com");
|
||||
const deleteRecord = vi.fn().mockResolvedValue(true);
|
||||
const collection = vi.spyOn(client.pocketBase, "collection").mockReturnValue({ delete: deleteRecord } as never);
|
||||
|
||||
await action(client);
|
||||
|
||||
expect(collection).toHaveBeenCalledWith(collectionName);
|
||||
expect(deleteRecord).toHaveBeenCalledWith(id);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import PocketBase, { ClientResponseError } from "pocketbase";
|
||||
|
||||
import type {
|
||||
WorkshopAdminAction,
|
||||
WorkshopReport,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionInput,
|
||||
@@ -13,16 +12,17 @@ import {
|
||||
WORKSHOP_API_URL,
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WorkshopError,
|
||||
workshopAdminActionSchema,
|
||||
workshopReportSchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
workshopSubmissionInputSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
workshopSubmissionInputSchema,
|
||||
workshopScreenshotsSchema,
|
||||
workshopUserSchema,
|
||||
workshopVoteSchema,
|
||||
} from "./schema";
|
||||
|
||||
const WORKSHOP_OAUTH_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
export interface WorkshopListOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@@ -71,6 +71,10 @@ function workshopError(error: unknown, fallback: string) {
|
||||
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]) {
|
||||
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
|
||||
}
|
||||
|
||||
export class WorkshopClient {
|
||||
public readonly pocketBase: PocketBase;
|
||||
|
||||
@@ -97,13 +101,7 @@ export class WorkshopClient {
|
||||
if (!this.pocketBase.authStore.isValid) return null;
|
||||
try {
|
||||
await this.pocketBase.collection("users").authRefresh();
|
||||
const user = this.currentUser;
|
||||
if (!user) return null;
|
||||
const admin = await this.pocketBase
|
||||
.collection("workshop_admins")
|
||||
.getFirstListItem(this.pocketBase.filter("user = {:user}", { user: user.id }))
|
||||
.catch(() => null);
|
||||
return { ...user, isAdmin: admin !== null };
|
||||
return this.currentUser;
|
||||
} catch {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
@@ -111,13 +109,76 @@ export class WorkshopClient {
|
||||
}
|
||||
|
||||
public async signInWithGitHub() {
|
||||
if (typeof window === "undefined" || typeof window.open !== "function") {
|
||||
throw new WorkshopError("unavailable", "GitHub sign-in requires a browser popup");
|
||||
}
|
||||
|
||||
const popup = window.open(
|
||||
"about:blank",
|
||||
"homarr_workshop_oauth",
|
||||
"width=1024,height=768,resizable=yes,scrollbars=yes,menubar=no",
|
||||
);
|
||||
if (!popup) throw new WorkshopError("unavailable", "GitHub sign-in popup was blocked by the browser");
|
||||
|
||||
const requestKey = `workshop_oauth_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
let closePoll: ReturnType<typeof setInterval> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const popupFailure = new Promise<never>((_resolve, reject) => {
|
||||
closePoll = setInterval(() => {
|
||||
if (popup.closed) reject(new WorkshopError("unknown", "GitHub sign-in was cancelled"));
|
||||
}, 250);
|
||||
timeout = setTimeout(
|
||||
() => reject(new WorkshopError("unavailable", "GitHub sign-in timed out")),
|
||||
WORKSHOP_OAUTH_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.pocketBase
|
||||
.collection("users")
|
||||
.authWithOAuth2({ provider: "github", createData: { displayName: "Community member" } });
|
||||
const auth = await Promise.race([
|
||||
this.pocketBase.collection("users").authWithOAuth2({
|
||||
provider: "github",
|
||||
createData: { displayName: "Community member" },
|
||||
requestKey,
|
||||
urlCallback: (url) => {
|
||||
if (popup.closed) throw new WorkshopError("unknown", "GitHub sign-in was cancelled");
|
||||
popup.location.href = url;
|
||||
},
|
||||
}),
|
||||
popupFailure,
|
||||
]);
|
||||
const rawUser =
|
||||
auth.meta?.rawUser && typeof auth.meta.rawUser === "object"
|
||||
? (auth.meta.rawUser as Record<string, unknown>)
|
||||
: undefined;
|
||||
const displayName = firstNonEmptyString(
|
||||
auth.meta?.name,
|
||||
auth.meta?.username,
|
||||
rawUser?.name,
|
||||
rawUser?.login,
|
||||
auth.record.name,
|
||||
auth.record.username,
|
||||
);
|
||||
const avatarUrl = firstNonEmptyString(
|
||||
auth.meta?.avatarUrl,
|
||||
auth.meta?.avatar,
|
||||
rawUser?.avatar_url,
|
||||
auth.record.avatarUrl,
|
||||
auth.record.avatar,
|
||||
);
|
||||
if (displayName || avatarUrl) {
|
||||
await this.pocketBase.collection("users").update(auth.record.id, {
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
});
|
||||
}
|
||||
return this.refreshAuth();
|
||||
} catch (error) {
|
||||
throw workshopError(error, "GitHub sign-in failed");
|
||||
} finally {
|
||||
if (closePoll) clearInterval(closePoll);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.pocketBase.cancelRequest(requestKey);
|
||||
if (!popup.closed) popup.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +245,6 @@ export class WorkshopClient {
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
|
||||
data.set("author", this.currentUser?.id ?? "");
|
||||
data.set("authorName", this.currentUser?.displayName ?? "Community member");
|
||||
screenshots.forEach((file) => data.append("screenshots", file));
|
||||
try {
|
||||
const result = await this.pocketBase.collection("submissions").create(data);
|
||||
@@ -194,13 +254,12 @@ export class WorkshopClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async update(id: string, input: WorkshopSubmissionInput, screenshots: File[] = [], removed: string[] = []) {
|
||||
public async update(id: string, input: WorkshopSubmissionInput, screenshots: File[] = []) {
|
||||
const parsed = workshopSubmissionInputSchema.parse(input);
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
|
||||
screenshots.forEach((file) => data.append("screenshots+", file));
|
||||
removed.forEach((file) => data.append("screenshots-", file));
|
||||
screenshots.forEach((file) => data.append("screenshots", file));
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").update(id, data);
|
||||
return this.get(id);
|
||||
@@ -209,9 +268,9 @@ export class WorkshopClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(id: string, reason = "Deleted by author") {
|
||||
public async delete(id: string) {
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").delete(id, { headers: { "x-workshop-reason": reason } });
|
||||
await this.pocketBase.collection("submissions").delete(id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to delete submission");
|
||||
}
|
||||
@@ -245,7 +304,7 @@ export class WorkshopClient {
|
||||
return workshopReportSchema.parse(
|
||||
await this.pocketBase
|
||||
.collection("reports")
|
||||
.create({ submission, reporter: this.currentUser?.id ?? "", category, explanation, status: "open" }),
|
||||
.create({ submission, reporter: this.currentUser?.id ?? "", category, explanation: explanation.trim() }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to report submission");
|
||||
@@ -256,7 +315,7 @@ export class WorkshopClient {
|
||||
try {
|
||||
const items = await this.pocketBase
|
||||
.collection("reports")
|
||||
.getFullList({ batch: 200, filter: "status = 'open'", sort: "-created", expand: "reporter,submission" });
|
||||
.getFullList({ batch: 200, sort: "-created", expand: "reporter,submission" });
|
||||
return items.map((item) =>
|
||||
workshopReportSchema.parse({
|
||||
...item,
|
||||
@@ -269,26 +328,11 @@ export class WorkshopClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async dismissReport(id: string, reason: string) {
|
||||
public async dismissReport(id: string) {
|
||||
try {
|
||||
return workshopReportSchema.parse(
|
||||
await this.pocketBase.collection("reports").update(id, { status: "dismissed", dismissalReason: reason }),
|
||||
);
|
||||
await this.pocketBase.collection("reports").delete(id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to dismiss report");
|
||||
}
|
||||
}
|
||||
|
||||
public async listAdminActions(): Promise<WorkshopAdminAction[]> {
|
||||
try {
|
||||
const items = await this.pocketBase
|
||||
.collection("workshop_admin_actions")
|
||||
.getFullList({ batch: 200, sort: "-created", expand: "actor" });
|
||||
return items.map((item) =>
|
||||
workshopAdminActionSchema.parse({ ...item, actorName: item.expand?.actor?.displayName }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load administrator history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,59 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { validateWorkshopWidget } from "./schema";
|
||||
import { validateWorkshopWidget, workshopSubmissionInputSchema } from "./schema";
|
||||
|
||||
describe("Workshop widget validation", () => {
|
||||
test("accepts a canonical credential-free widget", () => {
|
||||
expect(validateWorkshopWidget(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(true);
|
||||
expect(
|
||||
workshopSubmissionInputSchema.safeParse({
|
||||
title: "Starter widget",
|
||||
description: "",
|
||||
content: JSON.stringify(CUSTOM_WIDGET_STARTER),
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("uses canonical option-binding validation before publication", () => {
|
||||
const request = {
|
||||
id: "data",
|
||||
sourceId: "default",
|
||||
kind: "query" as const,
|
||||
method: "GET" as const,
|
||||
pathTemplate: "/data/{endpointId}",
|
||||
parameters: { endpointId: "string" as const, limit: "number" as const },
|
||||
optionsBinding: { endpointId: { $option: "endpointId" }, limit: 5 },
|
||||
queryTemplate: { limit: { $param: "limit" } },
|
||||
auth: "inherit" as const,
|
||||
minimumBoardPermission: "view" as const,
|
||||
trigger: "load" as const,
|
||||
};
|
||||
const widget = {
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: [request],
|
||||
optionsSchema: {
|
||||
type: "object" as const,
|
||||
properties: { endpointId: { type: "string" as const } },
|
||||
required: ["endpointId"],
|
||||
additionalProperties: false as const,
|
||||
},
|
||||
defaultOptions: { endpointId: "local" },
|
||||
};
|
||||
|
||||
expect(validateWorkshopWidget(JSON.stringify(widget)).success).toBe(true);
|
||||
expect(
|
||||
validateWorkshopWidget(JSON.stringify({ ...widget, requests: [{ ...request, optionsBinding: { limit: 5 } }] }))
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateWorkshopWidget(
|
||||
JSON.stringify({
|
||||
...widget,
|
||||
requests: [{ ...request, optionsBinding: { endpointId: { $option: "missing" }, limit: 5 } }],
|
||||
}),
|
||||
).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects literal credentials in templates and icon URLs", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
|
||||
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
|
||||
export const workshopReportStatusSchema = z.enum(["open", "dismissed"]);
|
||||
|
||||
const workshopFileListSchema = z
|
||||
.preprocess((value) => {
|
||||
@@ -35,10 +34,7 @@ const workshopSubmissionBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().default(""),
|
||||
contentHash: z.string(),
|
||||
screenshots: workshopFileListSchema,
|
||||
revision: z.number().int().positive(),
|
||||
changelog: z.string().default(""),
|
||||
author: z.string(),
|
||||
authorName: z.string(),
|
||||
score: z.number().int(),
|
||||
@@ -58,7 +54,6 @@ export const workshopSubmissionInputSchema = z
|
||||
title: z.string().trim().min(3).max(100),
|
||||
description: z.string().trim().max(2_000).default(""),
|
||||
content: z.string().min(1).max(MAX_WORKSHOP_CONTENT_LENGTH),
|
||||
changelog: z.string().trim().max(2_000).default(""),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
const result = validateWorkshopWidget(input.content);
|
||||
@@ -91,25 +86,11 @@ export const workshopReportSchema = z.object({
|
||||
submissionTitle: z.string().default("Deleted submission"),
|
||||
category: workshopReportCategorySchema,
|
||||
explanation: z.string(),
|
||||
status: workshopReportStatusSchema,
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
});
|
||||
export type WorkshopReport = z.infer<typeof workshopReportSchema>;
|
||||
|
||||
export const workshopAdminActionSchema = z.object({
|
||||
id: z.string(),
|
||||
actor: z.string(),
|
||||
actorName: z.string().default("Workshop administrator"),
|
||||
action: z.enum(["delete_submission", "dismiss_report"]),
|
||||
submissionId: z.string().default(""),
|
||||
reportId: z.string().default(""),
|
||||
reason: z.string().default(""),
|
||||
snapshot: z.string().default(""),
|
||||
created: z.string(),
|
||||
});
|
||||
export type WorkshopAdminAction = z.infer<typeof workshopAdminActionSchema>;
|
||||
|
||||
export type WorkshopValidationResult =
|
||||
| { success: true; data: z.infer<typeof customWidgetImportSchema> }
|
||||
| { success: false; error: string };
|
||||
@@ -118,7 +99,13 @@ export function validateWorkshopWidget(content: string): WorkshopValidationResul
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) return { success: false, error: result.error.issues[0]?.message ?? "Invalid widget" };
|
||||
if (!result.success) {
|
||||
const error = result.error.issues
|
||||
.slice(0, 5)
|
||||
.map((issue) => `${issue.path.join(".") || "widget"}: ${issue.message}`)
|
||||
.join("\n");
|
||||
return { success: false, error: error || "Invalid widget" };
|
||||
}
|
||||
if (containsCredentialLikeValue(result.data)) {
|
||||
return { success: false, error: "Widget exports must not contain credentials or credential-like static values" };
|
||||
}
|
||||
@@ -148,6 +135,7 @@ function containsCredentialLikeValue(widget: z.infer<typeof customWidgetImportSc
|
||||
([name, value]) =>
|
||||
(sensitiveName.test(name) && value.trim().length > 0) || /^\s*bearer\s+\S{8,}\s*$/iu.test(value),
|
||||
) ||
|
||||
containsCredential(request.optionsBinding, "", sensitiveName) ||
|
||||
containsCredential(request.bodyTemplate, "", sensitiveName) ||
|
||||
containsCredential(request.queryTemplate, "", sensitiveName),
|
||||
) ||
|
||||
|
||||
Reference in new issue
Block a user