also add cookie/localStorage based ratelimited to avoid (stupid) free
vpn abusers
This commit is contained in:
parent
5b5e1c7a10
commit
9baa12abf0
|
|
@ -2,6 +2,11 @@ import { Elysia } from 'elysia';
|
||||||
import { m, eta, error } from '@/utils/html'
|
import { m, eta, error } from '@/utils/html'
|
||||||
import healthStatus from '@/utils/health';
|
import healthStatus from '@/utils/health';
|
||||||
import { checkIpRanges } from '@/utils/ranges';
|
import { checkIpRanges } from '@/utils/ranges';
|
||||||
|
import {
|
||||||
|
buildRateLimitCookie,
|
||||||
|
createRateLimitCookieValue,
|
||||||
|
getRateLimitCookie,
|
||||||
|
} from '@/utils/rate-limit';
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
|
||||||
app.get('/', async ({ set }) => {
|
app.get('/', async ({ set }) => {
|
||||||
|
|
@ -29,15 +34,20 @@ app.get('/save', async ({ query: { url }, set, headers, error }) => {
|
||||||
websocket = process.env.ALTERNATIVE_WEBSOCKET!
|
websocket = process.env.ALTERNATIVE_WEBSOCKET!
|
||||||
}
|
}
|
||||||
const ytPatched = (process.env.YT_PATCHED || '').toLowerCase() === 'true' || process.env.YT_PATCHED === '1'
|
const ytPatched = (process.env.YT_PATCHED || '').toLowerCase() === 'true' || process.env.YT_PATCHED === '1'
|
||||||
|
const rateLimitId = getRateLimitCookie(headers.cookie) || createRateLimitCookieValue()
|
||||||
|
if (!getRateLimitCookie(headers.cookie)) {
|
||||||
|
set.headers['Set-Cookie'] = buildRateLimitCookie(rateLimitId)
|
||||||
|
}
|
||||||
|
|
||||||
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
||||||
return error(412, await m(eta.render('./save', {
|
return await m(eta.render('./save', {
|
||||||
title: 'Save Video | PreserveTube',
|
title: 'Save Video | PreserveTube',
|
||||||
websocket,
|
websocket,
|
||||||
sitekey: process.env.SITEKEY,
|
sitekey: process.env.SITEKEY,
|
||||||
url,
|
url,
|
||||||
ytPatched
|
ytPatched,
|
||||||
})))
|
rateLimitId
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/savechannel', async ({ query: { url }, set, headers, error }) => {
|
app.get('/savechannel', async ({ query: { url }, set, headers, error }) => {
|
||||||
|
|
@ -46,22 +56,27 @@ app.get('/savechannel', async ({ query: { url }, set, headers, error }) => {
|
||||||
const ranges = await checkIpRanges(headers['cf-connecting-ip'] || headers['x-forwarded-for'] || '')
|
const ranges = await checkIpRanges(headers['cf-connecting-ip'] || headers['x-forwarded-for'] || '')
|
||||||
if (ranges.blocked) {
|
if (ranges.blocked) {
|
||||||
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
||||||
return await m(eta.render('./blocked', {
|
return error(412, await m(eta.render('./blocked', {
|
||||||
title: 'Blocked | PreserveTube'
|
title: 'Blocked | PreserveTube'
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
let websocket = process.env.WEBSOCKET
|
let websocket = process.env.WEBSOCKET
|
||||||
if (healthStatus[process.env.METADATA!] != 'healthy') {
|
if (healthStatus[process.env.METADATA!] != 'healthy') {
|
||||||
websocket = process.env.ALTERNATIVE_WEBSOCKET!
|
websocket = process.env.ALTERNATIVE_WEBSOCKET!
|
||||||
}
|
}
|
||||||
|
const rateLimitId = getRateLimitCookie(headers.cookie) || createRateLimitCookieValue()
|
||||||
|
if (!getRateLimitCookie(headers.cookie)) {
|
||||||
|
set.headers['Set-Cookie'] = buildRateLimitCookie(rateLimitId)
|
||||||
|
}
|
||||||
|
|
||||||
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
set.headers['Content-Type'] = 'text/html; charset=utf-8'
|
||||||
return await m(eta.render('./savechannel', {
|
return await m(eta.render('./savechannel', {
|
||||||
title: 'Save Channel | PreserveTube',
|
title: 'Save Channel | PreserveTube',
|
||||||
websocket,
|
websocket,
|
||||||
sitekey: process.env.SITEKEY,
|
sitekey: process.env.SITEKEY,
|
||||||
url
|
url,
|
||||||
|
rateLimitId
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { error } from '@/utils/html'
|
||||||
import redis from '@/utils/redis';
|
import redis from '@/utils/redis';
|
||||||
import { parseSlop } from '@/utils/slop';
|
import { parseSlop } from '@/utils/slop';
|
||||||
import { checkIpRanges } from '@/utils/ranges';
|
import { checkIpRanges } from '@/utils/ranges';
|
||||||
|
import { getRateLimitSubjects } from '@/utils/rate-limit';
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
const videoIds: Record<string, string> = {}
|
const videoIds: Record<string, string> = {}
|
||||||
|
|
@ -18,15 +19,19 @@ const videoIds: Record<string, string> = {}
|
||||||
const MB_LIMIT = 250
|
const MB_LIMIT = 250
|
||||||
const saveKey = (videoId: string) => `save:${videoId}`
|
const saveKey = (videoId: string) => `save:${videoId}`
|
||||||
|
|
||||||
const checkMbLimit = async (hash: string, mb?: number): Promise<boolean> => {
|
const checkMbLimit = async (subjects: string[], mb?: number): Promise<boolean> => {
|
||||||
const key = `save-mb:${hash}`
|
const keys = subjects.map(subject => `save-mb:${Bun.hash(subject).toString()}`)
|
||||||
const current = parseInt(await redis.get(key) || '0')
|
const currentValues = await Promise.all(keys.map(key => redis.get(key)))
|
||||||
if (!mb) return current >= MB_LIMIT
|
const currents = currentValues.map(value => parseInt(value || '0'))
|
||||||
if (current + mb > MB_LIMIT) return true
|
|
||||||
|
if (!mb) return currents.some(current => current >= MB_LIMIT)
|
||||||
|
if (currents.some(current => current + mb > MB_LIMIT)) return true
|
||||||
|
|
||||||
const pipeline = redis.pipeline()
|
const pipeline = redis.pipeline()
|
||||||
pipeline.incrby(key, mb)
|
for (const key of keys) {
|
||||||
pipeline.expire(key, 24 * 60 * 60)
|
pipeline.incrby(key, mb)
|
||||||
|
pipeline.expire(key, 24 * 60 * 60)
|
||||||
|
}
|
||||||
await pipeline.exec()
|
await pipeline.exec()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +94,8 @@ const getRateLimitKey = (ip: string): string => {
|
||||||
|
|
||||||
app.ws('/save', {
|
app.ws('/save', {
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
url: t.String()
|
url: t.String(),
|
||||||
|
rlid: t.Optional(t.String())
|
||||||
}),
|
}),
|
||||||
body: t.String(),
|
body: t.String(),
|
||||||
open: async (ws) => {
|
open: async (ws) => {
|
||||||
|
|
@ -115,13 +121,16 @@ app.ws('/save', {
|
||||||
ws.send(`DONE - ${process.env.FRONTEND}/watch?v=${videoId}`)
|
ws.send(`DONE - ${process.env.FRONTEND}/watch?v=${videoId}`)
|
||||||
ws.close()
|
ws.close()
|
||||||
} else {
|
} else {
|
||||||
const hash = Bun.hash(getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'))
|
const subjects = getRateLimitSubjects(
|
||||||
const isLimited = await checkMbLimit(hash.toString())
|
getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'),
|
||||||
|
ws.data.query.rlid
|
||||||
|
)
|
||||||
|
const isLimited = await checkMbLimit(subjects)
|
||||||
if (isLimited) {
|
if (isLimited) {
|
||||||
return sendError(ws, 'You have been ratelimited. </br>Is this an urgent archive? Please email me: admin@preservetube.com');
|
return sendError(ws, 'You have been ratelimited. </br>Is this an urgent archive? Please email me: admin@preservetube.com');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`saving (${hash}) - ${ws.data.path} - ${JSON.stringify(ws.data.query)}`)
|
console.log(`saving (${subjects.map(subject => Bun.hash(subject).toString()).join(',')}) - ${ws.data.path} - ${JSON.stringify(ws.data.query)}`)
|
||||||
ws.send('DATA - This process is automatic. Your video will start archiving shortly.')
|
ws.send('DATA - This process is automatic. Your video will start archiving shortly.')
|
||||||
ws.send('CAPTCHA - Solving a cryptographic challenge before downloading.')
|
ws.send('CAPTCHA - Solving a cryptographic challenge before downloading.')
|
||||||
videoIds[ws.id] = videoId
|
videoIds[ws.id] = videoId
|
||||||
|
|
@ -167,8 +176,11 @@ app.ws('/save', {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mbsUsed = Math.ceil(downloadResult.size / (1024 * 1024))
|
const mbsUsed = Math.ceil(downloadResult.size / (1024 * 1024))
|
||||||
const hash = Bun.hash(getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'))
|
const subjects = getRateLimitSubjects(
|
||||||
const isMbLimited = await checkMbLimit(hash.toString(), mbsUsed)
|
getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'),
|
||||||
|
ws.data.query.rlid
|
||||||
|
)
|
||||||
|
const isMbLimited = await checkMbLimit(subjects, mbsUsed)
|
||||||
if (isMbLimited) {
|
if (isMbLimited) {
|
||||||
const file = fs.readdirSync('./videos/').find(f => f.includes(`${videoId}.`))
|
const file = fs.readdirSync('./videos/').find(f => f.includes(`${videoId}.`))
|
||||||
if (file) fs.unlinkSync('./videos/' + file)
|
if (file) fs.unlinkSync('./videos/' + file)
|
||||||
|
|
@ -193,7 +205,8 @@ app.ws('/save', {
|
||||||
|
|
||||||
app.ws('/savechannel', {
|
app.ws('/savechannel', {
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
url: t.String()
|
url: t.String(),
|
||||||
|
rlid: t.Optional(t.String())
|
||||||
}),
|
}),
|
||||||
body: t.String(),
|
body: t.String(),
|
||||||
open: async (ws) => {
|
open: async (ws) => {
|
||||||
|
|
@ -246,14 +259,17 @@ app.ws('/savechannel', {
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (already) continue
|
if (already) continue
|
||||||
|
|
||||||
const hash = Bun.hash(getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'))
|
const subjects = getRateLimitSubjects(
|
||||||
const isLimited = await checkMbLimit(hash.toString())
|
getRateLimitKey(ws.data.headers['cf-connecting-ip'] || '0.0.0.0'),
|
||||||
|
ws.data.query.rlid
|
||||||
|
)
|
||||||
|
const isLimited = await checkMbLimit(subjects)
|
||||||
if (isLimited) {
|
if (isLimited) {
|
||||||
sendError(ws, 'You have been ratelimited. </br>Is this an urgent archive? Please email me: admin@preservetube.com', false);
|
sendError(ws, 'You have been ratelimited. </br>Is this an urgent archive? Please email me: admin@preservetube.com', false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`saving (${hash}) - ${ws.data.path} - ${video.video_id}`)
|
console.log(`saving (${subjects.map(subject => Bun.hash(subject).toString()).join(',')}) - ${ws.data.path} - ${video.video_id}`)
|
||||||
|
|
||||||
const isSlop = await parseSlop(video.video_id, video.title.text,
|
const isSlop = await parseSlop(video.video_id, video.title.text,
|
||||||
video.description_snippet?.text || '', channelId)
|
video.description_snippet?.text || '', channelId)
|
||||||
|
|
@ -271,7 +287,7 @@ app.ws('/savechannel', {
|
||||||
const downloadResult = await downloadVideo(ws, video.video_id);
|
const downloadResult = await downloadVideo(ws, video.video_id);
|
||||||
if (!downloadResult.fail) {
|
if (!downloadResult.fail) {
|
||||||
const mbsUsed = Math.ceil(downloadResult.size / (1024 * 1024))
|
const mbsUsed = Math.ceil(downloadResult.size / (1024 * 1024))
|
||||||
const isMbLimited = await checkMbLimit(hash.toString(), mbsUsed)
|
const isMbLimited = await checkMbLimit(subjects, mbsUsed)
|
||||||
if (isMbLimited) {
|
if (isMbLimited) {
|
||||||
const file = fs.readdirSync('./videos/').find(f => f.includes(`${video.video_id}.`))
|
const file = fs.readdirSync('./videos/').find(f => f.includes(`${video.video_id}.`))
|
||||||
if (file) fs.unlinkSync('./videos/' + file)
|
if (file) fs.unlinkSync('./videos/' + file)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
const url = "<%= it.url %>"
|
const url = "<%= it.url %>"
|
||||||
const websocket = "<%= it.websocket %>"
|
const websocket = "<%= it.websocket %>"
|
||||||
const sitekey = "<%= it.sitekey %>"
|
const sitekey = "<%= it.sitekey %>"
|
||||||
|
const initialRateLimitId = "<%= it.rateLimitId %>"
|
||||||
let ws = null
|
let ws = null
|
||||||
const h1 = document.getElementById("title")
|
const h1 = document.getElementById("title")
|
||||||
const h3 = document.getElementById("bottom")
|
const h3 = document.getElementById("bottom")
|
||||||
|
|
@ -53,8 +54,33 @@
|
||||||
const acceptInstructions = document.getElementById("accept-instructions")
|
const acceptInstructions = document.getElementById("accept-instructions")
|
||||||
const declineInstructions = document.getElementById("decline-instructions")
|
const declineInstructions = document.getElementById("decline-instructions")
|
||||||
|
|
||||||
|
function getRateLimitId() {
|
||||||
|
if (initialRateLimitId) {
|
||||||
|
return initialRateLimitId
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("pt_rlid")
|
||||||
|
if (stored) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialRateLimitId
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRateLimitId(rateLimitId) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("pt_rlid", rateLimitId)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startSaveFlow() {
|
function startSaveFlow() {
|
||||||
ws = new WebSocket(`${websocket}/save?url=${encodeURIComponent(url)}`)
|
const rateLimitId = getRateLimitId()
|
||||||
|
persistRateLimitId(rateLimitId)
|
||||||
|
ws = new WebSocket(`${websocket}/save?url=${encodeURIComponent(url)}&rlid=${encodeURIComponent(rateLimitId)}`)
|
||||||
|
|
||||||
ws.onopen = function () {
|
ws.onopen = function () {
|
||||||
document.querySelector("footer").innerHTML = `page rendered by <code><%= hostname %></code>, connected via <code>${websocket.split('-')[1].split('.')[0]}</code>`
|
document.querySelector("footer").innerHTML = `page rendered by <code><%= hostname %></code>, connected via <code>${websocket.split('-')[1].split('.')[0]}</code>`
|
||||||
|
|
@ -145,6 +171,7 @@
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
runFirstSaveGate()
|
runFirstSaveGate()
|
||||||
|
persistRateLimitId(initialRateLimitId)
|
||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,27 @@
|
||||||
const url = "<%= it.url %>"
|
const url = "<%= it.url %>"
|
||||||
const websocket = "<%= it.websocket %>"
|
const websocket = "<%= it.websocket %>"
|
||||||
const sitekey = "<%= it.sitekey %>"
|
const sitekey = "<%= it.sitekey %>"
|
||||||
const ws = new WebSocket(`${websocket}/savechannel?url=${encodeURIComponent(url)}`)
|
const initialRateLimitId = "<%= it.rateLimitId %>"
|
||||||
|
function getRateLimitId() {
|
||||||
|
if (initialRateLimitId) {
|
||||||
|
return initialRateLimitId
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("pt_rlid")
|
||||||
|
if (stored) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialRateLimitId
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem("pt_rlid", initialRateLimitId)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(`${websocket}/savechannel?url=${encodeURIComponent(url)}&rlid=${encodeURIComponent(getRateLimitId())}`)
|
||||||
const h1 = document.getElementById("title")
|
const h1 = document.getElementById("title")
|
||||||
const h3 = document.getElementById("bottom")
|
const h3 = document.getElementById("bottom")
|
||||||
const captcha = document.getElementById("captcha")
|
const captcha = document.getElementById("captcha")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue