diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab01fe4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +node_modules +npm-debug.log* +.git +.github +.gitignore +.env + +# Output di compilazione di installazioni precedenti: non servono a runtime. +backend/compiled +backend/static +esempi/*.pdf +esempi/*.html +latex-source/*.html + +# Non serve a runtime (frontend/ e docs/ invece sì: li serve server.js) +deploy +api +vercel.json +CHANGELOG.md +CONTRIBUTING.md +.vscode +.idea diff --git a/.env.example b/.env.example index 5b5b0de..466fab1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ -# .env.example +# Copia questo file in .env e modifica i valori se serve. +# Nessuna variabile è obbligatoria: i default bastano per lo sviluppo. + +# Porta su cui il server Express si mette in ascolto (default: 3000) PORT=3000 + +# production disattiva i log di sviluppo di Express NODE_ENV=development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd4dffe..be54dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,51 +7,180 @@ on: branches: [main, master] jobs: - test: + server: + name: Server Node + smoke test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - name: Setup Node + uses: actions/setup-node@v4 with: - python-version: "3.11" + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Avvia il server + run: | + node server.js & + echo $! > server.pid + for i in $(seq 1 30); do + if curl -sf -o /dev/null http://localhost:3000/; then + echo "server pronto dopo ${i}s" + exit 0 + fi + sleep 1 + done + echo "il server non ha risposto entro 30s" + exit 1 + + - name: Il frontend viene servito + run: | + curl -sf http://localhost:3000/ | grep -q 'id="dropzone"' + curl -sf -o /dev/null http://localhost:3000/app.js + curl -sf -o /dev/null http://localhost:3000/app.css + curl -sf -o /dev/null http://localhost:3000/manifest.json + echo "Frontend OK" + + - name: La documentazione viene servita + run: | + curl -sf http://localhost:3000/docs/ | grep -q 'id="navLinks"' + echo "Docs OK" + + - name: Gli endpoint di download rispondono + run: | + curl -sf http://localhost:3000/api/template/quiz.tex | grep -q 'documentclass' + curl -sf -o /dev/null http://localhost:3000/api/download/file/quizstruct.sty + curl -sf -o /dev/null http://localhost:3000/api/download/package.zip + echo "Download OK" + + - name: Compilazione di un singolo .tex + run: | + curl -sf -X POST http://localhost:3000/compile \ + -F "files=@latex-source/quiz.tex" -o out.json + node -e " + const fs = require('fs'); + const d = require('./out.json'); + if (!d.success) { console.error(d.log); process.exit(1); } + if (!d.pdf || !d.html) { console.error('output mancanti', d); process.exit(1); } + // gli output arrivano nella risposta, non da un URL sul server + fs.writeFileSync('out.pdf', Buffer.from(d.pdf.base64, 'base64')); + fs.writeFileSync('out.html', Buffer.from(d.html.base64, 'base64')); + console.log('Compile OK'); + " + head -c 4 out.pdf | grep -q '%PDF' + grep -qi ' evil.tex <<'TEX' + \documentclass{article} + \usepackage{enumitem} + \usepackage{quizstruct} + \begin{document} + \begin{quiz}{T} + \domanda[1]{D?} + \begin{opzioni} + \rispostacorretta{ok} + \end{opzioni} + \end{quiz} + \end{document} + TEX + curl -sf -X POST http://localhost:3000/compile -F "files=@evil.tex" -o evil.json + node -e " + const d = require('./evil.json'); + const html = Buffer.from(d.html.base64, 'base64').toString(); + if (/ + + diff --git a/frontend/app.css b/frontend/app.css index 3a3f7f2..a32e82f 100644 --- a/frontend/app.css +++ b/frontend/app.css @@ -160,6 +160,133 @@ color: var(--paper); } +/* ── pagine legali ── */ +.legal { + max-width: 720px; + margin: 7rem auto 4rem; + padding: 0 1.5rem; +} +.legal h1 { + font-family: var(--serif); + font-size: clamp(1.9rem, 4vw, 2.4rem); + line-height: 1.15; + margin-bottom: 0.4rem; +} +.legal-updated { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--ink-dim); + margin-bottom: 2rem; +} +.legal h2 { + font-family: var(--serif); + font-size: 1.35rem; + margin: 2.5rem 0 0.75rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--line); +} +.legal h3 { + font-family: var(--mono); + font-size: 0.95rem; + color: var(--accent); + margin: 1.5rem 0 0.5rem; +} +.legal p { margin-bottom: 0.9rem; color: var(--ink-soft); } +.legal ul { margin: 0 0 1rem 1.25rem; color: var(--ink-soft); } +.legal li { margin-bottom: 0.4rem; } +.legal strong { color: var(--ink); } +.legal mark { + background: #fdf3c7; + border-bottom: 1px dashed #b8912a; + padding: 0.05em 0.3em; + border-radius: 3px; + font-family: var(--mono); + font-size: 0.85em; +} +.legal .callout { margin-bottom: 2rem; } + +.output-btn-ghost { + background: transparent; + border: 1px solid var(--line); + color: var(--ink-soft); + margin-top: 0.6rem; + cursor: pointer; + font-family: var(--sans); + font-size: 1rem; +} +.output-btn-ghost:hover { + background: var(--paper-dim); + border-color: var(--accent); + color: var(--accent); +} + +.preview-panel { + margin-top: 2rem; + background: var(--paper-card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +.preview-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + background: var(--paper-dim); + border-bottom: 1px solid var(--line); +} +.preview-head h4 { + font-family: var(--mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-dim); +} +.preview-close { + background: none; + border: none; + font-size: 1.4rem; + line-height: 1; + color: var(--ink-dim); + cursor: pointer; +} +.preview-close:hover { color: var(--err); } +.preview-frame { + display: block; + width: 100%; + height: 60vh; + min-height: 380px; + border: none; + background: var(--paper); +} + +.compile-log { + margin-top: 2rem; + background: var(--paper-card); + border: 1px solid var(--line); + border-left: 3px solid var(--err); + border-radius: var(--r-md); + padding: 1.25rem 1.5rem; + box-shadow: var(--shadow-xs); +} +.compile-log h4 { + font-family: var(--mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-dim); + margin-bottom: 0.75rem; +} +.compile-log pre { + font-family: var(--mono); + font-size: 0.85rem; + line-height: 1.6; + color: var(--ink-soft); + white-space: pre-wrap; + word-break: break-word; +} + @media (max-width: 640px) { .outputs-grid { grid-template-columns: 1fr; } } diff --git a/frontend/app.js b/frontend/app.js index 83e1ee1..63f90e5 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -7,6 +7,7 @@ const pdfCard = document.getElementById('pdfCard'); const htmlCard = document.getElementById('htmlCard'); const logSection = document.getElementById('log'); const logStatus = document.getElementById('logStatus'); +const previewBtn = document.getElementById('previewBtn'); let selectedFiles = []; @@ -80,30 +81,72 @@ document.querySelectorAll('.template-btn').forEach(btn => { }); }); +// Gli output arrivano dentro la risposta, non da un URL sul server. +// Vengono ricostruiti qui come Blob locali: nulla resta sul server. +const objectUrls = []; + +function releaseObjectUrls() { + while (objectUrls.length) URL.revokeObjectURL(objectUrls.pop()); +} + +function blobUrlFrom(output) { + const bytes = Uint8Array.from(atob(output.base64), (c) => c.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], { type: output.mime })); + objectUrls.push(url); + return url; +} + +const preview = document.getElementById('preview'); +const previewFrame = document.getElementById('previewFrame'); +const previewClose = document.getElementById('previewClose'); + +if (previewClose) { + previewClose.addEventListener('click', () => { + preview.hidden = true; + previewFrame.removeAttribute('srcdoc'); + }); +} + compileBtn.addEventListener('click', async () => { compileBtn.disabled = true; compileBtn.querySelector('.run-label').textContent = 'Compilazione in corso...'; outputs.style.display = 'none'; logSection.hidden = true; - + if (preview) preview.hidden = true; + releaseObjectUrls(); + const form = new FormData(); selectedFiles.forEach(f => form.append('files', f)); - + try { const res = await fetch('/compile', { method: 'POST', body: form }); const data = await res.json(); - + if (data.success) { - if (data.pdfUrl) { - pdfCard.href = data.pdfUrl; - pdfCard.textContent = data.pdfZip ? 'Scarica ZIP ⟶' : 'Scarica PDF ⟶'; + if (data.pdf) { + pdfCard.href = blobUrlFrom(data.pdf); + pdfCard.download = data.pdf.filename; + pdfCard.textContent = data.pdf.zip ? 'Scarica ZIP ⟶' : 'Scarica PDF ⟶'; } - if (data.htmlUrl) { - htmlCard.href = data.htmlUrl; - htmlCard.textContent = data.htmlZip ? 'Scarica ZIP ⟶' : 'Apri Web App ⟶'; + if (data.html) { + htmlCard.href = blobUrlFrom(data.html); + htmlCard.download = data.html.filename; + htmlCard.textContent = data.html.zip ? 'Scarica ZIP ⟶' : 'Scarica HTML ⟶'; + + // L'anteprima gira in un iframe sandbox, quindi con un'origine + // opaca: il documento generato non può toccare questa pagina. + if (previewBtn) { + previewBtn.hidden = data.html.zip === true; + previewBtn.onclick = () => { + previewFrame.srcdoc = new TextDecoder().decode( + Uint8Array.from(atob(data.html.base64), (c) => c.charCodeAt(0)) + ); + preview.hidden = false; + preview.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }; + } } outputs.style.display = 'grid'; - // Scroll to outputs setTimeout(() => { outputs.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 100); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..209431d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,150 @@ + + + + + + tex⟶ texforge — compilatore LaTeX + + + + + + + + + + + + +
+ +
+ compilatore online +

Un sorgente, due output

+

+ Carica uno o più file .tex: ricevi un PDF da stampare + e un HTML interattivo con quiz e punteggio automatico. +

+
+ +
+

Trascina qui i tuoi file

+

+ oppure clicca per sceglierli — .tex, .sty, + .cfg, .js, .css +

+ + + + + +
+ + + + +
+
+ + + + + + + + + +
+ Non serve installare nulla. I file di supporto + (quizstruct.sty, quiz.cfg, quiz.js, + quiz.css) vengono aggiunti automaticamente se non li carichi. +
+ scelta multipla + menu a tendina + risposta numerica + punteggio finale +
+
+ +
+ + + + + + + diff --git a/frontend/privacy.html b/frontend/privacy.html new file mode 100644 index 0000000..76fecc8 --- /dev/null +++ b/frontend/privacy.html @@ -0,0 +1,156 @@ + + + + + + Privacy — texforge + + + + + + + + + + +
+

Informativa sulla privacy

+ + +
+ In breve. texforge non ha account, non usa cookie, non fa + profilazione e non usa servizi di analisi. I file che carichi servono solo a + produrre il PDF e l'HTML che ricevi indietro, e vengono cancellati dal server + subito dopo, nella stessa richiesta. +
+ +

1. Titolare del trattamento

+

+ Il titolare del trattamento è + [NOME E COGNOME / RAGIONE SOCIALE — DA COMPILARE], + contattabile all'indirizzo + [INDIRIZZO EMAIL DI CONTATTO — DA COMPILARE]. +

+ +

2. Quali dati vengono trattati

+ +

2.1 I file che carichi

+

+ I sorgenti LaTeX e i file di supporto che invii vengono ricevuti dal server, + scritti in una cartella temporanea, compilati, e cancellati insieme a + tutti gli output nella stessa richiesta, prima ancora che la risposta + ti venga inviata. I risultati della compilazione viaggiano dentro la risposta e + vengono ricostruiti dal tuo browser: sul server non resta alcun file, e non + esiste alcun indirizzo pubblico da cui i tuoi documenti possano essere + recuperati da altri. +

+

+ Non leggiamo, non analizziamo e non conserviamo il contenuto dei tuoi documenti + per nessuna finalità diversa dalla compilazione che hai richiesto. +

+ +

2.2 Dati tecnici di connessione

+

+ Per impedire abusi, il server tiene in memoria il numero di compilazioni + richieste da ciascun indirizzo IP nell'ultimo minuto. Questo conteggio è + volatile: non viene scritto su disco e sparisce al riavvio del servizio. +

+

+ Il fornitore di hosting registra inoltre i normali log di accesso (indirizzo IP, + data e ora, pagina richiesta, tipo di browser), necessari al funzionamento e + alla sicurezza dell'infrastruttura. +

+ +

2.3 Cosa NON viene trattato

+ + +

3. Base giuridica e finalità

+

+ Il trattamento dei file caricati è necessario per erogare il servizio che hai + richiesto (art. 6.1.b GDPR). Il trattamento dei dati tecnici di connessione si + fonda sul legittimo interesse a mantenere il servizio disponibile e protetto + dagli abusi (art. 6.1.f GDPR). +

+ +

4. Conservazione

+ + +

5. Destinatari e hosting

+

+ L'applicazione è ospitata su Vercel, che agisce come responsabile + del trattamento per i dati tecnici necessari all'erogazione del servizio. Vercel + può trattare tali dati anche su server situati fuori dall'Unione Europea, sulla + base delle garanzie previste dal capo V del GDPR (clausole contrattuali standard). +

+

+ Nessun altro soggetto riceve i tuoi dati. I file che carichi non vengono + trasmessi ad alcun servizio esterno. +

+ +

6. Storage locale del browser

+

+ Se installi texforge come app (PWA), il browser salva sul tuo dispositivo + una copia dei file dell'interfaccia, per permetterne l'apertura offline. È una + cache locale, non è accessibile al server e puoi rimuoverla svuotando i dati del + sito o disinstallando l'app. +

+ +

7. I tuoi diritti

+

+ Puoi esercitare i diritti previsti dagli articoli 15–22 del GDPR (accesso, + rettifica, cancellazione, limitazione, opposizione, portabilità) scrivendo al + contatto indicato al punto 1. Poiché non conserviamo i tuoi documenti né dati + identificativi, nella maggior parte dei casi non saremo in grado di collegare + una richiesta a dati ancora esistenti. +

+

+ Hai inoltre diritto di proporre reclamo al Garante per la protezione dei dati + personali. +

+ +

8. Minori

+

+ Il servizio è pensato anche per un uso didattico. Non richiede la creazione di un + account e non raccoglie deliberatamente dati personali di minori. Ti invitiamo a + non inserire dati personali non necessari all'interno dei documenti che compili. +

+ +

9. Modifiche

+

+ Eventuali aggiornamenti di questa informativa vengono pubblicati su questa + pagina, con la data di revisione aggiornata in cima. +

+
+ + + + + diff --git a/frontend/sw.js b/frontend/sw.js index 0aef788..687f318 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -2,12 +2,16 @@ // Cache offline per asset statici del frontend. // Il backend /compile resta sempre online (compilazione reale). -const CACHE_NAME = "texforge-v1.1.0"; +const CACHE_NAME = "texforge-v1.3.0"; const ASSETS = [ "/", + "/index.html", "/style.css", - "/script.js", + "/app.css", + "/app.js", "/manifest.json", + "/termini.html", + "/privacy.html", "/icons/icon-192.png", "/icons/icon-512.png", ]; @@ -37,8 +41,12 @@ self.addEventListener("fetch", (e) => { // Solo GET if (e.request.method !== "GET") return; - // /compile e /static/* → sempre network (contenuto dinamico) - if (url.pathname.startsWith("/compile") || url.pathname.startsWith("/static")) { + // /compile, /static/* e /api/* → sempre network (contenuto dinamico) + if ( + url.pathname.startsWith("/compile") || + url.pathname.startsWith("/static") || + url.pathname.startsWith("/api") + ) { return; } diff --git a/frontend/termini.html b/frontend/termini.html new file mode 100644 index 0000000..790496b --- /dev/null +++ b/frontend/termini.html @@ -0,0 +1,155 @@ + + + + + + Termini di servizio — texforge + + + + + + + + + + +
+

Termini di servizio

+ + +
+ In breve. texforge è uno strumento gratuito che compila i tuoi + file LaTeX in PDF e HTML. I documenti restano tuoi, il servizio è offerto così + com'è, e ti chiediamo di non usarlo per contenuti illeciti o per sovraccaricarlo. +
+ +

1. Oggetto

+

+ texforge (di seguito «il servizio») è un compilatore online che riceve sorgenti + LaTeX e restituisce un documento PDF e una versione HTML interattiva. Il servizio + è fornito da + [NOME E COGNOME / RAGIONE SOCIALE — DA COMPILARE], + contattabile a + [INDIRIZZO EMAIL DI CONTATTO — DA COMPILARE]. +

+

+ Usando il servizio accetti questi termini. Se non li accetti, non utilizzarlo. +

+ +

2. Gratuità e assenza di account

+

+ Il servizio è gratuito e non richiede registrazione. Non essendoci account, non + esiste alcun contenuto salvato a tuo nome: ogni compilazione è indipendente dalle + precedenti. +

+ +

3. Proprietà dei contenuti

+

+ I documenti che carichi restano di tua proprietà. Non + rivendichiamo alcun diritto sui tuoi sorgenti né sugli output prodotti, e non li + utilizziamo per finalità diverse dalla compilazione richiesta. Come descritto + nella informativa privacy, i file vengono cancellati dal + server nel corso della richiesta stessa. +

+

+ Sei responsabile di avere i diritti necessari sui contenuti che carichi. +

+ +

4. Uso consentito

+

Utilizzando il servizio ti impegni a non:

+ +

+ Per proteggere la disponibilità del servizio sono attivi limiti tecnici: numero + massimo di file per richiesta, dimensione massima dell'upload e un tetto al numero + di compilazioni al minuto per ciascun indirizzo IP. Il superamento di questi limiti + comporta il rifiuto temporaneo della richiesta. +

+ +

5. Sicurezza dell'HTML generato

+

+ L'HTML prodotto è un file autonomo che può contenere JavaScript, incluso quello + che fornisci tu stesso caricando un quiz.js personalizzato. Il testo + proveniente dai tuoi sorgenti viene sempre messo in sicurezza prima di finire + nella pagina, e l'anteprima all'interno del sito gira in un contesto isolato. +

+

+ Resta però un file che apri sul tuo dispositivo: apri gli HTML generati a + partire da sorgenti che non conosci con la stessa prudenza che useresti per un + qualsiasi file scaricato da internet. +

+ +

6. Nessuna garanzia

+

+ Il servizio è fornito «così com'è», senza garanzie di alcun tipo, esplicite o + implicite, incluse quelle di idoneità a uno scopo specifico o di continuità di + funzionamento. In particolare: +

+ +

+ Conserva sempre una copia dei tuoi sorgenti. Non essendoci + archiviazione lato server, un output non scaricato è definitivamente perso. +

+ +

7. Limitazione di responsabilità

+

+ Nei limiti consentiti dalla legge, il fornitore non risponde di danni diretti o + indiretti derivanti dall'uso o dal mancato funzionamento del servizio, inclusa la + perdita di dati o documenti. Nulla in questi termini esclude la responsabilità per + dolo o colpa grave, né i diritti inderogabili riconosciuti ai consumatori. +

+ +

8. Licenza del software

+

+ Il codice di texforge è distribuito con licenza MIT. Puoi consultarlo, modificarlo + e installarlo per conto tuo. La licenza riguarda il software, non i documenti che + compili con questa istanza del servizio. +

+ +

9. Modifiche ai termini

+

+ Questi termini possono essere aggiornati; la versione in vigore è sempre quella + pubblicata su questa pagina, con la data di revisione in cima. L'uso continuato + del servizio dopo una modifica ne comporta l'accettazione. +

+ +

10. Legge applicabile

+

+ Questi termini sono regolati dalla legge italiana. Per i consumatori resta ferma + la competenza del foro di residenza o domicilio elettivo. +

+
+ + + + + diff --git a/latex-source/quizstruct.sty b/latex-source/quizstruct.sty index b794e89..b7718cf 100644 --- a/latex-source/quizstruct.sty +++ b/latex-source/quizstruct.sty @@ -12,6 +12,7 @@ \RequirePackage{amsmath} \RequirePackage{amssymb} \RequirePackage{xcolor} +\RequirePackage{enumitem} % per itemize[label=...] usato da opzioni/tendina % Contatori per numerare domande e opzioni \newcounter{domanda} diff --git a/metadata.json b/metadata.json index 30ffb97..c36d7af 100644 --- a/metadata.json +++ b/metadata.json @@ -2,7 +2,5 @@ "name": "texforge", "description": "Compilatore LaTeX in PDF statico e HTML interattivo con quiz", "requestFramePermissions": [], - "majorCapabilities": [ - "MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API" - ] + "majorCapabilities": [] } diff --git a/package.json b/package.json index 1538b19..c751b4c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "compilatore LaTeX → PDF + HTML", "type": "module", "main": "server.js", + "engines": { + "node": ">=18" + }, "scripts": { "dev": "node server.js", "build": "echo 'Build complete'", diff --git a/server.js b/server.js index c618c74..15af95f 100644 --- a/server.js +++ b/server.js @@ -2,11 +2,13 @@ import express from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; +import os from 'os'; import { fileURLToPath } from 'url'; import { execFile } from 'child_process'; import { promisify } from 'util'; import archiver from 'archiver'; import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import fontkit from '@pdf-lib/fontkit'; const execFileAsync = promisify(execFile); const __filename = fileURLToPath(import.meta.url); @@ -14,23 +16,26 @@ const __dirname = path.dirname(__filename); const app = express(); app.disable('x-powered-by'); -const PORT = 3000; +const PORT = Number(process.env.PORT) || 3000; const BASE_DIR = __dirname; const FRONTEND_DIR = path.join(BASE_DIR, 'frontend'); -const STATIC_DIR = path.join(BASE_DIR, 'backend', 'static'); -const COMPILED_DIR = path.join(BASE_DIR, 'backend', 'compiled'); const LATEX_SOURCE_DIR = path.join(BASE_DIR, 'latex-source'); const DOCS_DIR = path.join(BASE_DIR, 'docs'); -fs.mkdirSync(STATIC_DIR, { recursive: true }); -fs.mkdirSync(COMPILED_DIR, { recursive: true }); +// Tutte le scritture avvengono qui, sotto la cartella temporanea di sistema. +// La directory del progetto resta di sola lettura, come richiesto dalle +// piattaforme serverless (su Vercel è scrivibile solo /tmp). +const WORK_ROOT = path.join(os.tmpdir(), 'texforge'); +fs.mkdirSync(WORK_ROOT, { recursive: true }); -const upload = multer({ - dest: path.join(COMPILED_DIR, 'tmp'), +const upload = multer({ + dest: path.join(WORK_ROOT, 'upload'), limits: { fileSize: 10 * 1024 * 1024, // 10MB max per file - files: 20 // max 20 files per upload + files: 20, // max 20 file per richiesta + fields: 10, + parts: 30, } }); @@ -106,65 +111,344 @@ function shuffleArray(array) { return arr; } +// --------------------------------------------------------------------------- +// Escaping e rendering matematico +// +// Tutto il testo che proviene dal .tex caricato dall'utente è NON FIDATO: +// va sempre passato per escapeHtml() prima di finire nell'HTML generato, +// altrimenti un sorgente con - + ${escapeHtml(docTitle)} - + ${htmlBody} `; @@ -481,8 +802,39 @@ function generateHtmlFromQuiz(texContent, cssText, jsText) { async function generatePdfFromQuiz(texContent, outputPath) { const pdfDoc = await PDFDocument.create(); let page = pdfDoc.addPage([595, 842]); - const font = await pdfDoc.embedFont(StandardFonts.Helvetica); - const boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // I font standard del PDF usano WinAnsi, che non sa codificare i simboli + // matematici (∫, ≤, α…). Se il font Unicode è disponibile lo usiamo, così + // le formule restano leggibili; altrimenti si ripiega su Helvetica e il + // testo viene ridotto ad ASCII per non far fallire la generazione. + let font; + let boldFont; + let unicodeOk = false; + try { + const fontBytes = fs.readFileSync(path.join(BASE_DIR, 'backend', 'fonts', 'DejaVuSans.ttf')); + pdfDoc.registerFontkit(fontkit); + font = await pdfDoc.embedFont(fontBytes, { subset: true }); + boldFont = font; // il repo non ha il peso bold: il grassetto è simulato + unicodeOk = true; + } catch { + font = await pdfDoc.embedFont(StandardFonts.Helvetica); + boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + } + + // Riduce il testo a ciò che il font in uso sa codificare. + const fit = (text) => (unicodeOk ? String(text ?? '') : toAsciiMath(String(text ?? ''))); + + // `page` viene riassegnata dai salti pagina: la closure legge sempre l'ultima. + const drawText = (text, { bold = false, ...opts }) => { + const safe = fit(text); + const useFont = bold ? boldFont : font; + page.drawText(safe, { ...opts, font: useFont }); + // senza un file di font bold, il grassetto si simula ridisegnando il testo + if (bold && unicodeOk) page.drawText(safe, { ...opts, font: useFont, x: opts.x + 0.3 }); + }; + + const widthOf = (text, size, bold = false) => + (bold ? boldFont : font).widthOfTextAtSize(fit(text), size); let y = 800; const margin = 50; @@ -495,14 +847,15 @@ async function generatePdfFromQuiz(texContent, outputPath) { } let title = 'Documento LaTeX'; - const titleArg = parseBracedArg(texContent, texContent.indexOf('\\title')); + const titleAt = texContent.indexOf('\\title'); + const titleArg = titleAt === -1 ? null : parseBracedArg(texContent, titleAt); if (titleArg) title = formatLatexForPdf(titleArg.content); - page.drawText(title, { + drawText(title, { x: margin, y: y, size: 18, - font: boldFont, + bold: true, color: rgb(0.25, 0.36, 0.23), }); y -= 30; @@ -530,11 +883,11 @@ async function generatePdfFromQuiz(texContent, outputPath) { const questions = parseQuizQuestions(quizBody); checkPageBreak(40); - page.drawText(quizTitle, { + drawText(quizTitle, { x: margin, y: y, size: 13, - font: boldFont, + bold: true, color: rgb(0.11, 0.12, 0.10), }); y -= 25; @@ -546,22 +899,21 @@ async function generatePdfFromQuiz(texContent, outputPath) { const formattedQText = formatLatexForPdf(q.questionText); const qLabel = `Domanda ${qCount}. `; - page.drawText(qLabel, { + drawText(qLabel, { x: margin, y: y, size: 10, - font: boldFont, + bold: true, color: rgb(0, 0, 0), }); - const labelWidth = boldFont.widthOfTextAtSize(qLabel, 10); + const labelWidth = widthOf(qLabel, 10, true); const ptsLabel = ` (${q.points} pt)`; - page.drawText(formattedQText + ptsLabel, { + drawText(formattedQText + ptsLabel, { x: margin + labelWidth, y: y, size: 10, - font: font, color: rgb(0.1, 0.1, 0.1), }); y -= 18; @@ -570,11 +922,10 @@ async function generatePdfFromQuiz(texContent, outputPath) { q.options.forEach(opt => { checkPageBreak(16); const optText = formatLatexForPdf(opt.text); - page.drawText(`[ ] ${optText}`, { + drawText(`[ ] ${optText}`, { x: margin + 15, y: y, size: 9.5, - font: font, color: rgb(0.2, 0.2, 0.2), }); y -= 16; @@ -582,21 +933,19 @@ async function generatePdfFromQuiz(texContent, outputPath) { } else if (q.type === 'tendina') { checkPageBreak(16); const optionsList = [q.selectCorrect, ...q.selectWrong].map(formatLatexForPdf).join(', '); - page.drawText(`Scegli tra: ${optionsList}`, { + drawText(`Scegli tra: ${optionsList}`, { x: margin + 15, y: y, size: 9.5, - font: font, color: rgb(0.4, 0.4, 0.4), }); y -= 16; } else if (q.type === 'numerica') { checkPageBreak(16); - page.drawText(`Risposta: ____________________`, { + drawText(`Risposta: ____________________`, { x: margin + 15, y: y, size: 9.5, - font: font, color: rgb(0.3, 0.3, 0.3), }); y -= 16; @@ -611,11 +960,11 @@ async function generatePdfFromQuiz(texContent, outputPath) { checkPageBreak(40); y -= 10; - page.drawText(`Punteggio massimo ottenibile: ${totalPts} punti`, { + drawText(`Punteggio massimo ottenibile: ${totalPts} punti`, { x: margin, y: y, size: 11, - font: boldFont, + bold: true, color: rgb(0.25, 0.36, 0.23), }); @@ -642,26 +991,108 @@ function createZipArchive(files, destPath) { archive.finalize(); }); } +// --------------------------------------------------------------------------- +// Sicurezza: intestazioni, limiti, rate limit +// --------------------------------------------------------------------------- + +// Estensioni ammesse in upload. Tutto il resto viene rifiutato: il compilatore +// non ha alcun motivo di ricevere altri tipi di file. +const ALLOWED_EXTENSIONS = new Set(['.tex', '.sty', '.cfg', '.js', '.css', '.cls', '.bib']); + +// Tetto complessivo per richiesta (i limiti di multer sono per singolo file). +const MAX_TOTAL_UPLOAD_BYTES = 20 * 1024 * 1024; + +// La risposta contiene gli output in base64: oltre questa soglia si supera +// il limite di payload delle piattaforme serverless (Vercel: 4.5 MB). +const MAX_RESPONSE_BYTES = 3.5 * 1024 * 1024; + +app.use((req, res, next) => { + // Nessuna di queste pagine ha motivo di essere incorniciata o sniffata. + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), interest-cohort=()'); + // L'app non carica nulla da terze parti: la CSP lo rende esplicito. + // 'unsafe-inline' serve agli stili inline delle pagine e allo script di + // registrazione del service worker. + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "frame-src blob:", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join('; ') + ); + next(); +}); + +// Rate limit in memoria: /compile avvia processi LaTeX, quindi è l'endpoint +// costoso da proteggere. Su più istanze il conteggio è per istanza, il che +// va bene: serve a fermare gli abusi, non a fare quota precisa. +const rateBuckets = new Map(); +const RATE_WINDOW_MS = 60 * 1000; +const RATE_MAX_REQUESTS = 12; + +function rateLimit(req, res, next) { + const now = Date.now(); + // Su Vercel/nginx l'IP reale arriva in X-Forwarded-For. + const fwd = req.headers['x-forwarded-for']; + const key = (typeof fwd === 'string' ? fwd.split(',')[0].trim() : '') || req.ip || 'sconosciuto'; + + const bucket = rateBuckets.get(key); + if (!bucket || now > bucket.reset) { + rateBuckets.set(key, { count: 1, reset: now + RATE_WINDOW_MS }); + } else if (bucket.count >= RATE_MAX_REQUESTS) { + const retry = Math.ceil((bucket.reset - now) / 1000); + res.setHeader('Retry-After', String(retry)); + return res.status(429).json({ + success: false, + log: `Troppe compilazioni consecutive. Riprova fra ${retry} secondi.`, + }); + } else { + bucket.count += 1; + } + + // Pulizia opportunistica: evita che la mappa cresca all'infinito. + if (rateBuckets.size > 5000) { + for (const [k, v] of rateBuckets) if (now > v.reset) rateBuckets.delete(k); + } + next(); +} // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- -// Serve frontend assets with no-cache headers to ensure immediate UI updates -app.use('/', express.static(FRONTEND_DIR, { +const staticOptions = { etag: false, maxAge: 0, setHeaders: (res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - } -})); -app.use('/static', express.static(STATIC_DIR)); -app.use('/docs', express.static(DOCS_DIR)); + }, +}; + +app.use('/', express.static(FRONTEND_DIR, staticOptions)); +app.use('/docs', express.static(DOCS_DIR, staticOptions)); + +// Pagine legali (servite dal frontend, ma con URL puliti) +app.get('/termini', (req, res) => res.sendFile(path.join(FRONTEND_DIR, 'termini.html'))); +app.get('/privacy', (req, res) => res.sendFile(path.join(FRONTEND_DIR, 'privacy.html'))); // Endpoint per scaricare pacchetti ed esempi app.get('/api/download/package.zip', async (req, res) => { + let tmpDir; try { const filesToZip = [ path.join(LATEX_SOURCE_DIR, 'quizstruct.sty'), @@ -672,44 +1103,51 @@ app.get('/api/download/package.zip', async (req, res) => { path.join(BASE_DIR, 'esempi', 'lezione.tex'), path.join(BASE_DIR, 'esempi', 'fisica-lezione.tex'), path.join(BASE_DIR, 'esempi', 'matematica.tex'), - ].filter(f => fs.existsSync(f)); + ].filter((f) => fs.existsSync(f)); - const zipPath = path.join(STATIC_DIR, 'texforge-pacchetto-completo.zip'); + // Ogni richiesta usa una cartella propria: due download simultanei non + // possono più sovrascriversi lo stesso file a vicenda. + tmpDir = fs.mkdtempSync(path.join(WORK_ROOT, 'pkg-')); + const zipPath = path.join(tmpDir, 'texforge-pacchetto-completo.zip'); await createZipArchive(filesToZip, zipPath); - res.download(zipPath, 'texforge-pacchetto-completo.zip'); + + res.download(zipPath, 'texforge-pacchetto-completo.zip', () => { + fs.rm(tmpDir, { recursive: true, force: true }, () => {}); + }); } catch (err) { + if (tmpDir) fs.rm(tmpDir, { recursive: true, force: true }, () => {}); res.status(500).json({ error: 'Impossibile generare lo ZIP del pacchetto' }); } }); -app.get('/api/download/file/:filename', (req, res) => { - const allowedFiles = { - 'quizstruct.sty': path.join(LATEX_SOURCE_DIR, 'quizstruct.sty'), - 'quiz.cfg': path.join(LATEX_SOURCE_DIR, 'quiz.cfg'), - 'quiz.css': path.join(LATEX_SOURCE_DIR, 'quiz.css'), - 'quiz.js': path.join(LATEX_SOURCE_DIR, 'quiz.js'), - 'quiz.tex': path.join(LATEX_SOURCE_DIR, 'quiz.tex'), - 'lezione.tex': path.join(BASE_DIR, 'esempi', 'lezione.tex'), - 'fisica-lezione.tex': path.join(BASE_DIR, 'esempi', 'fisica-lezione.tex'), - 'matematica.tex': path.join(BASE_DIR, 'esempi', 'matematica.tex'), - }; +const DOWNLOADABLE_FILES = { + 'quizstruct.sty': path.join(LATEX_SOURCE_DIR, 'quizstruct.sty'), + 'quiz.cfg': path.join(LATEX_SOURCE_DIR, 'quiz.cfg'), + 'quiz.css': path.join(LATEX_SOURCE_DIR, 'quiz.css'), + 'quiz.js': path.join(LATEX_SOURCE_DIR, 'quiz.js'), + 'quiz.tex': path.join(LATEX_SOURCE_DIR, 'quiz.tex'), + 'lezione.tex': path.join(BASE_DIR, 'esempi', 'lezione.tex'), + 'fisica-lezione.tex': path.join(BASE_DIR, 'esempi', 'fisica-lezione.tex'), + 'matematica.tex': path.join(BASE_DIR, 'esempi', 'matematica.tex'), +}; - const filePath = allowedFiles[req.params.filename]; +app.get('/api/download/file/:filename', (req, res) => { + const filePath = DOWNLOADABLE_FILES[req.params.filename]; if (!filePath || !fs.existsSync(filePath)) { return res.status(404).json({ error: 'File non trovato' }); } res.download(filePath, req.params.filename); }); -app.get('/api/template/:filename', (req, res) => { - const allowedFiles = { - 'lezione.tex': path.join(BASE_DIR, 'esempi', 'lezione.tex'), - 'fisica-lezione.tex': path.join(BASE_DIR, 'esempi', 'fisica-lezione.tex'), - 'matematica.tex': path.join(BASE_DIR, 'esempi', 'matematica.tex'), - 'quiz.tex': path.join(LATEX_SOURCE_DIR, 'quiz.tex'), - }; +const TEMPLATE_FILES = { + 'lezione.tex': path.join(BASE_DIR, 'esempi', 'lezione.tex'), + 'fisica-lezione.tex': path.join(BASE_DIR, 'esempi', 'fisica-lezione.tex'), + 'matematica.tex': path.join(BASE_DIR, 'esempi', 'matematica.tex'), + 'quiz.tex': path.join(LATEX_SOURCE_DIR, 'quiz.tex'), +}; - const filePath = allowedFiles[req.params.filename]; +app.get('/api/template/:filename', (req, res) => { + const filePath = TEMPLATE_FILES[req.params.filename]; if (!filePath || !fs.existsSync(filePath)) { return res.status(404).json({ error: 'Esempio non trovato' }); } @@ -718,15 +1156,48 @@ app.get('/api/template/:filename', (req, res) => { }); // POST /compile -app.post('/compile', upload.array('files'), async (req, res) => { +// +// Gli output NON vengono salvati sul server: vengono restituiti dentro questa +// stessa risposta e la cartella di lavoro è cancellata prima di rispondere. +// Così non esistono URL indovinabili verso i documenti altrui, non resta nulla +// da ripulire dopo un riavvio e la funzione gira anche dove il filesystem è +// di sola lettura (Vercel e simili). +app.post('/compile', rateLimit, upload.array('files'), async (req, res) => { const files = req.files || []; + + const cleanupUploads = () => { + for (const f of files) { + if (f.path && fs.existsSync(f.path)) { + try { fs.unlinkSync(f.path); } catch { /* già rimosso */ } + } + } + }; + if (files.length === 0) { return res.status(400).json({ success: false, log: 'Nessun file caricato.' }); } - const jobId = Math.random().toString(36).substring(2, 14); - const workDir = path.join(COMPILED_DIR, jobId); - fs.mkdirSync(workDir, { recursive: true }); + const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0); + if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) { + cleanupUploads(); + return res.status(413).json({ + success: false, + log: `Upload troppo grande (${(totalBytes / 1048576).toFixed(1)} MB). Il massimo complessivo è ${MAX_TOTAL_UPLOAD_BYTES / 1048576} MB.`, + }); + } + + const rejected = files + .map((f) => path.basename(f.originalname)) + .filter((name) => !ALLOWED_EXTENSIONS.has(path.extname(name).toLowerCase())); + if (rejected.length > 0) { + cleanupUploads(); + return res.status(415).json({ + success: false, + log: `Tipo di file non ammesso: ${rejected.join(', ')}.\nSono accettati solo: ${[...ALLOWED_EXTENSIONS].join(' ')}`, + }); + } + + const workDir = fs.mkdtempSync(path.join(WORK_ROOT, 'job-')); try { for (const f of files) { @@ -748,8 +1219,8 @@ app.post('/compile', upload.array('files'), async (req, res) => { // Find main .tex files const workFiles = fs.readdirSync(workDir); - const texFiles = workFiles.filter(f => f.endsWith('.tex')); - + const texFiles = workFiles.filter((f) => f.endsWith('.tex')); + let mainStems = []; for (const tf of texFiles) { const content = fs.readFileSync(path.join(workDir, tf), 'utf-8'); @@ -758,7 +1229,7 @@ app.post('/compile', upload.array('files'), async (req, res) => { } } if (mainStems.length === 0) { - mainStems = texFiles.map(tf => path.parse(tf).name); + mainStems = texFiles.map((tf) => path.parse(tf).name); } if (mainStems.length === 0) { @@ -785,7 +1256,7 @@ app.post('/compile', upload.array('files'), async (req, res) => { const texContent = fs.readFileSync(texPath, 'utf-8'); // Compile PDF - let pdfPath = path.join(workDir, `${stem}.pdf`); + const pdfPath = path.join(workDir, `${stem}.pdf`); if (hasPdflatex) { try { const env = { ...process.env, openin_any: 'p', openout_any: 'p' }; @@ -807,10 +1278,10 @@ app.post('/compile', upload.array('files'), async (req, res) => { } // Compile HTML - let htmlPath = path.join(workDir, `${stem}.html`); + const htmlPath = path.join(workDir, `${stem}.html`); if (hasHtlatex) { try { - const cfgFile = fs.readdirSync(workDir).find(f => f.endsWith('.cfg')); + const cfgFile = fs.readdirSync(workDir).find((f) => f.endsWith('.cfg')); const cfgStem = cfgFile ? path.parse(cfgFile).name : null; const cmdArgs = cfgStem ? [stem, cfgStem] : [stem]; const env = { ...process.env, openin_any: 'p', openout_any: 'p' }; @@ -832,65 +1303,61 @@ app.post('/compile', upload.array('files'), async (req, res) => { } } - let pdfUrl = null; - let htmlUrl = null; - - if (pdfFiles.length === 1) { - const destName = `${jobId}.pdf`; - fs.copyFileSync(pdfFiles[0], path.join(STATIC_DIR, destName)); - pdfUrl = `/static/${destName}`; - } else if (pdfFiles.length > 1) { - const destName = `${jobId}-pdf.zip`; - await createZipArchive(pdfFiles, path.join(STATIC_DIR, destName)); - pdfUrl = `/static/${destName}`; - } + // Impacchetta gli output e restituiscili nella risposta. + const buildOutput = async (paths, kind, mime) => { + if (paths.length === 0) return null; + if (paths.length === 1) { + return { + kind, + zip: false, + filename: path.basename(paths[0]), + mime, + base64: fs.readFileSync(paths[0]).toString('base64'), + }; + } + const zipPath = path.join(workDir, `texforge-${kind}.zip`); + await createZipArchive(paths, zipPath); + return { + kind, + zip: true, + filename: `texforge-${kind}.zip`, + mime: 'application/zip', + base64: fs.readFileSync(zipPath).toString('base64'), + }; + }; - if (htmlFiles.length === 1) { - const destName = `${jobId}.html`; - fs.copyFileSync(htmlFiles[0], path.join(STATIC_DIR, destName)); - htmlUrl = `/static/${destName}`; - } else if (htmlFiles.length > 1) { - const destName = `${jobId}-html.zip`; - await createZipArchive(htmlFiles, path.join(STATIC_DIR, destName)); - htmlUrl = `/static/${destName}`; - } + const pdf = await buildOutput(pdfFiles, 'pdf', 'application/pdf'); + const html = await buildOutput(htmlFiles, 'html', 'text/html'); - // Auto-delete the static files after 5 minutes for privacy - setTimeout(() => { - if (pdfUrl) { - const p = path.join(STATIC_DIR, path.basename(pdfUrl)); - if (fs.existsSync(p)) fs.unlink(p, () => {}); - } - if (htmlUrl) { - const p = path.join(STATIC_DIR, path.basename(htmlUrl)); - if (fs.existsSync(p)) fs.unlink(p, () => {}); - } - }, 5 * 60 * 1000); + const payloadBytes = (pdf?.base64.length || 0) + (html?.base64.length || 0); + if (payloadBytes > MAX_RESPONSE_BYTES) { + return res.status(413).json({ + success: false, + log: `Gli output superano il limite di ${(MAX_RESPONSE_BYTES / 1048576).toFixed(1)} MB trasferibili in una sola risposta.\nProva a compilare meno documenti per volta.`, + }); + } return res.json({ success: true, log: logParts.join('\n'), - pdfUrl, - htmlUrl, - pdfZip: pdfFiles.length > 1, - htmlZip: htmlFiles.length > 1, + pdf, + html, }); } catch (err) { return res.status(500).json({ success: false, log: `Errore compilazione: ${err.message}` }); } finally { - // Always cleanup temporary uploaded files - for (const f of files) { - if (fs.existsSync(f.path)) { - try { fs.unlinkSync(f.path); } catch (e) {} - } - } - // Always cleanup compilation working directory - if (fs.existsSync(workDir)) { - try { fs.rmSync(workDir, { recursive: true, force: true }); } catch (e) {} - } + cleanupUploads(); + // La cartella di lavoro sparisce sempre, anche in caso di errore. + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* già rimossa */ } } }); -app.listen(PORT, '0.0.0.0', () => { - console.log(`texforge running on http://0.0.0.0:${PORT}`); -}); +// Su Vercel il modulo viene importato come handler: si mette in ascolto solo +// quando è eseguito direttamente. +if (!process.env.VERCEL) { + app.listen(PORT, '0.0.0.0', () => { + console.log(`texforge running on http://0.0.0.0:${PORT}`); + }); +} + +export default app; diff --git a/test_fisica.json b/test_fisica.json deleted file mode 100644 index 52e7a3d..0000000 --- a/test_fisica.json +++ /dev/null @@ -1 +0,0 @@ -{"success":true,"log":" auto-load: quizstruct.sty\n auto-load: quiz.cfg\n auto-load: quiz.js\n auto-load: quiz.css\nFile .tex da compilare: fisica-lezione\n✓ PDF: fisica-lezione.pdf\n✓ HTML: fisica-lezione.html","pdfUrl":"/static/jv0a6tgkv8.pdf","htmlUrl":"/static/jv0a6tgkv8.html","pdfZip":false,"htmlZip":false} \ No newline at end of file diff --git a/test_fisica2.json b/test_fisica2.json deleted file mode 100644 index bba0320..0000000 --- a/test_fisica2.json +++ /dev/null @@ -1 +0,0 @@ -{"success":true,"log":" auto-load: quizstruct.sty\n auto-load: quiz.cfg\n auto-load: quiz.js\n auto-load: quiz.css\nFile .tex da compilare: fisica-lezione\n✓ PDF: fisica-lezione.pdf\n✓ HTML: fisica-lezione.html","pdfUrl":"/static/g60u58wt07l.pdf","htmlUrl":"/static/g60u58wt07l.html","pdfZip":false,"htmlZip":false} \ No newline at end of file diff --git a/test_tabular.js b/test_tabular.js deleted file mode 100644 index 8132eaa..0000000 --- a/test_tabular.js +++ /dev/null @@ -1,28 +0,0 @@ -const htmlBody = ` -\\begin{tabular}{c|c} -$t$ (h) & $s$ (km) \\\\ -\\hline -0 & 0 \\\\ -1 & 120 \\\\ -2 & 240 \\\\ -3 & 360 \\\\ -\\end{tabular} -`; - -let res = htmlBody.replace(/\\begin\{tabular\}\{[^}]*\}([\s\S]*?)\\end\{tabular\}/g, (match, p1) => { - let rows = p1.split(/\\\\/); - let tableHtml = ''; - for (let r of rows) { - r = r.replace(/\\hline/g, '').trim(); - if (!r) continue; - let cols = r.split('&'); - tableHtml += ''; - for (let c of cols) { - tableHtml += ''; - } - tableHtml += ''; - } - tableHtml += '
' + c.trim() + '
'; - return tableHtml; -}); -console.log(res); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..c827642 --- /dev/null +++ b/vercel.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { "source": "/(.*)", "destination": "/api/index" } + ], + "functions": { + "api/index.js": { + "memory": 1024, + "maxDuration": 30, + "includeFiles": "{frontend,docs,latex-source,esempi,backend/fonts}/**" + } + } +}