688 lines
19 KiB
JavaScript
688 lines
19 KiB
JavaScript
require('dotenv').config();
|
|
|
|
const crypto = require('crypto');
|
|
const path = require('path');
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
|
|
const db = require('./db');
|
|
const { sendMail, fetchLatestEmails, fetchMessageDetail } = require('./mailService');
|
|
|
|
const app = express();
|
|
const port = Number(process.env.PORT || 3000);
|
|
const appPassword = String(process.env.APP_PASSWORD || '').trim();
|
|
const authCookieName = 'mail_sr_auth';
|
|
const authCookieValue = appPassword
|
|
? crypto.createHash('sha256').update(appPassword).digest('hex')
|
|
: '';
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '2mb' }));
|
|
app.use(express.urlencoded({ extended: false }));
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
app.get('/api/auth/status', (req, res) => {
|
|
res.json({ ok: true, authenticated: isAuthenticated(req) });
|
|
});
|
|
|
|
app.post('/api/auth/login', (req, res) => {
|
|
if (!appPassword) {
|
|
return res.json({ ok: true, authenticated: true });
|
|
}
|
|
|
|
const password = String(req.body.password || '');
|
|
if (password !== appPassword) {
|
|
return res.status(401).json({ error: '密码错误' });
|
|
}
|
|
|
|
res.setHeader('Set-Cookie', serializeAuthCookie(authCookieValue));
|
|
res.json({ ok: true, authenticated: true });
|
|
});
|
|
|
|
app.post('/api/auth/logout', (_req, res) => {
|
|
res.setHeader('Set-Cookie', serializeAuthCookie('', { maxAge: 0 }));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.use('/api', (req, res, next) => {
|
|
if (!appPassword) {
|
|
return next();
|
|
}
|
|
|
|
if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/health') {
|
|
return next();
|
|
}
|
|
|
|
if (!isAuthenticated(req)) {
|
|
return res.status(401).json({ error: '请先输入访问密码' });
|
|
}
|
|
|
|
return next();
|
|
});
|
|
|
|
const getChannelStmt = db.prepare('SELECT * FROM channels WHERE id = ?');
|
|
const getAccountStmt = db.prepare('SELECT * FROM accounts WHERE id = ?');
|
|
const listChannelsStmt = db.prepare(`
|
|
SELECT
|
|
c.*,
|
|
COUNT(a.id) AS account_count
|
|
FROM channels c
|
|
LEFT JOIN accounts a ON a.channel_id = c.id
|
|
GROUP BY c.id
|
|
ORDER BY c.name ASC
|
|
`);
|
|
const listAccountsByChannelStmt = db.prepare(`
|
|
SELECT a.*, c.name AS channel_name, c.code AS channel_code
|
|
FROM accounts a
|
|
JOIN channels c ON c.id = a.channel_id
|
|
WHERE a.channel_id = ?
|
|
ORDER BY a.email ASC
|
|
`);
|
|
const listAccountsStmt = db.prepare(`
|
|
SELECT a.*, c.name AS channel_name, c.code AS channel_code
|
|
FROM accounts a
|
|
JOIN channels c ON c.id = a.channel_id
|
|
ORDER BY c.name ASC, a.email ASC
|
|
`);
|
|
const listAccountsWithSecretsStmt = db.prepare(`
|
|
SELECT a.email, a.auth_code, c.name AS channel_name
|
|
FROM accounts a
|
|
JOIN channels c ON c.id = a.channel_id
|
|
ORDER BY c.name ASC, a.email ASC
|
|
`);
|
|
const getChannelByNameStmt = db.prepare('SELECT * FROM channels WHERE name = ?');
|
|
const getAccountByChannelEmailStmt = db.prepare('SELECT * FROM accounts WHERE channel_id = ? AND email = ?');
|
|
const insertChannelStmt = db.prepare(`
|
|
INSERT INTO channels (
|
|
name, code, imap_host, imap_port, imap_secure, smtp_host, smtp_port, smtp_secure
|
|
) VALUES (
|
|
@name, @code, @imap_host, @imap_port, @imap_secure, @smtp_host, @smtp_port, @smtp_secure
|
|
)
|
|
`);
|
|
const insertAccountStmt = db.prepare(`
|
|
INSERT INTO accounts (channel_id, email, auth_code, display_name)
|
|
VALUES (@channel_id, @email, @auth_code, @display_name)
|
|
`);
|
|
const updateAccountAuthCodeStmt = db.prepare('UPDATE accounts SET auth_code = ? WHERE id = ?');
|
|
const deleteAccountStmt = db.prepare('DELETE FROM accounts WHERE id = ?');
|
|
const deleteMailCacheByAccountStmt = db.prepare('DELETE FROM mail_cache WHERE account_id = ?');
|
|
const deleteAccountByEmailStmt = db.prepare('DELETE FROM accounts WHERE email = ?');
|
|
const deleteMailCacheByAccountEmailStmt = db.prepare(`
|
|
DELETE FROM mail_cache
|
|
WHERE account_id IN (SELECT id FROM accounts WHERE email = ?)
|
|
`);
|
|
const listCacheByAccountStmt = db.prepare(`
|
|
SELECT *
|
|
FROM mail_cache
|
|
WHERE account_id = ?
|
|
ORDER BY datetime(COALESCE(sent_at, created_at)) DESC, id DESC
|
|
LIMIT ?
|
|
`);
|
|
const latestCacheByAccountStmt = db.prepare(`
|
|
SELECT *
|
|
FROM mail_cache
|
|
WHERE account_id = ?
|
|
ORDER BY datetime(COALESCE(sent_at, created_at)) DESC, id DESC
|
|
LIMIT 1
|
|
`);
|
|
const getCacheByAccountUidStmt = db.prepare(`
|
|
SELECT *
|
|
FROM mail_cache
|
|
WHERE account_id = ? AND uid = ?
|
|
LIMIT 1
|
|
`);
|
|
const upsertMailCacheStmt = db.prepare(`
|
|
INSERT INTO mail_cache (
|
|
account_id, uid, folder, subject, from_name, from_address, to_addresses,
|
|
sent_at, text_content, html_content, raw_json
|
|
) VALUES (
|
|
@account_id, @uid, @folder, @subject, @from_name, @from_address, @to_addresses,
|
|
@sent_at, @text_content, @html_content, @raw_json
|
|
)
|
|
ON CONFLICT(account_id, uid) DO UPDATE SET
|
|
subject = excluded.subject,
|
|
from_name = excluded.from_name,
|
|
from_address = excluded.from_address,
|
|
to_addresses = excluded.to_addresses,
|
|
sent_at = excluded.sent_at,
|
|
text_content = excluded.text_content,
|
|
html_content = excluded.html_content,
|
|
raw_json = excluded.raw_json
|
|
`);
|
|
|
|
app.get('/api/health', (_req, res) => {
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/channels', (_req, res) => {
|
|
const channels = listChannelsStmt.all().map((channel) => ({
|
|
...channel,
|
|
imap_secure: Boolean(channel.imap_secure),
|
|
smtp_secure: Boolean(channel.smtp_secure),
|
|
}));
|
|
res.json(channels);
|
|
});
|
|
|
|
app.post('/api/channels', (req, res) => {
|
|
const payload = sanitizeChannelPayload(req.body);
|
|
if (!payload.ok) {
|
|
return res.status(400).json({ error: payload.error });
|
|
}
|
|
|
|
try {
|
|
const result = insertChannelStmt.run(payload.data);
|
|
const channel = getChannelStmt.get(result.lastInsertRowid);
|
|
res.status(201).json(channel);
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/accounts', (_req, res) => {
|
|
res.json(listAccountsStmt.all().map(serializeAccount));
|
|
});
|
|
|
|
app.get('/api/channels/:id/accounts', (req, res) => {
|
|
res.json(listAccountsByChannelStmt.all(req.params.id).map(serializeAccount));
|
|
});
|
|
|
|
app.post('/api/accounts', (req, res) => {
|
|
const payload = sanitizeAccountPayload(req.body);
|
|
if (!payload.ok) {
|
|
return res.status(400).json({ error: payload.error });
|
|
}
|
|
|
|
try {
|
|
const result = insertAccountStmt.run(payload.data);
|
|
const account = getAccountStmt.get(result.lastInsertRowid);
|
|
res.status(201).json(serializeAccount(account));
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/accounts/export', (_req, res) => {
|
|
const content = listAccountsWithSecretsStmt
|
|
.all()
|
|
.map((account) => `${account.channel_name}----${account.email}----${account.auth_code}`)
|
|
.join('\n');
|
|
|
|
res.json({ content });
|
|
});
|
|
|
|
app.post('/api/accounts/import', (req, res) => {
|
|
try {
|
|
const payload = sanitizeImportPayload(req.body);
|
|
if (!payload.ok) {
|
|
return res.status(400).json({ error: payload.error });
|
|
}
|
|
|
|
const result = importAccounts(payload.lines);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/accounts/:id', (req, res) => {
|
|
try {
|
|
const account = ensureAccount(req.params.id);
|
|
deleteAccountWithCache(account.id);
|
|
|
|
if (Number(req.params.id) === 1 && account.email === 'demo@qq.com') {
|
|
return res.json({ ok: true, removed: account.email });
|
|
}
|
|
|
|
res.json({ ok: true, removed: account.email });
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/accounts/:id/messages', async (req, res) => {
|
|
try {
|
|
const account = ensureAccount(req.params.id);
|
|
const channel = ensureChannel(account.channel_id);
|
|
const limit = clampLimit(req.query.limit);
|
|
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
|
|
|
if (refresh) {
|
|
const messages = await withTimeout(
|
|
fetchLatestEmails({ channel, account, limit }),
|
|
45000,
|
|
'收信超时,请稍后重试'
|
|
);
|
|
persistMessages(account.id, messages);
|
|
}
|
|
|
|
const cached = listCacheByAccountStmt.all(account.id, limit).map(serializeCachedMail);
|
|
res.json(cached);
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/accounts/:id/messages/latest', async (req, res) => {
|
|
try {
|
|
const account = ensureAccount(req.params.id);
|
|
const channel = ensureChannel(account.channel_id);
|
|
const refresh = String(req.query.refresh || 'true').toLowerCase() !== 'false';
|
|
|
|
if (refresh) {
|
|
const messages = await withTimeout(
|
|
fetchLatestEmails({ channel, account, limit: 1 }),
|
|
45000,
|
|
'收信超时,请稍后重试'
|
|
);
|
|
persistMessages(account.id, messages);
|
|
}
|
|
|
|
const latest = latestCacheByAccountStmt.get(account.id);
|
|
if (!latest) {
|
|
return res.status(404).json({ error: '该邮箱暂无邮件' });
|
|
}
|
|
|
|
res.json(serializeCachedMail(latest));
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/accounts/:id/messages/:uid', async (req, res) => {
|
|
try {
|
|
const account = ensureAccount(req.params.id);
|
|
const channel = ensureChannel(account.channel_id);
|
|
const refresh = String(req.query.refresh || 'true').toLowerCase() !== 'false';
|
|
const uid = decodeURIComponent(req.params.uid);
|
|
|
|
if (refresh) {
|
|
const message = await withTimeout(
|
|
fetchMessageDetail({ channel, account, compositeUid: uid }),
|
|
45000,
|
|
'加载邮件正文超时,请稍后重试'
|
|
);
|
|
if (message) {
|
|
persistMessages(account.id, [message]);
|
|
}
|
|
}
|
|
|
|
const cached = getCacheByAccountUidStmt.get(account.id, uid);
|
|
if (!cached) {
|
|
return res.status(404).json({ error: '邮件不存在' });
|
|
}
|
|
|
|
res.json(serializeCachedMail(cached));
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/accounts/:id/messages/send', async (req, res) => {
|
|
try {
|
|
const result = await sendMailForAccount(req.params.id, req.body);
|
|
res.json({ ok: true, ...result });
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/send', async (req, res) => {
|
|
const accountId = req.body.accountId;
|
|
if (!accountId) {
|
|
return res.status(400).json({ error: '缺少 accountId' });
|
|
}
|
|
|
|
try {
|
|
const result = await sendMailForAccount(accountId, req.body);
|
|
res.json({ ok: true, ...result });
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/messages/latest', async (req, res) => {
|
|
const accountId = req.query.accountId;
|
|
if (!accountId) {
|
|
return res.status(400).json({ error: '缺少 accountId' });
|
|
}
|
|
|
|
try {
|
|
const account = ensureAccount(accountId);
|
|
const channel = ensureChannel(account.channel_id);
|
|
const refresh = String(req.query.refresh || 'true').toLowerCase() !== 'false';
|
|
|
|
if (refresh) {
|
|
const messages = await withTimeout(
|
|
fetchLatestEmails({ channel, account, limit: 1 }),
|
|
45000,
|
|
'收信超时,请稍后重试'
|
|
);
|
|
persistMessages(account.id, messages);
|
|
}
|
|
|
|
const latest = latestCacheByAccountStmt.get(account.id);
|
|
if (!latest) {
|
|
return res.status(404).json({ error: '该邮箱暂无邮件' });
|
|
}
|
|
|
|
res.json(serializeCachedMail(latest));
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/messages', async (req, res) => {
|
|
const accountId = req.query.accountId;
|
|
if (!accountId) {
|
|
return res.status(400).json({ error: '缺少 accountId' });
|
|
}
|
|
|
|
try {
|
|
const account = ensureAccount(accountId);
|
|
const channel = ensureChannel(account.channel_id);
|
|
const limit = clampLimit(req.query.limit);
|
|
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
|
|
|
if (refresh) {
|
|
const messages = await withTimeout(
|
|
fetchLatestEmails({ channel, account, limit }),
|
|
45000,
|
|
'收信超时,请稍后重试'
|
|
);
|
|
persistMessages(account.id, messages);
|
|
}
|
|
|
|
res.json(listCacheByAccountStmt.all(account.id, limit).map(serializeCachedMail));
|
|
} catch (error) {
|
|
res.status(resolveStatus(error)).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(`mail-sr running at http://localhost:${port}`);
|
|
});
|
|
|
|
function serializeAccount(account) {
|
|
return {
|
|
...account,
|
|
auth_code: undefined,
|
|
};
|
|
}
|
|
|
|
function isAuthenticated(req) {
|
|
if (!appPassword) {
|
|
return true;
|
|
}
|
|
|
|
const cookies = parseCookies(req.headers.cookie || '');
|
|
return cookies[authCookieName] === authCookieValue;
|
|
}
|
|
|
|
function parseCookies(cookieHeader) {
|
|
return String(cookieHeader || '')
|
|
.split(';')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
.reduce((result, item) => {
|
|
const separatorIndex = item.indexOf('=');
|
|
if (separatorIndex === -1) {
|
|
return result;
|
|
}
|
|
|
|
const key = item.slice(0, separatorIndex).trim();
|
|
const value = decodeURIComponent(item.slice(separatorIndex + 1).trim());
|
|
result[key] = value;
|
|
return result;
|
|
}, {});
|
|
}
|
|
|
|
function serializeAuthCookie(value, options = {}) {
|
|
const parts = [
|
|
`${authCookieName}=${encodeURIComponent(value)}`,
|
|
'Path=/',
|
|
'HttpOnly',
|
|
'SameSite=Lax',
|
|
];
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
parts.push('Secure');
|
|
}
|
|
|
|
if (typeof options.maxAge === 'number') {
|
|
parts.push(`Max-Age=${options.maxAge}`);
|
|
}
|
|
|
|
return parts.join('; ');
|
|
}
|
|
|
|
function sanitizeChannelPayload(body) {
|
|
const data = {
|
|
name: String(body.name || '').trim(),
|
|
code: String(body.code || '').trim().toLowerCase(),
|
|
imap_host: String(body.imap_host || '').trim(),
|
|
imap_port: Number(body.imap_port),
|
|
imap_secure: body.imap_secure === false ? 0 : 1,
|
|
smtp_host: String(body.smtp_host || '').trim(),
|
|
smtp_port: Number(body.smtp_port),
|
|
smtp_secure: body.smtp_secure === false ? 0 : 1,
|
|
};
|
|
|
|
if (!data.name || !data.code || !data.imap_host || !data.smtp_host || !data.imap_port || !data.smtp_port) {
|
|
return { ok: false, error: '渠道参数不完整' };
|
|
}
|
|
|
|
return { ok: true, data };
|
|
}
|
|
|
|
function sanitizeAccountPayload(body) {
|
|
const data = {
|
|
channel_id: Number(body.channel_id),
|
|
email: String(body.email || '').trim(),
|
|
auth_code: String(body.auth_code || '').trim(),
|
|
display_name: String(body.display_name || '').trim() || null,
|
|
};
|
|
|
|
if (!data.channel_id || !data.email || !data.auth_code) {
|
|
return { ok: false, error: '帐号参数不完整' };
|
|
}
|
|
|
|
return { ok: true, data };
|
|
}
|
|
|
|
function sanitizeSendPayload(body) {
|
|
const data = {
|
|
from: body.from ? String(body.from).trim() : undefined,
|
|
to: String(body.to || '').trim(),
|
|
cc: body.cc ? String(body.cc).trim() : undefined,
|
|
bcc: body.bcc ? String(body.bcc).trim() : undefined,
|
|
subject: String(body.subject || '').trim(),
|
|
text: body.text ? String(body.text) : undefined,
|
|
html: body.html ? String(body.html) : undefined,
|
|
};
|
|
|
|
if (!data.to || !data.subject || (!data.text && !data.html)) {
|
|
return { ok: false, error: '发信参数不完整,需要 to、subject、text/html' };
|
|
}
|
|
|
|
return { ok: true, data };
|
|
}
|
|
|
|
function sanitizeImportPayload(body) {
|
|
const content = String(body.content || '').replace(/\r\n/g, '\n').trim();
|
|
if (!content) {
|
|
return { ok: false, error: '导入内容不能为空' };
|
|
}
|
|
|
|
const lines = content
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!lines.length) {
|
|
return { ok: false, error: '导入内容不能为空' };
|
|
}
|
|
|
|
return { ok: true, lines };
|
|
}
|
|
|
|
function ensureAccount(id) {
|
|
const account = getAccountStmt.get(id);
|
|
if (!account) {
|
|
const error = new Error('邮箱帐号不存在');
|
|
error.status = 404;
|
|
throw error;
|
|
}
|
|
return account;
|
|
}
|
|
|
|
function ensureChannel(id) {
|
|
const channel = getChannelStmt.get(id);
|
|
if (!channel) {
|
|
const error = new Error('邮箱渠道不存在');
|
|
error.status = 404;
|
|
throw error;
|
|
}
|
|
return channel;
|
|
}
|
|
|
|
function importAccounts(lines) {
|
|
const transaction = db.transaction((rawLines) => {
|
|
let created = 0;
|
|
let updated = 0;
|
|
|
|
rawLines.forEach((line, index) => {
|
|
const lineNumber = index + 1;
|
|
const parts = line.split('----').map((item) => item.trim());
|
|
if (parts.length !== 3 || parts.some((item) => !item)) {
|
|
const error = new Error(`第 ${lineNumber} 行格式错误,应为:渠道名----帐号----授权码`);
|
|
error.status = 400;
|
|
throw error;
|
|
}
|
|
|
|
const [channelName, email, authCode] = parts;
|
|
const channel = getChannelByNameStmt.get(channelName);
|
|
if (!channel) {
|
|
const error = new Error(`第 ${lineNumber} 行渠道不存在:${channelName}`);
|
|
error.status = 400;
|
|
throw error;
|
|
}
|
|
|
|
const existing = getAccountByChannelEmailStmt.get(channel.id, email);
|
|
if (existing) {
|
|
updateAccountAuthCodeStmt.run(authCode, existing.id);
|
|
updated += 1;
|
|
return;
|
|
}
|
|
|
|
insertAccountStmt.run({
|
|
channel_id: channel.id,
|
|
email,
|
|
auth_code: authCode,
|
|
display_name: null,
|
|
});
|
|
created += 1;
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
created,
|
|
updated,
|
|
total: rawLines.length,
|
|
};
|
|
});
|
|
|
|
return transaction(lines);
|
|
}
|
|
|
|
function persistMessages(accountId, messages) {
|
|
const transaction = db.transaction((list) => {
|
|
for (const message of list) {
|
|
const from = message.from[0] || {};
|
|
upsertMailCacheStmt.run({
|
|
account_id: accountId,
|
|
uid: message.uid,
|
|
folder: message.folder || 'INBOX',
|
|
subject: message.subject || '',
|
|
from_name: from.name || '',
|
|
from_address: from.address || '',
|
|
to_addresses: JSON.stringify(message.to || []),
|
|
sent_at: message.date || null,
|
|
text_content: message.text || '',
|
|
html_content: message.html || '',
|
|
raw_json: JSON.stringify(message),
|
|
});
|
|
}
|
|
});
|
|
|
|
transaction(messages);
|
|
}
|
|
|
|
async function sendMailForAccount(accountId, body) {
|
|
const account = ensureAccount(accountId);
|
|
const channel = ensureChannel(account.channel_id);
|
|
const payload = sanitizeSendPayload(body);
|
|
if (!payload.ok) {
|
|
const error = new Error(payload.error);
|
|
error.status = 400;
|
|
throw error;
|
|
}
|
|
|
|
return sendMail({ channel, account, payload: payload.data });
|
|
}
|
|
|
|
function deleteAccountWithCache(accountId) {
|
|
const transaction = db.transaction((id) => {
|
|
deleteMailCacheByAccountStmt.run(id);
|
|
deleteAccountStmt.run(id);
|
|
});
|
|
|
|
transaction(accountId);
|
|
}
|
|
|
|
function serializeCachedMail(row) {
|
|
return {
|
|
id: row.id,
|
|
uid: row.uid,
|
|
folder: row.folder,
|
|
subject: row.subject,
|
|
from: {
|
|
name: row.from_name,
|
|
address: row.from_address,
|
|
},
|
|
to: JSON.parse(row.to_addresses || '[]'),
|
|
date: row.sent_at,
|
|
text: row.text_content,
|
|
html: row.html_content,
|
|
raw: JSON.parse(row.raw_json),
|
|
};
|
|
}
|
|
|
|
function clampLimit(value) {
|
|
const number = Number(value || 20);
|
|
if (Number.isNaN(number)) {
|
|
return 20;
|
|
}
|
|
return Math.min(Math.max(number, 1), 100);
|
|
}
|
|
|
|
function resolveStatus(error) {
|
|
return error.status || 500;
|
|
}
|
|
|
|
function withTimeout(promise, timeoutMs, message) {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) => {
|
|
setTimeout(() => {
|
|
const error = new Error(message);
|
|
error.status = 504;
|
|
reject(error);
|
|
}, timeoutMs);
|
|
}),
|
|
]);
|
|
}
|