Merge widget Workshop identity fix into mobile
This commit is contained in:
19 files changed
+114
-362
No files matched your search
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
@@ -70,7 +29,6 @@ const sendEmail = (app, recipientEmail, subject, text, html) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
deriveGithubIdentity,
|
||||
emailTemplate,
|
||||
escapeHtml,
|
||||
rejectRequest,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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')",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user