Implement support for HEIC/HEIF image formats and increase body size limit to 3M

This commit is contained in:
Raj Nandan Sharma
2026-03-19 12:28:58 +05:30
parent 0b2cd5fc8a
commit a8841ad8a3
10 changed files with 216 additions and 94 deletions
+1
View File
@@ -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"
+3
View File
@@ -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..."
+59
View File
@@ -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",
+2
View File
@@ -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",
+9 -1
View File
@@ -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,6 +12,11 @@ import knexOb from "../knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
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");
const app: any = express();
const db = knex(knexOb);
@@ -91,3 +96,6 @@ async function gracefulShutdown(signal: string) {
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}
start();
@@ -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.
+24 -5
View File
@@ -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<string, string> = {
@@ -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",
@@ -204,7 +204,7 @@
<input
id="monitor-image-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={handleImageUpload}
disabled={uploadingImage}
@@ -86,7 +86,8 @@
// Page settings state
let pageSettings = $state<PageSettingsType>(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 @@
<input
id="page-logo-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={handleLogoUpload}
disabled={uploadingLogo}
@@ -830,8 +841,8 @@
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={savePageSettings} disabled={savingSettings}>
{#if savingSettings}
<Button onclick={() => savePageSettings("display")} disabled={savingDisplaySettings}>
{#if savingDisplaySettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
@@ -885,7 +896,7 @@
<input
id="page-social-preview-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={handleSocialPreviewUpload}
disabled={uploadingSocialPreview}
@@ -926,8 +937,8 @@
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={savePageSettings} disabled={savingSettings}>
{#if savingSettings}
<Button onclick={() => savePageSettings("seo")} disabled={savingSeoSettings}>
{#if savingSeoSettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
@@ -792,7 +792,7 @@
<input
id="logo-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "logo")}
disabled={uploadingLogo}
@@ -862,7 +862,7 @@
<input
id="favicon-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "favicon")}
disabled={uploadingFavicon}
@@ -932,7 +932,7 @@
<input
id="social-preview-image-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "socialPreviewImage")}
disabled={uploadingSocialPreviewImage}
@@ -1028,7 +1028,7 @@
<input
id="nav-icon-input-{index}"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleNavIconUpload(e, index)}
/>