mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-17 05:20:51 -05:00
245 lines
8.3 KiB
Crystal
245 lines
8.3 KiB
Crystal
# HTTP Proxy route for SABR streaming
|
|
# This is a "dumb" proxy that forwards requests to googlevideo.com and other YouTube services
|
|
# Used by the client-side SABR player to proxy segment requests
|
|
|
|
module Invidious::Routes::Proxy
|
|
ALLOWED_HEADERS = [
|
|
"origin",
|
|
"x-requested-with",
|
|
"content-type",
|
|
"accept",
|
|
"authorization",
|
|
"x-goog-visitor-id",
|
|
"x-goog-api-key",
|
|
"x-origin",
|
|
"x-youtube-client-version",
|
|
"x-youtube-client-name",
|
|
"x-goog-api-format-version",
|
|
"x-goog-authuser",
|
|
"x-user-agent",
|
|
"accept-language",
|
|
"x-goog-fieldmask",
|
|
"range",
|
|
"referer",
|
|
]
|
|
|
|
CONTENT_HEADERS = [
|
|
"content-length",
|
|
"content-type",
|
|
"content-disposition",
|
|
"accept-ranges",
|
|
"content-range",
|
|
]
|
|
|
|
# Allowed hosts for proxying (security measure)
|
|
ALLOWED_HOST_PATTERNS = [
|
|
/\.googlevideo\.com$/,
|
|
/\.youtube\.com$/,
|
|
/\.ytimg\.com$/,
|
|
/\.ggpht\.com$/,
|
|
/^redirector\.googlevideo\.com$/,
|
|
/^jnn-pa\.googleapis\.com$/,
|
|
/^play\.googleapis\.com$/,
|
|
]
|
|
|
|
def self.is_host_allowed?(host : String) : Bool
|
|
ALLOWED_HOST_PATTERNS.any? { |pattern| host.matches?(pattern) }
|
|
end
|
|
|
|
# OPTIONS /proxy
|
|
def self.options(env)
|
|
origin = env.request.headers["Origin"]? || "*"
|
|
|
|
env.response.headers["Access-Control-Allow-Origin"] = origin
|
|
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ")
|
|
env.response.headers["Access-Control-Max-Age"] = "86400"
|
|
env.response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
|
|
env.response.status_code = 200
|
|
""
|
|
end
|
|
|
|
# GET /proxy
|
|
# POST /proxy
|
|
def self.proxy(env)
|
|
origin = env.request.headers["Origin"]? || "*"
|
|
query_params = env.params.query
|
|
|
|
# Get target host from __host parameter
|
|
target_host = query_params["__host"]?
|
|
|
|
if target_host.nil? || target_host.empty?
|
|
env.response.status_code = 400
|
|
return "Request is formatted incorrectly. Please include __host in the query string."
|
|
end
|
|
|
|
# Security check: only allow proxying to known YouTube/Google domains
|
|
if !is_host_allowed?(target_host)
|
|
env.response.status_code = 403
|
|
return "Proxying to this host is not allowed."
|
|
end
|
|
|
|
# Parse custom headers from __headers parameter
|
|
custom_headers = HTTP::Headers.new
|
|
if headers_param = query_params["__headers"]?
|
|
begin
|
|
puts "[DEBUG] Proxy: Parsing __headers parameter: #{headers_param}"
|
|
headers_array = JSON.parse(headers_param).as_a
|
|
headers_array.each do |header|
|
|
# header is a JSON::Any, need to extract as array
|
|
header_array = header.as_a
|
|
name = header_array[0]?.try &.as_s
|
|
value = header_array[1]?.try &.as_s
|
|
if name && value
|
|
custom_headers[name] = value
|
|
puts "[DEBUG] Proxy: Adding custom header #{name}: #{value}"
|
|
end
|
|
end
|
|
rescue ex
|
|
# Ignore malformed headers but log the error
|
|
puts "[WARN] Proxy: Failed to parse __headers: #{ex.message}"
|
|
end
|
|
end
|
|
|
|
# Get target path from __path parameter, or fall back to the request path
|
|
target_path = query_params["__path"]? || env.request.path.sub("/proxy", "")
|
|
if target_path.empty?
|
|
target_path = "/"
|
|
end
|
|
|
|
# Build the target URL
|
|
target_url = URI.new(
|
|
scheme: "https",
|
|
host: target_host,
|
|
port: 443,
|
|
path: target_path,
|
|
query: build_query_without_proxy_params(query_params)
|
|
)
|
|
|
|
# Build request headers
|
|
request_headers = HTTP::Headers.new
|
|
|
|
# Copy custom headers
|
|
puts "[DEBUG] Proxy: custom_headers size: #{custom_headers.size}"
|
|
custom_headers.each do |key, values|
|
|
# HTTP::Headers stores values as arrays, get the first value
|
|
value = values.is_a?(Array) ? values.first : values.to_s
|
|
puts "[DEBUG] Proxy: Forwarding header #{key}: #{value}"
|
|
request_headers[key] = value
|
|
end
|
|
puts "[DEBUG] Proxy: request_headers size after copying: #{request_headers.size}"
|
|
|
|
# Copy range header from original request
|
|
if range = env.request.headers["Range"]?
|
|
request_headers["Range"] = range
|
|
end
|
|
|
|
# Copy content-type header for POST requests
|
|
if content_type = env.request.headers["Content-Type"]?
|
|
if !request_headers["Content-Type"]?
|
|
request_headers["Content-Type"] = content_type
|
|
puts "[DEBUG] Proxy: Copied Content-Type from request: #{content_type}"
|
|
end
|
|
end
|
|
|
|
# Copy user-agent if not already set
|
|
if !request_headers["User-Agent"]? && env.request.headers["User-Agent"]?
|
|
request_headers["User-Agent"] = env.request.headers["User-Agent"]
|
|
puts "[DEBUG] Proxy: Copied User-Agent from request: #{request_headers["User-Agent"]}"
|
|
end
|
|
|
|
# Set origin/referer for YouTube and Google domains (only if not already set)
|
|
if target_host.includes?("youtube") || target_host.includes?("googlevideo") || target_host.includes?("googleapis")
|
|
if !request_headers["Origin"]?
|
|
request_headers["Origin"] = "https://www.youtube.com"
|
|
puts "[DEBUG] Proxy: Set Origin header"
|
|
end
|
|
if !request_headers["Referer"]?
|
|
request_headers["Referer"] = "https://www.youtube.com/"
|
|
puts "[DEBUG] Proxy: Set Referer header"
|
|
end
|
|
end
|
|
|
|
# CRITICAL: Set Content-Type for POST requests to videoplayback (SABR protocol)
|
|
# YouTube requires application/x-protobuf for SABR videoplayback requests
|
|
if env.request.method == "POST" && target_url.path.includes?("videoplayback")
|
|
request_headers["Content-Type"] = "application/x-protobuf"
|
|
puts "[DEBUG] Proxy: Set Content-Type: application/x-protobuf for videoplayback POST"
|
|
end
|
|
|
|
# Copy authorization if present
|
|
if auth = env.request.headers["Authorization"]?
|
|
request_headers["Authorization"] = auth
|
|
end
|
|
|
|
# Final debug output showing all headers being sent
|
|
puts "[DEBUG] Proxy: Final headers to send: #{request_headers.to_a.map { |k, v| "#{k}: #{v[0..50]}..." }.join(", ")}"
|
|
|
|
# Make the proxied request
|
|
begin
|
|
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
|
|
client.connect_timeout = 10.seconds
|
|
client.read_timeout = 30.seconds
|
|
|
|
case env.request.method
|
|
when "GET"
|
|
response = client.get(target_url.request_target, headers: request_headers)
|
|
when "POST"
|
|
# Read body as binary Slice to preserve protobuf data integrity
|
|
body_io = env.request.body
|
|
body_bytes : Bytes? = nil
|
|
if body_io
|
|
body_bytes = body_io.getb_to_end
|
|
end
|
|
body_size = body_bytes.try(&.size) || 0
|
|
puts "[DEBUG] Proxy: POST body size: #{body_size} bytes (binary)"
|
|
puts "[DEBUG] Proxy: POST target: #{target_url.request_target[0..200]}"
|
|
response = client.post(target_url.request_target, headers: request_headers, body: body_bytes)
|
|
puts "[DEBUG] Proxy: Response status: #{response.status_code}, content-type: #{response.headers["content-type"]?}"
|
|
else
|
|
env.response.status_code = 405
|
|
return "Method not allowed"
|
|
end
|
|
|
|
# Set response status
|
|
env.response.status_code = response.status_code
|
|
|
|
# Copy content headers
|
|
CONTENT_HEADERS.each do |header|
|
|
if value = response.headers[header]?
|
|
env.response.headers[header] = value
|
|
end
|
|
end
|
|
|
|
# Add CORS headers
|
|
env.response.headers["Access-Control-Allow-Origin"] = origin
|
|
env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ")
|
|
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
env.response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
|
|
# Return the response body
|
|
# Wrap in error handling to suppress "Broken pipe" errors when client disconnects
|
|
begin
|
|
response.body
|
|
rescue ex : IO::Error
|
|
# Client disconnected (common during seeking/quality changes) - this is expected
|
|
puts "[DEBUG] Proxy: Client disconnected (#{ex.message})"
|
|
""
|
|
end
|
|
rescue ex
|
|
env.response.status_code = 502
|
|
"Proxy error: #{ex.message}"
|
|
end
|
|
end
|
|
|
|
private def self.build_query_without_proxy_params(params : HTTP::Params) : String?
|
|
filtered = HTTP::Params.new
|
|
params.each do |key, value|
|
|
next if key == "__host" || key == "__headers" || key == "__path"
|
|
filtered.add(key, value)
|
|
end
|
|
filtered.empty? ? nil : filtered.to_s
|
|
end
|
|
end
|