diff --git a/bun.lockb b/bun.lockb
index f959ebb..b169c15 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 6a259f0..b97df07 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,13 @@
"typescript": "^5.0.0"
},
"dependencies": {
+ "@elysiajs/static": "^1.4.0",
+ "@types/html-minifier-next": "^2.1.0",
"@types/pg": "^8.11.10",
"date-fns": "^4.1.0",
"elysia": "^1.1.25",
+ "eta": "^4.0.1",
+ "html-minifier-next": "^2.1.4",
"ioredis": "^5.4.1",
"isomorphic-dompurify": "^2.18.0",
"kysely": "^0.27.4",
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..d32a04d
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/pgp.txt b/public/pgp.txt
new file mode 100644
index 0000000..8aa8d90
--- /dev/null
+++ b/public/pgp.txt
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+cw3z0oKcZ3Irwfu2LQ==
+=5zdM
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..b2cffa8
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,41 @@
+User-agent: *
+Disallow: /playlist
+Disallow: /hashtag/*
+Disallow: /live/*
+Disallow: /user/*
+Disallow: /shorts/*
+Disallow: /c/*
+Disallow: /@*
+
+# seo backlink bots are a waste of bandwidth
+User-agent: dotbot
+Disallow: /
+
+User-agent: BLEXBot
+Disallow: /
+
+User-agent: SemrushBot
+Disallow: /
+
+User-agent: AhrefsBot
+Disallow: /
+
+User-agent: barkrowler
+Disallow: /
+
+User-agent: VelenPublicWebCrawler
+Disallow: /
+
+User-agent: AwarioRssBot
+Disallow: /
+
+User-agent: AwarioSmartBot
+Disallow: /
+
+User-agent: PetalBot
+Disallow: /
+
+User-Agent: ImagesiftBot
+Disallow: /
+
+Sitemap: https://api.preservetube.com/sitemap.xml
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index dc96d78..ceb92d4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,10 +1,12 @@
import { Elysia } from 'elysia';
+import { staticPlugin } from '@elysiajs/static'
import latest from '@/router/latest'
import search from '@/router/search'
import transparency from '@/router/transparency'
import video from '@/router/video'
import websocket from '@/router/websocket'
+import html from '@/router/html'
const app = new Elysia()
app.use(latest)
@@ -12,11 +14,16 @@ app.use(search)
app.use(transparency)
app.use(video)
app.use(websocket)
+app.use(html)
+app.onRequest(({ set, path }: any) => {
+ set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion' + path
+})
process.on('uncaughtException', err => {
console.log(err)
})
+app.use(staticPlugin({ prefix: '/' }))
app.listen(1337);
console.log(
`api is running at ${app.server?.hostname}:${app.server?.port}`
diff --git a/src/router/html.ts b/src/router/html.ts
new file mode 100644
index 0000000..a0c778e
--- /dev/null
+++ b/src/router/html.ts
@@ -0,0 +1,68 @@
+import { Elysia } from 'elysia';
+import { m, eta } from '@/utils/html'
+const app = new Elysia()
+
+app.get('/', async ({ set }) => {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./index', {
+ title: 'Home | PreserveTube',
+ description: 'PreserveTube is a time capsule for YouTube videos! It allows you to preserve any YouTube video, creating a snapshot that will always be available even if the original video disappears or is taken down.',
+ keywords: 'youtube archive, youtube video history, youtube deleted, youtube deleted video, youtube downloader, youtube archiver'
+ }))
+})
+
+app.get('/save', async ({ query: { url }, set, error }) => {
+ if (!url) return error(400, 'No url provided.')
+
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./save', {
+ title: 'Save Video | PreserveTube',
+ websocket: process.env.WEBSOCKET,
+ sitekey: process.env.SITEKEY,
+ url
+ }))
+})
+
+app.get('/savechannel', async ({ query: { url }, set, error }) => {
+ if (!url) return error(400, 'No url provided.')
+
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./savechannel', {
+ title: 'Save Channel | PreserveTube',
+ websocket: process.env.WEBSOCKET,
+ sitekey: process.env.SITEKEY,
+ url
+ }))
+})
+
+app.get('/abuse', async ({ set }) => {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./abuse', {
+ title: 'Abuse Report | PreserveTube',
+ }))
+})
+
+app.get('/dmca', async ({ set }) => {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./dmca', {
+ title: 'DMCA Takedown | PreserveTube',
+ }))
+})
+
+app.get('/privacy', async ({ set }) => {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./privacy', {
+ title: 'Privacy Policy | PreserveTube',
+ }))
+})
+
+app.get('/donate', async ({ set }) => {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return await m(eta.render('./donate', {
+ title: 'Donations | PreserveTube',
+ description: "Support our mission through donations.",
+ keywords: "preservetube donations, crypto"
+ }))
+})
+
+export default app;
\ No newline at end of file
diff --git a/src/router/latest.ts b/src/router/latest.ts
index 3f71e4e..73e08de 100644
--- a/src/router/latest.ts
+++ b/src/router/latest.ts
@@ -1,24 +1,32 @@
import { Elysia } from 'elysia';
-
import { db } from '@/utils/database'
import { createSitemapXML, createSitemapIndexXML } from '@/utils/sitemap'
+import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
-app.get('/latest', async () => {
- const cached = await redis.get('latest')
- if (cached) return JSON.parse(cached)
+app.get('/latest', async ({ set }) => {
+ const cached = await redis.get('latest:html')
+ if (cached) {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return cached
+ }
const json = await db.selectFrom('videos')
.select(['id', 'title', 'thumbnail', 'published', 'archived', 'channel', 'channelId', 'channelAvatar', 'channelVerified'])
.orderBy('archived desc')
- .limit(50)
+ .limit(51)
.execute()
+
+ const html = await m(eta.render('./latest', {
+ data: json,
+ title: 'Latest | PreserveTube',
+ }))
+ await redis.set('latest:html', html, 'EX', 3600)
- await redis.set('latest', JSON.stringify(json), 'EX', 3600)
-
- return json
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return html
})
app.get('/sitemap-index.xml', async ({ set }) => {
diff --git a/src/router/search.ts b/src/router/search.ts
index 35bc72c..d85f1ae 100644
--- a/src/router/search.ts
+++ b/src/router/search.ts
@@ -3,6 +3,7 @@ import { RedisRateLimiter } from 'rolling-rate-limiter'
import { db } from '@/utils/database'
import { validateVideo, validateChannel } from '@/utils/regex'
+import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
@@ -14,20 +15,33 @@ const limiter = new RedisRateLimiter({
maxInInterval: 15
})
-app.get('/search/video', async ({ headers, query: { search }, error }) => {
+app.get('/search', async ({ headers, query: { search }, set, redirect, error }) => {
const hash = Bun.hash(headers['x-userip'] || headers['cf-connecting-ip'] || '0.0.0.0')
const isLimited = await limiter.limit(hash.toString())
- if (isLimited) return error(429, 'error-You have been ratelimited.')
+ if (isLimited) return error(429, 'You have been ratelimited.')
const videoId = validateVideo(search)
- if (videoId) return `redirect-${process.env.FRONTEND}/watch?v=${videoId}`
+ if (videoId) return redirect(`/watch?v=${videoId}`)
+
+ const cached = await redis.get(`search:${Bun.hash(search).toString()}:html`)
+ if (cached) {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return cached
+ }
const videos = await db.selectFrom('videos')
.selectAll()
.where('title', 'ilike', `%${search}%`)
.execute()
- return videos
+ const html = await m(eta.render('./search', {
+ data: videos,
+ title: 'Search | PreserveTube',
+ }))
+ await redis.set(`search:${Bun.hash(search).toString()}:html`, html, 'EX', 3600)
+
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return html
}, {
query: t.Object({
search: t.String()
diff --git a/src/router/transparency.ts b/src/router/transparency.ts
index fa6a5c0..974b57c 100644
--- a/src/router/transparency.ts
+++ b/src/router/transparency.ts
@@ -1,41 +1,55 @@
import { Elysia } from 'elysia';
import { db } from '@/utils/database'
+import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
-app.get('/transparency/list', async () => {
- const cached = await redis.get('transparency')
- if (cached) return JSON.parse(cached)
+app.get('/transparency', async ({ set }) => {
+ const cached = await redis.get('transparency:html')
+ if (cached) {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return cached
+ }
const reports = await db.selectFrom('reports')
.selectAll()
.execute()
- const json = reports.map(r => {
- return {
- ...r,
- details: (r.details).split('<').join('<').split('>').join('>'),
- date: (r.date).toISOString().slice(0, 10)
- }
- })
-
- await redis.set('transparency', JSON.stringify(json), 'EX', 3600)
- return json
+ const html = await m(eta.render('./transparency', {
+ data: reports,
+ title: 'Transparency | PreserveTube',
+ }))
+ await redis.set('transparency:html', html, 'EX', 3600)
+
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return html
})
-app.get('/transparency/:id', async ({ params: { id } }) => {
- const cached = await redis.get(`transparency:${id}`)
- if (cached) return JSON.parse(cached)
+app.get('/transparency/:id', async ({ params: { id }, set, error }) => {
+ const cached = await redis.get(`transparency:${id}:html`)
+ if (cached) {
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return cached
+ }
const json = await db.selectFrom('reports')
.selectAll()
.where('target', '=', id)
- .execute()
+ .executeTakeFirst()
+ if (!json) return error(404, 'Report not found.')
+
+ const html = await m(eta.render('./transparency-entry', {
+ title: `${json.title} | PreserveTube`,
+ t_title: json.title,
+ date: json.date.toISOString(),
+ details: json.details,
+ }))
+ await redis.set(`transparency:${id}:html`, html, 'EX', 3600)
- await redis.set(`transparency:${id}`, JSON.stringify(json), 'EX', 3600)
- return json
+ set.headers['Content-Type'] = 'text/html; charset=utf-8'
+ return html
})
export default app
\ No newline at end of file
diff --git a/src/router/video.ts b/src/router/video.ts
index e2b9c0f..b87a282 100644
--- a/src/router/video.ts
+++ b/src/router/video.ts
@@ -4,6 +4,7 @@ import DOMPurify from 'isomorphic-dompurify'
import { db } from '@/utils/database'
import { getChannel, getChannelVideos } from '@/utils/metadata';
import { convertRelativeToDate } from '@/utils/common';
+import { m, eta } from '@/utils/html'
import redis from '@/utils/redis';
const app = new Elysia()
@@ -16,6 +17,58 @@ interface processedVideo {
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
+ }
+
+ if (!v.match(/[\w\-_]{11}/)) return error(404)
+
+ const json = await db.selectFrom('videos')
+ .selectAll()
+ .where('id', '=', v)
+ .executeTakeFirst()
+
+ if (!json) {
+ const html = await m(eta.render('./watch', {
+ isMissing: true,
+ id: v,
+ title: 'Video Not Found | PreserveTube'
+ }))
+
+ set.headers['cache-control'] = 'public, no-cache'
+ set.headers['content-type'] = 'text/html; charset=utf-8'
+ return error(404, html)
+ }
+ if (json.disabled) return redirect(`/transparency/${v}`)
+
+ let transparency: any[] = []
+ if (json.hasBeenReported) {
+ transparency = await db.selectFrom('reports')
+ .selectAll()
+ .where('target', '=', v)
+ .execute()
+ }
+
+ const html = await m(eta.render('./watch', {
+ transparency,
+ ...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
+})
+
app.get('/video/:id', async ({ params: { id }, error }) => {
const cached = await redis.get(`video:${id}`)
if (cached) return JSON.parse(cached)
@@ -34,16 +87,26 @@ app.get('/video/:id', async ({ params: { id }, error }) => {
}
})
-app.get('/channel/:id', async ({ params: { id }, error }) => {
- const cached = await redis.get(`channel:${id}`)
- if (cached) return JSON.parse(cached)
+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) return error(404, { error: '404' })
+ 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'])
@@ -68,20 +131,26 @@ app.get('/channel/:id', async ({ params: { id }, error }) => {
processedVideos.sort((a: any, b: any) => new Date(b.published).getTime() - new Date(a.published).getTime());
- const json = {
+ 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
- }
-
- await redis.set(`channel:${id}`, JSON.stringify(json), 'EX', 3600)
- return json
+ 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 } }) => {
- const cached = await redis.get(`channelVideos:${id}`)
- if (cached) return JSON.parse(cached)
+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'])
@@ -89,11 +158,15 @@ app.get('/channel/:id/videos', async ({ params: { id } }) => {
.orderBy('published desc')
.execute()
- const json = {
- videos: archived
- }
- await redis.set(`channelVideos:${id}`, JSON.stringify(json), 'EX', 3600)
- return json
+ 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
})
export default app
\ No newline at end of file
diff --git a/src/templates/abuse.eta b/src/templates/abuse.eta
new file mode 100644
index 0000000..afed0ce
--- /dev/null
+++ b/src/templates/abuse.eta
@@ -0,0 +1,67 @@
+<% layout('./layout') %>
+
+
+
+ When it comes to reports of child abuse, PLEASE report it to
NCMEC or your local law enforcement agency, who will then contact us. PreserveTube is not legally equipped to handle such reports.
+
+
+ Our top priority at PreserveTube is to provide a safe and respectful environment for all users of our service. If you come across any content on our website that you believe is inappropriate or violates our community guidelines, we urge you to report it immediately so we can take appropriate action.
+
+ To file an abuse report, please follow these steps:
+
+
+ Identify the content that you believe is inappropriate or in violation of our community guidelines.
+ Take a screenshot or copy the URL of the content for reference.
+ Send an email to abuse@preservetube.com with the subject line "Abuse Report".
+ In the email, please provide the following information:
+
+ Your full name and contact information.
+ A detailed description of the content and why you believe it is inappropriate or in violation of our community guidelines.
+ The screenshot or URL of the content for reference.
+
+
+
+
+ At Preservetube, we take a
Law of the Land approach to content moderation. This means that we do not allow any content that is illegal or violates the laws of either The Netherlands or The United States of America.
+
+
+
+
+
+
diff --git a/src/templates/channel-videos.eta b/src/templates/channel-videos.eta
new file mode 100644
index 0000000..1e32330
--- /dev/null
+++ b/src/templates/channel-videos.eta
@@ -0,0 +1,76 @@
+<% layout('./layout') %>
+
+
+ <% it.videos.forEach(function(v){ %>
+
+ <% }) %>
+
+
+
\ No newline at end of file
diff --git a/src/templates/channel.eta b/src/templates/channel.eta
new file mode 100644
index 0000000..bd42f89
--- /dev/null
+++ b/src/templates/channel.eta
@@ -0,0 +1,92 @@
+<% layout('./layout') %>
+
+<% if (it.failedToFetch) { %>
+
+
+ An error occurred while attempting to retrieve this channel from YouTube.
+ To access all videos without relying on YouTube data, please click here .
+
+
+<% } else { %>
+
+
+
<%= it.name %>
+
+
+
+ <% it.videos.forEach(function(v){ %>
+
+ <% }) %>
+
+<% } %>
+
+
\ No newline at end of file
diff --git a/src/templates/dmca.eta b/src/templates/dmca.eta
new file mode 100644
index 0000000..4b4940b
--- /dev/null
+++ b/src/templates/dmca.eta
@@ -0,0 +1,51 @@
+<% layout('./layout') %>
+
+
+ To submit a DMCA takedown notice, please send an email to
legal@preservetube.com that includes the following information:
+
+ Identification of the copyrighted work that you claim has been infringed, or if multiple copyrighted works are covered by a single notification, provide a representative list of such works.
+ Identification of the material that you claim is infringing and information reasonably sufficient to permit us to locate the material, including the URL(s) where the infringing material is located on PreserveTube.
+ Your contact information, including your full legal name, mailing address, telephone number, and email address.
+ A statement that you have a good faith belief that the use of the copyrighted materials described above and contained on the service is not authorized by the copyright owner, its agent, or by law.
+ A statement that you hereby declare, under penalty of perjury, that the information in your notification is accurate and that you are the owner of the copyright or other intellectual property interest, or authorized to act on behalf of the owner of the copyright or other intellectual property interest.
+ An electronic or physical signature of the person authorized to act on behalf of the owner of the copyright or other intellectual property interest.
+
+
+ If we receive a valid DMCA takedown notice, we will add a notice to the video page indicating that the content has been stricken with a copyright claim. This notice will include the name of the person or entity that sent the copyright notice and the email logs associated with the notice. If the video is obviously copyrighted content, such as a movie or TV show, it will be taken down. Any other videos will remain online.
+ Please note that submitting a false or misleading DMCA takedown notice may result in legal consequences. If you are unsure whether your copyrighted material has been infringed or if you need legal advice, we recommend that you consult with an attorney.
+ P.S.: A DMCA takedown notice will not remove embarrassing videos that Preservetube has archived from the internet. We will ensure that the videos remain accessible to the public, even if that's not possible via our website.
+
+ If you have any questions about this DMCA policy, please contact
legal@preservetube.com .
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/donate.eta b/src/templates/donate.eta
new file mode 100644
index 0000000..e976c72
--- /dev/null
+++ b/src/templates/donate.eta
@@ -0,0 +1,49 @@
+<% layout('./layout') %>
+
+
+ Infrastructure costs money, and running PreserveTube costs $1,185 a year. If you love what we do, a little support would go a long way in keeping us safe and sound!
+
+
FIAT Donations
+
+ Unfortunately, PreserveTube only accepts cryptocurrency donations at this time.
+ Feeling extra generous? You can learn how to purchase Bitcoin here: buybitcoinworldwide.com .
+
+
+
Cryptocurrency Donations
+
We accept the following cryptocurrencies to support our cause:
+
+ BTC : bc1qfa6uqp72a2hgg3ac06f2du2cvug4hadufd7sql
+ ETH : 0xF6923d9e17B32efF1D82200E82E3b9f500c6fAED
+ LTC : LW7qhYnAfjqieNdySNeURhrpCzcCjcuj7U
+ SOL : GK5WTtJeZRuJheSbm2ZFYWHQ3QfZPgLaZ1Vq5EXi3e91
+ XMR : 8BqeuL5iRzXccqc8wy8U2RgJF7RdTX52Pgpdc6QYcrHfLnoK9jtQ3MrDv7V6T14hHjMHfE28wsVqhCGiCeXsRF4651SnGNC
+ Or anything else:
+
+
+
+
+
diff --git a/src/templates/index.eta b/src/templates/index.eta
new file mode 100644
index 0000000..29b32a5
--- /dev/null
+++ b/src/templates/index.eta
@@ -0,0 +1,225 @@
+<% layout('./layout') %>
+
+
+
+
+
+
+
+
+
+
+
+ PreserveTube is a time capsule for YouTube videos! It allows you to
+ "preserve" any YouTube video, creating a snapshot that will always
+ be available even if the original video disappears or is taken down.
+ It saves not only the original video, but also descriptions, titles and
+ other interesting metadata.
+
+ PreserveTube is dedicated to preserving the legacy of YouTube, one
+ video at a time.
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/latest.eta b/src/templates/latest.eta
new file mode 100644
index 0000000..4fcff94
--- /dev/null
+++ b/src/templates/latest.eta
@@ -0,0 +1,80 @@
+<% layout('./layout') %>
+
+
+ <% it.data.forEach(function(v){ %>
+
+ <% }) %>
+
+
+
diff --git a/src/templates/layout.eta b/src/templates/layout.eta
new file mode 100644
index 0000000..e7cf017
--- /dev/null
+++ b/src/templates/layout.eta
@@ -0,0 +1,144 @@
+
+
+
+
+
+ <% if (it.keywords) { %>
+
+ <% } %>
+
+
+
+ <% if (it.description) { %>
+
+ <% } else { %>
+
+ <% } %>
+
+
+
+
+
+
+ <% if (it.description) { %>
+
+ <% } else { %>
+
+ <% } %>
+
+
+
+ <% if (it.manualAnalytics) { %>
+
+
+
+ <% } else { %>
+
+ <% } %>
+
+ <%= it.title %>
+
+
+
+
+
PreserveTube is dedicated to preserving internet history, and we need your help! Every donation, big or small, makes a difference.
+
Donate now.
+
+
+ <%~ it.body %>
+
+
+ page rendered by <%= hostname %>
+
+
+
+
+
diff --git a/src/templates/privacy.eta b/src/templates/privacy.eta
new file mode 100644
index 0000000..19dbcab
--- /dev/null
+++ b/src/templates/privacy.eta
@@ -0,0 +1,61 @@
+<% layout('./layout') %>
+
+
+
+ Last updated: 2025-08-16
+
+
+
+
Anonymous Analytics
+ PreserveTube uses
Plausible to get an aproximate number of active users, and the most watched videos.
+ No identifiable information, such as IPs, is stored. This Plausible instance is selfhosted, and not proxied through Cloudflare.
+
+
+
Reverse Proxying
+ We use
Caddy to reverse proxy our services. On certain ocassions, such as when errors occur in requests,
+ Caddy might log these. These logs include headers, client IPs, and paths. Otherwise, our Caddy setup does not log any requests.
+ Logs are purged automatically once the file size reaches 50MB.
+
+
+
DDoS Protection
+ We use a combination of Cloudflare (preservetube.com), BasedFlare (preservetube.net), and selfhosted solutions (preservetube.org) for our DDoS protection. We additionally use Cloudflare Turnstile as a captcha
+ to avoid automated bot traffic on the /save page.
+ Cloudflare's privacy policy can be found here:
https://www.cloudflare.com/en-gb/privacypolicy/ .
+ BasedFlare has no privacy policy. It should be assumed everything is logged. Avoid visiting preservetube.net if you're uncomfortable with this.
+
+
+
DNS
+ Besides Cloudflare and BasedFlare, PreserveTube also relies on ClouDNS for DNS services. ClouDNS's privacy policy can be found here:
https://www.cloudns.net/privacy-policy/
+
+
+
Video Saving
+ Saving videos is completly anonymous. We do not log who saved a video.
+
+
+
Questions?
+ I'm reachable at
admin@preservetube.com (
🔑 ).
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/save.eta b/src/templates/save.eta
new file mode 100644
index 0000000..c8eab24
--- /dev/null
+++ b/src/templates/save.eta
@@ -0,0 +1,57 @@
+<% layout('./layout') %>
+
+
+
+
+
+ This feature doesen't function properly unless JavaScript is enabled.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/savechannel.eta b/src/templates/savechannel.eta
new file mode 100644
index 0000000..997b100
--- /dev/null
+++ b/src/templates/savechannel.eta
@@ -0,0 +1,57 @@
+<% layout('./layout') %>
+
+
+
+
+
+ This feature doesen't function properly unless JavaScript is enabled.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/search.eta b/src/templates/search.eta
new file mode 100644
index 0000000..25d0f10
--- /dev/null
+++ b/src/templates/search.eta
@@ -0,0 +1,74 @@
+<% layout('./layout') %>
+
+
+ <% it.data.forEach(function(v){ %>
+
+ <% }) %>
+
+
+
\ No newline at end of file
diff --git a/src/templates/transparency-entry.eta b/src/templates/transparency-entry.eta
new file mode 100644
index 0000000..b3a6f47
--- /dev/null
+++ b/src/templates/transparency-entry.eta
@@ -0,0 +1,46 @@
+<% layout('./layout') %>
+
+
+
+
diff --git a/src/templates/transparency.eta b/src/templates/transparency.eta
new file mode 100644
index 0000000..c8bfabf
--- /dev/null
+++ b/src/templates/transparency.eta
@@ -0,0 +1,21 @@
+<% layout('./layout') %>
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/watch.eta b/src/templates/watch.eta
new file mode 100644
index 0000000..c8c50b2
--- /dev/null
+++ b/src/templates/watch.eta
@@ -0,0 +1,268 @@
+<% layout('./layout') %>
+
+<% if (it.isMissing) { %>
+
+
Archive not found
+
+ Archive Me!
+
+
+<% } else { %>
+
+
+
+
+ <% if (it.transparency.length != 0) { %>
+
+
Somebody has complained about this video...
+ <% it.transparency.forEach(function(t){ %>
+
<%= t.title %>
+ <% }) %>
+
+ <% } %>
+
+
+
<%= it.v_title %>
+
+
+
+
+
+
+<% } %>
+
+
+
\ No newline at end of file
diff --git a/src/utils/html.ts b/src/utils/html.ts
new file mode 100644
index 0000000..c00f301
--- /dev/null
+++ b/src/utils/html.ts
@@ -0,0 +1,13 @@
+import { minify } from 'html-minifier-next';
+import { Eta } from 'eta';
+import path from 'path';
+
+export const m = async (html: string) => {
+ return await minify(html, {
+ collapseWhitespace: true,
+ removeComments: true,
+ minifyCSS: true
+ })
+}
+
+export const eta = new Eta({ views: path.join(__dirname, '../templates'), functionHeader: `const hostname = "${process.env.SERVER_NICKNAME}"` })
\ No newline at end of file