Creare un'app in stile WeTransfer con Node.js
WeTransfer ha reso popolare un'idea tanto semplice quanto efficace: caricare uno o più file, ottenere un link opaco, condividerlo e lasciare che il contenuto scada dopo un certo periodo. Dietro questa apparente semplicità si nasconde una serie di problemi tecnici tutt'altro che banali: gestione di upload di grandi dimensioni senza saturare la memoria, generazione di token non indovinabili, streaming di archivi ZIP creati al volo, supporto alle richieste parziali, scadenza e pulizia automatica dei dati, protezione da abusi.
In questo articolo costruiremo, passo dopo passo, un'applicazione completa di questo tipo con Node.js, Express, Multer e SQLite. Il risultato sarà un servizio funzionante, con backend REST, frontend minimale con barra di avanzamento, protezione opzionale con password, notifiche via email, scadenza automatica dei trasferimenti e deploy dietro Nginx.
Requisiti e obiettivi funzionali
Prima di scrivere codice conviene fissare con precisione il perimetro dell'applicazione. Le funzionalità che implementeremo sono le seguenti.
- Upload di più file in un unico transfer, con limiti configurabili su numero e dimensione.
- Generazione di un link di download opaco, basato su un token crittograficamente sicuro.
- Download di un singolo file in streaming, con supporto alle richieste parziali (header
Range), utile per la ripresa dei download. - Download dell'intero transfer come archivio ZIP generato al volo, senza scriverlo su disco.
- Protezione opzionale con password, verificata tramite derivazione di chiave e confronto a tempo costante.
- Scadenza automatica dei transfer e cancellazione fisica dei file.
- Limite opzionale al numero massimo di download e contatore delle richieste.
- Notifica via email al mittente e ai destinatari.
- Token di eliminazione che permette al mittente di revocare un transfer in qualsiasi momento.
Restano volutamente fuori dall'ambito di questo articolo l'autenticazione degli utenti, la fatturazione e la distribuzione su CDN: sono livelli che si aggiungono naturalmente sopra l'architettura descritta.
Architettura generale
L'applicazione segue un'architettura a livelli piuttosto classica, con una separazione netta tra trasporto HTTP, logica di dominio e persistenza.
| Livello | Responsabilità | Componenti |
|---|---|---|
| Trasporto | Routing, validazione dell'input, serializzazione delle risposte | Express, Multer, middleware di rate limiting |
| Dominio | Creazione, lettura, scadenza e cancellazione dei transfer | Servizi applicativi puri |
| Persistenza | Metadati e binari | SQLite per i metadati, filesystem per i binari |
Una scelta importante riguarda la separazione tra metadati e binari. I metadati (nome originale, dimensione, tipo MIME, scadenza) finiscono in un database relazionale; i byte veri e propri finiscono sul filesystem, in file con nome casuale che non ha alcuna relazione con il nome originale. Questa separazione previene un'intera classe di vulnerabilità legate al path traversal e rende semplice, in un secondo momento, sostituire il filesystem locale con un object storage compatibile con S3 senza toccare la logica di dominio.
Un secondo principio guida è che i byte non devono mai transitare interamente in memoria. Ogni operazione, dall'upload al download allo ZIP, è realizzata con stream. Un file da 2 GB deve poter essere gestito da un processo Node.js con poche decine di megabyte di heap.
Inizializzazione del progetto
Creiamo la struttura di base e installiamo le dipendenze.
mkdir filedrop && cd filedrop
npm init -y
npm pkg set type="module"
npm install express multer better-sqlite3 archiver nodemailer helmet \
express-rate-limit dotenv node-cron mime-types
npm install --save-dev nodemon vitest supertest
Il valore type: "module" abilita la sintassi ESM nativa, che useremo in tutto il progetto. Vediamo brevemente il ruolo di ogni dipendenza.
- express: framework HTTP minimale.
- multer: parsing di richieste
multipart/form-datacon scrittura diretta su disco. - better-sqlite3: driver SQLite sincrono, estremamente veloce per carichi con molte letture.
- archiver: generazione di archivi ZIP in streaming.
- nodemailer: invio delle email transazionali.
- helmet ed express-rate-limit: header di sicurezza e protezione da abusi.
- node-cron: pianificazione del job di pulizia.
La struttura delle directory sarà la seguente.
filedrop/
├── src/
│ ├── config.js
│ ├── db.js
│ ├── app.js
│ ├── server.js
│ ├── middleware/
│ │ ├── upload.js
│ │ ├── errorHandler.js
│ │ └── limiters.js
│ ├── routes/
│ │ ├── transfers.js
│ │ └── downloads.js
│ ├── services/
│ │ ├── transferService.js
│ │ ├── storageService.js
│ │ ├── mailService.js
│ │ └── cleanupService.js
│ └── utils/
│ ├── tokens.js
│ ├── password.js
│ └── format.js
├── public/
│ ├── index.html
│ └── client.js
├── storage/
│ ├── blobs/
│ └── app.db
├── .env
└── package.json
Configurazione centralizzata
Tutta la configurazione viene letta una sola volta all'avvio e validata. Un errore di configurazione deve produrre un crash immediato, non un comportamento anomalo a runtime.
// src/config.js
import 'dotenv/config';
import path from 'node:path';
import crypto from 'node:crypto';
const rootDir = process.cwd();
function toInt(value, fallback) {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
export const config = {
env: process.env.NODE_ENV ?? 'development',
port: toInt(process.env.PORT, 3000),
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
storage: {
blobsDir: process.env.BLOBS_DIR ?? path.join(rootDir, 'storage', 'blobs'),
databaseFile: process.env.DATABASE_FILE ?? path.join(rootDir, 'storage', 'app.db'),
},
limits: {
// Dimensione massima del singolo file: 2 GB
maxFileSize: toInt(process.env.MAX_FILE_SIZE, 2 * 1024 * 1024 * 1024),
// Numero massimo di file per transfer
maxFiles: toInt(process.env.MAX_FILES, 20),
// Dimensione massima complessiva del transfer: 4 GB
maxTransferSize: toInt(process.env.MAX_TRANSFER_SIZE, 4 * 1024 * 1024 * 1024),
maxExpiryDays: toInt(process.env.MAX_EXPIRY_DAYS, 30),
defaultExpiryDays: toInt(process.env.DEFAULT_EXPIRY_DAYS, 7),
},
security: {
// Chiave usata per firmare i token di accesso ai transfer protetti
accessSecret: process.env.ACCESS_SECRET ?? crypto.randomBytes(32).toString('hex'),
accessTtlSeconds: toInt(process.env.ACCESS_TTL_SECONDS, 3600),
},
mail: {
enabled: process.env.MAIL_ENABLED === 'true',
host: process.env.SMTP_HOST,
port: toInt(process.env.SMTP_PORT, 587),
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.MAIL_FROM ?? 'FileDrop <no-reply@example.com>',
},
};
if (config.env === 'production' && !process.env.ACCESS_SECRET) {
throw new Error('ACCESS_SECRET è obbligatorio in produzione');
}
Si noti il controllo finale: in sviluppo è comodo generare una chiave effimera a ogni avvio, in produzione una chiave non persistente invaliderebbe tutti i token di accesso a ogni riavvio del processo. Meglio fallire subito e in modo esplicito.
Il file .env corrispondente:
# .env
NODE_ENV=development
PORT=3000
BASE_URL=http://localhost:3000
MAX_FILE_SIZE=2147483648
MAX_FILES=20
DEFAULT_EXPIRY_DAYS=7
ACCESS_SECRET=cambiami-con-una-stringa-casuale-di-64-caratteri
MAIL_ENABLED=false
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=no-reply@example.com
SMTP_PASS=segreto
MAIL_FROM=FileDrop <no-reply@example.com>
Schema del database
Il modello dati è composto da due tabelle. Un transfer rappresenta la spedizione nel suo insieme; ogni file appartiene a un transfer.
CREATE TABLE IF NOT EXISTS transfers (
id TEXT PRIMARY KEY,
download_token TEXT NOT NULL UNIQUE,
delete_token TEXT NOT NULL UNIQUE,
sender_email TEXT,
recipients TEXT NOT NULL DEFAULT '[]',
message TEXT,
password_hash TEXT,
password_salt TEXT,
total_size INTEGER NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0,
max_downloads INTEGER,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
transfer_id TEXT NOT NULL,
original_name TEXT NOT NULL,
storage_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
checksum TEXT,
position INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (transfer_id) REFERENCES transfers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_transfers_expires_at ON transfers(expires_at);
CREATE INDEX IF NOT EXISTS idx_files_transfer_id ON files(transfer_id);
Alcune osservazioni sul design dello schema. I timestamp sono memorizzati come interi (millisecondi Unix): SQLite non ha un tipo data nativo e questa rappresentazione rende i confronti banali e gli indici efficienti. Il campo recipients contiene un array JSON serializzato, una soluzione accettabile finché non serve interrogare i destinatari; nel momento in cui servisse, una tabella dedicata sarebbe la scelta corretta. La colonna deleted_at implementa una cancellazione logica: il record resta per fini statistici, ma i binari vengono rimossi.
Il modulo di accesso al database applica lo schema all'avvio e configura SQLite per un uso concorrente ragionevole.
// src/db.js
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.js';
fs.mkdirSync(path.dirname(config.storage.databaseFile), { recursive: true });
fs.mkdirSync(config.storage.blobsDir, { recursive: true });
export const db = new Database(config.storage.databaseFile);
// Il journal WAL permette letture concorrenti durante le scritture
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('synchronous = NORMAL');
const schema = `
CREATE TABLE IF NOT EXISTS transfers (
id TEXT PRIMARY KEY,
download_token TEXT NOT NULL UNIQUE,
delete_token TEXT NOT NULL UNIQUE,
sender_email TEXT,
recipients TEXT NOT NULL DEFAULT '[]',
message TEXT,
password_hash TEXT,
password_salt TEXT,
total_size INTEGER NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0,
max_downloads INTEGER,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
transfer_id TEXT NOT NULL,
original_name TEXT NOT NULL,
storage_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
checksum TEXT,
position INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (transfer_id) REFERENCES transfers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_transfers_expires_at ON transfers(expires_at);
CREATE INDEX IF NOT EXISTS idx_files_transfer_id ON files(transfer_id);
`;
db.exec(schema);
La modalità WAL merita una nota: senza di essa, SQLite blocca l'intero database durante ogni scrittura, e un upload concorrente potrebbe far fallire una lettura. Con WAL, lettori e scrittore procedono in parallelo, il che è più che sufficiente per il profilo di carico di un servizio come questo, dove le scritture sui metadati sono rare e brevi rispetto al tempo speso a muovere byte.
Generazione dei token
Il token di download è l'unico segreto che protegge il contenuto: deve essere impossibile da indovinare o enumerare. Non usiamo mai Math.random(), che non è crittograficamente sicuro, ma crypto.randomBytes().
// src/utils/tokens.js
import crypto from 'node:crypto';
import { config } from '../config.js';
// Alfabeto senza caratteri ambigui (0/O, 1/l/I): il token può essere letto ad alta voce
const ALPHABET = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export function generateToken(length = 22) {
const bytes = crypto.randomBytes(length);
let output = '';
for (let i = 0; i < length; i += 1) {
output += ALPHABET[bytes[i] % ALPHABET.length];
}
return output;
}
export function generateId() {
return crypto.randomUUID();
}
// Token di accesso firmato, rilasciato dopo la verifica della password
export function signAccessToken(transferId) {
const expiresAt = Date.now() + config.security.accessTtlSeconds * 1000;
const payload = `${transferId}.${expiresAt}`;
const signature = crypto
.createHmac('sha256', config.security.accessSecret)
.update(payload)
.digest('base64url');
return `${Buffer.from(payload).toString('base64url')}.${signature}`;
}
export function verifyAccessToken(token, transferId) {
if (typeof token !== 'string' || !token.includes('.')) return false;
const [encodedPayload, signature] = token.split('.');
let payload;
try {
payload = Buffer.from(encodedPayload, 'base64url').toString('utf8');
} catch {
return false;
}
const expected = crypto
.createHmac('sha256', config.security.accessSecret)
.update(payload)
.digest('base64url');
// Confronto a tempo costante per evitare timing attack
const signatureBuffer = Buffer.from(signature ?? '');
const expectedBuffer = Buffer.from(expected);
if (signatureBuffer.length !== expectedBuffer.length) return false;
if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) return false;
const [tokenTransferId, expiresAt] = payload.split('.');
if (tokenTransferId !== transferId) return false;
return Number(expiresAt) > Date.now();
}
Un token di 22 caratteri sull'alfabeto scelto offre circa 127 bit di entropia, ampiamente sufficienti: enumerare lo spazio delle chiavi è computazionalmente impossibile. L'uso di bytes[i] % ALPHABET.length introduce un bias statistico trascurabile ma reale; se si desidera eliminarlo del tutto, si può scartare e riestrarre i byte che cadono oltre il più grande multiplo della lunghezza dell'alfabeto.
Il modulo per la password usa scrypt, una funzione di derivazione deliberatamente costosa in tempo e memoria.
// src/utils/password.js
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(crypto.scrypt);
export async function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const derived = await scrypt(plain, salt, 64);
return { hash: derived.toString('hex'), salt };
}
export async function verifyPassword(plain, hash, salt) {
const derived = await scrypt(plain, salt, 64);
const stored = Buffer.from(hash, 'hex');
if (stored.length !== derived.length) return false;
return crypto.timingSafeEqual(stored, derived);
}
Il livello di storage
Il servizio di storage isola ogni accesso al filesystem. È l'unico punto del codice che conosce i percorsi reali, e applica una regola difensiva fondamentale: il percorso risolto deve trovarsi sotto la directory dei blob, altrimenti la richiesta viene rifiutata.
// src/services/storageService.js
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { config } from '../config.js';
const blobsDir = config.storage.blobsDir;
// I blob sono distribuiti in sottodirectory a due caratteri per evitare
// directory con centinaia di migliaia di voci
export function buildStoragePath(storageName) {
const prefix = storageName.slice(0, 2);
const resolved = path.resolve(blobsDir, prefix, storageName);
// Difesa contro il path traversal: il percorso deve restare nella radice
if (!resolved.startsWith(path.resolve(blobsDir) + path.sep)) {
throw new Error('Percorso di storage non valido');
}
return resolved;
}
export function generateStorageName() {
return crypto.randomBytes(16).toString('hex');
}
export async function ensureBlobDir(storageName) {
const dir = path.dirname(buildStoragePath(storageName));
await fsp.mkdir(dir, { recursive: true });
return dir;
}
export function createReadStream(storageName, options = {}) {
return fs.createReadStream(buildStoragePath(storageName), options);
}
export async function statBlob(storageName) {
return fsp.stat(buildStoragePath(storageName));
}
export async function removeBlob(storageName) {
try {
await fsp.unlink(buildStoragePath(storageName));
} catch (error) {
// Un blob già assente non è un errore: la pulizia deve essere idempotente
if (error.code !== 'ENOENT') throw error;
}
}
// Calcola l'hash SHA-256 leggendo il file in streaming
export async function computeChecksum(storageName) {
const hash = crypto.createHash('sha256');
const stream = createReadStream(storageName);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest('hex');
}
La distribuzione dei blob in sottodirectory basate sui primi due caratteri del nome è un accorgimento classico: con 256 sottodirectory possibili, anche un milione di file resta gestibile per il filesystem e per gli strumenti a riga di comando. È lo stesso schema usato internamente da Git per gli oggetti.
Ricezione dell'upload con Multer
Multer intercetta le richieste multipart/form-data e scrive ogni parte su disco man mano che arriva. Questo è il punto in cui si decide se l'applicazione reggerà file di grandi dimensioni: usando diskStorage anziché memoryStorage, i byte non entrano mai nell'heap del processo.
// src/middleware/upload.js
import multer from 'multer';
import mime from 'mime-types';
import path from 'node:path';
import { config } from '../config.js';
import { generateStorageName, ensureBlobDir, buildStoragePath } from '../services/storageService.js';
const storage = multer.diskStorage({
async destination(req, file, callback) {
try {
// Il nome di storage viene deciso qui e riusato in filename()
file.storageName = generateStorageName();
const dir = await ensureBlobDir(file.storageName);
callback(null, dir);
} catch (error) {
callback(error);
}
},
filename(req, file, callback) {
callback(null, file.storageName);
},
});
function fileFilter(req, file, callback) {
// Il nome originale arriva dal client: va trattato come dato non fidato
const safeName = path.basename(file.originalname).replace(/[\u0000-\u001f\u007f]/g, '');
if (safeName.length === 0 || safeName.length > 255) {
return callback(new Error('Nome del file non valido'));
}
file.safeName = safeName;
// Non ci si fida del Content-Type dichiarato dal client: viene ricalcolato
file.detectedMime = mime.lookup(safeName) || 'application/octet-stream';
callback(null, true);
}
export const uploadMiddleware = multer({
storage,
fileFilter,
limits: {
fileSize: config.limits.maxFileSize,
files: config.limits.maxFiles,
fields: 10,
fieldSize: 64 * 1024,
},
}).array('files', config.limits.maxFiles);
Tre punti meritano attenzione. Primo: il nome originale del file viene sempre normalizzato con path.basename(), perché un client malevolo può inviare stringhe come ../../etc/passwd; poiché però il nome originale non viene mai usato per costruire un percorso, questa è una difesa in profondità, non l'unica linea di protezione. Secondo: il tipo MIME dichiarato dal browser non è affidabile e viene ricalcolato dall'estensione. Terzo: i limiti sono espressi a livello di Multer, che interrompe il parsing non appena vengono superati, senza attendere la fine dell'upload.
Il servizio di dominio
Qui vive la logica applicativa vera e propria, indipendente da Express. Ogni funzione riceve dati semplici e restituisce dati semplici, il che la rende facilmente testabile.
// src/services/transferService.js
import { db } from '../db.js';
import { generateId, generateToken } from '../utils/tokens.js';
import { hashPassword } from '../utils/password.js';
import { removeBlob } from './storageService.js';
import { config } from '../config.js';
const DAY_MS = 24 * 60 * 60 * 1000;
const insertTransferStmt = db.prepare(`
INSERT INTO transfers (
id, download_token, delete_token, sender_email, recipients, message,
password_hash, password_salt, total_size, max_downloads, created_at, expires_at
) VALUES (
@id, @downloadToken, @deleteToken, @senderEmail, @recipients, @message,
@passwordHash, @passwordSalt, @totalSize, @maxDownloads, @createdAt, @expiresAt
)
`);
const insertFileStmt = db.prepare(`
INSERT INTO files (id, transfer_id, original_name, storage_name, mime_type, size, position)
VALUES (@id, @transferId, @originalName, @storageName, @mimeType, @size, @position)
`);
const selectByDownloadTokenStmt = db.prepare(`
SELECT * FROM transfers WHERE download_token = ? AND deleted_at IS NULL
`);
const selectByDeleteTokenStmt = db.prepare(`
SELECT * FROM transfers WHERE delete_token = ? AND deleted_at IS NULL
`);
const selectFilesStmt = db.prepare(`
SELECT * FROM files WHERE transfer_id = ? ORDER BY position ASC
`);
const incrementDownloadStmt = db.prepare(`
UPDATE transfers SET download_count = download_count + 1 WHERE id = ?
`);
const markDeletedStmt = db.prepare(`
UPDATE transfers SET deleted_at = ? WHERE id = ?
`);
// La creazione avviene in una transazione: o si scrivono transfer e file, o niente
const createTransferTx = db.transaction((transfer, files) => {
insertTransferStmt.run(transfer);
for (const file of files) {
insertFileStmt.run(file);
}
});
export async function createTransfer({ files, senderEmail, recipients, message, password, expiryDays, maxDownloads }) {
if (!Array.isArray(files) || files.length === 0) {
throw Object.assign(new Error('Nessun file caricato'), { status: 400 });
}
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
if (totalSize > config.limits.maxTransferSize) {
throw Object.assign(new Error('Transfer troppo grande'), { status: 413 });
}
const days = Math.min(
Math.max(Number(expiryDays) || config.limits.defaultExpiryDays, 1),
config.limits.maxExpiryDays,
);
let passwordHash = null;
let passwordSalt = null;
if (password) {
const derived = await hashPassword(password);
passwordHash = derived.hash;
passwordSalt = derived.salt;
}
const now = Date.now();
const transfer = {
id: generateId(),
downloadToken: generateToken(22),
deleteToken: generateToken(32),
senderEmail: senderEmail ?? null,
recipients: JSON.stringify(recipients ?? []),
message: message ?? null,
passwordHash,
passwordSalt,
totalSize,
maxDownloads: maxDownloads ? Number(maxDownloads) : null,
createdAt: now,
expiresAt: now + days * DAY_MS,
};
const fileRows = files.map((file, index) => ({
id: generateId(),
transferId: transfer.id,
originalName: file.safeName ?? file.originalname,
storageName: file.storageName ?? file.filename,
mimeType: file.detectedMime ?? file.mimetype,
size: file.size,
position: index,
}));
createTransferTx(transfer, fileRows);
return { transfer, files: fileRows };
}
export function findByDownloadToken(token) {
return selectByDownloadTokenStmt.get(token) ?? null;
}
export function findByDeleteToken(token) {
return selectByDeleteTokenStmt.get(token) ?? null;
}
export function listFiles(transferId) {
return selectFilesStmt.all(transferId);
}
export function isExpired(transfer) {
if (transfer.expires_at <= Date.now()) return true;
if (transfer.max_downloads !== null && transfer.download_count >= transfer.max_downloads) {
return true;
}
return false;
}
export function registerDownload(transferId) {
incrementDownloadStmt.run(transferId);
}
export async function deleteTransfer(transfer) {
const files = listFiles(transfer.id);
for (const file of files) {
await removeBlob(file.storage_name);
}
markDeletedStmt.run(Date.now(), transfer.id);
}
L'uso di db.transaction() di better-sqlite3 è essenziale: senza di esso, un errore durante l'inserimento del terzo file lascerebbe nel database un transfer con soli due file, in uno stato incoerente. La funzione restituita esegue automaticamente BEGIN, COMMIT e, in caso di eccezione, ROLLBACK.
Notevole anche la scelta di precompilare tutti gli statement a livello di modulo. In SQLite la preparazione di una query ha un costo non trascurabile; farla una volta sola all'avvio significa che ogni richiesta paga solo il costo dell'esecuzione.
Le rotte di creazione
Il router espone la creazione del transfer, la lettura dei metadati, lo sblocco tramite password e l'eliminazione.
// src/routes/transfers.js
import express from 'express';
import { uploadMiddleware } from '../middleware/upload.js';
import { uploadLimiter, apiLimiter } from '../middleware/limiters.js';
import { verifyPassword } from '../utils/password.js';
import { signAccessToken } from '../utils/tokens.js';
import { config } from '../config.js';
import * as transferService from '../services/transferService.js';
import * as mailService from '../services/mailService.js';
export const transfersRouter = express.Router();
function parseRecipients(raw) {
if (!raw) return [];
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return String(raw)
.split(',')
.map((value) => value.trim())
.filter((value) => pattern.test(value))
.slice(0, 10);
}
function serializeTransfer(transfer, files) {
return {
downloadUrl: `${config.baseUrl}/d/${transfer.download_token ?? transfer.downloadToken}`,
expiresAt: new Date(transfer.expires_at ?? transfer.expiresAt).toISOString(),
totalSize: transfer.total_size ?? transfer.totalSize,
protected: Boolean(transfer.password_hash ?? transfer.passwordHash),
files: files.map((file) => ({
id: file.id,
name: file.original_name ?? file.originalName,
size: file.size,
mimeType: file.mime_type ?? file.mimeType,
})),
};
}
transfersRouter.post('/transfers', uploadLimiter, (req, res, next) => {
uploadMiddleware(req, res, async (uploadError) => {
if (uploadError) return next(uploadError);
try {
const { transfer, files } = await transferService.createTransfer({
files: req.files,
senderEmail: req.body.senderEmail,
recipients: parseRecipients(req.body.recipients),
message: req.body.message,
password: req.body.password,
expiryDays: req.body.expiryDays,
maxDownloads: req.body.maxDownloads,
});
// L'invio delle email non deve bloccare la risposta al client
mailService.notifyTransferCreated(transfer, files).catch((error) => {
console.error('Invio email fallito', error);
});
res.status(201).json({
...serializeTransfer(transfer, files),
deleteUrl: `${config.baseUrl}/api/transfers/${transfer.deleteToken}/delete`,
});
} catch (error) {
next(error);
}
});
});
transfersRouter.get('/transfers/:token', apiLimiter, (req, res) => {
const transfer = transferService.findByDownloadToken(req.params.token);
if (!transfer || transferService.isExpired(transfer)) {
return res.status(404).json({ error: 'Transfer non trovato o scaduto' });
}
const files = transferService.listFiles(transfer.id);
// Se il transfer è protetto, i metadati dei file non vengono rivelati
if (transfer.password_hash) {
return res.json({
protected: true,
expiresAt: new Date(transfer.expires_at).toISOString(),
fileCount: files.length,
totalSize: transfer.total_size,
});
}
res.json(serializeTransfer(transfer, files));
});
transfersRouter.post('/transfers/:token/unlock', apiLimiter, async (req, res) => {
const transfer = transferService.findByDownloadToken(req.params.token);
if (!transfer || transferService.isExpired(transfer)) {
return res.status(404).json({ error: 'Transfer non trovato o scaduto' });
}
if (!transfer.password_hash) {
return res.status(400).json({ error: 'Il transfer non è protetto' });
}
const valid = await verifyPassword(
String(req.body.password ?? ''),
transfer.password_hash,
transfer.password_salt,
);
if (!valid) {
return res.status(401).json({ error: 'Password errata' });
}
const files = transferService.listFiles(transfer.id);
res.json({
accessToken: signAccessToken(transfer.id),
...serializeTransfer(transfer, files),
});
});
transfersRouter.delete('/transfers/:deleteToken', apiLimiter, async (req, res, next) => {
try {
const transfer = transferService.findByDeleteToken(req.params.deleteToken);
if (!transfer) {
return res.status(404).json({ error: 'Transfer non trovato' });
}
await transferService.deleteTransfer(transfer);
res.status(204).end();
} catch (error) {
next(error);
}
});
Due dettagli di sicurezza. Il primo: quando un transfer è protetto da password, l'endpoint dei metadati non rivela i nomi dei file, che potrebbero essere essi stessi informazione sensibile. Il secondo: l'invio delle email avviene in modo asincrono rispetto alla risposta HTTP. Un server SMTP lento non deve mai far attendere l'utente che ha appena completato un upload da un gigabyte; l'errore viene registrato ma non propagato.
Download in streaming con supporto Range
Il download è la parte più delicata. Un client che scarica un file da 2 GB deve poter riprendere da dove si era interrotto, e il server deve poter servire decine di download concorrenti senza allocare memoria proporzionale alla dimensione dei file.
// src/routes/downloads.js
import express from 'express';
import archiver from 'archiver';
import { verifyAccessToken } from '../utils/tokens.js';
import * as storage from '../services/storageService.js';
import * as transferService from '../services/transferService.js';
export const downloadsRouter = express.Router();
// Codifica il nome del file secondo la RFC 5987 per gestire i caratteri non ASCII
function contentDisposition(filename) {
const asciiFallback = filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '');
const encoded = encodeURIComponent(filename);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
function loadTransfer(req, res) {
const transfer = transferService.findByDownloadToken(req.params.token);
if (!transfer || transferService.isExpired(transfer)) {
res.status(404).json({ error: 'Transfer non trovato o scaduto' });
return null;
}
if (transfer.password_hash) {
const accessToken = req.query.access ?? req.get('x-access-token');
if (!verifyAccessToken(accessToken, transfer.id)) {
res.status(401).json({ error: 'Accesso non autorizzato' });
return null;
}
}
return transfer;
}
// Interpreta un header Range della forma "bytes=start-end"
function parseRange(header, size) {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const [, rawStart, rawEnd] = match;
let start;
let end;
if (rawStart === '') {
// Forma "bytes=-500": gli ultimi 500 byte
const suffixLength = Number(rawEnd);
if (!Number.isFinite(suffixLength) || suffixLength === 0) return null;
start = Math.max(size - suffixLength, 0);
end = size - 1;
} else {
start = Number(rawStart);
end = rawEnd === '' ? size - 1 : Number(rawEnd);
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start > end || start >= size) return null;
return { start, end: Math.min(end, size - 1) };
}
downloadsRouter.get('/download/:token/file/:fileId', async (req, res, next) => {
try {
const transfer = loadTransfer(req, res);
if (!transfer) return;
const file = transferService
.listFiles(transfer.id)
.find((candidate) => candidate.id === req.params.fileId);
if (!file) return res.status(404).json({ error: 'File non trovato' });
const stats = await storage.statBlob(file.storage_name);
const range = parseRange(req.get('range'), stats.size);
res.setHeader('Content-Type', file.mime_type);
res.setHeader('Content-Disposition', contentDisposition(file.original_name));
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'private, no-store');
if (req.get('range') && !range) {
res.setHeader('Content-Range', `bytes */${stats.size}`);
return res.status(416).end();
}
if (range) {
res.status(206);
res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${stats.size}`);
res.setHeader('Content-Length', range.end - range.start + 1);
} else {
res.setHeader('Content-Length', stats.size);
transferService.registerDownload(transfer.id);
}
const stream = storage.createReadStream(file.storage_name, range ?? {});
stream.on('error', next);
// Se il client interrompe la connessione, lo stream va chiuso esplicitamente
res.on('close', () => stream.destroy());
stream.pipe(res);
} catch (error) {
next(error);
}
});
Il contatore dei download viene incrementato solo per le richieste complete, non per quelle parziali: altrimenti un client che riprende un download interrotto consumerebbe più "crediti" di uno che lo completa al primo tentativo.
Il gestore su res.on('close') è tutt'altro che un dettaglio. Quando un utente annulla un download, Node.js non chiude automaticamente lo stream di lettura sottostante: senza stream.destroy() si accumulerebbero descrittori di file aperti fino a esaurire il limite del processo, un classico leak in produzione che si manifesta solo sotto carico reale.
Archivio ZIP generato al volo
Per scaricare l'intero transfer in un colpo solo generiamo uno ZIP in streaming. Il punto cruciale è che l'archivio non viene mai materializzato su disco né in memoria: archiver produce byte man mano e li invia direttamente nella risposta.
downloadsRouter.get('/download/:token/zip', async (req, res, next) => {
const transfer = loadTransfer(req, res);
if (!transfer) return;
const files = transferService.listFiles(transfer.id);
if (files.length === 0) {
return res.status(404).json({ error: 'Nessun file da scaricare' });
}
const archiveName = `filedrop-${transfer.download_token}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', contentDisposition(archiveName));
res.setHeader('Cache-Control', 'private, no-store');
// Livello 0: i file caricati sono spesso già compressi (jpeg, mp4, pdf).
// Comprimerli di nuovo costerebbe CPU senza ridurre i byte trasmessi.
const archive = archiver('zip', { store: true });
archive.on('warning', (warning) => {
if (warning.code !== 'ENOENT') next(warning);
});
archive.on('error', next);
res.on('close', () => archive.abort());
archive.pipe(res);
const usedNames = new Map();
for (const file of files) {
// Due file possono avere lo stesso nome: si disambigua con un suffisso
let entryName = file.original_name;
if (usedNames.has(entryName)) {
const count = usedNames.get(entryName) + 1;
usedNames.set(entryName, count);
const extension = entryName.includes('.') ? entryName.slice(entryName.lastIndexOf('.')) : '';
const base = extension ? entryName.slice(0, -extension.length) : entryName;
entryName = `${base} (${count})${extension}`;
} else {
usedNames.set(entryName, 0);
}
archive.append(storage.createReadStream(file.storage_name), { name: entryName });
}
transferService.registerDownload(transfer.id);
await archive.finalize();
});
La scelta di store: true (nessuna compressione) è deliberata. Il contenuto tipico di un servizio di questo tipo è già compresso: immagini JPEG, video MP4, PDF, archivi. Applicare Deflate a questi dati consuma CPU significativa per un guadagno che spesso è inferiore all'uno per cento, e in alcuni casi produce persino un file leggermente più grande. Se si sa che il contenuto sarà comprimibile, si può passare a { zlib: { level: 6 } }.
Un limite intrinseco di questo approccio: poiché lo ZIP viene generato al volo, non conosciamo in anticipo la sua dimensione finale e non possiamo inviare un header Content-Length. Il browser mostrerà quindi un download di dimensione ignota. Con store: true la dimensione sarebbe in realtà calcolabile a priori, sommando i byte dei file e l'overhead delle strutture ZIP, ma è un'ottimizzazione che raramente vale la complessità aggiuntiva.
Rate limiting e gestione degli errori
Un servizio di file hosting aperto al pubblico è un bersaglio naturale per gli abusi. Il rate limiting è la prima linea di difesa.
// src/middleware/limiters.js
import rateLimit from 'express-rate-limit';
export const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 20, // Venti upload all'ora per indirizzo IP
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Troppi upload, riprova più tardi' },
});
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
});
Il gestore degli errori centralizzato traduce le eccezioni di Multer in risposte HTTP sensate e, soprattutto, rimuove i file già scritti su disco quando la creazione del transfer fallisce.
// src/middleware/errorHandler.js
import multer from 'multer';
import { removeBlob } from '../services/storageService.js';
const MULTER_MESSAGES = {
LIMIT_FILE_SIZE: 'Il file supera la dimensione massima consentita',
LIMIT_FILE_COUNT: 'Troppi file in un singolo transfer',
LIMIT_UNEXPECTED_FILE: 'Campo file non atteso',
};
export async function errorHandler(error, req, res, next) {
// Se l'upload è fallito dopo la scrittura di alcuni file, questi vanno rimossi
if (Array.isArray(req.files)) {
for (const file of req.files) {
if (file.storageName) {
await removeBlob(file.storageName).catch(() => {});
}
}
}
if (error instanceof multer.MulterError) {
return res.status(413).json({
error: MULTER_MESSAGES[error.code] ?? 'Upload non valido',
});
}
const status = error.status ?? 500;
if (status >= 500) {
console.error(error);
}
res.status(status).json({
error: status >= 500 ? 'Errore interno del server' : error.message,
});
}
Questa pulizia è ciò che distingue un prototipo da un servizio reale. Senza di essa, ogni upload fallito lascerebbe file orfani sul disco, invisibili al database e quindi mai raggiunti dal job di pulizia: dopo qualche mese il disco si riempirebbe di byte che nessuno sa spiegare.
Scadenza e pulizia automatica
Il job di pulizia gira periodicamente e rimuove i transfer scaduti. È progettato per essere idempotente e per non caricare in memoria l'intera tabella.
// src/services/cleanupService.js
import cron from 'node-cron';
import { db } from '../db.js';
import { removeBlob } from './storageService.js';
const selectExpiredStmt = db.prepare(`
SELECT id FROM transfers
WHERE deleted_at IS NULL AND expires_at <= ?
LIMIT 100
`);
const selectFilesStmt = db.prepare('SELECT storage_name FROM files WHERE transfer_id = ?');
const markDeletedStmt = db.prepare('UPDATE transfers SET deleted_at = ? WHERE id = ?');
export async function runCleanup() {
let removedCount = 0;
// Si lavora a blocchi di cento per non bloccare il database a lungo
for (;;) {
const expired = selectExpiredStmt.all(Date.now());
if (expired.length === 0) break;
for (const transfer of expired) {
const files = selectFilesStmt.all(transfer.id);
for (const file of files) {
await removeBlob(file.storage_name);
}
markDeletedStmt.run(Date.now(), transfer.id);
removedCount += 1;
}
}
return removedCount;
}
export function scheduleCleanup() {
// Ogni ora, al minuto zero
cron.schedule('0 * * * *', async () => {
try {
const count = await runCleanup();
if (count > 0) console.log(`Pulizia completata: ${count} transfer rimossi`);
} catch (error) {
console.error('Pulizia fallita', error);
}
});
}
Si noti che la cancellazione fisica avviene prima di marcare il record come eliminato. L'ordine inverso creerebbe una finestra in cui, se il processo crashasse, il transfer risulterebbe eliminato ma i suoi blob resterebbero sul disco per sempre. Con l'ordine adottato, un crash lascia al più un transfer già svuotato ma non marcato, che il ciclo successivo riprenderà senza danni: removeBlob ignora i file già assenti.
In un deploy con più istanze del processo, questo job va eseguito su una sola istanza, oppure va introdotto un lock (una tabella dedicata con un INSERT vincolato, o un lock su Redis) per evitare esecuzioni sovrapposte.
Notifiche via email
// src/services/mailService.js
import nodemailer from 'nodemailer';
import { config } from '../config.js';
import { formatBytes } from '../utils/format.js';
let transporter = null;
function getTransporter() {
if (!config.mail.enabled) return null;
if (transporter) return transporter;
transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === 465,
auth: { user: config.mail.user, pass: config.mail.pass },
});
return transporter;
}
export async function notifyTransferCreated(transfer, files) {
const mailer = getTransporter();
if (!mailer) return;
const downloadUrl = `${config.baseUrl}/d/${transfer.downloadToken}`;
const fileList = files.map((file) => `- ${file.originalName} (${formatBytes(file.size)})`).join('\n');
const expiresAt = new Date(transfer.expiresAt).toLocaleString('it-IT');
const body = [
transfer.message ? `${transfer.message}\n` : '',
'Hai ricevuto dei file:',
fileList,
'',
`Scarica tutto: ${downloadUrl}`,
`Il link scade il ${expiresAt}.`,
].join('\n');
const recipients = JSON.parse(transfer.recipients ?? '[]');
if (recipients.length > 0) {
await mailer.sendMail({
from: config.mail.from,
to: recipients.join(', '),
subject: transfer.senderEmail
? `${transfer.senderEmail} ti ha inviato dei file`
: 'Hai ricevuto dei file',
text: body,
});
}
if (transfer.senderEmail) {
await mailer.sendMail({
from: config.mail.from,
to: transfer.senderEmail,
subject: 'Il tuo transfer è pronto',
text: [
`Link di download: ${downloadUrl}`,
`Link di eliminazione: ${config.baseUrl}/api/transfers/${transfer.deleteToken}`,
`Scadenza: ${expiresAt}`,
].join('\n'),
});
}
}
Il transporter viene creato una sola volta e riutilizzato: Nodemailer mantiene un pool di connessioni SMTP, e ricrearlo a ogni invio significherebbe pagare l'handshake TLS ogni volta.
Una nota operativa importante: inviare email contenenti link a file caricati da utenti anonimi è un ottimo modo per finire nelle blocklist. In produzione servono SPF, DKIM e DMARC configurati correttamente, e una qualche forma di verifica dell'indirizzo del mittente.
Assemblaggio dell'applicazione
// src/utils/format.js
export function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = Number(bytes);
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
// src/app.js
import express from 'express';
import helmet from 'helmet';
import path from 'node:path';
import { transfersRouter } from './routes/transfers.js';
import { downloadsRouter } from './routes/downloads.js';
import { errorHandler } from './middleware/errorHandler.js';
export function createApp() {
const app = express();
// Necessario dietro un reverse proxy per ottenere l'IP reale del client
app.set('trust proxy', 1);
app.disable('x-powered-by');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
},
},
// Necessario per permettere il download di file da altre origini
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(express.urlencoded({ extended: false, limit: '64kb' }));
app.use(express.json({ limit: '64kb' }));
app.use(express.static(path.join(process.cwd(), 'public')));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api', transfersRouter);
app.use('/', downloadsRouter);
// La pagina di download è servita dal frontend statico
app.get('/d/:token', (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'download.html'));
});
app.use((req, res) => res.status(404).json({ error: 'Risorsa non trovata' }));
app.use(errorHandler);
return app;
}
// src/server.js
import { createApp } from './app.js';
import { config } from './config.js';
import { scheduleCleanup } from './services/cleanupService.js';
const app = createApp();
const server = app.listen(config.port, () => {
console.log(`FileDrop in ascolto su ${config.baseUrl}`);
});
// Gli upload lenti non devono essere interrotti dal timeout predefinito
server.requestTimeout = 0;
server.headersTimeout = 60_000;
server.keepAliveTimeout = 65_000;
scheduleCleanup();
// Spegnimento controllato: si attende la fine delle richieste in corso
function shutdown(signal) {
console.log(`Ricevuto ${signal}, arresto in corso`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 30_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
La riga server.requestTimeout = 0 è essenziale e viene spesso dimenticata. A partire da Node.js 18 esiste un timeout predefinito di 300 secondi sull'intera richiesta: un upload di 2 GB da una connessione domestica lo supera facilmente e verrebbe troncato senza spiegazioni apparenti. Disabilitarlo è sicuro solo perché Multer impone già limiti stringenti sulla dimensione dei dati accettati.
Il frontend
Il client deve fare una cosa che fetch() ancora non permette in modo semplice: riportare l'avanzamento dell'upload. Per questo motivo usiamo XMLHttpRequest, che espone l'evento upload.onprogress.
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FileDrop</title>
</head>
<body>
<h1>FileDrop</h1>
<form id="upload-form">
<p>
<label for="files">File da inviare</label>
<input type="file" id="files" name="files" multiple required>
</p>
<p>
<label for="senderEmail">La tua email</label>
<input type="email" id="senderEmail" name="senderEmail">
</p>
<p>
<label for="recipients">Destinatari (separati da virgola)</label>
<input type="text" id="recipients" name="recipients">
</p>
<p>
<label for="message">Messaggio</label>
<textarea id="message" name="message" rows="4"></textarea>
</p>
<p>
<label for="password">Password (opzionale)</label>
<input type="password" id="password" name="password" autocomplete="new-password">
</p>
<p>
<label for="expiryDays">Scadenza in giorni</label>
<input type="number" id="expiryDays" name="expiryDays" value="7" min="1" max="30">
</p>
<p>
<button type="submit" id="submit-button">Invia</button>
</p>
</form>
<progress id="progress" value="0" max="100" hidden></progress>
<p id="status" role="status" aria-live="polite"></p>
<p id="result" hidden></p>
<script src="/client.js" type="module"></script>
</body>
</html>
// public/client.js
const form = document.querySelector('#upload-form');
const progress = document.querySelector('#progress');
const status = document.querySelector('#status');
const result = document.querySelector('#result');
const submitButton = document.querySelector('#submit-button');
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function uploadWithProgress(formData, onProgress) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('POST', '/api/transfers');
request.responseType = 'json';
let lastLoaded = 0;
let lastTime = Date.now();
request.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) return;
const now = Date.now();
const elapsed = (now - lastTime) / 1000;
// La velocità viene stimata solo ogni mezzo secondo per evitare oscillazioni
const speed = elapsed > 0.5 ? (event.loaded - lastLoaded) / elapsed : null;
if (speed !== null) {
lastLoaded = event.loaded;
lastTime = now;
}
onProgress({
percent: (event.loaded / event.total) * 100,
loaded: event.loaded,
total: event.total,
speed,
});
});
request.addEventListener('load', () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.response);
} else {
reject(new Error(request.response?.error ?? `Errore HTTP ${request.status}`));
}
});
request.addEventListener('error', () => reject(new Error('Errore di rete')));
request.addEventListener('abort', () => reject(new Error('Upload annullato')));
request.send(formData);
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
submitButton.disabled = true;
progress.hidden = false;
result.hidden = true;
status.textContent = 'Caricamento in corso...';
let currentSpeed = null;
try {
const response = await uploadWithProgress(formData, (info) => {
progress.value = info.percent;
if (info.speed !== null) currentSpeed = info.speed;
const speedLabel = currentSpeed ? ` a ${formatBytes(currentSpeed)}/s` : '';
status.textContent =
`${info.percent.toFixed(1)}% — ${formatBytes(info.loaded)} di ${formatBytes(info.total)}${speedLabel}`;
});
status.textContent = 'Caricamento completato.';
result.hidden = false;
result.textContent = `Link di download: ${response.downloadUrl}`;
form.reset();
} catch (error) {
status.textContent = `Errore: ${error.message}`;
} finally {
submitButton.disabled = false;
}
});
Il calcolo della velocità è campionato ogni mezzo secondo anziché a ogni evento progress: il browser emette questi eventi con frequenza irregolare, e calcolare la velocità istantanea su intervalli di pochi millisecondi produce numeri che saltano in modo inutilizzabile.
Test automatici
La separazione tra app.js e server.js paga proprio qui: possiamo istanziare l'applicazione senza aprire una porta.
// test/transfers.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';
let app;
beforeAll(() => {
app = createApp();
});
describe('API dei transfer', () => {
it('crea un transfer e restituisce un link di download', async () => {
const response = await request(app)
.post('/api/transfers')
.attach('files', Buffer.from('contenuto di prova'), 'documento.txt')
.field('expiryDays', '3')
.expect(201);
expect(response.body.downloadUrl).toMatch(/\/d\/[A-Za-z0-9]{22}$/);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].name).toBe('documento.txt');
});
it('rifiuta una richiesta senza file', async () => {
await request(app).post('/api/transfers').expect(400);
});
it('restituisce 404 per un token inesistente', async () => {
await request(app).get('/api/transfers/tokenNonEsistente').expect(404);
});
it('non rivela i nomi dei file di un transfer protetto', async () => {
const created = await request(app)
.post('/api/transfers')
.attach('files', Buffer.from('segreto'), 'riservato.txt')
.field('password', 'passwordSicura123')
.expect(201);
const token = created.body.downloadUrl.split('/').pop();
const metadata = await request(app).get(`/api/transfers/${token}`).expect(200);
expect(metadata.body.protected).toBe(true);
expect(metadata.body.files).toBeUndefined();
});
it('supporta le richieste parziali', async () => {
const created = await request(app)
.post('/api/transfers')
.attach('files', Buffer.from('0123456789'), 'numeri.txt')
.expect(201);
const token = created.body.downloadUrl.split('/').pop();
const fileId = created.body.files[0].id;
const response = await request(app)
.get(`/download/${token}/file/${fileId}`)
.set('Range', 'bytes=2-5')
.expect(206);
expect(response.headers['content-range']).toBe('bytes 2-5/10');
expect(response.text).toBe('2345');
});
});
Deploy dietro Nginx
La configurazione di Nginx per un servizio di questo tipo contiene due direttive che, se sbagliate, rendono l'applicazione inutilizzabile con file grandi.
server {
listen 443 ssl http2;
server_name filedrop.example.com;
ssl_certificate /etc/letsencrypt/live/filedrop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/filedrop.example.com/privkey.pem;
# Nessun limite sulla dimensione del corpo: il controllo è delegato a Multer
client_max_body_size 0;
# Il corpo della richiesta viene inoltrato in streaming, non bufferizzato su disco
proxy_request_buffering off;
proxy_buffering off;
# Timeout ampi: un upload lento non deve essere interrotto
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_body_timeout 3600s;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Il valore predefinito di client_max_body_size è appena 1 MB: senza modificarlo, ogni upload significativo riceve un 413 che non arriva nemmeno all'applicazione Node.js, generando confusione durante il debug. La direttiva proxy_request_buffering off è altrettanto importante: per impostazione predefinita Nginx scrive l'intero corpo della richiesta su disco prima di inoltrarlo, raddoppiando la scrittura e ritardando l'inizio dell'elaborazione fino al completamento dell'upload.
Containerizzazione
# Dockerfile
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
# better-sqlite3 richiede la compilazione di un modulo nativo
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& npm ci --omit=dev \
&& apt-get purge -y python3 make g++ && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
COPY src ./src
COPY public ./public
RUN mkdir -p /app/storage/blobs && chown -R node:node /app/storage
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "src/server.js"]
# docker-compose.yml
services:
app:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file: .env
volumes:
# Lo storage deve sopravvivere alla ricreazione del container
- filedrop_storage:/app/storage
tmpfs:
- /tmp
volumes:
filedrop_storage:
Il volume nominato per /app/storage non è un dettaglio: contiene sia il database SQLite sia i blob. Dimenticarlo significa perdere tutti i transfer a ogni ricreazione del container.
Considerazioni sulla scalabilità
L'architettura descritta regge senza problemi il carico di un servizio interno o di un progetto di dimensioni contenute su una singola macchina. Quando i limiti si avvicinano, questi sono i punti da affrontare, in ordine di priorità.
| Collo di bottiglia | Sintomo | Intervento |
|---|---|---|
| Banda in uscita del server | Download lenti sotto carico | Delegare la consegna a Nginx con X-Accel-Redirect o a un object storage con URL prefirmati |
| Disco locale | Impossibile aggiungere istanze | Migrare i blob su object storage compatibile S3 |
| SQLite | Contesa in scrittura con molte istanze | Passare a PostgreSQL |
| Rate limiting in memoria | Limiti aggirabili con più istanze | Store condiviso su Redis |
| Upload attraverso l'applicazione | CPU e banda consumate inutilmente | Upload diretto dal browser all'object storage con URL prefirmati |
Il passaggio più trasformativo è l'ultimo. Con gli URL prefirmati, il browser invia i byte direttamente all'object storage e il backend interviene solo per emettere l'autorizzazione e registrare i metadati a upload concluso. L'applicazione Node.js smette di essere un intermediario per i byte e diventa un puro servizio di coordinamento: il consumo di banda e CPU crolla, e la scalabilità orizzontale diventa banale.
La delega a Nginx tramite X-Accel-Redirect è invece un intervento a costo quasi nullo che si può adottare subito: l'applicazione verifica il token e la scadenza, poi risponde con un header che indica a Nginx quale file servire, uscendo completamente dal percorso dei dati.
// Variante del download che delega la consegna a Nginx
downloadsRouter.get('/download/:token/file/:fileId', (req, res) => {
const transfer = loadTransfer(req, res);
if (!transfer) return;
const file = transferService
.listFiles(transfer.id)
.find((candidate) => candidate.id === req.params.fileId);
if (!file) return res.status(404).json({ error: 'File non trovato' });
transferService.registerDownload(transfer.id);
res.setHeader('Content-Type', file.mime_type);
res.setHeader('Content-Disposition', contentDisposition(file.original_name));
// Nginx intercetta questo header e serve il file direttamente dal disco
res.setHeader('X-Accel-Redirect', `/protected/${file.storage_name.slice(0, 2)}/${file.storage_name}`);
res.end();
});
location /protected/ {
# Accessibile solo tramite X-Accel-Redirect, mai direttamente dall'esterno
internal;
alias /var/lib/filedrop/blobs/;
}
La direttiva internal è ciò che rende sicura questa configurazione: senza di essa l'intera directory dei blob sarebbe esposta pubblicamente, e chiunque conoscesse o indovinasse un nome di storage potrebbe scaricare qualsiasi file bypassando ogni controllo.
Sicurezza: riepilogo delle misure adottate
Vale la pena raccogliere in un unico punto le decisioni di sicurezza sparse nel codice, perché in un servizio che accetta file da utenti anonimi ogni scelta ha conseguenze concrete.
- Token opachi ad alta entropia: 22 caratteri casuali generati con
crypto.randomBytes()rendono l'enumerazione impossibile. - Nessun nome utente nel percorso di storage: i blob hanno nomi casuali, scorrelati dai nomi originali, il che neutralizza il path traversal alla radice.
- Verifica del percorso risolto:
buildStoragePath()rifiuta qualunque percorso che esca dalla directory dei blob. - Confronto a tempo costante:
timingSafeEqual()per password e firme HMAC. - Derivazione lenta della password:
scryptrende gli attacchi a dizionario costosi. - Nessuna informazione rivelata sui transfer protetti: i nomi dei file restano nascosti fino allo sblocco.
- Content-Type non fidato: il tipo MIME viene ricalcolato lato server.
- Header
Content-Disposition: attachment: il contenuto non viene mai renderizzato inline, il che previene l'esecuzione di HTML o SVG malevolo nel contesto del dominio. - Rate limiting su upload e API.
- Header di sicurezza tramite Helmet, inclusa una Content Security Policy restrittiva.
L'header Content-Disposition: attachment merita un'ultima sottolineatura. Se un file HTML caricato da un utente venisse servito inline dal dominio dell'applicazione, il suo JavaScript girerebbe con l'origine del servizio: sarebbe una XSS memorizzata offerta a chiunque. Forzare il download elimina il problema; servire i file da un dominio separato, senza cookie di sessione, lo elimina in modo ancora più radicale ed è la strada seguita da tutti i servizi commerciali di questo tipo.
Conclusioni
Abbiamo costruito un servizio di condivisione file completo in poco più di seicento righe di JavaScript, senza compromessi sulle parti che contano davvero: nessun byte transita per l'heap, gli upload interrotti non lasciano rifiuti, i download riprendono da dove si erano fermati, i token sono impossibili da indovinare e i dati scadono davvero.
Le lezioni che si portano via da un progetto del genere sono trasferibili ben oltre il caso specifico. Gli stream non sono un'ottimizzazione prematura ma il modello mentale corretto per qualunque applicazione che muova dati di dimensione arbitraria. La separazione tra metadati e binari apre la porta a migrazioni indolori verso storage distribuiti. E le difese in profondità (validazione dell'input, verifica dei percorsi risolti, confronti a tempo costante) costano poche righe quando sono progettate fin dall'inizio, mentre diventano riscritture dolorose quando vengono aggiunte dopo un incidente.
Da qui, le direzioni di sviluppo naturali sono l'upload chunked con ripresa lato client, l'anteprima delle immagini generata con sharp, la scansione antivirus tramite ClamAV, e un pannello di amministrazione per il monitoraggio dei transfer attivi.