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:
@@ -2,6 +2,9 @@
|
|||||||
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
|
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
|
||||||
FROM node:24-alpine
|
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
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
|
|
||||||
|
|||||||
@@ -1162,7 +1162,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<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.
|
// 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
|
// 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 A = m.attachment;
|
||||||
const att = A ? (
|
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.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>') : '';
|
: '<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'));
|
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).
|
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
||||||
|
|||||||
+54
-11
@@ -150,29 +150,72 @@ function handleGet(req, res) {
|
|||||||
rs.pipe(res);
|
rs.pipe(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/files/')) {
|
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
|
||||||
const u = currentUser(req);
|
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
const authAttachment = (id, u) => {
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
|
||||||
const a = R.attachments.byId(id);
|
const a = R.attachments.byId(id);
|
||||||
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
if (!a || a.team_id !== u.team_id) return null;
|
||||||
// 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).
|
|
||||||
const avatarGroup = R.conversations.byAvatar(id);
|
const avatarGroup = R.conversations.byAvatar(id);
|
||||||
const carriers = R.messages.allByAttachment(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))
|
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|
||||||
|| carriers.some((msg) => msg.conversation_id
|
|| carriers.some((msg) => msg.conversation_id
|
||||||
? R.conversations.isMember(msg.conversation_id, u.id)
|
? R.conversations.isMember(msg.conversation_id, u.id)
|
||||||
: (msg.sender_id === u.id || msg.recipient_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);
|
const fp = path.join(UPLOADS_DIR, id);
|
||||||
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
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, '');
|
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
|
||||||
return fs.stat(fp, (err, st) => {
|
return fs.stat(fp, (err, st) => {
|
||||||
if (err) return json(res, 404, { error: 'not found' });
|
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);
|
const rs = fs.createReadStream(fp);
|
||||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||||
rs.pipe(res);
|
rs.pipe(res);
|
||||||
|
|||||||
Reference in New Issue
Block a user