diff --git a/.svelte-kit/types/src/routes/items/[id]/proxy+page.server.ts b/.svelte-kit/types/src/routes/items/[id]/proxy+page.server.ts index 79aca696..d023e403 100644 --- a/.svelte-kit/types/src/routes/items/[id]/proxy+page.server.ts +++ b/.svelte-kit/types/src/routes/items/[id]/proxy+page.server.ts @@ -2,19 +2,11 @@ import { error, fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqItem } from "$lib/types/items"; +import { parseBool, parseNum } from "$lib/parse-form"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Items"; -function parseNum(val: unknown): number | undefined { - if (val === "" || val === null || val === undefined) return undefined; - const n = Number(val); - return Number.isFinite(n) ? n : undefined; -} - -function parseBool(val: unknown): boolean { - return val === "on" || val === "true" || val === true; -} - export const load = async ({ params, locals }: Parameters[0]) => { const id = params.id; if (!id) throw error(404, "Item not found"); @@ -52,6 +44,10 @@ export const actions = { const Stock = parseBool(form.get("Stock")); const Image = form.get("Image") as File | null; const imageFile = Image && Image.size > 0 ? Image : undefined; + if (imageFile) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, form: "update" }); + } if (!Item) return fail(400, { error: "Item name is required", form: "update" }); try { const body: Record = { diff --git a/.svelte-kit/types/src/routes/items/proxy+page.server.ts b/.svelte-kit/types/src/routes/items/proxy+page.server.ts index 128f9b61..308c870a 100644 --- a/.svelte-kit/types/src/routes/items/proxy+page.server.ts +++ b/.svelte-kit/types/src/routes/items/proxy+page.server.ts @@ -2,19 +2,11 @@ import { fail } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqItem } from "$lib/types/items"; +import { parseBool, parseNum } from "$lib/parse-form"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Items"; -function parseNum(val: unknown): number | undefined { - if (val === "" || val === null || val === undefined) return undefined; - const n = Number(val); - return Number.isFinite(n) ? n : undefined; -} - -function parseBool(val: unknown): boolean { - return val === "on" || val === "true" || val === true; -} - export const load = async ({ locals }: Parameters[0]) => { const list = await locals.pb .collection(COLLECTION) @@ -48,6 +40,10 @@ export const actions = { const Stock = parseBool(form.get("Stock")); const imageFileCreate = form.get("Image") as File | null; const hasImage = imageFileCreate && imageFileCreate.size > 0; + if (hasImage) { + const imgError = validateImageUpload(imageFileCreate); + if (imgError) return fail(400, { error: imgError, form: "create" }); + } if (!Item) return fail(400, { error: "Item name is required", form: "create" }); @@ -92,6 +88,10 @@ export const actions = { const Stock = parseBool(form.get("Stock")); const Image = form.get("Image") as File | null; const imageFile = Image && Image.size > 0 ? Image : undefined; + if (imageFile) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, form: "update" }); + } if (!Item) return fail(400, { error: "Item name is required", form: "update" }); diff --git a/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts b/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts index 494bafa9..bd7bec70 100644 --- a/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts +++ b/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts @@ -2,6 +2,7 @@ import { fail } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqReceipt } from "$lib/types/receipts"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Receipts"; @@ -31,6 +32,10 @@ export const actions = { const Type = (form.get("Type") as string)?.trim() || "Purchase"; const imageFile = form.get("Image") as File | null; const hasImage = imageFile && imageFile.size > 0; + if (hasImage) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, Type }); + } const body: Record = { Status: "New", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 215368bd..e9f03498 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,8 +1,8 @@ import { redirect } from "@sveltejs/kit"; import PocketBase from "pocketbase"; -import type { Handle } from "@sveltejs/kit"; +import type { Handle, HandleServerError } from "@sveltejs/kit"; +import { POCKETBASE_BASE_URL } from "$lib/pocketbase"; -const POCKETBASE_URL = "https://pocketbase.ccllc.pro"; const BASE_SSO_URL = process.env.BASE_SSO_URL ?? "https://base.ccllc.pro"; const LOGIN_PATH = "/login"; const LOGOUT_PATH = "/logout"; @@ -28,18 +28,20 @@ export const handle: Handle = async ({ event, resolve }) => { const cookieVal = encodeURIComponent( JSON.stringify({ token: auth.token, model: auth.model }) ); - const setCookie = `pb_auth=${cookieVal}; Path=/; SameSite=Lax; Max-Age=604800`; + const setCookie = `pb_auth=${cookieVal}; Path=/; SameSite=Lax; HttpOnly; Max-Age=604800`; return new Response(null, { status: 302, headers: { Location: event.url.pathname, "Set-Cookie": setCookie }, }); + } else { + console.error("[SSO] Exchange failed:", res.status, await res.text().catch(() => "")); } - } catch { - // fall through to normal auth + } catch (err) { + console.error("[SSO] Exchange error:", err); } } - event.locals.pb = new PocketBase(POCKETBASE_URL); + event.locals.pb = new PocketBase(POCKETBASE_BASE_URL); event.locals.pb.authStore.loadFromCookie(event.request.headers.get("cookie") || ""); try { @@ -62,9 +64,21 @@ export const handle: Handle = async ({ event, resolve }) => { } const response = await resolve(event); - response.headers.append( - "set-cookie", - event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: "Lax" }) - ); - return response; + const cookieHeader = event.locals.pb.authStore.exportToCookie({ + httpOnly: false, + sameSite: "Lax", + }); + const headers = new Headers(response.headers); + headers.append("set-cookie", cookieHeader); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +export const handleError: HandleServerError = ({ error, event }) => { + console.error("[handleError]", event.url.pathname, error); + const message = import.meta.env?.DEV ? String(error) : "Something went wrong."; + return { message }; }; diff --git a/src/lib/parse-form.ts b/src/lib/parse-form.ts new file mode 100644 index 00000000..ae1f1446 --- /dev/null +++ b/src/lib/parse-form.ts @@ -0,0 +1,19 @@ +/** + * Parse form values. Shared by items and receipts server actions. + */ + +export function parseNum(val: unknown): number | undefined { + if (val === "" || val === null || val === undefined) return undefined; + const n = Number(val); + return Number.isFinite(n) ? n : undefined; +} + +/** Like parseNum but return a number (default when empty/invalid). */ +export function parseNumWithDefault(val: unknown, defaultVal: number): number { + const n = parseNum(val); + return n ?? defaultVal; +} + +export function parseBool(val: unknown): boolean { + return val === "on" || val === "true" || val === true; +} diff --git a/src/lib/validate-image-upload.ts b/src/lib/validate-image-upload.ts new file mode 100644 index 00000000..99da43c2 --- /dev/null +++ b/src/lib/validate-image-upload.ts @@ -0,0 +1,33 @@ +/** Allowed MIME types for receipt/item image uploads (server-side check). */ +const ALLOWED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", +]); + +/** Max size 10MB. */ +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +/** + * Validates an image file for upload. Returns an error message if invalid, or null if valid/no file. + * Use in form actions before sending the file to PocketBase. + */ +export function validateImageUpload(file: File | null): string | null { + if (!file || file.size === 0) return null; + const type = (file.type ?? "").toLowerCase(); + const name = file.name.toLowerCase(); + const isHeic = + type === "image/heic" || + type === "image/heif" || + name.endsWith(".heic") || + name.endsWith(".heif"); + if (!isHeic && !ALLOWED_IMAGE_TYPES.has(type)) { + return "Invalid image type. Use JPEG, PNG, WebP, or HEIC."; + } + if (file.size > MAX_IMAGE_BYTES) { + return `Image must be under ${MAX_IMAGE_BYTES / 1024 / 1024}MB.`; + } + return null; +} diff --git a/src/routes/api/files/[...path]/+server.ts b/src/routes/api/files/[...path]/+server.ts index 7a204ace..abbc1ceb 100644 --- a/src/routes/api/files/[...path]/+server.ts +++ b/src/routes/api/files/[...path]/+server.ts @@ -1,6 +1,13 @@ import { POCKETBASE_BASE_URL } from "$lib/pocketbase"; import type { RequestHandler } from "./$types"; +/** Reject path traversal, leading slash, null bytes, and unreasonably long paths. */ +function isSafeFilePath(path: string): boolean { + if (path.length === 0 || path.length > 512) return false; + if (path.includes("..") || path.startsWith("/") || path.includes("\0")) return false; + return true; +} + function isHeicPath(path: string): boolean { const lower = path.toLowerCase(); return lower.endsWith(".heic") || lower.endsWith(".heif"); @@ -8,7 +15,7 @@ function isHeicPath(path: string): boolean { export const GET: RequestHandler = async ({ params, request }) => { const path = params.path; - if (!path) return new Response(null, { status: 404 }); + if (!path || !isSafeFilePath(path)) return new Response(null, { status: 404 }); const url = `${POCKETBASE_BASE_URL}/api/files/${path}`; const cookie = request.headers.get("cookie") ?? ""; @@ -46,7 +53,8 @@ export const GET: RequestHandler = async ({ params, request }) => { "cache-control": res.headers.get("cache-control") ?? "private, max-age=3600", }, }); - } catch { + } catch (err) { + console.error("[api/files] HEIC conversion failed:", path, err); return new Response(null, { status: 500 }); } } diff --git a/src/routes/items/+page.server.ts b/src/routes/items/+page.server.ts index a1921a6d..ee85f455 100644 --- a/src/routes/items/+page.server.ts +++ b/src/routes/items/+page.server.ts @@ -1,19 +1,11 @@ import { fail } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqItem } from "$lib/types/items"; +import { parseBool, parseNum } from "$lib/parse-form"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Items"; -function parseNum(val: unknown): number | undefined { - if (val === "" || val === null || val === undefined) return undefined; - const n = Number(val); - return Number.isFinite(n) ? n : undefined; -} - -function parseBool(val: unknown): boolean { - return val === "on" || val === "true" || val === true; -} - export const load: PageServerLoad = async ({ locals }) => { const list = await locals.pb .collection(COLLECTION) @@ -47,6 +39,10 @@ export const actions: Actions = { const Stock = parseBool(form.get("Stock")); const imageFileCreate = form.get("Image") as File | null; const hasImage = imageFileCreate && imageFileCreate.size > 0; + if (hasImage) { + const imgError = validateImageUpload(imageFileCreate); + if (imgError) return fail(400, { error: imgError, form: "create" }); + } if (!Item) return fail(400, { error: "Item name is required", form: "create" }); @@ -91,6 +87,10 @@ export const actions: Actions = { const Stock = parseBool(form.get("Stock")); const Image = form.get("Image") as File | null; const imageFile = Image && Image.size > 0 ? Image : undefined; + if (imageFile) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, form: "update" }); + } if (!Item) return fail(400, { error: "Item name is required", form: "update" }); diff --git a/src/routes/items/[id]/+page.server.ts b/src/routes/items/[id]/+page.server.ts index 4d661077..93076181 100644 --- a/src/routes/items/[id]/+page.server.ts +++ b/src/routes/items/[id]/+page.server.ts @@ -1,19 +1,11 @@ import { error, fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqItem } from "$lib/types/items"; +import { parseBool, parseNum } from "$lib/parse-form"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Items"; -function parseNum(val: unknown): number | undefined { - if (val === "" || val === null || val === undefined) return undefined; - const n = Number(val); - return Number.isFinite(n) ? n : undefined; -} - -function parseBool(val: unknown): boolean { - return val === "on" || val === "true" || val === true; -} - export const load: PageServerLoad = async ({ params, locals }) => { const id = params.id; if (!id) throw error(404, "Item not found"); @@ -51,6 +43,10 @@ export const actions: Actions = { const Stock = parseBool(form.get("Stock")); const Image = form.get("Image") as File | null; const imageFile = Image && Image.size > 0 ? Image : undefined; + if (imageFile) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, form: "update" }); + } if (!Item) return fail(400, { error: "Item name is required", form: "update" }); try { const body: Record = { diff --git a/src/routes/receipts/+page.server.ts b/src/routes/receipts/+page.server.ts index 87a4a377..ba01ee3d 100644 --- a/src/routes/receipts/+page.server.ts +++ b/src/routes/receipts/+page.server.ts @@ -1,6 +1,7 @@ import { fail } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqReceipt } from "$lib/types/receipts"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Receipts"; @@ -30,6 +31,10 @@ export const actions: Actions = { const Type = (form.get("Type") as string)?.trim() || "Purchase"; const imageFile = form.get("Image") as File | null; const hasImage = imageFile && imageFile.size > 0; + if (hasImage) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, Type }); + } const body: Record = { Status: "New", diff --git a/src/routes/receipts/[id]/+page.server.ts b/src/routes/receipts/[id]/+page.server.ts index e0b21941..644c6894 100644 --- a/src/routes/receipts/[id]/+page.server.ts +++ b/src/routes/receipts/[id]/+page.server.ts @@ -2,17 +2,14 @@ import { error, fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqReceipt } from "$lib/types/receipts"; import type { ReceiptDataShape } from "$lib/types/receipts"; +import { parseNumWithDefault } from "$lib/parse-form"; +import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Receipts"; const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const; const TYPE_OPTIONS = ["Purchase", "Gas"] as const; - -function parseNum(val: unknown): number { - if (val === "" || val === null || val === undefined) return 0; - const n = Number(val); - return Number.isFinite(n) ? n : 0; -} +const MAX_LINE_ITEMS = 500; export const load: PageServerLoad = async ({ params, locals }) => { const { id } = params; @@ -43,6 +40,10 @@ export const actions: Actions = { const dataJson = (form.get("dataJson") as string)?.trim() ?? "{}"; const imageFile = form.get("Image") as File | null; const hasImage = imageFile && imageFile.size > 0; + if (hasImage) { + const imgError = validateImageUpload(imageFile); + if (imgError) return fail(400, { error: imgError, form: "update" }); + } let Data: ReceiptDataShape = {}; try { @@ -50,26 +51,27 @@ export const actions: Actions = { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const o = parsed as Record; if (Array.isArray(o.lineItems)) { - Data.lineItems = o.lineItems.filter( + const filtered = o.lineItems.filter( (row: unknown): row is { item: string; description: string; quantity: number; price: number } => row != null && typeof row === "object" && "item" in row && "quantity" in row && "price" in row - ).map((row) => ({ + ); + Data.lineItems = filtered.slice(0, MAX_LINE_ITEMS).map((row) => ({ item: String(row.item ?? ""), description: String(row.description ?? ""), - quantity: parseNum(row.quantity), - price: parseNum(row.price), + quantity: parseNumWithDefault(row.quantity, 0), + price: parseNumWithDefault(row.price, 0), })); } if (typeof o.tax === "number" && Number.isFinite(o.tax)) Data.tax = o.tax; - else if (typeof o.tax === "string") Data.tax = parseNum(o.tax); + else if (typeof o.tax === "string") Data.tax = parseNumWithDefault(o.tax, 0); if (typeof o.receiptTotal === "number" && Number.isFinite(o.receiptTotal)) { Data.receiptTotal = o.receiptTotal; } else if (typeof o.receiptTotal === "string") { - Data.receiptTotal = parseNum(o.receiptTotal); + Data.receiptTotal = parseNumWithDefault(o.receiptTotal, 0); } } } catch {