alotta good shit
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import cfg from "./inc/config.mjs";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import audit from "./inc/audit.mjs";
|
||||
import { getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
};
|
||||
|
||||
/** Shared admin session + CSRF lookup */
|
||||
async function getAdminSession(req, res) {
|
||||
if (!req.cookies || !req.cookies.session) {
|
||||
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db`
|
||||
SELECT "user".id, "user".login, "user".user, "user".admin,
|
||||
"user_sessions".id AS sess_id, "user_sessions".csrf_token
|
||||
FROM "user_sessions"
|
||||
LEFT JOIN "user" ON "user".id = "user_sessions".user_id
|
||||
WHERE "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (user.length === 0 || !user[0].admin) {
|
||||
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = user[0];
|
||||
|
||||
// CSRF validation via header
|
||||
if (session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked brand image request for user ${session.user}. Invalid token.`);
|
||||
sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Delete the physical file for a stored brand image URL (if any) */
|
||||
async function deleteOldBrandFile() {
|
||||
const current = getBrandImageUrl();
|
||||
if (!current) return;
|
||||
// Strip query string to get the bare filename
|
||||
const urlPath = current.split('?')[0];
|
||||
// Only delete files that live under our navbar img dir
|
||||
if (!urlPath.startsWith('/s/img/navbar/brand.')) return;
|
||||
const filename = path.basename(urlPath);
|
||||
const filePath = path.join(cfg.paths.s, 'img', 'navbar', filename);
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
|
||||
/** Persist brand image URL to site_settings DB and in-memory setting */
|
||||
async function persistBrandImage(url) {
|
||||
setBrandImageUrl(url);
|
||||
await db`
|
||||
INSERT INTO site_settings (key, value)
|
||||
VALUES ('brand_image_url', ${url})
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────────────────
|
||||
export const handleBrandImageUpload = async (req, res) => {
|
||||
console.log('[BRAND UPLOAD] Started');
|
||||
|
||||
const session = await getAdminSession(req, res);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type — multipart boundary missing' }, 400);
|
||||
}
|
||||
|
||||
const body = await collectBody(req, 5 * 1024 * 1024); // 5 MB hard cap
|
||||
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||
const file = parts.file;
|
||||
|
||||
if (!file || !file.data || file.data.length === 0) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// 2 MB soft cap
|
||||
const maxSize = 2 * 1024 * 1024;
|
||||
if (file.data.length > maxSize) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `File too large. Maximum is 2 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/svg+xml'];
|
||||
const mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type. Allowed: gif, jpg, png, webp, svg. Got: ${mime}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const imgDir = path.join(cfg.paths.s, 'img', 'navbar');
|
||||
await fs.mkdir(imgDir, { recursive: true });
|
||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||
|
||||
const isSvg = mime === 'image/svg+xml';
|
||||
const ts = Date.now();
|
||||
// Timestamp baked into the filename — every upload is a unique file
|
||||
const outFilename = isSvg ? `brand.${ts}.svg` : `brand.${ts}.webp`;
|
||||
const tmpPath = path.join(cfg.paths.tmp, `brand_tmp_${ts}`);
|
||||
const finalPath = path.join(imgDir, outFilename);
|
||||
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
if (!isSvg) {
|
||||
// Verify actual MIME with file(1)
|
||||
try {
|
||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||
const safeActual = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!safeActual.includes(actualMime.trim())) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime.trim()}` }, 400);
|
||||
}
|
||||
} catch (_) {
|
||||
// file(1) not available — skip magic check
|
||||
}
|
||||
|
||||
// Convert to WebP via ImageMagick
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-quality', '85', finalPath]);
|
||||
} catch (err) {
|
||||
console.error('[BRAND UPLOAD] ImageMagick error:', err);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'Failed to process image (ImageMagick required)' }, 500);
|
||||
}
|
||||
} else {
|
||||
// SVG: copy as-is
|
||||
await fs.copyFile(tmpPath, finalPath);
|
||||
}
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
// Delete the previous brand file from disk
|
||||
await deleteOldBrandFile();
|
||||
|
||||
const publicUrl = `/s/img/navbar/${outFilename}`;
|
||||
|
||||
await persistBrandImage(publicUrl);
|
||||
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: publicUrl })})`.catch(() => {});
|
||||
await audit.log(session.id, 'update_brand_image', 'system', 0, { url: publicUrl });
|
||||
|
||||
console.log('[BRAND UPLOAD] Done:', publicUrl);
|
||||
return sendJson(res, { success: true, url: publicUrl, msg: 'Brand image updated' });
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'BODY_TOO_LARGE') {
|
||||
return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413);
|
||||
}
|
||||
console.error('[BRAND UPLOAD ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
export const handleBrandImageDelete = async (req, res) => {
|
||||
console.log('[BRAND DELETE] Started');
|
||||
|
||||
const session = await getAdminSession(req, res);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
// Delete the physical file before clearing the setting
|
||||
await deleteOldBrandFile();
|
||||
|
||||
await persistBrandImage('');
|
||||
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: null })})`.catch(() => {});
|
||||
await audit.log(session.id, 'delete_brand_image', 'system', 0, {});
|
||||
|
||||
console.log('[BRAND DELETE] Done');
|
||||
return sendJson(res, { success: true, msg: 'Brand image removed' });
|
||||
} catch (err) {
|
||||
console.error('[BRAND DELETE ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: 'Delete failed: ' + err.message }, 500);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user