Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ yarn-error.log
# System Files
.DS_Store
Thumbs.db

# kip-plugin test scratch dirs
/.tmp-kip*
78 changes: 78 additions & 0 deletions kip-plugin/src/images/image-processing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import sharp from 'sharp';
import convert from 'heic-convert';
import type { ImageFormat } from './image-store';

/**
* Pure image-processing logic shared by the in-process processor and the worker-thread script.
* Converts/resizes a validated original to WebP, preserving animation (GIF/WebP) and decoding
* HEIC/HEIF via the portable pure-JS heic-convert (so we don't depend on libheif in the sharp build).
*/

const MAX_INPUT_PIXELS = 50_000_000;
const WEBP_QUALITY = 80;

/** Allowed output widths. Requests are snapped to one of these to bound the on-disk cache. */
export const WIDTH_ALLOWLIST: readonly number[] = [160, 320, 640, 960, 1280, 1920, 2560];
export const CANONICAL_WIDTH = WIDTH_ALLOWLIST[WIDTH_ALLOWLIST.length - 1];

/** Snap a requested width up to the nearest allow-listed width (canonical when unset/invalid). */
export function snapWidth(requested?: number | null): number {
if (!requested || !Number.isFinite(requested) || requested <= 0) return CANONICAL_WIDTH;
for (const w of WIDTH_ALLOWLIST) {
if (w >= requested) return w;
}
return CANONICAL_WIDTH;
}

/** Worker pool size: n-1 CPUs to keep the server responsive, clamped to at least 1. */
export function computeWorkerCount(cpuCount: number): number {
return Math.max(1, (Number.isFinite(cpuCount) ? cpuCount : 1) - 1);
}

export interface ProcessRequest {
buffer: Buffer;
format: ImageFormat; // detected source format (never 'svg' here)
width: number; // already snapped to the allow-list
animated: boolean;
}

export interface ProcessResult {
buffer: Buffer; // WebP bytes
width: number;
height: number;
}

export interface ImageProcessor {
process(req: ProcessRequest, coalesceKey?: string): Promise<ProcessResult>;
}

/** Convert + resize a raster original to a WebP variant. */
export async function processImage(req: ProcessRequest): Promise<ProcessResult> {
let input = req.buffer;

if (req.format === 'heic') {
// Decode HEIC/HEIF to JPEG first (pure JS), then hand to sharp.
const decoded = await convert({ buffer: input as unknown as ArrayBufferLike, format: 'JPEG', quality: 0.92 });
input = Buffer.from(decoded);
}

let pipe = sharp(input, { limitInputPixels: MAX_INPUT_PIXELS, animated: req.animated });
if (!req.animated) {
pipe = pipe.rotate(); // auto-orient static images from EXIF
}
pipe = pipe
.resize({ width: req.width, fit: 'inside', withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY, effort: 4 });

const { data, info } = await pipe.toBuffer({ resolveWithObject: true });
return {
buffer: data,
width: info.width,
height: (info as { pageHeight?: number }).pageHeight ?? info.height
};
}

/** A processor that runs jobs inline (used in tests and as a no-worker fallback). */
export const inProcessProcessor: ImageProcessor = {
process: (req) => processImage(req)
};
161 changes: 161 additions & 0 deletions kip-plugin/src/images/image-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { IRouter, Request, Response } from 'express';
import multer from 'multer';
import { ImageStore, ImageValidationError, MAX_UPLOAD_BYTES } from './image-store';

/**
* Signal K does not expose a request principal in its public plugin API, but its security
* middleware augments authenticated requests with `skPrincipal` at runtime. We treat:
* - principal present with an identifier => authenticated (allowed)
* - security configured but no principal => anonymous (rejected)
* - no security signals at all => security disabled / no users => allowed
* SK's own middleware is the primary gate (it already protects the existing write routes); this is
* a defensive in-handler check. Verify against a secured server during the e2e step.
*/
interface SkRequest extends Request {
skPrincipal?: { identifier?: string } | null;
skIsAuthenticated?: boolean;
}

export function isAuthenticatedRequest(req: SkRequest): boolean {
if (req.skPrincipal === undefined && req.skIsAuthenticated === undefined) {
return true; // security disabled (no users) — consistent with the plugin's other write routes
}
return Boolean(req.skPrincipal && req.skPrincipal.identifier) || req.skIsAuthenticated === true;
}

function principalId(req: SkRequest): string | null {
return (req.skPrincipal && req.skPrincipal.identifier) || null;
}

const ID_RE = /^[A-Za-z0-9-]+$/;

function sendJson(res: Response, status: number, body: unknown): void {
res.status(status).json(body);
}
function sendError(res: Response, status: number, message: string): void {
res.status(status).json({ error: message });
}

export interface ImageRouterDeps {
/** Lazily resolve the store (the data dir is only known after the plugin initializes). */
resolveStore: () => ImageStore | null;
isAuthenticated?: (req: Request) => boolean;
log?: (msg: string) => void;
}

/** Register the image-asset routes on the plugin's Express router (mounted at /plugins/kip). */
export function registerImageRoutes(router: IRouter, deps: ImageRouterDeps): void {
const isAuth = deps.isAuthenticated ?? isAuthenticatedRequest;
const getStore = (res: Response): ImageStore | null => {
const store = deps.resolveStore();
if (!store) sendError(res, 503, 'Image service is not ready');
return store;
};
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD_BYTES, files: 1 } });
const single = upload.single('file');

// POST /images — upload (auth required; auth is checked BEFORE multipart parsing).
router.post('/images', (req: Request, res: Response) => {
if (!isAuth(req)) return sendError(res, 401, 'Login required to upload images');
single(req, res, (err: unknown) => {
void (async () => {
if (err) {
const code = (err as { code?: string }).code;
if (code === 'LIMIT_FILE_SIZE') return sendError(res, 413, `File exceeds ${MAX_UPLOAD_BYTES} byte limit`);
return sendError(res, 400, `Upload failed: ${(err as Error).message}`);
}
const file = (req as Request & { file?: { buffer: Buffer; originalname: string } }).file;
if (!file || !file.buffer) return sendError(res, 400, 'No file provided (expected form field "file")');
const store = getStore(res);
if (!store) return;
try {
const meta = await store.ingest(file.buffer, file.originalname, principalId(req as SkRequest));
return sendJson(res, 201, { ...meta, url: `images/${meta.id}` });
} catch (e) {
if (e instanceof ImageValidationError) return sendError(res, 415, e.message);
deps.log?.(`[KIP][images] ingest error: ${(e as Error).message}`);
return sendError(res, 500, 'Failed to store image');
}
})();
});
});

// GET /images — list the shared library.
router.get('/images', (_req: Request, res: Response) => {
const store = getStore(res);
if (!store) return;
void (async () => {
try {
res.json(await store.list());
} catch {
sendError(res, 500, 'Failed to list images');
}
})();
});

// Cache routes MUST be registered before /images/:id so "cache" is not matched as an id.
router.get('/images/cache', (_req: Request, res: Response) => {
const store = getStore(res);
if (!store) return;
void (async () => {
try {
res.json(await store.cacheStats());
} catch {
sendError(res, 500, 'Failed to read cache stats');
}
})();
});

router.delete('/images/cache', (req: Request, res: Response) => {
if (!isAuth(req)) return sendError(res, 401, 'Login required to purge the image cache');
const store = getStore(res);
if (!store) return;
void (async () => {
try {
await store.purgeCache();
res.json({ ok: true });
} catch {
sendError(res, 500, 'Failed to purge cache');
}
})();
});

// GET /images/:id?w= — serve a variant (raster re-encoded to WebP) or sanitized SVG.
router.get('/images/:id', (req: Request, res: Response) => {
const id = String(req.params.id ?? '');
if (!ID_RE.test(id)) return sendError(res, 400, 'Invalid image id');
const rawW = req.query.w;
const width = typeof rawW === 'string' && rawW.trim() !== '' ? Number(rawW) : undefined;
const store = getStore(res);
if (!store) return;
void (async () => {
try {
const servable = await store.getServable(id, Number.isFinite(width) ? width : undefined);
if (!servable) return sendError(res, 404, 'Image not found');
for (const [k, v] of Object.entries(servable.headers)) res.setHeader(k, v);
res.status(200).send(servable.buffer);
} catch (e) {
deps.log?.(`[KIP][images] serve error: ${(e as Error).message}`);
sendError(res, 500, 'Failed to render image');
}
})();
});

// DELETE /images/:id — remove an image (auth required).
router.delete('/images/:id', (req: Request, res: Response) => {
if (!isAuth(req)) return sendError(res, 401, 'Login required to delete images');
const id = String(req.params.id ?? '');
if (!ID_RE.test(id)) return sendError(res, 400, 'Invalid image id');
const store = getStore(res);
if (!store) return;
void (async () => {
try {
const removed = await store.remove(id);
if (!removed) return sendError(res, 404, 'Image not found');
res.json({ ok: true });
} catch {
sendError(res, 500, 'Failed to delete image');
}
})();
});
}
Loading