mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: unify project on port 1349, improve strip-metadata and UI components
Consolidate all access to localhost:1349 — Vite dev server serves on 1349 and proxies API calls to an internal dev port (13490). Production API defaults to 1349. Also includes strip-metadata improvements, UI component updates, and compress operation fixes.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(1350),
|
||||
PORT: z.coerce.number().default(1349),
|
||||
AUTH_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import sharp from "sharp";
|
||||
import { createWorkspace } from "../lib/workspace.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
@@ -125,9 +126,24 @@ export function createToolRoute<T>(
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
// Auto-orient based on EXIF metadata before processing.
|
||||
// Camera photos often have EXIF orientation tags (values 2-8) that browsers
|
||||
// respect when displaying, but Sharp does NOT apply by default. Without this,
|
||||
// processed images appear rotated because the output (PNG) strips EXIF data.
|
||||
// Only re-encodes when orientation correction is actually needed.
|
||||
let processBuffer = fileBuffer;
|
||||
try {
|
||||
const meta = await sharp(fileBuffer).metadata();
|
||||
if (meta.orientation && meta.orientation > 1) {
|
||||
processBuffer = await sharp(fileBuffer).rotate().toBuffer();
|
||||
}
|
||||
} catch {
|
||||
// If metadata reading fails, proceed with original buffer
|
||||
}
|
||||
|
||||
// Process the image
|
||||
try {
|
||||
const result = await config.process(fileBuffer, settings, filename);
|
||||
const result = await config.process(processBuffer, settings, filename);
|
||||
|
||||
// Create workspace and save output
|
||||
const jobId = randomUUID();
|
||||
|
||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
import { stripMetadata } from "@stirling-image/image-engine";
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import exifReader from "exif-reader";
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
stripExif: z.boolean().default(false),
|
||||
@@ -12,7 +14,264 @@ const settingsSchema = z.object({
|
||||
stripAll: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize a value for JSON — convert Buffers/Dates and drop overly large blobs.
|
||||
*/
|
||||
function sanitizeValue(v: unknown): unknown {
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (Buffer.isBuffer(v)) {
|
||||
if (v.length > 256) return `<binary ${v.length} bytes>`;
|
||||
return Array.from(v);
|
||||
}
|
||||
if (Array.isArray(v)) return v.map(sanitizeValue);
|
||||
if (v !== null && typeof v === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
out[k] = sanitizeValue(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GPS coordinates from EXIF GPSInfo into decimal degrees.
|
||||
*/
|
||||
function parseGpsCoordinates(gps: Record<string, unknown>): {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
altitude: number | null;
|
||||
} {
|
||||
let latitude: number | null = null;
|
||||
let longitude: number | null = null;
|
||||
let altitude: number | null = null;
|
||||
|
||||
const lat = gps.GPSLatitude as number[] | undefined;
|
||||
const latRef = gps.GPSLatitudeRef as string | undefined;
|
||||
if (lat && lat.length === 3) {
|
||||
latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
|
||||
if (latRef === "S") latitude = -latitude;
|
||||
}
|
||||
|
||||
const lon = gps.GPSLongitude as number[] | undefined;
|
||||
const lonRef = gps.GPSLongitudeRef as string | undefined;
|
||||
if (lon && lon.length === 3) {
|
||||
longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
|
||||
if (lonRef === "W") longitude = -longitude;
|
||||
}
|
||||
|
||||
if (typeof gps.GPSAltitude === "number") {
|
||||
altitude = gps.GPSAltitude;
|
||||
if (gps.GPSAltitudeRef === 1) altitude = -altitude;
|
||||
}
|
||||
|
||||
return { latitude, longitude, altitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XMP XML buffer into key-value pairs.
|
||||
*/
|
||||
function parseXmp(xmpBuffer: Buffer): Record<string, string> {
|
||||
const xml = xmpBuffer.toString("utf-8");
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
const attrRegex = /(\w+:\w+)="([^"]+)"/g;
|
||||
let match;
|
||||
while ((match = attrRegex.exec(xml)) !== null) {
|
||||
const key = match[1];
|
||||
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
|
||||
result[key] = match[2];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ICC profile buffer into basic info.
|
||||
*/
|
||||
function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
|
||||
const info: Record<string, string> = {};
|
||||
|
||||
if (iccBuffer.length < 128) return info;
|
||||
|
||||
info["Profile Size"] = `${iccBuffer.length} bytes`;
|
||||
|
||||
const colorSpace = iccBuffer.subarray(16, 20).toString("ascii").trim();
|
||||
if (colorSpace) info["Color Space"] = colorSpace;
|
||||
|
||||
const pcs = iccBuffer.subarray(20, 24).toString("ascii").trim();
|
||||
if (pcs) info["Connection Space"] = pcs;
|
||||
|
||||
const classMap: Record<string, string> = {
|
||||
scnr: "Input (Scanner)",
|
||||
mntr: "Display (Monitor)",
|
||||
prtr: "Output (Printer)",
|
||||
link: "Device Link",
|
||||
spac: "Color Space",
|
||||
abst: "Abstract",
|
||||
nmcl: "Named Color",
|
||||
};
|
||||
const deviceClass = iccBuffer.subarray(12, 16).toString("ascii").trim();
|
||||
if (deviceClass) info["Device Class"] = classMap[deviceClass] ?? deviceClass;
|
||||
|
||||
const major = iccBuffer[8];
|
||||
const minor = (iccBuffer[9] >> 4) & 0xf;
|
||||
if (major) info["Version"] = `${major}.${minor}`;
|
||||
|
||||
// Extract description tag from ICC tag table
|
||||
const tagCount = iccBuffer.readUInt32BE(128);
|
||||
for (let i = 0; i < tagCount && i < 50; i++) {
|
||||
const tagOffset = 132 + i * 12;
|
||||
if (tagOffset + 12 > iccBuffer.length) break;
|
||||
const sig = iccBuffer.subarray(tagOffset, tagOffset + 4).toString("ascii");
|
||||
if (sig === "desc") {
|
||||
const dataOffset = iccBuffer.readUInt32BE(tagOffset + 4);
|
||||
const dataLen = iccBuffer.readUInt32BE(tagOffset + 8);
|
||||
if (dataOffset + dataLen <= iccBuffer.length && dataLen > 12) {
|
||||
const descType = iccBuffer.subarray(dataOffset, dataOffset + 4).toString("ascii");
|
||||
if (descType === "desc") {
|
||||
const strLen = iccBuffer.readUInt32BE(dataOffset + 8);
|
||||
if (strLen > 0 && strLen < 256) {
|
||||
const desc = iccBuffer.subarray(dataOffset + 12, dataOffset + 12 + strLen - 1).toString("ascii");
|
||||
info["Description"] = desc;
|
||||
}
|
||||
} else if (descType === "mluc") {
|
||||
const recCount = iccBuffer.readUInt32BE(dataOffset + 8);
|
||||
if (recCount > 0) {
|
||||
const strOffset = iccBuffer.readUInt32BE(dataOffset + 20);
|
||||
const strLength = iccBuffer.readUInt32BE(dataOffset + 16);
|
||||
if (strOffset && strLength && dataOffset + strOffset + strLength <= iccBuffer.length) {
|
||||
const raw = iccBuffer.subarray(dataOffset + strOffset, dataOffset + strOffset + strLength);
|
||||
// ICC mluc strings are UTF-16BE: swap bytes for Node's utf16le decoder
|
||||
const swapped = Buffer.alloc(raw.length);
|
||||
for (let j = 0; j < raw.length - 1; j += 2) {
|
||||
swapped[j] = raw[j + 1];
|
||||
swapped[j + 1] = raw[j];
|
||||
}
|
||||
const desc = swapped.toString("utf16le");
|
||||
info["Description"] = desc.replace(/\0/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export function registerStripMetadata(app: FastifyInstance) {
|
||||
// Inspect endpoint — returns parsed metadata as JSON
|
||||
app.post(
|
||||
"/api/v1/tools/strip-metadata/inspect",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = basename(part.filename ?? "image");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await sharp(fileBuffer).metadata();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
filename,
|
||||
fileSize: fileBuffer.length,
|
||||
};
|
||||
|
||||
// Parse EXIF
|
||||
if (metadata.exif) {
|
||||
try {
|
||||
const parsed = exifReader(metadata.exif);
|
||||
const exifData: Record<string, unknown> = {};
|
||||
const gpsData: Record<string, unknown> = {};
|
||||
|
||||
if (parsed.Image) {
|
||||
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||
exifData[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.Photo) {
|
||||
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||
exifData[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.Iop) {
|
||||
for (const [k, v] of Object.entries(parsed.Iop)) {
|
||||
exifData[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.GPSInfo) {
|
||||
for (const [k, v] of Object.entries(parsed.GPSInfo)) {
|
||||
gpsData[k] = sanitizeValue(v);
|
||||
}
|
||||
const coords = parseGpsCoordinates(parsed.GPSInfo as Record<string, unknown>);
|
||||
if (coords.latitude !== null) gpsData["_latitude"] = coords.latitude;
|
||||
if (coords.longitude !== null) gpsData["_longitude"] = coords.longitude;
|
||||
if (coords.altitude !== null) gpsData["_altitude"] = coords.altitude;
|
||||
}
|
||||
|
||||
if (Object.keys(exifData).length > 0) result.exif = exifData;
|
||||
if (Object.keys(gpsData).length > 0) result.gps = gpsData;
|
||||
} catch {
|
||||
result.exif = null;
|
||||
result.exifError = "Failed to parse EXIF data";
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ICC
|
||||
if (metadata.icc) {
|
||||
try {
|
||||
result.icc = parseIccProfile(metadata.icc);
|
||||
} catch {
|
||||
result.icc = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse XMP
|
||||
if (metadata.xmp) {
|
||||
try {
|
||||
result.xmp = parseXmp(metadata.xmp);
|
||||
} catch {
|
||||
result.xmp = null;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send(result);
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to read image metadata",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Strip endpoint — processes and returns cleaned image
|
||||
createToolRoute(app, {
|
||||
toolId: "strip-metadata",
|
||||
settingsSchema,
|
||||
|
||||
Reference in New Issue
Block a user