merge: 10.9 from main

This commit is contained in:
wukko
2025-04-02 21:47:49 +06:00
25 changed files with 479 additions and 146 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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");
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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);

View File

@ -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: "*",
},

View File

@ -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) ||

View File

@ -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

View File

@ -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)) {

View File

@ -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));

View File

@ -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 {

View File

@ -123,6 +123,7 @@
{
"name": "private instagram post",
"url": "https://www.instagram.com/p/C5_A1TQNPrYw4c2g9KAUTPUl8RVHqiAdAcOOSY0",
"canFail": true,
"params": {},
"expected": {
"code": 400,

View File

@ -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"
}
}
]