diff --git a/Dockerfile b/Dockerfile index c136d4cb..916cedee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -135,6 +135,7 @@ ARG KENER_BASE_PATH= ENV NODE_ENV=production \ PORT=${PORT} \ KENER_BASE_PATH=${KENER_BASE_PATH} \ + BODY_SIZE_LIMIT=3M \ TZ=UTC \ # Required so Node can import .ts migration/seed files at runtime NODE_OPTIONS="--experimental-strip-types" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5fe87ab4..437a33ba 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e +# Default body size limit for SvelteKit adapter-node (512K default is too small for image uploads) +export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-3M}" + # Index documentation into Redis when docs are bundled in the image if [ -f /app/scripts/index-docs.ts ]; then echo "[kener] Indexing documentation into Redis..." diff --git a/package-lock.json b/package-lock.json index 5e5ee37d..9b6a9ff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "front-matter": "^4.0.2", "gamedig": "^5.3.2", "glob": "^13.0.6", + "heic-convert": "^2.1.0", "highlight.js": "^11.11.1", "ioredis": "^5.8.2", "js-yaml": "^4.1.1", @@ -97,6 +98,7 @@ "@types/d3-shape": "^3.1.8", "@types/dns2": "^2.0.10", "@types/express": "^5.0.6", + "@types/heic-convert": "^2.1.0", "@types/jsonwebtoken": "^9.0.10", "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", @@ -2756,6 +2758,13 @@ "@types/send": "*" } }, + "node_modules/@types/heic-convert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/heic-convert/-/heic-convert-2.1.0.tgz", + "integrity": "sha512-Cf5Sdc2Gm2pfZ0uN1zjj35wcf3mF1lJCMIzws5OdJynrdMJRTIRUGa5LegbVg0hatzOPkH2uAf2JRjPYgl9apg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -5760,6 +5769,32 @@ "he": "bin/he" } }, + "node_modules/heic-convert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz", + "integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==", + "license": "ISC", + "dependencies": { + "heic-decode": "^2.0.0", + "jpeg-js": "^0.4.4", + "pngjs": "^6.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -6399,6 +6434,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -6638,6 +6679,15 @@ "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", "license": "MIT" }, + "node_modules/libheif-js": { + "version": "1.19.8", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz", + "integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/libmime": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", @@ -8253,6 +8303,15 @@ "node": ">=22.0.0" } }, + "node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 3b5b71f8..584f54c3 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@types/d3-shape": "^3.1.8", "@types/dns2": "^2.0.10", "@types/express": "^5.0.6", + "@types/heic-convert": "^2.1.0", "@types/jsonwebtoken": "^9.0.10", "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", @@ -139,6 +140,7 @@ "front-matter": "^4.0.2", "gamedig": "^5.3.2", "glob": "^13.0.6", + "heic-convert": "^2.1.0", "highlight.js": "^11.11.1", "ioredis": "^5.8.2", "js-yaml": "^4.1.1", diff --git a/scripts/main.ts b/scripts/main.ts index a84fda83..6b7475d8 100644 --- a/scripts/main.ts +++ b/scripts/main.ts @@ -1,6 +1,6 @@ -import { handler } from "../build/handler.js"; import dotenv from "dotenv"; dotenv.config(); + import express from "express"; import Startup from "../src/lib/server/startup.ts"; import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts"; @@ -12,82 +12,90 @@ import knexOb from "../knexfile.js"; const PORT = process.env.PORT || 3000; const base = process.env.KENER_BASE_PATH || ""; -const app: any = express(); -const db = knex(knexOb); +async function start() { + // Dynamic import so BODY_SIZE_LIMIT from .env is available + // before the handler reads it at module top-level + const { handler } = await import("../build/handler.js"); -app.get(base + "/healthcheck", (req: any, res: any) => { - res.end("ok"); -}); + const app: any = express(); + const db = knex(knexOb); -app.use(handler); + app.get(base + "/healthcheck", (req: any, res: any) => { + res.end("ok"); + }); -//migrations -async function runMigrations() { - try { - // Rename old .js migration entries to .ts in the knex_migrations table - // so Knex can find the renamed files on disk - const hasTable = await db.schema.hasTable("knex_migrations"); - if (hasTable) { - const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js"); - for (const row of oldJsMigrations) { - const newName = row.name.replace(/\.js$/, ".ts"); - await db("knex_migrations").where("id", row.id).update({ name: newName }); - console.log(`Renamed migration record: ${row.name} -> ${newName}`); - } - } + app.use(handler); - console.log("Running migrations..."); - await db.migrate.latest(); // Runs migrations to the latest state - console.log("Migrations completed successfully!"); - } catch (err) { - console.error("Error running migrations:", err); - } + //migrations + async function runMigrations() { + try { + // Rename old .js migration entries to .ts in the knex_migrations table + // so Knex can find the renamed files on disk + const hasTable = await db.schema.hasTable("knex_migrations"); + if (hasTable) { + const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js"); + for (const row of oldJsMigrations) { + const newName = row.name.replace(/\.js$/, ".ts"); + await db("knex_migrations").where("id", row.id).update({ name: newName }); + console.log(`Renamed migration record: ${row.name} -> ${newName}`); + } + } + + console.log("Running migrations..."); + await db.migrate.latest(); // Runs migrations to the latest state + console.log("Migrations completed successfully!"); + } catch (err) { + console.error("Error running migrations:", err); + } + } + + //seed + async function runSeed() { + try { + console.log("Running seed..."); + await db.seed.run(); // Runs seed to the latest state + console.log("Seed completed successfully!"); + } catch (err) { + console.error("Error running seed:", err); + } + } + + app.listen(PORT, async () => { + await runMigrations(); + await runSeed(); + await db.destroy(); + Startup(); + console.log("Kener is running on port " + PORT + "!"); + }); + + // Graceful shutdown handler + async function gracefulShutdown(signal: string) { + console.log(`\nReceived ${signal}. Starting graceful shutdown...`); + + try { + console.log("Shutting down schedulers..."); + await shutdownSchedulers(); + console.log("Schedulers shut down successfully."); + + console.log("Shutting down queues..."); + await shutdownQueues(); + console.log("Queues shut down successfully."); + + console.log("Closing database connection..."); + await dbInstance.close(); + console.log("Database connection closed successfully."); + + console.log("Graceful shutdown completed."); + process.exit(0); + } catch (err) { + console.error("Error during graceful shutdown:", err); + process.exit(1); + } + } + + // Handle termination signals + process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); + process.on("SIGINT", () => gracefulShutdown("SIGINT")); } -//seed -async function runSeed() { - try { - console.log("Running seed..."); - await db.seed.run(); // Runs seed to the latest state - console.log("Seed completed successfully!"); - } catch (err) { - console.error("Error running seed:", err); - } -} - -app.listen(PORT, async () => { - await runMigrations(); - await runSeed(); - await db.destroy(); - Startup(); - console.log("Kener is running on port " + PORT + "!"); -}); - -// Graceful shutdown handler -async function gracefulShutdown(signal: string) { - console.log(`\nReceived ${signal}. Starting graceful shutdown...`); - - try { - console.log("Shutting down schedulers..."); - await shutdownSchedulers(); - console.log("Schedulers shut down successfully."); - - console.log("Shutting down queues..."); - await shutdownQueues(); - console.log("Queues shut down successfully."); - - console.log("Closing database connection..."); - await dbInstance.close(); - console.log("Database connection closed successfully."); - - console.log("Graceful shutdown completed."); - process.exit(0); - } catch (err) { - console.error("Error during graceful shutdown:", err); - process.exit(1); - } -} - -// Handle termination signals -process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); -process.on("SIGINT", () => gracefulShutdown("SIGINT")); +start(); diff --git a/src/routes/(docs)/docs/content/v4/setup/environment-variables.md b/src/routes/(docs)/docs/content/v4/setup/environment-variables.md index f1d4246f..27a5b9f6 100644 --- a/src/routes/(docs)/docs/content/v4/setup/environment-variables.md +++ b/src/routes/(docs)/docs/content/v4/setup/environment-variables.md @@ -178,6 +178,25 @@ NODE_ENV=production - Controls logging verbosity - Enables/disables development-only features +### BODY_SIZE_LIMIT {#body-size-limit} + +**Purpose**: Maximum allowed request body size. This is a SvelteKit setting that controls the largest payload the server will accept. + +**Default**: `3M` (3 megabytes) + +**When to Change**: Increase if you need to upload larger images through the admin panel or send larger API payloads. + +**Format**: Number followed by unit — `K` (kilobytes), `M` (megabytes), or `Infinity` for no limit. + +**Example**: + +```bash +BODY_SIZE_LIMIT=10M +``` + +> [!NOTE] +> The Docker image sets `BODY_SIZE_LIMIT=3M` by default. Override it by passing the variable to your container. + ## Integration Variables {#integration-variables} For detailed configuration of these integrations, see their dedicated documentation pages. diff --git a/src/routes/(manage)/manage/api/+server.ts b/src/routes/(manage)/manage/api/+server.ts index 32b685ac..f46ec983 100644 --- a/src/routes/(manage)/manage/api/+server.ts +++ b/src/routes/(manage)/manage/api/+server.ts @@ -118,6 +118,7 @@ import sendWebhook from "$lib/server/notification/webhook_notification.js"; import sendEmail from "$lib/server/notification/email_notification.js"; import sendDiscord from "$lib/server/notification/discord_notification.js"; import sendSlack from "$lib/server/notification/slack_notification.js"; +import heicConvert from "heic-convert"; import serverResolver from "$lib/server/resolver.js"; function AdminCan(role: string) { @@ -716,7 +717,7 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st throw new Error("Image data is required"); } - const allowedMimeTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; + const allowedMimeTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/heic", "image/heif"]; if (!allowedMimeTypes.includes(mimeType)) { throw new Error(`Invalid image type. Allowed types: ${allowedMimeTypes.join(", ")}`); } @@ -744,8 +745,21 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st let width: number | undefined; let height: number | undefined; + // Pre-convert HEIC/HEIF to JPEG before passing to sharp (sharp may lack HEVC codec) + let sharpInputBuffer = imageBuffer; + const heicSignature = imageBuffer.subarray(4, 12).toString("ascii"); + const isHeicData = heicSignature.includes("ftyp"); + if (isHeicData) { + const converted = await heicConvert({ + buffer: new Uint8Array(imageBuffer) as unknown as ArrayBuffer, + format: "JPEG", + quality: 0.85, + }); + sharpInputBuffer = Buffer.from(converted); + } + // Process with sharp and normalize output - const image = sharp(imageBuffer, { limitInputPixels: GC.MAX_INPUT_PIXELS }); + const image = sharp(sharpInputBuffer, { limitInputPixels: GC.MAX_INPUT_PIXELS }); const metadata = await image.metadata(); const formatToMime: Record = { @@ -753,6 +767,8 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml", + heic: "image/heic", + heif: "image/heif", }; const detectedMimeType = metadata.format ? formatToMime[metadata.format] : undefined; @@ -764,7 +780,10 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st throw new Error("SVG uploads are not allowed"); } - if (normalizedRequestedMime !== detectedMimeType) { + // HEIC/HEIF files often have .jpg extension (e.g. iPhone photos); allow the mismatch + const isHeicDetected = detectedMimeType === "image/heic" || detectedMimeType === "image/heif"; + const isHeicRequested = normalizedRequestedMime === "image/heic" || normalizedRequestedMime === "image/heif"; + if (normalizedRequestedMime !== detectedMimeType && !isHeicDetected && !isHeicRequested) { throw new Error("Image MIME type does not match file content"); } @@ -796,8 +815,8 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st width = newWidth; height = newHeight; - // Keep JPEG as JPEG; convert everything else (WebP/PNG) to PNG. - if (detectedMimeType === "image/jpeg") { + // Keep JPEG as JPEG; convert HEIC/HEIF to JPEG; convert everything else (WebP/PNG) to PNG. + if (detectedMimeType === "image/jpeg" || isHeicDetected) { processedBuffer = await image .resize(newWidth, newHeight, { fit: forceDimensions ? "cover" : "inside", diff --git a/src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte b/src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte index 2344f0a7..f4ce0983 100644 --- a/src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte +++ b/src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte @@ -204,7 +204,7 @@ (structuredClone(defaultPageSettings)); - let savingSettings = $state(false); + let savingDisplaySettings = $state(false); + let savingSeoSettings = $state(false); // Validation const isFormValid = $derived(formData.page_title.trim().length > 0 && formData.page_header.trim().length > 0); @@ -381,6 +382,10 @@ }) }); + if (!response.ok) { + toast.error("Failed to upload logo"); + return; + } const result = await response.json(); if (result.error) { toast.error(result.error); @@ -452,6 +457,10 @@ }) }); + if (!response.ok) { + toast.error("Failed to upload social preview image"); + return; + } const result = await response.json(); if (result.error) { toast.error(result.error); @@ -467,10 +476,11 @@ } } - async function savePageSettings() { + async function savePageSettings(source: "display" | "seo") { if (!currentPage) return; - savingSettings = true; + if (source === "display") savingDisplaySettings = true; + else savingSeoSettings = true; try { const response = await fetch(clientResolver(resolve, "/manage/api"), { method: "POST", @@ -493,7 +503,8 @@ } catch (e) { toast.error("Failed to save page settings"); } finally { - savingSettings = false; + if (source === "display") savingDisplaySettings = false; + else savingSeoSettings = false; } } @@ -635,7 +646,7 @@ -