feat(video): server-generated poster thumbnails + HTTP Range streaming

- Dockerfile: add ffmpeg (Alpine).
- static.js: new /thumbs/<id> — ffmpeg extracts the first frame (0.5s), caches it next to
  the file, serves as the video poster (cosmetic; 404s gracefully if ffmpeg unavailable).
- static.js: /files now supports HTTP Range (206 Partial Content) + Accept-Ranges, which
  iOS requires to stream/seek video reliably (fixes the buffer-before-play / multi-tap);
  media (image/video/audio) now served inline, other files still download. Shared
  attachment auth refactored into one helper used by /files and /thumbs.
- home.html: video poster points at /thumbs/<id>. build batch157.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:09:32 +05:30
parent 92d41e6324
commit fd15628393
3 changed files with 59 additions and 13 deletions
+3
View File
@@ -2,6 +2,9 @@
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
FROM node:24-alpine
# ffmpeg: server-side video poster thumbnails (see static.js /thumbs/<id>). Small on Alpine.
RUN apk add --no-cache ffmpeg
ENV NODE_ENV=production
WORKDIR /app/server
+2 -2
View File
@@ -1162,7 +1162,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-22-batch156';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-22-batch157';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2010,7 +2010,7 @@ function bubbleHTML(m){
const A = m.attachment;
const att = A ? (
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
: A.isVideo ? '<video class="att-vid" src="/files/'+pEsc(A.id)+'#t=0.1" controls playsinline preload="metadata" title="'+pEsc(A.name)+'"></video>'
: A.isVideo ? '<video class="att-vid" src="/files/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="metadata" title="'+pEsc(A.name)+'"></video>'
: '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>') : '';
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
+54 -11
View File
@@ -150,29 +150,72 @@ function handleGet(req, res) {
rs.pipe(res);
});
}
if (pathOnly.startsWith('/files/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
const authAttachment = (id, u) => {
const a = R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
// Authorize: the uploader, a member of the group using it as an avatar, or a participant of ANY
// message carrying it (the "any" covers forwarded attachments, which reuse the same id).
if (!a || a.team_id !== u.team_id) return null;
const avatarGroup = R.conversations.byAvatar(id);
const carriers = R.messages.allByAttachment(id);
const allowed = a.uploader_id === u.id
const ok = a.uploader_id === u.id
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|| carriers.some((msg) => msg.conversation_id
? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id));
if (!allowed) return json(res, 403, { error: 'forbidden' });
return ok ? a : null;
};
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
if (pathOnly.startsWith('/thumbs/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' });
if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
const src = path.join(UPLOADS_DIR, id);
const thumb = path.join(UPLOADS_DIR, id + '.thumb.jpg');
if (!src.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
const send = () => fs.stat(thumb, (e, st) => {
if (e) return json(res, 404, { error: 'no thumbnail' });
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': st.size, 'Cache-Control': 'private, max-age=604800' });
const rs = fs.createReadStream(thumb); rs.on('error', () => { try { res.destroy(); } catch (e2) {} }); rs.pipe(res);
});
if (fs.existsSync(thumb)) return send();
return require('child_process').execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', thumb], { timeout: 15000 }, (err) => {
if (err) { try { fs.unlinkSync(thumb); } catch (_) {} return json(res, 404, { error: 'thumbnail unavailable' }); }
send();
});
}
if (pathOnly.startsWith('/files/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' });
const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
const isImage = /^image\//.test(a.mime || '');
const inline = /^(image|video|audio)\//.test(a.mime || ''); // media opens/plays inline; other files download
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Length': st.size, 'Content-Disposition': (isImage ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400' });
const total = st.size;
const base = { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Disposition': (inline ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400', 'Accept-Ranges': 'bytes' };
// Range support — required for reliable iOS video streaming/seeking (206 Partial Content).
const range = req.headers.range;
const mm = range && /^bytes=(\d*)-(\d*)$/.exec(range);
if (mm) {
let start = mm[1] ? parseInt(mm[1], 10) : 0;
let end = mm[2] ? parseInt(mm[2], 10) : total - 1;
if (isNaN(start)) start = 0;
if (isNaN(end) || end >= total) end = total - 1;
if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); return res.end(); }
res.writeHead(206, Object.assign({}, base, { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Content-Length': (end - start + 1) }));
const rs = fs.createReadStream(fp, { start, end });
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
return rs.pipe(res);
}
res.writeHead(200, Object.assign({}, base, { 'Content-Length': total }));
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);