backend/src/router/video.ts

230 lines
7.2 KiB
TypeScript
Raw Normal View History

import { Elysia } from 'elysia';
import DOMPurify from 'isomorphic-dompurify'
import { db } from '@/utils/database'
import { getChannel, getChannelVideos } from '@/utils/metadata';
import { convertRelativeToDate } from '@/utils/common';
2025-11-01 21:42:00 +00:00
import { m, eta, error } from '@/utils/html'
2024-12-03 20:29:03 +00:00
import redis from '@/utils/redis';
const app = new Elysia()
interface processedVideo {
id: string;
title: string;
thumbnail: string;
published: string;
deleted?: undefined;
}
app.get('/watch', async ({ query: { v }, set, redirect, error }) => {
if (!v) return error(404)
const cached = await redis.get(`watch:${v}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
2026-02-17 13:33:18 +00:00
const idMatch = v.match(/^([\w\-_]{11})(?:-(\d+))?$/)
if (!idMatch) return error(404)
2026-02-17 13:33:18 +00:00
const baseId = idMatch[1];
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const allowedVersionPattern = new RegExp(`^${escapeRegex(baseId)}(?:-\\d+)?$`);
const videoVersions = (await db.selectFrom('videos')
.selectAll()
2026-02-17 13:33:18 +00:00
.where('id', 'like', `${baseId}%`)
.execute())
.filter(video => allowedVersionPattern.test(video.id))
.sort((a, b) => {
const aVersion = Number(a.id.slice(baseId.length + 1) || 1);
const bVersion = Number(b.id.slice(baseId.length + 1) || 1);
return aVersion - bVersion;
});
const json = videoVersions.find(video => video.id === v) || videoVersions[0];
if (!json) {
const html = await m(eta.render('./watch', {
isMissing: true,
2026-02-17 13:33:18 +00:00
id: baseId,
title: 'Video Not Found | PreserveTube',
manualAnalytics: true
}))
set.headers['cache-control'] = 'public, no-cache'
set.headers['content-type'] = 'text/html; charset=utf-8'
return error(404, html)
}
2026-02-17 13:33:18 +00:00
if (json.disabled) return redirect(`/transparency/${json.id}`)
let transparency: any[] = []
if (json.hasBeenReported) {
transparency = await db.selectFrom('reports')
.selectAll()
2026-02-17 13:33:18 +00:00
.where('target', '=', json.id)
.execute()
}
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if (node.tagName === 'A') {
const disallowedPatterns: RegExp[] = [
/\/playlist/i,
/\/hashtag\//i,
/\/live\//i,
/\/user\//i,
/\/shorts\//i,
/\/c\//i,
/\/@[^\/]/i
];
const href = node.getAttribute('href') || '';
const shouldConvertToSpan = disallowedPatterns.some(pattern =>
pattern.test(href)
);
if (shouldConvertToSpan) {
const span = node.ownerDocument.createElement('span');
span.innerHTML = node.innerHTML;
if (node.className) span.className = node.className;
node.parentNode?.replaceChild(span, node);
} else {
node.setAttribute('rel', 'nofollow noopener noreferrer');
}
}
})
const html = await m(eta.render('./watch', {
transparency,
2026-02-17 13:33:18 +00:00
versions: videoVersions.map(video => ({
id: video.id,
archived: video.archived
})),
baseId,
...json,
description: DOMPurify.sanitize(json.description),
title: `${json.title} | PreserveTube`,
v_title: json.title,
keywords: `${json.title} video archive, ${json.title} ${json.channel} archive`,
manualAnalytics: true
}))
await redis.set(`watch:${v}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
2026-02-18 22:16:42 +00:00
function hasIdentifyingUA(req: Request): boolean {
const ua = req.headers.get('user-agent') ?? '';
return /^[^\s]+\/[\d.]+ \([^)]+\)$/.test(ua);
}
app.get('/video/:id', async ({ request, params: { id }, error }) => {
const isNice = hasIdentifyingUA(request)
if (!isNice) return error(403, `This endpoint is provided for people that prefer not to scrape, and depends on your honesty.
Please identify yourself with a User-Agent in the format: AppName/1.0 (a way for me to contact you, or a brief explanation of what you're doing).`)
2026-02-18 22:16:42 +00:00
const cached = await redis.get(`video:${id}`)
if (cached) return JSON.parse(cached)
const json = await db.selectFrom('videos')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
if (!json) return error(404, { error: '404' })
await redis.set(`video:${id}`, JSON.stringify(json), 'EX', 3600)
return {
...json,
description: DOMPurify.sanitize(json.description),
}
})
app.get('/channel/:id', async ({ params: { id }, set }) => {
const cached = await redis.get(`channel:${id}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const [videos, channel] = await Promise.all([
getChannelVideos(id),
getChannel(id)
])
if (!videos || !channel || videos.error || channel.error) {
const html = await m(eta.render('./channel', {
failedToFetch: true,
id
}))
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
}
const archived = await db.selectFrom('videos')
.select(['id', 'title', 'thumbnail', 'published', 'archived'])
.where('channelId', '=', id)
.execute()
const processedVideos: processedVideo[] = videos.map((video: any) => ({ // it would be impossible to set types for youtube output... they change it every day.
2025-03-21 14:04:45 +00:00
id: video.video_id,
title: video.title.text,
thumbnail: video.thumbnails[0].url,
published: video.upcoming?.slice(0, 10) || (video.published.text.endsWith('ago') ? convertRelativeToDate(video.published.text) : new Date(video.published.text)).toISOString().slice(0, 10)
}))
archived.forEach(v => {
const existingVideoIndex = processedVideos.findIndex(video => video.id === v.id);
if (existingVideoIndex !== -1) {
processedVideos[existingVideoIndex] = v;
} else {
processedVideos.push({ ...v, deleted: undefined });
}
});
processedVideos.sort((a: any, b: any) => new Date(b.published).getTime() - new Date(a.published).getTime());
const html = await m(eta.render('./channel', {
name: channel.metadata.title,
avatar: channel.metadata.avatar[0].url,
verified: channel.header.author?.is_verified,
videos: processedVideos,
title: `${channel.metadata.title} | PreserveTube`,
keywords: `${channel.metadata.title} archive, ${channel.metadata.title} channel archive, ${channel.metadata.title} deleted video, ${channel.metadata.title} video deleted`
}))
await redis.set(`channel:${id}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
app.get('/channel/:id/videos', async ({ params: { id }, set }) => {
const cached = await redis.get(`channelVideos:${id}:html`)
if (cached) {
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return cached
}
const archived = await db.selectFrom('videos')
.select(['id', 'title', 'thumbnail', 'published', 'archived'])
.where('channelId', '=', id)
.orderBy('published desc')
.execute()
const html = await m(eta.render('./channel-videos', {
videos: archived,
title: `${id} videos | PreserveTube`,
keywords: `${id} archive, ${id} channel archive, ${id} deleted video, ${id} video deleted`
}))
await redis.set(`channelVideos:${id}:html`, html, 'EX', 3600)
set.headers['Content-Type'] = 'text/html; charset=utf-8'
return html
})
2025-11-01 21:42:00 +00:00
app.onError(error)
2026-02-17 13:33:18 +00:00
export default app