~cadence/cloudtube

eb111a44c4392f982d120ce6158067101c27a636 — Lomanic 2 years ago db49bb3
Proxy captions via new /proxy route

We can add more authorized paths to authorizedPaths if we need
more resources to be pulled from the NewLeaf/Invidious backend
on the same domain.

This route forwards to the client a few headers like Bibliogram
https://git.sr.ht/~cadence/bibliogram/tree/ea7cd5d5/item/src/site/api/proxy.js#L28-29
so we can also use this route to possibly proxy videos in the
future.

We are strict about the url parameter not overriding the
NewLeaf/Invidious backend (instanceOrigin) by ensuring
fetchURL has instanceOrigin as prefix.
3 files changed, 32 insertions(+), 27 deletions(-)

D api/captions.js
A api/proxy.js
M api/video.js
D api/captions.js => api/captions.js +0 -27
@@ 1,27 0,0 @@
/** @type {import("node-fetch").default} */
// @ts-ignore
const fetch = require("node-fetch")
const {getUser} = require("../utils/getuser")
const constants = require("../utils/constants.js")

module.exports = [
	{
		route: `/api/v1/captions/(${constants.regex.video_id})`, methods: ["GET"], code: async ({req, fill, url}) => {
			const instanceOrigin = getUser(req).getSettingsOrDefaults().instance
			const fetchURL = new URL(`${url.pathname}${url.search}`, instanceOrigin)
			return fetch(fetchURL.toString()).then(res => {
				return res.text().then(text => {
					if (res.status === 200) {
						// Remove the position annotations that youtube unhelpfully provides
						text = text.replace(/(--> \S+).*/g, "$1")
					}
					return {
						statusCode: res.status,
						contentType: res.headers.get("content-type"),
						content: text
					}
				})
			})
		}
	}
]

A api/proxy.js => api/proxy.js +27 -0
@@ 0,0 1,27 @@
const {proxy} = require("pinski/plugins")
const {getUser} = require("../utils/getuser")
const constants = require("../utils/constants.js")

// list of paths relative to the backend this route is authorized to serve
const authorizedPaths = [`/api/v1/captions/(${constants.regex.video_id})`]

// headers relayed as-is from the proxied backend to the client
const proxiedHeaders = ["content-type", "date", "last-modified", "expires", "cache-control", "accept-ranges", "content-range", "origin", "etag", "content-length", "transfer-encoding"]

module.exports = [
	{
		route: `/proxy`, methods: ["GET"], code: async ({req, fill, url}) => {
			const instanceOrigin = getUser(req).getSettingsOrDefaults().instance
			const remotePath = url.searchParams.get("url")
			const fetchURL = new URL(remotePath, instanceOrigin)
			if (!fetchURL.toString().startsWith(instanceOrigin) || !authorizedPaths.some(element => fetchURL.pathname.match(new RegExp(`^${element}$`)))) {
				return {
					statusCode: 401,
					content: "CloudTube: Unauthorized",
					contentType: "text/plain"
				}
			}
			return proxy(fetchURL, {}, (h) => Object.keys(h).filter(key => proxiedHeaders.includes(key)).reduce((res, key) => (res[key] = h[key], res), {}))
		}
	}
]

M api/video.js => api/video.js +5 -0
@@ 174,6 174,11 @@ module.exports = [
				// rewrite description
				video.descriptionHtml = converters.rewriteVideoDescription(video.descriptionHtml, id)

				// rewrite captions urls so they are served on the same domain via the /proxy route
				for (const caption of video.captions) {
					caption.url = `/proxy?${new URLSearchParams({"url": caption.url})}`
				}

				return render(200, "pug/video.pug", {
					url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous,
					sessionWatched, sessionWatchedNext