Update project files

This commit is contained in:
dev
2026-09-10 11:26:51 +03:00
parent 7ccc9199e0
commit 3850fe35e4
19 changed files with 511 additions and 155 deletions
+131 -60
View File
@@ -68,6 +68,54 @@ app.use(express.static(path.join(__dirname, 'public')));
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getDiskInfo() {
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
if (totalDisk) {
const blockSize = totalDisk.bsize || 4096;
const total = totalDisk.blocks * blockSize;
const free = totalDisk.bfree * blockSize;
const used = total - free;
return {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
}
try {
const { execSync } = require('child_process');
const out = execSync('df -B1 .', { encoding: 'utf8' });
const lines = out.trim().split('\n');
if (lines.length > 1) {
const parts = lines[1].split(/\s+/);
const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const free = parseInt(parts[3], 10);
return {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
}
} catch {}
return null;
}
function safeUser(u) {
return {
id: u.id,
@@ -1189,7 +1237,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
if (l.group_id) {
const pRes = await pool.query(
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
WHERE group_id = $1 ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
[l.group_id]
);
photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at }));
@@ -1253,13 +1301,14 @@ app.get('/s/:token', (req, res) => {
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
const { rows } = await pool.query(
`SELECT g.*, gp.photo_path AS cover_path, b.name AS branch_name
`SELECT g.*,
COALESCE(g.cover_path,
(SELECT photo_path FROM group_photos
WHERE group_id = g.id
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
LIMIT 1)) AS cover_path,
b.name AS branch_name
FROM groups g
LEFT JOIN LATERAL (
SELECT photo_path FROM group_photos
WHERE group_id = g.id
ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1
) gp ON true
LEFT JOIN branches b ON b.id = g.branch_id
WHERE 1=1${bw.where}
ORDER BY g.id`,
@@ -1428,7 +1477,7 @@ app.get('/api/groups/:id/photos', requireAuth, async (req, res) => {
);
const total = crows[0].n;
let q = `SELECT * FROM group_photos WHERE group_id = $1
ORDER BY taken_at DESC NULLS LAST, created_at DESC`;
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`;
const qparams = [req.params.id];
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
@@ -1464,8 +1513,10 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
try {
await convertPhoto(req.file);
const { rows } = await pool.query(
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at)
VALUES ($1, $2, $3, $4) RETURNING *`,
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at, sort_order)
VALUES ($1, $2, $3, $4,
COALESCE((SELECT MIN(sort_order) - 1 FROM group_photos WHERE group_id = $1), 0))
RETURNING *`,
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
);
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
@@ -1477,6 +1528,49 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
}
});
app.put('/api/groups/:id/photos/reorder', requireAuth, async (req, res) => {
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const { order } = req.body;
if (!Array.isArray(order) || order.some(id => !Number.isInteger(Number(id)))) {
return res.status(400).json({ error: 'Некорректный порядок фото' });
}
const ids = order.map(id => Number(id));
if (new Set(ids).size !== ids.length) {
return res.status(400).json({ error: 'Порядок фото содержит дубликаты' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: owned } = await client.query(
'SELECT id FROM group_photos WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC',
[req.params.id]
);
const ownedIds = owned.map(r => r.id);
if (ids.some(id => !ownedIds.includes(id))) {
throw { http: 404, message: 'Фото не найдено' };
}
const rest = ownedIds.filter(id => !ids.includes(id));
const allIds = [...ids, ...rest];
for (let i = 0; i < allIds.length; i++) {
await client.query(
'UPDATE group_photos SET sort_order = $1 WHERE id = $2',
[i + 1, allIds[i]]
);
}
await client.query('COMMIT');
await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids });
res.json({ ok: true });
} catch (e) {
await client.query('ROLLBACK');
if (e.http) return res.status(e.http).json({ error: e.message });
throw e;
} finally {
client.release();
}
});
app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
@@ -1504,6 +1598,7 @@ app.delete('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
safeUnlink(rows[0].photo_path);
await pool.query('UPDATE groups SET cover_path = NULL WHERE id = $1 AND cover_path = $2', [req.params.id, rows[0].photo_path]);
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
res.json({ ok: true });
@@ -1961,6 +2056,24 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
`);
function sumPhotoSizes(paths) {
let bytes = 0;
for (const p of paths) {
if (!p) continue;
const fp = path.join(__dirname, p);
try {
const st = fs.statSync(fp);
if (st.isFile()) bytes += st.size;
} catch {}
}
return bytes;
}
const groupPhotoSizes = await pool.query('SELECT photo_path FROM group_photos');
const entryPhotoSizes = await pool.query('SELECT photo_path FROM entries WHERE photo_path IS NOT NULL AND deleted_at IS NULL');
const groupPhotoBytes = sumPhotoSizes(groupPhotoSizes.rows.map(r => r.photo_path));
const entryPhotoBytes = sumPhotoSizes(entryPhotoSizes.rows.map(r => r.photo_path));
const uploadsDir = path.join(__dirname, 'uploads');
let uploadsSize = 0;
let uploadsCount = 0;
@@ -1974,52 +2087,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
}
}
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
let diskInfo = null;
if (totalDisk) {
const blockSize = totalDisk.bsize || 4096;
const total = totalDisk.blocks * blockSize;
const free = totalDisk.bfree * blockSize;
const used = total - free;
diskInfo = {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
} else {
try {
const { execSync } = require('child_process');
const out = execSync('df -B1 .', { encoding: 'utf8' });
const lines = out.trim().split('\n');
if (lines.length > 1) {
const parts = lines[1].split(/\s+/);
const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const free = parseInt(parts[3], 10);
diskInfo = {
total: formatBytes(total),
total_bytes: total,
used: formatBytes(used),
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
};
}
} catch {}
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const diskInfo = getDiskInfo();
res.json({
database: {
@@ -2032,9 +2100,11 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
})),
},
photos: {
group_photos: { count: photoStats.rows[0].count || 0 },
entry_photos: { count: entryPhotoStats.rows[0].count || 0 },
group_photos: { count: photoStats.rows[0].count || 0, size: formatBytes(groupPhotoBytes), size_bytes: groupPhotoBytes },
entry_photos: { count: entryPhotoStats.rows[0].count || 0, size: formatBytes(entryPhotoBytes), size_bytes: entryPhotoBytes },
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
total_size: formatBytes(groupPhotoBytes + entryPhotoBytes),
total_size_bytes: groupPhotoBytes + entryPhotoBytes,
},
files: {
project_files: { count: fileStats.rows[0].count || 0 },
@@ -2104,12 +2174,12 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
pool.query(
`SELECT gp.id, gp.photo_path, gp.caption, gp.taken_at, g.name AS group_name
FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
whereGroup ? whereGroupParams : []
),
pool.query(
`SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
whereGroup ? whereGroupParams : []
),
]);
@@ -2128,6 +2198,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
top_students: top.rows,
photos: photos.rows,
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
disk: getDiskInfo(),
});
});