2024-12-03 19:33:43 +00:00
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' ;
2024-12-03 19:33:43 +00:00
const app = new Elysia ( )
interface processedVideo {
id : string ;
title : string ;
thumbnail : string ;
published : string ;
deleted? : undefined ;
}
2025-10-14 18:53:40 +00:00
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 )
2025-10-14 18:53:40 +00:00
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' )
2025-10-14 18:53:40 +00:00
. 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 ] ;
2025-10-14 18:53:40 +00:00
if ( ! json ) {
const html = await m ( eta . render ( './watch' , {
isMissing : true ,
2026-02-17 13:33:18 +00:00
id : baseId ,
2025-11-01 21:13:41 +00:00
title : 'Video Not Found | PreserveTube' ,
manualAnalytics : true
2025-10-14 18:53:40 +00:00
} ) )
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 } ` )
2025-10-14 18:53:40 +00:00
let transparency : any [ ] = [ ]
if ( json . hasBeenReported ) {
transparency = await db . selectFrom ( 'reports' )
. selectAll ( )
2026-02-17 13:33:18 +00:00
. where ( 'target' , '=' , json . id )
2025-10-14 18:53:40 +00:00
. execute ( )
}
2026-01-24 15:35:46 +00:00
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' ) ;
}
}
} )
2025-10-14 18:53:40 +00:00
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 ,
2025-10-14 18:53:40 +00:00
. . . 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 )
2026-02-18 22:18:04 +00:00
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
2024-12-03 19:33:43 +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 ) ,
}
} )
2025-10-14 18:53:40 +00:00
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
}
2024-12-03 19:33:43 +00:00
const [ videos , channel ] = await Promise . all ( [
getChannelVideos ( id ) ,
getChannel ( id )
] )
2025-10-14 18:53:40 +00:00
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
}
2024-12-03 19:33:43 +00:00
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 ,
2024-12-03 19:33:43 +00:00
title : video.title.text ,
thumbnail : video.thumbnails [ 0 ] . url ,
2025-08-16 10:24:26 +00:00
published : video.upcoming?.slice ( 0 , 10 ) || ( video . published . text . endsWith ( 'ago' ) ? convertRelativeToDate ( video . published . text ) : new Date ( video . published . text ) ) . toISOString ( ) . slice ( 0 , 10 )
2024-12-03 19:33:43 +00:00
} ) )
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 ( ) ) ;
2025-10-14 18:53:40 +00:00
const html = await m ( eta . render ( './channel' , {
2024-12-03 19:33:43 +00:00
name : channel.metadata.title ,
avatar : channel.metadata.avatar [ 0 ] . url ,
verified : channel.header.author?.is_verified ,
2025-10-14 18:53:40 +00:00
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
2024-12-03 19:33:43 +00:00
} )
2025-10-14 18:53:40 +00:00
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
}
2024-12-03 19:33:43 +00:00
const archived = await db . selectFrom ( 'videos' )
. select ( [ 'id' , 'title' , 'thumbnail' , 'published' , 'archived' ] )
. where ( 'channelId' , '=' , id )
. orderBy ( 'published desc' )
. execute ( )
2025-10-14 18:53:40 +00:00
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
2024-12-03 19:33:43 +00:00
} )
2025-11-01 21:42:00 +00:00
app . onError ( error )
2026-02-17 13:33:18 +00:00
export default app