From 5000864a9e0ec3019c77640d1676db7dc8aee476 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:25 +0300 Subject: [PATCH 01/10] fix: harden repository validation and recent checks --- dashboard/lib/repo-validation.mjs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/dashboard/lib/repo-validation.mjs b/dashboard/lib/repo-validation.mjs index 340cff5..f2d1e52 100644 --- a/dashboard/lib/repo-validation.mjs +++ b/dashboard/lib/repo-validation.mjs @@ -1,35 +1,33 @@ const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/ const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/ -/** - * Parse only a GitHub owner/name slug or an https://github.com/owner/name URL. - * Returning null keeps untrusted input out of every outbound GitHub request. - */ +/** Parse only a canonical GitHub repository slug or repository-root HTTPS URL. */ export function parseRepoSlug(input) { if (typeof input !== 'string') return null const value = input.trim() - if (!value || value.length > 200 || /[\u0000-\u001f\u007f]/.test(value)) return null + if (!value || value.length > 200 || /[\\u0000-\\u001f\\u007f]/.test(value)) return null let path = value - if (/^https?:\/\//i.test(value)) { + if (/^https?:\\/\\//i.test(value)) { try { const url = new URL(value) if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') return null if (url.username || url.password || url.search || url.hash) return null path = url.pathname - } catch { - return null - } + } catch { return null } } else if (value.includes('://') || value.startsWith('//')) { return null } - path = path.replace(/^\/+|\/+$/g, '') + path = path.replace(/^\\/+|\\/+$/g, '') const parts = path.split('/') if (parts.length !== 2) return null const [owner, name] = parts if (!OWNER_PATTERN.test(owner) || !REPO_PATTERN.test(name)) return null if (owner.length > 39 || name.length > 100) return null - return { owner, name, slug: `${owner}/${name}` } } + +export function repoInputError() { + return 'Invalid repository. Use owner/name or the repository root URL https://github.com/owner/name.' +} From c54ff935d4b3ce5909a8b9cc74ed24fe305c1334 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:27 +0300 Subject: [PATCH 02/10] fix: harden repository validation and recent checks --- dashboard/app/api/watchlist/route.ts | 64 +++++++++++++++++----------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/dashboard/app/api/watchlist/route.ts b/dashboard/app/api/watchlist/route.ts index bbd426c..ec0b0be 100644 --- a/dashboard/app/api/watchlist/route.ts +++ b/dashboard/app/api/watchlist/route.ts @@ -5,7 +5,6 @@ import { consumeRateLimit, requestIdentity } from '@/lib/rate-limit.mjs'; import { normalizeWatchEntry } from '@/lib/watchlist-validation.mjs'; const redis = Redis.fromEnv(); - const KEY = 'devlens:watchlist'; const LOCK_KEY = 'devlens:watchlist:lock'; const MAX = 100; @@ -19,20 +18,44 @@ export interface WatchEntry { } async function acquireLock(): Promise { - const result = await redis.set(LOCK_KEY, `${Date.now()}-${Math.random()}`, { nx: true, ex: 5 }); - return result === 'OK'; + return (await redis.set(LOCK_KEY, `${Date.now()}-${Math.random()}`, { nx: true, ex: 5 })) === 'OK'; +} +async function releaseLock() { try { await redis.del(LOCK_KEY); } catch {} } + +function dedupe(entries: WatchEntry[]): WatchEntry[] { + const seen = new Set(); + const result: WatchEntry[] = []; + for (const item of entries) { + const normalized = normalizeWatchEntry(item); + if (!normalized || seen.has(normalized.slug)) continue; + seen.add(normalized.slug); + result.push({ ...normalized, savedAt: item.savedAt || normalized.savedAt }); + if (result.length >= MAX) break; + } + return result; } -async function releaseLock() { - try { await redis.del(LOCK_KEY); } catch {} +async function readCanonicalList(): Promise { + const raw = await redis.lrange(KEY, 0, MAX - 1); + const canonical = dedupe(raw ?? []); + if ((raw ?? []).length !== canonical.length) { + let locked = false; + try { + locked = await acquireLock(); + if (locked) { + await redis.del(KEY); + if (canonical.length) await redis.rpush(KEY, ...canonical); + } + } finally { if (locked) await releaseLock(); } + } + return canonical; } export async function GET(req: Request) { try { const limit = await consumeRateLimit(redis, requestIdentity(req), 'watchlist'); if (!limit.allowed) return NextResponse.json({ error: 'rate_limited', message: 'Watchlist rate limit exceeded. Try again shortly.' }, { status: 429, headers: limit.headers }); - const list = await redis.lrange(KEY, 0, MAX - 1); - return NextResponse.json({ list: list ?? [] }, { headers: limit.headers }); + return NextResponse.json({ list: await readCanonicalList() }, { headers: limit.headers }); } catch (err) { console.error('watchlist GET error', err); return NextResponse.json({ list: [], degraded: true }); @@ -51,19 +74,15 @@ export async function POST(req: Request) { try { locked = await acquireLock(); if (!locked) return NextResponse.json({ ok: false, error: 'watchlist_busy' }, { status: 409 }); - const existing = await redis.lrange(KEY, 0, MAX - 1); - for (const item of existing ?? []) { - if (item?.slug === entry.slug) await redis.lrem(KEY, 0, item); - } - await redis.lpush(KEY, entry); - await redis.ltrim(KEY, 0, MAX - 1); + const existing = dedupe((await redis.lrange(KEY, 0, MAX - 1)) ?? []); + const next = [entry, ...existing.filter(item => item.slug !== entry.slug)].slice(0, MAX); + await redis.del(KEY); + await redis.rpush(KEY, ...next); return NextResponse.json({ ok: true }, { headers: limit.headers }); } catch (err) { console.error('watchlist POST error', err); return NextResponse.json({ ok: false, error: 'watchlist_unavailable' }, { status: 503 }); - } finally { - if (locked) await releaseLock(); - } + } finally { if (locked) await releaseLock(); } } export async function DELETE(req: Request) { @@ -71,20 +90,17 @@ export async function DELETE(req: Request) { if (!limit.allowed) return NextResponse.json({ ok: false, error: 'rate_limited', message: 'Watchlist rate limit exceeded. Try again shortly.' }, { status: 429, headers: limit.headers }); const parsed = parseRepoSlug(new URL(req.url).searchParams.get('slug')); if (!parsed) return NextResponse.json({ ok: false, error: 'invalid_repository' }, { status: 400 }); - let locked = false; try { locked = await acquireLock(); if (!locked) return NextResponse.json({ ok: false, error: 'watchlist_busy' }, { status: 409 }); - const existing = await redis.lrange(KEY, 0, MAX - 1); - for (const item of existing ?? []) { - if (item?.slug === parsed.slug) await redis.lrem(KEY, 0, item); - } + const existing = dedupe((await redis.lrange(KEY, 0, MAX - 1)) ?? []); + const next = existing.filter(item => item.slug !== parsed.slug); + await redis.del(KEY); + if (next.length) await redis.rpush(KEY, ...next); return NextResponse.json({ ok: true }, { headers: limit.headers }); } catch (err) { console.error('watchlist DELETE error', err); return NextResponse.json({ ok: false, error: 'watchlist_unavailable' }, { status: 503 }); - } finally { - if (locked) await releaseLock(); - } + } finally { if (locked) await releaseLock(); } } From 8fd2006de13326ced3be1900f15160dbed54bb4a Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:30 +0300 Subject: [PATCH 03/10] fix: harden repository validation and recent checks --- dashboard/app/api/analyze/route.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/dashboard/app/api/analyze/route.ts b/dashboard/app/api/analyze/route.ts index cae0a68..dbcebfb 100644 --- a/dashboard/app/api/analyze/route.ts +++ b/dashboard/app/api/analyze/route.ts @@ -52,20 +52,6 @@ export async function GET(req: NextRequest) { redis.hset('stats:repo_scores', { [slug]: report.healthScore }), redis.hset('stats:repo_last_seen', { [slug]: new Date().toISOString() }), redis.sadd('stats:unique_ips', ip), - // Watchlist (recently checked) — dedupe then prepend - redis.lrange('devlens:watchlist', 0, 99).then(async (existing: any[]) => { - for (const item of existing ?? []) { - if (item?.slug === slug) await redis.lrem('devlens:watchlist', 0, item) - } - await redis.lpush('devlens:watchlist', { - slug, - score: report.healthScore, - description: report.description ?? null, - language: report.language ?? null, - savedAt: new Date().toISOString(), - }) - await redis.ltrim('devlens:watchlist', 0, 99) - }), ]).catch(() => {}) // ───────────────────────────────────── @@ -74,6 +60,13 @@ export async function GET(req: NextRequest) { if (e.code === 'rate_limited') { return NextResponse.json({ error: 'rate_limited', message: e.message }, { status: 429 }) } - return NextResponse.json({ error: e.message ?? 'Analysis failed' }, { status: 500 }) + if (e.code === 'not_found') { + return NextResponse.json({ error: 'repository_not_found', message: 'Repository not found or inaccessible. Check the owner/name and make sure the repository is public.' }, { status: 404 }) + } + if (e.code === 'private_repository') { + return NextResponse.json({ error: 'private_repository', message: 'Private repositories are not supported.' }, { status: 403 }) + } + console.error('analysis error', e) + return NextResponse.json({ error: 'analysis_failed', message: 'Unable to analyze this repository right now. Please verify the repository and try again.' }, { status: 500 }) } } From 7ee3d418093b91b73592910e35fb1034d15121cb Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:33 +0300 Subject: [PATCH 04/10] fix: harden repository validation and recent checks --- dashboard/lib/scorer.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dashboard/lib/scorer.ts b/dashboard/lib/scorer.ts index d2ee5d3..1209c06 100644 --- a/dashboard/lib/scorer.ts +++ b/dashboard/lib/scorer.ts @@ -62,7 +62,13 @@ async function ghFetch(url: string, token?: string): Promise { err.code = 'rate_limited' throw err } - throw new Error(`GitHub API error ${r.status} ${url}`) + const err: any = new Error( + r.status === 404 + ? 'Repository not found or inaccessible.' + : 'GitHub request failed.' + ) + if (r.status === 404) err.code = 'not_found' + throw err } return r.json() } @@ -265,7 +271,9 @@ export async function analyzeRepo( // private report from ever being served through a public owner/name key. const repoData = await ghFetch(`${GH}/repos/${owner}/${name}`, token) if (repoData.private === true) { - throw new Error('Private repositories are not supported') + const err: any = new Error('Private repositories are not supported') + err.code = 'private_repository' + throw err } if (redis && !customWeights) { From 69b13b399df5941aecca5ded3faed6a13abdcb50 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:35 +0300 Subject: [PATCH 05/10] fix: harden repository validation and recent checks --- dashboard/app/page.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index e788bfd..45c94c6 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -7,6 +7,7 @@ import SnippetModal from '@/components/SnippetModal' import WeightEditor from '@/components/WeightEditor' import type { RepoReport } from '@/lib/scorer' import { DEFAULT_WEIGHTS, DimKey } from '@/lib/constants' +import { parseRepoSlug } from '@/lib/repo-validation.mjs' import Link from 'next/link' import { signIn } from 'next-auth/react' import type { WatchEntry } from '@/app/api/watchlist/route' @@ -39,7 +40,12 @@ export default function Home() { if (!input.trim()) return setLoading(true); setError(null); setReport(null); setHistory([]) try { - const slug = input.trim().replace('https://github.com/', '').replace(/\/$/, '') + const parsed = parseRepoSlug(input) + if (!parsed) { + setError({ type: 'invalid_repository', message: 'Enter a repository as owner/name or a repository-root GitHub URL.' }) + return + } + const slug = parsed.slug const weightsSum = Object.values(weights).reduce((a, b) => a + b, 0) const sourceWeights = Number.isFinite(weightsSum) && weightsSum > 0 ? weights : DEFAULT_WEIGHTS const sourceSum = Object.values(sourceWeights).reduce((a, b) => a + b, 0) @@ -59,7 +65,6 @@ export default function Home() { } setReport(data) - // Save to watchlist so "Recently Checked" and /checked are live const entry: WatchEntry = { slug, score: data.healthScore, @@ -67,12 +72,7 @@ export default function Home() { language: data.language ?? null, savedAt: new Date().toISOString(), } - fetch('/api/watchlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(entry), - }).catch(() => {}) - // Optimistic update — prepend and dedupe + // The analyze API is the single writer for recently checked repositories. setRecentList(prev => [entry, ...prev.filter(w => w.slug !== slug)].slice(0, 10)) const hData = await histRes.json() From 74a068cde806b9e64f5664b397edff9def93a0d8 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:38 +0300 Subject: [PATCH 06/10] fix: harden repository validation and recent checks --- dashboard/app/badge/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dashboard/app/badge/page.tsx b/dashboard/app/badge/page.tsx index 3dd01a2..8a41615 100644 --- a/dashboard/app/badge/page.tsx +++ b/dashboard/app/badge/page.tsx @@ -4,6 +4,7 @@ import type { RepoReport } from '@/lib/scorer' import ThemeToggle from '@/components/ThemeToggle' import { Search, Loader2, ArrowRight, Copy, Check, ArrowLeft } from 'lucide-react' import Link from 'next/link' +import { parseRepoSlug } from '@/lib/repo-validation.mjs' export default function BadgePage() { const [input, setInput] = useState('') @@ -17,7 +18,12 @@ export default function BadgePage() { if (!input.trim()) return setLoading(true); setError(''); setReport(null) try { - const slug = input.trim().replace('https://github.com/', '').replace(/\/$/, '') + const parsed = parseRepoSlug(input) + if (!parsed) { + setError('Enter a repository as owner/name or a repository-root GitHub URL.') + return + } + const slug = parsed.slug const res = await fetch(`/api/analyze?repo=${encodeURIComponent(slug)}`) const data = await res.json() if (!res.ok) { setError(data.error ?? 'Analysis failed'); return } From 69fc697e940879eb2ec50f8367d33e0f0206f890 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:46:43 +0300 Subject: [PATCH 07/10] test: reject non-root GitHub repository URLs --- dashboard/scripts/validation.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/scripts/validation.test.mjs b/dashboard/scripts/validation.test.mjs index 15c71c1..b28618a 100644 --- a/dashboard/scripts/validation.test.mjs +++ b/dashboard/scripts/validation.test.mjs @@ -16,7 +16,7 @@ test('accepts owner/name and canonical GitHub HTTPS URLs', () => { test('rejects non-GitHub, ambiguous, and control-character repository inputs', () => { for (const value of [ - '', 'owner', 'owner/name/extra', 'http://github.com/owner/name', + '', 'owner', 'owner/name/extra', 'https://github.com/owner/name/blob/main/README.md', 'https://github.com/owner/name/tree/main', 'http://github.com/owner/name', 'https://example.com/owner/name', 'https://github.com/owner/name?x=1', 'https://github.com/owner/name#fragment', 'owner/name\u0000', '//github.com/owner/name', ]) assert.equal(parseRepoSlug(value), null, value) From 69b3f1100ce3d34dda9d1908c4c0c73d7978dfff Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:47:29 +0300 Subject: [PATCH 08/10] fix: correct repository URL regex escaping --- dashboard/lib/repo-validation.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/lib/repo-validation.mjs b/dashboard/lib/repo-validation.mjs index f2d1e52..990abcd 100644 --- a/dashboard/lib/repo-validation.mjs +++ b/dashboard/lib/repo-validation.mjs @@ -8,7 +8,7 @@ export function parseRepoSlug(input) { if (!value || value.length > 200 || /[\\u0000-\\u001f\\u007f]/.test(value)) return null let path = value - if (/^https?:\\/\\//i.test(value)) { + if (/^https?:\/\//i.test(value)) { try { const url = new URL(value) if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') return null From ff80aae81a7680d6c9f3b9c4248887c26b12f14e Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:47:34 +0300 Subject: [PATCH 09/10] fix: restore control character validation --- dashboard/lib/repo-validation.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/lib/repo-validation.mjs b/dashboard/lib/repo-validation.mjs index 990abcd..6d51a8a 100644 --- a/dashboard/lib/repo-validation.mjs +++ b/dashboard/lib/repo-validation.mjs @@ -5,7 +5,7 @@ const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$/ export function parseRepoSlug(input) { if (typeof input !== 'string') return null const value = input.trim() - if (!value || value.length > 200 || /[\\u0000-\\u001f\\u007f]/.test(value)) return null + if (!value || value.length > 200 || /[\u0000-\u001f\u007f]/.test(value)) return null let path = value if (/^https?:\/\//i.test(value)) { From 4dc1e5a30da23f100c8248cf08b5462de2c7b165 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 22:48:19 +0300 Subject: [PATCH 10/10] fix: correct repository path regex escaping --- dashboard/lib/repo-validation.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/lib/repo-validation.mjs b/dashboard/lib/repo-validation.mjs index 6d51a8a..c19a9fd 100644 --- a/dashboard/lib/repo-validation.mjs +++ b/dashboard/lib/repo-validation.mjs @@ -19,7 +19,7 @@ export function parseRepoSlug(input) { return null } - path = path.replace(/^\\/+|\\/+$/g, '') + path = path.replace(/^\/+|\/+$/g, '') const parts = path.split('/') if (parts.length !== 2) return null const [owner, name] = parts