* feat(setup): configure the public address and SMTP in the wizard, not .env
A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:
- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
getFrontendBaseUrl() returned on its first branch every time and the setting
was never read. .env.example shipped the same value as an uncommented
placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
step unless a CRM-ish feature was selected — so a gallery-only install was
also never offered SMTP, despite gallery links, guest invites and expiry
warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
resolver, three of them defaulting to placeholder hosts that reached real
recipients: https://app.example.com in payment-reminder emails, localhost:3005
in admin invitation emails, https://app.example.com in dev template previews.
Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.
The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.
Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.
backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.
Refs #705
* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links
The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:
userManagementService was: FRONTEND_URL || ADMIN_URL || localhost:3005
became: ADMIN_URL || resolver
adminEvents/crud was: FRONTEND_URL || APP_URL || ''
became: APP_URL || resolver
An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.
Refs #705
* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable
Review feedback on #1104.
isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.
APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.
general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.
Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.
* fix(setup): surface a rejected public address instead of swallowing it
Review round 2 follow-up on #1104, pushed onto the branch.
saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:
http://my_nas.local client: accepted server: rejected
http://foo_bar:3000 client: accepted server: rejected
validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.
Three parts:
- saveSiteUrl() throws. finish() resolves it before anything else is posted and
puts the message on the address field rather than the generic "some settings
could not be saved" warning. Skip for now still always leaves, by contract,
but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
off: browsers resolve http://my_nas.local and the client accepts it, so
rejecting it server-side only produced the mismatch above. Both validators
now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
https://localhost-nas.example.com, and now that this predicate gates the
whole resolver rather than just the slideshow QR, being demoted means a
configured address is silently ignored. 127. stays a bare prefix on purpose:
all of 127.0.0.0/8 is loopback.
Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.
* fix(settings): don't lock the General tab on a site URL nobody typed
Review follow-up on #1104, pushed onto the branch.
general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.
An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.
That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.
Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.
stored value invalid, untouched Save works, key not sent
edited to something unusable Save blocked
edited to a usable absolute url saved
Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.
---------
Co-authored-by: Paul Nothaft <[email protected]>
1016 lines
38 KiB
JavaScript
1016 lines
38 KiB
JavaScript
/**
|
|
* transferService — PicTransfer (#997).
|
|
*
|
|
* A "transfer" is a share link that bundles ORIGINAL photos picked from any
|
|
* number of events and hands them to a recipient as a download link. It can
|
|
* also open a short (6-char) upload token so the client can send files back
|
|
* (logos etc.).
|
|
*
|
|
* Design decisions (from the issue):
|
|
* - Downloads always serve ORIGINAL files, never watermarked — a transfer is
|
|
* a deliberate hand-off, not a preview.
|
|
* - The ZIP is built on demand by replicating the gallery download-selected
|
|
* loop (resolvePhotoStorageKey → storage.get → archiver), generalised to
|
|
* span multiple events. No pre-generation / caching.
|
|
* - The link is simply disabled after `expires_at`; an optional max-downloads
|
|
* cap can disable it earlier. Files are kept `grace_days` days past disable
|
|
* (retention), then the cleanup sweep hard-deletes them.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const archiver = require('archiver');
|
|
|
|
const { db } = require('../database/db');
|
|
const logger = require('../utils/logger');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const { getAppSetting } = require('../utils/appSettings');
|
|
const { getStorage } = require('./storage');
|
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
|
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
|
const { sanitizeForZipEntry } = require('../utils/filenameSanitizer');
|
|
const { filterOwnedEventIds } = require('../middleware/ownership');
|
|
|
|
// Unambiguous alphabet for the client upload token — no 0/O/1/I/L to keep it
|
|
// easy to read aloud / type from an email. 6 chars ≈ 31 bits; brute force is
|
|
// mitigated by the per-route rate limiter + IP lockout on the upload endpoint.
|
|
const UPLOAD_TOKEN_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
const UPLOAD_TOKEN_LENGTH = 6;
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
async function getFrontendUrl() {
|
|
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
|
|
return getAbsoluteFrontendUrl();
|
|
}
|
|
|
|
function generateDownloadToken() {
|
|
return crypto.randomBytes(32).toString('hex'); // 64 hex chars
|
|
}
|
|
|
|
function generateUploadTokenCandidate() {
|
|
let out = '';
|
|
for (let i = 0; i < UPLOAD_TOKEN_LENGTH; i += 1) {
|
|
// crypto.randomInt is unbiased over [0, len); a plain byte % len would
|
|
// over-represent the first (256 % len) characters of the alphabet.
|
|
out += UPLOAD_TOKEN_ALPHABET[crypto.randomInt(0, UPLOAD_TOKEN_ALPHABET.length)];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Return the subset of `photoIds` whose event the admin may act on. Mirrors the
|
|
* event-ownership rule used everywhere else (super_admin unrestricted; others
|
|
* get events they created plus ownerless legacy events) so a scoped admin can
|
|
* never bundle — and then hand out via a public token — originals from an event
|
|
* they don't own. Foreign and non-existent ids are both dropped.
|
|
*/
|
|
async function filterOwnedPhotoIds(admin, photoIds) {
|
|
const ids = [...new Set((photoIds || []).map((n) => parseInt(n, 10)).filter(Boolean))];
|
|
if (!ids.length) return [];
|
|
const photos = await db('photos').whereIn('id', ids).select('id', 'event_id');
|
|
const eventIds = [...new Set(photos.map((p) => p.event_id))];
|
|
if (!eventIds.length) return [];
|
|
const { allowed } = await filterOwnedEventIds(admin, eventIds);
|
|
const allowedEvents = new Set(allowed.map(Number));
|
|
return photos.filter((p) => allowedEvents.has(Number(p.event_id))).map((p) => p.id);
|
|
}
|
|
|
|
async function generateUniqueUploadToken(conn = db) {
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const candidate = generateUploadTokenCandidate();
|
|
const clash = await conn('transfers').where({ upload_token: candidate }).first('id');
|
|
if (!clash) return candidate;
|
|
}
|
|
// Astronomically unlikely; fall back to a longer token so we never loop.
|
|
return generateUploadTokenCandidate() + generateUploadTokenCandidate();
|
|
}
|
|
|
|
/** Storage-relative directory that holds a transfer's client uploads. */
|
|
function uploadDirKey(transferId) {
|
|
return path.posix.join('uploads/transfers', String(transferId));
|
|
}
|
|
|
|
/**
|
|
* Storage-relative directory for the admin's own deliverable files — the files
|
|
* dropped straight into a transfer at creation (transfer_extra_files), as
|
|
* opposed to the gallery photos it references or the client's return uploads.
|
|
*/
|
|
function extraFilesDirKey(transferId) {
|
|
return path.posix.join('transfers', String(transferId), 'files');
|
|
}
|
|
|
|
/**
|
|
* Derive the recipient-facing/admin status of a transfer row.
|
|
* Never mutates — the cron sweep is what actually flips is_active/deleted_at.
|
|
*/
|
|
function computeStatus(transfer) {
|
|
if (transfer.deleted_at) return 'deleted';
|
|
const now = Date.now();
|
|
const expired = !transfer.is_active
|
|
|| (transfer.expires_at && new Date(transfer.expires_at).getTime() <= now);
|
|
if (expired) return 'expired';
|
|
return 'active';
|
|
}
|
|
|
|
function downloadsRemaining(transfer) {
|
|
const cap = Number(transfer.max_downloads) || 0;
|
|
if (cap <= 0) return null; // unlimited
|
|
return Math.max(0, cap - (Number(transfer.download_count) || 0));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function createTransfer(input, admin) {
|
|
const adminId = admin && admin.id ? admin.id : null;
|
|
const {
|
|
title = '',
|
|
message = null,
|
|
expiresInDays,
|
|
maxDownloads,
|
|
graceDays,
|
|
allowUploads = false,
|
|
uploadExpiresInDays,
|
|
photoIds = [],
|
|
deliveryMethod = 'link',
|
|
} = input || {};
|
|
|
|
const defaultExpiry = await getAppSetting('transfer_default_expiry_days', 14);
|
|
const defaultGrace = await getAppSetting('transfer_default_grace_days', 7);
|
|
const defaultMax = await getAppSetting('transfer_default_max_downloads', 0);
|
|
|
|
const expiryDays = Number.isFinite(Number(expiresInDays)) && Number(expiresInDays) > 0
|
|
? Number(expiresInDays) : Number(defaultExpiry) || 14;
|
|
const grace = Number.isFinite(Number(graceDays)) && Number(graceDays) >= 0
|
|
? Number(graceDays) : Number(defaultGrace) || 7;
|
|
const cap = Number.isFinite(Number(maxDownloads)) && Number(maxDownloads) > 0
|
|
? Number(maxDownloads) : (Number(defaultMax) > 0 ? Number(defaultMax) : null);
|
|
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + expiryDays * DAY_MS);
|
|
|
|
const row = {
|
|
token: generateDownloadToken(),
|
|
title: String(title || '').slice(0, 255),
|
|
message: message || null,
|
|
created_by: adminId || null,
|
|
expires_at: expiresAt,
|
|
max_downloads: cap,
|
|
download_count: 0,
|
|
is_active: formatBoolean(true),
|
|
grace_days: grace,
|
|
allow_uploads: formatBoolean(!!allowUploads),
|
|
delivery_method: deliveryMethod === 'email' ? 'email' : 'link',
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
if (allowUploads) {
|
|
row.upload_token = await generateUniqueUploadToken();
|
|
const uploadDays = Number.isFinite(Number(uploadExpiresInDays)) && Number(uploadExpiresInDays) > 0
|
|
? Number(uploadExpiresInDays) : expiryDays;
|
|
row.upload_expires_at = new Date(now.getTime() + uploadDays * DAY_MS);
|
|
}
|
|
|
|
const [id] = await db('transfers').insert(row).returning('id');
|
|
const transferId = typeof id === 'object' && id !== null ? id.id : id;
|
|
|
|
if (Array.isArray(photoIds) && photoIds.length) {
|
|
await addFiles(transferId, photoIds, admin);
|
|
}
|
|
|
|
return getTransfer(transferId);
|
|
}
|
|
|
|
async function listTransfers({ search = '', admin } = {}) {
|
|
let query = db('transfers').whereNull('deleted_at');
|
|
|
|
// Non-super_admins only see their own transfers (plus ownerless legacy rows).
|
|
// Otherwise the list — which used to carry each transfer's download token —
|
|
// handed every admin a public link to everyone else's originals.
|
|
if (admin && admin.roleName !== 'super_admin') {
|
|
query = query.where((q) => q.whereNull('created_by').orWhere('created_by', admin.id));
|
|
}
|
|
if (search) {
|
|
query = query.where('title', 'like', `%${search}%`);
|
|
}
|
|
query = query.orderBy('created_at', 'desc');
|
|
|
|
const rows = await query;
|
|
const ids = rows.map((r) => r.id);
|
|
|
|
// File + upload counts in two grouped queries rather than N+1.
|
|
const fileCounts = ids.length
|
|
? await db('transfer_files').whereIn('transfer_id', ids)
|
|
.select('transfer_id').count('* as count').groupBy('transfer_id')
|
|
: [];
|
|
const uploadCounts = ids.length
|
|
? await db('transfer_uploads').whereIn('transfer_id', ids)
|
|
.select('transfer_id').count('* as count').groupBy('transfer_id')
|
|
: [];
|
|
// Admin-uploaded deliverable files count toward file_count alongside photos.
|
|
const extraCounts = ids.length
|
|
? await db('transfer_extra_files').whereIn('transfer_id', ids)
|
|
.select('transfer_id').count('* as count').groupBy('transfer_id')
|
|
: [];
|
|
const fileCountMap = new Map(fileCounts.map((r) => [r.transfer_id, Number(r.count)]));
|
|
const uploadCountMap = new Map(uploadCounts.map((r) => [r.transfer_id, Number(r.count)]));
|
|
const extraCountMap = new Map(extraCounts.map((r) => [r.transfer_id, Number(r.count)]));
|
|
|
|
return rows.map((r) => {
|
|
// The list view never needs the secrets — a row is a summary, and the
|
|
// recipient/upload links live on the detail response. Strip them so the
|
|
// list can't be used to read another (or one's own, over-broadly) token.
|
|
const safe = serializeTransfer(r);
|
|
delete safe.token;
|
|
delete safe.upload_token;
|
|
delete safe.download_url;
|
|
delete safe.upload_url;
|
|
safe.file_count = (fileCountMap.get(r.id) || 0) + (extraCountMap.get(r.id) || 0);
|
|
safe.upload_count = uploadCountMap.get(r.id) || 0;
|
|
return safe;
|
|
});
|
|
}
|
|
|
|
function serializeTransfer(row) {
|
|
return {
|
|
id: row.id,
|
|
token: row.token,
|
|
title: row.title,
|
|
message: row.message,
|
|
created_by: row.created_by,
|
|
expires_at: row.expires_at,
|
|
max_downloads: row.max_downloads || null,
|
|
download_count: row.download_count || 0,
|
|
downloads_remaining: downloadsRemaining(row),
|
|
is_active: row.is_active === true || row.is_active === 1,
|
|
disabled_at: row.disabled_at || null,
|
|
grace_days: row.grace_days,
|
|
deleted_at: row.deleted_at || null,
|
|
allow_uploads: row.allow_uploads === true || row.allow_uploads === 1,
|
|
delivery_method: row.delivery_method === 'email' ? 'email' : 'link',
|
|
upload_token: row.upload_token || null,
|
|
upload_expires_at: row.upload_expires_at || null,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
status: computeStatus(row),
|
|
download_url: `/transfer/${row.token}`,
|
|
upload_url: row.upload_token ? `/transfer-upload/${row.upload_token}` : null,
|
|
};
|
|
}
|
|
|
|
/** Full detail: transfer + its files (with photo/event info) + client uploads. */
|
|
async function getTransfer(id) {
|
|
const row = await db('transfers').where({ id }).first();
|
|
if (!row) return null;
|
|
|
|
const files = await db('transfer_files')
|
|
.join('photos', 'photos.id', 'transfer_files.photo_id')
|
|
.join('events', 'events.id', 'photos.event_id')
|
|
.where('transfer_files.transfer_id', id)
|
|
.orderBy('transfer_files.sort_order', 'asc')
|
|
.orderBy('transfer_files.id', 'asc')
|
|
.select(
|
|
'transfer_files.id as file_id',
|
|
'transfer_files.sort_order',
|
|
'photos.id as photo_id',
|
|
'photos.filename',
|
|
'photos.original_filename',
|
|
'photos.type',
|
|
'photos.size_bytes',
|
|
'photos.event_id',
|
|
'events.event_name',
|
|
'events.slug as event_slug',
|
|
);
|
|
|
|
const uploads = await db('transfer_uploads')
|
|
.where('transfer_id', id)
|
|
.orderBy('uploaded_at', 'desc')
|
|
.select('id', 'original_filename', 'size_bytes', 'mime_type', 'uploader_ip', 'uploaded_at');
|
|
|
|
// Admin-uploaded deliverable files (no photo/event — the operator's own bytes).
|
|
const extraFiles = await db('transfer_extra_files')
|
|
.where('transfer_id', id)
|
|
.orderBy('sort_order', 'asc')
|
|
.orderBy('id', 'asc')
|
|
.select('id', 'original_filename', 'size_bytes', 'mime_type', 'created_at');
|
|
|
|
const recipients = await db('transfer_recipients')
|
|
.where('transfer_id', id)
|
|
.orderBy('id', 'asc')
|
|
.select('id', 'email', 'last_sent_at');
|
|
|
|
return {
|
|
...serializeTransfer(row),
|
|
file_count: files.length + extraFiles.length,
|
|
upload_count: uploads.length,
|
|
extra_files: extraFiles.map((f) => ({
|
|
id: f.id,
|
|
filename: f.original_filename,
|
|
size_bytes: f.size_bytes,
|
|
mime_type: f.mime_type,
|
|
})),
|
|
recipients: recipients.map((r) => ({ id: r.id, email: r.email, last_sent_at: r.last_sent_at || null })),
|
|
files: files.map((f) => ({
|
|
file_id: f.file_id,
|
|
photo_id: f.photo_id,
|
|
filename: f.original_filename || f.filename,
|
|
type: f.type,
|
|
size_bytes: f.size_bytes,
|
|
event_id: f.event_id,
|
|
event_name: f.event_name,
|
|
event_slug: f.event_slug,
|
|
// Admin picker previews thumbnails via the existing admin photo endpoint.
|
|
thumbnail_url: `/admin/photos/${f.event_id}/thumbnail/${f.photo_id}`,
|
|
})),
|
|
uploads,
|
|
};
|
|
}
|
|
|
|
/** Minimal row for the ownership guard: { id, created_by } or undefined. */
|
|
async function getTransferOwner(id) {
|
|
return db('transfers').where({ id }).whereNull('deleted_at').first('id', 'created_by');
|
|
}
|
|
|
|
async function updateTransfer(id, fields) {
|
|
const row = await db('transfers').where({ id }).first();
|
|
if (!row) return null;
|
|
|
|
const update = { updated_at: new Date() };
|
|
if (fields.title !== undefined) update.title = String(fields.title || '').slice(0, 255);
|
|
if (fields.message !== undefined) update.message = fields.message || null;
|
|
if (fields.maxDownloads !== undefined) {
|
|
const cap = Number(fields.maxDownloads);
|
|
update.max_downloads = Number.isFinite(cap) && cap > 0 ? cap : null;
|
|
}
|
|
if (fields.graceDays !== undefined) {
|
|
const grace = Number(fields.graceDays);
|
|
if (Number.isFinite(grace) && grace >= 0) update.grace_days = grace;
|
|
}
|
|
if (fields.expiresAt !== undefined) {
|
|
update.expires_at = new Date(fields.expiresAt);
|
|
} else if (fields.expiresInDays !== undefined) {
|
|
const days = Number(fields.expiresInDays);
|
|
if (Number.isFinite(days) && days > 0) {
|
|
update.expires_at = new Date(Date.now() + days * DAY_MS);
|
|
}
|
|
}
|
|
if (fields.isActive !== undefined) {
|
|
update.is_active = formatBoolean(!!fields.isActive);
|
|
// Re-activating clears the retention clock; disabling starts it.
|
|
if (fields.isActive) {
|
|
update.disabled_at = null;
|
|
update.admin_notified_at = null;
|
|
} else if (!row.disabled_at) {
|
|
update.disabled_at = new Date();
|
|
}
|
|
}
|
|
|
|
await db('transfers').where({ id }).update(update);
|
|
return getTransfer(id);
|
|
}
|
|
|
|
async function deleteTransfer(id) {
|
|
const row = await db('transfers').where({ id }).first();
|
|
if (!row) return false;
|
|
await removeUploadedFiles(id);
|
|
await removeExtraFiles(id);
|
|
// transfer_files / transfer_uploads / transfer_downloads / transfer_extra_files
|
|
// / transfer_recipients cascade on the FK, but we delete explicitly too so the
|
|
// feature works even where SQLite FK enforcement is off.
|
|
await db('transfer_files').where({ transfer_id: id }).del();
|
|
await db('transfer_uploads').where({ transfer_id: id }).del();
|
|
await db('transfer_downloads').where({ transfer_id: id }).del();
|
|
await db('transfer_extra_files').where({ transfer_id: id }).del();
|
|
await db('transfer_recipients').where({ transfer_id: id }).del();
|
|
await db('transfers').where({ id }).del();
|
|
return true;
|
|
}
|
|
|
|
async function addFiles(transferId, photoIds, admin) {
|
|
const ids = [...new Set((photoIds || []).map((n) => parseInt(n, 10)).filter(Boolean))];
|
|
if (!ids.length) return getTransfer(transferId);
|
|
|
|
// Only photos whose event the caller owns (ownership implies existence).
|
|
// Without this a scoped admin could bundle any event's originals and hand
|
|
// them out through the public download token — every ownership control
|
|
// bypassed. Mirrors the GHSA-wrg5 fix pattern.
|
|
const ownedIds = await filterOwnedPhotoIds(admin, ids);
|
|
const validIds = new Set(ownedIds);
|
|
|
|
// Skip photos already attached (the unique index would reject them anyway).
|
|
const already = await db('transfer_files')
|
|
.where('transfer_id', transferId)
|
|
.whereIn('photo_id', ids)
|
|
.select('photo_id');
|
|
const alreadySet = new Set(already.map((r) => r.photo_id));
|
|
|
|
const maxOrderRow = await db('transfer_files')
|
|
.where('transfer_id', transferId)
|
|
.max('sort_order as max')
|
|
.first();
|
|
let order = (maxOrderRow && Number(maxOrderRow.max)) || 0;
|
|
|
|
const rows = ids
|
|
.filter((pid) => validIds.has(pid) && !alreadySet.has(pid))
|
|
.map((pid) => {
|
|
order += 1;
|
|
return { transfer_id: transferId, photo_id: pid, sort_order: order, created_at: new Date() };
|
|
});
|
|
|
|
if (rows.length) {
|
|
await db('transfer_files').insert(rows);
|
|
await db('transfers').where({ id: transferId }).update({ updated_at: new Date() });
|
|
}
|
|
return getTransfer(transferId);
|
|
}
|
|
|
|
async function removeFile(transferId, fileId) {
|
|
await db('transfer_files').where({ id: fileId, transfer_id: transferId }).del();
|
|
await db('transfers').where({ id: transferId }).update({ updated_at: new Date() });
|
|
return getTransfer(transferId);
|
|
}
|
|
|
|
async function enableUploads(transferId, { uploadExpiresInDays } = {}) {
|
|
const row = await db('transfers').where({ id: transferId }).first();
|
|
if (!row) return null;
|
|
const now = new Date();
|
|
const days = Number.isFinite(Number(uploadExpiresInDays)) && Number(uploadExpiresInDays) > 0
|
|
? Number(uploadExpiresInDays)
|
|
: Math.max(1, Math.ceil((new Date(row.expires_at).getTime() - now.getTime()) / DAY_MS));
|
|
const update = {
|
|
allow_uploads: formatBoolean(true),
|
|
upload_token: row.upload_token || (await generateUniqueUploadToken()),
|
|
upload_expires_at: new Date(now.getTime() + days * DAY_MS),
|
|
updated_at: now,
|
|
};
|
|
await db('transfers').where({ id: transferId }).update(update);
|
|
return getTransfer(transferId);
|
|
}
|
|
|
|
async function disableUploads(transferId) {
|
|
await db('transfers').where({ id: transferId }).update({
|
|
allow_uploads: formatBoolean(false),
|
|
upload_token: null,
|
|
upload_expires_at: null,
|
|
updated_at: new Date(),
|
|
});
|
|
return getTransfer(transferId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public lookups (token-authenticated)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getTransferByToken(token) {
|
|
return db('transfers').where({ token }).whereNull('deleted_at').first();
|
|
}
|
|
|
|
async function getTransferByUploadToken(uploadToken) {
|
|
return db('transfers').where({ upload_token: uploadToken }).whereNull('deleted_at').first();
|
|
}
|
|
|
|
/**
|
|
* Recipient-facing projection — filenames + sizes only. The download page has
|
|
* NO thumbnails by design, so we deliberately don't expose any image URLs.
|
|
*/
|
|
async function getPublicView(transfer) {
|
|
const files = await db('transfer_files')
|
|
.join('photos', 'photos.id', 'transfer_files.photo_id')
|
|
.where('transfer_files.transfer_id', transfer.id)
|
|
.orderBy('transfer_files.sort_order', 'asc')
|
|
.orderBy('transfer_files.id', 'asc')
|
|
.select(
|
|
'transfer_files.id as file_id',
|
|
'photos.filename',
|
|
'photos.original_filename',
|
|
'photos.size_bytes',
|
|
);
|
|
|
|
const extraFiles = await db('transfer_extra_files')
|
|
.where('transfer_id', transfer.id)
|
|
.orderBy('sort_order', 'asc')
|
|
.orderBy('id', 'asc')
|
|
.select('id', 'original_filename', 'size_bytes');
|
|
|
|
const useOriginal = await getUseOriginalFilenames();
|
|
const totalBytes = files.reduce((sum, f) => sum + (Number(f.size_bytes) || 0), 0)
|
|
+ extraFiles.reduce((sum, f) => sum + (Number(f.size_bytes) || 0), 0);
|
|
|
|
// Public file ids are prefixed so the single-file route knows which table to
|
|
// read: `p<id>` = a referenced gallery photo, `x<id>` = an admin-uploaded file.
|
|
const photoEntries = files.map((f) => ({
|
|
file_id: `p${f.file_id}`,
|
|
filename: (useOriginal && f.original_filename) ? f.original_filename : f.filename,
|
|
size_bytes: f.size_bytes || null,
|
|
}));
|
|
const extraEntries = extraFiles.map((f) => ({
|
|
file_id: `x${f.id}`,
|
|
filename: f.original_filename,
|
|
size_bytes: f.size_bytes || null,
|
|
}));
|
|
|
|
return {
|
|
title: transfer.title || 'Transfer',
|
|
message: transfer.message || null,
|
|
expires_at: transfer.expires_at,
|
|
file_count: files.length + extraFiles.length,
|
|
total_bytes: totalBytes,
|
|
downloads_remaining: downloadsRemaining(transfer),
|
|
files: [...photoEntries, ...extraEntries],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether a transfer can currently be downloaded. Returns a reason code so the
|
|
* route can map it to a clean 403/410.
|
|
*/
|
|
function assertDownloadable(transfer) {
|
|
if (!transfer || transfer.deleted_at) return { ok: false, code: 'NOT_FOUND', status: 404 };
|
|
const isActive = transfer.is_active === true || transfer.is_active === 1;
|
|
if (!isActive) return { ok: false, code: 'TRANSFER_DISABLED', status: 410 };
|
|
if (transfer.expires_at && new Date(transfer.expires_at).getTime() <= Date.now()) {
|
|
return { ok: false, code: 'TRANSFER_EXPIRED', status: 410 };
|
|
}
|
|
const remaining = downloadsRemaining(transfer);
|
|
if (remaining !== null && remaining <= 0) {
|
|
return { ok: false, code: 'DOWNLOAD_LIMIT_REACHED', status: 410 };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Record one download and, if it hit the cap, flip the link inactive. */
|
|
async function recordDownload(transfer, { kind = 'all', photoId = null, ip = null } = {}) {
|
|
await db('transfer_downloads').insert({
|
|
transfer_id: transfer.id,
|
|
kind,
|
|
photo_id: photoId,
|
|
ip,
|
|
downloaded_at: new Date(),
|
|
});
|
|
await db('transfers').where({ id: transfer.id }).increment('download_count', 1);
|
|
|
|
const cap = Number(transfer.max_downloads) || 0;
|
|
if (cap > 0) {
|
|
// Evaluate the cap against the freshly-incremented persisted count, not the
|
|
// stale in-memory `transfer.download_count` — two concurrent downloads
|
|
// reading the same snapshot would otherwise both think they're under the
|
|
// cap and blow past it. The disable is idempotent, so a double-trip here is
|
|
// harmless.
|
|
const fresh = await db('transfers').where({ id: transfer.id })
|
|
.first('download_count', 'is_active');
|
|
const count = Number(fresh && fresh.download_count) || 0;
|
|
const stillActive = fresh && (fresh.is_active === true || fresh.is_active === 1);
|
|
if (count >= cap && stillActive) {
|
|
// Cap reached — disable and start the retention clock.
|
|
await db('transfers').where({ id: transfer.id }).update({
|
|
is_active: formatBoolean(false),
|
|
disabled_at: new Date(),
|
|
updated_at: new Date(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ZIP building — cross-event, originals only
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Load the ordered photos for a transfer, each joined to its event. */
|
|
async function loadTransferPhotos(transferId) {
|
|
const rows = await db('transfer_files')
|
|
.join('photos', 'photos.id', 'transfer_files.photo_id')
|
|
.join('events', 'events.id', 'photos.event_id')
|
|
.where('transfer_files.transfer_id', transferId)
|
|
.orderBy('transfer_files.sort_order', 'asc')
|
|
.orderBy('transfer_files.id', 'asc')
|
|
.select(
|
|
'photos.*',
|
|
'events.slug as event_slug',
|
|
'events.event_name as event_name',
|
|
'events.source_mode as event_source_mode',
|
|
'events.external_path as event_external_path',
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Stream a ZIP of a transfer's ORIGINAL files to `res`. Mirrors the gallery
|
|
* download-selected loop but spans events: each photo carries its own event
|
|
* fields (aliased above) so the resolver gets the right event. Photos are
|
|
* grouped into per-event subfolders to keep same-named files apart.
|
|
*
|
|
* Returns the number of files successfully appended.
|
|
*/
|
|
async function streamTransferArchive(transfer, res) {
|
|
const photos = await loadTransferPhotos(transfer.id);
|
|
|
|
const archiveName = `${sanitizeForZipEntry(transfer.title || 'transfer') || 'transfer'}.zip`;
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
|
|
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
|
archive.on('error', (err) => {
|
|
logger.error('transferService: archive error', { transferId: transfer.id, error: err.message });
|
|
try { res.destroy(err); } catch (_) { /* noop */ }
|
|
});
|
|
archive.pipe(res);
|
|
|
|
const storage = getStorage();
|
|
const useOriginal = await getUseOriginalFilenames();
|
|
const entryNames = getZipEntryNames(photos, useOriginal);
|
|
const multiEvent = new Set(photos.map((p) => p.event_id)).size > 1;
|
|
|
|
let appended = 0;
|
|
for (let i = 0; i < photos.length; i += 1) {
|
|
const photo = photos[i];
|
|
const event = {
|
|
id: photo.event_id,
|
|
slug: photo.event_slug,
|
|
source_mode: photo.event_source_mode,
|
|
external_path: photo.event_external_path,
|
|
};
|
|
let name = entryNames[i] || `photo-${photo.id}.jpg`;
|
|
// Only foldered when the transfer actually spans multiple events, so a
|
|
// single-event transfer stays flat.
|
|
if (multiEvent) {
|
|
const folder = sanitizeForZipEntry(photo.event_name || photo.event_slug || `event-${photo.event_id}`);
|
|
name = `${folder}/${name}`;
|
|
}
|
|
try {
|
|
const storageKey = resolvePhotoStorageKey(event, photo);
|
|
if (storageKey && storage.kind() === 'local') {
|
|
const srcStat = await storage.stat(storageKey);
|
|
if (!srcStat) throw new Error(`Photo missing in storage: ${storageKey}`);
|
|
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(event, photo))) {
|
|
throw new Error('Photo file missing on disk');
|
|
}
|
|
|
|
if (storageKey) {
|
|
const stream = await storage.get(storageKey);
|
|
archive.append(stream, { name });
|
|
} else {
|
|
archive.file(resolvePhotoFilePath(event, photo), { name });
|
|
}
|
|
appended += 1;
|
|
} catch (err) {
|
|
logger.warn('transferService: skipping photo in transfer archive', {
|
|
transferId: transfer.id, photoId: photo.id, error: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Admin-uploaded deliverable files. Foldered under files/ only when the
|
|
// transfer also spans multiple events, to match the photo foldering above.
|
|
const extraFiles = await loadTransferExtraFiles(transfer.id);
|
|
const usedNames = new Set();
|
|
for (const extra of extraFiles) {
|
|
let base = sanitizeForZipEntry(extra.original_filename) || `file-${extra.id}`;
|
|
if (usedNames.has(base)) base = `${extra.id}-${base}`; // keep same-named uploads apart
|
|
usedNames.add(base);
|
|
const name = multiEvent ? `files/${base}` : base;
|
|
try {
|
|
const srcStat = storage.kind() === 'local' ? await storage.stat(extra.stored_path) : true;
|
|
if (!srcStat) throw new Error(`Extra file missing in storage: ${extra.stored_path}`);
|
|
const stream = await storage.get(extra.stored_path);
|
|
archive.append(stream, { name });
|
|
appended += 1;
|
|
} catch (err) {
|
|
logger.warn('transferService: skipping extra file in transfer archive', {
|
|
transferId: transfer.id, extraId: extra.id, error: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
await archive.finalize();
|
|
return appended;
|
|
}
|
|
|
|
/** Load the admin-uploaded deliverable files for a transfer, in order. */
|
|
async function loadTransferExtraFiles(transferId) {
|
|
return db('transfer_extra_files')
|
|
.where('transfer_id', transferId)
|
|
.orderBy('sort_order', 'asc')
|
|
.orderBy('id', 'asc')
|
|
.select('id', 'original_filename', 'stored_path', 'size_bytes', 'mime_type');
|
|
}
|
|
|
|
/**
|
|
* Stream a single ORIGINAL file from a transfer to `res`. Returns false when
|
|
* the file id isn't part of this transfer or the bytes are missing.
|
|
*/
|
|
async function streamTransferFile(transfer, rawFileId, res) {
|
|
// Public file ids are prefixed (see getPublicView): `p<id>` = referenced
|
|
// gallery photo, `x<id>` = admin-uploaded file. Tolerate a bare number as a
|
|
// photo id for safety.
|
|
const idStr = String(rawFileId || '');
|
|
const prefix = /^[a-z]/i.test(idStr) ? idStr[0].toLowerCase() : 'p';
|
|
const numId = parseInt(/^[a-z]/i.test(idStr) ? idStr.slice(1) : idStr, 10);
|
|
if (!Number.isFinite(numId) || numId <= 0) return false;
|
|
|
|
if (prefix === 'x') {
|
|
return streamTransferExtraFile(transfer, numId, res);
|
|
}
|
|
|
|
const row = await db('transfer_files')
|
|
.join('photos', 'photos.id', 'transfer_files.photo_id')
|
|
.join('events', 'events.id', 'photos.event_id')
|
|
.where('transfer_files.transfer_id', transfer.id)
|
|
.where('transfer_files.id', numId)
|
|
.select(
|
|
'photos.*',
|
|
'events.slug as event_slug',
|
|
'events.source_mode as event_source_mode',
|
|
'events.external_path as event_external_path',
|
|
)
|
|
.first();
|
|
if (!row) return false;
|
|
|
|
const event = {
|
|
id: row.event_id,
|
|
slug: row.event_slug,
|
|
source_mode: row.event_source_mode,
|
|
external_path: row.event_external_path,
|
|
};
|
|
const useOriginal = await getUseOriginalFilenames();
|
|
const [name] = getZipEntryNames([row], useOriginal);
|
|
const filename = name || row.filename || `photo-${row.id}.jpg`;
|
|
|
|
// Resolve + verify the source exists BEFORE writing any response header, so a
|
|
// missing file yields a clean 404 rather than a truncated 200. The joined row
|
|
// carries photos.* (path / source_origin / external_relpath), so it is a
|
|
// valid photo object for the resolver as-is.
|
|
const storage = getStorage();
|
|
const storageKey = resolvePhotoStorageKey(event, row);
|
|
let source; // { type: 'stream' | 'file', value }
|
|
if (storageKey) {
|
|
if (storage.kind() === 'local') {
|
|
const srcStat = await storage.stat(storageKey);
|
|
if (!srcStat) return false;
|
|
}
|
|
source = { type: 'stream', value: await storage.get(storageKey) };
|
|
} else {
|
|
const abs = resolvePhotoFilePath(event, row);
|
|
if (!fs.existsSync(abs)) return false;
|
|
source = { type: 'file', value: abs };
|
|
}
|
|
|
|
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
|
if (source.type === 'stream') {
|
|
source.value.pipe(res);
|
|
} else {
|
|
fs.createReadStream(source.value).pipe(res);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Stream a single admin-uploaded deliverable file from storage to `res`. */
|
|
async function streamTransferExtraFile(transfer, extraId, res) {
|
|
const row = await db('transfer_extra_files')
|
|
.where({ id: extraId, transfer_id: transfer.id })
|
|
.first();
|
|
if (!row) return false;
|
|
|
|
const storage = getStorage();
|
|
if (storage.kind() === 'local') {
|
|
const srcStat = await storage.stat(row.stored_path);
|
|
if (!srcStat) return false;
|
|
}
|
|
const stream = await storage.get(row.stored_path);
|
|
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(row.original_filename)}"`);
|
|
stream.pipe(res);
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin-uploaded deliverable files
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Record an admin-uploaded deliverable file (bytes already written to storage). */
|
|
async function addExtraFile(transferId, { originalFilename, storedPath, sizeBytes, mimeType }) {
|
|
const maxOrderRow = await db('transfer_extra_files')
|
|
.where('transfer_id', transferId)
|
|
.max('sort_order as max')
|
|
.first();
|
|
const order = ((maxOrderRow && Number(maxOrderRow.max)) || 0) + 1;
|
|
const [id] = await db('transfer_extra_files').insert({
|
|
transfer_id: transferId,
|
|
original_filename: String(originalFilename || 'file').slice(0, 512),
|
|
stored_path: storedPath,
|
|
size_bytes: sizeBytes || null,
|
|
mime_type: mimeType || null,
|
|
sort_order: order,
|
|
created_at: new Date(),
|
|
}).returning('id');
|
|
await db('transfers').where({ id: transferId }).update({ updated_at: new Date() });
|
|
return typeof id === 'object' && id !== null ? id.id : id;
|
|
}
|
|
|
|
/** Remove one admin-uploaded deliverable file (row + bytes). */
|
|
async function removeExtraFile(transferId, extraId) {
|
|
const row = await db('transfer_extra_files').where({ id: extraId, transfer_id: transferId }).first();
|
|
if (!row) return getTransfer(transferId);
|
|
try {
|
|
await getStorage().delete(row.stored_path);
|
|
} catch (err) {
|
|
logger.warn('transferService: failed to delete extra file', {
|
|
transferId, path: row.stored_path, error: err.message,
|
|
});
|
|
}
|
|
await db('transfer_extra_files').where({ id: extraId, transfer_id: transferId }).del();
|
|
await db('transfers').where({ id: transferId }).update({ updated_at: new Date() });
|
|
return getTransfer(transferId);
|
|
}
|
|
|
|
/** Delete all admin-uploaded deliverable bytes for a transfer (hard delete). */
|
|
async function removeExtraFiles(transferId) {
|
|
const rows = await db('transfer_extra_files').where({ transfer_id: transferId }).select('stored_path');
|
|
const storage = getStorage();
|
|
for (const r of rows) {
|
|
if (!r.stored_path) continue;
|
|
try {
|
|
await storage.delete(r.stored_path);
|
|
} catch (err) {
|
|
logger.warn('transferService: failed to delete extra file', {
|
|
transferId, path: r.stored_path, error: err.message,
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
if (storage.kind() === 'local') {
|
|
const dir = storage.resolveLocalPath(extraFilesDirKey(transferId));
|
|
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
} catch (_) { /* noop */ }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Email delivery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Email the download link to one or more recipients and record them. Sending is
|
|
* best-effort per address (a bad SMTP config must not fail the whole create);
|
|
* `sendTemplateEmail` is required lazily to avoid a service-load cycle.
|
|
*/
|
|
async function sendTransferEmails(transferId, emails) {
|
|
const clean = [...new Set((emails || [])
|
|
.map((e) => String(e || '').trim())
|
|
.filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)))];
|
|
if (!clean.length) return { sent: 0, recipients: [] };
|
|
|
|
const transfer = await db('transfers').where({ id: transferId }).first();
|
|
if (!transfer) return { sent: 0, recipients: [] };
|
|
|
|
const fileCountRow = await db('transfer_files').where('transfer_id', transferId).count('* as c').first();
|
|
const extraCountRow = await db('transfer_extra_files').where('transfer_id', transferId).count('* as c').first();
|
|
const fileCount = (Number(fileCountRow?.c) || 0) + (Number(extraCountRow?.c) || 0);
|
|
|
|
const { sendTemplateEmail } = require('./emailProcessor');
|
|
const downloadUrl = `${await getFrontendUrl()}/transfer/${transfer.token}`;
|
|
const vars = {
|
|
transfer_title: transfer.title || `Transfer #${transferId}`,
|
|
message: transfer.message || '',
|
|
download_url: downloadUrl,
|
|
file_count: String(fileCount),
|
|
expiry_date: transfer.expires_at ? new Date(transfer.expires_at).toISOString().slice(0, 10) : '',
|
|
};
|
|
|
|
let sent = 0;
|
|
for (const email of clean) {
|
|
try {
|
|
await sendTemplateEmail(email, 'transfer_ready', vars);
|
|
sent += 1;
|
|
} catch (err) {
|
|
logger.warn('transferService: failed to send transfer_ready email', {
|
|
transferId, email, error: err.message,
|
|
});
|
|
}
|
|
// Record the recipient regardless of delivery so the detail panel shows who
|
|
// it was addressed to (and a future resend has the list).
|
|
const existing = await db('transfer_recipients').where({ transfer_id: transferId, email }).first();
|
|
if (existing) {
|
|
await db('transfer_recipients').where({ id: existing.id }).update({ last_sent_at: new Date() });
|
|
} else {
|
|
await db('transfer_recipients').insert({
|
|
transfer_id: transferId, email, created_at: new Date(), last_sent_at: new Date(),
|
|
});
|
|
}
|
|
}
|
|
return { sent, recipients: clean };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client uploads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function assertUploadable(transfer) {
|
|
if (!transfer || transfer.deleted_at) return { ok: false, code: 'NOT_FOUND', status: 404 };
|
|
const allow = transfer.allow_uploads === true || transfer.allow_uploads === 1;
|
|
if (!allow) return { ok: false, code: 'UPLOADS_DISABLED', status: 403 };
|
|
const exp = transfer.upload_expires_at || transfer.expires_at;
|
|
if (exp && new Date(exp).getTime() <= Date.now()) {
|
|
return { ok: false, code: 'UPLOAD_EXPIRED', status: 410 };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Record a client-uploaded file (bytes already written by the route/multer). */
|
|
async function addUpload(transferId, { originalFilename, storedPath, sizeBytes, mimeType, ip }) {
|
|
const [id] = await db('transfer_uploads').insert({
|
|
transfer_id: transferId,
|
|
original_filename: String(originalFilename || 'file').slice(0, 512),
|
|
stored_path: storedPath,
|
|
size_bytes: sizeBytes || null,
|
|
mime_type: mimeType || null,
|
|
uploader_ip: ip || null,
|
|
uploaded_at: new Date(),
|
|
}).returning('id');
|
|
await db('transfers').where({ id: transferId }).update({ updated_at: new Date() });
|
|
return typeof id === 'object' && id !== null ? id.id : id;
|
|
}
|
|
|
|
/** Resolve the on-disk path of a stored upload for admin download / deletion. */
|
|
async function getUpload(transferId, uploadId) {
|
|
const upload = await db('transfer_uploads')
|
|
.where({ id: uploadId, transfer_id: transferId })
|
|
.first();
|
|
if (!upload) return null;
|
|
const storage = getStorage();
|
|
let localPath = null;
|
|
try {
|
|
localPath = storage.kind() === 'local' ? storage.resolveLocalPath(upload.stored_path) : null;
|
|
} catch (_) {
|
|
localPath = null;
|
|
}
|
|
return { ...upload, localPath };
|
|
}
|
|
|
|
/** Delete all client-uploaded bytes for a transfer (retention / hard delete). */
|
|
async function removeUploadedFiles(transferId) {
|
|
const uploads = await db('transfer_uploads').where({ transfer_id: transferId }).select('stored_path');
|
|
const storage = getStorage();
|
|
for (const u of uploads) {
|
|
if (!u.stored_path) continue;
|
|
try {
|
|
await storage.delete(u.stored_path);
|
|
} catch (err) {
|
|
logger.warn('transferService: failed to delete upload file', {
|
|
transferId, path: u.stored_path, error: err.message,
|
|
});
|
|
}
|
|
}
|
|
// Best-effort: remove the now-empty per-transfer directory on local storage.
|
|
try {
|
|
if (storage.kind() === 'local') {
|
|
const dir = storage.resolveLocalPath(uploadDirKey(transferId));
|
|
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
} catch (_) { /* noop */ }
|
|
}
|
|
|
|
module.exports = {
|
|
// constants / helpers
|
|
UPLOAD_TOKEN_LENGTH,
|
|
getFrontendUrl,
|
|
uploadDirKey,
|
|
extraFilesDirKey,
|
|
computeStatus,
|
|
downloadsRemaining,
|
|
// admin CRUD
|
|
createTransfer,
|
|
listTransfers,
|
|
getTransfer,
|
|
getTransferOwner,
|
|
filterOwnedPhotoIds,
|
|
updateTransfer,
|
|
deleteTransfer,
|
|
addFiles,
|
|
removeFile,
|
|
addExtraFile,
|
|
removeExtraFile,
|
|
removeExtraFiles,
|
|
enableUploads,
|
|
disableUploads,
|
|
sendTransferEmails,
|
|
// public
|
|
getTransferByToken,
|
|
getTransferByUploadToken,
|
|
getPublicView,
|
|
assertDownloadable,
|
|
recordDownload,
|
|
streamTransferArchive,
|
|
streamTransferFile,
|
|
streamTransferExtraFile,
|
|
// uploads
|
|
assertUploadable,
|
|
addUpload,
|
|
getUpload,
|
|
removeUploadedFiles,
|
|
};
|