Files
picpeak/backend/src/services/transferCleanupService.js
T
LucaandLuca-Timo 2e495d7c48 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]>
2026-08-09 13:40:03 +02:00

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,
};