diff --git a/db/init.sql b/db/init.sql index 88faa7b..8e314ad 100644 --- a/db/init.sql +++ b/db/init.sql @@ -29,18 +29,25 @@ CREATE TABLE IF NOT EXISTS modules ( name VARCHAR(200) NOT NULL UNIQUE, lessons_count INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, + photo_path VARCHAR(255), created_at TIMESTAMPTZ DEFAULT now() ); ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); CREATE TABLE IF NOT EXISTS students ( id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE, group_id INT REFERENCES groups(id), - created_at TIMESTAMPTZ DEFAULT now() + created_at TIMESTAMPTZ DEFAULT now(), + photo_path VARCHAR(255), + profile JSONB ); +ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); +ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB; + CREATE TABLE IF NOT EXISTS entries ( id SERIAL PRIMARY KEY, student_name VARCHAR(150) NOT NULL, diff --git a/db/migration.sql b/db/migration.sql index 65f40ad..289f707 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS banned_ips ( ); ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id); +ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); +ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -233,5 +235,6 @@ CREATE TABLE IF NOT EXISTS modules ( ); ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255); ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id); diff --git a/public/js/modules.js b/public/js/modules.js index c857277..98aedc0 100644 --- a/public/js/modules.js +++ b/public/js/modules.js @@ -2,6 +2,7 @@ let modulesList = []; let mPage = 1; const M_PAGE_SIZE = 25; let mSearchTimer = null; +let modulePhotoDeleteFlag = false; function mPlural(n, one, few, many) { n = Math.abs(n) % 100; const n1 = n % 10; @@ -33,6 +34,7 @@ function renderModules(total) { } else { list.innerHTML = modulesList.map(m => `
Портфолио ученика
-Фотографий пока нет.
'} -Работ пока нет.
'} -Записей о занятиях пока нет.
'} -diff --git a/public/students.html b/public/students.html index 85e077f..6a3a658 100644 --- a/public/students.html +++ b/public/students.html @@ -130,6 +130,86 @@ +
+
diff --git a/server.js b/server.js index 10a140e..fead479 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ const helmet = require('helmet'); const bcrypt = require('bcrypt'); const heicConvert = require('heic-convert'); const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker'); +const { createZipWriter, renderStudentReport } = require('./student-report'); const https = require('https'); const path = require('path'); @@ -615,15 +616,16 @@ 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 }] = await Promise.all([ + const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }] = 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'), pool.query('SELECT photo_path AS p FROM entry_photos'), 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'), ]); const refs = new Set(); - [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, ''))); + [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos].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; @@ -674,6 +676,7 @@ async function ensureModulesTable() { created_at TIMESTAMPTZ DEFAULT now() )`); await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true'); + await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255)'); await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL'); await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)'); } @@ -1285,6 +1288,107 @@ function reqAiStatus(v, fallback) { return AI_STATUSES.has(s) ? s : fallback; } +const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i; +const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/; + +function profText(v, max) { + if (v === null || v === undefined) return null; + if (typeof v !== 'string') throw new Error('Ожидалась строка'); + const s = v.trim(); + if (!s) return null; + if (s.length > max) throw new Error('Слишком длинное значение'); + return s; +} + +function profIcon(v) { + const s = String(v || '').trim().toLowerCase(); + return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link'; +} + +function profHref(v) { + const s = String(v || '').trim(); + if (!s || s.length > 500) return null; + return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null; +} + +function profList(v, max, fn) { + if (v === null || v === undefined) return []; + if (!Array.isArray(v)) throw new Error('Ожидался список'); + const out = []; + for (const item of v.slice(0, max)) { + const row = fn(item); + if (row) out.push(row); + } + return out; +} + +function sanitizeStudentProfile(raw) { + if (raw === null || raw === undefined) return null; + if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля'); + const out = { + role: profText(raw.role, 200), + status: profText(raw.status, 60), + status_note: profText(raw.status_note, 120), + city: profText(raw.city, 120), + mentor: profText(raw.mentor, 150), + joined: profText(raw.joined, 120), + bio: profText(raw.bio, 2000), + quote: profText(raw.quote, 300), + tags: profList(raw.tags, 20, t => profText(t, 40)), + achievements: profList(raw.achievements, 40, a => profText(a, 200)), + contacts: profList(raw.contacts, 20, c => { + if (!c || typeof c !== 'object') return null; + const label = profText(c.label, 120); + if (!label) return null; + return { icon: profIcon(c.icon), label, href: profHref(c.href) }; + }), + skills: profList(raw.skills, 80, s => { + if (!s || typeof s !== 'object') return null; + const name = profText(s.name, 120); + if (!name) return null; + const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100); + return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value }; + }), + experience: profList(raw.experience, 30, e => { + if (!e || typeof e !== 'object') return null; + const title = profText(e.title, 160); + if (!title) return null; + return { + title, + company: profText(e.company, 160), + period: profText(e.period, 80), + date: profText(e.date, 40), + badge: profText(e.badge, 40), + description: profText(e.description, 800), + tags: profList(e.tags, 10, t => profText(t, 40)), + }; + }), + education: profList(raw.education, 60, m => { + if (!m || typeof m !== 'object') return null; + const module = profText(m.module, 200); + if (!module) return null; + const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100); + return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) }; + }), + stats: profList(raw.stats, 12, s => { + if (!s || typeof s !== 'object') return null; + const label = profText(s.label, 80); + const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20); + if (!label || !value) return null; + return { + icon: profIcon(s.icon || 'star'), + value, + suffix: profText(s.suffix, 20), + label, + hint: profText(s.hint, 120), + delta: profText(s.delta, 60), + }; + }), + }; + const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null)); + return hasData ? out : null; +} + function normalizeRestoreData(data) { const groups = (data.groups || []).map(x => ({ id: reqInt(x.id), @@ -1300,6 +1404,8 @@ function normalizeRestoreData(data) { name: reqStr(x.name, 150), created_at: optTs(x.created_at), group_id: optInt(x.group_id, 0, 2147483647), + photo_path: optUploadPath(x.photo_path, 255), + profile: sanitizeStudentProfile(x.profile), })); const entries = (data.entries || []).map(x => ({ id: reqInt(x.id), @@ -1349,6 +1455,7 @@ function normalizeRestoreData(data) { name: reqStr(x.name, 200), lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0, is_active: x.is_active !== false, + photo_path: optUploadPath(x.photo_path, 255), created_at: optTs(x.created_at), })); const entry_photos = (data.entry_photos || []).map(x => ({ @@ -1526,14 +1633,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of ndata.students) { await client.query( - 'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)', - [x.id, x.name, x.created_at, x.group_id] + 'INSERT INTO students (id, name, created_at, group_id, photo_path, profile) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.name, x.created_at, x.group_id, x.photo_path, x.profile ? JSON.stringify(x.profile) : null] ); } for (const x of ndata.modules) { await client.query( - 'INSERT INTO modules (id, name, lessons_count, is_active, created_at) VALUES ($1,$2,$3,$4,$5)', - [x.id, x.name, x.lessons_count, x.is_active, x.created_at] + 'INSERT INTO modules (id, name, lessons_count, is_active, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.name, x.lessons_count, x.is_active, x.photo_path, x.created_at] ); } for (const x of ndata.entries) { @@ -2375,6 +2482,48 @@ app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => { res.json(rows[0]); }); +const modulePhotoUpload = upload.single('photo'); +app.post('/api/modules/:id/photo', requireAdmin, (req, res) => { + modulePhotoUpload(req, res, async (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, jfif)' }); + if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' }); + return res.status(400).json({ error: 'Недопустимый файл' }); + } + if (!req.file) return res.status(400).json({ error: 'Файл обязателен' }); + try { + const mod = await pool.query('SELECT photo_path FROM modules WHERE id = $1', [req.params.id]); + if (!mod.rows.length) { + removeUpload(req.file); + return res.status(404).json({ error: 'Не найдено' }); + } + await convertPhoto(req.file); + const oldPath = mod.rows[0].photo_path; + const { rows } = await pool.query( + 'UPDATE modules SET photo_path = $1 WHERE id = $2 RETURNING *', + [`/uploads/${req.file.filename}`, req.params.id] + ); + if (oldPath) safeUnlink(oldPath); + await logAudit(req, 'module.photo.create', { id: req.params.id, photo_path: rows[0].photo_path }); + res.json(rows[0]); + } catch (e) { + removeUpload(req.file); + console.error('POST /api/modules/:id/photo:', e); + res.status(500).json({ error: e.message }); + } + }); +}); + +app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => { + const { rows } = await pool.query('SELECT id, photo_path FROM modules WHERE id = $1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + if (rows[0].photo_path) safeUnlink(rows[0].photo_path); + await pool.query('UPDATE modules SET photo_path = NULL WHERE id = $1', [req.params.id]); + await logAudit(req, 'module.photo.delete', { id: req.params.id, photo_path: rows[0].photo_path }); + res.json({ ok: true }); +}); + // --- Students CRUD --- app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => { @@ -2499,463 +2648,74 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => { res.json({ ok: true }); }); -// --- Student portfolio export (ZIP: HTML report + photos + files) --- -const CRC_TABLE = (() => { - const table = new Int32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); - table[n] = c; +async function studentProfileAccess(user, id) { + const { rows } = await pool.query( + 'SELECT id, name, group_id, photo_path, profile FROM students WHERE id = $1', + [id] + ); + if (!rows.length) return { found: false }; + const gid = rows[0].group_id; + if (user.role !== 'admin' && gid && !(await groupBelongsToBranches(user, gid))) { + return { found: false, forbidden: true }; } - return table; -})(); - -function crc32(buf) { - let crc = 0xffffffff; - for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); - return (crc ^ 0xffffffff) >>> 0; + return { found: true, student: rows[0] }; } -function dosDateTime(d = new Date()) { - return { - time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2), - date: ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), - }; -} - -function createZipWriter() { - const parts = []; - const central = []; - let count = 0; - let offset = 0; - function buildEntry(nameBuf, method, crc, compressed, plain, dt) { - const local = Buffer.alloc(30); - local.writeUInt32LE(0x04034b50, 0); - local.writeUInt16LE(20, 4); - local.writeUInt16LE(0x0800, 6); - local.writeUInt16LE(method, 8); - local.writeUInt16LE(dt.time, 10); - local.writeUInt16LE(dt.date, 12); - local.writeUInt32LE(crc, 14); - local.writeUInt32LE(compressed, 18); - local.writeUInt32LE(plain, 22); - local.writeUInt16LE(nameBuf.length, 26); - local.writeUInt16LE(0, 28); - const cen = Buffer.alloc(46); - cen.writeUInt32LE(0x02014b50, 0); - cen.writeUInt16LE(20, 4); - cen.writeUInt16LE(20, 6); - cen.writeUInt16LE(0x0800, 8); - cen.writeUInt16LE(method, 10); - cen.writeUInt16LE(dt.time, 12); - cen.writeUInt16LE(dt.date, 14); - cen.writeUInt32LE(crc, 16); - cen.writeUInt32LE(compressed, 20); - cen.writeUInt32LE(plain, 24); - cen.writeUInt16LE(nameBuf.length, 28); - cen.writeUInt16LE(0, 30); - cen.writeUInt16LE(0, 32); - cen.writeUInt16LE(0, 34); - cen.writeUInt16LE(0, 36); - cen.writeUInt32LE(0, 38); - cen.writeUInt32LE(offset, 42); - return { local, cen, nameBuf }; +app.get('/api/students/:id/profile', requireAuth, async (req, res) => { + let id; + try { + id = reqInt(req.params.id); + } catch { + return res.status(400).json({ error: 'Неверный id ученика' }); } - return { - addFile(name, data, d) { - const nameBuf = Buffer.from(name, 'utf8'); - const dt = dosDateTime(d); - const crc = crc32(data); - const compressed = zlib.deflateRawSync(data, { level: 9 }); - const e = buildEntry(nameBuf, 8, crc, compressed.length, data.length, dt); - const chunk = Buffer.concat([e.local, e.nameBuf, compressed]); - parts.push(chunk); - central.push(Buffer.concat([e.cen, e.nameBuf])); - offset += chunk.length; - count++; - }, - addDir(name) { - const nameBuf = Buffer.from(String(name).replace(/\/?$/, '/'), 'utf8'); - const dt = dosDateTime(); - const e = buildEntry(nameBuf, 0, 0, 0, 0, dt); - const chunk = Buffer.concat([e.local, e.nameBuf]); - parts.push(chunk); - central.push(Buffer.concat([e.cen, e.nameBuf])); - offset += chunk.length; - count++; - }, - toBuffer() { - const centralStart = offset; - const centralBuf = Buffer.concat(central); - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); - eocd.writeUInt16LE(0, 4); - eocd.writeUInt16LE(0, 6); - eocd.writeUInt16LE(count, 8); - eocd.writeUInt16LE(count, 10); - eocd.writeUInt32LE(centralBuf.length, 12); - eocd.writeUInt32LE(centralStart, 16); - eocd.writeUInt16LE(0, 20); - return Buffer.concat([...parts, centralBuf, eocd]); - }, - }; -} + const acc = await studentProfileAccess(req.user, id); + if (!acc.found) { + return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' }); + } + res.json({ ...acc.student, profile: acc.student.profile || null }); +}); + +app.put('/api/students/:id/profile', requireAuth, async (req, res) => { + let id; + try { + id = reqInt(req.params.id); + } catch { + return res.status(400).json({ error: 'Неверный id ученика' }); + } + let profile; + let photoPath; + try { + profile = sanitizeStudentProfile(req.body?.profile); + photoPath = req.body?.photo_path === undefined ? undefined : optUploadPath(req.body.photo_path, 255); + } catch (e) { + return res.status(400).json({ error: 'Неверные данные профиля: ' + e.message }); + } + const acc = await studentProfileAccess(req.user, id); + if (!acc.found) { + return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' }); + } + const sets = ['profile = $1']; + const params = [profile ? JSON.stringify(profile) : null]; + if (photoPath !== undefined) { + params.push(photoPath); + sets.push(`photo_path = $${params.length}`); + } + params.push(id); + const { rows } = await pool.query( + `UPDATE students SET ${sets.join(', ')} WHERE id = $${params.length} + RETURNING id, name, group_id, photo_path, profile`, + params + ); + await logAudit(req, 'student.profile.update', { id, name: acc.student.name, blocks: profile ? Object.keys(profile) : [] }); + invalidateStudents(); + res.json(rows[0]); +}); function fmtLongDate(iso) { if (!iso) return ''; return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); } -function fmtBytes(n) { - if (!Number.isFinite(n)) return ''; - if (n < 1024) return n + ' Б'; - if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(/\.0$/, '') + ' КБ'; - return (n / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + ' МБ'; -} - -function truncate(str, max) { - const s = String(str || ''); - return s.length > max ? s.slice(0, max - 1) + '…' : s; -} - -function renderStudentReport(data, opts) { - const o = opts || {}; - const showEntries = o.includeEntries !== false; - const showPhotos = o.includePhotos !== false; - const showFiles = o.includeFiles !== false; - const showCaptions = o.includeCaptions !== false; - const showDates = o.showDates !== false; - const { name, groups, entries, photos, files, generatedAt, period } = data; - const IMG_EXT = new Set(['JPG','JPEG','PNG','GIF','WEBP','BMP','AVIF','SVG','ICO','JFIF']); - const VID_EXT = new Set(['MP4','WEBM','MOV','M4V','OGV','MKV','MPEG','MPG','3GP','AVI']); - const gallery = []; - const galIdx = new Map(); - for (const p of photos) { gallery.push({ type: 'image', src: 'photos/' + p.stored }); galIdx.set('photos/' + p.stored, gallery.length - 1); } - for (const f of files) { - const fn = String(f.original || f.saved || ''); - const ext = fn.indexOf('.') >= 0 ? fn.split('.').pop().toUpperCase() : ''; - if (VID_EXT.has(ext)) { gallery.push({ type: 'video', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); } - else if (IMG_EXT.has(ext)) { gallery.push({ type: 'image', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); } - } - const avatar = showPhotos && photos.length ? photos[0].stored : null; - const plural = (n, one, few, many) => { - const m10 = n % 10, m100 = n % 100; - if (m10 === 1 && m100 !== 11) return one; - if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; - return many; - }; - const counters = []; - if (showEntries) counters.push(`
`); - if (showPhotos) counters.push(`
`); - if (showFiles) counters.push(`
`); - counters.push(`
`);
- const photoCards = showPhotos ? photos.map(p => {
- let caption = '';
- if (showCaptions && p.caption) caption = truncate(p.caption, 120);
- if (!caption && showDates && !p.caption) caption = fmtLongDate(p.createdAt);
- const pg = galIdx.get('photos/' + p.stored);
- return `
-
- ${caption ? `
`).join('') : '';
- const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : [];
- const fls = entryFiles.length ? `
` : ''; - const desc = e.description ? `
${escapeHtml(e.description)}
` : '';
- const gr = e.group_name ? `${escapeHtml(e.group_name)}` : '';
- const dt = showDates && e.created_at ? `${escapeHtml(fmtLongDate(e.created_at))}` : '';
- return `
-
` : ''; - const navItems = []; - navItems.push('Обзор'); - if (showPhotos) navItems.push('Фотографии'); - if (showFiles) navItems.push('Работы'); - if (showEntries) navItems.push('Занятия'); - const nav = navItems.join(''); - return ` - -
- - -
- - -
-