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]>
148 lines
5.3 KiB
JavaScript
148 lines
5.3 KiB
JavaScript
/**
|
|
* transferCleanupService — retention lifecycle for PicTransfer (#997).
|
|
*
|
|
* Runs hourly (offset from the gallery expiration checker so the two don't
|
|
* collide) and drives three transitions:
|
|
*
|
|
* 1. Expire — an active transfer past `expires_at` is disabled
|
|
* (is_active=false, disabled_at=now). This is the "disable the
|
|
* link after the set time period" behaviour. A transfer
|
|
* disabled early by its download cap is already in this state.
|
|
* 2. Notify — the admin is emailed once when a transfer becomes inactive
|
|
* (admin_notified_at stamped so it never repeats).
|
|
* 3. Delete — `grace_days` after disable, the client-uploaded files are
|
|
* removed and the transfer record is dropped. (The gallery
|
|
* originals a transfer pointed at are owned by their events and
|
|
* are never touched — only the transfer's own ad-hoc uploads
|
|
* are deleted, which is what the retention cap is about.)
|
|
*/
|
|
|
|
const cron = require('node-cron');
|
|
const { db } = require('../database/db');
|
|
const logger = require('../utils/logger');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const { sendTemplateEmail } = require('./emailProcessor');
|
|
const transferService = require('./transferService');
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function startTransferCleanup() {
|
|
// Hourly at :15 — staggered from the gallery expiration checker (:00).
|
|
cron.schedule('15 * * * *', async () => {
|
|
await runTransferCleanup();
|
|
});
|
|
logger.info('Transfer cleanup scheduler started');
|
|
}
|
|
|
|
async function runTransferCleanup() {
|
|
try {
|
|
await expireTransfers();
|
|
await notifyExpiredTransfers();
|
|
await deleteRetiredTransfers();
|
|
} catch (err) {
|
|
logger.error('Transfer cleanup error', { error: err.message });
|
|
}
|
|
}
|
|
|
|
/** Disable links whose time window has passed. */
|
|
async function expireTransfers() {
|
|
const now = new Date();
|
|
const due = await db('transfers')
|
|
.where('is_active', formatBoolean(true))
|
|
.whereNull('deleted_at')
|
|
.whereNotNull('expires_at')
|
|
.where('expires_at', '<=', now);
|
|
|
|
for (const t of due) {
|
|
await db('transfers').where({ id: t.id }).update({
|
|
is_active: formatBoolean(false),
|
|
disabled_at: t.disabled_at || now,
|
|
updated_at: now,
|
|
});
|
|
logger.info(`Transfer ${t.id} expired`);
|
|
}
|
|
}
|
|
|
|
/** Email the admin(s) once per transfer that has become inactive. */
|
|
async function notifyExpiredTransfers() {
|
|
const pending = await db('transfers')
|
|
.where('is_active', formatBoolean(false))
|
|
.whereNull('deleted_at')
|
|
.whereNull('admin_notified_at')
|
|
.whereNotNull('disabled_at');
|
|
|
|
if (!pending.length) return;
|
|
|
|
const admins = await db('admin_users')
|
|
.where('is_active', formatBoolean(true))
|
|
.whereNotNull('email')
|
|
.select('email');
|
|
const adminUrl = `${transferService.getFrontendUrl()}/admin/transfers`;
|
|
|
|
for (const t of pending) {
|
|
const fileCount = await db('transfer_files').where('transfer_id', t.id).count('* as c').first();
|
|
const uploadCount = await db('transfer_uploads').where('transfer_id', t.id).count('* as c').first();
|
|
const grace = Number(t.grace_days) || 0;
|
|
const deleteDate = new Date(new Date(t.disabled_at).getTime() + grace * DAY_MS);
|
|
|
|
const vars = {
|
|
transfer_title: t.title || `Transfer #${t.id}`,
|
|
expiry_date: new Date(t.disabled_at).toISOString().slice(0, 10),
|
|
file_count: String(Number(fileCount?.c) || 0),
|
|
upload_count: String(Number(uploadCount?.c) || 0),
|
|
grace_days: String(grace),
|
|
delete_date: deleteDate.toISOString().slice(0, 10),
|
|
admin_url: adminUrl,
|
|
};
|
|
|
|
let sent = false;
|
|
for (const { email } of admins) {
|
|
try {
|
|
await sendTemplateEmail(email, 'transfer_link_expired', vars);
|
|
sent = true;
|
|
} catch (err) {
|
|
// Email not configured / SMTP down — don't spin forever retrying; just
|
|
// stamp so the sweep moves on. The transfer still expires + deletes.
|
|
logger.warn('Failed to send transfer_link_expired notification', {
|
|
transferId: t.id, email, error: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Stamp regardless so we notify at most once even if delivery failed
|
|
// (avoids an unbounded retry loop every hour).
|
|
await db('transfers').where({ id: t.id }).update({ admin_notified_at: new Date() });
|
|
if (sent) logger.info(`Notified admins that transfer ${t.id} expired`);
|
|
}
|
|
}
|
|
|
|
/** Hard-delete transfers whose retention window has fully elapsed. */
|
|
async function deleteRetiredTransfers() {
|
|
const candidates = await db('transfers')
|
|
.where('is_active', formatBoolean(false))
|
|
.whereNull('deleted_at')
|
|
.whereNotNull('disabled_at');
|
|
|
|
const now = Date.now();
|
|
for (const t of candidates) {
|
|
const grace = Number(t.grace_days) || 0;
|
|
const deleteAt = new Date(t.disabled_at).getTime() + grace * DAY_MS;
|
|
if (deleteAt > now) continue;
|
|
try {
|
|
await transferService.deleteTransfer(t.id);
|
|
logger.info(`Transfer ${t.id} deleted after ${grace}-day retention`);
|
|
} catch (err) {
|
|
logger.error('Failed to delete retired transfer', { transferId: t.id, error: err.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
startTransferCleanup,
|
|
// exported for tests / manual invocation
|
|
runTransferCleanup,
|
|
expireTransfers,
|
|
notifyExpiredTransfers,
|
|
deleteRetiredTransfers,
|
|
};
|