Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
31 changes: 31 additions & 0 deletions convex/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/components/SkillDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ export function SkillDetailPage({
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
</div>
) : null}
{skill.repoUrl ? (
<div className="stat">
<a href={skill.repoUrl} target="_blank" rel="noopener noreferrer">
🔗 GitHub
</a>
</div>
) : null}
{forkOf && forkOfHref ? (
<div className="stat">
{forkOfLabel}{' '}
Expand Down