Merge widget Workshop identity fix into mobile

This commit is contained in:
Thomas Camlong committed 2026-08-10 15:15:36 +02:00
commit efd3dae0db
19 files changed
+114 -362

No files matched your search

+5 -2
View File
@@ -95,8 +95,11 @@ irreversible development reset.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
links and email use the public host. Workshop uses the OAuth provider username as the public display name instead of
the profile's full name. Configure SMTP for comment, report, and removal notices.
links and email use the public host. Workshop stores only the OAuth provider username in `users.githubUsername`, and
users cannot rename it. Map **OAuth2 username** to `githubUsername`; leave OAuth2 id, full name, and avatar mappings
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
SMTP for comment, report, and removal notices.
The public URL contract is:
@@ -3,6 +3,7 @@ import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTras
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { errorMessage } from "@site/src/lib/utils";
@@ -158,13 +159,12 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
const canDelete = ownComment || currentUser?.isAdmin === true;
const isEditing = editingId === comment.id;
const edited = comment.updated !== comment.created;
const authorName = ownComment ? (currentUser?.displayName ?? comment.authorName) : comment.authorName;
const avatarUrl = ownComment
? (currentUser?.avatarUrl ?? comment.authorAvatarUrl)
: comment.authorAvatarUrl;
const profileUrl = ownComment
? (currentUser?.githubProfileUrl ?? comment.authorGithubProfileUrl)
: comment.authorGithubProfileUrl;
const authorGithubUsername = ownComment
? (currentUser?.githubUsername ?? comment.authorGithubUsername)
: comment.authorGithubUsername;
const authorName = authorGithubUsername || "Community member";
const avatarUrl = githubAvatarUrl(authorGithubUsername);
const profileUrl = githubProfileUrl(authorGithubUsername);
return (
<article
@@ -292,9 +292,9 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2.5">
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
{currentUser?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
{currentUser?.githubUsername && <AvatarImage src={githubAvatarUrl(currentUser.githubUsername)} alt="" />}
<AvatarFallback className="bg-background">
{avatarFallback(currentUser?.displayName ?? "Guest")}
{avatarFallback(currentUser?.githubUsername ?? "Guest")}
</AvatarFallback>
</Avatar>
<Textarea
@@ -33,6 +33,8 @@ import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
githubProfileUrl,
MAX_WORKSHOP_SCREENSHOTS,
MAX_WORKSHOP_SCREENSHOT_BYTES,
WORKSHOP_SCREENSHOT_MIME_TYPES,
@@ -524,10 +526,10 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const canManage = user?.id === submission.author || user?.isAdmin === true;
const canEdit = user?.id === submission.author;
const signedInAuthor = user?.id === submission.author ? user : null;
const authorName = signedInAuthor?.displayName || submission.authorName;
const authorAvatarUrl = signedInAuthor?.avatarUrl || submission.authorAvatarUrl;
const authorGithubUsername = signedInAuthor?.githubUsername || submission.authorGithubUsername;
const authorGithubProfileUrl = signedInAuthor?.githubProfileUrl || submission.authorGithubProfileUrl;
const authorName = authorGithubUsername || "Community member";
const authorAvatarUrl = githubAvatarUrl(authorGithubUsername);
const authorGithubProfileUrl = githubProfileUrl(authorGithubUsername);
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
@@ -593,10 +595,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{authorName}
{authorGithubProfileUrl && <IconBrandGithub size={14} />}
</a>
<p className="text-xs text-muted-foreground">
{authorGithubUsername ? `@${authorGithubUsername} · ` : ""}
Published {formatRelativeTime(submission.created)}
</p>
<p className="text-xs text-muted-foreground">Published {formatRelativeTime(submission.created)}</p>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -9,10 +10,6 @@ import { cn } from "@/lib/utils";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
const githubProfileUrl = (user: WorkshopUser) =>
user.githubProfileUrl ||
(user.githubUsername ? `https://github.com/${encodeURIComponent(user.githubUsername)}` : undefined);
interface WorkshopAccountMenuProps {
user: WorkshopUser;
onSignOut: () => void;
@@ -21,8 +18,9 @@ interface WorkshopAccountMenuProps {
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
const profileUrl = githubProfileUrl(user.githubUsername);
const avatarUrl = githubAvatarUrl(user.githubUsername);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : "Account";
useEffect(() => {
if (!open) return;
@@ -54,8 +52,8 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
onClick={() => setOpen((value) => !value)}
>
<Avatar className="size-6">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.githubUsername)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
@@ -73,12 +71,11 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.githubUsername)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.displayName}</p>
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
<p className="truncate text-sm font-medium">{accountLabel}</p>
</div>
</div>
@@ -10,6 +10,7 @@ import {
} from "@tabler/icons-react";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -194,18 +195,20 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
{submission.authorGithubUsername && (
<AvatarImage src={githubAvatarUrl(submission.authorGithubUsername)} alt="" />
)}
<AvatarFallback>{avatarFallback(submission.authorGithubUsername)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
href={githubProfileUrl(submission.authorGithubUsername) || undefined}
target={submission.authorGithubUsername ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorName}
{submission.authorGithubUsername || "Community member"}
</a>
</div>
</div>
@@ -237,7 +240,8 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<div className="min-w-0">
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
<p className="m-0 mt-1 text-xs text-destructive/75">
Reported by {report.reporterName} · {new Date(report.created).toLocaleString()}
Reported by {report.reporterGithubUsername || "Community member"} ·{" "}
{new Date(report.created).toLocaleString()}
</p>
</div>
<Button
@@ -18,6 +18,7 @@ import {
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -98,7 +99,8 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
.filter((item) => includeOutdated || !item.outdated)
.filter(
(item) =>
!q || [item.title, item.description, item.authorName].some((value) => value?.toLowerCase().includes(q)),
!q ||
[item.title, item.description, item.authorGithubUsername].some((value) => value?.toLowerCase().includes(q)),
)
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
@@ -356,16 +358,18 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</div>
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
href={githubProfileUrl(submission.authorGithubUsername) || undefined}
target={submission.authorGithubUsername ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
>
<Avatar className="size-4">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
{submission.authorGithubUsername && (
<AvatarImage src={githubAvatarUrl(submission.authorGithubUsername)} alt="" />
)}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorGithubUsername)}</AvatarFallback>
</Avatar>
<span className="truncate">{submission.authorName}</span>
<span className="truncate">{submission.authorGithubUsername || "Community member"}</span>
</a>
<span className="shrink-0 whitespace-nowrap">
· v{submission.revision} · {formatRelativeTime(submission.created)}
+4 -1
View File
@@ -91,7 +91,10 @@ GITHUB_CLIENT_SECRET=...
OAuth settings synchronize on every PocketBase startup. Restart after rotation. A production deployment must never
supply only one credential. `PB_ALLOWED_ORIGINS=*` is appropriate for the central community because arbitrary
self-hosted Homarr origins must open its OAuth popup; a restricted list must include every allowed browser origin.
Workshop uses the OAuth provider username as the public display name instead of the profile's full name.
Workshop stores only the OAuth provider username in `users.githubUsername`; users cannot rename it. PocketBase maps
**OAuth2 username** to `githubUsername` and leaves the OAuth2 id, full name, and avatar mappings empty. GitHub profiles
and avatars are derived from `https://github.com/<username>` and `https://github.com/<username>.png`, so no duplicate
profile metadata or avatar file is stored.
## Promote a Workshop moderator
-42
View File
@@ -1,48 +1,7 @@
const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const rejectRequest = (message) => {
throw new BadRequestError(message);
};
const normalizedIdentityText = (...values) => {
for (const value of values) {
if (typeof value !== "string") continue;
const normalized = [...value]
.filter((character) => {
const code = character.codePointAt(0) || 0;
return code > 31 && code !== 127;
})
.join("")
.trim();
if (normalized) return normalized;
}
return "";
};
const deriveGithubIdentity = (oauthUser, recordId) => {
const rawUser = oauthUser && typeof oauthUser.rawUser === "object" ? oauthUser.rawUser : {};
const candidateUsername = normalizedIdentityText(oauthUser && oauthUser.username, rawUser.login);
const githubUsername =
GITHUB_USERNAME_PATTERN.test(candidateUsername) && !candidateUsername.includes("--") ? candidateUsername : "";
const displayName =
normalizedIdentityText(githubUsername, rawUser.login, oauthUser && oauthUser.name, rawUser.name).slice(0, 100) ||
`GitHub user ${String(recordId || "").slice(0, 8)}`;
const candidateAvatarUrl = normalizedIdentityText(
oauthUser && oauthUser.avatarURL,
oauthUser && oauthUser.avatarUrl,
rawUser.avatar_url,
);
const avatarUrl =
candidateAvatarUrl.length <= 2_048 && /^https:\/\/[^"'\\\s]+$/i.test(candidateAvatarUrl) ? candidateAvatarUrl : "";
return {
displayName,
avatarUrl,
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${githubUsername}` : "",
};
};
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
@@ -70,7 +29,6 @@ const sendEmail = (app, recipientEmail, subject, text, html) => {
};
module.exports = {
deriveGithubIdentity,
emailTemplate,
escapeHtml,
rejectRequest,
+3 -45
View File
@@ -11,54 +11,12 @@ onBootstrap((event) => {
event.next();
const users = event.app.findCollectionByNameOrId("users");
users.oauth2.enabled = configured;
users.oauth2.mappedFields = { id: "", name: "", username: "displayName", avatarURL: "avatarUrl" };
users.oauth2.mappedFields = { id: "", name: "", username: "githubUsername", avatarURL: "" };
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
event.app.save(users);
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
});
onRecordAuthWithOAuth2Request((event) => {
if (event.providerName !== "github" || !event.oauth2User) {
event.next();
return;
}
const { deriveGithubIdentity } = require(`${__hooks}/workshop-utils.js`);
const identity = deriveGithubIdentity(event.oauth2User, event.record ? event.record.id : event.oauth2User.id);
if (event.record) {
event.record.set("displayName", identity.displayName);
event.record.set("avatar", "");
event.record.set("avatarUrl", identity.avatarUrl);
event.record.set("githubUsername", identity.githubUsername);
event.record.set("githubProfileUrl", identity.githubProfileUrl);
event.app.save(event.record);
} else {
event.createData.displayName = identity.displayName;
event.createData.avatarUrl = identity.avatarUrl;
event.createData.githubUsername = identity.githubUsername;
event.createData.githubProfileUrl = identity.githubProfileUrl;
}
console.log(
JSON.stringify({
event: "workshop_oauth_identity_synchronized",
provider: "github",
recordId: event.record ? event.record.id : null,
newRecord: !event.record,
}),
);
event.next();
}, "users");
onRecordUpdateRequest((event) => {
const { rejectRequest } = require(`${__hooks}/workshop-utils.js`);
const originalAvatar = event.record.original().getString("avatar");
const pendingAvatarFiles = event.record.getUnsavedFiles("avatar");
if (event.record.getString("avatar") !== originalAvatar || pendingAvatarFiles.length > 0) {
rejectRequest("Workshop avatars are managed by GitHub OAuth");
}
event.next();
}, "users");
onRecordCreateRequest((event) => {
event.record.set("revision", 1);
event.record.set("expectedRevision", 0);
@@ -137,7 +95,7 @@ onRecordAfterCreateSuccess((event) => {
return;
}
const commenterName = commenter.getString("displayName") || "A community member";
const commenterName = commenter.getString("githubUsername") || "A community member";
const submissionTitle = submission.getString("title");
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
@@ -174,7 +132,7 @@ onRecordAfterCreateSuccess((event) => {
return;
}
const reporterName = reporter.getString("displayName") || "A community member";
const reporterName = reporter.getString("githubUsername") || "A community member";
const submissionTitle = submission.getString("title");
const category = event.record.getString("category");
const explanation = event.record.getString("explanation");
@@ -5,10 +5,7 @@ const cloneRule = (value) => (value === null ? null : String(value));
const adminRule = "@request.auth.isAdmin = true";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
].join(" && ");
const immutableSubmissionFields = [
"@request.body.author:changed = false",
@@ -67,18 +64,7 @@ migrate(
};
users.passwordAuth = { enabled: false };
addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
addUserField(new URLField({ name: "avatarUrl" }));
addUserField(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
addUserField(new TextField({ name: "githubUsername", max: 100 }));
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new TextField({ name: "githubUsername", required: true, min: 1, max: 39 }));
addUserField(new BoolField({ name: "isAdmin" }));
users.listRule = "";
@@ -250,8 +236,7 @@ migrate(
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.widgetSchema, s.screenshots, s.author,
u.displayName AS authorName, u.avatar AS authorAvatar, u.avatarUrl AS authorAvatarUrl,
u.githubUsername AS authorGithubUsername, u.githubProfileUrl AS authorGithubProfileUrl,
u.githubUsername AS authorGithubUsername,
s.revision, s.changelog, s.outdated, s.created, s.updated,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = 1), 0) AS upvotes,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = -1), 0) AS downvotes,
-1
View File
@@ -6,7 +6,6 @@ WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
node apps/workshop/tests/workshop-identity.mjs
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
@@ -24,9 +24,10 @@ if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
}
if (
users.oauth2?.mappedFields?.username !== "displayName" ||
users.oauth2?.mappedFields?.username !== "githubUsername" ||
users.oauth2?.mappedFields?.id ||
users.oauth2?.mappedFields?.name ||
users.oauth2?.mappedFields?.avatarURL !== "avatarUrl"
users.oauth2?.mappedFields?.avatarURL
) {
throw new Error(`GitHub OAuth field mapping was not synchronized: ${JSON.stringify(users.oauth2?.mappedFields)}`);
}
@@ -15,6 +15,9 @@ if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (!hook.includes('username: "githubUsername"') || hook.includes("onRecordAuthWithOAuth2Request")) {
throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping");
}
if (hook.includes("validateAndNormalizeSubmission")) {
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
}
@@ -38,6 +41,9 @@ if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFil
}
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl"]) {
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
}
for (const required of [
"workshop_migration_state",
"addedUserFields",
-63
View File
@@ -1,63 +0,0 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { deriveGithubIdentity } = require("../pb_hooks/workshop-utils.js");
const identity = deriveGithubIdentity(
{
name: " Octo Cat ",
username: "octocat",
avatarURL: "https://avatars.githubusercontent.com/u/1?v=4",
rawUser: {},
},
"record123456",
);
if (
identity.displayName !== "octocat" ||
identity.githubUsername !== "octocat" ||
identity.githubProfileUrl !== "https://github.com/octocat" ||
identity.avatarUrl !== "https://avatars.githubusercontent.com/u/1?v=4"
) {
throw new Error(`Trusted GitHub identity was not normalized: ${JSON.stringify(identity)}`);
}
const rawFallback = deriveGithubIdentity(
{
name: "",
username: "",
avatarURL: "",
rawUser: {
name: "\nRaw User\u0000",
login: "raw-user",
avatar_url: "https://avatars.githubusercontent.com/u/2",
},
},
"record123456",
);
if (
rawFallback.displayName !== "raw-user" ||
rawFallback.githubUsername !== "raw-user" ||
rawFallback.githubProfileUrl !== "https://github.com/raw-user"
) {
throw new Error(`Raw GitHub identity fallback was not normalized: ${JSON.stringify(rawFallback)}`);
}
const rejected = deriveGithubIdentity(
{
name: "\n",
username: "not/a/github-user",
avatarURL: "javascript:alert(1)",
rawUser: {},
},
"record123456",
);
if (
rejected.displayName !== "GitHub user record12" ||
rejected.githubUsername !== "" ||
rejected.githubProfileUrl !== "" ||
rejected.avatarUrl !== ""
) {
throw new Error(`Invalid provider identity fields were retained: ${JSON.stringify(rejected)}`);
}
console.log("Workshop OAuth identity normalization passed");
+11 -49
View File
@@ -46,7 +46,7 @@ await request("/api/collections/users", {
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
});
const createUser = async (email, displayName, password, extra = {}) =>
const createUser = async (email, githubUsername, password, extra = {}) =>
request("/api/collections/users/records", {
method: "POST",
headers: rootHeaders,
@@ -56,7 +56,7 @@ const createUser = async (email, displayName, password, extra = {}) =>
verified: true,
password,
passwordConfirm: password,
displayName,
githubUsername,
...extra,
}),
});
@@ -70,65 +70,27 @@ const signIn = async (email, password) => {
};
const authorPassword = "WorkshopAuthor123!";
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword);
const author = await createUser("widget-author@example.invalid", "widget-author", authorPassword);
const authorSession = await signIn(author.email, authorPassword);
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
404,
);
for (const [field, value] of [
["displayName", "Homarr Team"],
["avatarUrl", "https://example.invalid/forged-avatar.png"],
["githubUsername", "homarr-labs"],
["githubProfileUrl", "https://github.com/homarr-labs"],
]) {
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ [field]: value }) },
404,
);
}
const forgedAvatar = new FormData();
forgedAvatar.set(
"avatar",
new Blob(
[
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
],
{ type: "image/png" },
),
"forged-avatar.png",
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ githubUsername: "homarr-labs" }) },
404,
);
const avatarResponse = await fetch(`${baseUrl}/api/collections/users/records/${author.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: forgedAvatar,
});
const avatarResponseBody = await avatarResponse.json().catch(() => null);
if (avatarResponse.status !== 400 || !JSON.stringify(avatarResponseBody).includes("managed by GitHub OAuth")) {
throw new Error(
`Workshop user avatar upload bypassed the OAuth identity hook: ${avatarResponse.status} ${JSON.stringify(avatarResponseBody)}`,
);
}
const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
headers: authorSession.headers,
});
if (
unchangedAuthor.displayName !== "Widget Author" ||
unchangedAuthor.avatar ||
unchangedAuthor.avatarUrl ||
unchangedAuthor.githubUsername ||
unchangedAuthor.githubProfileUrl
) {
if (unchangedAuthor.githubUsername !== "widget-author") {
throw new Error("Workshop users can forge OAuth provider identity fields");
}
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword);
const visitorSession = await signIn(visitor.email, visitorPassword);
const widget = {
@@ -259,7 +221,7 @@ if (
listing.score !== 2 ||
listing.upvotes !== 2 ||
listing.downvotes !== 0 ||
listing.authorName !== "Widget Author" ||
listing.authorGithubUsername !== "widget-author" ||
listing.widgetSchema !== widget.$schema
) {
throw new Error("Workshop listing data is incorrect");
@@ -270,7 +232,7 @@ const comment = await request("/api/collections/comments/records?expand=author",
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, author: visitor.id, content: "Useful widget" }),
});
if (comment.author !== visitor.id || comment.expand.author.displayName !== "Widget Visitor") {
if (comment.author !== visitor.id || comment.expand.author.githubUsername !== "widget-visitor") {
throw new Error("Comment author expansion is incorrect");
}
await expectStatus(
+17 -81
View File
@@ -71,12 +71,7 @@ export interface WorkshopUserRecord extends WorkshopBaseRecord {
email: string;
emailVisibility: boolean;
verified: boolean;
username: string;
displayName: string;
avatarUrl: string;
avatar: string;
githubUsername: string;
githubProfileUrl: string;
isAdmin: boolean;
created: string;
updated: string;
@@ -100,11 +95,7 @@ export interface WorkshopSubmissionRecord extends WorkshopBaseRecord {
export interface WorkshopListingRecord extends Omit<WorkshopSubmissionRecord, "content" | "screenshots"> {
screenshots: string[] | string;
authorName: string;
authorAvatar: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: string;
score: number;
upvotes: number;
downvotes: number;
@@ -176,7 +167,10 @@ function filterListings(items: WorkshopSubmissionSummary[], options: WorkshopLis
if (options.type && options.type !== "all" && item.type !== options.type) return false;
if (options.author && item.author !== options.author) return false;
if (options.includeOutdated === false && item.outdated) return false;
if (search && !`${item.title}\n${item.description}\n${item.authorName}`.toLocaleLowerCase().includes(search))
if (
search &&
!`${item.title}\n${item.description}\n${item.authorGithubUsername}`.toLocaleLowerCase().includes(search)
)
return false;
return true;
}),
@@ -196,7 +190,7 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
if (options.type && options.type !== "all") filters.push(pocketBase.filter("type = {:type}", { type: options.type }));
if (options.search?.trim()) {
filters.push(
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorName ~ {:search})", {
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorGithubUsername ~ {:search})", {
search: options.search.trim(),
}),
);
@@ -209,10 +203,7 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
const isViewCompatibilityError = (error: unknown) =>
error instanceof ClientResponseError && (error.status === 400 || error.status === 404);
const enrichAuthor = (
row: unknown,
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) => {
const enrichAuthor = (row: unknown) => {
if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
const record = row as Record<string, unknown>;
const expand = record.expand;
@@ -222,27 +213,15 @@ const enrichAuthor = (
: undefined;
const user =
typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record<string, unknown>) : {};
const githubUsername = record.authorGithubUsername || user.githubUsername || user.username;
return {
...record,
authorName: record.authorName || user.displayName || user.username,
authorAvatarUrl: resolveAvatarUrl?.(record, user) || record.authorAvatarUrl || user.avatarUrl,
authorGithubUsername: githubUsername,
authorGithubProfileUrl:
record.authorGithubProfileUrl ||
user.githubProfileUrl ||
(typeof githubUsername === "string" && githubUsername
? `https://github.com/${encodeURIComponent(githubUsername)}`
: undefined),
authorGithubUsername: record.authorGithubUsername || user.githubUsername,
};
};
const parseListings = (
rows: unknown[],
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) =>
const parseListings = (rows: unknown[]) =>
rows.flatMap((row) => {
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row, resolveAvatarUrl));
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row));
return result.success ? [result.data] : [];
});
@@ -284,33 +263,12 @@ export class WorkshopBackend {
this.pocketBase.autoCancellation(false);
}
private userAvatarUrl(record: Record<string, unknown>) {
const avatar = typeof record.avatar === "string" ? record.avatar : "";
if (avatar) return this.pocketBase.files.getURL(record, avatar);
return typeof record.avatarUrl === "string" ? record.avatarUrl : "";
}
private listingAuthorAvatarUrl(record: Record<string, unknown>, user: Record<string, unknown>) {
const expandedAvatarUrl = this.userAvatarUrl(user);
if (expandedAvatarUrl) return expandedAvatarUrl;
const author = typeof record.author === "string" ? record.author : "";
const avatar = typeof record.authorAvatar === "string" ? record.authorAvatar : "";
if (!author || !avatar) return "";
return `${this.baseUrl}/api/files/users/${encodeURIComponent(author)}/${encodeURIComponent(avatar)}`;
}
public get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: this.userAvatarUrl(record),
githubUsername: record.githubUsername || "",
githubProfileUrl:
record.githubProfileUrl ||
(record.githubUsername ? `https://github.com/${encodeURIComponent(record.githubUsername)}` : ""),
isAdmin: record.isAdmin === true,
});
}
@@ -337,7 +295,6 @@ export class WorkshopBackend {
try {
const auth = await this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
});
this.pocketBase.authStore.save(auth.token, auth.record);
return this.currentUser;
@@ -362,7 +319,7 @@ export class WorkshopBackend {
});
return {
...result,
items: parseListings(result.items, (record, user) => this.listingAuthorAvatarUrl(record, user)),
items: parseListings(result.items),
};
} catch (error) {
if (!isViewCompatibilityError(error)) throw error;
@@ -389,7 +346,7 @@ export class WorkshopBackend {
sort: listingSort(options.sort),
signal: requestSignal(options.signal),
});
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
return parseListings(rows);
} catch (error) {
if (!isViewCompatibilityError(error)) throw error;
return filterListings(await this.loadListingsFallback(options.signal), options);
@@ -409,7 +366,7 @@ export class WorkshopBackend {
return this.pocketBase.collection("submissions").getFullList({ ...options, expand: "author" });
throw error;
});
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
return parseListings(rows);
}
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
@@ -425,10 +382,7 @@ export class WorkshopBackend {
throw error;
});
return workshopSubmissionDetailSchema.parse(
enrichAuthor(
{ ...submission, ...listing, expand: submission.expand, content: submission.content },
(record, user) => this.listingAuthorAvatarUrl(record, user),
),
enrichAuthor({ ...submission, ...listing, expand: submission.expand, content: submission.content }),
);
} catch (error) {
throw workshopError(error, "Submission not found");
@@ -548,7 +502,7 @@ export class WorkshopBackend {
return items.map((item) =>
workshopReportSchema.parse({
...item,
reporterName: item.expand?.reporter?.displayName,
reporterGithubUsername: item.expand?.reporter?.githubUsername,
submissionTitle: item.expand?.submission?.title,
}),
);
@@ -585,13 +539,7 @@ export class WorkshopBackend {
return rows.map((row) =>
workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
authorGithubUsername: row.expand?.author?.githubUsername,
}),
);
} catch (error) {
@@ -606,13 +554,7 @@ export class WorkshopBackend {
.create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
authorGithubUsername: row.expand?.author?.githubUsername,
});
} catch (error) {
throw workshopError(error, "Failed to post comment");
@@ -626,13 +568,7 @@ export class WorkshopBackend {
.update(id, { content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
authorGithubUsername: row.expand?.author?.githubUsername,
});
} catch (error) {
throw workshopError(error, "Failed to update comment");
+4 -8
View File
@@ -70,9 +70,7 @@ describe("WorkshopBackend", () => {
token: "token",
record: {
id: "user-id",
displayName: "octocat",
githubUsername: "octocat",
githubProfileUrl: "https://github.com/octocat",
},
meta: { username: "octocat" },
});
@@ -80,14 +78,11 @@ describe("WorkshopBackend", () => {
const client = new WorkshopBackend("https://workshop.example.com");
const user = await client.signInWithGitHub();
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
provider: "github",
createData: { displayName: "GitHub user" },
});
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({ provider: "github" });
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" });
expect(user).toMatchObject({ githubUsername: "octocat" });
});
test("exposes the typed submission collection service", () => {
@@ -219,7 +214,8 @@ describe("WorkshopBackend", () => {
1,
12,
expect.objectContaining({
filter: "(title ~ 'active streams' || description ~ 'active streams' || authorName ~ 'active streams')",
filter:
"(title ~ 'active streams' || description ~ 'active streams' || authorGithubUsername ~ 'active streams')",
}),
);
});
+8 -1
View File
@@ -3,6 +3,8 @@ import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
githubProfileUrl,
normalizeHttpUrl,
resolveHomarrUrlConfig,
validateWorkshopWidget,
@@ -11,6 +13,11 @@ import {
} from "./schema";
describe("Workshop URL configuration", () => {
test("derives GitHub identity URLs from the stored username", () => {
expect(githubProfileUrl("octocat")).toBe("https://github.com/octocat");
expect(githubAvatarUrl("octocat")).toBe("https://github.com/octocat.png");
});
test("normalizes the URL contract and derives Workshop defaults", () => {
expect(
resolveHomarrUrlConfig({
@@ -98,7 +105,7 @@ describe("Workshop widget validation", () => {
expect(result.type).toBe("customWidget");
expect(result.screenshots).toEqual([]);
expect(result.authorName).toBe("Community member");
expect(result.authorGithubUsername).toBe("");
expect(result.upvotes).toBe(0);
});
+7 -10
View File
@@ -16,6 +16,11 @@ 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 const githubProfileUrl = (username: string) =>
username ? `https://github.com/${encodeURIComponent(username)}` : "";
export const githubAvatarUrl = (username: string) =>
username ? `https://github.com/${encodeURIComponent(username)}.png` : "";
export interface HomarrUrlConfig {
homarrWebsiteUrl: string;
workshopApiUrl: string;
@@ -85,10 +90,7 @@ const workshopFileListSchema = z
export const workshopUserSchema = z.object({
id: z.string(),
displayName: z.string(),
avatarUrl: z.string().default(""),
githubUsername: z.string().default(""),
githubProfileUrl: z.string().default(""),
isAdmin: z.boolean().default(false),
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
@@ -111,10 +113,7 @@ const workshopSubmissionBaseObjectSchema = z.object({
widgetSchema: z.string(),
screenshots: workshopFileListSchema,
author: z.string(),
authorName: z.string().default("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
score: z.number().int().default(0),
upvotes: z.number().int().default(0),
downvotes: z.number().int().default(0),
@@ -174,7 +173,7 @@ export const workshopReportSchema = z.object({
id: z.string(),
submission: z.string(),
reporter: z.string(),
reporterName: z.string().default("Community member"),
reporterGithubUsername: z.string().default(""),
submissionTitle: z.string().default("Deleted submission"),
category: workshopReportCategorySchema,
explanation: z.string(),
@@ -191,9 +190,7 @@ export const workshopCommentSchema = z.object({
content: z.string().min(1).max(2_000),
created: z.string(),
updated: z.string(),
authorName: z.string().default("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
});
export type WorkshopComment = z.infer<typeof workshopCommentSchema>;