feat: env-driven upload limits + request timeout, inline video playback, range requests

- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
This commit is contained in:
dev
2026-10-03 12:00:48 +03:00
parent 104bdc4f49
commit 70b0c7ae9b
71 changed files with 307 additions and 6 deletions
+66 -1
View File
@@ -8,7 +8,7 @@ const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report');
const { createStorage } = require('./storage');
const { createStorage, mimeFor } = require('./storage');
const { createRedis } = require('./redis');
const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('./diff');
@@ -16,6 +16,7 @@ const https = require('https');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { pipeline } = require('stream/promises');
types.setTypeParser(1082, v => v);
@@ -4513,6 +4514,65 @@ function isImageName(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i.test(name || '');
}
const BROWSER_VIDEO_EXT = /\.(?:mp4|m4v|webm|ogv)$/i;
const OTHER_VIDEO_EXT = /\.(?:mov|mkv|avi|mpeg|mpg|3gp|ts)$/i;
function isVideoName(name) {
return BROWSER_VIDEO_EXT.test(name || '') || OTHER_VIDEO_EXT.test(name || '');
}
function isPlayableVideoName(name) {
return BROWSER_VIDEO_EXT.test(name || '');
}
function parseByteRange(header, size) {
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header || '').trim());
if (!m || size <= 0) return null;
const hasStart = m[1] !== '';
const hasEnd = m[2] !== '';
if (!hasStart && !hasEnd) return null;
let start;
let end;
if (hasStart) {
start = parseInt(m[1], 10);
end = hasEnd ? parseInt(m[2], 10) : size - 1;
} else {
const suffix = parseInt(m[2], 10);
if (!Number.isFinite(suffix) || suffix <= 0) return null;
start = Math.max(0, size - suffix);
end = size - 1;
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start >= size) return { unsatisfiable: true };
return { start, end: Math.min(end, size - 1) };
}
async function sendPlayableFile(req, res, key, name) {
const size = await storage.sizeOf(key);
if (!size) return false;
const contentType = mimeFor(name);
const cacheControl = 'private, max-age=3600';
const range = parseByteRange(req.headers.range, size);
if (range && range.unsatisfiable) {
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Range', `bytes */${size}`);
res.status(416).end();
return true;
}
if (!range) {
const source = await storage.getStream(key);
if (!source) return false;
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', String(size));
res.setHeader('Cache-Control', cacheControl);
await pipeline(source.stream, res);
return true;
}
const ok = await storage.streamRangeTo(res, key, range.start, range.end, { contentType, cacheControl });
return ok;
}
app.get('/api/files', requireAuth, async (req, res) => {
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
const conditions = [];
@@ -4641,6 +4701,11 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => {
if (!ok && !res.headersSent) return res.status(404).json({ error: 'File missing' });
return;
}
if (isPlayableVideoName(r.name) && req.query.play) {
const played = await sendPlayableFile(req, res, key, r.name);
if (!played && !res.headersSent) return res.status(404).json({ error: 'File missing' });
return;
}
if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) {
return res.status(404).json({ error: 'File missing' });
}