feat(chat): личный чат тьютор ↔ админ с вложениями
Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat вместо общей ленты notifications, вложения с белым списком расширений и вставкой из буфера (Ctrl+V), очистка по chat_retention_days. Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
@@ -419,6 +419,78 @@ async function ensureNotificationsTable() {
|
||||
}
|
||||
}
|
||||
|
||||
const CHAT_RETENTION_DEFAULT_DAYS = 365;
|
||||
const CHAT_BODY_MAX = 5000;
|
||||
const CHAT_ATTACH_MAX = 10;
|
||||
const CHAT_ALLOWED_EXT = new Set([
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif',
|
||||
'.pdf', '.doc', '.docx', '.txt', '.md', '.rtf', '.odt', '.ods',
|
||||
'.xls', '.xlsx', '.csv', '.ppt', '.pptx',
|
||||
'.zip', '.rar', '.7z', '.tar', '.gz',
|
||||
]);
|
||||
|
||||
function chatUploadFilter(req, file, cb) {
|
||||
file.originalname = fixFilename(file.originalname);
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!CHAT_ALLOWED_EXT.has(ext)) return cb(new Error('Not allowed extension'));
|
||||
cb(null, true);
|
||||
}
|
||||
|
||||
async function ensureChatTables() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS chat_threads (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tutor_id INT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_message_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_message_text VARCHAR(300),
|
||||
tutor_unread INT NOT NULL DEFAULT 0,
|
||||
admin_unread INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC)`);
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
||||
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)`);
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS chat_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) UNIQUE NOT NULL,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
size INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_attachments_message ON chat_attachments(message_id)`);
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('chat_retention_days', $1) ON CONFLICT (key) DO NOTHING`,
|
||||
[String(CHAT_RETENTION_DEFAULT_DAYS)]
|
||||
);
|
||||
}
|
||||
|
||||
async function purgeOldChat() {
|
||||
const raw = parseInt(await getSetting('chat_retention_days', String(CHAT_RETENTION_DEFAULT_DAYS)), 10);
|
||||
const days = Number.isFinite(raw) && raw >= 1 ? Math.min(raw, 3650) : CHAT_RETENTION_DEFAULT_DAYS;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.path FROM chat_attachments a
|
||||
JOIN chat_messages m ON m.id = a.message_id
|
||||
WHERE m.created_at < now() - ($1 || ' days')::interval`,
|
||||
[String(days)]
|
||||
);
|
||||
const msgs = await pool.query(
|
||||
`DELETE FROM chat_messages WHERE created_at < now() - ($1 || ' days')::interval`,
|
||||
[String(days)]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM chat_threads t WHERE NOT EXISTS (SELECT 1 FROM chat_messages m WHERE m.thread_id = t.id)`
|
||||
);
|
||||
rows.forEach(r => safeUnlink(r.path));
|
||||
if (msgs.rowCount) console.log(`Chat pruned: ${msgs.rowCount} messages, ${rows.length} files (older than ${days} days)`);
|
||||
}
|
||||
|
||||
function notificationsScope(user) {
|
||||
const s = branchScope(user);
|
||||
if (s.admin) return { cond: '', params: [] };
|
||||
@@ -1340,7 +1412,7 @@ async function removeEntryFiles(entryId) {
|
||||
async function sweepOrphanedUploads() {
|
||||
const dir = UPLOADS_DIR;
|
||||
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
||||
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }, { rows: studentAvatars }, { rows: groupCovers }, { rows: originals }, { rows: jobBefore }] = await Promise.all([
|
||||
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }, { rows: studentAvatars }, { rows: groupCovers }, { rows: originals }, { rows: jobBefore }, { rows: chatFiles }] = 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'),
|
||||
@@ -1353,9 +1425,10 @@ async function sweepOrphanedUploads() {
|
||||
pool.query('SELECT cover_path AS p FROM groups WHERE cover_path IS NOT NULL'),
|
||||
pool.query('SELECT photo_original_path AS p FROM entries WHERE photo_original_path IS NOT NULL'),
|
||||
pool.query('SELECT before_path AS p FROM photo_jobs WHERE before_path IS NOT NULL'),
|
||||
pool.query('SELECT path AS p FROM chat_attachments'),
|
||||
]);
|
||||
const refs = new Set();
|
||||
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos, ...studentAvatars, ...groupCovers, ...originals, ...jobBefore].forEach(r => {
|
||||
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos, ...studentAvatars, ...groupCovers, ...originals, ...jobBefore, ...chatFiles].forEach(r => {
|
||||
const key = storage.keyFromPath(r.p);
|
||||
if (key) refs.add(key);
|
||||
});
|
||||
@@ -1830,6 +1903,347 @@ app.delete('/api/notifications', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true, deleted: rowCount });
|
||||
});
|
||||
|
||||
// --- Chat (тьютор ↔ админ) ---
|
||||
const CHAT_CHANNEL = 'whatido:chat';
|
||||
const chatClients = new Set();
|
||||
|
||||
function chatVisible(user, tutorId) {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
return Number(user.id) === Number(tutorId);
|
||||
}
|
||||
|
||||
function writeChatFrame(client, event, data) {
|
||||
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function publishChat(payload) {
|
||||
cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message));
|
||||
}
|
||||
|
||||
cache.on(CHAT_CHANNEL, message => {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(message); } catch (e) { return; }
|
||||
if (!payload || !payload.thread_id) return;
|
||||
for (const client of [...chatClients]) {
|
||||
if (!chatVisible(client.user, payload.tutor_id)) continue;
|
||||
try { writeChatFrame(client, 'message', payload); } catch (e) { chatClients.delete(client); }
|
||||
}
|
||||
});
|
||||
|
||||
const chatLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: cache.rateLimitStore('chat', 60 * 1000),
|
||||
message: { error: 'Слишком много сообщений. Подождите минуту.' },
|
||||
});
|
||||
|
||||
async function chatThreadFor(user, threadId) {
|
||||
const id = parseInt(threadId, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return { error: 400, message: 'Invalid thread' };
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.admin_unread, t.created_at,
|
||||
u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active
|
||||
FROM chat_threads t JOIN users u ON u.id = t.tutor_id
|
||||
WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return { error: 404, message: 'Диалог не найден' };
|
||||
if (user.role !== 'admin' && rows[0].tutor_id !== user.id) return { error: 403, message: 'Нет доступа к диалогу' };
|
||||
return { thread: rows[0] };
|
||||
}
|
||||
|
||||
function chatThreadJson(t, unread) {
|
||||
return {
|
||||
id: t.id,
|
||||
tutor_id: t.tutor_id,
|
||||
tutor_username: t.tutor_username,
|
||||
tutor_name: t.tutor_name || t.tutor_username,
|
||||
tutor_active: t.tutor_active !== false,
|
||||
last_message_at: t.last_message_at,
|
||||
last_message_text: t.last_message_text || '',
|
||||
unread: unread === undefined ? 0 : unread,
|
||||
created_at: t.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function chatUnread(user) {
|
||||
if (user.role === 'admin') {
|
||||
const { rows } = await pool.query('SELECT COALESCE(SUM(admin_unread), 0)::int AS n FROM chat_threads');
|
||||
return rows[0] ? rows[0].n : 0;
|
||||
}
|
||||
const { rows } = await pool.query('SELECT tutor_unread AS n FROM chat_threads WHERE tutor_id = $1', [user.id]);
|
||||
return rows.length ? rows[0].n : 0;
|
||||
}
|
||||
|
||||
app.get('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => {
|
||||
try {
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const { rows } = isAdmin
|
||||
? await pool.query(
|
||||
`SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.admin_unread, t.created_at,
|
||||
u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active
|
||||
FROM chat_threads t JOIN users u ON u.id = t.tutor_id
|
||||
ORDER BY COALESCE(t.last_message_at, t.created_at) DESC`
|
||||
)
|
||||
: await pool.query(
|
||||
`SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.created_at,
|
||||
u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active
|
||||
FROM chat_threads t JOIN users u ON u.id = t.tutor_id
|
||||
WHERE t.tutor_id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ items: rows.map(t => chatThreadJson(t, isAdmin ? t.admin_unread : t.tutor_unread)), unread: await chatUnread(req.user) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => {
|
||||
try {
|
||||
let tutorId = req.user.id;
|
||||
if (req.user.role === 'admin') {
|
||||
const wanted = parseInt(req.body?.user_id, 10);
|
||||
if (!Number.isInteger(wanted) || wanted < 1) return res.status(400).json({ error: 'Некорректный пользователь' });
|
||||
const { rows } = await pool.query('SELECT id FROM users WHERE id = $1', [wanted]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
tutorId = rows[0].id;
|
||||
}
|
||||
const { rows: ins } = await pool.query(
|
||||
`INSERT INTO chat_threads (tutor_id) VALUES ($1)
|
||||
ON CONFLICT (tutor_id) DO UPDATE SET tutor_id = EXCLUDED.tutor_id
|
||||
RETURNING id`,
|
||||
[tutorId]
|
||||
);
|
||||
const acc = await chatThreadFor(req.user, ins[0].id);
|
||||
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
||||
res.json({ thread: chatThreadJson(acc.thread, 0) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req, res) => {
|
||||
try {
|
||||
const acc = await chatThreadFor(req.user, req.params.id);
|
||||
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 200);
|
||||
const before = parseInt(req.query.before, 10);
|
||||
const params = [acc.thread.id];
|
||||
let extra = '';
|
||||
if (Number.isInteger(before) && before > 0) {
|
||||
params.push(before);
|
||||
extra = ` AND m.id < $${params.length}`;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT m.id, m.sender_id, m.body, m.created_at, u.username, u.name, u.role
|
||||
FROM chat_messages m JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.thread_id = $1${extra}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT $${params.length + 1}`,
|
||||
[...params, limit]
|
||||
);
|
||||
const ids = rows.map(m => m.id);
|
||||
const byMessage = new Map();
|
||||
if (ids.length) {
|
||||
const fr = await pool.query(
|
||||
`SELECT a.id, a.message_id, a.token, a.name, a.size
|
||||
FROM chat_attachments a
|
||||
WHERE a.message_id = ANY($1::int[])
|
||||
ORDER BY a.id`,
|
||||
[ids]
|
||||
);
|
||||
for (const f of fr.rows) {
|
||||
if (!byMessage.has(f.message_id)) byMessage.set(f.message_id, []);
|
||||
byMessage.get(f.message_id).push({ id: f.id, token: f.token, name: f.name, size: f.size });
|
||||
}
|
||||
}
|
||||
const items = rows.reverse().map(m => ({
|
||||
id: m.id,
|
||||
sender_id: m.sender_id,
|
||||
mine: m.sender_id === req.user.id,
|
||||
sender_name: m.name || m.username,
|
||||
sender_role: m.role,
|
||||
body: m.body,
|
||||
created_at: m.created_at,
|
||||
files: byMessage.get(m.id) || [],
|
||||
}));
|
||||
const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread;
|
||||
res.json({ items, thread: chatThreadJson(acc.thread, unreadCol) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
const chatFilesUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
fs.mkdirSync('uploads', { recursive: true });
|
||||
cb(null, 'uploads');
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const ext = path.extname(fixFilename(file.originalname)) || '.bin';
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: MAX_FILE_UPLOAD_BYTES },
|
||||
fileFilter: chatUploadFilter,
|
||||
}).array('files', CHAT_ATTACH_MAX);
|
||||
|
||||
app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, next) => {
|
||||
chatFilesUpload(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
|
||||
if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Не больше ${CHAT_ATTACH_MAX} файлов` });
|
||||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||
});
|
||||
}, async (req, res) => {
|
||||
const acc = await chatThreadFor(req.user, req.params.id);
|
||||
const files = req.files || [];
|
||||
if (acc.error) {
|
||||
files.forEach(removeUpload);
|
||||
return res.status(acc.error).json({ error: acc.message });
|
||||
}
|
||||
const body = String(req.body?.body || '').trim().slice(0, CHAT_BODY_MAX);
|
||||
if (!body && !files.length) return res.status(400).json({ error: 'Пустое сообщение' });
|
||||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||
files.forEach(removeUpload);
|
||||
return res.status(400).json({ error: TOTAL_TOO_LARGE_ERROR });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
let created;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const mr = await client.query(
|
||||
'INSERT INTO chat_messages (thread_id, sender_id, body) VALUES ($1, $2, $3) RETURNING id, sender_id, body, created_at',
|
||||
[acc.thread.id, req.user.id, body]
|
||||
);
|
||||
created = mr.rows[0];
|
||||
for (const f of files) {
|
||||
await convertPhoto(f);
|
||||
await client.query(
|
||||
'INSERT INTO chat_attachments (message_id, token, path, name, size) VALUES ($1, $2, $3, $4, $5)',
|
||||
[created.id, crypto.randomBytes(16).toString('hex'), `/uploads/${f.filename}`, f.originalname, f.size || 0]
|
||||
);
|
||||
}
|
||||
const byTutor = req.user.role !== 'admin';
|
||||
await client.query(
|
||||
`UPDATE chat_threads
|
||||
SET last_message_at = now(),
|
||||
last_message_text = $2,
|
||||
tutor_unread = tutor_unread + $3,
|
||||
admin_unread = admin_unread + $4
|
||||
WHERE id = $1`,
|
||||
[acc.thread.id, body.slice(0, 300) || `Вложение: ${files[0].originalname}`, byTutor ? 0 : 1, byTutor ? 1 : 0]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
files.forEach(removeUpload);
|
||||
return res.status(500).json({ error: e.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
const { rows: frows } = await pool.query(
|
||||
'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id',
|
||||
[created.id]
|
||||
);
|
||||
const message = {
|
||||
id: created.id,
|
||||
sender_id: created.sender_id,
|
||||
mine: true,
|
||||
sender_name: req.user.name || req.user.username,
|
||||
sender_role: req.user.role,
|
||||
body: created.body,
|
||||
created_at: created.created_at,
|
||||
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
|
||||
};
|
||||
publishChat({ thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, message });
|
||||
await logAudit(req, 'chat.message.create', { thread_id: acc.thread.id, message_id: created.id, files: frows.length });
|
||||
res.json({ message });
|
||||
});
|
||||
|
||||
app.post('/api/chat/threads/:id/read', requireAuth, chatLimiter, async (req, res) => {
|
||||
const acc = await chatThreadFor(req.user, req.params.id);
|
||||
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
||||
const col = req.user.role === 'admin' ? 'admin_unread' : 'tutor_unread';
|
||||
await pool.query(`UPDATE chat_threads SET ${col} = 0 WHERE id = $1`, [acc.thread.id]);
|
||||
res.json({ ok: true, unread: await chatUnread(req.user) });
|
||||
});
|
||||
|
||||
app.get('/api/chat/unread', requireAuth, chatLimiter, async (req, res) => {
|
||||
res.json({ unread: await chatUnread(req.user) });
|
||||
});
|
||||
|
||||
app.get('/api/chat/stream', async (req, res) => {
|
||||
let user = null;
|
||||
try {
|
||||
user = await loadUserByToken(req.headers['x-auth-token'] || req.query.token);
|
||||
} catch (e) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
if (!user || !user.is_active) return res.status(401).end();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write(':ok\n\n');
|
||||
const client = { res, user };
|
||||
chatClients.add(client);
|
||||
chatUnread(user)
|
||||
.then(unread => writeChatFrame(client, 'ready', { unread }))
|
||||
.catch(err => console.error('Chat unread failed:', err.message));
|
||||
const ping = setInterval(() => {
|
||||
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); chatClients.delete(client); }
|
||||
}, 25000);
|
||||
req.on('close', () => { clearInterval(ping); chatClients.delete(client); });
|
||||
});
|
||||
|
||||
app.get('/api/chat/files/:token', fileLimiter, async (req, res) => {
|
||||
let user = null;
|
||||
try {
|
||||
user = await loadUserByToken(req.headers['x-auth-token'] || req.query.token);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
if (!user || !user.is_active) return res.status(401).json({ error: 'Требуется вход' });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.token, a.path, a.name, t.tutor_id
|
||||
FROM chat_attachments a
|
||||
JOIN chat_messages m ON m.id = a.message_id
|
||||
JOIN chat_threads t ON t.id = m.thread_id
|
||||
WHERE a.token = $1`,
|
||||
[req.params.token]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Файл не найден' });
|
||||
const r = rows[0];
|
||||
if (!chatVisible(user, r.tutor_id)) return res.status(403).json({ error: 'Нет доступа к файлу' });
|
||||
const key = storage.keyFromPath(r.path);
|
||||
if (!key) return res.status(404).json({ error: 'Файл не найден' });
|
||||
if (isImageName(r.name)) {
|
||||
if (req.query.thumb) return sendImageThumb(res, key);
|
||||
const okImg = await storage.streamTo(res, key, { cacheControl: 'private, max-age=3600' });
|
||||
if (!okImg && !res.headersSent) return res.status(404).json({ error: 'Файл не найден' });
|
||||
return;
|
||||
}
|
||||
if (isPlayableVideoName(r.name) && req.query.play) {
|
||||
const played = await sendPlayableFile(req, res, key, r.name);
|
||||
if (!played && !res.headersSent) return res.status(404).json({ error: 'Файл не найден' });
|
||||
return;
|
||||
}
|
||||
if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) {
|
||||
return res.status(404).json({ error: 'Файл не найден' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Users (admin only) ---
|
||||
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
@@ -2517,7 +2931,7 @@ function sweepBackupStorage() {
|
||||
async function buildBackupArchive() {
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||
try {
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi] = await Promise.all([
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca] = await Promise.all([
|
||||
pool.query('SELECT * FROM groups ORDER BY id'),
|
||||
pool.query('SELECT * FROM students ORDER BY id'),
|
||||
pool.query('SELECT * FROM entries ORDER BY id'),
|
||||
@@ -2538,6 +2952,9 @@ async function buildBackupArchive() {
|
||||
pool.query('SELECT * FROM notifications ORDER BY id'),
|
||||
pool.query('SELECT * FROM notification_reads ORDER BY user_id, notification_id'),
|
||||
pool.query('SELECT * FROM banned_ips ORDER BY ip'),
|
||||
pool.query('SELECT * FROM chat_threads ORDER BY id'),
|
||||
pool.query('SELECT * FROM chat_messages ORDER BY id'),
|
||||
pool.query('SELECT * FROM chat_attachments ORDER BY id'),
|
||||
]);
|
||||
const settings = {};
|
||||
st.rows.forEach(r => { settings[r.key] = r.value; });
|
||||
@@ -2552,8 +2969,9 @@ async function buildBackupArchive() {
|
||||
share_links: sl.rowCount, photo_jobs: pj.rowCount, lesson_reports: lr.rowCount,
|
||||
lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount,
|
||||
notification_reads: nr.rowCount, banned_ips: bi.rowCount,
|
||||
chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount,
|
||||
},
|
||||
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows,
|
||||
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows,
|
||||
};
|
||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||
const files = await storage.downloadAll(path.join(staging, 'uploads'));
|
||||
@@ -2716,6 +3134,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
await client.query('DELETE FROM notification_reads');
|
||||
await client.query('DELETE FROM notifications');
|
||||
await client.query('DELETE FROM banned_ips');
|
||||
await client.query('DELETE FROM chat_attachments');
|
||||
await client.query('DELETE FROM chat_messages');
|
||||
await client.query('DELETE FROM chat_threads');
|
||||
await client.query('DELETE FROM project_files');
|
||||
await client.query('DELETE FROM lesson_report_versions');
|
||||
await client.query('DELETE FROM lesson_reports');
|
||||
@@ -2843,6 +3264,32 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
[x.user_id, x.notification_id, x.read_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.chat_threads) {
|
||||
const ex = await client.query('SELECT 1 FROM users WHERE id = $1', [x.tutor_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
await client.query(
|
||||
'INSERT INTO chat_threads (id, tutor_id, last_message_at, last_message_text, tutor_unread, admin_unread, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.chat_messages) {
|
||||
const ex = await client.query('SELECT 1 FROM chat_threads WHERE id = $1', [x.thread_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]);
|
||||
if (!eu.rowCount) continue;
|
||||
await client.query(
|
||||
'INSERT INTO chat_messages (id, thread_id, sender_id, body, created_at) VALUES ($1,$2,$3,$4,$5)',
|
||||
[x.id, x.thread_id, x.sender_id, x.body, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.chat_attachments) {
|
||||
const ex = await client.query('SELECT 1 FROM chat_messages WHERE id = $1', [x.message_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
await client.query(
|
||||
'INSERT INTO chat_attachments (id, message_id, token, path, name, size, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[x.id, x.message_id, x.token, x.path, x.name, x.size || 0, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.audit_log) {
|
||||
const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
|
||||
await client.query(
|
||||
@@ -7677,8 +8124,11 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
|
||||
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); }
|
||||
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
|
||||
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
|
||||
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
|
||||
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
|
||||
try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }
|
||||
setInterval(() => { purgeOldChat().catch(err => console.error('Chat purge:', err)); }, 24 * 60 * 60 * 1000).unref();
|
||||
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Camera setting:', err); }
|
||||
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Trash purge days setting:', err); }
|
||||
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||
|
||||
Reference in New Issue
Block a user