File size: 9,130 Bytes
33d5d60 0d0d792 33d5d60 0d0d792 33d5d60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | 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));
|