commit c0e37812448d2beb027caa578bd62b10457bee86 Author: Caileb Date: Mon May 26 22:25:42 2025 -0500 Initial commit: Upload Checkpoint project diff --git a/.cursor/rules/coding-practices.mdc b/.cursor/rules/coding-practices.mdc new file mode 100644 index 0000000..2c7a985 --- /dev/null +++ b/.cursor/rules/coding-practices.mdc @@ -0,0 +1,16 @@ +--- +description: +globs: +alwaysApply: true +--- +# Code Quality Standards + +- Follow established industry best practices for each programming language +- Maintain consistent code style throughout the project +- Write self-documenting code with descriptive variable and function names +- Keep functions small and focused on a single responsibility +- Add appropriate error handling where necessary +- Include comments only for complex logic that requires additional context +- Write DRY (Don't Repeat Yourself) code that minimizes duplication +- Consider performance implications of implemented solutions + diff --git a/.cursor/rules/comments.mdc b/.cursor/rules/comments.mdc new file mode 100644 index 0000000..b93c988 --- /dev/null +++ b/.cursor/rules/comments.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/conversation-style.mdc b/.cursor/rules/conversation-style.mdc new file mode 100644 index 0000000..35bbbb3 --- /dev/null +++ b/.cursor/rules/conversation-style.mdc @@ -0,0 +1,13 @@ +--- +description: +globs: +alwaysApply: true +--- +# Conversation Style Guidelines + +- Maintain a concise, direct communication style in all responses +- Focus on delivering information efficiently without unnecessary filler words +- Prioritize clarity and brevity while keeping responses informative +- Avoid repetitive phrasing or excessive explanations unless specifically requested +- Use straightforward language that conveys the point clearly and precisely + diff --git a/.cursor/rules/css-animations.mdc b/.cursor/rules/css-animations.mdc new file mode 100644 index 0000000..d5e74c3 --- /dev/null +++ b/.cursor/rules/css-animations.mdc @@ -0,0 +1,17 @@ +--- +description: +globs: *.css,*.html +alwaysApply: false +--- +# CSS Animation Excellence Standards + +- Prioritize CSS-based animations over JavaScript for better performance +- Target buttery-smooth 60fps animations with clean transitions +- Use hardware-accelerated properties (transform, opacity) whenever possible +- Implement appropriate easing functions that feel natural and responsive +- Ensure animations are subtle and enhance user experience without being distracting +- Design animations that meet Apple-level quality standards for smoothness and polish +- Consider reduced-motion preferences for accessibility +- Optimize animation performance by minimizing repaints and reflows +- Test animations across different device capabilities to ensure consistent experience + diff --git a/.cursor/rules/js-ts-naming.mdc b/.cursor/rules/js-ts-naming.mdc new file mode 100644 index 0000000..e602eff --- /dev/null +++ b/.cursor/rules/js-ts-naming.mdc @@ -0,0 +1,16 @@ +--- +description: +globs: *.ts,*.js +alwaysApply: false +--- +# JavaScript/TypeScript Naming Conventions + +- Use camelCase for all variable names (`userProfile`, `itemCount`, `fetchData`) +- Use camelCase for function and method names (`calculateTotal`, `getUserInfo`) +- Use SCREAMING_SNAKE_CASE for constants (`API_KEY`, `MAX_ATTEMPTS`, `DEFAULT_TIMEOUT`) +- Use PascalCase for class and component names (`UserProfile`, `PaymentForm`) +- Use descriptive names that clearly indicate the purpose or content +- Avoid single-letter variable names except in small loop contexts +- Prefix boolean variables with "is", "has", or "should" for clarity +- Maintain consistent naming patterns throughout the codebase + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78bc649 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Data Folder +data + +# DB Folder +db \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e78a706 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "semi": true +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..00d40d6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,158 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "bunbun", + "dependencies": { + "@iarna/toml": "^2.2.5", + "cookie": "^1.0.2", + "dotenv": "^16.5.0", + "express-http-proxy": "^2.1.1", + "http-proxy": "^1.18.1", + "level": "^10.0.0", + "level-ttl": "^3.1.1", + "maxmind": "^4.3.25", + "string-dsa": "^2.1.0", + "tar": "^7.4.3", + "tar-stream": "^3.1.7", + "toml": "^3.0.0", + }, + "devDependencies": { + "prettier": "^2.8.8", + }, + }, + }, + "packages": { + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "abstract-level": ["abstract-level@3.1.0", "", { "dependencies": { "buffer": "^6.0.3", "is-buffer": "^2.0.5", "level-supports": "^6.2.0", "level-transcoder": "^1.0.1", "maybe-combine-errors": "^1.0.0", "module-error": "^1.0.1" } }, "sha512-j2e+TsAxy7Ri+0h7dJqwasymgt0zHBWX4+nMk3XatyuqgHfdstBJ9wsMfbiGwE1O+QovRyPcVAqcViMYdyPaaw=="], + + "after": ["after@0.8.2", "", {}, "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA=="], + + "b4a": ["b4a@1.6.7", "", {}, "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg=="], + + "bare-events": ["bare-events@2.5.4", "", {}, "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "browser-level": ["browser-level@3.0.0", "", { "dependencies": { "abstract-level": "^3.1.0" } }, "sha512-kGXtLh29jMwqKaskz5xeDLtCtN1KBz/DbQSqmvH7QdJiyGRC7RAM8PPg6gvUiNMa+wVnaxS9eSmEtP/f5ajOVw=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "classic-level": ["classic-level@3.0.0", "", { "dependencies": { "abstract-level": "^3.1.0", "module-error": "^1.0.1", "napi-macros": "^2.2.2", "node-gyp-build": "^4.3.0" } }, "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ=="], + + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="], + + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "express-http-proxy": ["express-http-proxy@2.1.1", "", { "dependencies": { "debug": "^3.0.1", "es6-promise": "^4.1.1", "raw-body": "^2.3.0" } }, "sha512-4aRQRqDQU7qNPV5av0/hLcyc0guB9UP71nCYrQEYml7YphTo8tmWf3nDZWdTJMMjFikyz9xKXaURor7Chygdwg=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-buffer": ["is-buffer@2.0.5", "", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "level": ["level@10.0.0", "", { "dependencies": { "abstract-level": "^3.1.0", "browser-level": "^3.0.0", "classic-level": "^3.0.0" } }, "sha512-aZJvdfRr/f0VBbSRF5C81FHON47ZsC2TkGxbBezXpGGXAUEL/s6+GP73nnhAYRSCIqUNsmJjfeOF4lzRDKbUig=="], + + "level-supports": ["level-supports@6.2.0", "", {}, "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w=="], + + "level-transcoder": ["level-transcoder@1.0.1", "", { "dependencies": { "buffer": "^6.0.3", "module-error": "^1.0.1" } }, "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w=="], + + "level-ttl": ["level-ttl@3.1.1", "", { "dependencies": { "after": ">=0.8.1 <0.9.0-0", "list-stream": ">=1.0.0 <1.1.0-0", "lock": "~0.1.2", "xtend": ">=4.0.0 <4.1.0-0" } }, "sha512-OeiHOD2IPkmdLqqU4feVJL7mnZX/Q03WEClrQi5t9558alkajVaecCgwJQZVVL/zFR9q74n5pWN1eozifa1Ghw=="], + + "list-stream": ["list-stream@1.0.1", "", { "dependencies": { "readable-stream": "~2.0.5", "xtend": "~4.0.1" } }, "sha512-XheYsTtN+/nay6Co4N9NlTjQzo1ohknNlDJfxTeH0tvvssxBINUXwmjqPtj8+7rYMBwTRb3kO8C8d6ogeRwD1A=="], + + "lock": ["lock@0.1.4", "", {}, "sha512-IcEe2R+NA7WgM622ppgmJFCFZl20f2owsA1YiJg7qpvO0wdOgOuZdfhQMxCYXdESVX+QIF/eikE4hB5ZPM2ipA=="], + + "maxmind": ["maxmind@4.3.25", "", { "dependencies": { "mmdb-lib": "2.2.0", "tiny-lru": "11.2.11" } }, "sha512-u7L6LrbXZUtpdoovTVHo/l4/EoWUT2eHfCKWDMNNTsW9BaLa7h0jCHjqVx5ZeS5aWorLGZSsZwqxcpoollBw1g=="], + + "maybe-combine-errors": ["maybe-combine-errors@1.0.0", "", {}, "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "mmdb-lib": ["mmdb-lib@2.2.0", "", {}, "sha512-V6DDh3v8tfZFWbeH6fsL5uBIlWL7SvRgGDaAZWFC5kjQ2xP5dl/mLpWwJQ1Ho6ZbEKVp/351QF1JXYTAmeZ/zA=="], + + "module-error": ["module-error@1.0.2", "", {}, "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-macros": ["napi-macros@2.2.2", "", {}, "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + + "process-nextick-args": ["process-nextick-args@1.0.7", "", {}, "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw=="], + + "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], + + "readable-stream": ["readable-stream@2.0.6", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "~1.0.0", "process-nextick-args": "~1.0.6", "string_decoder": "~0.10.x", "util-deprecate": "~1.0.1" } }, "sha512-TXcFfb63BQe1+ySzsHZI/5v1aJPCShfqvWJ64ayNImXMsN1Cd0YGk/wm8KB7/OeessgPc9QvS9Zou8QTkFzsLw=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "streamx": ["streamx@2.22.0", "", { "dependencies": { "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" }, "optionalDependencies": { "bare-events": "^2.2.0" } }, "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw=="], + + "string-dsa": ["string-dsa@2.1.0", "", {}, "sha512-ht+H83VtdA0JXmZsRfhQYzUSwqK3T7STPqiD/u3bIvYUHLEw8zzZyvP9WI3l9uKbK/2IpU+ZdshAe5BoRil3wA=="], + + "string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + + "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + + "tiny-lru": ["tiny-lru@11.2.11", "", {}, "sha512-27BIW0dIWTYYoWNnqSmoNMKe5WIbkXsc0xaCQHd3/3xT2XMuMJrzHdrO9QBFR14emBz1Bu0dOAs2sCBBrvgPQA=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + } +} diff --git a/checkpoint.js b/checkpoint.js new file mode 100644 index 0000000..d1d4a3f --- /dev/null +++ b/checkpoint.js @@ -0,0 +1,961 @@ +import { registerPlugin, loadConfig, rootDir } from './index.js'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { Level } from 'level'; +import cookie from 'cookie'; +import { parseDuration } from './utils/time.js'; +import { getRealIP } from './utils/network.js'; +import ttl from 'level-ttl'; +import { Readable } from 'stream'; +import { + challengeStore, + generateRequestID as proofGenerateRequestID, + getChallengeParams, + deleteChallenge, + verifyPoW, + verifyPoS, +} from './utils/proof.js'; +// Import recordEvent dynamically to avoid circular dependency issues +let recordEvent; +let statsLoadPromise = import('./plugins/stats.js') + .then((stats) => { + recordEvent = stats.recordEvent; + }) + .catch((err) => { + console.error('Failed to import stats module:', err); + recordEvent = null; + }); + +function sanitizePath(inputPath) { + let pathOnly = inputPath.replace(/[\x00-\x1F\x7F]/g, ''); + + pathOnly = pathOnly.replace(/[<>;"'`|]/g, ''); + + const parts = pathOnly.split('/').filter((seg) => seg && seg !== '.' && seg !== '..'); + + return '/' + parts.map((seg) => encodeURIComponent(seg)).join('/'); +} + +const checkpointConfig = {}; +let hmacSecret = null; +const usedNonces = new Map(); +const ipRateLimit = new Map(); + +const tokenCache = new Map(); + +let db; + +const tokenExpirations = new Map(); + +let interstitialTemplate = null; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function simpleTemplate(str) { + return function (data) { + return str.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => { + let value = data; + + for (const part of key.trim().split('.')) { + value = value?.[part]; + if (value == null) break; + } + return value != null ? String(value) : ''; + }); + }; +} + +async function initConfig() { + await loadConfig('checkpoint', checkpointConfig); + + // Handle new nested configuration structure + // Map nested structure to flat structure for internal use + checkpointConfig.Enabled = checkpointConfig.Core.Enabled; + checkpointConfig.CookieName = checkpointConfig.Core.CookieName; + checkpointConfig.CookieDomain = checkpointConfig.Core.CookieDomain; + checkpointConfig.SanitizeURLs = checkpointConfig.Core.SanitizeURLs; + + // Proof of Work settings + checkpointConfig.Difficulty = checkpointConfig.ProofOfWork.Difficulty; + checkpointConfig.SaltLength = checkpointConfig.ProofOfWork.SaltLength; + checkpointConfig.ChallengeExpiration = parseDuration( + checkpointConfig.ProofOfWork.ChallengeExpiration, + ); + checkpointConfig.MaxAttemptsPerHour = checkpointConfig.ProofOfWork.MaxAttemptsPerHour; + + // Proof of Space-Time settings + checkpointConfig.CheckPoSTimes = checkpointConfig.ProofOfSpaceTime.Enabled; + checkpointConfig.PoSTimeConsistencyRatio = checkpointConfig.ProofOfSpaceTime.ConsistencyRatio; + + // Token settings + checkpointConfig.TokenExpiration = parseDuration(checkpointConfig.Token.Expiration); + checkpointConfig.MaxNonceAge = parseDuration(checkpointConfig.Token.MaxNonceAge); + + // Storage settings + checkpointConfig.SecretConfigPath = checkpointConfig.Storage.SecretPath; + checkpointConfig.TokenStoreDBPath = checkpointConfig.Storage.TokenDBPath; + checkpointConfig.InterstitialPaths = checkpointConfig.Storage.InterstitialTemplates; + + // Process exclusions + checkpointConfig.ExclusionRules = checkpointConfig.Exclusion || []; + + // Process bypass keys + checkpointConfig.BypassQueryKeys = []; + checkpointConfig.BypassHeaderKeys = []; + checkpointConfig.BypassKeys.forEach((key) => { + if (key.Type === 'query') { + checkpointConfig.BypassQueryKeys.push({ + Key: key.Key, + Value: key.Value, + Domains: key.Hosts || [], + }); + } else if (key.Type === 'header') { + checkpointConfig.BypassHeaderKeys.push({ + Name: key.Key, + Value: key.Value, + Domains: key.Hosts || [], + }); + } + }); + + // Extension handling + checkpointConfig.HTMLCheckpointIncludedExtensions = checkpointConfig.Extensions.IncludeOnly; + checkpointConfig.HTMLCheckpointExcludedExtensions = checkpointConfig.Extensions.Exclude; + + // Remove legacy arrays + checkpointConfig.HTMLCheckpointExclusions = []; + checkpointConfig.UserAgentValidationExclusions = []; + checkpointConfig.UserAgentRequiredPrefixes = {}; + checkpointConfig.ReverseProxyMappings = {}; +} + +function addReadStreamSupport(dbInstance) { + if (!dbInstance.createReadStream) { + dbInstance.createReadStream = (opts) => + Readable.from( + (async function* () { + for await (const [key, value] of dbInstance.iterator(opts)) { + yield { key, value }; + } + })(), + ); + } + return dbInstance; +} + +function initTokenStore() { + try { + const storePath = join(rootDir, checkpointConfig.TokenStoreDBPath || 'db/tokenstore'); + fs.mkdirSync(storePath, { recursive: true }); + + let rawDB = new Level(storePath, { valueEncoding: 'json' }); + + addReadStreamSupport(rawDB); + db = ttl(rawDB, { defaultTTL: checkpointConfig.TokenExpiration }); + + addReadStreamSupport(db); + console.log('Token store initialized with TTL'); + } catch (err) { + console.error('Failed to initialize token store:', err); + } +} + +function getFullClientIP(request) { + const ip = getRealIP(request) || ''; + const h = crypto.createHash('sha256').update(ip).digest(); + return h.slice(0, 8).toString('hex'); +} + +function hashUserAgent(ua) { + if (!ua) return ''; + const h = crypto.createHash('sha256').update(ua).digest(); + return h.slice(0, 8).toString('hex'); +} + +function extractBrowserFingerprint(request) { + const headers = [ + 'sec-ch-ua', + 'sec-ch-ua-platform', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform-version', + 'sec-ch-ua-arch', + 'sec-ch-ua-model', + ]; + const parts = headers.map((h) => request.headers.get(h)).filter(Boolean); + if (!parts.length) return ''; + const buf = Buffer.from(parts.join('|')); + const h = crypto.createHash('sha256').update(buf).digest(); + return h.slice(0, 12).toString('hex'); +} + +async function getInterstitialTemplate() { + if (!interstitialTemplate) { + for (const p of checkpointConfig.InterstitialPaths) { + try { + let templatePath = join(__dirname, p); + if (fs.existsSync(templatePath)) { + const raw = await fsPromises.readFile(templatePath, 'utf8'); + interstitialTemplate = simpleTemplate(raw); + break; + } + + templatePath = join(rootDir, p); + if (fs.existsSync(templatePath)) { + const raw = await fsPromises.readFile(templatePath, 'utf8'); + interstitialTemplate = simpleTemplate(raw); + break; + } + } catch (e) { + console.warn(`Failed to load interstitial template from path ${p}:`, e); + } + } + + if (!interstitialTemplate) { + // Create a minimal fallback template + console.warn('Could not find interstitial HTML template, using minimal fallback'); + interstitialTemplate = simpleTemplate(` + + + + Security Verification + + + + +

Security Verification Required

+

Please wait while we verify your request...

+
+
+ + + + `); + } + } + return interstitialTemplate; +} + +// Helper function for safe stats recording +function safeRecordEvent(metric, data) { + // If recordEvent is not yet loaded, try to wait for it + if (!recordEvent && statsLoadPromise) { + statsLoadPromise.then(() => { + if (recordEvent) { + try { + recordEvent(metric, data); + } catch (err) { + console.error(`Failed to record ${metric} event:`, err); + } + } + }); + return; + } + + if (typeof recordEvent === 'function') { + try { + recordEvent(metric, data); + } catch (err) { + console.error(`Failed to record ${metric} event:`, err); + } + } +} + +async function serveInterstitial(request) { + const ip = getRealIP(request); + const requestPath = new URL(request.url).pathname; + safeRecordEvent('checkpoint.sent', { ip, path: requestPath }); + let tpl; + try { + tpl = await getInterstitialTemplate(); + } catch (err) { + console.error('Interstitial template error:', err); + return new Response('Security verification required.', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + const requestID = proofGenerateRequestID(request, checkpointConfig); + const url = new URL(request.url); + const host = request.headers.get('host') || url.hostname; + const targetPath = url.pathname; + const fullURL = request.url; + + const html = tpl({ + TargetPath: targetPath, + RequestID: requestID, + Host: host, + FullURL: fullURL, + }); + + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} + +async function handleGetCheckpointChallenge(request) { + const url = new URL(request.url); + const requestID = url.searchParams.get('id'); + if (!requestID) { + return new Response(JSON.stringify({ error: 'Missing request ID' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const ip = getRealIP(request); + const attempts = (ipRateLimit.get(ip) || 0) + 1; + ipRateLimit.set(ip, attempts); + + if (attempts > checkpointConfig.MaxAttemptsPerHour) { + return new Response( + JSON.stringify({ error: 'Too many challenge requests. Try again later.' }), + { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const params = getChallengeParams(requestID); + if (!params) { + return new Response(JSON.stringify({ error: 'Challenge not found or expired' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (ip !== params.ClientIP) { + return new Response(JSON.stringify({ error: 'IP address mismatch for challenge' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const payload = { + a: params.Challenge, + b: params.Salt, + c: params.Difficulty, + d: params.PoSSeed, + }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function calculateTokenHash(token) { + const data = `${token.Nonce}:${token.Entropy}:${token.Created.getTime()}`; + return crypto.createHash('sha256').update(data).digest('hex'); +} + +function computeTokenSignature(token) { + const copy = { ...token, Signature: '' }; + const serialized = JSON.stringify(copy); + return crypto.createHmac('sha256', hmacSecret).update(serialized).digest('hex'); +} + +function verifyTokenSignature(token) { + if (!token.Signature) return false; + const expected = computeTokenSignature(token); + try { + return crypto.timingSafeEqual( + Buffer.from(token.Signature, 'hex'), + Buffer.from(expected, 'hex'), + ); + } catch (e) { + return false; + } +} + +async function issueToken(request, token) { + const tokenHash = calculateTokenHash(token); + const storedData = { + ClientIPHash: token.ClientIP, + UserAgentHash: token.UserAgent, + BrowserHint: token.BrowserHint, + LastVerified: new Date(token.LastVerified).toISOString(), + ExpiresAt: new Date(token.ExpiresAt).toISOString(), + }; + + try { + await addToken(tokenHash, storedData); + } catch (err) { + console.error('Failed to store token:', err); + } + + token.Signature = computeTokenSignature(token); + + const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64'); + + const url = new URL(request.url); + const cookieDomain = checkpointConfig.CookieDomain || ''; + const sameSite = cookieDomain ? 'Lax' : 'Strict'; + const secure = url.protocol === 'https:'; + const expires = new Date(token.ExpiresAt).toUTCString(); + + const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : ''; + const securePart = secure ? '; Secure' : ''; + const cookieStr = + `${checkpointConfig.CookieName}=${tokenStr}; Path=/` + + `${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`; + return new Response(JSON.stringify({ token: tokenStr, expires_at: token.ExpiresAt }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': cookieStr, + }, + }); +} + +async function handleVerifyCheckpoint(request) { + let body; + try { + body = await request.json(); + } catch (e) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_json', ip: getRealIP(request) }); + return new Response(JSON.stringify({ error: 'Invalid JSON' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const ip = getRealIP(request); + const params = getChallengeParams(body.request_id); + + if (!params) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_or_expired_request', ip }); + return new Response(JSON.stringify({ error: 'Invalid or expired request ID' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (ip !== params.ClientIP) { + safeRecordEvent('checkpoint.failure', { reason: 'ip_mismatch', ip }); + return new Response(JSON.stringify({ error: 'IP address mismatch' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const challenge = params.Challenge; + const salt = params.Salt; + + if (!body.g || !verifyPoW(challenge, salt, body.g, params.Difficulty)) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_pow', ip }); + return new Response(JSON.stringify({ error: 'Invalid proof-of-work solution' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const nonceKey = body.g + challenge; + usedNonces.set(nonceKey, Date.now()); + + if (body.h?.length === 3 && body.i?.length === 3) { + try { + verifyPoS(body.h, body.i, checkpointConfig); + } catch (e) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_pos', ip }); + return new Response(JSON.stringify({ error: e.message }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + deleteChallenge(body.request_id); + safeRecordEvent('checkpoint.success', { ip }); + const now = new Date(); + const expiresAt = new Date(now.getTime() + checkpointConfig.TokenExpiration); + + const token = { + Nonce: body.g, + ExpiresAt: expiresAt, + ClientIP: getFullClientIP(request), + UserAgent: hashUserAgent(request.headers.get('user-agent')), + BrowserHint: extractBrowserFingerprint(request), + Entropy: crypto.randomBytes(8).toString('hex'), + Created: now, + LastVerified: now, + TokenFormat: 2, + }; + + token.Signature = computeTokenSignature(token); + const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64'); + + const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex'); + try { + await db.put(tokenKey, true); + tokenCache.set(tokenKey, true); + + tokenExpirations.set(tokenKey, new Date(token.ExpiresAt).getTime()); + console.log(`checkpoint: token stored in DB and cache key=${tokenKey}`); + } catch (e) { + console.error('checkpoint: failed to store token in DB:', e); + } + + return new Response(JSON.stringify({ token: tokenStr, expires_at: token.ExpiresAt }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function generateUpdatedCookie(token, secure) { + token.Signature = computeTokenSignature(token); + const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64'); + const cookieDomain = checkpointConfig.CookieDomain || ''; + const sameSite = cookieDomain ? 'Lax' : 'Strict'; + const expires = new Date(token.ExpiresAt).toUTCString(); + + const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : ''; + const securePart = secure ? '; Secure' : ''; + const cookieStr = + `${checkpointConfig.CookieName}=${tokenStr}; Path=/` + + `${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`; + return cookieStr; +} + +async function validateToken(tokenStr, request) { + if (!tokenStr) return false; + let token; + try { + token = JSON.parse(Buffer.from(tokenStr, 'base64').toString()); + } catch { + console.log('checkpoint: invalid token format'); + return false; + } + + if (Date.now() > new Date(token.ExpiresAt).getTime()) { + console.log('checkpoint: token expired'); + return false; + } + + if (!verifyTokenSignature(token)) { + console.log('checkpoint: invalid token signature'); + return false; + } + + const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex'); + + if (tokenCache.has(tokenKey)) return true; + + try { + await db.get(tokenKey); + tokenCache.set(tokenKey, true); + + tokenExpirations.set(tokenKey, new Date(token.ExpiresAt).getTime()); + return true; + } catch { + console.log('checkpoint: token not found in DB'); + return false; + } +} + +async function createProxyResponse(targetURL, request) { + const url = new URL(request.url); + const targetUrl = new URL(url.pathname + url.search, targetURL); + + const headers = Object.fromEntries(request.headers.entries()); + delete headers.host; + + try { + const method = request.method; + const options = { + method, + headers, + redirect: 'manual', + }; + + if (method !== 'GET' && method !== 'HEAD') { + options.body = await request.blob(); + } + + const response = await fetch(targetUrl.toString(), options); + + const responseHeaders = new Headers(response.headers); + const hopByHopHeaders = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + ]; + + hopByHopHeaders.forEach((h) => responseHeaders.delete(h)); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (err) { + console.error('Proxy error:', err); + return new Response('Bad Gateway', { status: 502 }); + } +} + +async function handleTokenRedirect(request) { + const url = new URL(request.url); + const tokenStr = url.searchParams.get('token'); + if (!tokenStr) return undefined; + + let token; + try { + token = JSON.parse(Buffer.from(tokenStr, 'base64').toString()); + + if (Date.now() > new Date(token.ExpiresAt).getTime()) { + console.log('checkpoint: token in URL parameter expired'); + return undefined; + } + + if (!verifyTokenSignature(token)) { + console.log('checkpoint: invalid token signature in URL parameter'); + return undefined; + } + + const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex'); + try { + await db.get(tokenKey); + } catch { + console.log('checkpoint: token in URL parameter not found in DB'); + return undefined; + } + } catch (e) { + console.log('checkpoint: invalid token format in URL parameter', e); + return undefined; + } + + const expires = new Date(token.ExpiresAt).toUTCString(); + const cookieDomain = checkpointConfig.CookieDomain || ''; + const sameSite = cookieDomain ? 'Lax' : 'Strict'; + const securePart = url.protocol === 'https:' ? '; Secure' : ''; + const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : ''; + const cookieStr = + `${checkpointConfig.CookieName}=${tokenStr}; Path=/` + + `${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`; + + url.searchParams.delete('token'); + const cleanUrl = url.pathname + (url.search || ''); + return new Response(null, { + status: 302, + headers: { + 'Set-Cookie': cookieStr, + Location: cleanUrl, + }, + }); +} + +function CheckpointMiddleware() { + return async (request) => { + // Check if checkpoint is enabled + if (checkpointConfig.Enabled === false) { + return undefined; + } + + const urlObj = new URL(request.url); + const host = request.headers.get('host')?.split(':')[0]; + const userAgent = request.headers.get('user-agent') || ''; + + // 1) Bypass via query keys + for (const { Key, Value, Domains } of checkpointConfig.BypassQueryKeys) { + if (urlObj.searchParams.get(Key) === Value) { + if (!Array.isArray(Domains) || Domains.length === 0 || Domains.includes(host)) { + return undefined; + } + } + } + + // 2) Bypass via header keys + for (const { Name, Value, Domains } of checkpointConfig.BypassHeaderKeys) { + const headerVal = request.headers.get(Name); + if (headerVal === Value) { + if (!Array.isArray(Domains) || Domains.length === 0 || Domains.includes(host)) { + return undefined; + } + } + } + + // Handle token redirect for URL-token login + const tokenResponse = await handleTokenRedirect(request); + if (tokenResponse) return tokenResponse; + + // Setup request context + const url = new URL(request.url); + let path = url.pathname; + if (checkpointConfig.SanitizeURLs) { + path = sanitizePath(path); + } + const method = request.method; + + // Always allow challenge & verify endpoints + if (method === 'GET' && path === '/api/challenge') { + return handleGetCheckpointChallenge(request); + } + if (method === 'POST' && path === '/api/verify') { + return handleVerifyCheckpoint(request); + } + + // Check new exclusion rules + if (checkpointConfig.ExclusionRules && checkpointConfig.ExclusionRules.length > 0) { + for (const rule of checkpointConfig.ExclusionRules) { + // Check if path matches + if (!rule.Path || !path.startsWith(rule.Path)) { + continue; + } + + // Check if host matches (if specified) + if (rule.Hosts && rule.Hosts.length > 0 && !rule.Hosts.includes(host)) { + continue; + } + + // Check if user agent matches (if specified) + if (rule.UserAgents && rule.UserAgents.length > 0) { + const matchesUA = rule.UserAgents.some((ua) => userAgent.includes(ua)); + if (!matchesUA) { + continue; + } + } + + // All conditions match - exclude this request + return undefined; + } + } + + // Check file extensions + const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : ''; + + // First check excluded extensions + if (ext && checkpointConfig.HTMLCheckpointExcludedExtensions.includes(ext)) { + return undefined; + } + + // Then check if we should only include specific extensions + if (checkpointConfig.HTMLCheckpointIncludedExtensions.length > 0) { + // If extension list is specified and current extension is not in it, skip + if (!checkpointConfig.HTMLCheckpointIncludedExtensions.includes(ext)) { + return undefined; + } + } + + // Validate session token + const cookies = cookie.parse(request.headers.get('cookie') || ''); + const tokenCookie = cookies[checkpointConfig.CookieName]; + const validation = await validateToken(tokenCookie, request); + if (validation) { + // Active session: bypass checkpoint + return undefined; + } + + // Log new checkpoint flow + console.log(`checkpoint: incoming ${method} ${request.url}`); + console.log(`checkpoint: tokenCookie=${tokenCookie}`); + console.log(`checkpoint: validateToken => ${validation}`); + + // Serve interstitial challenge + return serveInterstitial(request); + }; +} + +async function addToken(tokenHash, data) { + if (!db) return; + try { + const ttlMs = checkpointConfig.TokenExpiration; + + await db.put(tokenHash, data); + + tokenExpirations.set(tokenHash, Date.now() + ttlMs); + } catch (err) { + console.error('Error adding token:', err); + } +} + +async function updateTokenVerification(tokenHash) { + if (!db) return; + try { + const data = await db.get(tokenHash); + data.LastVerified = new Date().toISOString(); + await db.put(tokenHash, data); + } catch (err) { + console.error('Error updating token verification:', err); + } +} + +async function lookupTokenData(tokenHash) { + if (!db) return { data: null, found: false }; + try { + const expireTime = tokenExpirations.get(tokenHash); + if (!expireTime || expireTime <= Date.now()) { + if (expireTime) { + tokenExpirations.delete(tokenHash); + try { + await db.del(tokenHash); + } catch (e) {} + } + return { data: null, found: false }; + } + + const data = await db.get(tokenHash); + return { data, found: true }; + } catch (err) { + if (err.code === 'LEVEL_NOT_FOUND') return { data: null, found: false }; + console.error('Error looking up token:', err); + throw err; + } +} + +async function closeTokenStore() { + if (db) await db.close(); +} + +function startCleanupTimer() { + // Cleanup expired data hourly + setInterval(() => { + cleanupExpiredData(); + }, 3600000); + // Cleanup expired challenges at the challenge expiration interval + const challengeInterval = checkpointConfig.ChallengeExpiration || 60000; + setInterval(() => { + cleanupExpiredChallenges(); + }, challengeInterval); +} + +function cleanupExpiredData() { + const now = Date.now(); + let count = 0; + + try { + for (const [nonce, ts] of usedNonces.entries()) { + if (now - ts > checkpointConfig.MaxNonceAge) { + usedNonces.delete(nonce); + count++; + } + } + if (count) console.log(`Checkpoint: cleaned up ${count} expired nonces.`); + } catch (err) { + console.error('Error cleaning up nonces:', err); + } + + // Clean up expired tokens from cache + let tokenCacheCount = 0; + try { + for (const [tokenKey, _] of tokenCache.entries()) { + const expireTime = tokenExpirations.get(tokenKey); + if (!expireTime || expireTime <= now) { + tokenCache.delete(tokenKey); + tokenExpirations.delete(tokenKey); + tokenCacheCount++; + } + } + if (tokenCacheCount) + console.log(`Checkpoint: cleaned up ${tokenCacheCount} expired tokens from cache.`); + } catch (err) { + console.error('Error cleaning up token cache:', err); + } + + try { + ipRateLimit.clear(); + console.log('Checkpoint: IP rate limits reset.'); + } catch (err) { + console.error('Error resetting IP rate limits:', err); + } +} + +function cleanupExpiredChallenges() { + const now = Date.now(); + let count = 0; + for (const [id, params] of challengeStore.entries()) { + if (params.ExpiresAt && params.ExpiresAt < now) { + // Record failure for expired challenges that were never completed + safeRecordEvent('checkpoint.failure', { + reason: 'challenge_expired', + ip: params.ClientIP, + challenge_id: id.substring(0, 8), // Include partial ID for debugging + age_ms: now - params.CreatedAt, // How long the challenge existed + expiry_ms: checkpointConfig.ChallengeExpiration, // Configured expiry time + }); + challengeStore.delete(id); + count++; + } + } + if (count) console.log(`Checkpoint: cleaned up ${count} expired challenges.`); +} + +async function initSecret() { + try { + if (!checkpointConfig.SecretConfigPath) { + checkpointConfig.SecretConfigPath = join(rootDir, 'data', 'checkpoint_secret.json'); + } + + const secretPath = checkpointConfig.SecretConfigPath; + const exists = fs.existsSync(secretPath); + + if (exists) { + const loaded = loadSecretFromFile(); + if (loaded) { + hmacSecret = loaded; + console.log(`Loaded existing HMAC secret from ${secretPath}`); + return; + } + } + + hmacSecret = crypto.randomBytes(32); + fs.mkdirSync(path.dirname(secretPath), { recursive: true }); + + const secretCfg = { + hmac_secret: hmacSecret.toString('base64'), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + fs.writeFileSync(secretPath, JSON.stringify(secretCfg), { mode: 0o600 }); + console.log(`Created and saved new HMAC secret to ${secretPath}`); + } catch (err) { + console.error('Error initializing secret:', err); + + hmacSecret = crypto.randomBytes(32); + } +} + +function loadSecretFromFile() { + try { + const data = fs.readFileSync(checkpointConfig.SecretConfigPath, 'utf8'); + const cfg = JSON.parse(data); + const buf = Buffer.from(cfg.hmac_secret, 'base64'); + if (buf.length < 16) return null; + + cfg.updated_at = new Date().toISOString(); + fs.writeFileSync(checkpointConfig.SecretConfigPath, JSON.stringify(cfg), { mode: 0o600 }); + return buf; + } catch (e) { + console.warn('Could not load HMAC secret from file:', e); + return null; + } +} + +(async function initialize() { + await initConfig(); + await initSecret(); + initTokenStore(); + startCleanupTimer(); + + // Only register plugin if enabled + if (checkpointConfig.Enabled !== false) { + registerPlugin('checkpoint', CheckpointMiddleware()); + } else { + console.log('Checkpoint plugin disabled via configuration'); + } +})(); + +export { checkpointConfig, addToken, updateTokenVerification, lookupTokenData, closeTokenStore }; diff --git a/config/checkpoint.toml b/config/checkpoint.toml new file mode 100644 index 0000000..a01cec6 --- /dev/null +++ b/config/checkpoint.toml @@ -0,0 +1,143 @@ +# ============================================================================= +# CHECKPOINT SECURITY CONFIGURATION +# ============================================================================= +# This configuration controls the checkpoint security middleware that protects +# your services with proof-of-work challenges and token-based authentication. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the checkpoint system entirely +Enabled = true + +# Cookie name for storing checkpoint tokens +CookieName = "checkpoint_token" + +# Cookie domain (empty = host-only cookie for localhost) +# Set to ".yourdomain.com" for all subdomains +CookieDomain = "" + +# Enable URL path sanitization to prevent path traversal attacks +SanitizeURLs = true + +# ----------------------------------------------------------------------------- +# PROOF OF WORK SETTINGS +# ----------------------------------------------------------------------------- +[ProofOfWork] +# Number of leading zeros required in the SHA-256 hash +Difficulty = 4 + +# Random salt length in bytes +SaltLength = 16 + +# Time allowed to solve a challenge before it expires +ChallengeExpiration = "3m" + +# Maximum attempts per IP address per hour +MaxAttemptsPerHour = 10 + +# ----------------------------------------------------------------------------- +# PROOF OF SPACE-TIME SETTINGS (Optional additional verification) +# ----------------------------------------------------------------------------- +[ProofOfSpaceTime] +# Enable consistency checks for PoS-Time verification +Enabled = true + +# Maximum allowed ratio between slowest and fastest PoS runs +ConsistencyRatio = 1.35 + +# ----------------------------------------------------------------------------- +# TOKEN SETTINGS +# ----------------------------------------------------------------------------- +[Token] +# How long tokens remain valid +Expiration = "24h" + +# Maximum age for used nonces before cleanup +MaxNonceAge = "24h" + +# ----------------------------------------------------------------------------- +# STORAGE PATHS +# ----------------------------------------------------------------------------- +[Storage] +# HMAC secret storage location +SecretPath = "./data/checkpoint_secret.json" + +# Token database directory +TokenDBPath = "./db/tokenstore" + +# Interstitial page templates (in order of preference) +InterstitialTemplates = [ + "/pages/interstitial/page.html", + "/pages/ipfilter/default.html" +] + +# ----------------------------------------------------------------------------- +# EXCLUSION RULES +# ----------------------------------------------------------------------------- +# Define which requests should bypass the checkpoint system. +# Each rule can specify: +# - Path (required): URL path or prefix to match +# - Hosts (optional): Specific hostnames this rule applies to +# - UserAgents (optional): User-Agent patterns to match +# ----------------------------------------------------------------------------- + +[[Exclusion]] +# Skip checkpoint for all API endpoints (required for Immich and similar apps) +Path = "/api" +Hosts = ["gallery.caileb.com"] # Optional: only for specific hosts + +[[Exclusion]] +# Skip checkpoint for health checks +Path = "/health" + +[[Exclusion]] +# Skip checkpoint for metrics endpoint +Path = "/metrics" + +# [[Exclusion]] +# Example: Mobile app API with specific user agent +# Path = "/mobile-api" +# UserAgents = ["MyApp/", "Dart/"] + +# [[Exclusion]] +# Example: Host-specific exclusion +# Path = "/admin" +# Hosts = ["admin.internal.com"] + +# ----------------------------------------------------------------------------- +# BYPASS KEYS +# ----------------------------------------------------------------------------- +# Special keys that can bypass the checkpoint when provided + +[[BypassKeys]] +# Query parameter bypass +Type = "query" +Key = "bypass_key" +Value = "your-secret-key-here" +Hosts = ["music.caileb.com"] # Optional: restrict to specific hosts + +[[BypassKeys]] +# Header bypass +Type = "header" +Key = "X-Bypass-Token" +Value = "another-secret-key" +# Hosts = [] # If empty or omitted, applies to all hosts + +# ----------------------------------------------------------------------------- +# FILE EXTENSION HANDLING +# ----------------------------------------------------------------------------- +[Extensions] +# Only apply checkpoint to these file extensions (for HTML content) +# Empty = check all paths +IncludeOnly = [".html", ".htm", ".shtml", ""] + +# Never apply checkpoint to these file extensions +# This takes precedence over IncludeOnly +Exclude = [ + ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", + ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map", + ".json", ".xml", ".txt", ".webp", ".avif" +] \ No newline at end of file diff --git a/config/ipfilter.toml b/config/ipfilter.toml new file mode 100644 index 0000000..4c7f86f --- /dev/null +++ b/config/ipfilter.toml @@ -0,0 +1,92 @@ +# ============================================================================= +# IP FILTER CONFIGURATION +# ============================================================================= +# This configuration controls the IP filtering middleware that blocks requests +# based on geographic location (country/continent) and network (ASN) information. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the IP filter entirely +Enabled = true + +# MaxMind account ID for downloading GeoIP databases +# Can also be set via MAXMIND_ACCOUNT_ID environment variable or .env file +AccountID = "" + +# MaxMind license key for downloading GeoIP databases +# Can also be set via MAXMIND_LICENSE_KEY environment variable or .env file +LicenseKey = "" + +# How often to check for database updates (in hours) +DBUpdateIntervalHours = 12 + +# ----------------------------------------------------------------------------- +# CACHING SETTINGS +# ----------------------------------------------------------------------------- +[Cache] +# TTL for cached IP block decisions (in seconds) +# 0 = cache indefinitely until server restart +IPBlockCacheTTLSec = 300 + +# Maximum number of cached IP decisions +# 0 = unlimited +IPBlockCacheMaxEntries = 10000 + +# ----------------------------------------------------------------------------- +# BLOCKING RULES +# ----------------------------------------------------------------------------- +[Blocking] +# ISO country codes to block (2-letter codes) +CountryCodes = [ + "IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ", + "LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG", + "TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL" +] + +# Continent codes to block +ContinentCodes = ["AF", "SA", "AS", "AN"] + +# Default block page when no specific page is configured +DefaultBlockPage = "/pages/ipfilter/default.html" + +# ----------------------------------------------------------------------------- +# ASN BLOCKING +# ----------------------------------------------------------------------------- +# Block by Autonomous System Number (ASN) +# Group ASNs by category for different block pages + +# [ASN.Example] +# Numbers = [12345, 67890] +# BlockPage = "pages/ipfilter/example.html" + +# ----------------------------------------------------------------------------- +# ASN NAME BLOCKING +# ----------------------------------------------------------------------------- +# Block by ASN organization name patterns + +[ASNNames.DataCenter] +# Block data center and cloud providers +Patterns = [ + "Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS", + "Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247", + "Datacamp", "Datapacket", "Amanah", "Hern Labs" +] +BlockPage = "/pages/ipfilter/datacenter.html" + +# ----------------------------------------------------------------------------- +# COUNTRY-SPECIFIC BLOCK PAGES +# ----------------------------------------------------------------------------- +[CountryBlockPages] +# Custom block pages for specific countries +IN = "/pages/ipfilter/india.html" + +# ----------------------------------------------------------------------------- +# CONTINENT-SPECIFIC BLOCK PAGES +# ----------------------------------------------------------------------------- +[ContinentBlockPages] +# Custom block pages for specific continents +# AS = "pages/ipfilter/asia.html" +# AF = "pages/ipfilter/africa.html" \ No newline at end of file diff --git a/config/proxy.toml b/config/proxy.toml new file mode 100644 index 0000000..b009c94 --- /dev/null +++ b/config/proxy.toml @@ -0,0 +1,55 @@ +# ============================================================================= +# PROXY CONFIGURATION +# ============================================================================= +# This configuration controls the reverse proxy middleware that forwards +# requests to backend services based on hostname mappings. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the proxy middleware +Enabled = true + +# ----------------------------------------------------------------------------- +# TIMEOUT SETTINGS +# ----------------------------------------------------------------------------- +[Timeouts] +# WebSocket connection timeout in milliseconds +WebSocketTimeoutMs = 5000 + +# Upstream HTTP request timeout in milliseconds +UpstreamTimeoutMs = 30000 + +# ----------------------------------------------------------------------------- +# PROXY MAPPINGS +# ----------------------------------------------------------------------------- +# Map hostnames to backend service URLs +# Format: "hostname" = "backend_url" +# ----------------------------------------------------------------------------- + +[[Mapping]] +# Immich +Host = "gallery.caileb.com" +Target = "http://192.168.0.2:2283" + +[[Mapping]] +# Navidrome +Host = "music.caileb.com" +Target = "http://192.168.0.2:4533" + +[[Mapping]] +# ForgeJo +Host = "git.caileb.com" +Target = "http://192.168.0.2:3053" + +# [[Mapping]] +# Example: API service +# Host = "api.example.com" +# Target = "http://localhost:3001" + +# [[Mapping]] +# Example: Admin panel +# Host = "admin.example.com" +# Target = "http://localhost:3002" \ No newline at end of file diff --git a/config/stats.toml b/config/stats.toml new file mode 100644 index 0000000..3819696 --- /dev/null +++ b/config/stats.toml @@ -0,0 +1,31 @@ +# ============================================================================= +# STATS CONFIGURATION +# ============================================================================= +# This configuration controls the statistics collection and visualization +# middleware that tracks events and provides a web UI for viewing metrics. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the stats plugin +Enabled = true + +# ----------------------------------------------------------------------------- +# STORAGE SETTINGS +# ----------------------------------------------------------------------------- +[Storage] +# TTL for stats entries +# Format: "30d", "24h", "1h", etc. +StatsTTL = "30d" + +# ----------------------------------------------------------------------------- +# WEB UI SETTINGS +# ----------------------------------------------------------------------------- +[WebUI] +# Path for stats UI +StatsUIPath = "/stats" + +# Path for stats API +StatsAPIPath = "/stats/api" \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..d210683 --- /dev/null +++ b/index.js @@ -0,0 +1,197 @@ +import { mkdir, readFile } from 'fs/promises'; +import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { secureImportModule } from './utils/plugins.js'; +import * as logs from './utils/logs.js'; + +// Load environment variables from .env file +import dotenv from 'dotenv'; +dotenv.config(); + +// Stop daemon: if run with -k, kill the running process and exit. +if (process.argv.includes('-k')) { + const pidFile = join(dirname(fileURLToPath(import.meta.url)), 'checkpoint.pid'); + if (existsSync(pidFile)) { + const pid = parseInt(readFileSync(pidFile, 'utf8'), 10); + try { + process.kill(pid); + unlinkSync(pidFile); + console.log(`Stopped daemon (pid ${pid})`); + } catch (err) { + console.error(`Failed to stop pid ${pid}: ${err}`); + } + } else { + console.error(`No pid file found at ${pidFile}`); + } + process.exit(0); +} + +// Daemonize: if run with -d, kill any existing daemon, then re-spawn detached, write pid file, and exit parent. +if (process.argv.includes('-d')) { + const pidFile = join(dirname(fileURLToPath(import.meta.url)), 'checkpoint.pid'); + // If already running, stop the old daemon + if (existsSync(pidFile)) { + const oldPid = parseInt(readFileSync(pidFile, 'utf8'), 10); + try { + process.kill(oldPid); + console.log(`Stopped old daemon (pid ${oldPid})`); + } catch (e) { + console.error(`Failed to stop old daemon (pid ${oldPid}): ${e}`); + } + try { + unlinkSync(pidFile); + } catch {} + } + // Spawn new background process + const args = process.argv.slice(1).filter((arg) => arg !== '-d'); + const cp = Bun.spawn({ + cmd: [process.argv[0], ...args], + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + }); + cp.unref(); + writeFileSync(pidFile, cp.pid.toString(), 'utf8'); + console.log(`Daemonized (pid ${cp.pid}), pid stored in ${pidFile}`); + process.exit(0); +} + +const pluginRegistry = []; +export function registerPlugin(pluginName, handler) { + pluginRegistry.push({ name: pluginName, handler }); +} +/** + * Return the array of middleware handlers in registration order. + */ +export function loadPlugins() { + return pluginRegistry.map((item) => item.handler); +} +/** + * Return the names of all registered plugins. + */ +export function getRegisteredPluginNames() { + return pluginRegistry.map((item) => item.name); +} +/** + * Freeze plugin registry to prevent further registration and log the final set. + */ +export function freezePlugins() { + Object.freeze(pluginRegistry); + pluginRegistry.forEach((item) => Object.freeze(item)); + logs.msg('Plugin registration frozen'); +} + +// Determine root directory for config loading +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const rootDir = __dirname; + +export async function loadConfig(name, target) { + const configPath = join(rootDir, 'config', `${name}.toml`); + const txt = await readFile(configPath, 'utf8'); + const { default: toml } = await import('@iarna/toml'); + Object.assign(target, toml.parse(txt)); + logs.config(name, 'loaded'); +} + +async function initDataDirectories() { + logs.section('INIT'); + const directories = [join(rootDir, 'data'), join(rootDir, 'db'), join(rootDir, 'config')]; + for (const dirPath of directories) { + try { + await mkdir(dirPath, { recursive: true }); + } catch {} + } + logs.init('Data directories are now in place'); +} + +function staticFileMiddleware() { + return async (request) => { + const url = new URL(request.url); + const pathname = url.pathname; + if (pathname.startsWith('/webfont/') || pathname.startsWith('/js/')) { + const filePath = join(rootDir, 'pages/interstitial', pathname.slice(1)); + try { + return new Response(Bun.file(filePath), { + headers: { 'Cache-Control': 'public, max-age=604800' }, + }); + } catch { + return new Response('Not Found', { status: 404 }); + } + } + return undefined; + }; +} + +async function main() { + await initDataDirectories(); + + logs.section('CONFIG'); + logs.config('checkpoint', 'loaded'); + logs.config('ipfilter', 'loaded'); + logs.config('proxy', 'loaded'); + logs.config('stats', 'loaded'); + + logs.section('OPERATIONS'); + let wsHandler; + try { + await secureImportModule('checkpoint.js'); + } catch (e) { + logs.error('checkpoint', `Failed to load checkpoint plugin: ${e}`); + } + try { + await secureImportModule('plugins/ipfilter.js'); + } catch (e) { + logs.error('ipfilter', `Failed to load IP filter plugin: ${e}`); + } + try { + await secureImportModule('plugins/proxy.js'); + const mod = await import('./plugins/proxy.js'); + wsHandler = mod.proxyWebSocketHandler; + } catch (e) { + logs.error('proxy', `Failed to load proxy plugin: ${e}`); + } + try { + await secureImportModule('plugins/stats.js'); + } catch (e) { + logs.error('stats', `Failed to load stats plugin: ${e}`); + } + + registerPlugin('static', staticFileMiddleware()); + + logs.section('PLUGINS'); + // Ensure ipfilter runs first by moving it to front of the registry + const ipIndex = pluginRegistry.findIndex((item) => item.name === 'ipfilter'); + if (ipIndex > 0) { + const [ipEntry] = pluginRegistry.splice(ipIndex, 1); + pluginRegistry.unshift(ipEntry); + } + pluginRegistry.forEach((item) => logs.msg(item.name)); + logs.section('SYSTEM'); + freezePlugins(); + + logs.section('SERVER'); + const portNumber = Number(process.env.PORT || 3000); + const middlewareHandlers = loadPlugins(); + logs.server(`🚀 Server is up and running on port ${portNumber}...`); + logs.section('REQ LOGS'); + Bun.serve({ + port: portNumber, + async fetch(request, server) { + for (const handler of middlewareHandlers) { + try { + const resp = await handler(request, server); + if (resp instanceof Response) return resp; + } catch (err) { + logs.error('server', `Handler error: ${err}`); + } + } + return new Response('Not Found', { status: 404 }); + }, + websocket: wsHandler, + error(err) { + return new Response(`Server Error: ${err.message}`, { status: 500 }); + }, + }); +} + +main(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..880800e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,728 @@ +{ + "name": "checkpoint", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "checkpoint", + "dependencies": { + "@iarna/toml": "^2.2.5", + "cookie": "^1.0.2", + "dotenv": "^16.5.0", + "express-http-proxy": "^2.1.1", + "http-proxy": "^1.18.1", + "level": "^10.0.0", + "level-ttl": "^3.1.1", + "maxmind": "^4.3.25", + "string-dsa": "^2.1.0", + "tar": "^7.4.3", + "tar-stream": "^3.1.7", + "toml": "^3.0.0" + }, + "devDependencies": { + "prettier": "^2.8.8" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/abstract-level": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-3.1.0.tgz", + "integrity": "sha512-j2e+TsAxy7Ri+0h7dJqwasymgt0zHBWX4+nMk3XatyuqgHfdstBJ9wsMfbiGwE1O+QovRyPcVAqcViMYdyPaaw==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "is-buffer": "^2.0.5", + "level-supports": "^6.2.0", + "level-transcoder": "^1.0.1", + "maybe-combine-errors": "^1.0.0", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/browser-level": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/browser-level/-/browser-level-3.0.0.tgz", + "integrity": "sha512-kGXtLh29jMwqKaskz5xeDLtCtN1KBz/DbQSqmvH7QdJiyGRC7RAM8PPg6gvUiNMa+wVnaxS9eSmEtP/f5ajOVw==", + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/classic-level": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-3.0.0.tgz", + "integrity": "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0", + "module-error": "^1.0.1", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express-http-proxy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-2.1.1.tgz", + "integrity": "sha512-4aRQRqDQU7qNPV5av0/hLcyc0guB9UP71nCYrQEYml7YphTo8tmWf3nDZWdTJMMjFikyz9xKXaURor7Chygdwg==", + "license": "MIT", + "dependencies": { + "debug": "^3.0.1", + "es6-promise": "^4.1.1", + "raw-body": "^2.3.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/level": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/level/-/level-10.0.0.tgz", + "integrity": "sha512-aZJvdfRr/f0VBbSRF5C81FHON47ZsC2TkGxbBezXpGGXAUEL/s6+GP73nnhAYRSCIqUNsmJjfeOF4lzRDKbUig==", + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0", + "browser-level": "^3.0.0", + "classic-level": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/level-supports": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-6.2.0.tgz", + "integrity": "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", + "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/level-ttl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/level-ttl/-/level-ttl-3.1.1.tgz", + "integrity": "sha512-OeiHOD2IPkmdLqqU4feVJL7mnZX/Q03WEClrQi5t9558alkajVaecCgwJQZVVL/zFR9q74n5pWN1eozifa1Ghw==", + "license": "MIT", + "dependencies": { + "after": ">=0.8.1 <0.9.0-0", + "list-stream": ">=1.0.0 <1.1.0-0", + "lock": "~0.1.2", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/list-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/list-stream/-/list-stream-1.0.1.tgz", + "integrity": "sha512-XheYsTtN+/nay6Co4N9NlTjQzo1ohknNlDJfxTeH0tvvssxBINUXwmjqPtj8+7rYMBwTRb3kO8C8d6ogeRwD1A==", + "license": "MIT +no-false-attribs", + "dependencies": { + "readable-stream": "~2.0.5", + "xtend": "~4.0.1" + } + }, + "node_modules/lock": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/lock/-/lock-0.1.4.tgz", + "integrity": "sha512-IcEe2R+NA7WgM622ppgmJFCFZl20f2owsA1YiJg7qpvO0wdOgOuZdfhQMxCYXdESVX+QIF/eikE4hB5ZPM2ipA==", + "license": "MIT" + }, + "node_modules/maxmind": { + "version": "4.3.25", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-4.3.25.tgz", + "integrity": "sha512-u7L6LrbXZUtpdoovTVHo/l4/EoWUT2eHfCKWDMNNTsW9BaLa7h0jCHjqVx5ZeS5aWorLGZSsZwqxcpoollBw1g==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "2.2.0", + "tiny-lru": "11.2.11" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/maybe-combine-errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz", + "integrity": "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mmdb-lib": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-2.2.0.tgz", + "integrity": "sha512-V6DDh3v8tfZFWbeH6fsL5uBIlWL7SvRgGDaAZWFC5kjQ2xP5dl/mLpWwJQ1Ho6ZbEKVp/351QF1JXYTAmeZ/zA==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/module-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", + "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha512-TXcFfb63BQe1+ySzsHZI/5v1aJPCShfqvWJ64ayNImXMsN1Cd0YGk/wm8KB7/OeessgPc9QvS9Zou8QTkFzsLw==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/string-dsa": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/string-dsa/-/string-dsa-2.1.0.tgz", + "integrity": "sha512-ht+H83VtdA0JXmZsRfhQYzUSwqK3T7STPqiD/u3bIvYUHLEw8zzZyvP9WI3l9uKbK/2IpU+ZdshAe5BoRil3wA==", + "license": "ISC" + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tiny-lru": { + "version": "11.2.11", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.2.11.tgz", + "integrity": "sha512-27BIW0dIWTYYoWNnqSmoNMKe5WIbkXsc0xaCQHd3/3xT2XMuMJrzHdrO9QBFR14emBz1Bu0dOAs2sCBBrvgPQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b29319 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "checkpoint", + "module": "index.js", + "private": true, + "type": "module", + "scripts": { + "start": "bun index.js" + }, + "devDependencies": { + "prettier": "^2.8.8" + }, + "dependencies": { + "@iarna/toml": "^2.2.5", + "cookie": "^1.0.2", + "dotenv": "^16.5.0", + "express-http-proxy": "^2.1.1", + "http-proxy": "^1.18.1", + "level": "^10.0.0", + "level-ttl": "^3.1.1", + "maxmind": "^4.3.25", + "string-dsa": "^2.1.0", + "tar": "^7.4.3", + "tar-stream": "^3.1.7", + "toml": "^3.0.0" + } +} diff --git a/pages/interstitial/js/c.js b/pages/interstitial/js/c.js new file mode 100644 index 0000000..539a806 --- /dev/null +++ b/pages/interstitial/js/c.js @@ -0,0 +1,518 @@ +// Modal state variables +let isModalOpen = false; +let pendingAction = null; // Can be 'success' or 'error' +let storedErrorMessage = ''; +let storedRedirectUrl = ''; +// let redirectToken = ''; // This was defined but not used, removing for now. +const REDIRECT_DELAY = 1488; // Moved for wider accessibility + +function workerFunction() { + self.onmessage = function (e) { + const { type, data } = e.data; + + if (type === 'pow') { + const { challenge, salt, startNonce, endNonce, target, batchId } = data; + let count = 0; + let solution = null; + + processNextNonce(startNonce); + + function processNextNonce(nonce) { + const input = String(challenge) + String(salt) + nonce.toString(); + const msgBuffer = new TextEncoder().encode(input); + + crypto.subtle + .digest('SHA-256', msgBuffer) + .then((hashBuffer) => { + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const result = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + count++; + + if (result.startsWith(target)) { + solution = { nonce: nonce.toString(), found: true }; + self.postMessage({ + type: 'pow_result', + solution: solution, + count: count, + batchId: batchId, + }); + return; + } + + if (count % 1000 === 0) { + self.postMessage({ + type: 'progress', + count: count, + batchId: batchId, + }); + } + + if (nonce < endNonce && !solution) { + setTimeout(() => processNextNonce(nonce + 1), 0); + } else if (!solution) { + self.postMessage({ + type: 'pow_result', + solution: null, + count: count, + batchId: batchId, + }); + } + }) + .catch((err) => { + self.postMessage({ + type: 'error', + error: 'Crypto API error: ' + err.message, + }); + }); + } + } else { + self.postMessage({ type: 'error', error: 'Unknown message type: ' + type }); + } + }; +} +const workerCode = '(' + workerFunction.toString() + ')()'; + +function posWorkerFunction() { + self.onmessage = async function (e) { + const { type, seedHex, isDecoy } = e.data; + if (type === 'pos') { + const minMB = 48, + maxMB = 160; + let seedInt = parseInt(seedHex.slice(0, 8), 16); + if (isNaN(seedInt)) seedInt = Math.floor(Math.random() * (maxMB - minMB + 1)); + const CHUNK_MB = isDecoy + ? minMB + ((seedInt * 3 + 17) % (maxMB - minMB + 1)) + : minMB + (seedInt % (maxMB - minMB + 1)); + const CHUNK_SIZE = CHUNK_MB * 1024 * 1024; + const chunkCount = 4 + (seedInt % 5); + const chunkSize = Math.floor(CHUNK_SIZE / chunkCount); + const FILL_STEP_4K = 4096, + FILL_STEP_1K = 1024; + const FILL_STEP_SWITCH = 35 * 1024 * 1024; + const runs = 3; + + const mainBuf = new ArrayBuffer(CHUNK_SIZE); + const view = new Uint8Array(mainBuf); + const pressureBuf = new ArrayBuffer(16 * 1024 * 1024); + const pressureView = new Uint8Array(pressureBuf); + + const hashes = []; + const times = []; + for (let r = 0; r < runs; r++) { + const prng = seededPRNG(seedHex + r.toString(16)); + + const order = Array.from({ length: chunkCount }, (_, i) => i); + for (let i = order.length - 1; i > 0; i--) { + const j = prng() % (i + 1); + [order[i], order[j]] = [order[j], order[i]]; + } + + const t0 = performance.now(); + for (let c = 0; c < chunkCount; c++) { + const idx = order[c]; + const start = idx * chunkSize; + const end = idx === chunkCount - 1 ? CHUNK_SIZE : start + chunkSize; + const step = start < FILL_STEP_SWITCH ? FILL_STEP_4K : FILL_STEP_1K; + for (let i = start; i < end; i += step) view[i] = prng() & 0xff; + } + const hashBuf = await crypto.subtle.digest('SHA-256', view); + const t2 = performance.now(); + hashes.push( + Array.from(new Uint8Array(hashBuf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + times.push(Math.round(t2 - t0)); + + for (let i = 0; i < pressureView.length; i += 4096) pressureView[i] = prng() & 0xff; + } + + self.postMessage({ type: 'pos_result', hashes, times }); + } + }; + function seededPRNG(seedHex) { + const s = []; + for (let i = 0; i < 4; i++) s[i] = parseInt(seedHex.substr(i * 8, 8), 16) >>> 0; + function rotl(x, k) { + return ((x << k) | (x >>> (32 - k))) >>> 0; + } + return function () { + const t = s[1] << 9; + let r = (s[0] * 5) >>> 0; + r = (rotl(r, 7) * 9) >>> 0; + const tmp = s[0] ^ s[2]; + s[2] ^= s[1]; + s[1] ^= s[3]; + s[0] ^= s[1]; + s[3] ^= tmp; + s[2] ^= t; + s[3] = rotl(s[3], 11); + return r >>> 0; + }; + } +} +const posWorkerCode = '(' + posWorkerFunction.toString() + ')()'; + +document.addEventListener('DOMContentLoaded', function () { + setTimeout(initVerification, 650); + + // Modal elements + const infoButton = document.getElementById('infoBtn'); + const infoModal = document.getElementById('infoModal'); + const modalCloseButton = document.getElementById('modalCloseBtn'); + + if (infoButton) { + infoButton.addEventListener('click', () => { + if (isModalOpen) { + closeModal(); + } else { + isModalOpen = true; + if (infoModal) infoModal.classList.add('active'); + } + }); + } + + if (modalCloseButton) { + modalCloseButton.addEventListener('click', closeModal); + } + + // Close modal if overlay is clicked + if (infoModal) { + infoModal.addEventListener('click', (event) => { + if (event.target === infoModal) { + closeModal(); + } + }); + } + + function closeModal() { + if (infoModal) infoModal.classList.remove('active'); + isModalOpen = false; + if (pendingAction === 'success') { + triggerStoredSuccess(); + } else if (pendingAction === 'error') { + triggerStoredError(storedErrorMessage); + } + pendingAction = null; // Reset after handling + } + + // Moved triggerStoredSuccess and triggerStoredError to this scope + function triggerStoredSuccess() { + document.querySelector('.container').classList.add('success'); + const statusEl = document.getElementById('status'); + if (statusEl) statusEl.textContent = 'Redirecting'; // Ensure status is updated + if (storedRedirectUrl) { + setTimeout(() => { + window.location.href = storedRedirectUrl; + }, REDIRECT_DELAY); // Ensure REDIRECT_DELAY is accessible or define it here + } + } + + function triggerStoredError(message) { + const container = document.querySelector('.container'); + const statusEl = document.getElementById('status'); + const spinnerContainer = document.querySelector('.spinner-container'); + + container.classList.add('error'); + container.classList.remove('success'); + + if (statusEl) { + statusEl.style.display = 'inline-block'; + statusEl.textContent = 'Error'; + statusEl.classList.add('error'); + statusEl.classList.remove('success'); + } + + let errorDetails = document.getElementById('error-details'); + if (!errorDetails && spinnerContainer) { + errorDetails = document.createElement('div'); + errorDetails.id = 'error-details'; + errorDetails.className = 'error-details'; + spinnerContainer.appendChild(errorDetails); + } + if (errorDetails) { + // errorDetails.textContent = message; // Display the specific error message + errorDetails.style.display = 'block'; + } + // Ensure any running workers are stopped on error + // This might need to be called from within Verifier's scope or Verifier needs a public method + // For now, if terminateWorkers is global or accessible, it would be called. + // However, terminateWorkers is defined within Verifier. This needs careful handling. + // Let's assume for now the original placement inside Verifier handles termination on actual error progression. + // The primary goal here is to show the UI error state. + } + + function initVerification() { + const dataEl = document.getElementById('verification-data'); + const targetPath = dataEl.getAttribute('data-target'); + const requestID = dataEl.getAttribute('data-request-id'); + + startVerification(); + + async function startVerification() { + try { + const challengeResponse = await fetch( + '/api/challenge?id=' + encodeURIComponent(requestID), + { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' }, + ); + + if (!challengeResponse.ok) { + throw new Error('Failed to get challenge parameters'); + } + + const challengeData = await challengeResponse.json(); + + const verifier = new Verifier(challengeData, targetPath, requestID); + verifier.start(); + } catch (error) { + showError('Verification setup failed: ' + error.message); + } + } + + function createWorker() { + const blob = new Blob([workerCode], { type: 'text/javascript' }); + return new Worker(URL.createObjectURL(blob)); + } + + function createPosWorker() { + const blob = new Blob([posWorkerCode], { type: 'text/javascript' }); + return new Worker(URL.createObjectURL(blob)); + } + + function showError(message) { + const container = document.querySelector('.container'); + const statusEl = document.getElementById('status'); + const spinnerContainer = document.querySelector('.spinner-container'); + + storedErrorMessage = message; // Store for later + + if (isModalOpen) { + pendingAction = 'error'; + if (statusEl) { + statusEl.textContent = 'Error'; // Generic message while modal is open + statusEl.style.display = 'inline-block'; // Make sure status is visible + statusEl.classList.remove('success'); // Ensure no success styling + statusEl.classList.add('error'); // Add error styling for text color + } + // Do not show full error details or animations yet + return; + } + + // Proceed with normal error display if modal is not open + container.classList.add('error'); + container.classList.remove('success'); + + if (statusEl) { + statusEl.style.display = 'inline-block'; + statusEl.textContent = ''; + statusEl.classList.add('error'); + statusEl.classList.remove('success'); + } + + let errorDetails = document.getElementById('error-details'); + if (!errorDetails && spinnerContainer) { + errorDetails = document.createElement('div'); + errorDetails.id = 'error-details'; + errorDetails.className = 'error-details'; + spinnerContainer.appendChild(errorDetails); + } + + if (errorDetails) { + // errorDetails.textContent = message; // This was causing issues, let animations handle it or remove. + errorDetails.style.display = 'none'; // Keep this hidden, rely on class-based animation + } + } + + function showSuccess() { + const statusEl = document.getElementById('status'); + if (statusEl) statusEl.textContent = 'Redirecting'; + + if (isModalOpen) { + pendingAction = 'success'; + // Do not add 'success' class or start animations yet + return; + } + document.querySelector('.container').classList.add('success'); + // The redirect will be handled by submitSolution after a delay + } + + function Verifier(params, targetPath, requestID) { + const workers = []; + const activeBatches = {}; + let powSolution = null; + let isRunning = false; + + const cpuCount = navigator.hardwareConcurrency || 4; + const workerCount = Math.max(1, Math.floor(cpuCount * 0.8)); + + // const REDIRECT_DELAY = 1488; // Defined globally now + + this.start = function () { + setTimeout(findProofOfWork, 100); + }; + + async function findProofOfWork() { + try { + isRunning = true; + + const challenge = params.a; + const salt = params.b; + const target = '0'.repeat(params.c); + + for (let i = 0; i < workerCount; i++) { + const worker = createWorker(); + + worker.onmessage = (e) => handleWorkerMessage(e.data); + worker.onerror = (error) => {}; + workers.push(worker); + } + + const totalRange = Number.MAX_SAFE_INTEGER; + const rangePerWorker = Math.floor(totalRange / workerCount); + + for (let i = 0; i < workers.length; i++) { + const startNonce = i * rangePerWorker; + const endNonce = i === workers.length - 1 ? totalRange : (i + 1) * rangePerWorker - 1; + + const workerId = `pow-worker-${i}`; + + activeBatches[workerId] = { + workerId: i, + startNonce, + endNonce, + }; + + workers[i].postMessage({ + type: 'pow', + data: { + challenge: challenge, + salt: salt, + startNonce, + endNonce, + target, + batchId: workerId, + }, + }); + } + } catch (error) { + terminateWorkers(); + showError(error.message); + } + } + + function handleWorkerMessage(data) { + if (!isRunning) return; + + if (data.type === 'pow_result') { + if (activeBatches[data.batchId]) { + delete activeBatches[data.batchId]; + + if (data.solution && data.solution.found) { + if (!powSolution) { + powSolution = data.solution; + proofOfWorkFound(powSolution); + } + } + } + } else if (data.type === 'error') { + showError('Compatibility error: ' + data.error); + terminateWorkers(); + } + } + + async function proofOfWorkFound(solution) { + isRunning = false; + terminateWorkers(); + try { + const posResult = await new Promise((res) => { + const w = createPosWorker(); + w.onmessage = (e) => { + if (e.data.type === 'pos_result') { + res(e.data); + w.terminate(); + } + }; + w.postMessage({ type: 'pos', seedHex: params.d, isDecoy: false }); + }); + + await submitSolution({ + requestID, + g: solution.nonce, + h: posResult.hashes, + i: posResult.times, + }); + } catch (error) { + showError(error.message); + } + } + + function terminateWorkers() { + workers.forEach((worker) => worker.terminate()); + } + + async function submitSolution(solutionData) { + try { + const response = await fetch('/api/verify', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + request_id: solutionData.requestID, + g: solutionData.g, + h: solutionData.h, + i: solutionData.i, + }), + }); + + if (!response.ok) { + let errorMsg = `Verification failed: ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData && errorData.error) { + errorMsg += ` - ${errorData.error}`; + } else { + const text = await response.text(); + errorMsg += ` - Response: ${text}`; + } + } catch (parseError) {} + showError(errorMsg); + return; + } + + let result; + try { + result = await response.json(); + } catch (e) { + showError('Invalid verification response'); + return; + } + const token = result.token; + if (!token) { + // Use existing showError for immediate display if modal not involved + // or to set up pending error if modal is open. + showError('Verification did not return a token'); + return; + } + + // Store for potential delayed redirect + const sep = targetPath.includes('?') ? '&' : '?'; + storedRedirectUrl = `${targetPath}${sep}token=${encodeURIComponent(token)}`; + + showSuccess(); // This will now respect isModalOpen + + if (!isModalOpen) { + setTimeout(() => { + window.location.href = storedRedirectUrl; + }, REDIRECT_DELAY); + } + } catch (error) { + // Use existing showError + showError('Verification failed. Please refresh the page.'); + } + } + } + } +}); diff --git a/pages/interstitial/page.html b/pages/interstitial/page.html new file mode 100644 index 0000000..b2aac9c --- /dev/null +++ b/pages/interstitial/page.html @@ -0,0 +1,742 @@ + + + + + +Security Checkpoint + + + + + +
+
+

Security Checkpoint

+

Verifying your browser to protect from automated abuse. This may take a few seconds...

+
+
+
Redirecting
+
+
+
+ +
+ \ No newline at end of file diff --git a/pages/interstitial/webfont/Poppins-Regular.woff2 b/pages/interstitial/webfont/Poppins-Regular.woff2 new file mode 100644 index 0000000..4aae28c Binary files /dev/null and b/pages/interstitial/webfont/Poppins-Regular.woff2 differ diff --git a/pages/interstitial/webfont/Poppins-SemiBold.woff2 b/pages/interstitial/webfont/Poppins-SemiBold.woff2 new file mode 100644 index 0000000..990f6f7 Binary files /dev/null and b/pages/interstitial/webfont/Poppins-SemiBold.woff2 differ diff --git a/pages/ipfilter/datacenter.html b/pages/ipfilter/datacenter.html new file mode 100644 index 0000000..b64d56f --- /dev/null +++ b/pages/ipfilter/datacenter.html @@ -0,0 +1 @@ +Blocked (Datacenter) diff --git a/pages/ipfilter/default.html b/pages/ipfilter/default.html new file mode 100644 index 0000000..cb9f9a6 --- /dev/null +++ b/pages/ipfilter/default.html @@ -0,0 +1 @@ +Blocked (Default) diff --git a/pages/ipfilter/india.html b/pages/ipfilter/india.html new file mode 100644 index 0000000..493f6c6 --- /dev/null +++ b/pages/ipfilter/india.html @@ -0,0 +1 @@ +Blocked (India) diff --git a/pages/stats/stats.html b/pages/stats/stats.html new file mode 100644 index 0000000..92bd330 --- /dev/null +++ b/pages/stats/stats.html @@ -0,0 +1,1274 @@ + + + + + + Checkpoint Stats + + + + + +
+

Checkpoint Service Statistics

+ +
+
+
+
+
+
Total Hits
+
--
+
+
+
Total Passes
+
--
+
+
+
Total Failures
+
--
+
+
+
+
+
+ + +
+ +
+
+
+ +
+
+
+
Checkpoint Hits
+
+
+ + +
+
+
+ +
+
Checkpoint Passes
+
+
+ + +
+
+
+ +
+
Checkpoint Failures
+
+
+ + +
+
+
+
+
+ +
Plugin stats below this line
+ +
+
+

IPfilter Statistics

+
+
+
+
Top Blocked ASNs
+
+
+
No data available yet
+
+
+
+ +
+
Top Blocked IPs
+
+
+
No data available yet
+
+
+
+ +
+
Top Triggered Rules
+
+
+
No data available yet
+
+
+
+
+

Data retention period: 30 days

+
+
+ + + + diff --git a/plugins/ipfilter.js b/plugins/ipfilter.js new file mode 100644 index 0000000..3bd7d1d --- /dev/null +++ b/plugins/ipfilter.js @@ -0,0 +1,470 @@ +import { registerPlugin, loadConfig, rootDir } from '../index.js'; +import fs from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import maxmind from 'maxmind'; +import { AhoCorasick } from 'string-dsa'; +import { getRealIP } from '../utils/network.js'; +import { createGunzip } from 'zlib'; +import tarStream from 'tar-stream'; +import { Buffer } from 'buffer'; +import * as logs from '../utils/logs.js'; +import { recordEvent } from './stats.js'; + +const cfg = {}; +await loadConfig('ipfilter', cfg); + +// Map configuration to internal structure +const enabled = cfg.Core.Enabled; +const accountId = cfg.Core.AccountID || process.env.MAXMIND_ACCOUNT_ID; +const licenseKey = cfg.Core.LicenseKey || process.env.MAXMIND_LICENSE_KEY; +const dbUpdateInterval = cfg.Core.DBUpdateIntervalHours; + +const ipBlockCacheTTL = cfg.Cache.IPBlockCacheTTLSec * 1000; +const ipBlockCacheMaxEntries = cfg.Cache.IPBlockCacheMaxEntries; + +const blockedCountryCodes = new Set(cfg.Blocking.CountryCodes); +const blockedContinentCodes = new Set(cfg.Blocking.ContinentCodes); +const defaultBlockPage = cfg.Blocking.DefaultBlockPage; + +// Process ASN blocks +const blockedASNs = {}; +const asnGroupBlockPages = {}; +for (const [group, config] of Object.entries(cfg.ASN || {})) { + blockedASNs[group] = config.Numbers || []; + asnGroupBlockPages[group] = config.BlockPage; +} + +// Process ASN name blocks +const blockedASNNames = {}; +for (const [group, config] of Object.entries(cfg.ASNNames || {})) { + blockedASNNames[group] = config.Patterns || []; + if (config.BlockPage) { + asnGroupBlockPages[group] = config.BlockPage; + } +} + +const countryBlockPages = cfg.CountryBlockPages || {}; +const continentBlockPages = cfg.ContinentBlockPages || {}; + +const ipBlockCache = new Map(); + +const blockPageCache = new Map(); +async function loadBlockPage(filePath) { + if (!blockPageCache.has(filePath)) { + try { + const txt = await fs.promises.readFile(filePath, 'utf8'); + blockPageCache.set(filePath, txt); + } catch { + blockPageCache.set(filePath, null); + } + } + return blockPageCache.get(filePath); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const geoIPCountryDBPath = join(rootDir, 'data/GeoLite2-Country.mmdb'); +const geoIPASNDBPath = join(rootDir, 'data/GeoLite2-ASN.mmdb'); +const updateTimestampPath = join(rootDir, 'data/ipfilter_update.json'); + +let geoipCountryReader, geoipASNReader; + +let isReloading = false; +let reloadLock = Promise.resolve(); + +async function getLastUpdateTimestamp() { + try { + if (fs.existsSync(updateTimestampPath)) { + const data = await fs.promises.readFile(updateTimestampPath, 'utf8'); + const json = JSON.parse(data); + return json.lastUpdated || 0; + } + } catch (err) { + logs.warn('ipfilter', `Failed to read last update timestamp: ${err}`); + } + return 0; +} + +async function saveUpdateTimestamp() { + try { + const timestamp = Date.now(); + await fs.promises.writeFile( + updateTimestampPath, + JSON.stringify({ lastUpdated: timestamp }), + 'utf8', + ); + return timestamp; + } catch (err) { + logs.error('ipfilter', `Failed to save update timestamp: ${err}`); + return Date.now(); + } +} + +// Ensure the update timestamp file exists on first run +if (!fs.existsSync(updateTimestampPath)) { + try { + await saveUpdateTimestamp(); + } catch (err) { + logs.error('ipfilter', `Failed to initialize update timestamp file: ${err}`); + } +} + +// Download GeoIP databases if missing +async function downloadGeoIPDatabases() { + if (!licenseKey || !accountId) { + logs.warn( + 'ipfilter', + 'No MaxMind credentials found; skipping GeoIP database download. Please set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY environment variables or add AccountID and LicenseKey to config/ipfilter.toml', + ); + return; + } + const editions = [ + { id: 'GeoLite2-Country', filePath: geoIPCountryDBPath }, + { id: 'GeoLite2-ASN', filePath: geoIPASNDBPath }, + ]; + for (const { id, filePath } of editions) { + if (!fs.existsSync(filePath)) { + logs.plugin('ipfilter', `Downloading ${id} database...`); + const url = `https://download.maxmind.com/app/geoip_download?edition_id=${id}&license_key=${licenseKey}&suffix=tar.gz`; + const res = await fetch(url); + if (!res.ok) { + logs.error( + 'ipfilter', + `Failed to download ${id} database: ${res.status} ${res.statusText}`, + ); + continue; + } + const tempTar = join(rootDir, 'data', `${id}.tar.gz`); + // write response body into a .tar.gz file + const arrayBuf = await res.arrayBuffer(); + await fs.promises.writeFile(tempTar, Buffer.from(arrayBuf)); + // extract .mmdb files from the downloaded tar.gz + const extract = tarStream.extract(); + extract.on('entry', (header, stream, next) => { + if (header.name.endsWith('.mmdb')) { + const filename = header.name.split('/').pop(); + const outPath = join(rootDir, 'data', filename); + const ws = fs.createWriteStream(outPath); + stream + .pipe(ws) + .on('finish', next) + .on('error', (err) => { + logs.error('ipfilter', `Extraction error: ${err}`); + next(); + }); + } else { + stream.resume(); + next(); + } + }); + await new Promise((resolve, reject) => { + fs.createReadStream(tempTar) + .pipe(createGunzip()) + .pipe(extract) + .on('finish', resolve) + .on('error', reject); + }); + await fs.promises.unlink(tempTar); + logs.plugin('ipfilter', `${id} database downloaded and extracted.`); + } + } +} + +await downloadGeoIPDatabases(); + +async function loadGeoDatabases() { + if (isReloading) { + await reloadLock; + return true; + } + + isReloading = true; + let lockResolve; + reloadLock = new Promise((resolve) => { + lockResolve = resolve; + }); + + try { + const countryStats = fs.statSync(geoIPCountryDBPath); + const asnStats = fs.statSync(geoIPASNDBPath); + + if (countryStats.size > 1024 && asnStats.size > 1024) { + logs.plugin('ipfilter', 'Initializing GeoIP databases from disk...'); + const newCountryReader = await maxmind.open(geoIPCountryDBPath); + const newASNReader = await maxmind.open(geoIPASNDBPath); + + try { + const testIP = '8.8.8.8'; + const countryTest = newCountryReader.get(testIP); + const asnTest = newASNReader.get(testIP); + + if (!countryTest || !asnTest) { + throw new Error('Database validation failed: test lookups returned empty results'); + } + } catch (validationErr) { + logs.error('ipfilter', `GeoIP database validation failed: ${validationErr}`); + + try { + await newCountryReader.close(); + } catch (e) {} + try { + await newASNReader.close(); + } catch (e) {} + throw new Error('Database validation failed'); + } + + const oldCountryReader = geoipCountryReader; + const oldASNReader = geoipASNReader; + + geoipCountryReader = newCountryReader; + geoipASNReader = newASNReader; + if (oldCountryReader || oldASNReader) { + logs.plugin('ipfilter', 'GeoIP databases reloaded and active'); + } else { + logs.plugin('ipfilter', 'GeoIP databases loaded and active'); + } + + ipBlockCache.clear(); + + await saveUpdateTimestamp(); + + if (oldCountryReader || oldASNReader) { + setTimeout(async () => { + if (oldCountryReader) { + try { + await oldCountryReader.close(); + } catch (e) {} + } + if (oldASNReader) { + try { + await oldASNReader.close(); + } catch (e) {} + } + logs.plugin('ipfilter', 'Old GeoIP database instances closed successfully'); + }, 5000); + } + + return true; + } else { + logs.warn( + 'ipfilter', + 'GeoIP database files are empty or too small. IP filtering will be disabled.', + ); + return false; + } + } catch (err) { + logs.error('ipfilter', `Failed to load GeoIP databases: ${err}`); + return false; + } finally { + isReloading = false; + lockResolve(); + } +} + +async function checkAndUpdateDatabases() { + if (isReloading) return false; + + const lastUpdate = await getLastUpdateTimestamp(); + const now = Date.now(); + const hoursSinceUpdate = (now - lastUpdate) / (1000 * 60 * 60); + + if (hoursSinceUpdate >= dbUpdateInterval) { + logs.plugin( + 'ipfilter', + `GeoIP databases last updated ${hoursSinceUpdate.toFixed(1)} hours ago, reloading...`, + ); + return await loadGeoDatabases(); + } + + return false; +} + +function startPeriodicDatabaseUpdates() { + // Calculate interval in milliseconds + const intervalMs = dbUpdateInterval * 60 * 60 * 1000; + + // Schedule periodic updates + setInterval(async () => { + try { + await checkAndUpdateDatabases(); + } catch (err) { + logs.error('ipfilter', `Failed during periodic database update: ${err}`); + } + }, intervalMs); + + logs.plugin('ipfilter', `Scheduled GeoIP database updates every ${dbUpdateInterval} hours`); +} + +await loadGeoDatabases(); + +startPeriodicDatabaseUpdates(); + +const asnNameMatchers = new Map(); +for (const [group, names] of Object.entries(blockedASNNames)) { + asnNameMatchers.set(group, new AhoCorasick(names)); +} + +function cacheAndReturn(ip, blocked, blockType, blockValue, customPage, asnOrgName) { + const expiresAt = Date.now() + ipBlockCacheTTL; + ipBlockCache.set(ip, { blocked, blockType, blockValue, customPage, asnOrgName, expiresAt }); + // Enforce maximum cache size + if (ipBlockCacheMaxEntries > 0 && ipBlockCache.size > ipBlockCacheMaxEntries) { + // Remove the oldest entry (first key in insertion order) + const oldestKey = ipBlockCache.keys().next().value; + ipBlockCache.delete(oldestKey); + } + return [blocked, blockType, blockValue, customPage, asnOrgName]; +} + +function isBlockedIPExtended(ip) { + const now = Date.now(); + const entry = ipBlockCache.get(ip); + if (entry) { + if (entry.expiresAt > now) { + // Refresh recency by re-inserting entry + ipBlockCache.delete(ip); + ipBlockCache.set(ip, entry); + return [entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName]; + } else { + // Entry expired, remove it + ipBlockCache.delete(ip); + } + } + + const countryReader = geoipCountryReader; + const asnReader = geoipASNReader; + + if (!countryReader || !asnReader) { + return [false, '', '', '', '']; + } + + let countryInfo; + try { + countryInfo = countryReader.get(ip); + } catch (e) {} + if (countryInfo?.country && blockedCountryCodes.has(countryInfo.country.iso_code)) { + const page = countryBlockPages[countryInfo.country.iso_code] || defaultBlockPage; + return cacheAndReturn(ip, true, 'country', countryInfo.country.iso_code, page, ''); + } + + if (countryInfo?.continent && blockedContinentCodes.has(countryInfo.continent.code)) { + const page = continentBlockPages[countryInfo.continent.code] || defaultBlockPage; + return cacheAndReturn(ip, true, 'continent', countryInfo.continent.code, page, ''); + } + + let asnInfo; + try { + asnInfo = asnReader.get(ip); + } catch (e) {} + if (asnInfo?.autonomous_system_number) { + const asn = asnInfo.autonomous_system_number; + const orgName = asnInfo.autonomous_system_organization || ''; + + for (const [group, arr] of Object.entries(blockedASNs)) { + if (arr.includes(asn)) { + const page = asnGroupBlockPages[group] || defaultBlockPage; + return cacheAndReturn(ip, true, 'asn', group, page, orgName); + } + } + + for (const [group, matcher] of asnNameMatchers.entries()) { + const matches = matcher.find(orgName); + if (matches.length) { + const page = asnGroupBlockPages[group] || defaultBlockPage; + return cacheAndReturn(ip, true, 'asn', group, page, orgName); + } + } + } + + return cacheAndReturn(ip, false, '', '', '', ''); +} + +function IPBlockMiddleware() { + return async (request, server) => { + const clientIP = getRealIP(request, server); + logs.plugin('ipfilter', `Incoming request from IP: ${clientIP}`); + const [blocked, blockType, blockValue, customPage, asnOrgName] = isBlockedIPExtended(clientIP); + + if (blocked) { + recordEvent('ipfilter.block', { + type: blockType, + value: blockValue, + asn_org: asnOrgName, + ip: clientIP, // Include the IP address for stats + }); + const url = new URL(request.url); + + if (url.pathname.startsWith('/api')) { + return new Response( + JSON.stringify({ + error: 'Access denied from your location or network.', + reason: 'geoip', + type: blockType, + value: blockValue, + asn_org: asnOrgName, + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + // Normalize page paths by stripping leading slash + const cleanCustomPage = customPage.replace(/^\/+/, ''); + const cleanDefaultPage = defaultBlockPage.replace(/^\/+/, ''); + + let html = ''; + logs.plugin( + 'ipfilter', + `Block pages: custom="${cleanCustomPage}", default="${cleanDefaultPage}"`, + ); + logs.plugin('ipfilter', 'Searching for block page in the following locations:'); + const paths = [ + // allow absolute paths relative to project root first + join(rootDir, cleanCustomPage), + ]; + // Fallback to default block page if custom page isn't found + if (customPage !== defaultBlockPage) { + paths.push( + // check default page at root directory + join(rootDir, cleanDefaultPage), + ); + } + + for (const p of paths) { + logs.plugin('ipfilter', `Trying block page at: ${p}`); + const content = await loadBlockPage(p); + logs.plugin('ipfilter', `Load result for ${p}: ${content ? 'FOUND' : 'NOT FOUND'}`); + if (content) { + html = content; + break; + } + } + + if (html) { + const output = html.replace('{{.ASNName}}', asnOrgName || 'Blocked Network'); + return new Response(output, { + status: 403, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } else { + return new Response('Access denied from your location or network.', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, + }); + } + } + + return undefined; + }; +} + +if (enabled) { + registerPlugin('ipfilter', IPBlockMiddleware()); +} else { + logs.plugin('ipfilter', 'IP filter plugin disabled via config'); +} + +export { checkAndUpdateDatabases, loadGeoDatabases }; diff --git a/plugins/proxy.js b/plugins/proxy.js new file mode 100644 index 0000000..5fda5bf --- /dev/null +++ b/plugins/proxy.js @@ -0,0 +1,303 @@ +import { registerPlugin, loadConfig } from '../index.js'; +import * as logs from '../utils/logs.js'; + +const proxyConfig = {}; +await loadConfig('proxy', proxyConfig); + +// Map configuration to internal structure +const enabled = proxyConfig.Core.Enabled; +const wsTimeout = proxyConfig.Timeouts.WebSocketTimeoutMs; +const upstreamTimeout = proxyConfig.Timeouts.UpstreamTimeoutMs; + +// Build proxy mappings from array format +const proxyMappings = {}; +proxyConfig.Mapping.forEach((mapping) => { + proxyMappings[mapping.Host] = mapping.Target; +}); + +logs.plugin('proxy', `Proxy mappings loaded: ${JSON.stringify(proxyMappings)}`); + +const HOP_BY_HOP_HEADERS = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]; + +// Connect to upstream WebSocket with handshake timeout +async function connectUpstreamWebSocket(url, headers) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { headers }); + const timer = setTimeout(() => { + ws.close(); + reject(new Error('timeout')); + }, wsTimeout); + ws.onopen = () => { + clearTimeout(timer); + resolve(ws); + }; + ws.onerror = (err) => { + clearTimeout(timer); + reject(err); + }; + ws.onclose = () => { + clearTimeout(timer); + reject(new Error('closed')); + }; + }); +} + +async function createProxyResponse(targetURL, request) { + try { + const url = new URL(request.url); + const targetPathAndQuery = url.pathname + url.search; + const fullTargetURL = new URL(targetPathAndQuery, targetURL).toString(); + + const startTime = Date.now(); + + const outgoingHeaders = new Headers(request.headers); + outgoingHeaders.delete('host'); + + // Set proper host header for the target + const targetHost = new URL(targetURL).host; + outgoingHeaders.set('host', targetHost); + + // Forward the original host as X-Forwarded-Host for applications that need it + outgoingHeaders.set('x-forwarded-host', request.headers.get('host')); + outgoingHeaders.set('x-forwarded-proto', url.protocol.replace(':', '')); + + // Preserve important headers for authentication + // Don't delete content-length or transfer-encoding here, handle them properly below + const options = { + method: request.method, + headers: outgoingHeaders, + // Follow redirects automatically for GET; forward redirects for non-GET + // Absolute requirement: DONT REMOVE + redirect: request.method === 'GET' ? 'follow' : 'manual', + credentials: 'include', + }; + + const isChunked = request.headers.get('transfer-encoding')?.toLowerCase() === 'chunked'; + + // Define methods that can legitimately have request bodies + const methodsWithBody = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + + if (methodsWithBody.has(request.method) && request.body) { + if (isChunked) { + logs.plugin('proxy', `De-chunking request body for ${request.method} ${request.url}`); + try { + const bodyBuffer = await request.arrayBuffer(); + options.body = bodyBuffer; + outgoingHeaders.set('content-length', String(bodyBuffer.byteLength)); + outgoingHeaders.delete('transfer-encoding'); + } catch (bufferError) { + logs.error('proxy', `Error buffering chunked request body: ${bufferError}`); + return new Response('Error processing chunked request body', { status: 500 }); + } + } else { + // For non-chunked bodies, preserve the body stream + options.body = request.body; + // Keep the original content-length if it exists + if (request.headers.has('content-length')) { + outgoingHeaders.set('content-length', request.headers.get('content-length')); + } + } + } + + // Add a timeout controller for the upstream fetch + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + logs.warn( + 'proxy', + `Upstream request to ${fullTargetURL} timed out after ${upstreamTimeout}ms`, + ); + controller.abort(); + }, upstreamTimeout); + + let response; + try { + response = await fetch(fullTargetURL, { + ...options, + signal: controller.signal, + verbose: true, + }); + } catch (fetchErr) { + clearTimeout(timeoutId); + if (fetchErr.name === 'AbortError') { + logs.error('proxy', `Upstream fetch aborted for ${fullTargetURL} (likely due to timeout)`); + return new Response('Gateway Timeout', { status: 504 }); + } + throw fetchErr; + } + clearTimeout(timeoutId); + + const latency = Date.now() - startTime; + + logs.plugin( + 'proxy', + `Proxied request to: ${fullTargetURL} (${response.status} ${response.statusText}) (${latency}ms)`, + ); + + const responseHeaders = new Headers(response.headers); + + // Remove hop-by-hop headers + HOP_BY_HOP_HEADERS.forEach((h) => responseHeaders.delete(h)); + + // Remove content-encoding and content-length headers + // This is necessary because Bun/fetch automatically decompresses the response body + // but leaves the content-encoding header, causing the browser to try to decompress already decompressed content + responseHeaders.delete('content-encoding'); + responseHeaders.delete('content-length'); + + // Add proxy information + responseHeaders.set('X-Proxy-Latency', `${latency}ms`); + + // Handle Set-Cookie headers - rewrite domain if needed + const setCookieHeaders = response.headers.getSetCookie ? response.headers.getSetCookie() : []; + if (setCookieHeaders.length > 0) { + responseHeaders.delete('set-cookie'); + + setCookieHeaders.forEach((cookieStr) => { + // Parse and potentially rewrite the cookie domain + let modifiedCookie = cookieStr; + + // Remove domain restrictions that might prevent the cookie from working + modifiedCookie = modifiedCookie.replace(/;\s*domain=[^;]*/gi, ''); + + // If the cookie has SameSite=None, ensure it also has Secure + if (modifiedCookie.match(/samesite\s*=\s*none/i) && !modifiedCookie.match(/secure/i)) { + modifiedCookie += '; Secure'; + } + + // For local development, you might need to adjust SameSite + if (url.protocol === 'http:' && modifiedCookie.match(/samesite\s*=\s*none/i)) { + modifiedCookie = modifiedCookie.replace(/;\s*samesite=[^;]*/gi, '; SameSite=Lax'); + modifiedCookie = modifiedCookie.replace(/;\s*secure/gi, ''); + } + + responseHeaders.append('set-cookie', modifiedCookie); + }); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (err) { + logs.error( + 'proxy', + `Proxy error processing ${request.method} ${request.url}: ${err.message}${ + err.cause ? ' - Cause: ' + err.cause : '' + }`, + ); + let causeDetails = ''; + if (err.cause) { + causeDetails = typeof err.cause === 'object' ? JSON.stringify(err.cause) : String(err.cause); + } + logs.error( + 'proxy', + `Full error details: ${err.stack}${err.cause ? '\nCause: ' + causeDetails : ''}`, + ); + return new Response('Bad Gateway', { status: 502 }); + } +} + +function proxyMiddleware() { + return async (request, server) => { + const url = new URL(request.url); + const path = url.pathname; + + // Skip checkpoint endpoints + if (path.startsWith('/api/challenge') || path.startsWith('/api/verify')) return undefined; + + // Skip static assets + if (path.startsWith('/webfont/') || path.startsWith('/js/')) return undefined; + + // Get the hostname from the request + const hostname = request.headers.get('host')?.split(':')[0]; + const target = proxyMappings[hostname]; + if (!target) return undefined; + + // Handle WebSocket upgrade requests + const upgradeHeader = request.headers.get('upgrade')?.toLowerCase(); + if (upgradeHeader === 'websocket') { + const targetUrl = new URL(url.pathname + url.search, target); + targetUrl.protocol = targetUrl.protocol.replace(/^http/, 'ws'); + + // Forward important headers for WebSocket + const wsHeaders = new Headers(); + if (request.headers.has('cookie')) wsHeaders.set('Cookie', request.headers.get('cookie')); + if (request.headers.has('authorization')) + wsHeaders.set('Authorization', request.headers.get('authorization')); + if (request.headers.has('origin')) wsHeaders.set('Origin', request.headers.get('origin')); + if (request.headers.has('sec-websocket-protocol')) + wsHeaders.set('Sec-WebSocket-Protocol', request.headers.get('sec-websocket-protocol')); + if (request.headers.has('sec-websocket-extensions')) + wsHeaders.set('Sec-WebSocket-Extensions', request.headers.get('sec-websocket-extensions')); + + let upstream; + try { + // Convert Headers object to a plain object for the WebSocket constructor + const plainWsHeaders = {}; + for (const [key, value] of wsHeaders) { + plainWsHeaders[key] = value; + } + upstream = await connectUpstreamWebSocket(targetUrl.toString(), plainWsHeaders); + } catch (err) { + logs.error('proxy', `Upstream WebSocket connection failed: ${err}`); + return new Response('Bad Gateway', { status: 502 }); + } + // Upgrade incoming client connection and attach upstream socket + const ok = server.upgrade(request, { data: { upstream } }); + if (!ok) { + logs.error('proxy', 'WebSocket upgrade failed'); + upstream.close(); + return new Response('Bad Gateway', { status: 502 }); + } + logs.plugin('proxy', `WebSocket proxied to: ${targetUrl.toString()}`); + return; + } + + return createProxyResponse(target, request); + }; +} + +// WebSocket handlers for proxying messages between client and upstream +export const proxyWebSocketHandler = { + open(ws) { + const upstream = ws.data.upstream; + upstream.onopen = () => logs.plugin('proxy', 'Upstream WebSocket connected'); + // Forward messages from target to client + upstream.onmessage = (event) => ws.send(event.data); + upstream.onerror = (err) => { + logs.error('proxy', `Upstream WebSocket error: ${err}`); + ws.close(1011, 'Upstream error'); + }; + upstream.onclose = ({ code, reason }) => ws.close(code, reason); + }, + message(ws, message) { + const upstream = ws.data.upstream; + // Forward messages from client to target + upstream.send(message); + }, + close(ws, code, reason) { + const upstream = ws.data.upstream; + upstream.close(code, reason); + }, + error(ws, err) { + logs.error('proxy', `WebSocket proxy error: ${err}`); + const upstream = ws.data.upstream; + upstream.close(); + }, +}; + +if (enabled) { + registerPlugin('proxy', proxyMiddleware()); +} else { + logs.plugin('proxy', 'Proxy plugin disabled via config'); +} diff --git a/plugins/stats.js b/plugins/stats.js new file mode 100644 index 0000000..5a5f29c --- /dev/null +++ b/plugins/stats.js @@ -0,0 +1,132 @@ +import { registerPlugin, rootDir, loadConfig } from '../index.js'; +import { Level } from 'level'; +import ttl from 'level-ttl'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Readable } from 'stream'; +import cookie from 'cookie'; +import { getRealIP } from '../utils/network.js'; +import { parseDuration } from '../utils/time.js'; + +// Load stats configuration +const statsConfig = {}; +await loadConfig('stats', statsConfig); + +// Map configuration to internal structure +const enabled = statsConfig.Core.Enabled; +const statsTTL = parseDuration(statsConfig.Storage.StatsTTL); +const statsUIPath = statsConfig.WebUI.StatsUIPath; +const statsAPIPath = statsConfig.WebUI.StatsAPIPath; + +// Determine __dirname for ES modules +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Adds createReadStream support to LevelDB instances using async iterator. + */ +function addReadStreamSupport(dbInstance) { + if (!dbInstance.createReadStream) { + dbInstance.createReadStream = (opts) => + Readable.from( + (async function* () { + for await (const [key, value] of dbInstance.iterator(opts)) { + yield { key, value }; + } + })(), + ); + } + return dbInstance; +} + +// Initialize LevelDB for stats under db/stats with TTL and stream support +const statsDBPath = path.join(rootDir, 'db', 'stats'); +await fs.mkdir(statsDBPath, { recursive: true }); +let rawStatsDB = new Level(statsDBPath, { valueEncoding: 'json' }); +rawStatsDB = addReadStreamSupport(rawStatsDB); +const statsDB = ttl(rawStatsDB, { defaultTTL: statsTTL }); +addReadStreamSupport(statsDB); + +/** + * Record a stat event with a metric name and optional data. + * @param {string} metric + * @param {object} data + */ +function recordEvent(metric, data = {}) { + // Skip if statsDB is not initialized + if (typeof statsDB === 'undefined' || !statsDB || typeof statsDB.put !== 'function') { + console.warn(`stats: cannot record "${metric}", statsDB not available`); + return; + } + const timestamp = Date.now(); + // key includes metric and timestamp and a random suffix to avoid collisions + const key = `${metric}:${timestamp}:${Math.random().toString(36).slice(2, 8)}`; + try { + // Use callback form to avoid promise chaining + statsDB.put(key, { timestamp, metric, ...data }, (err) => { + if (err) console.error('stats: failed to record event', err); + }); + } catch (err) { + console.error('stats: failed to record event', err); + } +} + +// Handler for serving the stats HTML UI +async function handleStatsPage(request) { + const url = new URL(request.url); + if (url.pathname !== statsUIPath) return undefined; + try { + const html = await fs.readFile(path.join(__dirname, 'stats.html'), 'utf8'); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } catch (e) { + return new Response('Stats UI not found', { status: 404 }); + } +} + +// Handler for stats API +async function handleStatsAPI(request) { + const url = new URL(request.url); + if (url.pathname !== statsAPIPath) return undefined; + const metric = url.searchParams.get('metric'); + const start = parseInt(url.searchParams.get('start') || '0', 10); + const end = parseInt(url.searchParams.get('end') || `${Date.now()}`, 10); + const result = []; + // Iterate over keys for this metric in the time range + for await (const [key, value] of statsDB.iterator({ + gte: `${metric}:${start}`, + lte: `${metric}:${end}\uffff`, + })) { + result.push(value); + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// Middleware for stats plugin +function StatsMiddleware() { + return async (request) => { + // Always serve stats UI and API first, bypassing auth + const pageResp = await handleStatsPage(request); + if (pageResp) return pageResp; + const apiResp = await handleStatsAPI(request); + if (apiResp) return apiResp; + + // For any other routes, do not handle + return undefined; + }; +} + +// Register the stats plugin +if (enabled) { + registerPlugin('stats', StatsMiddleware()); +} else { + console.log('Stats plugin disabled via config'); +} + +// Export recordEvent for other plugins to use +export { recordEvent }; diff --git a/utils/logs.js b/utils/logs.js new file mode 100644 index 0000000..038a902 --- /dev/null +++ b/utils/logs.js @@ -0,0 +1,41 @@ +const seenConfigs = new Set(); + +export function init(msg) { + console.log(msg); +} + +export function plugin(_name, msg) { + console.log(msg); +} + +export function config(name, msg) { + if (!seenConfigs.has(name)) { + console.log(`Config ${msg} for ${name}`); + seenConfigs.add(name); + } +} + +export function db(msg) { + console.log(msg); +} + +export function server(msg) { + console.log(msg); +} + +export function section(title) { + console.log(`\n=== ${title.toUpperCase()} ===`); +} + +export function warn(_category, msg) { + console.warn(`WARNING: ${msg}`); +} + +export function error(_category, msg) { + console.error(`ERROR: ${msg}`); +} + +// General message function for bullet items +export function msg(msg) { + console.log(msg); +} diff --git a/utils/network.js b/utils/network.js new file mode 100644 index 0000000..100eb13 --- /dev/null +++ b/utils/network.js @@ -0,0 +1,13 @@ +export function getRealIP(request, server) { + let ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'); + if (ip?.includes(',')) ip = ip.split(',')[0].trim(); + if (!ip && server) { + ip = server.remoteAddress; + } + if (!ip) { + const url = new URL(request.url); + ip = url.hostname; + } + if (ip?.startsWith('::ffff:')) ip = ip.slice(7); + return ip; +} diff --git a/utils/plugins.js b/utils/plugins.js new file mode 100644 index 0000000..b2fd961 --- /dev/null +++ b/utils/plugins.js @@ -0,0 +1,28 @@ +import { resolve, extname, sep, isAbsolute } from 'path'; +import { pathToFileURL } from 'url'; +import { rootDir } from '../index.js'; + +/** + * Securely import a JavaScript module from within the application root. + * Prevents path traversal and disallows non-.js extensions. + * + * @param {string} relPath - The relative path to the module from the application root. + * @returns {Promise} The imported module. + */ +export async function secureImportModule(relPath) { + if (isAbsolute(relPath)) { + throw new Error('Absolute paths are not allowed for module imports'); + } + if (relPath.includes('..')) { + throw new Error('Relative paths containing .. are not allowed for module imports'); + } + if (extname(relPath) !== '.js') { + throw new Error(`Only .js files can be imported: ${relPath}`); + } + const absPath = resolve(rootDir, relPath); + if (!absPath.startsWith(rootDir + sep)) { + throw new Error(`Module path outside of application root: ${relPath}`); + } + const url = pathToFileURL(absPath).href; + return import(url); +} diff --git a/utils/proof.js b/utils/proof.js new file mode 100644 index 0000000..2058e91 --- /dev/null +++ b/utils/proof.js @@ -0,0 +1,72 @@ +import crypto from 'crypto'; +import { getRealIP } from './network.js'; + +export function generateChallenge(checkpointConfig) { + const challenge = crypto.randomBytes(16).toString('hex'); + const salt = crypto.randomBytes(checkpointConfig.SaltLength).toString('hex'); + return { challenge, salt }; +} + +export function calculateHash(input) { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +export function verifyPoW(challenge, salt, nonce, difficulty) { + const hash = calculateHash(challenge + salt + nonce); + return hash.startsWith('0'.repeat(difficulty)); +} + +export function checkPoSTimes(times, enableCheck, ratio) { + if (!Array.isArray(times) || times.length !== 3) { + throw new Error('Invalid PoS run times length'); + } + const minT = Math.min(...times); + const maxT = Math.max(...times); + if (enableCheck && maxT > minT * ratio) { + throw new Error(`PoS run times inconsistent (ratio ${maxT / minT} > ${ratio})`); + } +} + +export const challengeStore = new Map(); + +export function generateRequestID(request, checkpointConfig) { + const { challenge, salt } = generateChallenge(checkpointConfig); + const posSeed = crypto.randomBytes(32).toString('hex'); + const requestID = crypto.randomBytes(16).toString('hex'); + const params = { + Challenge: challenge, + Salt: salt, + Difficulty: checkpointConfig.Difficulty, + ExpiresAt: Date.now() + checkpointConfig.ChallengeExpiration, + CreatedAt: Date.now(), + ClientIP: getRealIP(request), + PoSSeed: posSeed, + }; + challengeStore.set(requestID, params); + return requestID; +} + +export function getChallengeParams(requestID) { + return challengeStore.get(requestID); +} + +export function deleteChallenge(requestID) { + challengeStore.delete(requestID); +} + +export function verifyPoS(hashes, times, checkpointConfig) { + if (!Array.isArray(hashes) || hashes.length !== 3) { + throw new Error('Invalid PoS hashes length'); + } + if (!Array.isArray(times) || times.length !== 3) { + throw new Error('Invalid PoS run times length'); + } + if (hashes[0] !== hashes[1] || hashes[1] !== hashes[2]) { + throw new Error('PoS hashes do not match'); + } + if (hashes[0].length !== 64) { + throw new Error('Invalid PoS hash length'); + } + + checkPoSTimes(times, checkpointConfig.CheckPoSTimes, checkpointConfig.PoSTimeConsistencyRatio); +} diff --git a/utils/time.js b/utils/time.js new file mode 100644 index 0000000..d10e569 --- /dev/null +++ b/utils/time.js @@ -0,0 +1,20 @@ +export function parseDuration(str) { + if (!str) return 0; + const m = /^([0-9]+)(ms|s|m|h|d)$/.exec(str); + if (!m) return 0; + const val = parseInt(m[1], 10); + switch (m[2]) { + case 'ms': + return val; + case 's': + return val * 1000; + case 'm': + return val * 60 * 1000; + case 'h': + return val * 60 * 60 * 1000; + case 'd': + return val * 24 * 60 * 60 * 1000; + default: + return 0; + } +}