000x's picture
Upload 5 files
0d0d792 verified
Raw History Blame Contribute Delete
9.13 kB
const express = require('express');
const sharp = require('sharp');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { Readable } = require('stream');
const { pipeline } = require('stream/promises');
const { createCanvas, loadImage, ImageData, GlobalFonts } = require('@napi-rs/canvas');
/* Works with a public/ folder or with all files uploaded flat into the repo root */
const PUB = fs.existsSync(path.join(__dirname, 'public', 'render.js')) ? path.join(__dirname, 'public') : __dirname;
const R = require(path.join(PUB, 'render.js'));
const PORT = process.env.PORT || 7860;
const ROOT = process.env.DATA_DIR || '/tmp/mr';
const PREVIEW_EDGE = 1600;
const SESSION_TTL = 3 * 3600 * 1000;
fs.mkdirSync(ROOT, { recursive: true });
sharp.cache(false);
/* Windows font names used by the app -> Linux look-alikes (best effort) */
for (const [real, alias] of [
['Liberation Sans', 'Arial'],
['Liberation Mono', 'Courier New'],
['DejaVu Serif', 'Georgia'],
['DejaVu Sans', 'Impact'],
['Noto Sans Sinhala', 'Iskoola Pota'],
]) {
try { GlobalFonts.setAlias(real, alias); } catch {}
}
/* ---------- sessions (one per browser tab, id is random and unguessable) ---------- */
const sessions = new Map();
function getSess(sid) {
if (!/^[a-f0-9]{32}$/.test(sid || '')) throw Object.assign(new Error('bad session'), { code: 400 });
let s = sessions.get(sid);
if (!s) {
const dir = path.join(ROOT, sid);
fs.mkdirSync(dir, { recursive: true });
s = { dir, items: new Map(), layers: [], t: Date.now() };
sessions.set(sid, s);
}
s.t = Date.now();
return s;
}
setInterval(() => {
const now = Date.now();
for (const [sid, s] of sessions) {
if (now - s.t > SESSION_TTL) {
sessions.delete(sid);
fs.rm(s.dir, { recursive: true, force: true }, () => {});
}
}
}, 10 * 60 * 1000).unref();
/* ---------- helpers ---------- */
const bearer = req => (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
async function pool(list, n, fn) {
let i = 0;
await Promise.all(Array.from({ length: Math.min(n, list.length) }, async () => {
while (i < list.length) await fn(list[i++]);
}));
}
function ndjson(res) {
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
return o => { if (!res.writableEnded && !res.destroyed) res.write(JSON.stringify(o) + '\n'); };
}
const fail = (res, e) => res.status(e.code && e.code < 600 ? e.code : 500).send(e.message || 'error');
async function driveUpload(tok, folderId, name, mime, buf) {
let last;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const init = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + tok,
'Content-Type': 'application/json; charset=UTF-8',
'X-Upload-Content-Type': mime,
'X-Upload-Content-Length': String(buf.length),
},
body: JSON.stringify({ name, parents: [folderId] }),
});
if (init.status === 401) throw new Error('Google session expired');
if (!init.ok) throw new Error('Drive ' + init.status);
const put = await fetch(init.headers.get('location'), {
method: 'PUT',
headers: { 'Content-Type': mime, 'Content-Length': String(buf.length) },
body: buf,
});
if (!put.ok) throw new Error('Drive upload ' + put.status);
return;
} catch (e) {
last = e;
if (/expired/.test(e.message)) break;
await new Promise(r => setTimeout(r, 800 * (attempt + 1)));
}
}
throw last;
}
/* Same drawing code as the browser preview (public/render.js), at full resolution. */
async function renderBuf(sess, rid, ov, o) {
const it = sess.items.get(rid);
if (!it) throw Object.assign(new Error('unknown photo'), { code: 404 });
const k = o.max ? Math.min(1, o.max / Math.max(it.w, it.h)) : 1;
const w = Math.round(it.w * k), h = Math.round(it.h * k);
let pipe = sharp(it.file).rotate();
if (k < 1) pipe = pipe.resize(w, h, { fit: 'fill' });
const { data, info } = await pipe.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const c = createCanvas(info.width, info.height);
const ctx = c.getContext('2d');
ctx.putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.length), info.width, info.height), 0, 0);
R.drawAll(ctx, info.width, info.height, sess.layers, { ov: ov || {} });
const png = o.fmt === 'png';
const q = Math.round(Math.min(1, Math.max(0.5, +o.q || 0.92)) * 100);
return { buf: await c.encode(png ? 'png' : 'jpeg', q), mime: png ? 'image/png' : 'image/jpeg' };
}
/* ---------- app ---------- */
const app = express();
app.disable('x-powered-by');
if (PUB === __dirname) {
app.get('/', (q, r) => r.sendFile(path.join(PUB, 'index.html')));
app.get('/render.js', (q, r) => r.sendFile(path.join(PUB, 'render.js')));
} else {
app.use(express.static(PUB));
}
app.get('/api/config', (req, res) => res.json({ clientId: process.env.GOOGLE_CLIENT_ID || '' }));
/* Download picked Drive files on the server (parallel), stream progress back */
app.post('/api/import', express.json({ limit: '2mb' }), async (req, res) => {
let sess;
try { sess = getSess(req.body.sid); } catch (e) { return fail(res, e); }
const tok = bearer(req);
const files = Array.isArray(req.body.files) ? req.body.files.slice(0, 2000) : [];
if (!tok) return res.status(401).send('missing token');
const send = ndjson(res);
send({ t: 'start', n: files.length });
await pool(files, 6, async f => {
try {
const rid = crypto.randomBytes(6).toString('hex');
const file = path.join(sess.dir, rid + '.orig');
const r = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(f.id)}?alt=media&supportsAllDrives=true`, {
headers: { Authorization: 'Bearer ' + tok },
});
if (r.status === 401) throw new Error('Google session expired, reconnect Drive');
if (!r.ok) throw new Error('Drive returned ' + r.status);
await pipeline(Readable.fromWeb(r.body), fs.createWriteStream(file));
const meta = await sharp(file).metadata();
const swap = (meta.orientation || 1) >= 5;
const w = swap ? meta.height : meta.width, h = swap ? meta.width : meta.height;
await sharp(file).rotate()
.resize({ width: PREVIEW_EDGE, height: PREVIEW_EDGE, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 86 }).toFile(path.join(sess.dir, rid + '.prev.jpg'));
sess.items.set(rid, { name: String(f.name || rid), w, h, file });
send({ t: 'item', rid, name: String(f.name || rid), w, h });
} catch (e) {
send({ t: 'err', name: f.name, msg: e.message });
}
});
send({ t: 'done' });
res.end();
});
app.get('/api/preview/:sid/:rid', (req, res) => {
try {
const s = getSess(req.params.sid);
if (!s.items.has(req.params.rid)) return res.sendStatus(404);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.sendFile(path.join(s.dir, req.params.rid + '.prev.jpg'));
} catch (e) { fail(res, e); }
});
/* Watermark layers (image layers arrive as PNG data URLs) */
app.post('/api/settings/:sid', express.json({ limit: '80mb' }), async (req, res) => {
try {
const s = getSess(req.params.sid);
const layers = Array.isArray(req.body.layers) ? req.body.layers : [];
s.layers = await Promise.all(layers.map(async l => {
const L = { ...l };
if (l.type === 'image') {
L.cv = await loadImage(Buffer.from(String(l.png).split(',')[1] || '', 'base64'));
delete L.png;
}
return L;
}));
res.json({ ok: true });
} catch (e) { fail(res, e); }
});
/* Render one photo at full size and send it back (for ZIP / separate downloads) */
app.post('/api/render/:sid/:rid', express.json({ limit: '1mb' }), async (req, res) => {
try {
const s = getSess(req.params.sid);
const { buf, mime } = await renderBuf(s, req.params.rid, req.body.ov, req.body);
res.type(mime).send(buf);
} catch (e) { fail(res, e); }
});
/* Render + upload straight into a Drive folder (created by the browser in the chosen account) */
app.post('/api/export-drive', express.json({ limit: '5mb' }), async (req, res) => {
let sess;
try { sess = getSess(req.body.sid); } catch (e) { return fail(res, e); }
const tok = bearer(req);
const { folderId, items } = req.body;
if (!tok || !folderId || !Array.isArray(items)) return res.status(400).send('bad request');
const send = ndjson(res);
await pool(items, 3, async it => {
try {
const { buf, mime } = await renderBuf(sess, it.rid, it.ov, req.body);
await driveUpload(tok, folderId, String(it.name), mime, buf);
send({ t: 'ok', name: it.name });
} catch (e) {
send({ t: 'err', name: it.name, msg: e.message });
}
});
send({ t: 'done' });
res.end();
});
app.listen(PORT, '0.0.0.0', () => console.log('markroom listening on ' + PORT));