diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 47815e6..3df2ea0 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -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); } diff --git a/backend/src/controllers/avatarController.js b/backend/src/controllers/avatarController.js new file mode 100644 index 0000000..8e7d7c1 --- /dev/null +++ b/backend/src/controllers/avatarController.js @@ -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 }; diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 9927cc4..f5476bb 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -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 ( diff --git a/backend/src/routes/avatar.js b/backend/src/routes/avatar.js new file mode 100644 index 0000000..380eda2 --- /dev/null +++ b/backend/src/routes/avatar.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 1209445..81231ae 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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) => { diff --git a/frontend/admin.html b/frontend/admin.html index 6c1108b..c74aa0f 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -827,6 +827,39 @@ .cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); } .cr-hist-search:focus { outline:none; border-color:var(--violet); } .cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; } + /* ── Avatar moderation ── */ + .av-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); gap: 16px; } + .av-card { + background: var(--surface); border: 1.5px solid var(--border-h); + border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 12px; + } + .av-card-top { display: flex; align-items: center; gap: 12px; } + .av-imgs { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; } + .av-img-wrap { text-align: center; } + .av-img-wrap span { font-size: 0.62rem; color: var(--text-3); font-weight: 600; display: block; margin-bottom: 4px; } + .av-img { + width: 64px; height: 64px; border-radius: 50%; object-fit: cover; + border: 2px solid var(--border-h); background: var(--surface-2); + display: flex; align-items: center; justify-content: center; + font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; + color: var(--text-2); overflow: hidden; + } + .av-img img { width: 100%; height: 100%; object-fit: cover; } + .av-arrow { color: var(--text-3); flex-shrink: 0; } + .av-user-name { font-size: 0.82rem; font-weight: 700; color: var(--text); } + .av-date { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; } + .av-actions { display: flex; gap: 8px; } + .av-approve { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(6,214,96,0.12); color: #06d660; transition: background .15s; } + .av-approve:hover { background: rgba(6,214,96,0.22); } + .av-reject { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(241,91,181,0.12); color: #F15BB5; transition: background .15s; } + .av-reject:hover { background: rgba(241,91,181,0.22); } + .av-empty { text-align: center; padding: 60px 0; color: var(--text-3); font-size: 0.85rem; } + .admin-badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 18px; border-radius: 99px; padding: 0 5px; + background: #F15BB5; color: #fff; font-size: 0.65rem; font-weight: 800; + margin-left: 4px; vertical-align: middle; + }
@@ -878,6 +911,10 @@ + @@ -1102,6 +1139,19 @@ + +