# SPDX-License-Identifier: AGPL-3.0-only import uri, strutils, httpclient, os, hashes, base64, re import asynchttpserver, asyncstreams, asyncfile, asyncnet import asyncdispatch import jester import router_utils import ".."/[types, formatters, utils] export asynchttpserver, asyncstreams, asyncfile, asyncnet export httpclient, os, strutils, asyncstreams, base64, re const m3u8Mime* = "application/vnd.apple.mpegurl" maxAge* = "max-age=604800" proc safeFetch*(url: string): Future[string] {.async.} = # maxRedirects=0: the caller already validated the host, so never follow a # redirect off the allowlisted host (would re-open the #1411 SSRF). let client = newAsyncHttpClient(maxRedirects = 0) try: result = await client.getContent(url) except: discard finally: client.close() template respond*(req: asynchttpserver.Request; headers) = var msg = "HTTP/1.1 200 OK\c\L" for k, v in headers: msg.add(k & ": " & v & "\c\L") msg.add "\c\L" yield req.client.send(msg) proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = result = Http200 let request = req.getNativeReq() var fetchUrl = url for attempt in 0 .. 2: let client = newAsyncHttpClient(maxRedirects = 0) var shouldRetry = false try: let resFut = client.get(fetchUrl) let completed = await withTimeout(resFut, 5000) if not completed: if attempt < 2: echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl] shouldRetry = true else: echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl] return Http504 else: let res = resFut.read() if res.status != "200 OK": if res.status == "404 Not Found": return Http404 if res.status.startsWith("30") and res.headers.hasKey("location"): let location = res.headers["location", 0] if isTwitterUrl(location): fetchUrl = location shouldRetry = true continue else: return Http403 if attempt < 2: echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl] shouldRetry = true else: echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl] return Http404 else: let hashed = $hash(url) if request.headers.getOrDefault("If-None-Match") == hashed: return Http304 let contentLength = if res.headers.hasKey("content-length"): res.headers["content-length", 0] else: "" let headers = newHttpHeaders({ "content-type": res.headers["content-type", 0], "content-length": contentLength, "cache-control": maxAge, "etag": hashed }) respond(request, headers) var (hasValue, data) = (true, "") while hasValue: (hasValue, data) = await res.bodyStream.read() if hasValue: await request.client.send(data) data.setLen 0 return Http200 except CatchableError: if attempt < 2: echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl] shouldRetry = true else: echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl] result = Http404 finally: client.close() if not shouldRetry: break template check*(code): untyped = if code != Http200: resp code else: enableRawMode() break route proc decoded*(req: jester.Request; index: int): string = let based = req.matches[0].len > 1 encoded = req.matches[index] if based: decode(encoded) else: decodeUrl(encoded) proc normalizeImgUrl*(url: var string) = if not url.startsWith("http"): if "twimg.com" notin url: url.insert(twimg) url.insert(https) proc createMediaRouter*(cfg: Config) = router media: get "/pic/?": resp Http404 get re"^\/pic\/orig\/(enc)?\/?(.+)": var url = decoded(request, 1) cond "/amplify_video/" notin url normalizeImgUrl(url) url.add("?name=orig") let uri = parseUri(url) cond isTwitterUrl(uri) == true let code = await proxyMedia(request, url) check code get re"^\/pic\/(enc)?\/?(.+)": var url = decoded(request, 1) cond "/amplify_video/" notin url normalizeImgUrl(url) let uri = parseUri(url) cond isTwitterUrl(uri) == true let code = await proxyMedia(request, url) check code get re"^\/video\/(enc)?\/?(.+)\/(.+)$": let url = decoded(request, 2) cond isTwitterUrl(url) if getHmac(url) != request.matches[1]: resp Http403, showError("Failed to verify signature", cfg) if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url: let code = await proxyMedia(request, url) check code var content: string if ".vmap" in url: let m3u8 = getM3u8Url(await safeFetch(url)) if m3u8.len > 0: content = await safeFetch(url) else: resp Http404 if ".m3u8" in url: let vid = await safeFetch(url) content = proxifyVideo(vid, requestPrefs().proxyVideos, url) resp content, m3u8Mime