|
import { env } from "../config.js"; |
|
import { readFile } from "node:fs/promises"; |
|
import { Green, Yellow } from "../misc/console-text.js"; |
|
import ip from "ipaddr.js"; |
|
import * as cluster from "../misc/cluster.js"; |
|
|
|
|
|
|
|
const generateWildcardRegex = rule => { |
|
var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); |
|
return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$"); |
|
} |
|
|
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; |
|
|
|
let keys = {}; |
|
|
|
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const validateKeys = (input) => { |
|
if (typeof input !== 'object' || input === null) { |
|
throw "input is not an object"; |
|
} |
|
|
|
if (Object.keys(input).some(x => !UUID_REGEX.test(x))) { |
|
throw "key file contains invalid key(s)"; |
|
} |
|
|
|
Object.values(input).forEach(details => { |
|
if (typeof details !== 'object' || details === null) { |
|
throw "some key(s) are incorrectly configured"; |
|
} |
|
|
|
const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k)); |
|
if (unexpected_key) { |
|
throw "detail object contains unexpected key: " + unexpected_key; |
|
} |
|
|
|
if (details.limit && details.limit !== 'unlimited') { |
|
if (typeof details.limit !== 'number') |
|
throw "detail object contains invalid limit (not a number)"; |
|
else if (details.limit < 1) |
|
throw "detail object contains invalid limit (not a positive number)"; |
|
} |
|
|
|
if (details.ips) { |
|
if (!Array.isArray(details.ips)) |
|
throw "details object contains value for `ips` which is not an array"; |
|
|
|
const invalid_ip = details.ips.find( |
|
addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr)) |
|
); |
|
|
|
if (invalid_ip) { |
|
throw "`ips` in details contains an invalid IP or CIDR range: " + invalid_ip; |
|
} |
|
} |
|
|
|
if (details.userAgents) { |
|
if (!Array.isArray(details.userAgents)) |
|
throw "details object contains value for `userAgents` which is not an array"; |
|
|
|
const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string'); |
|
if (invalid_ua) { |
|
throw "`userAgents` in details contains an invalid user agent: " + invalid_ua; |
|
} |
|
} |
|
}); |
|
} |
|
|
|
const formatKeys = (keyData) => { |
|
const formatted = {}; |
|
|
|
for (let key in keyData) { |
|
const data = keyData[key]; |
|
key = key.toLowerCase(); |
|
|
|
formatted[key] = {}; |
|
|
|
if (data.limit) { |
|
if (data.limit === "unlimited") { |
|
data.limit = Infinity; |
|
} |
|
|
|
formatted[key].limit = data.limit; |
|
} |
|
|
|
if (data.ips) { |
|
formatted[key].ips = data.ips.map(addr => { |
|
if (ip.isValid(addr)) { |
|
const parsed = ip.parse(addr); |
|
const range = parsed.kind() === 'ipv6' ? 128 : 32; |
|
return [ parsed, range ]; |
|
} |
|
|
|
return ip.parseCIDR(addr); |
|
}); |
|
} |
|
|
|
if (data.userAgents) { |
|
formatted[key].userAgents = data.userAgents.map(generateWildcardRegex); |
|
} |
|
} |
|
|
|
return formatted; |
|
} |
|
|
|
const updateKeys = (newKeys) => { |
|
keys = formatKeys(newKeys); |
|
} |
|
|
|
const loadKeys = async (source) => { |
|
let updated; |
|
if (source.protocol === 'file:') { |
|
const pathname = source.pathname === '/' ? '' : source.pathname; |
|
updated = JSON.parse( |
|
await readFile( |
|
decodeURIComponent(source.host + pathname), |
|
'utf8' |
|
) |
|
); |
|
} else { |
|
updated = await fetch(source).then(a => a.json()); |
|
} |
|
|
|
validateKeys(updated); |
|
|
|
cluster.broadcast({ api_keys: updated }); |
|
|
|
updateKeys(updated); |
|
} |
|
|
|
const wrapLoad = (url, initial = false) => { |
|
loadKeys(url) |
|
.then(() => { |
|
if (initial) { |
|
console.log(`${Green('[✓]')} api keys loaded successfully!`) |
|
} |
|
}) |
|
.catch((e) => { |
|
console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`); |
|
console.error('Error:', e); |
|
}) |
|
} |
|
|
|
const err = (reason) => ({ success: false, error: reason }); |
|
|
|
export const validateAuthorization = (req) => { |
|
const authHeader = req.get('Authorization'); |
|
|
|
if (typeof authHeader !== 'string') { |
|
return err("missing"); |
|
} |
|
|
|
const [ authType, keyString ] = authHeader.split(' ', 2); |
|
if (authType.toLowerCase() !== 'api-key') { |
|
return err("not_api_key"); |
|
} |
|
|
|
if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) { |
|
return err("invalid"); |
|
} |
|
|
|
const matchingKey = keys[keyString.toLowerCase()]; |
|
if (!matchingKey) { |
|
return err("not_found"); |
|
} |
|
|
|
if (matchingKey.ips) { |
|
let addr; |
|
try { |
|
addr = ip.parse(req.ip); |
|
} catch { |
|
return err("invalid_ip"); |
|
} |
|
|
|
const ip_allowed = matchingKey.ips.some( |
|
([ allowed, size ]) => { |
|
return addr.kind() === allowed.kind() |
|
&& addr.match(allowed, size); |
|
} |
|
); |
|
|
|
if (!ip_allowed) { |
|
return err("ip_not_allowed"); |
|
} |
|
} |
|
|
|
if (matchingKey.userAgents) { |
|
const userAgent = req.get('User-Agent'); |
|
if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) { |
|
return err("ua_not_allowed"); |
|
} |
|
} |
|
|
|
req.rateLimitKey = keyString.toLowerCase(); |
|
req.rateLimitMax = matchingKey.limit; |
|
|
|
return { success: true }; |
|
} |
|
|
|
export const setup = (url) => { |
|
if (cluster.isPrimary) { |
|
wrapLoad(url, true); |
|
if (env.keyReloadInterval > 0) { |
|
setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000); |
|
} |
|
} else if (cluster.isWorker) { |
|
process.on('message', (message) => { |
|
if ('api_keys' in message) { |
|
updateKeys(message.api_keys); |
|
} |
|
}); |
|
} |
|
} |
|
|