feat(transfers): add PicTransfer — cross-event file transfers (#998)
Closes #997. Send original files from any event as a token-protected download link, with an optional client-upload channel. Strictly opt-in behind a new `transfers` feature flag, default OFF. Migrations 170-172 (transfers, transfer_files, transfer_extra_files, transfer_uploads, transfer_recipients, transfer_downloads, default settings and two email templates) — all hasTable/hasColumn-guarded and idempotent, with destructive statements confined to down(). Backend: transferService (CRUD, 256-bit download token, 6-char upload token, cross-event ZIP streaming of originals), admin CRUD routes, and two public token routes. transferCleanupService runs an hourly retention sweep; source-event photos are never touched. All three routers fail closed via requireFeatureFlag('transfers'). Review closed two ownership blockers, both the same root cause — permissions used where ownership was needed: - photoIds arrived from the request body and were validated only for existence, so a scoped admin could bundle any event's originals and hand them out through the public download token. filterOwnedPhotoIds now resolves ids to their events and gates them through filterOwnedEventIds, on both the create and add-files paths. - The transfer list was unscoped and carried each row's download token, so any admin with events.view could read another's token and fetch their originals. The list is now scoped by created_by, the token/url fields are stripped from the list payload, and a single router.use('/:id', requireTransferOwnership) covers all twelve /:id routes, 404ing foreign and missing alike. The admin photo picker filters its event list to the same rule, so the UI stops offering picks the API would discard. Fork-PR workflows had not been approved since the fix commits, so the PR's green checks were stale against the pre-fix head. Verified by dispatching tests.yml against the actual head: backend and frontend both green. Follow-up: neither ownership guard has a regression test yet. Co-authored-by: Luca-Timo <[email protected]>
This commit is contained in:
@@ -88,6 +88,11 @@ const KNOWN_FLAGS = [
|
||||
// per-event-type presets and global watermark defaults tab. Strictly opt-in;
|
||||
// gates all slideshow admin UI (per-event card, type preset, settings tab).
|
||||
'slideshow',
|
||||
// PicTransfer (migration 170) — cross-event file transfers
|
||||
// (recipient download link + optional client-upload channel). Strictly
|
||||
// opt-in; gates the sidebar entry, the /admin/transfers area AND every
|
||||
// transfer route (admin + public token routes).
|
||||
'transfers',
|
||||
// Workflow / automation engine — admin-configurable visual flows (triggers,
|
||||
// conditions, branches, loops, approval gates). Strictly opt-in; master
|
||||
// kill-switch for the Workflows admin area AND the engine's runtime side
|
||||
@@ -122,6 +127,7 @@ const DEFAULT_FLAGS = {
|
||||
projects: false,
|
||||
whatsapp: false,
|
||||
slideshow: false,
|
||||
transfers: false,
|
||||
workflows: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Admin → Transfers routes (PicTransfer, #997).
|
||||
*
|
||||
* Mounted at /api/admin/transfers. A transfer bundles ORIGINAL photos picked
|
||||
* from any event into a token-protected download link, and can optionally open
|
||||
* a short upload token so the client can send files back.
|
||||
*
|
||||
* Read = `events.view`; write = `events.edit` (transfers are an
|
||||
* events/photos-adjacent admin tool, so they ride the same permissions as the
|
||||
* projects cockpit rather than inventing a new permission).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { sanitizeFilename } = require('../utils/filenameSanitizer');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const transferService = require('../services/transferService');
|
||||
const logger = require('../utils/logger');
|
||||
const fs = require('fs');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Admin deliverable-file upload (the files dropped into a transfer) --------
|
||||
// Bytes are written to a temp dir, handed to the storage backend (so S3 works),
|
||||
// then the temp copy is removed — same shape as the public client-upload route.
|
||||
const ADMIN_MAX_FILES = 50;
|
||||
const DEFAULT_ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/tiff', 'application/pdf', 'application/zip'];
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
const tempStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = path.join(getStoragePath(), 'temp', 'transfer-admin-uploads');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const safe = sanitizeFilename(path.basename(file.originalname), 80) || 'file';
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}-${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
function buildAdminUploader(maxSizeBytes, allowed) {
|
||||
return multer({
|
||||
storage: tempStorage,
|
||||
limits: { fileSize: maxSizeBytes, files: ADMIN_MAX_FILES },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||
return cb(new Error('This file type is not allowed'));
|
||||
},
|
||||
}).array('files', ADMIN_MAX_FILES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run multer for a transfer request, reading the size/type limits from settings.
|
||||
* Resolves { ok:true } or sends a 4xx and resolves { ok:false }.
|
||||
*/
|
||||
async function runAdminUpload(req, res) {
|
||||
const maxSizeMb = Number(await getAppSetting('transfer_max_upload_size_mb', 50)) || 50;
|
||||
const allowedSetting = await getAppSetting('transfer_upload_allowed_mime', DEFAULT_ALLOWED);
|
||||
const allowed = Array.isArray(allowedSetting) ? allowedSetting : DEFAULT_ALLOWED;
|
||||
const uploader = buildAdminUploader(maxSizeMb * 1024 * 1024, allowed);
|
||||
try {
|
||||
await new Promise((resolve, reject) => uploader(req, res, (err) => (err ? reject(err) : resolve())));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const msg = err && err.code === 'LIMIT_FILE_SIZE'
|
||||
? `Each file must be ${maxSizeMb} MB or smaller`
|
||||
: (err && err.message) || 'Upload failed';
|
||||
if (!res.headersSent) res.status(400).json({ error: msg, code: 'UPLOAD_REJECTED' });
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the uploaded temp files as the transfer's deliverable extra files. */
|
||||
async function storeExtraFiles(transferId, files) {
|
||||
if (!files || !files.length) return;
|
||||
const storage = getStorage();
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i += 1;
|
||||
const safeName = sanitizeFilename(path.basename(file.originalname), 120) || 'file';
|
||||
const key = path.posix.join(transferService.extraFilesDirKey(transferId), `${Date.now()}-${i}-${safeName}`);
|
||||
try {
|
||||
await storage.putFromFile(key, file.path);
|
||||
await transferService.addExtraFile(transferId, {
|
||||
originalFilename: file.originalname,
|
||||
storedPath: key,
|
||||
sizeBytes: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('adminTransfers: failed to store deliverable file', { transferId, error: err.message });
|
||||
} finally {
|
||||
try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch (_) { /* noop */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a multipart field that carries a JSON array (photoIds, recipientEmails). */
|
||||
function parseJsonArrayField(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
// Fallback: comma-separated (e.g. a raw "[email protected], [email protected]" email field).
|
||||
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
router.use(adminAuth);
|
||||
// PicTransfer is a strictly opt-in module — refuse every admin transfer route
|
||||
// when the `transfers` feature flag is off, so a disabled feature is never
|
||||
// actable even by a direct API hit (the sidebar already hides the surface).
|
||||
router.use(requireFeatureFlag('transfers'));
|
||||
|
||||
/**
|
||||
* Ownership guard for every `/:id` route. A non-super_admin may only touch a
|
||||
* transfer they created (or an ownerless legacy row). Foreign AND missing ids
|
||||
* both 404 so the endpoint isn't an existence oracle — the same posture
|
||||
* filterOwnedEventIds takes. super_admin is unrestricted.
|
||||
*/
|
||||
async function requireTransferOwnership(req, res, next) {
|
||||
try {
|
||||
if (req.admin.roleName === 'super_admin') return next();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const owner = await transferService.getTransferOwner(id);
|
||||
if (!owner) return res.status(404).json({ error: 'Transfer not found' });
|
||||
if (owner.created_by != null && owner.created_by !== req.admin.id) {
|
||||
return res.status(404).json({ error: 'Transfer not found' });
|
||||
}
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// List
|
||||
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
|
||||
const transfers = await transferService.listTransfers({ search: req.query.q || '', admin: req.admin });
|
||||
return successResponse(res, { transfers });
|
||||
}));
|
||||
|
||||
// Create. multipart/form-data: text fields + optional `files` (the operator's
|
||||
// own deliverable files) + `photoIds`/`recipientEmails` as JSON-array fields.
|
||||
// Uploaded files land as transfer_extra_files; delivery_method='email' emails
|
||||
// the recipients the download link.
|
||||
router.post('/',
|
||||
requirePermission('events.edit'),
|
||||
handleAsync(async (req, res) => {
|
||||
const up = await runAdminUpload(req, res);
|
||||
if (!up.ok) return; // 4xx already sent
|
||||
|
||||
const b = req.body || {};
|
||||
const photoIds = parseJsonArrayField(b.photoIds)
|
||||
.map(Number).filter((n) => Number.isInteger(n) && n > 0).slice(0, 5000);
|
||||
const recipientEmails = parseJsonArrayField(b.recipientEmails)
|
||||
.map((e) => String(e || '').trim()).filter(Boolean).slice(0, 100);
|
||||
const deliveryMethod = b.deliveryMethod === 'email' ? 'email' : 'link';
|
||||
|
||||
const transfer = await transferService.createTransfer({
|
||||
title: b.title,
|
||||
message: b.message,
|
||||
expiresInDays: b.expiresInDays,
|
||||
maxDownloads: b.maxDownloads,
|
||||
graceDays: b.graceDays,
|
||||
allowUploads: b.allowUploads === 'true' || b.allowUploads === true,
|
||||
uploadExpiresInDays: b.uploadExpiresInDays,
|
||||
photoIds,
|
||||
deliveryMethod,
|
||||
}, req.admin);
|
||||
|
||||
await storeExtraFiles(transfer.id, req.files);
|
||||
|
||||
if (deliveryMethod === 'email' && recipientEmails.length) {
|
||||
await transferService.sendTransferEmails(transfer.id, recipientEmails);
|
||||
}
|
||||
|
||||
const fresh = await transferService.getTransfer(transfer.id);
|
||||
return successResponse(res, { transfer: fresh }, 201, 'Transfer created');
|
||||
}),
|
||||
);
|
||||
|
||||
// Ownership guard for every `/:id`, `/:id/files`, `/:id/download`, … route.
|
||||
// One mount covers them all — the POST `/` create + GET `/` list above are not
|
||||
// matched (no :id), and each route keeps its own requirePermission.
|
||||
router.use('/:id', requireTransferOwnership);
|
||||
|
||||
// Detail
|
||||
router.get('/:id',
|
||||
requirePermission('events.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.getTransfer(parseInt(req.params.id, 10));
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { transfer });
|
||||
}),
|
||||
);
|
||||
|
||||
// Update
|
||||
router.patch('/:id',
|
||||
requirePermission('events.edit'),
|
||||
[
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('title').optional({ nullable: true }).isString().isLength({ max: 255 }),
|
||||
body('message').optional({ nullable: true }).isString().isLength({ max: 5000 }),
|
||||
body('maxDownloads').optional({ nullable: true }).isInt({ min: 0, max: 1000000 }),
|
||||
body('graceDays').optional({ nullable: true }).isInt({ min: 0, max: 365 }),
|
||||
body('expiresInDays').optional({ nullable: true }).isInt({ min: 1, max: 3650 }),
|
||||
body('expiresAt').optional({ nullable: true }).isISO8601(),
|
||||
body('isActive').optional().isBoolean(),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.updateTransfer(parseInt(req.params.id, 10), {
|
||||
title: req.body.title,
|
||||
message: req.body.message,
|
||||
maxDownloads: req.body.maxDownloads,
|
||||
graceDays: req.body.graceDays,
|
||||
expiresInDays: req.body.expiresInDays,
|
||||
expiresAt: req.body.expiresAt,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { transfer }, 200, 'Transfer updated');
|
||||
}),
|
||||
);
|
||||
|
||||
// Delete
|
||||
router.delete('/:id',
|
||||
requirePermission('events.edit'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const ok = await transferService.deleteTransfer(parseInt(req.params.id, 10));
|
||||
if (!ok) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { deleted: true }, 200, 'Transfer deleted');
|
||||
}),
|
||||
);
|
||||
|
||||
// Add photos (cross-event) to a transfer
|
||||
router.post('/:id/files',
|
||||
requirePermission('events.edit'),
|
||||
[
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('photoIds').isArray({ min: 1, max: 5000 }),
|
||||
body('photoIds.*').isInt({ min: 1 }),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const existing = await transferService.getTransfer(parseInt(req.params.id, 10));
|
||||
if (!existing) return res.status(404).json({ error: 'Transfer not found' });
|
||||
const transfer = await transferService.addFiles(parseInt(req.params.id, 10), req.body.photoIds, req.admin);
|
||||
return successResponse(res, { transfer }, 200, 'Files added');
|
||||
}),
|
||||
);
|
||||
|
||||
// Remove one file from a transfer
|
||||
router.delete('/:id/files/:fileId',
|
||||
requirePermission('events.edit'),
|
||||
[param('id').isInt({ min: 1 }), param('fileId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.removeFile(
|
||||
parseInt(req.params.id, 10), parseInt(req.params.fileId, 10),
|
||||
);
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { transfer }, 200, 'File removed');
|
||||
}),
|
||||
);
|
||||
|
||||
// Upload deliverable files into an existing transfer (multipart `files`).
|
||||
router.post('/:id/upload-files',
|
||||
requirePermission('events.edit'),
|
||||
handleAsync(async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const existing = await transferService.getTransfer(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Transfer not found' });
|
||||
const up = await runAdminUpload(req, res);
|
||||
if (!up.ok) return;
|
||||
if (!req.files || !req.files.length) {
|
||||
return res.status(400).json({ error: 'No files uploaded', code: 'NO_FILES' });
|
||||
}
|
||||
await storeExtraFiles(id, req.files);
|
||||
const transfer = await transferService.getTransfer(id);
|
||||
return successResponse(res, { transfer }, 200, 'Files added');
|
||||
}),
|
||||
);
|
||||
|
||||
// Remove one admin-uploaded deliverable file from a transfer
|
||||
router.delete('/:id/extra-files/:extraId',
|
||||
requirePermission('events.edit'),
|
||||
[param('id').isInt({ min: 1 }), param('extraId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.removeExtraFile(
|
||||
parseInt(req.params.id, 10), parseInt(req.params.extraId, 10),
|
||||
);
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { transfer }, 200, 'File removed');
|
||||
}),
|
||||
);
|
||||
|
||||
// Admin download of a single admin-uploaded deliverable file
|
||||
router.get('/:id/extra-files/:extraId/download',
|
||||
requirePermission('events.view'),
|
||||
[param('id').isInt({ min: 1 }), param('extraId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.getTransfer(parseInt(req.params.id, 10));
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
const ok = await transferService.streamTransferExtraFile(
|
||||
{ id: transfer.id }, parseInt(req.params.extraId, 10), res,
|
||||
);
|
||||
if (!ok && !res.headersSent) return res.status(404).json({ error: 'File not found' });
|
||||
}),
|
||||
);
|
||||
|
||||
// Enable / regenerate the client-upload link
|
||||
router.post('/:id/upload-link',
|
||||
requirePermission('events.edit'),
|
||||
[param('id').isInt({ min: 1 }), body('uploadExpiresInDays').optional({ nullable: true }).isInt({ min: 1, max: 3650 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.enableUploads(
|
||||
parseInt(req.params.id, 10), { uploadExpiresInDays: req.body.uploadExpiresInDays },
|
||||
);
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { transfer }, 200, 'Upload link enabled');
|
||||
}),
|
||||
);
|
||||
|
||||
// Disable the client-upload link
|
||||
router.delete('/:id/upload-link',
|
||||
requirePermission('events.edit'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.disableUploads(parseInt(req.params.id, 10));
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
return successResponse(res, { transfer }, 200, 'Upload link disabled');
|
||||
}),
|
||||
);
|
||||
|
||||
// Admin download of the whole transfer (ZIP of originals). No expiry/limit
|
||||
// gate — this is the operator retrieving their own bundle.
|
||||
router.get('/:id/download',
|
||||
requirePermission('photos.download'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.getTransfer(parseInt(req.params.id, 10));
|
||||
if (!transfer) return res.status(404).json({ error: 'Transfer not found' });
|
||||
// getTransfer returns the serialized view; streamTransferArchive only needs
|
||||
// { id, title }, both present on it.
|
||||
await transferService.streamTransferArchive(transfer, res);
|
||||
}),
|
||||
);
|
||||
|
||||
// Admin download of a single client-uploaded file
|
||||
router.get('/:id/uploads/:uploadId/download',
|
||||
requirePermission('events.view'),
|
||||
[param('id').isInt({ min: 1 }), param('uploadId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const upload = await transferService.getUpload(
|
||||
parseInt(req.params.id, 10), parseInt(req.params.uploadId, 10),
|
||||
);
|
||||
if (!upload) return res.status(404).json({ error: 'Upload not found' });
|
||||
res.setHeader('Content-Type', upload.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(upload.original_filename)}"`);
|
||||
if (upload.localPath && fs.existsSync(upload.localPath)) {
|
||||
return fs.createReadStream(upload.localPath).pipe(res);
|
||||
}
|
||||
// S3 / non-local backend: stream via the storage abstraction.
|
||||
const { getStorage } = require('../services/storage');
|
||||
const stream = await getStorage().get(upload.stored_path);
|
||||
return stream.pipe(res);
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Public → Transfer download routes (PicTransfer, #997).
|
||||
*
|
||||
* Mounted at /api/public/transfer. NO authentication — the 64-hex token in the
|
||||
* recipient's link is the only secret. The recipient page has NO thumbnails by
|
||||
* design; this API exposes filenames + sizes only, never image URLs.
|
||||
*
|
||||
* Surface:
|
||||
* GET /:token metadata view (title, message, file list, expiry)
|
||||
* GET /:token/download ZIP of all ORIGINAL files
|
||||
* GET /:token/download/:fileId single ORIGINAL file
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { param } = require('express-validator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { clientIpForAudit } = require('../utils/clientIp');
|
||||
const transferService = require('../services/transferService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Belt-and-braces: a recipient link must stop resolving the moment an admin
|
||||
// turns PicTransfer off under Settings → Features, same as every other gated
|
||||
// module. The token is still the only secret; this just fails closed.
|
||||
router.use(requireFeatureFlag('transfers'));
|
||||
|
||||
const viewLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
|
||||
const downloadLimiter = rateLimit({ windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
const tokenValidator = [param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)];
|
||||
|
||||
// Recipient view. Always resolves for a live (non-deleted) transfer so the page
|
||||
// can render an "expired" state; file list is only included while downloadable.
|
||||
router.get('/:token', viewLimiter, tokenValidator, handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.getTransferByToken(req.params.token);
|
||||
if (!transfer) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
|
||||
|
||||
const gate = transferService.assertDownloadable(transfer);
|
||||
if (!gate.ok) {
|
||||
return successResponse(res, {
|
||||
transfer: {
|
||||
title: transfer.title || 'Transfer',
|
||||
status: gate.code === 'DOWNLOAD_LIMIT_REACHED' ? 'limit_reached' : 'expired',
|
||||
expires_at: transfer.expires_at,
|
||||
downloadable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const view = await transferService.getPublicView(transfer);
|
||||
return successResponse(res, { transfer: { ...view, status: 'active', downloadable: true } });
|
||||
}));
|
||||
|
||||
// Download all as a ZIP of originals.
|
||||
router.get('/:token/download', downloadLimiter, tokenValidator, handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.getTransferByToken(req.params.token);
|
||||
const gate = transferService.assertDownloadable(transfer);
|
||||
if (!gate.ok) {
|
||||
return res.status(gate.status).json({ error: 'This link is no longer available', code: gate.code });
|
||||
}
|
||||
// Count the download BEFORE streaming so a mid-stream disconnect still
|
||||
// counts against the cap (matches the "disable after N downloads" intent).
|
||||
await transferService.recordDownload(transfer, { kind: 'all', ip: clientIpForAudit(req) });
|
||||
await transferService.streamTransferArchive(transfer, res);
|
||||
}));
|
||||
|
||||
// Download a single original file. The file id is prefixed — `p<id>` for a
|
||||
// referenced gallery photo, `x<id>` for an admin-uploaded deliverable file (a
|
||||
// bare number is tolerated as a photo id) — so the service reads the right table.
|
||||
router.get('/:token/download/:fileId', downloadLimiter,
|
||||
[...tokenValidator, param('fileId').matches(/^[px]?[0-9]{1,15}$/i)],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await transferService.getTransferByToken(req.params.token);
|
||||
const gate = transferService.assertDownloadable(transfer);
|
||||
if (!gate.ok) {
|
||||
return res.status(gate.status).json({ error: 'This link is no longer available', code: gate.code });
|
||||
}
|
||||
const ok = await transferService.streamTransferFile(
|
||||
transfer, req.params.fileId, res,
|
||||
);
|
||||
if (!ok && !res.headersSent) {
|
||||
return res.status(404).json({ error: 'File not found', code: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
if (ok) {
|
||||
await transferService.recordDownload(transfer, {
|
||||
kind: 'single', photoId: null, ip: clientIpForAudit(req),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Public → Transfer upload routes (PicTransfer client uploads, #997).
|
||||
*
|
||||
* Mounted at /api/public/transfer-upload. NO authentication — a short (6-char)
|
||||
* upload token in the link is the only secret. This lets a photographer send a
|
||||
* client "here's a code, upload your logo / files here". Because the token is
|
||||
* low-entropy, brute force is mitigated by a tight per-route rate limiter plus
|
||||
* the shared per-IP bad-attempt lockout, and the guard runs BEFORE multer so a
|
||||
* bad token never costs a disk write.
|
||||
*
|
||||
* Surface:
|
||||
* GET /:token metadata (transfer title, allowed types, size limit)
|
||||
* POST /:token multipart upload (field name: files)
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { param } = require('express-validator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { clientIpForAudit } = require('../utils/clientIp');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { sanitizeFilename } = require('../utils/filenameSanitizer');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const transferService = require('../services/transferService');
|
||||
const { _internal: tokenLock } = require('../utils/publicTokenGuards');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Fail closed when PicTransfer is off — no client upload accepted (or even
|
||||
// probed) once an admin disables the feature under Settings → Features.
|
||||
router.use(requireFeatureFlag('transfers'));
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const MAX_FILES_PER_UPLOAD = 25;
|
||||
const DEFAULT_ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/tiff', 'application/pdf', 'application/zip'];
|
||||
|
||||
const infoLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false });
|
||||
const uploadLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// Upload tokens are drawn from an unambiguous alphabet (see transferService).
|
||||
// Accept a small range of lengths so a future longer token still validates.
|
||||
const TOKEN_RE = /^[A-Za-z0-9]{4,16}$/;
|
||||
|
||||
async function loadUploadTransfer(req, res) {
|
||||
const ip = clientIpForAudit(req);
|
||||
if (tokenLock.isIpLocked(ip)) {
|
||||
res.status(429).json({ error: 'Too many invalid attempts. Try again later.', code: 'TOKEN_LOOKUP_LOCKED' });
|
||||
return null;
|
||||
}
|
||||
const token = req.params.token;
|
||||
if (!token || !TOKEN_RE.test(token)) {
|
||||
res.status(400).json({ error: 'Invalid token format', code: 'BAD_TOKEN' });
|
||||
return null;
|
||||
}
|
||||
const transfer = await transferService.getTransferByUploadToken(token);
|
||||
if (!transfer) {
|
||||
tokenLock.recordBadAttempt(ip);
|
||||
res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
|
||||
return null;
|
||||
}
|
||||
const gate = transferService.assertUploadable(transfer);
|
||||
if (!gate.ok) {
|
||||
res.status(gate.status).json({ error: 'This upload link is no longer available', code: gate.code });
|
||||
return null;
|
||||
}
|
||||
return transfer;
|
||||
}
|
||||
|
||||
// Metadata for the upload page.
|
||||
router.get('/:token', infoLimiter, [param('token').matches(TOKEN_RE)], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const transfer = await loadUploadTransfer(req, res);
|
||||
if (!transfer) return;
|
||||
const maxSizeMb = Number(await getAppSetting('transfer_max_upload_size_mb', 50)) || 50;
|
||||
const allowed = await getAppSetting('transfer_upload_allowed_mime', DEFAULT_ALLOWED);
|
||||
return successResponse(res, {
|
||||
transfer: {
|
||||
title: transfer.title || 'Upload',
|
||||
message: transfer.message || null,
|
||||
expires_at: transfer.upload_expires_at || transfer.expires_at,
|
||||
max_size_mb: maxSizeMb,
|
||||
max_files: MAX_FILES_PER_UPLOAD,
|
||||
allowed_mime: Array.isArray(allowed) ? allowed : DEFAULT_ALLOWED,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Pre-multer guard: validates the token + upload eligibility BEFORE any bytes
|
||||
// touch disk, and stashes the transfer for the destination/handler.
|
||||
async function preUploadGuard(req, res, next) {
|
||||
try {
|
||||
const transfer = await loadUploadTransfer(req, res);
|
||||
if (!transfer) return; // response already sent
|
||||
req.transferRow = transfer;
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.error('preUploadGuard error', { error: err.message });
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Multer writes to a per-transfer temp dir; we then hand files to the storage
|
||||
// backend (so S3 works too) and delete the temp copy.
|
||||
const tempStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = path.join(getStoragePath(), 'temp', 'transfer-uploads');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const safe = sanitizeFilename(path.basename(file.originalname), 60) || 'file';
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}-${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
function buildUploader(maxSizeBytes, allowed) {
|
||||
return multer({
|
||||
storage: tempStorage,
|
||||
limits: { fileSize: maxSizeBytes, files: MAX_FILES_PER_UPLOAD },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||
return cb(new Error('This file type is not allowed'));
|
||||
},
|
||||
}).array('files', MAX_FILES_PER_UPLOAD);
|
||||
}
|
||||
|
||||
router.post('/:token', uploadLimiter, [param('token').matches(TOKEN_RE)], preUploadGuard, handleAsync(async (req, res) => {
|
||||
const transfer = req.transferRow;
|
||||
const maxSizeMb = Number(await getAppSetting('transfer_max_upload_size_mb', 50)) || 50;
|
||||
const allowedSetting = await getAppSetting('transfer_upload_allowed_mime', DEFAULT_ALLOWED);
|
||||
const allowed = Array.isArray(allowedSetting) ? allowedSetting : DEFAULT_ALLOWED;
|
||||
const uploader = buildUploader(maxSizeMb * 1024 * 1024, allowed);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
uploader(req, res, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
} catch (err) {
|
||||
// Translate multer errors to a clean 4xx.
|
||||
const msg = err && err.code === 'LIMIT_FILE_SIZE'
|
||||
? `Each file must be ${maxSizeMb} MB or smaller`
|
||||
: (err && err.message) || 'Upload failed';
|
||||
if (!res.headersSent) res.status(400).json({ error: msg, code: 'UPLOAD_REJECTED' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.files || !req.files.length) {
|
||||
return res.status(400).json({ error: 'No files uploaded', code: 'NO_FILES' });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const ip = clientIpForAudit(req);
|
||||
const saved = [];
|
||||
for (const file of req.files) {
|
||||
const safeName = sanitizeFilename(path.basename(file.originalname), 120) || 'file';
|
||||
const key = path.posix.join(transferService.uploadDirKey(transfer.id), `${Date.now()}-${saved.length}-${safeName}`);
|
||||
try {
|
||||
await storage.putFromFile(key, file.path);
|
||||
await transferService.addUpload(transfer.id, {
|
||||
originalFilename: file.originalname,
|
||||
storedPath: key,
|
||||
sizeBytes: file.size,
|
||||
mimeType: file.mimetype,
|
||||
ip,
|
||||
});
|
||||
saved.push({ filename: file.originalname, size_bytes: file.size });
|
||||
} catch (err) {
|
||||
logger.error('transfer upload: failed to store file', { transferId: transfer.id, error: err.message });
|
||||
} finally {
|
||||
// Remove the temp copy regardless of outcome.
|
||||
try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch (_) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!saved.length) {
|
||||
return res.status(500).json({ error: 'Could not store the uploaded files', code: 'STORE_FAILED' });
|
||||
}
|
||||
return successResponse(res, { uploaded: saved.length, files: saved }, 201, 'Files uploaded');
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user