Прикрепить к группе
diff --git a/server.js b/server.js
index 9caf34c..7b90be1 100644
--- a/server.js
+++ b/server.js
@@ -2163,6 +2163,574 @@ 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;
+ }
+ 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;
+}
+
+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 };
+ }
+ 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]);
+ },
+ };
+}
+
+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 statsChips = [];
+ if (showEntries) statsChips.push(`
${entries.length}занятий
`);
+ if (showPhotos) statsChips.push(`
${photos.length}фотографий
`);
+ if (showFiles) statsChips.push(`
${files.length}файлов
`);
+ 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 ? `${escapeHtml(caption)}` : ''}
+ `;
+ }).join('') : '';
+ const fileRows = showFiles ? files.map(f => {
+ const ext = f.original.indexOf('.') >= 0 ? f.original.split('.').pop().toUpperCase().slice(0, 8) : 'FILE';
+ const meta = showDates ? `${fmtLongDate(f.createdAt)} · ${fmtBytes(f.size)}` : fmtBytes(f.size);
+ const fg = galIdx.get('files/' + f.saved);
+ const gattr = fg !== undefined ? ` class="gfile" data-g="${fg}"` : ' target="_blank" rel="noopener"';
+ return `
+
+
+ ${escapeHtml(ext)}
+ ${escapeHtml(f.original)}
+ ${escapeHtml(meta)}
+
+ `;
+ }).join('') : '';
+ const photosByEntry = new Map();
+ for (const p of photos) {
+ if (!photosByEntry.has(p.entryId)) photosByEntry.set(p.entryId, []);
+ photosByEntry.get(p.entryId).push(p.stored);
+ }
+ const lessonRows = showEntries ? entries.map(e => {
+ const thumbs = showPhotos ? (photosByEntry.get(e.id) || []).slice(0, 4).map(t => `
+
})
`).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))}` : '';
+ const head = dt + gr;
+ return `
+
+ ${head ? `${head}
` : ''}
+ ${desc}
+ ${thumbs ? `${thumbs}
` : ''}
+ ${fls}
+ `;
+ }).join('') : '';
+ const groupLine = groups.length ? escapeHtml(groups.join(' · ')) : '';
+ const genLabel = escapeHtml(fmtLongDate(generatedAt));
+ const metaBits = [];
+ if (groupLine) metaBits.push(groupLine);
+ if (period) metaBits.push(escapeHtml(period));
+ metaBits.push(`Сформировано ${genLabel}`);
+ const heroAvatar = avatar ? `
` : '';
+ const navItems = [];
+ if (showPhotos) navItems.push('
Фотографии');
+ if (showFiles) navItems.push('
Работы');
+ if (showEntries) navItems.push('
Занятия');
+ const nav = navItems.length >= 2 ? '
' : '';
+ return `
+
+
+
+
+
${escapeHtml(name)} — портфолио
+
+
+
+
+
+
+
+
+
+ ${heroAvatar || '
🎨
'}
+
+
✦ Портфолио ученика
+
${escapeHtml(name)}
+
${metaBits.join(' · ')}
+
+ ${statsChips.join('')}
+
+
+
+
+
+ ${nav}
+
+ ${showPhotos ? `
+ 🏞️Фотографии
+ ${photoCards ? `${photoCards}
` : '🎈 Фотографий пока нет
'}
+ ` : ''}
+
+ ${showFiles ? `
+ 📁Работы и файлы
+ ${fileRows ? `` : '🎈 Файлов пока нет
'}
+ ` : ''}
+
+ ${showEntries ? `
+ 🎒Журнал занятий
+ ${lessonRows ? `${lessonRows}
` : '🎈 Записей о занятиях пока нет
'}
+ ` : ''}
+
+
+
+
+
+
+`;
+}
+
+app.get('/api/export/student', requireAuth, async (req, res) => {
+ let name;
+ try {
+ name = reqStr(req.query.name, 150);
+ } catch {
+ return res.status(400).json({ error: 'Укажите имя ученика' });
+ }
+ const opts = {
+ includeEntries: req.query.include_entries !== '0',
+ includePhotos: req.query.include_photos !== '0',
+ includeFiles: req.query.include_files !== '0',
+ includeCaptions: req.query.include_captions !== '0',
+ showDates: req.query.show_dates !== '0',
+ };
+ if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
+ return res.status(400).json({ error: 'Выберите, что включать в отчёт' });
+ }
+ let dateFrom = null;
+ let dateTo = null;
+ try {
+ if (req.query.date_from) dateFrom = optDate(req.query.date_from);
+ if (req.query.date_to) dateTo = optDate(req.query.date_to);
+ } catch {
+ return res.status(400).json({ error: 'Неверный период' });
+ }
+ if (dateFrom && dateTo && dateFrom > dateTo) {
+ return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
+ }
+ const hasPeriod = !!(dateFrom || dateTo);
+ try {
+ const conds = ['e.student_name = $1', 'e.deleted_at IS NULL'];
+ const params = [name];
+ const bw = branchWhere(req.user, 'g');
+ if (dateFrom) {
+ params.push(dateFrom);
+ conds.push(`e.created_at >= $${params.length}::date`);
+ }
+ if (dateTo) {
+ params.push(dateTo);
+ conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
+ }
+ if (bw.params.length) {
+ const start = params.length + 1;
+ conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
+ params.push(...bw.params);
+ }
+ const condStr = conds.join(' AND ');
+ const whereStr = ' WHERE ' + condStr;
+ const [studRes, entriesRes, photosRes, mainsRes, filesRes] = await Promise.all([
+ pool.query(`SELECT s.name, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.name = $1`, [name]),
+ pool.query(`SELECT e.id, e.description, e.created_at, g.name AS group_name
+ FROM entries e JOIN groups g ON g.id = e.group_id${whereStr}
+ ORDER BY e.created_at DESC`, params),
+ pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at
+ FROM entry_photos ep
+ JOIN entries e ON e.id = ep.entry_id
+ JOIN groups g ON g.id = e.group_id${whereStr}
+ ORDER BY e.created_at DESC, ep.sort_order ASC, ep.id ASC`, params),
+ pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id
+ FROM entries e JOIN groups g ON g.id = e.group_id
+ WHERE e.photo_path IS NOT NULL AND ${condStr}
+ ORDER BY e.created_at DESC`, params),
+ pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at
+ FROM project_files pf
+ JOIN entries e ON e.id = pf.entry_id
+ JOIN groups g ON g.id = e.group_id
+ WHERE ${condStr} AND pf.detached_at IS NULL
+ ORDER BY e.created_at DESC, pf.id DESC`, params),
+ ]);
+ const entryRows = entriesRes.rows;
+ if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
+ return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
+ }
+ const zip = createZipWriter();
+ if (opts.includePhotos) zip.addDir('photos');
+ if (opts.includeFiles) zip.addDir('files');
+
+ const seenPhotos = new Set();
+ const photosBuilt = [];
+ function addPhoto(p) {
+ if (!isSafeUploadPath(p.photo_path)) return;
+ const stored = p.photo_path.slice('/uploads/'.length);
+ if (seenPhotos.has(stored)) return;
+ seenPhotos.add(stored);
+ const src = path.join(UPLOADS_DIR, stored);
+ if (!fs.existsSync(src)) return;
+ const data = fs.readFileSync(src);
+ zip.addFile('photos/' + stored, data, new Date(p.created_at));
+ photosBuilt.push({ stored, caption: p.caption, createdAt: p.created_at, desc: p.description, entryId: p.entry_id });
+ }
+ if (opts.includePhotos) {
+ for (const p of photosRes.rows) addPhoto(p);
+ for (const m of mainsRes.rows) addPhoto(m);
+ photosBuilt.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+ }
+
+ const seenFiles = new Map();
+ const filesBuilt = [];
+ if (opts.includeFiles) {
+ for (const f of filesRes.rows) {
+ if (!isSafeUploadPath(f.path)) continue;
+ const stored = f.path.slice('/uploads/'.length);
+ const src = path.join(UPLOADS_DIR, stored);
+ if (!fs.existsSync(src)) continue;
+ const data = fs.readFileSync(src);
+ let base = String(f.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[.\s]+/, '').slice(0, 120) || 'file';
+ const ext = path.extname(base);
+ const stem = ext ? base.slice(0, -ext.length) : base;
+ let saved = base;
+ let n = 1;
+ while (seenFiles.has(saved)) {
+ n++;
+ saved = `${stem}(${n})${ext}`;
+ }
+ seenFiles.set(saved, true);
+ zip.addFile('files/' + saved, data, new Date(f.created_at));
+ filesBuilt.push({ saved, original: f.name, size: data.length, createdAt: f.created_at, entryId: f.entry_id });
+ }
+ }
+
+ const groupSet = new Set();
+ if (studRes.rows[0]?.group_name) groupSet.add(studRes.rows[0].group_name);
+ for (const e of entryRows) if (e.group_name) groupSet.add(e.group_name);
+ const groups = [...groupSet];
+
+ let period = null;
+ if (hasPeriod) {
+ period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
+ }
+
+ const html = renderStudentReport({
+ name,
+ groups,
+ entries: entryRows,
+ photos: photosBuilt,
+ files: filesBuilt,
+ generatedAt: new Date(),
+ period,
+ }, opts);
+ zip.addFile('index.html', Buffer.from(html, 'utf8'));
+
+ const buf = zip.toBuffer();
+ await logAudit(req, 'export.student', {
+ student: name,
+ entries: entryRows.length,
+ photos: photosBuilt.length,
+ files: filesBuilt.length,
+ opts,
+ date_from: dateFrom,
+ date_to: dateTo,
+ });
+ const safeName = name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'student';
+ const zipDate = new Date().toISOString().slice(0, 10);
+ const zipFname = `student_${safeName}_${zipDate}.zip`;
+ res.setHeader('Content-Type', 'application/zip');
+ res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`student_${name}_${zipDate}.zip`)}`);
+ res.send(buf);
+ } catch (err) {
+ console.error('export student:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
// --- Entries ---
app.get('/api/entries', requireAuth, async (req, res) => {
const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;