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:
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user