Update project files
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user