Refactor image upload handling across items and receipts routes to include validation for uploaded images, enhancing error handling and user feedback. Consolidate parsing functions by importing from a shared module, improving code maintainability. Update server hooks to ensure consistent cookie handling and error logging.
This commit is contained in:
@@ -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<PageServerLoad>[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<string, unknown> = {
|
||||
|
||||
@@ -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<PageServerLoad>[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" });
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
Status: "New",
|
||||
|
||||
+25
-11
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
Status: "New",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user