diff --git a/convex/schema.ts b/convex/schema.ts index 3485e61..a7fb50c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -37,6 +37,7 @@ const skills = defineTable({ at: v.number(), }), ), + repoUrl: v.optional(v.string()), latestVersionId: v.optional(v.id('skillVersions')), tags: v.record(v.string(), v.id('skillVersions')), softDeletedAt: v.optional(v.number()), diff --git a/convex/skills.ts b/convex/skills.ts index 9ea5a36..2873e0b 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -681,10 +681,16 @@ export const insertVersion = internalMutation({ } const summary = getFrontmatterValue(args.parsed.frontmatter, 'description') + const repoUrl = parseGitHubRepoUrl( + getFrontmatterValue(args.parsed.frontmatter, 'homepage') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repository') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repo'), + ) const skillId = await ctx.db.insert('skills', { slug: args.slug, displayName: args.displayName, summary: summary ?? undefined, + repoUrl: repoUrl ?? undefined, ownerUserId: userId, canonicalSkillId, forkOf, @@ -741,9 +747,16 @@ export const insertVersion = internalMutation({ const latestBefore = skill.latestVersionId + const updatedRepoUrl = parseGitHubRepoUrl( + getFrontmatterValue(args.parsed.frontmatter, 'homepage') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repository') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repo'), + ) ?? skill.repoUrl + await ctx.db.patch(skill._id, { displayName: args.displayName, summary: getFrontmatterValue(args.parsed.frontmatter, 'description') ?? skill.summary, + repoUrl: updatedRepoUrl, latestVersionId: versionId, tags: nextTags, stats: { ...skill.stats, versions: skill.stats.versions + 1 }, @@ -854,6 +867,24 @@ function clampInt(value: number, min: number, max: number) { return Math.min(max, Math.max(min, rounded)) } +const GITHUB_REPO_RE = /^https:\/\/github\.com\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+\/?$/ + +/** + * Validate and normalize a repo URL. Only accepts GitHub repository URLs + * (https://github.com/owner/repo) to prevent malicious links. + * Returns the validated URL or undefined if invalid/missing. + */ +function parseGitHubRepoUrl(raw: string | undefined): string | undefined { + if (!raw) return undefined + const trimmed = raw.trim().replace(/\/+$/, '') + if (!GITHUB_REPO_RE.test(trimmed + '/') && !GITHUB_REPO_RE.test(trimmed)) { + // Not a valid GitHub repo URL — silently ignore + return undefined + } + // Normalize: strip trailing slash + return trimmed +} + async function findCanonicalSkillForFingerprint( ctx: { db: MutationCtx['db'] }, fingerprint: string, diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index 0fc6022..bcb4c1e 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -202,6 +202,13 @@ export function SkillDetailPage({ by @{owner.handle} ) : null} + {skill.repoUrl ? ( +
+ + 🔗 GitHub + +
+ ) : null} {forkOf && forkOfHref ? (
{forkOfLabel}{' '}