master #1

Closed
admin wants to merge 19 commits from master into main
7 changed files with 258 additions and 23 deletions
Showing only changes of commit 686d82319e - Show all commits
+3 -2
View File
@@ -59,9 +59,10 @@ CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
CREATE TABLE IF NOT EXISTS project_files ( CREATE TABLE IF NOT EXISTS project_files (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, entry_id INT REFERENCES entries(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL, token VARCHAR(64) UNIQUE NOT NULL,
path VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now(),
detached_at TIMESTAMPTZ
); );
+3
View File
@@ -45,6 +45,9 @@ UPDATE project_files SET token = md5(random()::text || clock_timestamp()::text)
ALTER TABLE project_files ALTER COLUMN token SET NOT NULL; ALTER TABLE project_files ALTER COLUMN token SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token); CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token);
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS group_photos ( CREATE TABLE IF NOT EXISTS group_photos (
+11
View File
@@ -46,9 +46,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap} .card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px} .card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
.entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} .entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.entry-files .f-item{display:inline-flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px}
.entry-files .f-item:hover{border-color:var(--accent)}
.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files .f-item a:hover{color:var(--accent)}
.entry-files .f-item a::before{content:'📎'}
.entry-files .f-detach{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)}
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files a:hover{border-color:var(--accent);color:var(--accent)} .entry-files a:hover{border-color:var(--accent);color:var(--accent)}
.entry-files a::before{content:'📎'} .entry-files a::before{content:'📎'}
.f-actions{display:flex;align-items:center;flex-shrink:0}
.f-action{background:none;border:1px solid var(--border);color:var(--muted);cursor:pointer;border-radius:8px;padding:6px 10px;font-size:.75rem;white-space:nowrap}
.f-action:hover{color:var(--accent);border-color:var(--accent)}
.f-action.f-del:hover{color:#ef4444;border-color:#ef4444}
.card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0} .card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0}
.card .del:hover{color:#ef4444} .card .del:hover{color:#ef4444}
.card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0} .card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0}
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ошибка — WhatIDo</title>
<link rel="stylesheet" href="admin.css">
<style>
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
.error-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:48px 40px;max-width:400px;width:100%}
.error-code{font-size:6rem;font-weight:700;color:var(--accent);line-height:1;margin-bottom:8px}
.error-title{font-size:1.5rem;font-weight:600;color:var(--text);margin-bottom:12px}
.error-message{color:var(--muted);font-size:1rem;line-height:1.6;margin-bottom:24px}
.error-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
.error-actions a,.error-actions button{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:12px 24px;font-size:.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:opacity .15s}
.error-actions a:hover,.error-actions button:hover{opacity:.85}
.error-actions .ghost{background:var(--card);border:1px solid var(--border);color:var(--accent)}
.error-actions .ghost:hover{border-color:var(--accent);background:rgba(37,99,235,.06)}
.error-details{margin-top:24px;padding-top:24px;border-top:1px solid var(--border);font-size:.75rem;color:var(--muted);text-align:left}
.error-details pre{white-space:pre-wrap;word-break:break-word;font-family:monospace;background:var(--bg);padding:12px;border-radius:8px;margin-top:8px;max-height:200px;overflow:auto}
@media(max-width:480px){.error-code{font-size:4rem}.error-card{padding:32px 20px}}
</style>
</head>
<body>
<div class="error-page">
<div class="error-card">
<div class="error-code" id="errorCode">404</div>
<h1 class="error-title" id="errorTitle">Страница не найдена</h1>
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
<div class="error-actions">
<a href="/" class="ghost">На главную</a>
<button onclick="history.back()">Назад</button>
</div>
<div class="error-details" id="errorDetails" style="display:none">
<strong>Детали:</strong>
<pre id="errorStack"></pre>
</div>
</div>
</div>
<script>
(function(){
const params = new URLSearchParams(location.search);
const code = params.get('code') || '404';
const title = params.get('title') || '';
const message = params.get('message') || '';
const details = params.get('details') || '';
document.getElementById('errorCode').textContent = code;
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
if (details) {
document.getElementById('errorDetails').style.display = 'block';
document.getElementById('errorStack').textContent = details;
}
})();
</script>
</body>
</html>
+61 -13
View File
@@ -23,6 +23,12 @@
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div> <div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div> <div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div> <div><label>По</label><input type="date" id="dateTo"></div>
<div><label>Тип</label>
<div class="view-toggle" id="tabToggle">
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</button>
</div>
</div>
</div> </div>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div> <div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div> <div class="pager" id="pager"></div>
@@ -43,6 +49,20 @@ let currentFiles = [];
let groupsLoaded = false; let groupsLoaded = false;
let studentsLoaded = false; let studentsLoaded = false;
let searchTimer = null; let searchTimer = null;
let mode = 'attached';
function setMode(m) {
mode = m === 'detached' ? 'detached' : 'attached';
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
const show = mode === 'attached';
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
if (!show) { keep.forEach(el => { el.value = ''; }); }
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
page = 1;
loadFiles();
}
function isImageFile(name) { function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
@@ -63,14 +83,21 @@ function fileCardHTML(f) {
const target = isImageFile(f.name) const target = isImageFile(f.name)
? `href="#" onclick="showImg('${url}');return false;"` ? `href="#" onclick="showImg('${url}');return false;"`
: `href="${url}" download`; : `href="${url}" download`;
const meta = mode === 'detached'
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
const action = mode === 'detached'
? `<button class="f-action f-del" onclick="deleteFile(${f.id})" title="Удалить файл навсегда">Удалить</button>`
: `<button class="f-action" onclick="detachFile(${f.id})" title="Открепить файл от записи">Открепить</button>`;
return ` return `
<div class="card"> <div class="card">
${thumb} ${thumb}
<div class="info"> <div class="info">
<div class="file-name"><a ${target}>${esc(f.name)}</a></div> <div class="file-name"><a ${target}>${esc(f.name)}</a></div>
<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div> ${meta}
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div> <div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
</div> </div>
<div class="f-actions">${action}</div>
</div> </div>
`; `;
} }
@@ -79,13 +106,29 @@ function renderGrid() {
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
if (!currentFiles.length) { if (!currentFiles.length) {
grid.className = 'grid'; grid.className = 'grid';
grid.innerHTML = '<div class="empty">Файлов не найдено</div>'; grid.innerHTML = mode === 'detached'
? '<div class="empty">Откреплённых файлов нет</div>'
: '<div class="empty">Файлов не найдено</div>';
return; return;
} }
grid.className = 'grid'; grid.className = 'grid';
grid.innerHTML = currentFiles.map(fileCardHTML).join(''); grid.innerHTML = currentFiles.map(fileCardHTML).join('');
} }
async function detachFile(id) {
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function deleteFile(id) {
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function loadGroupOptions() { async function loadGroupOptions() {
if (groupsLoaded) return; if (groupsLoaded) return;
const sel = document.getElementById('groupFilter'); const sel = document.getElementById('groupFilter');
@@ -111,19 +154,21 @@ async function loadStudentOptions() {
async function loadFiles() { async function loadFiles() {
const p = new URLSearchParams(); const p = new URLSearchParams();
const q = document.getElementById('searchInput').value; if (mode === 'attached') {
const sn = document.getElementById('studentFilter').value; const q = document.getElementById('searchInput').value;
const gid = document.getElementById('groupFilter').value; const sn = document.getElementById('studentFilter').value;
const df = document.getElementById('dateFrom').value; const gid = document.getElementById('groupFilter').value;
const dt = document.getElementById('dateTo').value; const df = document.getElementById('dateFrom').value;
if (q) p.set('search', q); const dt = document.getElementById('dateTo').value;
if (sn) p.set('student_name', sn); if (q) p.set('search', q);
if (gid) p.set('group_id', gid); if (sn) p.set('student_name', sn);
if (df) p.set('date_from', df); if (gid) p.set('group_id', gid);
if (dt) p.set('date_to', dt); if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
}
p.set('limit', PAGE_SIZE); p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE); p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/files?${p}`, { headers: hdr() }); const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
const data = await res.json(); const data = await res.json();
currentFiles = data.files; currentFiles = data.files;
totalFiles = data.total; totalFiles = data.total;
@@ -148,6 +193,9 @@ document.getElementById('searchInput').addEventListener('input', () => {
(async () => { (async () => {
if (await checkAuth()) { if (await checkAuth()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
loadGroupOptions(); loadGroupOptions();
loadStudentOptions(); loadStudentOptions();
loadFiles(); loadFiles();
+27 -4
View File
@@ -113,11 +113,34 @@ function isImageFile(name) {
function filesHTML(files) { function filesHTML(files) {
if (!files || !files.length) return ''; if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => return `<div class="entry-files">${files.map(f => {
isImageFile(f.name) const link = isImageFile(f.name)
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>` ? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>` : `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
).join('')}</div>`; return `<span class="f-item">${link}<button class="f-detach" onclick="detachFile(${f.id})" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`;
}
function findFile(id) {
for (const e of currentEntries) {
const f = (e.files || []).find(x => x.id === id);
if (f) return f;
}
return null;
}
async function detachFile(id) {
const f = findFile(id);
if (!f) return;
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) {
showToast('Файл откреплён');
loadEntries();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
} }
function gridEntryHTML(e) { function gridEntryHTML(e) {
+96 -4
View File
@@ -25,6 +25,14 @@ function requireAdmin(req, res, next) {
next(); next();
} }
function fixFilename(str) {
try {
return Buffer.from(str, 'latin1').toString('utf8');
} catch {
return str;
}
}
const upload = multer({ const upload = multer({
storage: multer.diskStorage({ storage: multer.diskStorage({
destination: (_, __, cb) => { destination: (_, __, cb) => {
@@ -32,12 +40,14 @@ const upload = multer({
cb(null, 'uploads'); cb(null, 'uploads');
}, },
filename: (_, file, cb) => { filename: (_, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'; const original = fixFilename(file.originalname);
const ext = path.extname(original) || '.jpg';
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
file.originalname = fixFilename(file.originalname);
if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images')); if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images'));
else cb(null, true); else cb(null, true);
}, },
@@ -609,7 +619,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
if (rows.length) { if (rows.length) {
const ids = rows.map(r => r.id); const ids = rows.map(r => r.id);
const fRes = await pool.query( const fRes = await pool.query(
'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', 'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
[ids] [ids]
); );
files = {}; files = {};
@@ -623,7 +633,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT token, name FROM project_files WHERE entry_id = $1 ORDER BY id', 'SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
[req.params.id] [req.params.id]
); );
res.json(rows); res.json(rows);
@@ -668,6 +678,51 @@ app.get('/api/files', requireAdmin, async (req, res) => {
res.json({ files, total }); res.json({ files, total });
}); });
app.get('/api/files/detached', requireAdmin, async (req, res) => {
const { search, limit, offset } = req.query;
const conditions = [];
const params = [];
conditions.push('entry_id IS NULL');
if (search) { params.push(`%${search}%`); conditions.push(`name ILIKE $${params.length}`); }
const where = conditions.join(' AND ');
const { rows: crows } = await pool.query(
`SELECT count(*)::int AS n FROM project_files WHERE ${where}`,
params
);
const total = crows[0].n;
let q = `SELECT id, token, name, path, created_at FROM project_files
WHERE ${where} ORDER BY created_at DESC`;
const qparams = params.slice();
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
const off = parseInt(offset, 10);
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
const { rows } = await pool.query(q, qparams);
const files = rows.map(r => {
let size = 0;
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
return { id: r.id, token: r.token, name: r.name, created_at: r.created_at, size };
});
res.json({ files, total });
});
app.post('/api/files/:id/detach', requireAdmin, async (req, res) => {
const { rows } = await pool.query(
'UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *',
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
res.json({ ok: true });
});
app.delete('/api/files/:id', requireAdmin, async (req, res) => {
const { rows } = await pool.query('SELECT path FROM project_files WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
safeUnlink(rows[0].path);
await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]);
res.json({ ok: true });
});
app.get('/api/files/:token', async (req, res) => { app.get('/api/files/:token', async (req, res) => {
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]); const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
if (!rows.length) return res.status(404).json({ error: 'Not found' }); if (!rows.length) return res.status(404).json({ error: 'Not found' });
@@ -866,7 +921,7 @@ app.get('/api/trash', requireAdmin, async (req, res) => {
let files = {}; let files = {};
if (rows.length) { if (rows.length) {
const fRes = await pool.query( const fRes = await pool.query(
'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', 'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
[rows.map(r => r.id)] [rows.map(r => r.id)]
); );
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); }); fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
@@ -887,6 +942,43 @@ app.delete('/api/trash', requireAdmin, async (req, res) => {
res.json({ ok: true, deleted: d.rowCount }); res.json({ ok: true, deleted: d.rowCount });
}); });
// --- Error handlers ---
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
function isApiRoute(req) {
return req.path.startsWith('/api/') || req.path.startsWith('/s/');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '&#039;');
}
function renderErrorPage(code, title, message, details) {
return ERROR_HTML
.replace('id="errorCode">404', `id="errorCode">${code}`)
.replace('id="errorTitle">Страница не найдена', `id="errorTitle">${title}`)
.replace('id="errorMessage">Запрашиваемая страница не существует или была перемещена.', `id="errorMessage">${message}`)
.replace('style="display:none"', details ? '' : 'style="display:none"')
.replace('<pre id="errorStack"></pre>', details ? `<pre id="errorStack">${escapeHtml(details)}</pre>` : '<pre id="errorStack"></pre>');
}
app.use((req, res, next) => {
if (isApiRoute(req)) {
return res.status(404).json({ error: 'Not found' });
}
res.status(404).send(renderErrorPage(404, 'Страница не найдена', 'Запрашиваемая страница не существует или была перемещена.'));
});
app.use((err, req, res, next) => {
console.error('Error:', err);
if (isApiRoute(req)) {
return res.status(500).json({ error: 'Internal server error' });
}
const msg = process.env.NODE_ENV === 'production' ? 'Произошла ошибка на сервере.' : (err?.message || 'Internal server error');
const details = process.env.NODE_ENV === 'production' ? '' : (err?.stack || '');
res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
});
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HTTPS_PORT = process.env.HTTPS_PORT || 3443; const HTTPS_PORT = process.env.HTTPS_PORT || 3443;