// Abstract submission + LaTeX-import endpoints — plain Node.js, no dependencies. // Receives JSON: { title, authors, affiliations, email, abstract, caption, // fits_one_page, figure_name, figure_b64, pdf_name, pdf_b64 } // Stores each submission in ABSTRACTS// (meta.json + figure + pdf). // // Usage in a plain http.createServer if-chain (data = parsed JSON body): // if (url.pathname === '/api/abstract' && req.method === 'POST') // return send(200, handleAbstract(data, req)); // With Express: app.post('/api/abstract', (req, res) => res.json(handleAbstract(req.body, req))); import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; // submissions are stored next to this file, in ./abstracts/ const ABSTRACTS = path.join(path.dirname(fileURLToPath(import.meta.url)), 'abstracts'); export function handleAbstract(data, req) { const title = String(data.title || '').trim().slice(0, 300); const authors = String(data.authors || '').trim().slice(0, 300); const email = String(data.email || '').trim().slice(0, 200); const abstract = String(data.abstract || '').trim().slice(0, 20000); if (!title || !authors || !abstract) return { error: 'title, authors and abstract are required' }; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { error: 'invalid contact e-mail' }; // hard one-page limit: the client measures the rendered A4 page and sends the flag if (data.fits_one_page === false) return { error: 'abstract is longer than one page — please shorten it' }; const fig = Buffer.from(String(data.figure_b64 || ''), 'base64'); const pdf = Buffer.from(String(data.pdf_b64 || ''), 'base64'); if (fig.length > 8e6) return { error: 'figure too large (max 8 MB)' }; if (pdf.length > 15e6) return { error: 'PDF too large (max 15 MB)' }; if (pdf.length && pdf.subarray(0, 5).toString() !== '%PDF-') return { error: 'PDF file is not a valid PDF' }; fs.mkdirSync(ABSTRACTS, { recursive: true }); // basic flood protection: at most 300 stored submissions if (fs.readdirSync(ABSTRACTS).length >= 300) return { error: 'submission system is full — please contact the organisers' }; const id = crypto.randomBytes(4).toString('hex'); const dir = path.join(ABSTRACTS, id); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ id, title, authors, email, affiliations: String(data.affiliations || '').trim().slice(0, 400), abstract, caption: String(data.caption || '').trim().slice(0, 300), figure_name: String(data.figure_name || '').replace(/[^\w.\-]/g, '_').slice(0, 80), pdf_name: String(data.pdf_name || '').replace(/[^\w.\-]/g, '_').slice(0, 80), date: new Date().toISOString(), ip: req?.socket?.remoteAddress || req?.headers?.['x-forwarded-for'] || '', }, null, 2)); if (fig.length) { const ext = fig.subarray(0, 4).toString('hex').startsWith('89504e47') ? '.png' : fig.subarray(0, 3).toString('hex') === 'ffd8ff' ? '.jpg' : '.webp'; fs.writeFileSync(path.join(dir, 'figure' + ext), fig); } if (pdf.length) fs.writeFileSync(path.join(dir, 'paper.pdf'), pdf); return { ok: true, id }; } // ---- optional: AI import — paste a whole LaTeX file, Claude fills the form ---- // Requires an Anthropic API key. Route: POST /api/abstract/import { tex: "" } // Returns { ok, fields: { title, authors, affiliations, email, abstract, caption } }. const importLog = []; // simple rate limit: [timestamp, ip] export async function handleAbstractImport(data, req, apiKey) { if (!apiKey) return { error: 'AI import is not configured on this server' }; const tex = String(data.tex || '').slice(0, 200000); if (tex.trim().length < 40) return { error: 'please provide the LaTeX source of your abstract' }; const ip = req?.headers?.['x-forwarded-for'] || req?.socket?.remoteAddress || '?'; const now = Date.now(); while (importLog.length && importLog[0][0] < now - 3600e3) importLog.shift(); if (importLog.filter(e => e[1] === ip).length >= 10) return { error: 'rate limit: max 10 AI imports per hour — please fill the form manually' }; if (importLog.length >= 120) return { error: 'AI import is busy — please try again later or fill the form manually' }; importLog.push([now, ip]); const r = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: 'claude-sonnet-5-5', max_tokens: 8000, system: 'You extract conference-abstract form fields from LaTeX source. Reply with ONLY a JSON object, no markdown fences, with exactly these string keys: title, authors, affiliations, email, abstract, caption. Rules: title = plain text, keep inline math as $...$. authors = comma-separated names; if there are several affiliations, mark them with bracketed numbers after each name, e.g. "A. Author [1], B. Coauthor [2]" (never superscript characters). affiliations = the matching list, e.g. "[1] University of Vienna, Austria; [2] ...". email = the contact/corresponding e-mail found in the source, else "". abstract = the full abstract/body text as plain paragraphs separated by blank lines; keep all math exactly as LaTeX ($...$ inline, $$...$$ display, convert \\[ \\] and equation environments to $$...$$); strip layout commands (\\textbf -> plain text content, \\section headings removed, \\cite -> [1] style); do NOT shorten or rewrite the text. caption = the figure caption if a figure exists, else "".', messages: [{ role: 'user', content: 'Extract the form fields from this LaTeX file:\n\n' + tex }], }), }); if (!r.ok) return { error: 'AI service error (' + r.status + ') — please fill the form manually' }; const j = await r.json(); const txt = (j.content || []).map(c => c.text || '').join(''); const m = txt.match(/\{[\s\S]*\}/); if (!m) return { error: 'AI could not parse this file — please fill the form manually' }; let f; try { f = JSON.parse(m[0]); } catch { return { error: 'AI returned an unreadable result — please try again' }; } const s = (v, n) => String(v || '').trim().slice(0, n); return { ok: true, fields: { title: s(f.title, 300), authors: s(f.authors, 300), affiliations: s(f.affiliations, 400), email: s(f.email, 200), abstract: s(f.abstract, 20000), caption: s(f.caption, 300), } }; }