UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

375 lines (337 loc) 12.1 kB
import { $env, $inject, Alepha, z } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { ServerReply } from "../helpers/ServerReply.ts"; import type { RequestGeo, RequestReferer, ServerRequest, ServerRequestData, } from "../interfaces/ServerRequest.ts"; import { UserAgentParser } from "./UserAgentParser.ts"; const envSchema = z.object({ /** * Trust proxy headers (X-Forwarded-For, X-Real-IP) for client IP resolution. * * Default: true (modern deployments are typically behind a reverse proxy) * * Set to false only if your server accepts direct connections without a proxy * and you want to use the raw connection IP. */ TRUST_PROXY: z .boolean() .describe("Trust proxy headers for client IP") .default(true), }); export class ServerRequestParser { protected readonly alepha = $inject(Alepha); protected readonly userAgentParser = $inject(UserAgentParser); protected readonly cryptoProvider = $inject(CryptoProvider); protected readonly env = $env(envSchema); protected readonly rootURL = new URL("http://localhost/"); public createServerRequest( partialRawRequest: Partial<ServerRequestData>, ): ServerRequest { const rawRequest = { method: "GET", url: this.rootURL, headers: {}, query: {}, params: {}, ...partialRawRequest, } as ServerRequestData; const self = this; return { method: rawRequest.method, url: rawRequest.url, raw: rawRequest.raw, headers: rawRequest.headers, query: rawRequest.query, params: rawRequest.params, // --------------------------------------------------------------------------------------------------------------- // body will be filled by body parser middleware body: null, // --------------------------------------------------------------------------------------------------------------- metadata: {}, reply: this.alepha.inject(ServerReply, { lifetime: "transient" }), // --------------------------------------------------------------------------------------------------------------- get requestId() { return self.getRequestId(rawRequest); }, get ip() { return self.getRequestIp(rawRequest); }, get userAgent() { return self.getRequestUserAgent(rawRequest); }, get geo() { return self.getRequestGeo(rawRequest); }, get isBot() { return self.getIsBot(rawRequest); }, get isMobile() { return self.getIsMobile(rawRequest); }, get protocol() { return self.getProtocol(rawRequest); }, get language() { return self.getLanguage(rawRequest); }, get referer() { return self.getReferer(rawRequest); }, } as ServerRequest; } public getRequestId(request: ServerRequestData): string { return request.headers["x-request-id"] || this.cryptoProvider.randomUUID(); } public getRequestUserAgent(request: ServerRequestData) { return this.userAgentParser.parse(request.headers["user-agent"]); } public getRequestIp(request: ServerRequestData): string | undefined { // Only trust proxy headers when explicitly configured if (this.env.TRUST_PROXY) { const headers = request.headers; // X-Forwarded-For: standard proxy header (Cloudflare, Vercel, Nginx, etc.) const forwardedFor = headers["x-forwarded-for"]; if (forwardedFor) { return Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor.split(",")[0].trim(); } // X-Real-IP: alternative proxy header const xRealIP = headers["x-real-ip"]; if (xRealIP) { return Array.isArray(xRealIP) ? xRealIP[0] : xRealIP; } } // Default: use raw connection IP return this.getConnectionIp(request); } protected getConnectionIp(request: ServerRequestData): string | undefined { // Get IP from raw connection (Node.js socket) const nodeReq = request.raw.node?.req; if (nodeReq) { return nodeReq.socket?.remoteAddress; } return undefined; } // ───────────────────────────────────────────────────────────────────────────── // Geolocation // ───────────────────────────────────────────────────────────────────────────── public getRequestGeo(request: ServerRequestData): RequestGeo { const headers = request.headers; return { // Country: Cloudflare, Vercel, AWS CloudFront country: headers["cf-ipcountry"] || headers["x-vercel-ip-country"] || headers["cloudfront-viewer-country"], // City: Cloudflare, Vercel city: headers["cf-ipcity"] || headers["x-vercel-ip-city"], // Region: Cloudflare, Vercel region: headers["cf-region"] || headers["cf-region-code"] || headers["x-vercel-ip-country-region"], // Coordinates: Cloudflare, Vercel latitude: headers["cf-iplatitude"] || headers["x-vercel-ip-latitude"], longitude: headers["cf-iplongitude"] || headers["x-vercel-ip-longitude"], }; } // ───────────────────────────────────────────────────────────────────────────── // Bot detection // ───────────────────────────────────────────────────────────────────────────── protected static readonly BOT_PATTERNS = [ /bot/i, /crawl/i, /spider/i, /slurp/i, /googlebot/i, /bingbot/i, /yandex/i, /baiduspider/i, /facebookexternalhit/i, /twitterbot/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i, /discordbot/i, /slackbot/i, /applebot/i, /duckduckbot/i, /semrush/i, /ahrefsbot/i, /mj12bot/i, /dotbot/i, /petalbot/i, /bytespider/i, /gptbot/i, /claudebot/i, /anthropic/i, /curl/i, /wget/i, /python-requests/i, /axios/i, /node-fetch/i, /go-http-client/i, /java\//i, /libwww/i, /httpunit/i, /nutch/i, /phpcrawl/i, /biglotron/i, /teoma/i, /convera/i, /gigablast/i, /ia_archiver/i, /webmon/i, /httrack/i, /grub\.org/i, /netresearchserver/i, /speedy/i, /fluffy/i, /findlink/i, /panscient/i, /ips-agent/i, /yanga/i, /cyberpatrol/i, /postrank/i, /page2rss/i, /linkdex/i, /ezooms/i, /heritrix/i, /findthatfile/i, /europarchive\.org/i, /mappydata/i, /eright/i, /apercite/i, /aboundex/i, /domaincrawler/i, /wbsearchbot/i, /summify/i, /ccbot/i, /edisterbot/i, /seznambot/i, /ec2linkfinder/i, /gslfbot/i, /aihitbot/i, /intelium_bot/i, /yeti/i, /retrevopageanalyzer/i, /lb-spider/i, /sogou/i, /lssbot/i, /careerbot/i, /wotbox/i, /wocbot/i, /ichiro/i, /duckduckgo/i, /lssrocketcrawler/i, /drupact/i, /webcompanycrawler/i, /acoonbot/i, /openindexspider/i, /screaming frog/i, /pingdom/i, /uptimerobot/i, /headlesschrome/i, /phantomjs/i, /prerender/i, /lighthouse/i, /pagespeed/i, ]; public getIsBot(request: ServerRequestData): boolean { const ua = request.headers["user-agent"]; if (!ua) return false; return ServerRequestParser.BOT_PATTERNS.some((pattern) => pattern.test(ua)); } // ───────────────────────────────────────────────────────────────────────────── // Mobile detection // ───────────────────────────────────────────────────────────────────────────── protected static readonly MOBILE_PATTERNS = [ /android/i, /webos/i, /iphone/i, /ipad/i, /ipod/i, /blackberry/i, /iemobile/i, /opera mini/i, /mobile/i, /tablet/i, /kindle/i, /silk/i, /fennec/i, /windows phone/i, /windows ce/i, /symbian/i, /palm/i, /webmate/i, ]; public getIsMobile(request: ServerRequestData): boolean { const ua = request.headers["user-agent"]; if (!ua) return false; return ServerRequestParser.MOBILE_PATTERNS.some((pattern) => pattern.test(ua), ); } // ───────────────────────────────────────────────────────────────────────────── // Protocol detection // ───────────────────────────────────────────────────────────────────────────── public getProtocol(request: ServerRequestData): "http" | "https" { // Check proxy headers first const forwardedProto = request.headers["x-forwarded-proto"]; if (forwardedProto) { return forwardedProto.toLowerCase() === "https" ? "https" : "http"; } // Cloudflare-specific header const cfVisitorHeader = request.headers["cf-visitor"]; if (cfVisitorHeader) { try { const parsed = JSON.parse(cfVisitorHeader); if (parsed.scheme === "https") return "https"; } catch { // Ignore parse errors } } // Check URL scheme if (request.url.protocol === "https:") { return "https"; } return "http"; } // ───────────────────────────────────────────────────────────────────────────── // Language detection // ───────────────────────────────────────────────────────────────────────────── public getLanguage(request: ServerRequestData): string | undefined { const acceptLanguage = request.headers["accept-language"]; if (!acceptLanguage) return undefined; // Parse Accept-Language header // Format: "en-US,en;q=0.9,fr;q=0.8" const firstLang = acceptLanguage.split(",")[0]; if (!firstLang) return undefined; // Remove quality value if present (e.g., "en;q=0.9" -> "en") const lang = firstLang.split(";")[0].trim(); return lang || undefined; } // ───────────────────────────────────────────────────────────────────────────── // Referer parsing // ───────────────────────────────────────────────────────────────────────────── public getReferer(request: ServerRequestData): RequestReferer | undefined { const referer = request.headers.referer || request.headers.referrer; if (!referer) return undefined; try { const url = new URL(referer); return { url: referer, hostname: url.hostname, pathname: url.pathname, }; } catch { // Invalid URL return undefined; } } }