diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 463f651..0233675 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -3,6 +3,9 @@ import { validateSession, setSessionCookie, deleteSessionCookie } from '$lib/ser import { env } from '$env/dynamic/private'; import { error } from '@sveltejs/kit'; +// Route IDs are prefixed with their filesystem route group, e.g. "/(app)/devices/[id]". +const PROTECTED_ROUTE_PREFIX = '/(app)'; + export const handleError: HandleServerError = async ({ error: err }) => { const message = err instanceof Error ? err.message : 'Unknown error'; @@ -64,5 +67,19 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.session = null; } + // Auth gate for every request under the (app) route group — not just page + // renders. SvelteKit runs form `actions` and `+server.ts` handlers without + // first invoking +layout.server.ts `load`, so the layout's redirect-based + // guard never ran for POST/PUT/PATCH/DELETE requests; a crafted request + // with no session could reach any mutating action directly. GET/HEAD page + // requests still fall through to the layout guard for its redirect-to-login + // UX; everything else is hard-rejected here. + if (event.route.id?.startsWith(PROTECTED_ROUTE_PREFIX) && !event.locals.user) { + const method = event.request.method; + if (method !== 'GET' && method !== 'HEAD') { + error(401, 'Unauthorized'); + } + } + return resolve(event); }; diff --git a/src/lib/components/parts/PartLookup.svelte b/src/lib/components/parts/PartLookup.svelte new file mode 100644 index 0000000..b07e354 --- /dev/null +++ b/src/lib/components/parts/PartLookup.svelte @@ -0,0 +1,82 @@ + + +
+ +
+ + +
+ {#if message} +

+ {message} +

+ {/if} +
diff --git a/src/lib/server/digikeyLookup.ts b/src/lib/server/digikeyLookup.ts new file mode 100644 index 0000000..7711851 --- /dev/null +++ b/src/lib/server/digikeyLookup.ts @@ -0,0 +1,78 @@ +import { env } from '$env/dynamic/private'; +import type { PartLookupResult } from './partsLookup.js'; + +let cachedToken: { value: string; expiresAt: number } | null = null; + +async function getToken(clientId: string, clientSecret: string): Promise { + if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) { + return cachedToken.value; + } + try { + const res = await fetch('https://api.digikey.com/v1/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'client_credentials' + }), + signal: AbortSignal.timeout(8000) + }); + if (!res.ok) return null; + const body = await res.json(); + if (!body?.access_token) return null; + cachedToken = { + value: body.access_token, + expiresAt: Date.now() + (Number(body.expires_in) || 600) * 1000 + }; + return cachedToken.value; + } catch { + return null; + } +} + +export async function lookupDigikeyPart(pn: string): Promise { + const clientId = env.DIGIKEY_CLIENT_ID; + const clientSecret = env.DIGIKEY_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + + const token = await getToken(clientId, clientSecret); + if (!token) return null; + + try { + const res = await fetch( + `https://api.digikey.com/products/v4/search/${encodeURIComponent(pn)}/productdetails`, + { + headers: { + Authorization: `Bearer ${token}`, + 'X-DIGIKEY-Client-Id': clientId, + Accept: 'application/json' + }, + signal: AbortSignal.timeout(8000) + } + ); + if (!res.ok) return null; + + const body = await res.json(); + const product = body?.Product ?? body; + if (!product) return null; + + // Best-effort mapping — UNVERIFIED against a live Digikey response; recheck once real credentials exist. + return { + mpn: product.ManufacturerProductNumber ?? pn, + name: product.Manufacturer?.Name ?? null, + category: null, + value: null, + voltage: null, + tolerance: null, + power: null, + mounting: null, + package: null, + notes: `Auto-filled from Digikey ${pn}`, + sourceUrl: product.ProductUrl ?? null, + provider: 'digikey' + }; + } catch { + return null; + } +} diff --git a/src/lib/server/lcscLookup.ts b/src/lib/server/lcscLookup.ts new file mode 100644 index 0000000..6be3866 --- /dev/null +++ b/src/lib/server/lcscLookup.ts @@ -0,0 +1,79 @@ +import type { PartLookupResult } from './partsLookup.js'; + +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +const CATEGORY_RULES: [RegExp, string][] = [ + [/capacitor/, 'Capacitor'], + [/resistor/, 'Resistor'], + [/inductor|ferrite|choke/, 'Inductor'], + [/light emitting diode|\bled\b/, 'LED'], + [/diode/, 'Diode'], + [/transistor|mosfet|igbt/, 'Transistor'], + [/crystal|oscillator|resonator/, 'Crystal / Oscillator'], + [/fuse/, 'Fuse'], + [/switch|button|tactile/, 'Button / Switch'], + [/connector|header|terminal/, 'Connector'], + [/circuit|controller|amplifier|microcontroller|regulator|interface|logic|memory|driver|ic\b/, 'IC / Chip'] +]; + +function mapCategory(tags: unknown): string { + const joined = Array.isArray(tags) ? tags.join(' ').toLowerCase() : ''; + for (const [re, cat] of CATEGORY_RULES) { + if (re.test(joined)) return cat; + } + return 'Other'; +} + +function mapPackage(pkg: string | null | undefined): string | null { + if (!pkg) return null; + // Strip leading letters from codes like "R0603" / "C0402" → "0603" / "0402"; keep irregular ones as-is. + const m = pkg.match(/^[A-Za-z]+(\d{3,5})$/); + return m ? m[1] : pkg; +} + +export async function lookupLcscPart(code: string): Promise { + // Validate before building the URL — this is the only untrusted input on the request path (prevents SSRF/path injection). + if (!/^C\d+$/i.test(code)) return null; + + try { + const res = await fetch(`https://easyeda.com/api/products/${code}/components`, { + headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, + signal: AbortSignal.timeout(8000) + }); + if (!res.ok) return null; + + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) return null; + + const body = await res.json(); + if (!body?.success || !body.result) return null; + + const result = body.result; + const cPara = result?.dataStr?.head?.c_para ?? {}; + const description: string = typeof result.description === 'string' ? result.description : ''; + + const toleranceMatch = description.match(/±\s*([\d.]+\s*%)/); + const voltageMatches = description.match(/(\d+(?:\.\d+)?\s?[mkMGT]?V)\b/g); + const powerMatch = description.match(/(\d+(?:\.\d+)?\s?[mkM]?W)\b/); + + const value = typeof cPara.Value === 'string' ? cPara.Value.trim() : ''; + + return { + mpn: cPara['Manufacturer Part'] || result.title || code, + name: cPara.Manufacturer ? String(cPara.Manufacturer) : null, + category: mapCategory(result.tags), + value: value || null, + tolerance: toleranceMatch ? toleranceMatch[1].replace(/\s+/g, '') : null, + voltage: voltageMatches ? voltageMatches[voltageMatches.length - 1] : null, + power: powerMatch ? powerMatch[1] : null, + mounting: result.SMT === true ? 'SMT' : result.SMT === false ? 'THT' : null, + package: mapPackage(cPara.package), + notes: `Auto-filled from LCSC ${code}`, + sourceUrl: result.lcsc?.url ?? null, + provider: 'lcsc' + }; + } catch { + return null; + } +} diff --git a/src/lib/server/partsLookup.ts b/src/lib/server/partsLookup.ts new file mode 100644 index 0000000..324e43e --- /dev/null +++ b/src/lib/server/partsLookup.ts @@ -0,0 +1,27 @@ +import { lookupLcscPart } from './lcscLookup.js'; +import { lookupDigikeyPart } from './digikeyLookup.js'; + +export interface PartLookupResult { + name: string | null; + category: string | null; + mpn: string | null; + value: string | null; + voltage: string | null; + tolerance: string | null; + power: string | null; + mounting: 'THT' | 'SMT' | null; + package: string | null; + notes: string | null; + sourceUrl: string | null; + provider: 'lcsc' | 'digikey'; +} + +export async function lookupPartNumber(raw: string): Promise { + const trimmed = raw.trim(); + if (!trimmed) return null; + + if (/^C\d+$/i.test(trimmed)) { + return lookupLcscPart(trimmed); + } + return lookupDigikeyPart(trimmed); +} diff --git a/src/routes/(app)/parts/[id]/edit/+page.svelte b/src/routes/(app)/parts/[id]/edit/+page.svelte index 1cbe6a8..216a0c3 100644 --- a/src/routes/(app)/parts/[id]/edit/+page.svelte +++ b/src/routes/(app)/parts/[id]/edit/+page.svelte @@ -2,18 +2,40 @@ import { enhance } from '$app/forms'; import { PART_CATEGORIES } from '$lib/constants.js'; import LocationPicker from '$lib/components/ui/LocationPicker.svelte'; + import PartLookup from '$lib/components/parts/PartLookup.svelte'; + import type { PartLookupResult } from '$lib/server/partsLookup.js'; let { data, form } = $props(); + const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? ''; + let category = $state(String(form?.values?.category ?? data.part.category ?? '')); + let name = $state(String(val('name'))); + let mpn = $state(String(val('mpn'))); + let value = $state(String(val('value'))); + let voltage = $state(String(val('voltage'))); + let tolerance = $state(String(val('tolerance'))); + let power = $state(String(val('power'))); + let mounting = $state(String(val('mounting'))); + let pkg = $state(String(val('package'))); + + function onFound(r: PartLookupResult) { + if (r.name != null) name = r.name; + if (r.mpn != null) mpn = r.mpn; + if (r.category != null) category = r.category; + if (r.value != null) value = r.value; + if (r.voltage != null) voltage = r.voltage; + if (r.tolerance != null) tolerance = r.tolerance; + if (r.power != null) power = r.power; + if (r.mounting != null) mounting = r.mounting; + if (r.package != null) pkg = r.package; + } const showCapacitance = $derived(category === 'Capacitor'); const showResistance = $derived(category === 'Resistor'); const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category)); const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].includes(category)); const showTolerance = $derived(['Resistor', 'Capacitor', 'Inductor', 'Crystal / Oscillator'].includes(category)); - - const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? ''; @@ -27,6 +49,10 @@
{form.error}
{/if} +
+ +
+
@@ -45,14 +71,14 @@
-
-
@@ -61,27 +87,27 @@ - {#if showVoltage}
-
{/if} {#if showTolerance}
-
{/if} {#if showPower}
-
{/if} @@ -97,13 +123,13 @@
{#each ['THT', 'SMT'] as m} {/each} @@ -111,7 +137,7 @@
-
diff --git a/src/routes/(app)/parts/new/+page.svelte b/src/routes/(app)/parts/new/+page.svelte index b2acf2b..d6f9a0e 100644 --- a/src/routes/(app)/parts/new/+page.svelte +++ b/src/routes/(app)/parts/new/+page.svelte @@ -2,10 +2,32 @@ import { enhance } from '$app/forms'; import { PART_CATEGORIES } from '$lib/constants.js'; import LocationPicker from '$lib/components/ui/LocationPicker.svelte'; + import PartLookup from '$lib/components/parts/PartLookup.svelte'; + import type { PartLookupResult } from '$lib/server/partsLookup.js'; let { data, form } = $props(); let category = $state(String(form?.values?.category ?? '')); + let name = $state(String(form?.values?.name ?? '')); + let mpn = $state(String(form?.values?.mpn ?? '')); + let value = $state(String(form?.values?.value ?? '')); + let voltage = $state(String(form?.values?.voltage ?? '')); + let tolerance = $state(String(form?.values?.tolerance ?? '')); + let power = $state(String(form?.values?.power ?? '')); + let mounting = $state(String(form?.values?.mounting ?? '')); + let pkg = $state(String(form?.values?.package ?? '')); + + function onFound(r: PartLookupResult) { + if (r.name != null) name = r.name; + if (r.mpn != null) mpn = r.mpn; + if (r.category != null) category = r.category; + if (r.value != null) value = r.value; + if (r.voltage != null) voltage = r.voltage; + if (r.tolerance != null) tolerance = r.tolerance; + if (r.power != null) power = r.power; + if (r.mounting != null) mounting = r.mounting; + if (r.package != null) pkg = r.package; + } const showCapacitance = $derived(category === 'Capacitor'); const showResistance = $derived(category === 'Resistor'); @@ -25,6 +47,10 @@
{form.error}
{/if} +
+ +
+ @@ -44,7 +70,7 @@
-
@@ -52,7 +78,7 @@
-
@@ -62,14 +88,14 @@ - {#if showVoltage}
-
@@ -77,7 +103,7 @@ {#if showTolerance}
-
@@ -85,7 +111,7 @@ {#if showPower}
-
@@ -103,13 +129,13 @@
{#each ['THT', 'SMT'] as m} {/each} @@ -117,7 +143,7 @@
-
diff --git a/src/routes/api/parts/lookup/+server.ts b/src/routes/api/parts/lookup/+server.ts new file mode 100644 index 0000000..7d8134f --- /dev/null +++ b/src/routes/api/parts/lookup/+server.ts @@ -0,0 +1,17 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { lookupPartNumber } from '$lib/server/partsLookup.js'; + +export const GET: RequestHandler = async ({ url, locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + const pn = url.searchParams.get('pn') ?? ''; + + try { + const result = await lookupPartNumber(pn); + if (result) return json({ found: true, part: result }); + return json({ found: false }); + } catch { + return json({ found: false, error: 'lookup_failed' }, { status: 502 }); + } +};