mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
Implement support for HEIC/HEIF image formats and increase body size limit to 3M
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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..."
|
||||
|
||||
Generated
+59
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+27
-19
@@ -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,17 +12,22 @@ 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) => {
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
});
|
||||
|
||||
app.use(handler);
|
||||
app.use(handler);
|
||||
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
//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
|
||||
@@ -42,10 +47,10 @@ async function runMigrations() {
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//seed
|
||||
async function runSeed() {
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
@@ -53,18 +58,18 @@ async function runSeed() {
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
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) {
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
try {
|
||||
@@ -86,8 +91,11 @@ async function gracefulShutdown(signal: string) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user