screentinker/server/middleware/upload.js
screentinker 8529be5a30
feat(content): subtitle/caption support as a content property (#223)
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).

- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
  subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
  player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
  filter is video/image-only); stores the file in the content dir, records
  subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
  the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
  onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
  appended to the <video>, forced mode='showing' on load (same-origin, so
  CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
  video gets a .vtt file picker + language + a remove-subtitle option. en/es.

Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.

Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.

Closes #216

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:33:35 -05:00

79 lines
3.5 KiB
JavaScript

const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const config = require('../config');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, config.contentDir);
},
filename: (req, file, cb) => {
// busboy decodes the Content-Disposition filename header as latin1 by
// default. Modern clients send raw UTF-8 bytes for non-ASCII filenames
// (e.g. browsers + curl on UTF-8 locales send "Begrussungsscreens.jpg"
// with c3 bc for u-umlaut). Reading those bytes as latin1 produces the
// string "A-tilde + quarter-mark" which JS then re-encodes as 4 UTF-8
// bytes on the way to the DB - classic double-encoding mojibake.
//
// The `defParamCharset: 'utf8'` option below only takes effect for
// RFC 5987 encoded `filename*=...` params, which most clients don't send.
// For the plain `filename="..."` case, re-decode here to recover the
// original UTF-8 byte sequence. Mutating originalname here propagates to
// every downstream consumer (route handlers reading req.file.originalname).
if (file.originalname) {
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
}
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
const allowedTypes = [
'video/mp4', 'video/webm', 'video/avi', 'video/mkv', 'video/mov',
'video/x-msvideo', 'video/quicktime', 'video/x-matroska',
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'
];
if (allowedTypes.includes(file.mimetype) || file.mimetype.startsWith('video/') || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only video and image files are allowed'), false);
}
};
// `defParamCharset: 'utf8'` only takes effect for RFC 5987 encoded
// `filename*=utf-8''...` params. Most real clients (browsers, curl, programmatic
// HTTP) send the plain `filename="..."` form, where busboy still reads the bytes
// as latin1 regardless of this option. The actual UTF-8 recovery happens in the
// storage.filename callback above via Buffer.from(name,'latin1').toString('utf8').
// Kept here as defense-in-depth for the rare RFC 5987 case.
const upload = multer({
storage,
fileFilter,
limits: { fileSize: config.maxFileSize },
defParamCharset: 'utf8'
});
// #216: dedicated uploader for WebVTT subtitle files. The main `fileFilter` only allows
// video/image, so subtitles need their own instance. Written into the same content dir
// (served at /uploads/content/<file>) with a .vtt name; capped small — subtitles are tiny.
const subtitleStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, config.contentDir),
filename: (req, file, cb) => cb(null, `${uuidv4()}.vtt`),
});
const subtitleUpload = multer({
storage: subtitleStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — generous for a subtitle track
fileFilter: (req, file, cb) => {
// Browsers send .vtt as text/vtt; some send text/plain or application/octet-stream.
// Gate on the extension (authoritative here) plus those benign text mimetypes.
const okExt = /\.vtt$/i.test(file.originalname || '');
const okMime = ['text/vtt', 'text/plain', 'application/octet-stream'].includes(file.mimetype);
if (okExt && okMime) return cb(null, true);
cb(new Error('Only .vtt subtitle files are allowed'), false);
},
});
upload.subtitleUpload = subtitleUpload;
module.exports = upload;