From 8547cc900f9f19b132161c0dd2eb85da75b497dd Mon Sep 17 00:00:00 2001 From: Muhammad Ali Anjum Date: Wed, 30 Sep 2026 16:07:03 +0200 Subject: [PATCH] Harden SEO preview for portfolio release --- .github/workflows/ci.yml | 42 +++ README.md | 128 ++++---- lib/safe-fetch.js | 158 ++++++++++ package.json | 19 +- pnpm-lock.yaml | 622 ++++++++++++++++++++++++++------------- server.js | 576 ++++++++++++++++++++---------------- test/app.test.js | 119 ++++++++ test/safe-fetch.test.js | 118 ++++++++ test/security.test.js | 63 ++++ 9 files changed, 1335 insertions(+), 510 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 lib/safe-fetch.js create mode 100644 test/app.test.js create mode 100644 test/safe-fetch.test.js create mode 100644 test/security.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8ccc2b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x, 22.x] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check syntax + run: node --check server.js && node --check lib/safe-fetch.js + + - name: Run tests + run: pnpm test + + - name: Audit production dependencies + run: pnpm audit --prod --audit-level=high diff --git a/README.md b/README.md index 313fdba..714e45b 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,110 @@ # SEO Audit & Social Media Preview -This Node.js application performs a basic SEO audit and displays social media previews of a website by analyzing its meta tags. It uses Express, Axios, and Cheerio on the backend and is styled with [Bootstrap 5](https://getbootstrap.com/) for a responsive, user-friendly interface. +A compact Node.js application that audits a public webpage's SEO metadata and renders Open Graph and Twitter Card previews. It demonstrates server-side HTML parsing, defensive outbound HTTP requests, secure rendering, and automated HTTP testing. ## Features -- **SEO Audit:** - Checks for key SEO elements such as: - - Title Tag - - Meta Description - - Canonical Link - - H1 Tag - - Language Attribute - - Robots Meta Tag - - Viewport Meta Tag - - Open Graph Tags (og:title, og:description, og:image, og:url) - - Twitter Card Tags (twitter:card, twitter:title, twitter:description, twitter:image) +- Checks title, description, canonical URL, H1, language, robots, and viewport metadata. +- Reports completeness of Open Graph and Twitter Card tags. +- Renders website, Facebook, Twitter, and TikTok-style preview cards. +- Lists discovered Open Graph and Twitter metadata for inspection. +- Resolves relative preview URLs against the audited page. +- Returns clear errors for invalid, unreachable, oversized, slow, or non-HTML targets. -- **Social Media Preview:** - Displays preview cards for Facebook, Twitter, and TikTok based on the extracted meta tags. +## Security and reliability -- **Responsive UI:** - Powered by Bootstrap 5 for a modern and clean design. +Because the application fetches user-provided URLs, outbound requests are treated as untrusted: -## Live Preview +- Only HTTP and HTTPS URLs without embedded credentials are accepted. +- Local, private, reserved, link-local, and non-public IPv4/IPv6 targets are blocked. +- DNS results and redirect destinations are checked to reduce SSRF risk. +- Requests have a 10-second timeout, five-redirect limit, and 2 MB response limit. +- Non-HTML responses are rejected. +- Metadata and URL attributes are escaped or sanitized before rendering. +- Helmet security headers, a Content Security Policy, and per-IP rate limiting are enabled. -Check out a live demo of the project here: [Live Demo](https://boiling-escarpment-36147-49817ed139e2.herokuapp.com/?url=https%3A%2F%2Fwww.wikipedia.com) +These controls are appropriate for a demonstration project, but a public high-traffic deployment should also enforce limits at its reverse proxy or hosting platform. -Alternatively, if you prefer to run the project locally, follow the installation and usage instructions below. +## Technology -## Installation +- Node.js and CommonJS +- Express 5 +- Axios +- Cheerio +- Bootstrap 5 +- Node's built-in test runner and Supertest -1. **Clone the Repository:** -``` - git clone https://github.com/alianjum0/ogpreview-node.git -``` +## Requirements -2. **Navigate to the Project Directory:** +- Node.js 20.18.1 or newer +- pnpm 10.33.0 -``` - cd seo-audit-preview -``` +The repository is pnpm-based. Avoid running npm and pnpm against the same `node_modules` directory. -3. **Install Dependencies:** +## Run locally -``` - npm install +```bash +corepack enable +pnpm install +pnpm start ``` -## Usage +Open [http://localhost:3001](http://localhost:3001), then enter a complete public URL such as `https://example.com`. -1. **Start the Server:** +To use another port: +```bash +PORT=4000 pnpm start ``` - npm start + +No database, API key, or environment file is required. + +If the application runs behind a reverse proxy, set the number of trusted proxy hops so rate limiting uses the client address safely: + +```bash +TRUST_PROXY_HOPS=1 pnpm start ``` -2. **Open Your Browser:** +## Tests - Navigate to "http://localhost:3000" and enter the URL you want to analyze in the input field. Click **Analyze** to view the SEO audit results and social media previews. +```bash +pnpm test +``` -## Project Structure +The test suite covers: + +- Private-network and DNS-based SSRF rejection +- IPv4 and IPv6 classification +- URL scheme and credential validation +- HTML and attribute escaping +- Unsafe preview URL removal +- Successful SEO and social metadata rendering +- HTTP security headers, error responses, and rate limiting + +## Project structure + +```text +. +├── .github/workflows/ci.yml # Node 20/22 CI checks +├── lib/safe-fetch.js # URL validation and bounded HTTP fetching +├── test/ # Unit and HTTP integration tests +├── server.js # Express app, metadata parsing, and rendering +├── package.json +└── pnpm-lock.yaml +``` -seo-audit-preview/ -├── server.js # Main Node.js application file -├── package.json # Node.js package file with dependencies and scripts -├── README.md # Project documentation -└── .gitignore # Files and directories to ignore in Git +## Deployment -## Dependencies +The included `Procfile` starts the application with `node server.js`, and the server honors the hosting platform's `PORT` environment variable. Deploy it to a Node.js host, verify the public URL, and then add that URL to the repository's GitHub **Website** field. -- [Express](https://expressjs.com/) -- [Axios](https://axios-http.com/) -- [Cheerio](https://cheerio.js.org/) -- [Bootstrap 5](https://getbootstrap.com/) +The previous Heroku demo is no longer linked because it currently returns 404. -## Contributing +## Known limitations -Contributions are welcome! Feel free to submit issues or pull requests if you have suggestions or improvements. +- Social previews approximate platform layouts; platforms may apply additional rules and image processing. +- JavaScript-rendered metadata is not executed because the application analyzes the fetched HTML response. +- Public websites may block automated requests or return different metadata based on geography or user agent. ## License -This project is licensed under the [MIT License](LICENSE). - +Licensed under the [MIT License](LICENSE). diff --git a/lib/safe-fetch.js b/lib/safe-fetch.js new file mode 100644 index 0000000..58c94bb --- /dev/null +++ b/lib/safe-fetch.js @@ -0,0 +1,158 @@ +const dns = require("node:dns"); +const net = require("node:net"); +const axios = require("axios"); +const ipaddr = require("ipaddr.js"); + +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 10_000; + +class TargetUrlError extends Error { + constructor(message) { + super(message); + this.name = "TargetUrlError"; + this.statusCode = 400; + } +} + +class FetchPageError extends Error { + constructor(message) { + super(message); + this.name = "FetchPageError"; + this.statusCode = 502; + } +} + +function isBlockedAddress(address) { + if (!ipaddr.isValid(address)) return true; + + let parsed = ipaddr.parse(address); + if (parsed.kind() === "ipv6" && parsed.isIPv4MappedAddress()) { + parsed = parsed.toIPv4Address(); + } + + return parsed.range() !== "unicast"; +} + +function normalizeTargetUrl(value) { + if (typeof value !== "string" || !value.trim()) { + throw new TargetUrlError("Enter a valid HTTP or HTTPS URL."); + } + + let parsed; + try { + parsed = new URL(value.trim()); + } catch { + throw new TargetUrlError("Enter a valid HTTP or HTTPS URL."); + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new TargetUrlError("Only HTTP and HTTPS URLs are supported."); + } + if (parsed.username || parsed.password) { + throw new TargetUrlError("URLs containing credentials are not supported."); + } + + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new TargetUrlError( + "Private and local network addresses are not allowed.", + ); + } + if (net.isIP(hostname) && isBlockedAddress(hostname)) { + throw new TargetUrlError( + "Private and local network addresses are not allowed.", + ); + } + + return parsed.toString(); +} + +function createSafeLookup(lookup = dns.lookup) { + return (hostname, options, callback) => { + const requestedOptions = + typeof options === "number" ? { family: options } : { ...options }; + + lookup( + hostname, + { ...requestedOptions, all: true, verbatim: true }, + (error, addresses) => { + if (error) return callback(error); + + const results = Array.isArray(addresses) ? addresses : [addresses]; + if (!results.length || results.some(({ address }) => isBlockedAddress(address))) { + return callback( + new TargetUrlError( + "Private and local network addresses are not allowed.", + ), + ); + } + + if (requestedOptions.all) return callback(null, results); + return callback(null, results[0].address, results[0].family); + }, + ); + }; +} + +function validateRedirect(options) { + const port = options.port ? `:${options.port}` : ""; + normalizeTargetUrl( + `${options.protocol}//${options.hostname}${port}${options.path || "/"}`, + ); +} + +async function fetchPage( + value, + { client = axios, lookup = dns.lookup } = {}, +) { + const url = normalizeTargetUrl(value); + + try { + const response = await client.get(url, { + beforeRedirect: validateRedirect, + headers: { + Accept: "text/html,application/xhtml+xml", + "User-Agent": "OGPreview/1.0 (+https://github.com/alianjum0/ogpreview-node)", + }, + lookup: createSafeLookup(lookup), + maxBodyLength: MAX_RESPONSE_BYTES, + maxContentLength: MAX_RESPONSE_BYTES, + maxRedirects: 5, + proxy: false, + responseType: "text", + timeout: REQUEST_TIMEOUT_MS, + }); + + const contentType = String(response.headers["content-type"] || "") + .split(";", 1)[0] + .trim() + .toLowerCase(); + if (!["text/html", "application/xhtml+xml"].includes(contentType)) { + throw new TargetUrlError("The target URL did not return an HTML page."); + } + + return response.data; + } catch (error) { + if (error instanceof TargetUrlError) throw error; + if (error.cause instanceof TargetUrlError) throw error.cause; + if (error.code === "ECONNABORTED") { + throw new FetchPageError("The target website took too long to respond."); + } + if ( + error.code === "ERR_BAD_RESPONSE" && + error.message.includes("maxContentLength") + ) { + throw new FetchPageError("The target page is larger than 2 MB."); + } + throw new FetchPageError("The target website could not be fetched."); + } +} + +module.exports = { + FetchPageError, + TargetUrlError, + createSafeLookup, + fetchPage, + isBlockedAddress, + normalizeTargetUrl, +}; diff --git a/package.json b/package.json index 1ecc7bc..b4b2016 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "description": "A Node.js application that performs a basic SEO audit and displays social media previews based on a website's meta tags.", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "node --test" }, + "packageManager": "pnpm@10.33.0", "engines": { - "node": ">=20.0.0" + "node": ">=20.18.1" }, "keywords": [ "seo", @@ -20,9 +22,14 @@ "author": "Muhammad Ali Anjum", "license": "MIT", "dependencies": { - "axios": "^1.4.0", - "cheerio": "^1.0.0-rc.12", - "express": "^4.18.2" + "axios": "^1.20.0", + "cheerio": "^1.2.0", + "express": "^5.2.1", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", + "ipaddr.js": "^2.5.0" + }, + "devDependencies": { + "supertest": "^7.3.0" } } - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2508dad..8eaa9e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,33 +9,57 @@ importers: .: dependencies: axios: - specifier: ^1.4.0 - version: 1.7.9 + specifier: ^1.20.0 + version: 1.20.0 cheerio: - specifier: ^1.0.0-rc.12 - version: 1.0.0 + specifier: ^1.2.0 + version: 1.2.0 express: - specifier: ^4.18.2 - version: 4.21.2 + specifier: ^5.2.1 + version: 5.2.1 + express-rate-limit: + specifier: ^8.7.0 + version: 8.7.0(express@5.2.1) + helmet: + specifier: ^8.3.0 + version: 8.3.0 + ipaddr.js: + specifier: ^2.5.0 + version: 2.5.0 + devDependencies: + supertest: + specifier: ^7.3.0 + version: 7.3.0 packages: - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.7.9: - resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -55,29 +79,40 @@ packages: cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.0.0: - resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} - engines: {node: '>=18.17'} + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} @@ -85,8 +120,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: @@ -101,9 +137,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -125,21 +160,25 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding-sniffer@0.2.0: - resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -152,6 +191,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -159,16 +202,25 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -176,17 +228,21 @@ packages: debug: optional: true - form-data@4.0.1: - resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -207,42 +263,74 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - htmlparser2@9.1.0: - resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} @@ -252,24 +340,29 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -282,60 +375,65 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} parse5-parser-stream@7.1.2: resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -346,34 +444,42 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + superagent@10.4.1: + resolution: {integrity: sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==} + engines: {node: '>=14.18.0'} + + supertest@7.3.0: + resolution: {integrity: sha512-UwwmWq3xLhyU96c521wYNaBg7IqX78Wpj4FKs6Katp6vdklX42zU5ESvX3KeZRLFyO44RwFp4yvxYMNZzCcX9g==} + engines: {node: '>=14.18.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} - undici@6.21.1: - resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} - engines: {node: '>=18.17'} + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -381,44 +487,59 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + snapshots: - accepts@1.3.8: + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + '@noble/hashes': 1.8.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color - array-flatten@1.1.1: {} + asap@2.0.6: {} asynckit@0.4.0: {} - axios@1.7.9: + axios@1.20.0: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.1 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color - body-parser@1.20.3: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -445,34 +566,38 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 - cheerio@1.0.0: + cheerio@1.2.0: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 domutils: 3.2.2 - encoding-sniffer: 0.2.0 - htmlparser2: 9.1.0 - parse5: 7.2.1 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 6.21.1 + undici: 7.30.0 whatwg-mimetype: 4.0.0 combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 + component-emitter@1.3.1: {} + + content-disposition@1.1.0: {} content-type@1.0.5: {} - cookie-signature@1.0.6: {} + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} cookie@0.7.1: {} + cookiejar@2.1.4: {} + css-select@5.1.0: dependencies: boolbase: 1.0.0 @@ -483,15 +608,18 @@ snapshots: css-what@6.1.0: {} - debug@2.6.9: + debug@4.4.3: dependencies: - ms: 2.0.0 + ms: 2.1.3 delayed-stream@1.0.0: {} depd@2.0.0: {} - destroy@1.2.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 dom-serializer@2.0.0: dependencies: @@ -519,17 +647,19 @@ snapshots: ee-first@1.1.1: {} - encodeurl@1.0.2: {} - encodeurl@2.0.0: {} - encoding-sniffer@0.2.0: + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 whatwg-encoding: 3.1.1 entities@4.5.0: {} + entities@6.0.1: {} + + entities@7.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -538,69 +668,90 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + escape-html@1.0.3: {} etag@1.8.1: {} - express@4.21.2: + express-rate-limit@8.7.0(express@5.2.1): dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.2 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 + cookie-signature: 1.2.2 + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 + finalhandler: 2.1.1 + fresh: 2.0.0 http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 on-finished: 2.4.1 + once: 1.4.0 parseurl: 1.3.3 - path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.16.0 range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color - finalhandler@1.3.1: + fast-safe-stringify@2.1.1: {} + + finalhandler@2.1.1: dependencies: - debug: 2.6.9 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 statuses: 2.0.1 - unpipe: 1.0.0 transitivePeerDependencies: - supports-color - follow-redirects@1.15.9: {} + follow-redirects@1.16.0: {} - form-data@4.0.1: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 mime-types: 2.1.35 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} - fresh@0.5.2: {} + fresh@2.0.0: {} function-bind@1.1.2: {} @@ -626,16 +777,26 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 - htmlparser2@9.1.0: + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 4.5.0 + entities: 7.0.1 http-errors@2.0.0: dependencies: @@ -645,39 +806,66 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 - iconv-lite@0.4.24: + http-errors@2.0.1: dependencies: - safer-buffer: 2.1.2 + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + inherits@2.0.4: {} + ip-address@10.7.2: {} + ipaddr.js@1.9.1: {} + ipaddr.js@2.5.0: {} + + is-promise@4.0.0: {} + math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} + media-typer@1.1.1: {} - merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} methods@1.1.2: {} mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 - ms@2.0.0: {} + mime@2.6.0: {} ms@2.1.3: {} - negotiator@0.6.3: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 nth-check@2.1.1: dependencies: @@ -689,77 +877,88 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 - parse5: 7.2.1 + parse5: 7.3.0 parse5-parser-stream@7.1.2: dependencies: - parse5: 7.2.1 + parse5: 7.3.0 - parse5@7.2.1: + parse5@7.3.0: dependencies: - entities: 4.5.0 + entities: 6.0.1 parseurl@1.3.3: {} - path-to-regexp@0.1.12: {} + path-to-regexp@8.4.2: {} proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} - qs@6.13.0: + qs@6.16.0: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@3.0.2: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 + http-errors: 2.0.1 + iconv-lite: 0.7.3 unpipe: 1.0.0 - safe-buffer@5.2.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color safer-buffer@2.1.2: {} - send@0.19.0: + send@1.2.1: dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 + debug: 4.4.3 + encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color - serve-static@1.16.2: + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.0 + send: 1.2.1 transitivePeerDependencies: - supports-color setprototypeof@1.2.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -779,29 +978,52 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 statuses@2.0.1: {} + statuses@2.0.2: {} + + superagent@10.4.1: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.16.0 + transitivePeerDependencies: + - supports-color + + supertest@7.3.0: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.4.1 + transitivePeerDependencies: + - supports-color + toidentifier@1.0.1: {} - type-is@1.6.18: + type-is@2.1.0: dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 - undici@6.21.1: {} + undici@7.30.0: {} unpipe@1.0.0: {} - utils-merge@1.0.1: {} - vary@1.1.2: {} whatwg-encoding@3.1.1: @@ -809,3 +1031,5 @@ snapshots: iconv-lite: 0.6.3 whatwg-mimetype@4.0.0: {} + + wrappy@1.0.2: {} diff --git a/server.js b/server.js index f82fa1e..10d64fb 100644 --- a/server.js +++ b/server.js @@ -1,287 +1,355 @@ -const express = require('express'); -const axios = require('axios'); -const cheerio = require('cheerio'); -const app = express(); +const cheerio = require("cheerio"); +const express = require("express"); +const rateLimit = require("express-rate-limit"); +const helmet = require("helmet"); +const { fetchPage } = require("./lib/safe-fetch"); -// Helper: escape HTML for safe output. -function escapeHtml(text) { - if (!text) return ''; - return text +const PLACEHOLDER_IMAGE = "https://placehold.co/600x315?text=No+Image"; + +function escapeHtml(value) { + if (value === undefined || value === null) return ""; + return String(value) .replace(/&/g, "&") .replace(//g, ">"); + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } -app.get('/', async (req, res) => { - const url = req.query.url; - let content = ` -
-
-
-

SEO Audit & Social Media Preview

-
-
- - -
-
-
-
- `; +function safeWebUrl(value, baseUrl, fallback = "") { + try { + const parsed = new URL(value, baseUrl); + return ["http:", "https:"].includes(parsed.protocol) + ? parsed.toString() + : fallback; + } catch { + return fallback; + } +} - if (url) { - try { - // Fetch the target URL - const response = await axios.get(url); - const html = response.data; - const $ = cheerio.load(html); +function metaContent($, selector) { + return ($(selector).attr("content") || "").trim(); +} - // === Basic SEO Elements === - const title = $('title').text().trim(); - const metaDescription = $('meta[name="description"]').attr('content') - ? $('meta[name="description"]').attr('content').trim() - : ''; - const canonical = $('link[rel="canonical"]').attr('href') - ? $('link[rel="canonical"]').attr('href').trim() - : ''; - const firstH1 = $('h1').first().text().trim(); - const lang = $('html').attr('lang') - ? $('html').attr('lang').trim() - : ''; - const robots = $('meta[name="robots"]').attr('content') - ? $('meta[name="robots"]').attr('content').trim() - : ''; - const viewport = $('meta[name="viewport"]').attr('content') - ? $('meta[name="viewport"]').attr('content').trim() - : ''; +function renderAnalysis(html, targetUrl) { + const $ = cheerio.load(html); + const title = $("title").text().trim(); + const metaDescription = metaContent($, 'meta[name="description"]'); + const canonical = ($('link[rel="canonical"]').attr("href") || "").trim(); + const firstH1 = $("h1").first().text().trim(); + const lang = ($("html").attr("lang") || "").trim(); + const robots = metaContent($, 'meta[name="robots"]'); + const viewport = metaContent($, 'meta[name="viewport"]'); - // === Open Graph Tags === - const ogTitle = $('meta[property="og:title"]').attr('content') || ''; - const ogDescription = $('meta[property="og:description"]').attr('content') || ''; - const ogImage = $('meta[property="og:image"]').attr('content') || "https://via.placeholder.com/600x315.png?text=No+Image"; - const ogUrl = $('meta[property="og:url"]').attr('content') || url; + const ogTitle = metaContent($, 'meta[property="og:title"]'); + const ogDescription = metaContent($, 'meta[property="og:description"]'); + const ogImage = safeWebUrl( + metaContent($, 'meta[property="og:image"]'), + targetUrl, + PLACEHOLDER_IMAGE, + ); + const ogUrl = safeWebUrl( + metaContent($, 'meta[property="og:url"]'), + targetUrl, + targetUrl, + ); + const twitterCard = metaContent($, 'meta[name="twitter:card"]'); + const twitterTitle = metaContent($, 'meta[name="twitter:title"]'); + const twitterDescription = metaContent( + $, + 'meta[name="twitter:description"]', + ); + const twitterImage = safeWebUrl( + metaContent($, 'meta[name="twitter:image"]'), + targetUrl, + ogImage, + ); - // === Twitter Card Tags === - const twitterCard = $('meta[name="twitter:card"]').attr('content') || ''; - const twitterTitle = $('meta[name="twitter:title"]').attr('content') || ''; - const twitterDescription = $('meta[name="twitter:description"]').attr('content') || ''; - const twitterImage = $('meta[name="twitter:image"]').attr('content') || ogImage; + const seoChecks = [ + { + name: "Title Tag", + status: title ? "Found" : "Missing", + suggestion: title + ? title.length < 30 || title.length > 60 + ? "Title length should be between 30 and 60 characters." + : "Looks good!" + : "Add a tag to the page.", + }, + { + name: "Meta Description", + status: metaDescription ? "Found" : "Missing", + suggestion: metaDescription + ? metaDescription.length < 50 || metaDescription.length > 160 + ? "Meta description should be between 50 and 160 characters." + : "Looks good!" + : "Add a meta description for better SEO.", + }, + { + name: "Canonical Tag", + status: canonical ? "Found" : "Missing", + suggestion: canonical + ? "Looks good!" + : "Add a canonical tag to avoid duplicate content issues.", + }, + { + name: "H1 Tag", + status: firstH1 ? "Found" : "Missing", + suggestion: firstH1 + ? "Looks good!" + : "Add at least one <h1> tag for the main heading.", + }, + { + name: "Language Attribute", + status: lang ? `Found (${lang})` : "Missing", + suggestion: lang + ? "Looks good!" + : 'Specify the language attribute in the <html> tag, e.g., <html lang="en">.', + }, + { + name: "Robots Meta Tag", + status: robots ? "Found" : "Missing", + suggestion: robots + ? "Looks good!" + : 'Add a robots meta tag, e.g., <meta name="robots" content="index,follow">.', + }, + { + name: "Viewport Meta Tag", + status: viewport ? "Found" : "Missing", + suggestion: viewport + ? "Looks good!" + : "Add a viewport meta tag to ensure mobile responsiveness.", + }, + { + name: "Open Graph Tags", + status: + ogTitle && ogDescription && ogImage && ogUrl ? "Complete" : "Incomplete", + suggestion: + ogTitle && ogDescription && ogImage && ogUrl + ? "Looks good!" + : "Ensure all required OG tags are present: og:title, og:description, og:image, and og:url.", + }, + { + name: "Twitter Card Tags", + status: + twitterCard && twitterTitle && twitterDescription && twitterImage + ? "Complete" + : "Incomplete", + suggestion: + twitterCard && twitterTitle && twitterDescription && twitterImage + ? "Looks good!" + : "Consider adding Twitter Card tags for better social sharing.", + }, + ]; - // === SEO Audit Checks === - const seoChecks = [ - { - name: 'Title Tag', - status: title ? 'Found' : 'Missing', - suggestion: title - ? ((title.length < 30 || title.length > 60) ? 'Title length should be between 30 and 60 characters.' : 'Looks good!') - : 'Add a <title> tag to the page.' - }, - { - name: 'Meta Description', - status: metaDescription ? 'Found' : 'Missing', - suggestion: metaDescription - ? ((metaDescription.length < 50 || metaDescription.length > 160) ? 'Meta description should be between 50 and 160 characters.' : 'Looks good!') - : 'Add a meta description for better SEO.' - }, - { - name: 'Canonical Tag', - status: canonical ? 'Found' : 'Missing', - suggestion: canonical ? 'Looks good!' : 'Add a canonical tag to avoid duplicate content issues.' - }, - { - name: 'H1 Tag', - status: firstH1 ? 'Found' : 'Missing', - suggestion: firstH1 ? 'Looks good!' : 'Add at least one <h1> tag for the main heading.' - }, - { - name: 'Language Attribute', - status: lang ? `Found (${lang})` : 'Missing', - suggestion: lang ? 'Looks good!' : 'Specify the language attribute in the <html> tag, e.g., <html lang="en">.' - }, - { - name: 'Robots Meta Tag', - status: robots ? 'Found' : 'Missing', - suggestion: robots ? 'Looks good!' : 'Add a robots meta tag to control search engine crawling, e.g., <meta name="robots" content="index,follow">.' - }, - { - name: 'Viewport Meta Tag', - status: viewport ? 'Found' : 'Missing', - suggestion: viewport ? 'Looks good!' : 'Add a viewport meta tag to ensure mobile responsiveness.' - }, - { - name: 'Open Graph Tags', - status: (ogTitle && ogDescription && ogImage && ogUrl) ? 'Complete' : 'Incomplete', - suggestion: (ogTitle && ogDescription && ogImage && ogUrl) - ? 'Looks good!' - : 'Ensure all required OG tags are present: og:title, og:description, og:image, and og:url.' - }, - { - name: 'Twitter Card Tags', - status: (twitterCard && twitterTitle && twitterDescription && twitterImage) ? 'Complete' : 'Incomplete', - suggestion: (twitterCard && twitterTitle && twitterDescription && twitterImage) - ? 'Looks good!' - : 'Consider adding Twitter Card tags for better social sharing.' - } - ]; + const seoAuditRows = seoChecks + .map( + (check) => ` + <tr> + <td>${escapeHtml(check.name)}</td> + <td>${escapeHtml(check.status)}</td> + <td>${escapeHtml(check.suggestion)}</td> + </tr>`, + ) + .join(""); - // Build the SEO Audit Table rows - let seoAuditRows = ''; - seoChecks.forEach(check => { - seoAuditRows += ` - <tr> - <td>${escapeHtml(check.name)}</td> - <td>${escapeHtml(check.status)}</td> - <td>${escapeHtml(check.suggestion)}</td> - </tr> - `; - }); + const metaTags = {}; + $("meta").each((_index, element) => { + const property = $(element).attr("property"); + const name = $(element).attr("name"); + const value = $(element).attr("content"); + if (!value) return; + if (property?.startsWith("og:") || property?.startsWith("twitter:")) { + metaTags[property] = value; + } + if (name?.startsWith("og:") || name?.startsWith("twitter:")) { + metaTags[name] = value; + } + }); - // === Collect All OG/Twitter Meta Tags (for reference) === - let metaTags = {}; - $('meta').each((i, el) => { - const property = $(el).attr('property'); - const nameAttr = $(el).attr('name'); - const contentAttr = $(el).attr('content'); - if (contentAttr) { - if (property && (property.startsWith('og:') || property.startsWith('twitter:'))) { - metaTags[property] = contentAttr; - } - if (nameAttr && (nameAttr.startsWith('og:') || nameAttr.startsWith('twitter:'))) { - metaTags[nameAttr] = contentAttr; - } - } - }); + const metaRows = Object.entries(metaTags) + .map( + ([key, value]) => ` + <tr> + <td>${escapeHtml(key)}</td> + <td>${escapeHtml(value)}</td> + </tr>`, + ) + .join(""); - let metaRows = ''; - for (const [key, value] of Object.entries(metaTags)) { - metaRows += ` - <tr> - <td>${escapeHtml(key)}</td> - <td>${escapeHtml(value)}</td> - </tr>`; - } + const previewTitle = escapeHtml(ogTitle || title || "No Title Found"); + const previewDescription = escapeHtml( + ogDescription || metaDescription || "No Description Found", + ); + const facebookTitle = escapeHtml(ogTitle || "No Title"); + const facebookDescription = escapeHtml(ogDescription || "No Description"); + const safeOgImage = escapeHtml(ogImage); + const safeTwitterImage = escapeHtml(twitterImage); + const safeOgUrl = escapeHtml(ogUrl); - // === Build the Output HTML === + return ` + <h2 class="mb-3">SEO Audit</h2> + <div class="table-responsive mb-5"> + <table class="table table-bordered"> + <thead class="table-light"> + <tr><th>SEO Element</th><th>Status</th><th>Suggestion</th></tr> + </thead> + <tbody>${seoAuditRows}</tbody> + </table> + </div> - // SEO Audit Section - content += ` - <h2 class="mb-3">SEO Audit</h2> - <div class="table-responsive mb-5"> - <table class="table table-bordered"> - <thead class="table-light"> - <tr> - <th>SEO Element</th> - <th>Status</th> - <th>Suggestion</th> - </tr> - </thead> - <tbody> - ${seoAuditRows} - </tbody> - </table> - </div> - `; + <h2 class="mb-3">Website Preview</h2> + <div class="card mb-4"> + <img src="${safeOgImage}" class="card-img-top" alt="OG Image"> + <div class="card-body"> + <h5 class="card-title">${previewTitle}</h5> + <p class="card-text">${previewDescription}</p> + <a href="${safeOgUrl}" class="btn btn-primary" target="_blank" rel="noopener noreferrer">${safeOgUrl}</a> + </div> + </div> - // Website Preview Section (using OG data) - content += ` - <h2 class="mb-3">Website Preview</h2> - <div class="card mb-4"> - <img src="${ogImage}" class="card-img-top" alt="OG Image"> + <h2 class="mb-3">Social Media Previews</h2> + <div class="row mb-4 g-3"> + <div class="col-md-4"> + <div class="card h-100"> + <img src="${safeOgImage}" class="card-img-top" alt="Facebook Preview"> <div class="card-body"> - <h5 class="card-title">${ogTitle || title || 'No Title Found'}</h5> - <p class="card-text">${ogDescription || metaDescription || 'No Description Found'}</p> - <a href="${ogUrl}" class="btn btn-primary" target="_blank">${ogUrl}</a> + <h5 class="card-title">${facebookTitle}</h5> + <p class="card-text">${facebookDescription}</p> + <span class="badge bg-primary">Facebook</span> </div> </div> - `; - - // Social Media Preview Section - content += ` - <h2 class="mb-3">Social Media Previews</h2> - <div class="row mb-4"> - <div class="col-md-4"> - <div class="card"> - <img src="${ogImage}" class="card-img-top" alt="Facebook Preview"> - <div class="card-body"> - <h5 class="card-title">${ogTitle || 'No Title'}</h5> - <p class="card-text">${ogDescription || 'No Description'}</p> - <span class="badge bg-primary">Facebook</span> - </div> - </div> - </div> - <div class="col-md-4"> - <div class="card"> - <img src="${twitterImage}" class="card-img-top" alt="Twitter Preview"> - <div class="card-body"> - <h5 class="card-title">${twitterTitle || ogTitle || 'No Title'}</h5> - <p class="card-text">${twitterDescription || ogDescription || 'No Description'}</p> - <span class="badge bg-info text-dark">Twitter</span> - </div> - </div> - </div> - <div class="col-md-4"> - <div class="card"> - <img src="${ogImage}" class="card-img-top" alt="TikTok Preview"> - <div class="card-body"> - <h5 class="card-title">${ogTitle || 'No Title'}</h5> - <p class="card-text">${ogDescription || 'No Description'}</p> - <span class="badge bg-dark">TikTok</span> - </div> - </div> + </div> + <div class="col-md-4"> + <div class="card h-100"> + <img src="${safeTwitterImage}" class="card-img-top" alt="Twitter Preview"> + <div class="card-body"> + <h5 class="card-title">${escapeHtml(twitterTitle || ogTitle || "No Title")}</h5> + <p class="card-text">${escapeHtml(twitterDescription || ogDescription || "No Description")}</p> + <span class="badge bg-info text-dark">Twitter</span> </div> </div> - `; - - // All Meta Tags Section - content += ` - <h2 class="mb-3">All OG/Twitter Meta Tags</h2> - <div class="table-responsive mb-5"> - <table class="table table-striped"> - <thead class="table-light"> - <tr> - <th>Tag</th> - <th>Content</th> - </tr> - </thead> - <tbody> - ${metaRows} - </tbody> - </table> - </div> - `; - } catch (error) { - content += ` - <div class="alert alert-danger" role="alert"> - Error fetching URL: ${escapeHtml(error.message)} + </div> + <div class="col-md-4"> + <div class="card h-100"> + <img src="${safeOgImage}" class="card-img-top" alt="TikTok Preview"> + <div class="card-body"> + <h5 class="card-title">${facebookTitle}</h5> + <p class="card-text">${facebookDescription}</p> + <span class="badge bg-dark">TikTok</span> + </div> </div> - `; - } - } + </div> + </div> - content += `</div>`; // Close container + <h2 class="mb-3">All OG/Twitter Meta Tags</h2> + <div class="table-responsive mb-5"> + <table class="table table-striped"> + <thead class="table-light"><tr><th>Tag</th><th>Content</th></tr></thead> + <tbody>${metaRows}</tbody> + </table> + </div>`; +} - // Wrap content in full HTML with Bootstrap - const htmlResponse = ` - <!DOCTYPE html> +function renderPage(content) { + return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>SEO Audit & Preview + + SEO Audit & Social Preview - - ${content} - + +
+
+

SEO Audit & Social Media Preview

+

Inspect the SEO, Open Graph, and Twitter Card metadata of a public webpage.

+ ${content} +
+
- - `; - res.send(htmlResponse); -}); + `; +} -// Start the server -const PORT = process.env.PORT || 3000; -app.listen(PORT, () => { - console.log(`Server is running on http://localhost:${PORT}`); -}); +function createApp({ + pageFetcher = fetchPage, + rateLimitOptions = {}, + trustProxy = false, +} = {}) { + const app = express(); + app.set("trust proxy", trustProxy); + app.disable("x-powered-by"); + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + imgSrc: ["'self'", "data:", "http:", "https:"], + scriptSrc: ["'none'"], + styleSrc: ["'self'", "https://cdn.jsdelivr.net"], + }, + }, + }), + ); + + const analysisLimiter = rateLimit({ + legacyHeaders: false, + limit: 30, + message: "Too many analysis requests. Please try again later.", + standardHeaders: "draft-8", + windowMs: 15 * 60 * 1000, + ...rateLimitOptions, + }); + + app.get( + "/", + (req, res, next) => + req.query.url ? analysisLimiter(req, res, next) : next(), + async (req, res) => { + const requestedUrl = typeof req.query.url === "string" ? req.query.url : ""; + let content = ` +
+ +
+ + +
+
`; + + if (requestedUrl) { + try { + const html = await pageFetcher(requestedUrl); + content += renderAnalysis(html, requestedUrl); + } catch (error) { + const isPublicError = Number.isInteger(error.statusCode); + res.status(isPublicError ? error.statusCode : 502); + content += ` + `; + } + } + + res.type("html").send(renderPage(content)); + }, + ); + + return app; +} + +if (require.main === module) { + const port = process.env.PORT || 3001; + const trustProxy = /^\d+$/.test(process.env.TRUST_PROXY_HOPS || "") + ? Number(process.env.TRUST_PROXY_HOPS) + : false; + createApp({ trustProxy }).listen(port, () => { + console.log(`Server is running on http://localhost:${port}`); + }); +} +module.exports = { createApp, escapeHtml, renderAnalysis, safeWebUrl }; diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 0000000..45957ef --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,119 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const request = require("supertest"); +const { createApp, escapeHtml, safeWebUrl } = require("../server"); + +const fixtureHtml = ` + + + A useful fixture title for search results + + + + + + + + + + +

Fixture heading

+ `; + +test("renders a successful SEO and social metadata audit", async () => { + const app = createApp({ pageFetcher: async () => fixtureHtml }); + const response = await request(app) + .get("/") + .query({ url: "https://example.com/source" }) + .expect(200); + + assert.match(response.text, /Fixture Open Graph title/); + assert.match(response.text, /https:\/\/example\.com\/social\.png/); + assert.match(response.text, /https:\/\/example\.com\/article/); + assert.match(response.text, /SEO Audit/); +}); + +test("escapes untrusted metadata and rejects unsafe preview URLs", async () => { + const maliciousHtml = ` + Safe fallback + + + + `; + const app = createApp({ pageFetcher: async () => maliciousHtml }); + const response = await request(app) + .get("/") + .query({ url: "https://example.com/" }) + .expect(200); + + assert.doesNotMatch(response.text, /