cobalt/api/src/core/api.js

298 lines
8.4 KiB
JavaScript
Raw Normal View History

import cors from "cors";
import rateLimit from "express-rate-limit";
2024-07-24 17:49:47 +02:00
import { setGlobalDispatcher, ProxyAgent } from "undici";
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
import { env } from "../config.js";
2024-05-16 10:20:40 +02:00
import { generateHmac, generateSalt } from "../misc/crypto.js";
import { Bright, Cyan } from "../misc/console-text.js";
import { languageCode } from "../misc/utils.js";
2024-05-16 10:20:40 +02:00
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
import { verifyStream, getInternalStream } from "../stream/manage.js";
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
2024-08-16 19:28:03 +02:00
import { verifyTurnstileToken } from "../security/turnstile.js";
import { extract } from "../processing/url.js";
import match from "../processing/match.js";
import stream from "../stream/stream.js";
2024-08-16 19:28:03 +02:00
import jwt from "../security/jwt.js";
2024-05-16 10:20:40 +02:00
const git = {
branch: await getBranch(),
commit: await getCommit(),
remote: await getRemote(),
}
const version = await getVersion();
2024-05-16 10:20:40 +02:00
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
const ipSalt = generateSalt();
const corsConfig = env.corsWildcard ? {} : {
origin: env.corsURL,
optionsSuccessStatus: 200
}
export function runAPI(express, app, __dirname) {
2024-05-16 10:20:40 +02:00
const startTime = new Date();
const startTimestamp = startTime.getTime();
2024-05-16 10:20:40 +02:00
const serverInfo = {
version: version,
git,
2024-08-03 19:10:59 +02:00
cors: env.corsWildcard,
url: env.apiURL,
startTime: `${startTimestamp}`,
2024-05-16 10:20:40 +02:00
}
const apiLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
2024-05-16 09:58:28 +02:00
max: env.rateLimitMax,
2023-08-04 20:43:12 +02:00
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => generateHmac(getIP(req), ipSalt),
2024-05-14 09:08:36 +02:00
handler: (req, res) => {
const { status, body } = createResponse("error", {
code: "error.rate_exceeded",
context: {
limit: env.rateLimitWindow
}
2023-08-04 20:43:12 +02:00
});
return res.status(status).json(body);
}
})
const apiLimiterStream = rateLimit({
windowMs: env.rateLimitWindow * 1000,
2024-05-16 09:58:28 +02:00
max: env.rateLimitMax,
2023-08-04 20:43:12 +02:00
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => generateHmac(getIP(req), ipSalt),
2024-05-14 09:08:36 +02:00
handler: (req, res) => {
return res.sendStatus(429)
}
})
app.set('trust proxy', ['loopback', 'uniquelocal']);
app.use('/', cors({
methods: ['GET', 'POST'],
2024-05-16 09:59:53 +02:00
exposedHeaders: [
'Ratelimit-Limit',
'Ratelimit-Policy',
'Ratelimit-Remaining',
'Ratelimit-Reset'
],
...corsConfig,
}))
app.use('/', apiLimiter);
app.use('/stream', apiLimiterStream);
app.use((req, res, next) => {
try {
decodeURIComponent(req.path)
} catch {
return res.redirect('/')
}
next();
})
app.use('/', express.json({ limit: 1024 }));
2024-08-16 19:28:03 +02:00
app.use('/post', (err, _, res, next) => {
if (err) {
const { status, body } = createResponse("error", {
code: "error.body_invalid",
context: {
limit: env.rateLimitWindow
}
2023-08-04 20:43:12 +02:00
});
return res.status(status).json(body);
2023-08-04 20:43:12 +02:00
}
next();
});
2024-08-16 19:28:03 +02:00
app.post("/session", async (req, res) => {
if (!env.turnstileSecret || !env.jwtSecret) {
return fail("error.api.auth.not_configured")
}
const turnstileResponse = req.header("cf-turnstile-response");
if (!turnstileResponse) {
return fail("error.api.auth.turnstile.missing");
}
const turnstileResult = await verifyTurnstileToken(
turnstileResponse,
req.ip
);
if (!turnstileResult) {
return fail("error.api.auth.turnstile.invalid");
}
try {
res.json(jwt.generate());
} catch {
return fail("error.api.generic");
}
});
app.post('/', async (req, res) => {
2024-05-15 15:29:18 +02:00
const request = req.body;
const lang = languageCode(req);
const fail = (code) => {
const { status, body } = createResponse("error", { code });
2024-05-15 15:29:18 +02:00
res.status(status).json(body);
}
2024-08-16 19:28:03 +02:00
if (env.jwtSecret) {
const authorization = req.header("Authorization");
if (!authorization) {
return fail("error.api.auth.jwt.missing");
}
if (!authorization.startsWith("Bearer ")) {
return fail("error.api.auth.jwt.invalid");
}
const verifyJwt = jwt.verify(
req.header("Authorization").split("Bearer ", 2)[1]
);
if (!verifyJwt) {
return fail("error.api.auth.jwt.invalid");
}
}
if (!acceptRegex.test(req.header('Accept'))) {
return fail('ErrorInvalidAcceptHeader');
}
2024-05-15 15:29:18 +02:00
if (!acceptRegex.test(req.header('Content-Type'))) {
return fail('ErrorInvalidContentType');
}
if (!request.url) {
return fail('ErrorNoLink');
}
if (request.youtubeDubBrowserLang) {
request.youtubeDubLang = lang;
}
const { success, data: normalizedRequest } = await normalizeRequest(request);
if (!success) {
2024-05-15 15:29:18 +02:00
return fail('ErrorCantProcess');
}
const parsed = extract(normalizedRequest.url);
if (parsed === null) {
return fail('ErrorUnsupported');
}
try {
2024-05-15 15:29:18 +02:00
const result = await match(
parsed.host, parsed.patternMatch, normalizedRequest
2024-05-15 15:29:18 +02:00
);
2024-05-15 15:29:18 +02:00
res.status(result.status).json(result.body);
} catch {
fail('ErrorSomethingWentWrong');
}
})
app.get('/stream', (req, res) => {
const id = String(req.query.id);
const exp = String(req.query.exp);
const sig = String(req.query.sig);
const sec = String(req.query.sec);
const iv = String(req.query.iv);
const checkQueries = id && exp && sig && sec && iv;
const checkBaseLength = id.length === 21 && exp.length === 13;
const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22;
if (!checkQueries || !checkBaseLength || !checkSafeLength) {
return res.status(400).end();
}
if (req.query.p) {
return res.status(200).end();
}
const streamInfo = verifyStream(id, sig, exp, sec, iv);
if (!streamInfo?.service) {
return res.status(streamInfo.status).end();
}
return stream(res, streamInfo);
})
app.get('/istream', (req, res) => {
if (!req.ip.endsWith('127.0.0.1')) {
return res.sendStatus(403);
}
if (String(req.query.id).length !== 21) {
return res.sendStatus(400);
}
const streamInfo = getInternalStream(req.query.id);
if (!streamInfo) {
return res.sendStatus(404);
}
streamInfo.headers = new Map([
...(streamInfo.headers || []),
...Object.entries(req.headers)
]);
return stream(res, { type: 'internal', ...streamInfo });
})
app.get('/', (_, res) => {
return res.status(200).json(serverInfo);
})
app.get('/favicon.ico', (req, res) => {
2024-08-02 18:35:49 +02:00
res.status(404).end();
})
app.get('/*', (req, res) => {
res.redirect('/');
})
randomizeCiphers();
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
2024-07-24 17:49:47 +02:00
if (env.externalProxy) {
if (env.freebindCIDR) {
throw new Error('Freebind is not available when external proxy is enabled')
}
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
}
app.listen(env.apiPort, env.listenAddress, () => {
2023-08-04 20:43:12 +02:00
console.log(`\n` +
Bright(Cyan("cobalt ")) + Bright("API ^_^") + "\n" +
"~~~~~~\n" +
Bright("version: ") + version + "\n" +
Bright("commit: ") + git.commit + "\n" +
Bright("branch: ") + git.branch + "\n" +
Bright("remote: ") + git.remote + "\n" +
Bright("start time: ") + startTime.toUTCString() + "\n" +
"~~~~~~\n" +
Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
Bright("port: ") + env.apiPort + "\n"
2023-08-04 20:43:12 +02:00
)
})
}