mirror of
https://github.com/wukko/cobalt.git
synced 2025-06-12 05:07:41 +02:00
merge: 10.9 from main
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@imput/cobalt-api",
|
||||
"description": "save what you love",
|
||||
"version": "10.8.3",
|
||||
"version": "10.9.1",
|
||||
"author": "imput",
|
||||
"exports": "./src/cobalt.js",
|
||||
"type": "module",
|
||||
|
@ -28,6 +28,9 @@ const env = {
|
||||
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
|
||||
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
|
||||
|
||||
sessionRateLimitWindow: (process.env.SESSION_RATELIMIT_WINDOW && parseInt(process.env.SESSION_RATELIMIT_WINDOW)) || 60,
|
||||
sessionRateLimit: (process.env.SESSION_RATELIMIT && parseInt(process.env.SESSION_RATELIMIT)) || 10,
|
||||
|
||||
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
|
||||
streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90,
|
||||
|
||||
|
@ -75,8 +75,8 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
|
||||
|
||||
const sessionLimiter = rateLimit({
|
||||
windowMs: 60000,
|
||||
limit: 10,
|
||||
windowMs: env.sessionRateLimitWindow * 1000,
|
||||
limit: env.sessionRateLimit,
|
||||
standardHeaders: 'draft-6',
|
||||
legacyHeaders: false,
|
||||
keyGenerator,
|
||||
@ -92,7 +92,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
keyGenerator: req => req.rateLimitKey || keyGenerator(req),
|
||||
store: await createStore('api'),
|
||||
handler: handleRateExceeded
|
||||
})
|
||||
});
|
||||
|
||||
const apiTunnelLimiter = rateLimit({
|
||||
windowMs: env.rateLimitWindow * 1000,
|
||||
@ -104,7 +104,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
handler: (_, res) => {
|
||||
return res.sendStatus(429)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
app.set('trust proxy', ['loopback', 'uniquelocal']);
|
||||
|
||||
@ -176,7 +176,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
return fail(res, "error.api.auth.jwt.invalid");
|
||||
}
|
||||
|
||||
if (!jwt.verify(token)) {
|
||||
if (!jwt.verify(token, getIP(req, 32))) {
|
||||
return fail(res, "error.api.auth.jwt.invalid");
|
||||
}
|
||||
|
||||
@ -222,7 +222,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(jwt.generate());
|
||||
res.json(jwt.generate(getIP(req, 32)));
|
||||
} catch {
|
||||
return fail(res, "error.api.generic");
|
||||
}
|
||||
|
@ -1,17 +1,26 @@
|
||||
import { request } from 'undici';
|
||||
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
export async function getRedirectingURL(url, dispatcher, userAgent) {
|
||||
const location = await request(url, {
|
||||
export async function getRedirectingURL(url, dispatcher, headers) {
|
||||
const params = {
|
||||
dispatcher,
|
||||
method: 'HEAD',
|
||||
headers: { 'user-agent': userAgent }
|
||||
}).then(r => {
|
||||
headers,
|
||||
redirect: 'manual'
|
||||
};
|
||||
|
||||
let location = await request(url, params).then(r => {
|
||||
if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
|
||||
return r.headers['location'];
|
||||
}
|
||||
}).catch(() => null);
|
||||
|
||||
location ??= await fetch(url, params).then(r => {
|
||||
if (redirectStatuses.has(r.status) && r.headers.has('location')) {
|
||||
return r.headers.get('location');
|
||||
}
|
||||
}).catch(() => null);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,11 @@
|
||||
import * as cluster from "../../misc/cluster.js";
|
||||
|
||||
import { Agent } from "undici";
|
||||
import { env } from "../../config.js";
|
||||
import { Green, Yellow } from "../../misc/console-text.js";
|
||||
|
||||
const defaultAgent = new Agent();
|
||||
|
||||
let session;
|
||||
|
||||
const validateSession = (sessionResponse) => {
|
||||
@ -32,7 +35,11 @@ const loadSession = async () => {
|
||||
const sessionServerUrl = new URL(env.ytSessionServer);
|
||||
sessionServerUrl.pathname = "/token";
|
||||
|
||||
const newSession = await fetch(sessionServerUrl).then(a => a.json());
|
||||
const newSession = await fetch(
|
||||
sessionServerUrl,
|
||||
{ dispatcher: defaultAgent }
|
||||
).then(a => a.json());
|
||||
|
||||
validateSession(newSession);
|
||||
|
||||
if (!session || session.updated < newSession?.updated) {
|
||||
|
@ -118,14 +118,13 @@ export function normalizeRequest(request) {
|
||||
));
|
||||
}
|
||||
|
||||
export function getIP(req) {
|
||||
export function getIP(req, prefix = 56) {
|
||||
const strippedIP = req.ip.replace(/^::ffff:/, '');
|
||||
const ip = ipaddr.parse(strippedIP);
|
||||
if (ip.kind() === 'ipv4') {
|
||||
return strippedIP;
|
||||
}
|
||||
|
||||
const prefix = 56;
|
||||
const v6Bytes = ip.toByteArray();
|
||||
v6Bytes.fill(0, prefix / 8);
|
||||
|
||||
|
@ -90,7 +90,9 @@ export const services = {
|
||||
"r/u_:user/comments/:id/:title",
|
||||
"r/u_:user/comments/:id/comment/:commentId",
|
||||
|
||||
"r/:sub/s/:shareId"
|
||||
"r/:sub/s/:shareId",
|
||||
|
||||
"video/:shortId",
|
||||
],
|
||||
subdomains: "*",
|
||||
},
|
||||
|
@ -23,7 +23,8 @@ export const testers = {
|
||||
pattern.id?.length <= 16 && !pattern.sub && !pattern.user
|
||||
|| (pattern.sub?.length <= 22 && pattern.id?.length <= 16)
|
||||
|| (pattern.user?.length <= 22 && pattern.id?.length <= 16)
|
||||
|| (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16),
|
||||
|| (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16)
|
||||
|| (pattern.shortId?.length <= 16),
|
||||
|
||||
"rutube": pattern =>
|
||||
(pattern.id?.length === 32 && pattern.key?.length <= 32) ||
|
||||
|
@ -527,7 +527,7 @@ export default function instagram(obj) {
|
||||
// for some reason instagram decides to return HTML
|
||||
// instead of a redirect when requesting with a normal
|
||||
// browser user-agent
|
||||
'curl/7.88.1'
|
||||
{'User-Agent': 'curl/7.88.1'}
|
||||
).then(match => instagram({
|
||||
...obj, ...match,
|
||||
shareId: undefined
|
||||
|
@ -50,12 +50,24 @@ async function getAccessToken() {
|
||||
|
||||
export default async function(obj) {
|
||||
let params = obj;
|
||||
const accessToken = await getAccessToken();
|
||||
const headers = {
|
||||
'user-agent': genericUserAgent,
|
||||
authorization: accessToken && `Bearer ${accessToken}`,
|
||||
accept: 'application/json'
|
||||
};
|
||||
|
||||
if (params.shortId) {
|
||||
params = await resolveRedirectingURL(
|
||||
`https://www.reddit.com/video/${params.shortId}`,
|
||||
obj.dispatcher, headers
|
||||
);
|
||||
}
|
||||
|
||||
if (!params.id && params.shareId) {
|
||||
params = await resolveRedirectingURL(
|
||||
`https://www.reddit.com/r/${params.sub}/s/${params.shareId}`,
|
||||
obj.dispatcher,
|
||||
genericUserAgent
|
||||
obj.dispatcher, headers
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,17 +75,10 @@ export default async function(obj) {
|
||||
|
||||
const url = new URL(`https://www.reddit.com/comments/${params.id}.json`);
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
if (accessToken) url.hostname = 'oauth.reddit.com';
|
||||
|
||||
let data = await fetch(
|
||||
url, {
|
||||
headers: {
|
||||
'User-Agent': genericUserAgent,
|
||||
accept: 'application/json',
|
||||
authorization: accessToken && `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
url, { headers }
|
||||
).then(r => r.json()).catch(() => {});
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
|
@ -106,6 +106,14 @@ function aliasURL(url) {
|
||||
url.pathname = `/share/${idPart.slice(-32)}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "redd":
|
||||
/* reddit short video links can be treated by changing https://v.redd.it/<id>
|
||||
to https://reddit.com/video/<id>.*/
|
||||
if (url.hostname === "v.redd.it" && parts.length === 2) {
|
||||
url = new URL(`https://www.reddit.com/video/${parts[1]}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return url;
|
||||
@ -231,11 +239,11 @@ export function extract(url) {
|
||||
return { host, patternMatch };
|
||||
}
|
||||
|
||||
export async function resolveRedirectingURL(url, dispatcher, userAgent) {
|
||||
export async function resolveRedirectingURL(url, dispatcher, headers) {
|
||||
const originalService = getHostIfValid(normalizeURL(url));
|
||||
if (!originalService) return;
|
||||
|
||||
const canonicalURL = await getRedirectingURL(url, dispatcher, userAgent);
|
||||
const canonicalURL = await getRedirectingURL(url, dispatcher, headers);
|
||||
if (!canonicalURL) return;
|
||||
|
||||
const { host, patternMatch } = extract(normalizeURL(canonicalURL));
|
||||
|
@ -6,12 +6,19 @@ import { env } from "../config.js";
|
||||
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
|
||||
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
|
||||
|
||||
const makeHmac = (header, payload) =>
|
||||
createHmac("sha256", env.jwtSecret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
const makeHmac = (data) => {
|
||||
return createHmac("sha256", env.jwtSecret)
|
||||
.update(data)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
const generate = () => {
|
||||
const sign = (header, payload) =>
|
||||
makeHmac(`${header}.${payload}`);
|
||||
|
||||
const getIPHash = (ip) =>
|
||||
makeHmac(ip).slice(0, 8);
|
||||
|
||||
const generate = (ip) => {
|
||||
const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
|
||||
|
||||
const header = toBase64URL(JSON.stringify({
|
||||
@ -21,10 +28,11 @@ const generate = () => {
|
||||
|
||||
const payload = toBase64URL(JSON.stringify({
|
||||
jti: nanoid(8),
|
||||
sub: getIPHash(ip),
|
||||
exp,
|
||||
}));
|
||||
|
||||
const signature = makeHmac(header, payload);
|
||||
const signature = sign(header, payload);
|
||||
|
||||
return {
|
||||
token: `${header}.${payload}.${signature}`,
|
||||
@ -32,7 +40,7 @@ const generate = () => {
|
||||
};
|
||||
}
|
||||
|
||||
const verify = (jwt) => {
|
||||
const verify = (jwt, ip) => {
|
||||
const [header, payload, signature] = jwt.split(".", 3);
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
|
||||
@ -40,17 +48,16 @@ const verify = (jwt) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const verifySignature = makeHmac(header, payload);
|
||||
const verifySignature = sign(header, payload);
|
||||
|
||||
if (verifySignature !== signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (timestamp >= JSON.parse(fromBase64URL(payload)).exp) {
|
||||
return false;
|
||||
}
|
||||
const data = JSON.parse(fromBase64URL(payload));
|
||||
|
||||
return true;
|
||||
return getIPHash(ip) === data.sub
|
||||
&& timestamp <= data.exp;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
@ -123,6 +123,7 @@
|
||||
{
|
||||
"name": "private instagram post",
|
||||
"url": "https://www.instagram.com/p/C5_A1TQNPrYw4c2g9KAUTPUl8RVHqiAdAcOOSY0",
|
||||
"canFail": true,
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 400,
|
||||
|
@ -56,5 +56,23 @@
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shortened video link",
|
||||
"url": "https://v.redd.it/ifg2emt5ck0e1",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shortened video link (alternative)",
|
||||
"url": "https://reddit.com/video/ifg2emt5ck0e1",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
}
|
||||
]
|
Reference in New Issue
Block a user