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));