Close mutating-action auth bypass; add LCSC part-number autofill
Deploy to LXC / deploy (push) Failing after 13m2s

Form actions and API endpoints under (app) previously relied only on
the layout load's redirect, which SvelteKit never runs before POST
actions/+server.ts handlers -- an unauthenticated request could reach
any mutating action directly. Gate it centrally in hooks.server.ts
instead of patching ~20 files individually.

Also adds a part-number lookup box on Add/Edit Part: paste an LCSC
code (e.g. C25804) and category/value/voltage/tolerance/mounting/
package auto-fill from LCSC's product data. Digikey is wired as a
dispatcher branch, inactive until DIGIKEY_CLIENT_ID/SECRET are set.
This commit is contained in:
2026-07-01 11:23:22 +07:00
parent dd2440db9f
commit 01107c414e
8 changed files with 372 additions and 20 deletions
+17
View File
@@ -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);
};
@@ -0,0 +1,82 @@
<script lang="ts">
import type { PartLookupResult } from '$lib/server/partsLookup.js';
interface Props {
onFound: (result: PartLookupResult) => void;
}
let { onFound }: Props = $props();
let value = $state('');
let loading = $state(false);
let message = $state('');
let messageType = $state<'success' | 'error' | ''>('');
async function lookup() {
const pn = value.trim();
if (!pn || loading) return;
loading = true;
message = '';
messageType = '';
try {
const res = await fetch('/api/parts/lookup?pn=' + encodeURIComponent(pn));
const data = await res.json();
if (data.found && data.part) {
onFound(data.part);
message = 'Auto-filled from LCSC — you can edit any field below.';
messageType = 'success';
} else {
message = 'No match found for that part number — you can still fill in the fields manually.';
messageType = 'error';
}
} catch {
message = 'Lookup failed — check your connection and try again.';
messageType = 'error';
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
lookup();
}
}
</script>
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<label for="part-lookup" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Part number lookup
</label>
<div class="flex gap-2">
<input
type="text"
id="part-lookup"
bind:value
onkeydown={handleKeydown}
placeholder="e.g. C25804 (LCSC)"
autocomplete="off"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
/>
<button
type="button"
onclick={lookup}
disabled={loading}
class="shrink-0 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Looking up…' : 'Look up'}
</button>
</div>
{#if message}
<p
class="mt-2 text-sm {messageType === 'success'
? 'text-green-600 dark:text-green-400'
: 'text-amber-600 dark:text-amber-400'}"
>
{message}
</p>
{/if}
</div>
+78
View File
@@ -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<string | null> {
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<PartLookupResult | null> {
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;
}
}
+79
View File
@@ -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<PartLookupResult | null> {
// 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;
}
}
+27
View File
@@ -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<PartLookupResult | null> {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^C\d+$/i.test(trimmed)) {
return lookupLcscPart(trimmed);
}
return lookupDigikeyPart(trimmed);
}
+37 -11
View File
@@ -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] ?? '';
</script>
<svelte:head>
@@ -27,6 +49,10 @@
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
<div class="mb-5">
<PartLookup {onFound} />
</div>
<form method="POST" use:enhance class="space-y-5">
<!-- Identity -->
@@ -45,14 +71,14 @@
</div>
<div>
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
<input type="text" id="mpn" name="mpn" value={val('mpn')}
<input type="text" id="mpn" name="mpn" bind:value={mpn}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" id="name" name="name" value={val('name')}
<input type="text" id="name" name="name" bind:value={name}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
@@ -61,27 +87,27 @@
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
</label>
<input type="text" id="value" name="value" value={val('value')}
<input type="text" id="value" name="value" bind:value
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
{#if showVoltage}
<div>
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
<input type="text" id="voltage" name="voltage" value={val('voltage')}
<input type="text" id="voltage" name="voltage" bind:value={voltage}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
{/if}
{#if showTolerance}
<div>
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
<input type="text" id="tolerance" name="tolerance" value={val('tolerance')}
<input type="text" id="tolerance" name="tolerance" bind:value={tolerance}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
{/if}
{#if showPower}
<div>
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
<input type="text" id="power" name="power" value={val('power')}
<input type="text" id="power" name="power" bind:value={power}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
{/if}
@@ -97,13 +123,13 @@
<div class="flex gap-3">
{#each ['THT', 'SMT'] as m}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="radio" name="mounting" value={m} checked={val('mounting') === m}
<input type="radio" name="mounting" value={m} bind:group={mounting}
class="text-blue-600 focus:ring-blue-500" />
{m}
</label>
{/each}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<input type="radio" name="mounting" value="" checked={!val('mounting')}
<input type="radio" name="mounting" value="" bind:group={mounting}
class="text-blue-600 focus:ring-blue-500" />
Unknown
</label>
@@ -111,7 +137,7 @@
</div>
<div>
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
<input type="text" id="package" name="package" value={val('package')}
<input type="text" id="package" name="package" bind:value={pkg}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
</div>
+35 -9
View File
@@ -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 @@
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
<div class="mb-5">
<PartLookup {onFound} />
</div>
<form method="POST" use:enhance class="space-y-5">
<!-- Identity -->
@@ -44,7 +70,7 @@
</div>
<div>
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
<input type="text" id="mpn" name="mpn" value={form?.values?.mpn ?? ''}
<input type="text" id="mpn" name="mpn" bind:value={mpn}
placeholder="Manufacturer part number"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
@@ -52,7 +78,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" id="name" name="name" value={form?.values?.name ?? ''}
<input type="text" id="name" name="name" bind:value={name}
placeholder="e.g. Nichicon electrolytic, BC547 NPN"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
@@ -62,14 +88,14 @@
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
</label>
<input type="text" id="value" name="value" value={form?.values?.value ?? ''}
<input type="text" id="value" name="value" bind:value
placeholder={showCapacitance ? 'e.g. 100µF' : showResistance ? 'e.g. 10kΩ' : 'e.g. 1N4148'}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
{#if showVoltage}
<div>
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
<input type="text" id="voltage" name="voltage" value={form?.values?.voltage ?? ''}
<input type="text" id="voltage" name="voltage" bind:value={voltage}
placeholder="e.g. 16V, 50V"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
@@ -77,7 +103,7 @@
{#if showTolerance}
<div>
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
<input type="text" id="tolerance" name="tolerance" value={form?.values?.tolerance ?? ''}
<input type="text" id="tolerance" name="tolerance" bind:value={tolerance}
placeholder="e.g. 5%, 10%, 20%"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
@@ -85,7 +111,7 @@
{#if showPower}
<div>
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
<input type="text" id="power" name="power" value={form?.values?.power ?? ''}
<input type="text" id="power" name="power" bind:value={power}
placeholder="e.g. 0.25W, 1W"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
@@ -103,13 +129,13 @@
<div class="flex gap-3">
{#each ['THT', 'SMT'] as m}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="radio" name="mounting" value={m} checked={form?.values?.mounting === m}
<input type="radio" name="mounting" value={m} bind:group={mounting}
class="text-blue-600 focus:ring-blue-500" />
{m}
</label>
{/each}
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<input type="radio" name="mounting" value="" checked={!form?.values?.mounting}
<input type="radio" name="mounting" value="" bind:group={mounting}
class="text-blue-600 focus:ring-blue-500" />
Unknown
</label>
@@ -117,7 +143,7 @@
</div>
<div>
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
<input type="text" id="package" name="package" value={form?.values?.package ?? ''}
<input type="text" id="package" name="package" bind:value={pkg}
placeholder="e.g. 0805, DIP-8, TO-92, Radial 5mm"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
+17
View File
@@ -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 });
}
};