feat(branding): system name/logo, horizontal logo on all pages

This commit is contained in:
dev
2026-09-23 15:19:23 +03:00
parent e435b4ad43
commit f2d465e0c4
11 changed files with 275 additions and 18 deletions
+59 -4
View File
@@ -616,7 +616,7 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }] = await Promise.all([
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
@@ -624,9 +624,10 @@ async function sweepOrphanedUploads() {
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
pool.query('SELECT photo_path AS p FROM student_photos'),
pool.query(`SELECT value AS p FROM settings WHERE key = 'system_logo' AND value IS NOT NULL AND value <> ''`),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
@@ -1037,8 +1038,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine'];
const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine'];
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
@@ -1081,6 +1082,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
}
if (key === 'system_name' && String(value).trim().length > 60) {
return res.status(400).json({ error: 'system_name не может быть длиннее 60 символов' });
}
if (key === 'system_logo' && String(value) !== '' && !isSafeUploadPath(String(value))) {
return res.status(400).json({ error: 'system_logo — некорректный путь' });
}
}
const client = await pool.connect();
try {
@@ -1107,6 +1114,54 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
}
});
const logoUpload = upload.single('logo');
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
logoUpload(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
if (err.message === 'Only images') return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)' });
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
return res.status(400).json({ error: 'Недопустимый файл' });
}
next();
});
}, async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
const ext = (path.extname(req.file.originalname || '') || '').toLowerCase();
if (!ALLOWED_IMAGE_EXT.has(ext)) {
removeUpload(req.file);
return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
}
try {
await convertPhoto(req.file);
const finalPath = `/uploads/${req.file.filename}`;
const old = await getSetting('system_logo', '');
if (old && isSafeUploadPath(old) && old !== finalPath) safeUnlink(old);
await pool.query(
`INSERT INTO settings (key, value) VALUES ('system_logo', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[finalPath]
);
await logAudit(req, 'settings.logo.upload', { path: finalPath });
invalidateSettings();
res.json({ system_logo: finalPath });
} catch (e) {
removeUpload(req.file);
throw e;
}
});
app.delete('/api/settings/logo', requireAdmin, async (req, res) => {
const old = await getSetting('system_logo', '');
if (old) {
await pool.query(`UPDATE settings SET value = '' WHERE key = 'system_logo'`);
if (isSafeUploadPath(old)) safeUnlink(old);
}
await logAudit(req, 'settings.logo.remove', {});
invalidateSettings();
res.json({ ok: true, system_logo: '' });
});
app.get('/api/audit', requireAdmin, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);