85 lines
2.6 KiB
TypeScript
85 lines
2.6 KiB
TypeScript
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
import ipaddr from 'ipaddr.js';
|
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
export const fail = (status: number, code: string, message: string): never => {
|
|
throw new ApiError(status, code, message);
|
|
};
|
|
export const randomToken = () => randomBytes(32).toString('base64url');
|
|
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
export const hmac = (secret: string, value: string) =>
|
|
createHmac('sha256', secret).update(value).digest('hex');
|
|
export function normalizeIp(value: string): string | null {
|
|
try {
|
|
return ipaddr.process(value).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
export function clientIp(
|
|
remote: string | undefined,
|
|
headers: IncomingHttpHeaders,
|
|
trusted: string[],
|
|
): string | null {
|
|
const first = remote ? normalizeIp(remote) : null;
|
|
if (!first) return null;
|
|
const trusts = (v: string) =>
|
|
trusted.some((rule) => {
|
|
try {
|
|
const a = ipaddr.process(v);
|
|
const cidr = rule.includes('/')
|
|
? ipaddr.parseCIDR(rule)
|
|
: ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [
|
|
ipaddr.IPv4 | ipaddr.IPv6,
|
|
number,
|
|
]);
|
|
return a.kind() === cidr[0].kind() && a.match(cidr);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
if (!trusts(first)) return first;
|
|
const raw = headers['x-forwarded-for'];
|
|
if (typeof raw !== 'string') return null;
|
|
const chain = raw.split(',').map((v) => normalizeIp(v.trim()));
|
|
if (chain.some((v) => !v)) return null;
|
|
let current = first;
|
|
for (const next of chain.reverse()) {
|
|
if (!trusts(current)) break;
|
|
current = next!;
|
|
}
|
|
return current;
|
|
}
|
|
export function publicIp(ip: string | null): boolean {
|
|
if (!ip) return false;
|
|
try {
|
|
return ipaddr.process(ip).range() === 'unicast';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
export class Limiter {
|
|
private windows = new Map<string, { count: number; until: number }>();
|
|
hit(key: string, max: number, windowMs = 60000) {
|
|
const now = Date.now();
|
|
let w = this.windows.get(key);
|
|
if (!w || w.until <= now) {
|
|
w = { count: 0, until: now + windowMs };
|
|
this.windows.set(key, w);
|
|
}
|
|
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
|
|
}
|
|
prune() {
|
|
const now = Date.now();
|
|
for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key);
|
|
}
|
|
}
|