|
| 1 | +import { filetypemime } from 'magic-bytes.js' |
| 2 | + |
| 3 | +const TIMEOUT_HEAD = 2000 |
| 4 | +const TIMEOUT_GET = 10000 |
| 5 | +const BYTE_LIMIT = 8192 |
| 6 | + |
| 7 | +export function isImageMime (mime) { return typeof mime === 'string' && mime.startsWith('image/') } |
| 8 | + |
| 9 | +export function isVideoMime (mime) { return typeof mime === 'string' && mime.startsWith('video/') } |
| 10 | + |
| 11 | +// adapted from lib/time.js |
| 12 | +function timeoutSignal (timeout) { |
| 13 | + const controller = new AbortController() |
| 14 | + |
| 15 | + if (timeout) { |
| 16 | + setTimeout(() => { |
| 17 | + controller.abort(new Error(`timeout after ${timeout / 1000}s`)) |
| 18 | + }, timeout) |
| 19 | + } |
| 20 | + |
| 21 | + return controller.signal |
| 22 | +} |
| 23 | + |
| 24 | +const requiresAuth = (res) => res.status === 401 || res.status === 403 |
| 25 | + |
| 26 | +async function headMime (url, timeout = TIMEOUT_HEAD) { |
| 27 | + const res = await fetch(url, { method: 'HEAD', signal: timeoutSignal(timeout) }) |
| 28 | + // bail on auth or forbidden |
| 29 | + if (requiresAuth(res)) return null |
| 30 | + |
| 31 | + return res.headers.get('content-type') |
| 32 | +} |
| 33 | + |
| 34 | +async function readMagicBytes (url, { timeout = TIMEOUT_GET, byteLimit = BYTE_LIMIT } = {}) { |
| 35 | + const res = await fetch(url, { |
| 36 | + method: 'GET', |
| 37 | + // accept image and video, but not other types |
| 38 | + headers: { Range: `bytes=0-${byteLimit - 1}`, Accept: 'image/*,video/*;q=0.9,*/*;q=0.8' }, |
| 39 | + signal: timeoutSignal(timeout) |
| 40 | + }) |
| 41 | + // bail on auth or forbidden |
| 42 | + if (requiresAuth(res)) return { bytes: null, headers: res.headers } |
| 43 | + |
| 44 | + // stream a small chunk if possible, otherwise read buffer |
| 45 | + if (res.body?.getReader) { |
| 46 | + const reader = res.body.getReader() |
| 47 | + let received = 0 |
| 48 | + const chunks = [] |
| 49 | + try { |
| 50 | + while (received < byteLimit) { |
| 51 | + const { done, value } = await reader.read() |
| 52 | + if (done) break |
| 53 | + chunks.push(value) |
| 54 | + received += value.byteLength |
| 55 | + } |
| 56 | + } finally { |
| 57 | + try { reader.releaseLock?.() } catch {} |
| 58 | + try { res.body?.cancel?.() } catch {} |
| 59 | + } |
| 60 | + const buf = new Uint8Array(received) |
| 61 | + let offset = 0 |
| 62 | + for (const c of chunks) { |
| 63 | + buf.set(c, offset) |
| 64 | + offset += c.byteLength |
| 65 | + } |
| 66 | + return { bytes: buf, headers: res.headers } |
| 67 | + } else { |
| 68 | + const ab = await res.arrayBuffer() |
| 69 | + const buf = new Uint8Array(ab.slice(0, byteLimit)) |
| 70 | + return { bytes: buf, headers: res.headers } |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +export default async function mediaCheck (req, res) { |
| 75 | + // express automatically decodes the values in req.params (using decodeURIComponent) |
| 76 | + let url = req.params.url |
| 77 | + if (typeof url !== 'string' || !/^(https?:\/\/)/.test(url)) { |
| 78 | + return res.status(400).json({ error: 'Invalid URL' }) |
| 79 | + } |
| 80 | + |
| 81 | + try { |
| 82 | + // in development, the capture container can't reach the public media url, |
| 83 | + // so we need to replace it with its docker equivalent, e.g. http://s3:4566/uploads |
| 84 | + if (url.startsWith(process.env.NEXT_PUBLIC_MEDIA_URL) && process.env.NODE_ENV === 'development') { |
| 85 | + url = url.replace(process.env.NEXT_PUBLIC_MEDIA_URL, process.env.MEDIA_URL_DOCKER) |
| 86 | + } |
| 87 | + |
| 88 | + // trying with HEAD first, as it's the cheapest option |
| 89 | + try { |
| 90 | + const ct = await headMime(url) |
| 91 | + if (isImageMime(ct) || isVideoMime(ct)) { |
| 92 | + return res.status(200).json({ mime: ct, isImage: isImageMime(ct), isVideo: isVideoMime(ct) }) |
| 93 | + } |
| 94 | + } catch {} |
| 95 | + |
| 96 | + // otherwise, read the first bytes |
| 97 | + const { bytes, headers } = await readMagicBytes(url) |
| 98 | + const mimes = bytes ? filetypemime(bytes) : null |
| 99 | + const mime = mimes?.[0] ?? headers.get('content-type') ?? null |
| 100 | + return res.status(200).json({ mime, isImage: isImageMime(mime), isVideo: isVideoMime(mime) }) |
| 101 | + } catch (err) { |
| 102 | + console.log('media check error:', err) |
| 103 | + return res.status(500).json({ mime: null, isImage: false, isVideo: false }) |
| 104 | + } |
| 105 | +} |
0 commit comments