feat: avatar moderation — ученик загружает фото, учитель/админ подтверждает или отклоняет
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ async function login(req, res, next) {
|
||||
|
||||
function me(req, res) {
|
||||
const user = db.prepare(
|
||||
'SELECT id, email, name, role, created_at, last_login FROM users WHERE id = ?'
|
||||
'SELECT id, email, name, role, created_at, last_login, avatar_url FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
res.json(user);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../db/db');
|
||||
|
||||
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
||||
|
||||
/* ── POST /api/avatar/request ── student submits a new avatar ───────────── */
|
||||
function requestAvatar(req, res) {
|
||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
||||
|
||||
// Cancel any previous pending request from this user (replace it)
|
||||
const prev = db.prepare(
|
||||
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
|
||||
).get(req.user.id);
|
||||
if (prev) {
|
||||
// Delete old file
|
||||
try { fs.unlinkSync(path.join(AVATARS_DIR, prev.filename)); } catch {}
|
||||
db.prepare("DELETE FROM avatar_requests WHERE user_id=? AND status='pending'").run(req.user.id);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO avatar_requests (user_id, filename, status, created_at)
|
||||
VALUES (?, ?, 'pending', datetime('now'))
|
||||
`).run(req.user.id, req.file.filename);
|
||||
|
||||
res.json({ ok: true, filename: req.file.filename });
|
||||
}
|
||||
|
||||
/* ── GET /api/avatar/my-status ── student polls their request status ────── */
|
||||
function myStatus(req, res) {
|
||||
const row = db.prepare(`
|
||||
SELECT ar.id, ar.filename, ar.status, ar.reject_msg, ar.created_at, ar.reviewed_at
|
||||
FROM avatar_requests ar
|
||||
WHERE ar.user_id = ?
|
||||
ORDER BY ar.created_at DESC LIMIT 1
|
||||
`).get(req.user.id);
|
||||
|
||||
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id);
|
||||
res.json({ request: row || null, current_avatar: user?.avatar_url || null });
|
||||
}
|
||||
|
||||
/* ── GET /api/avatar/pending ── moderator sees all pending requests ──────── */
|
||||
function getPending(req, res) {
|
||||
const rows = db.prepare(`
|
||||
SELECT ar.id, ar.filename, ar.status, ar.created_at,
|
||||
u.id AS user_id, u.name AS user_name, u.email AS user_email,
|
||||
u.avatar_url AS current_avatar
|
||||
FROM avatar_requests ar
|
||||
JOIN users u ON u.id = ar.user_id
|
||||
WHERE ar.status = 'pending'
|
||||
ORDER BY ar.created_at ASC
|
||||
`).all();
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
/* ── POST /api/avatar/:id/approve ── moderator approves ─────────────────── */
|
||||
function approveAvatar(req, res) {
|
||||
const row = db.prepare('SELECT * FROM avatar_requests WHERE id=?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Заявка не найдена' });
|
||||
if (row.status !== 'pending') return res.status(400).json({ error: 'Заявка уже обработана' });
|
||||
|
||||
// Remove old avatar file if exists
|
||||
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(row.user_id);
|
||||
if (user?.avatar_url) {
|
||||
try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {}
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(row.filename, row.user_id);
|
||||
db.prepare(`
|
||||
UPDATE avatar_requests SET status='approved', reviewer_id=?, reviewed_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(req.user.id, row.id);
|
||||
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── POST /api/avatar/:id/reject ── moderator rejects ───────────────────── */
|
||||
function rejectAvatar(req, res) {
|
||||
const row = db.prepare('SELECT * FROM avatar_requests WHERE id=?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Заявка не найдена' });
|
||||
if (row.status !== 'pending') return res.status(400).json({ error: 'Заявка уже обработана' });
|
||||
|
||||
const msg = (req.body.reason || '').toString().slice(0, 200).trim();
|
||||
|
||||
// Delete uploaded file
|
||||
try { fs.unlinkSync(path.join(AVATARS_DIR, row.filename)); } catch {}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE avatar_requests
|
||||
SET status='rejected', reviewer_id=?, reject_msg=?, reviewed_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(req.user.id, msg || null, row.id);
|
||||
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── DELETE /api/avatar/me ── student removes their approved avatar ──────── */
|
||||
function removeAvatar(req, res) {
|
||||
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id);
|
||||
if (user?.avatar_url) {
|
||||
try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {}
|
||||
db.prepare('UPDATE users SET avatar_url=NULL WHERE id=?').run(req.user.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { requestAvatar, myStatus, getPending, approveAvatar, rejectAvatar, removeAvatar };
|
||||
@@ -2899,6 +2899,25 @@ db.exec(`
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)');
|
||||
|
||||
// Avatar photo (approved URL, NULL = use initials)
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT DEFAULT NULL"); } catch {}
|
||||
|
||||
// Avatar moderation queue
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS avatar_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
reviewer_id INTEGER REFERENCES users(id),
|
||||
reject_msg TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reviewed_at TEXT
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_avatar_req_user ON avatar_requests(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_avatar_req_status ON avatar_requests(status)');
|
||||
|
||||
// User preferences (server-synced: whiteboard defaults, dashboard visibility, etc.)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const ctrl = require('../controllers/avatarController');
|
||||
|
||||
/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */
|
||||
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
||||
const AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: AVATARS_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const name = crypto.randomBytes(16).toString('hex') + ext;
|
||||
cb(null, name);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
cb(null, AVATAR_TYPES.has(file.mimetype));
|
||||
},
|
||||
});
|
||||
|
||||
/* ── student routes ─────────────────────────────────────────────────────── */
|
||||
router.post('/request', authMiddleware, upload.single('avatar'), ctrl.requestAvatar);
|
||||
router.get('/my-status', authMiddleware, ctrl.myStatus);
|
||||
router.delete('/me', authMiddleware, ctrl.removeAvatar);
|
||||
|
||||
/* ── moderator routes (teacher or admin) ────────────────────────────────── */
|
||||
router.get('/pending', authMiddleware, requireRole('teacher', 'admin'), ctrl.getPending);
|
||||
router.post('/:id/approve', authMiddleware, requireRole('teacher', 'admin'), ctrl.approveAvatar);
|
||||
router.post('/:id/reject', authMiddleware, requireRole('teacher', 'admin'), ctrl.rejectAvatar);
|
||||
|
||||
module.exports = router;
|
||||
@@ -144,6 +144,7 @@ app.use('/api/search', searchRoutes);
|
||||
app.use('/api/flashcards', flashcardRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/preferences', require('./routes/preferences'));
|
||||
app.use('/api/avatar', require('./routes/avatar'));
|
||||
app.use('/api/analytics', analyticsRoutes);
|
||||
app.use('/api/live', liveRoutes);
|
||||
app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom
|
||||
@@ -273,9 +274,10 @@ function _fmtUptime(s) {
|
||||
const frontendDir = path.join(__dirname, '../../frontend');
|
||||
const jsDir = path.join(__dirname, '../../js');
|
||||
const staticCache = isProd ? { maxAge: '7d' } : { setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') };
|
||||
app.use('/js', express.static(jsDir, staticCache));
|
||||
app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
|
||||
app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache));
|
||||
app.use('/js', express.static(jsDir, staticCache));
|
||||
app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
|
||||
app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache));
|
||||
app.use('/avatars', express.static(path.join(__dirname, '../../uploads/avatars'), { maxAge: '1d' }));
|
||||
|
||||
// Redirect legacy .html URLs → clean URLs (301)
|
||||
app.use((req, res, next) => {
|
||||
|
||||
Reference in New Issue
Block a user