243 lines
22 KiB
JavaScript
243 lines
22 KiB
JavaScript
/**
|
|||
|
|
* 대용량 첨부파일(0.9.3~): 큰 파일을 메일에 첨부하지 않고 서버에 보관한 뒤 다운로드 링크를 보냅니다.
|
||
|
|
*
|
||
|
|
* 흐름
|
||
|
|
* 1) 작성 창에서 "대용량 첨부" 로 올리면 파일은 디스크(filesDir, 기본 <DB 디렉터리>/files/<id>)에 흘려 쓰고 large_files 표에 메타데이터만 남깁니다(state='draft').
|
||
|
|
* 업로드는 index.mjs 가 요청 본문을 모으지 않고 그대로 넘겨(스트리밍) 1GB 급 파일도 메모리를 쓰지 않습니다.
|
||
|
|
* 2) 보낼 때(store.sendDraft → finalizeLargeFiles) 보관 기간(일)과 다운로드 횟수를 **반드시** 정합니다. 둘 다 관리자 설정의 상한 이내여야 하며
|
||
|
|
* "제한 없음" 은 없습니다(요구사항: 기간 제한이나 횟수 제한 없는 발송 불가). 파일마다 비밀 토큰을 만들어 링크(https://<호스트>/dl/<토큰>)를 본문 끝에 넣습니다.
|
||
|
|
* 3) 받는 사람이 링크를 열면 랜딩 페이지(파일 이름·크기·남은 횟수·기한)가 보이고, "다운로드" 단추(POST)를 누를 때만 횟수가 1 줄며 파일을 흘려보냅니다.
|
||
|
|
* GET 으로 세지 않는 이유: 메일 보안 장치(Outlook Safe Links 등)와 미리 보기 봇이 링크를 미리 열어 보므로, 그때마다 횟수가 줄면 받는 사람이 정작 못 받습니다.
|
||
|
|
* 4) 기한이 지나거나 횟수를 다 쓰면(또는 보낸 사람이 링크를 끊으면) 파일을 지우고 state='expired' 로 둡니다. 정리 작업(cleanupLargeFiles)이 1시간마다 돌며,
|
||
|
|
* 횟수를 다 쓴 파일은 마지막 다운로드가 끝나는 즉시 지웁니다.
|
||
|
|
*
|
||
|
|
* 설정은 두 층입니다(limits.mjs 와 같은 방식, 단 어느 층도 "무제한" 이 없음).
|
||
|
|
* - 시스템(systemLargeFiles): 환경 변수 LIMIT_LARGE_FILE_BYTES(기본 1G) · LIMIT_LARGE_FILE_DAYS(기본 30) · LIMIT_LARGE_FILE_DOWNLOADS(기본 20).
|
||
|
|
* 0 이나 잘못된 값은 기본값으로 대체합니다(반드시 값이 있어야 함).
|
||
|
|
* - 관리자(readLargeFileSettings, settings 표의 large_files): 사용 여부(enabled), 최대 크기·보관 기간·다운로드 횟수. 각각 시스템 값 이내이며
|
||
|
|
* 기본값은 시스템 값의 50% 입니다(요구사항). 기본은 사용 안 함(디스크를 쓰는 기능이라 관리자가 켜야 합니다).
|
||
|
|
*/
|
||
|
|
import { createWriteStream, createReadStream } from 'node:fs';
|
||
|
|
import { mkdir, unlink, stat } from 'node:fs/promises';
|
||
|
|
import { once } from 'node:events';
|
||
|
|
import { dirname, join } from 'node:path';
|
||
|
|
import { now, uid, clean, secret, escapeHtml } from './util.mjs';
|
||
|
|
import { parseSize } from './limits.mjs';
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 설정
|
||
|
|
/** 시스템 기본값. 환경 변수가 없거나 잘못됐을 때 쓰며, "무제한" 은 없습니다. */
|
||
|
|
export const DEFAULT_SYSTEM_LARGE = { bytes: 1024 ** 3, days: 30, downloads: 20 };
|
||
|
|
/** 한 메일에 넣을 수 있는 대용량 파일 수. */
|
||
|
|
export const LARGE_FILE_COUNT = 10;
|
||
|
|
const posInt = (v, fallback) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : fallback; };
|
||
|
|
/** 시스템 설정(환경 파일). 값이 0·비어 있음·잘못됨이면 기본값(1GB / 30일 / 20회)으로 대체해 항상 유한한 값을 돌려줍니다. */
|
||
|
|
export function systemLargeFiles(env) {
|
||
|
|
const bytes = parseSize(env.LIMIT_LARGE_FILE_BYTES);
|
||
|
|
return { bytes: bytes > 0 ? bytes : DEFAULT_SYSTEM_LARGE.bytes, days: posInt(env.LIMIT_LARGE_FILE_DAYS, DEFAULT_SYSTEM_LARGE.days), downloads: posInt(env.LIMIT_LARGE_FILE_DOWNLOADS, DEFAULT_SYSTEM_LARGE.downloads) };
|
||
|
|
}
|
||
|
|
/** 관리자 설정의 기본값: 시스템 값의 50%(바이트는 내림, 일·횟수는 올림, 최소 1). */
|
||
|
|
export function defaultAdminLarge(sys) { return { enabled: false, maxBytes: Math.max(1, Math.floor(sys.bytes / 2)), maxDays: Math.max(1, Math.ceil(sys.days / 2)), maxDownloads: Math.max(1, Math.ceil(sys.downloads / 2)) }; }
|
||
|
|
const CAP = { maxBytes: 'bytes', maxDays: 'days', maxDownloads: 'downloads' };
|
||
|
|
/**
|
||
|
|
* 관리자 설정(settings 의 large_files). 저장된 값이 손상됐거나 시스템 값을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎고, 없으면 기본값(50%)입니다.
|
||
|
|
* @returns {Promise<{enabled:boolean,maxBytes:number,maxDays:number,maxDownloads:number}>}
|
||
|
|
*/
|
||
|
|
export async function readLargeFileSettings(env) {
|
||
|
|
const sys = systemLargeFiles(env), def = defaultAdminLarge(sys);
|
||
|
|
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('large_files').first();
|
||
|
|
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
|
||
|
|
if (!stored || typeof stored !== 'object') stored = {};
|
||
|
|
const out = { enabled: !!stored.enabled };
|
||
|
|
for (const [k, s] of Object.entries(CAP)) { const v = posInt(stored[k], def[k]); out[k] = Math.min(v, sys[s]); }
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 관리자 설정을 저장합니다. 크기·기간·횟수는 1 이상의 정수이고(0 = 무제한은 허용하지 않음 → limit_invalid) 시스템 값 이내여야 합니다(limit_exceeds_system).
|
||
|
|
* 오류에는 field 와 상한(limit)을 붙여 화면이 어느 항목인지 알려 줍니다.
|
||
|
|
*/
|
||
|
|
export async function writeLargeFileSettings(env, patch) {
|
||
|
|
const sys = systemLargeFiles(env), next = await readLargeFileSettings(env);
|
||
|
|
if (patch.enabled !== undefined) next.enabled = !!patch.enabled;
|
||
|
|
for (const [k, s] of Object.entries(CAP)) {
|
||
|
|
if (patch[k] === undefined) continue;
|
||
|
|
const n = Number(patch[k]);
|
||
|
|
if (!Number.isInteger(n) || n < 1) { const e = new Error('limit_invalid'); e.field = k; throw e; }
|
||
|
|
if (n > sys[s]) { const e = new Error('limit_exceeds_system'); e.field = k; e.limit = sys[s]; throw e; }
|
||
|
|
next[k] = n;
|
||
|
|
}
|
||
|
|
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('large_files', JSON.stringify(next), now()).run();
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
/** 파일을 둘 디렉터리: FILES_DIR 또는 DB 파일 옆의 files/. 배포 서비스는 <APP_DIR>/data 에 쓰기 권한이 있으므로 기본값이 바로 동작합니다. */
|
||
|
|
export function filesDir(env) { return env.FILES_DIR || join(dirname(env.DB_FILE || 'data/mail-gateway.sqlite'), 'files'); }
|
||
|
|
const pathOf = (env, id) => join(filesDir(env), id);
|
||
|
|
async function unlinkQuiet(path) { try { await unlink(path); } catch { /* 이미 없음 */ } }
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 업로드(초안에 파일 붙이기)
|
||
|
|
/**
|
||
|
|
* 초안에 대용량 파일을 올립니다. 본문 스트림(Node 요청 객체 또는 async iterable)을 디스크에 흘려 쓰며 관리자 상한(maxBytes)을 넘는 순간 중단하고 지웁니다.
|
||
|
|
* @param {object} user 로그인 사용자(mailbox 로 초안 소유를 확인)
|
||
|
|
* @param {AsyncIterable<Buffer>} stream 파일 바이트
|
||
|
|
* @param {object} meta { draftId, filename, contentType, declaredSize? } — declaredSize(Content-Length)가 상한을 넘으면 받기 전에 거부합니다.
|
||
|
|
* @returns {Promise<object>} large_files 행
|
||
|
|
* @throws large_files_disabled | mail_locked | too_many_files | file_too_large(limit) | bad_request
|
||
|
|
*/
|
||
|
|
export async function uploadLargeFile(env, user, stream, { draftId, filename, contentType, declaredSize = 0 }) {
|
||
|
|
const cfg = await readLargeFileSettings(env);
|
||
|
|
if (!cfg.enabled) throw new Error('large_files_disabled');
|
||
|
|
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts' AND mailbox=?").bind(String(draftId || ''), user.mailbox).first();
|
||
|
|
if (!draft) throw new Error('mail_locked');
|
||
|
|
const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM large_files WHERE message_id=? AND state='draft'").bind(draft.id).first();
|
||
|
|
if (Number(count?.n || 0) >= LARGE_FILE_COUNT) throw new Error('too_many_files');
|
||
|
|
const tooBig = () => { const e = new Error('file_too_large'); e.limit = cfg.maxBytes; return e; };
|
||
|
|
if (Number(declaredSize) > cfg.maxBytes) throw tooBig();
|
||
|
|
try { await mkdir(filesDir(env), { recursive: true }); } catch (e) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; }
|
||
|
|
const id = uid(), path = pathOf(env, id), out = createWriteStream(path, { flags: 'wx', mode: 0o600 });
|
||
|
|
// 쓰기 오류(디스크 가득 참 ENOSPC 등)는 다음 네트워크 조각을 기다리는 동안에도 날 수 있습니다. 듣는 쪽이 없으면 'error' 이벤트가 프로세스를 죽이므로
|
||
|
|
// 처음부터 받아 두었다가 조각마다 확인해 업로드만 실패시킵니다(0.9.5 수정).
|
||
|
|
// failed: 쓰기 오류가 나면 거부되는 프라미스 하나. 예전(0.9.5~1.0.0)에는 조각마다 out.once('error', reject) 를 새로 달아 drain 을 기다릴 때마다
|
||
|
|
// 리스너가 쌓였고(1GB 업로드면 수천 개), 11개째에 MaxListenersExceededWarning 이 기록됐습니다(mail.heart-m.com 2026-10-13). 리스너는 이 한 개만 둡니다(1.0.5 수정).
|
||
|
|
let writeError = null; const failed = new Promise((_, reject) => out.on('error', (e) => { writeError = e; reject(e); })); failed.catch(() => {});
|
||
|
|
let size = 0;
|
||
|
|
try {
|
||
|
|
// 파일이 실제로 열린 뒤에 쓰기 시작합니다. 열리기 전에 중단·삭제하면 삭제가 먼저 돌고 그 뒤 스트림이 파일을 만들어 디스크에 찌꺼기가 남습니다.
|
||
|
|
await Promise.race([once(out, 'open'), failed]);
|
||
|
|
for await (const chunk of stream) {
|
||
|
|
if (writeError) throw writeError;
|
||
|
|
size += chunk.length;
|
||
|
|
if (size > cfg.maxBytes) throw tooBig();
|
||
|
|
if (!out.write(chunk)) await Promise.race([once(out, 'drain'), failed]);
|
||
|
|
}
|
||
|
|
if (writeError) throw writeError;
|
||
|
|
await Promise.race([new Promise((resolve) => out.end(resolve)), failed]);
|
||
|
|
} catch (e) { if (!out.destroyed) { out.destroy(); await once(out, 'close').catch(() => {}); } await unlinkQuiet(path); if (e && e.code && !e.limit) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; } throw e; }
|
||
|
|
if (size === 0) { await unlinkQuiet(path); throw new Error('bad_request'); }
|
||
|
|
const name = clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'file';
|
||
|
|
await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,content_type,size,state,created_at) VALUES(?,?,?,?,?,?,'draft',?)")
|
||
|
|
.bind(id, draft.id, user.mailbox, name, clean(contentType, 120).split(';')[0] || 'application/octet-stream', size, now()).run();
|
||
|
|
return (await env.DB.prepare('SELECT * FROM large_files WHERE id=?').bind(id).first());
|
||
|
|
}
|
||
|
|
/** 메일에 딸린 대용량 파일 목록(화면용: 바이트는 빼고 상태·남은 횟수·기한). */
|
||
|
|
export async function listLargeFiles(env, messageId) {
|
||
|
|
const rows = (await env.DB.prepare('SELECT id,filename,content_type,size,state,max_downloads,downloads,expires_at,created_at,sent_at,last_download_at FROM large_files WHERE message_id=? ORDER BY created_at').bind(messageId).all()).results;
|
||
|
|
return rows.map((r) => ({ ...r, remaining: r.max_downloads == null ? null : Math.max(0, r.max_downloads - r.downloads), expired: r.state === 'expired' || (r.state === 'sent' && isExpired(r)) }));
|
||
|
|
}
|
||
|
|
const isExpired = (r) => (r.expires_at && Date.parse(r.expires_at) <= Date.now()) || (r.max_downloads != null && r.downloads >= r.max_downloads);
|
||
|
|
/**
|
||
|
|
* 파일을 뗍니다. 초안의 파일은 행과 파일을 지우고, 보낸 메일의 파일은 링크를 끊습니다(파일 삭제 + state='expired', 행은 기록으로 남김).
|
||
|
|
* 소유 확인은 메일함(mailbox)으로 합니다. 없으면 not_found.
|
||
|
|
*/
|
||
|
|
export async function removeLargeFile(env, id, mailbox, messageId = null) {
|
||
|
|
const row = await env.DB.prepare('SELECT * FROM large_files WHERE id=? AND mailbox=?').bind(id, mailbox).first();
|
||
|
|
if (!row || (messageId && row.message_id && row.message_id !== messageId)) throw new Error('not_found'); // 다른 메일의 파일 id 를 끼워 넣어도 그 메일 경로로는 지울 수 없음(0.9.5)
|
||
|
|
await unlinkQuiet(pathOf(env, id));
|
||
|
|
if (row.state === 'draft') await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(id).run();
|
||
|
|
else await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(id).run();
|
||
|
|
return row;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 발송(링크 만들기)
|
||
|
|
/**
|
||
|
|
* 발송 직전에 초안의 대용량 파일에 기한·횟수·토큰을 붙입니다(store.sendDraft 가 부름).
|
||
|
|
* @param {object} terms { days, downloads } — 둘 다 필수, 1 이상이고 관리자 상한 이내. 어긋나면 large_file_terms(422, field·limit 동봉).
|
||
|
|
* @param {string} host 링크에 쓸 공개 호스트(PUBLIC_HOST)
|
||
|
|
* @returns {Promise<{files: object[], html: string, text: string}>} 본문에 덧붙일 HTML/텍스트 블록. 파일이 없으면 files 가 비고 html/text 는 ''.
|
||
|
|
*/
|
||
|
|
export async function finalizeLargeFiles(env, draftId, terms, host, strings = {}) {
|
||
|
|
// 발송에 실패했던 초안을 다시 보낼 때는 지난 시도에서 이미 링크를 만든 파일(state='sent')을 그대로 씁니다(0.9.5). 예전에는 실패 시 링크를 되돌리고 재시도 때 새 토큰을 만들어,
|
||
|
|
// 같은 멱등 키(send_key)로 다른 본문을 보내게 되어 발송 서비스가 거부했고, 첫 시도가 실제로는 전달됐다면 받는 사람의 링크가 죽었습니다. 같은 토큰이면 재시도 본문이 똑같습니다.
|
||
|
|
const files = (await env.DB.prepare("SELECT * FROM large_files WHERE message_id=? AND state IN ('draft','sent') ORDER BY created_at").bind(draftId).all()).results;
|
||
|
|
if (!files.length) return { files: [], html: '', text: '' };
|
||
|
|
const pending = files.filter((f) => f.state === 'draft'), kept = files.filter((f) => f.state === 'sent');
|
||
|
|
const cfg = await readLargeFileSettings(env);
|
||
|
|
const check = (v, max, field) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } return n; };
|
||
|
|
// 새 파일이 있으면 조건이 필요하고, 모두 지난 시도의 파일이면 그때의 조건을 그대로 씁니다.
|
||
|
|
const days = pending.length ? check(terms?.days, cfg.maxDays, 'days') : Math.max(1, Math.round((Date.parse(kept[0].expires_at) - Date.parse(kept[0].sent_at)) / 86400000));
|
||
|
|
const downloads = pending.length ? check(terms?.downloads, cfg.maxDownloads, 'downloads') : kept[0].max_downloads;
|
||
|
|
const stamp = now(), expiresAt = pending.length ? new Date(Date.now() + days * 86400000).toISOString() : kept[0].expires_at;
|
||
|
|
const out = kept.map((f) => ({ ...f, url: `https://${host}/dl/${f.token}` }));
|
||
|
|
for (const f of pending) {
|
||
|
|
const token = secret(24);
|
||
|
|
await env.DB.prepare("UPDATE large_files SET token=?,state='sent',max_downloads=?,expires_at=?,sent_at=? WHERE id=?").bind(token, downloads, expiresAt, stamp, f.id).run();
|
||
|
|
out.push({ ...f, token, max_downloads: downloads, expires_at: expiresAt, url: `https://${host}/dl/${token}` });
|
||
|
|
}
|
||
|
|
return { files: out, ...bodyBlock(out, { days, downloads, expiresAt }, env, strings) };
|
||
|
|
}
|
||
|
|
/** 발송 조건만 미리 검사합니다(발송 시작 전에 불러 어긋나면 초안이 '발송 실패' 로 잠기지 않게). 파일이 없으면 아무것도 하지 않습니다. */
|
||
|
|
export async function checkLargeFileTerms(env, draftId, terms) {
|
||
|
|
const n = await env.DB.prepare("SELECT SUM(CASE WHEN state='draft' THEN 1 ELSE 0 END) AS pending, COUNT(*) AS total FROM large_files WHERE message_id=? AND state IN ('draft','sent')").bind(draftId).first();
|
||
|
|
if (!Number(n?.total || 0)) return false;
|
||
|
|
if (!Number(n?.pending || 0)) return true; // 지난 시도에서 링크를 만든 파일뿐이면(재시도) 조건을 다시 묻지 않습니다.
|
||
|
|
const cfg = await readLargeFileSettings(env);
|
||
|
|
for (const [field, max] of [['days', cfg.maxDays], ['downloads', cfg.maxDownloads]]) { const v = Number(terms?.[field]); if (!Number.isInteger(v) || v < 1 || v > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } }
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
/** 바이트 수를 사람이 읽는 크기로(1.2 GB / 34.5 MB / 120 KB). */
|
||
|
|
export const humanSize = (n) => n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
|
||
|
|
/** 기한을 통계 시간대(기본 Asia/Seoul)의 "YYYY-MM-DD HH:MM" 로. 메일 본문·랜딩 페이지에 씁니다. */
|
||
|
|
export function formatExpiry(iso, env = {}) {
|
||
|
|
const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul');
|
||
|
|
try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(iso)).map((x) => [x.type, x.value])); return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; }
|
||
|
|
catch { return String(iso).slice(0, 16).replace('T', ' '); }
|
||
|
|
}
|
||
|
|
/** 본문 끝에 덧붙이는 "대용량 첨부파일" 블록(HTML + 텍스트). 링크·파일 이름·크기·기한·횟수를 담고, 받는 사람이 만료 규칙을 알 수 있게 안내문을 붙입니다. */
|
||
|
|
function bodyBlock(files, { downloads, expiresAt }, env, s) {
|
||
|
|
const t = (k, d) => s[k] || d, until = formatExpiry(expiresAt, env);
|
||
|
|
const note = t('largeFileNote', '링크는 기한이 지나거나 다운로드 횟수를 다 쓰면 만료되어 파일을 받을 수 없습니다.');
|
||
|
|
const terms = t('largeFileTerms', '{until} 까지 · {downloads}회').replace('{until}', until).replace('{downloads}', String(downloads));
|
||
|
|
const html = `<div class="large-files" style="margin-top:18px;padding:12px 14px;border:1px solid #d9dee5;border-radius:8px;font-size:14px"><p style="margin:0 0 6px"><strong>${escapeHtml(t('largeFileHeading', '대용량 첨부파일'))}</strong></p><ul style="margin:0;padding-left:18px">${files.map((f) => `<li><a href="${escapeHtml(f.url)}">${escapeHtml(f.filename)}</a> (${humanSize(f.size)}) — ${escapeHtml(terms)}</li>`).join('')}</ul><p style="margin:8px 0 0;font-size:12px;color:#6b7a8c">${escapeHtml(note)}</p></div>`;
|
||
|
|
const text = `\n\n[${t('largeFileHeading', '대용량 첨부파일')}]\n${files.map((f) => `- ${f.filename} (${humanSize(f.size)}): ${f.url} (${terms})`).join('\n')}\n${note}\n`;
|
||
|
|
return { html, text };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 다운로드(공개 링크)
|
||
|
|
/** 토큰으로 파일 정보를 찾습니다(랜딩 페이지용, 횟수는 세지 않음). 없거나 만료면 { status: 'missing'|'expired' }. */
|
||
|
|
export async function largeFileInfo(env, token) {
|
||
|
|
const row = await env.DB.prepare('SELECT * FROM large_files WHERE token=?').bind(String(token || '')).first();
|
||
|
|
if (!row || row.state === 'draft') return { status: 'missing' };
|
||
|
|
if (row.state === 'expired' || isExpired(row)) return { status: 'expired', file: row };
|
||
|
|
return { status: 'ok', file: row, remaining: row.max_downloads - row.downloads };
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 다운로드 한 번을 차지합니다: 살아 있고 횟수가 남은 파일에만 downloads 를 1 올리고 그 행을 돌려줍니다(한 문장의 UPDATE 라 동시에 눌러도 횟수를 넘지 않음).
|
||
|
|
* @returns {Promise<{row:object, path:string, last:boolean}|null>} last 는 이번이 마지막 허용 횟수인지(끝나면 파일을 지움)
|
||
|
|
*/
|
||
|
|
export async function claimDownload(env, token) {
|
||
|
|
const row = await env.DB.prepare("UPDATE large_files SET downloads=downloads+1,last_download_at=? WHERE token=? AND state='sent' AND downloads<max_downloads AND expires_at>? RETURNING *").bind(now(), String(token || ''), now()).first();
|
||
|
|
if (!row) return null;
|
||
|
|
try { await stat(pathOf(env, row.id)); } catch { await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(row.id).run(); return null; }
|
||
|
|
return { row, path: pathOf(env, row.id), last: row.downloads >= row.max_downloads };
|
||
|
|
}
|
||
|
|
/** 다운로드 스트림(index.mjs 가 응답에 파이프). */
|
||
|
|
export const openLargeFile = (path) => createReadStream(path);
|
||
|
|
/** 마지막 허용 횟수의 다운로드가 끝난 뒤: 파일을 지우고 만료 처리합니다. */
|
||
|
|
export async function finishDownload(env, claim) {
|
||
|
|
if (!claim?.last) return;
|
||
|
|
await unlinkQuiet(claim.path);
|
||
|
|
await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(claim.row.id).run();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 정리·통계
|
||
|
|
/** 보낸 사람이 지운 초안(메일 행이 사라져 message_id 가 NULL)의 파일이 남아 있을 수 있는 유예 시간. 그 뒤 정리합니다. */
|
||
|
|
const ORPHAN_GRACE_MS = 24 * 3600 * 1000;
|
||
|
|
/**
|
||
|
|
* 주기 정리(1시간마다): 기한이 지났거나 횟수를 다 쓴 보낸 파일, 메일이 사라진 초안 파일, 하루 넘게 보내지 않은 초안의 파일 중 초안이 휴지통에 있는 것을 지웁니다.
|
||
|
|
* 작성 중인 초안(임시 보관함)의 파일은 그대로 둡니다.
|
||
|
|
* @returns {Promise<{expired:number, removed:number}>}
|
||
|
|
*/
|
||
|
|
export async function cleanupLargeFiles(env) {
|
||
|
|
const stamp = now(); let expired = 0, removed = 0;
|
||
|
|
const dead = (await env.DB.prepare("SELECT id FROM large_files WHERE state='sent' AND (expires_at<=? OR downloads>=max_downloads)").bind(stamp).all()).results;
|
||
|
|
for (const r of dead) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(r.id).run(); expired++; }
|
||
|
|
const cutoff = new Date(Date.now() - ORPHAN_GRACE_MS).toISOString();
|
||
|
|
// 유예는 초안을 버린 시점(휴지통으로 옮긴 updated_at, 메일이 지워졌으면 파일 올린 시점) 기준입니다. 예전에는 올린 시점 기준이라 하루 넘게 쓰던 초안을 버리면 한 시간 안에 파일이 사라졌습니다(0.9.5).
|
||
|
|
const orphans = (await env.DB.prepare(`SELECT f.id FROM large_files f LEFT JOIN messages m ON m.id=f.message_id WHERE f.state='draft' AND ((m.id IS NULL AND f.created_at<?) OR (m.folder='trash' AND m.updated_at<?))`).bind(cutoff, cutoff).all()).results;
|
||
|
|
for (const r of orphans) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(r.id).run(); removed++; }
|
||
|
|
return { expired, removed };
|
||
|
|
}
|
||
|
|
/** 디스크에 있는(만료되지 않은) 대용량 파일 수와 바이트 합계. 관리자 설정 카드·서버 상태에 보여 줍니다. */
|
||
|
|
export async function largeFileStats(env) {
|
||
|
|
const row = await env.DB.prepare("SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes, SUM(CASE WHEN state='sent' THEN 1 ELSE 0 END) AS sent FROM large_files WHERE state IN ('draft','sent')").first();
|
||
|
|
return { count: Number(row?.n || 0), bytes: Number(row?.bytes || 0), sent: Number(row?.sent || 0) };
|
||
|
|
}
|