diff --git a/.dockerignore b/.dockerignore index 9d9224f0..5697501c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ Dockerfile .dockerignore .git .gitignore -.github \ No newline at end of file +.github +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 24605624..9b069f91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,16 @@ FROM node:22-slim RUN apt-get update && \ - apt-get install -y --no-install-recommends ca-certificates curl && \ + apt-get install -y --no-install-recommends ca-certificates curl python3 make g++ && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY . . -RUN npm install +RUN npm install --omit=dev && \ + apt-get purge -y --auto-remove python3 make g++ && \ + rm -rf /root/.npm /tmp/* EXPOSE 8080 -CMD ["npm", "run", "start"] +CMD ["node", "--import", "./server/register.mjs", "server/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 79c8ff6e..9e53d048 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,5 @@ services: ports: - "7658:8080" volumes: - - ./wrangler.toml:/app/wrangler.toml - ./data:/app/data restart: unless-stopped diff --git a/functions/api/manage/delete/[[path]].js b/functions/api/manage/delete/[[path]].js index ffa53494..64a84479 100644 --- a/functions/api/manage/delete/[[path]].js +++ b/functions/api/manage/delete/[[path]].js @@ -36,7 +36,9 @@ export async function onRequest(context) { // 获取指定目录下的所有文件 const listUrl = new URL(`${url.origin}/api/manage/list?count=-1&dir=${currentFolder.path}`); - const listRequest = new Request(listUrl, request); + const listRequest = new Request(listUrl, { + headers: request.headers, + }); const listResponse = await fetch(listRequest); const listData = await listResponse.json(); diff --git a/functions/api/manage/move/[[path]].js b/functions/api/manage/move/[[path]].js index 3e055175..8d9c4f3f 100644 --- a/functions/api/manage/move/[[path]].js +++ b/functions/api/manage/move/[[path]].js @@ -33,7 +33,9 @@ export async function onRequest(context) { // 获取指定目录下的所有文件 const listUrl = new URL(`${url.origin}/api/manage/list?count=-1&dir=${currentFolder.path}`); - const listRequest = new Request(listUrl, request); + const listRequest = new Request(listUrl, { + headers: request.headers, + }); const listResponse = await fetch(listRequest); const listData = await listResponse.json(); diff --git a/functions/file/[[path]].js b/functions/file/[[path]].js index 45381a89..3cd2cea3 100644 --- a/functions/file/[[path]].js +++ b/functions/file/[[path]].js @@ -5,7 +5,7 @@ import { DiscordAPI } from "../utils/discordAPI"; import { HuggingFaceAPI } from "../utils/huggingfaceAPI"; import { setCommonHeaders, setRangeHeaders, handleHeadRequest, getFileContent, isTgChannel, - returnWithCheck, return404, isDomainAllowed + returnWithCheck, return404, returnBlockImg, isDomainAllowed } from './fileTools'; import { getDatabase } from '../utils/databaseAdapter.js'; diff --git a/functions/utils/purgeCache.js b/functions/utils/purgeCache.js index 3fe4185b..54477af2 100644 --- a/functions/utils/purgeCache.js +++ b/functions/utils/purgeCache.js @@ -6,19 +6,28 @@ let cfEmail = ""; let cfApiKey = ""; export async function purgeCFCache(env, cdnUrl) { - // 读取其他设置 - othersConfig = await fetchOthersConfig(env); - cfZoneId = othersConfig.cloudflareApiToken.CF_ZONE_ID; - cfEmail = othersConfig.cloudflareApiToken.CF_EMAIL; - cfApiKey = othersConfig.cloudflareApiToken.CF_API_KEY; + try { + // 读取其他设置 + othersConfig = await fetchOthersConfig(env); + cfZoneId = othersConfig.cloudflareApiToken.CF_ZONE_ID; + cfEmail = othersConfig.cloudflareApiToken.CF_EMAIL; + cfApiKey = othersConfig.cloudflareApiToken.CF_API_KEY; - // 清除CDN缓存 - const options = { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-Auth-Email': `${cfEmail}`, 'X-Auth-Key': `${cfApiKey}`}, - body: `{"files":["${ cdnUrl }"]}` - }; - await fetch(`https://api.cloudflare.com/client/v4/zones/${ cfZoneId }/purge_cache`, options); + // 如果没有配置Cloudflare API,跳过缓存清除 + if (!cfZoneId || !cfEmail || !cfApiKey) { + return; + } + + // 清除CDN缓存 + const options = { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-Auth-Email': `${cfEmail}`, 'X-Auth-Key': `${cfApiKey}`}, + body: `{"files":["${ cdnUrl }"]}` + }; + await fetch(`https://api.cloudflare.com/client/v4/zones/${ cfZoneId }/purge_cache`, options); + } catch (error) { + console.error('Failed to purge CF cache:', error.message || error); + } } export async function purgeRandomFileListCache(origin, ...dirs) { diff --git a/package-lock.json b/package-lock.json index bb70144d..576657c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "self-imgbed", + "name": "CloudFlare-ImgBed", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,7 +7,10 @@ "dependencies": { "@aws-sdk/client-s3": "3.726.1", "@cloudflare/pages-plugin-sentry": "^1.1.3", + "@hono/node-server": "^1.13.8", "@sentry/tracing": "^7.114.0", + "better-sqlite3": "^11.9.1", + "hono": "^4.7.4", "miniflare": "^3.20240718.0" }, "devDependencies": { @@ -1892,6 +1895,18 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", + "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -3398,6 +3413,37 @@ "dev": true, "license": "MIT" }, + "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/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3411,6 +3457,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -3454,6 +3520,30 @@ "dev": true, "license": "ISC" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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.1.13" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3536,6 +3626,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3675,6 +3771,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3689,7 +3809,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -3727,6 +3846,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -3863,6 +3991,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-xml-builder": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", @@ -3894,6 +4031,12 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3972,6 +4115,12 @@ "node": ">= 6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4063,6 +4212,12 @@ "source-map": "^0.6.1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -4178,6 +4333,35 @@ "he": "bin/he" } }, + "node_modules/hono": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz", + "integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==", + "license": "MIT", + "engines": { + "node": ">=16.9.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/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4194,7 +4378,12 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/is-binary-path": { @@ -4386,6 +4575,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/miniflare": { "version": "3.20250718.3", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", @@ -4428,12 +4629,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mocha": { "version": "10.8.2", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", @@ -4517,6 +4723,24 @@ "mustache": "bin/mustache" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4531,7 +4755,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4606,6 +4829,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", @@ -4619,6 +4869,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -4629,6 +4889,44 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4666,7 +4964,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -4687,7 +4984,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4764,6 +5060,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4799,6 +5140,15 @@ "npm": ">=6" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4868,6 +5218,34 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4897,6 +5275,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", @@ -4919,6 +5309,12 @@ "pathe": "^2.0.3" } }, + "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/wait-on": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", @@ -5204,7 +5600,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index 7f6270e1..95b382d6 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,19 @@ { + "type": "module", "scripts": { "ci-test": "concurrently --kill-others \"npm start\" \"wait-on http://localhost:8080 && mocha\"", + "ci-test:docker": "concurrently --kill-others \"npm run start:docker\" \"wait-on http://localhost:8080 && mocha\"", "test": "mocha", - "start": "npx wrangler pages dev ./ --kv \"img_url\" --r2 \"img_r2\" --ip 0.0.0.0 --port 8080 --persist-to ./data" + "start": "npx wrangler pages dev ./ --kv \"img_url\" --r2 \"img_r2\" --ip 0.0.0.0 --port 8080 --persist-to ./data", + "start:docker": "node --import ./server/register.mjs server/index.js" }, "dependencies": { "@aws-sdk/client-s3": "3.726.1", "@cloudflare/pages-plugin-sentry": "^1.1.3", "@sentry/tracing": "^7.114.0", + "better-sqlite3": "^11.9.1", + "hono": "^4.7.4", + "@hono/node-server": "^1.13.8", "miniflare": "^3.20240718.0" }, "overrides": { diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..5269c6a7 --- /dev/null +++ b/server/index.js @@ -0,0 +1,376 @@ +/** + * Docker 模式下的原生 Node.js 服务器 + * 使用 Hono 作为 Web 框架,代理 Cloudflare Pages Functions 请求 + * 使用 SQLite 替代 D1,本地文件系统替代 R2 + */ + +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { existsSync, readFileSync, readdirSync, statSync, mkdirSync } from 'fs'; +import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { SqliteD1 } from './sqliteD1.js'; +import { LocalR2Storage } from './r2Storage.js'; + +// ==================== 模拟 Cloudflare 全局 API ==================== + +// 模拟 Cloudflare Cache API(Node.js 中不存在) +if (typeof globalThis.caches === 'undefined') { + globalThis.caches = { + default: { + async match() { return undefined; }, + async put() {}, + async delete() { return false; }, + }, + }; +} + +// ==================== 自引用 Fetch 拦截器 ==================== +// 解决 Docker 端口映射导致 functions 内部 fetch(url.origin + ...) 失败的问题 +// 不再重写请求 URL,而是拦截自引用的 fetch 调用,透明路由到内部端口 +// 这样 url.origin 保持为外部 origin,确保 Referer 匹配、返回链接、重定向等功能正常 + +const selfOrigins = new Set(); +const originalFetch = globalThis.fetch; + +globalThis.fetch = async function(input, init) { + try { + let urlStr; + if (typeof input === 'string') { + urlStr = input; + } else if (input instanceof URL) { + urlStr = input.toString(); + } else if (input instanceof Request) { + urlStr = input.url; + } + + if (urlStr) { + const parsed = new URL(urlStr); + const internalOrigin = `http://localhost:${port}`; + // 如果目标 origin 是已知的外部自身 origin,重写为内部地址 + if (parsed.origin !== internalOrigin && selfOrigins.has(parsed.origin)) { + const newUrl = `${internalOrigin}${parsed.pathname}${parsed.search}`; + if (input instanceof Request) { + return originalFetch(new Request(newUrl, input), init); + } + return originalFetch(newUrl, init); + } + } + } catch (e) { + // URL 解析失败等异常,回退到原始 fetch + if (!(e instanceof TypeError)) { + console.error('Fetch interceptor error:', e.message); + } + } + return originalFetch(input, init); +}; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = resolve(__dirname, '..'); +const FUNCTIONS_DIR = resolve(ROOT_DIR, 'functions'); +const DATA_DIR = resolve(ROOT_DIR, 'data'); +const port = parseInt(process.env.PORT || '8080', 10); + +// 确保数据目录存在 +mkdirSync(DATA_DIR, { recursive: true }); + +// ==================== 初始化数据库 ==================== + +const sqliteD1 = new SqliteD1(join(DATA_DIR, 'database.sqlite')); + +// 执行初始化 SQL +const initSqlPath = join(ROOT_DIR, 'database', 'init.sql'); +if (existsSync(initSqlPath)) { + const initSql = readFileSync(initSqlPath, 'utf8'); + try { + sqliteD1.exec(initSql); + console.log('Database initialized successfully'); + } catch (e) { + console.log('Database init:', e.message); + } +} + +// 执行数据库迁移 +const migrationsDir = join(ROOT_DIR, 'database', 'migrations'); +if (existsSync(migrationsDir)) { + const migrations = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const migration of migrations) { + try { + const sql = readFileSync(join(migrationsDir, migration), 'utf8'); + sqliteD1.exec(sql); + console.log(`Migration ${migration}: OK`); + } catch (e) { + // 忽略已执行的迁移(如列已存在等) + console.log(`Migration ${migration}: ${e.message}`); + } + } +} + +// ==================== 初始化 R2 存储 ==================== + +const r2Storage = new LocalR2Storage(join(DATA_DIR, 'r2')); + +// ==================== 创建环境对象 ==================== + +function createEnv() { + return { + ...process.env, + img_d1: sqliteD1, + img_r2: r2Storage, + }; +} + +// ==================== Functions 路由解析 ==================== + +/** + * 根据请求路径查找对应的 function 文件 + */ +function findFunctionFile(pathname) { + const parts = pathname.split('/').filter(Boolean); + + // 1. 尝试精确匹配 + if (parts.length > 0) { + const exactFile = join(FUNCTIONS_DIR, ...parts) + '.js'; + if (existsSync(exactFile) && statSync(exactFile).isFile()) { + return { file: exactFile, params: {} }; + } + } + + // 2. 尝试 index.js 匹配 + if (parts.length > 0) { + const indexFile = join(FUNCTIONS_DIR, ...parts, 'index.js'); + if (existsSync(indexFile) && statSync(indexFile).isFile()) { + return { file: indexFile, params: {} }; + } + } + + // 3. 尝试 [[path]].js 通配符匹配(从深到浅) + for (let i = parts.length - 1; i >= 0; i--) { + const dirParts = parts.slice(0, i); + const dirPath = join(FUNCTIONS_DIR, ...dirParts); + if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { + const catchAllFile = join(dirPath, '[[path]].js'); + if (existsSync(catchAllFile) && statSync(catchAllFile).isFile()) { + const pathParam = parts.slice(i); + return { file: catchAllFile, params: { path: pathParam } }; + } + } + } + + return null; +} + +/** + * 查找请求路径对应的中间件链 + */ +const middlewareCache = new Map(); + +async function findMiddlewares(pathname) { + const parts = pathname.split('/').filter(Boolean); + const allMiddlewares = []; + + // 检查根 functions 目录 + const rootMiddleware = join(FUNCTIONS_DIR, '_middleware.js'); + if (existsSync(rootMiddleware)) { + const mod = await importModule(rootMiddleware); + if (mod.onRequest) { + const handlers = Array.isArray(mod.onRequest) ? mod.onRequest : [mod.onRequest]; + allMiddlewares.push(...handlers); + } + } + + // 逐级检查子目录中间件 + for (let i = 1; i <= parts.length; i++) { + const dirParts = parts.slice(0, i); + const middlewareFile = join(FUNCTIONS_DIR, ...dirParts, '_middleware.js'); + if (existsSync(middlewareFile) && statSync(middlewareFile).isFile()) { + const mod = await importModule(middlewareFile); + if (mod.onRequest) { + const handlers = Array.isArray(mod.onRequest) ? mod.onRequest : [mod.onRequest]; + allMiddlewares.push(...handlers); + } + } + } + + return allMiddlewares; +} + +/** + * 模块导入缓存 + */ +const moduleCache = new Map(); + +async function importModule(filePath) { + if (moduleCache.has(filePath)) { + return moduleCache.get(filePath); + } + const mod = await import(filePath); + moduleCache.set(filePath, mod); + return mod; +} + +/** + * 执行中间件链和处理函数 + */ +async function executeChain(middlewares, handler, context) { + const chain = [...middlewares, handler]; + let index = 0; + + context.next = async function () { + if (index < chain.length) { + const fn = chain[index++]; + return await fn(context); + } + // 如果链执行完毕,返回 404 + return new Response('Not Found', { status: 404 }); + }; + + return await context.next(); +} + +/** + * 处理 Functions 请求 + */ +async function handleFunctionRequest(originalRequest, pathname) { + // 查找对应的 function 文件 + const funcInfo = findFunctionFile(pathname); + if (!funcInfo) return null; + + // 记录外部 origin,供 fetch 拦截器使用 + // 不再重写请求 URL,保持 url.origin 为外部 origin + const request = originalRequest; + const requestUrl = new URL(originalRequest.url); + const internalOrigin = `http://localhost:${port}`; + if (requestUrl.origin !== internalOrigin) { + selfOrigins.add(requestUrl.origin); + } + + // 导入模块 + const mod = await importModule(funcInfo.file); + + // 根据请求方法查找处理函数 + const method = request.method.toUpperCase(); + const methodHandlerName = 'onRequest' + method.charAt(0) + method.slice(1).toLowerCase(); + + let handler = null; + if (typeof mod[methodHandlerName] === 'function') { + handler = mod[methodHandlerName]; + } else if (mod.onRequest) { + handler = typeof mod.onRequest === 'function' + ? mod.onRequest + : mod.onRequest[mod.onRequest.length - 1]; + } + + if (!handler) { + return new Response('Method Not Allowed', { status: 405 }); + } + + // 获取中间件 + const middlewares = await findMiddlewares(pathname); + + // 如果 onRequest 是数组,把前面的加入中间件链 + if (Array.isArray(mod.onRequest) && mod.onRequest.length > 1 && handler === mod.onRequest[mod.onRequest.length - 1]) { + middlewares.push(...mod.onRequest.slice(0, -1)); + } + + // 模拟 Cloudflare 的 request.cf 属性(telemetryData 等中间件依赖该属性) + if (!request.cf) { + request.cf = { + country: 'XX', + city: 'Unknown', + continent: 'XX', + latitude: '0', + longitude: '0', + region: '', + regionCode: '', + timezone: '', + postalCode: '', + asn: 0, + asOrganization: '', + colo: 'LOCAL', + httpProtocol: 'HTTP/1.1', + requestPriority: '', + tlsCipher: '', + tlsVersion: '', + }; + } + + // 创建 Cloudflare Pages Functions 风格的 context 对象 + const env = createEnv(); + const context = { + request, + env, + params: funcInfo.params, + waitUntil: (promise) => { + if (promise && typeof promise.catch === 'function') { + promise.catch(err => console.error('waitUntil error:', err)); + } + }, + next: null, // 由 executeChain 设置 + data: {}, + }; + + // 执行中间件链和处理函数 + return await executeChain(middlewares, handler, context); +} + +// ==================== Hono 应用 ==================== + +const app = new Hono(); + +// 判断是否是 function 路径 +const FUNCTION_PREFIXES = ['/api/', '/upload', '/file/', '/dav/', '/random']; + +function isFunctionPath(pathname) { + return FUNCTION_PREFIXES.some(prefix => pathname.startsWith(prefix)); +} + +// Functions 路由处理 - 处理所有 HTTP 方法 +app.all('*', async (c, next) => { + const url = new URL(c.req.url); + const pathname = url.pathname; + + // 检查是否是 function 路径 + if (isFunctionPath(pathname)) { + try { + const response = await handleFunctionRequest(c.req.raw, pathname); + if (response) { + return response; + } + } catch (err) { + console.error('Function error:', err); + return new Response(`Internal Server Error: ${err.message}`, { status: 500 }); + } + } + + // 不是 function 路径,继续到静态文件 + await next(); +}); + +// 静态文件服务 +app.use('/*', serveStatic({ + root: './', + rewriteRequestPath: (path) => path, +})); + +// 默认返回 index.html(SPA 支持) +app.get('*', async (c) => { + const indexPath = join(ROOT_DIR, 'index.html'); + if (existsSync(indexPath)) { + const content = readFileSync(indexPath, 'utf8'); + return c.html(content); + } + return c.text('Not Found', 404); +}); + +// ==================== 启动服务器 ==================== + +serve({ + fetch: app.fetch, + port, +}, (info) => { + console.log(`Server running at http://0.0.0.0:${info.port}`); + console.log(`Data directory: ${DATA_DIR}`); + console.log(`Mode: Docker (Native Node.js)`); +}); diff --git a/server/loader.mjs b/server/loader.mjs new file mode 100644 index 00000000..ce7aa377 --- /dev/null +++ b/server/loader.mjs @@ -0,0 +1,59 @@ +/** + * 自定义 ESM Loader + * 1. 自动为无扩展名的相对导入添加 .js 后缀 + * 2. 将 Cloudflare 特定的包重定向到本地 mock + */ + +import { fileURLToPath, pathToFileURL } from 'url'; +import { existsSync } from 'fs'; +import { dirname, resolve as pathResolve } from 'path'; + +// Mock 文件路径 +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MOCKS = { + '@cloudflare/pages-plugin-sentry': pathResolve(__dirname, 'mocks/sentry-plugin.js'), + '@sentry/tracing': pathResolve(__dirname, 'mocks/sentry-tracing.js'), +}; + +export async function resolve(specifier, context, nextResolve) { + // 拦截 Cloudflare 特定的包 + if (MOCKS[specifier]) { + return { + url: pathToFileURL(MOCKS[specifier]).href, + shortCircuit: true, + }; + } + + // 处理无扩展名的相对导入 + if (specifier.startsWith('.') && !hasExtension(specifier)) { + const parentDir = context.parentURL + ? dirname(fileURLToPath(context.parentURL)) + : process.cwd(); + + // 尝试添加 .js 扩展名 + const withJs = pathResolve(parentDir, specifier + '.js'); + if (existsSync(withJs)) { + return { + url: pathToFileURL(withJs).href, + shortCircuit: true, + }; + } + + // 尝试作为目录的 index.js + const indexJs = pathResolve(parentDir, specifier, 'index.js'); + if (existsSync(indexJs)) { + return { + url: pathToFileURL(indexJs).href, + shortCircuit: true, + }; + } + } + + return nextResolve(specifier, context); +} + +function hasExtension(specifier) { + const lastSlash = specifier.lastIndexOf('/'); + const filename = lastSlash >= 0 ? specifier.slice(lastSlash + 1) : specifier; + return filename.includes('.'); +} diff --git a/server/mocks/sentry-plugin.js b/server/mocks/sentry-plugin.js new file mode 100644 index 00000000..ced6bbcf --- /dev/null +++ b/server/mocks/sentry-plugin.js @@ -0,0 +1,19 @@ +/** + * Mock for @cloudflare/pages-plugin-sentry + * Docker 模式下不使用 Sentry,提供空实现 + */ +export default function sentryPlugin(config) { + return function(context) { + // 提供一个空的 sentry 对象以兼容 telemetryData 中的调用 + if (!context.data) context.data = {}; + context.data.sentry = { + setTag: () => {}, + setContext: () => {}, + startTransaction: () => ({ + startChild: () => ({ finish: () => {} }), + finish: () => {} + }) + }; + return context.next(); + }; +} diff --git a/server/mocks/sentry-tracing.js b/server/mocks/sentry-tracing.js new file mode 100644 index 00000000..b62cd021 --- /dev/null +++ b/server/mocks/sentry-tracing.js @@ -0,0 +1,5 @@ +/** + * Mock for @sentry/tracing + * Docker 模式下不使用 Sentry Tracing + */ +export default {}; diff --git a/server/r2Storage.js b/server/r2Storage.js new file mode 100644 index 00000000..848cdf33 --- /dev/null +++ b/server/r2Storage.js @@ -0,0 +1,194 @@ +/** + * 本地 R2 存储适配器 + * 使用本地文件系统模拟 Cloudflare R2 的 API 接口 + */ + +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, statSync, rmSync } from 'fs'; +import { dirname, join } from 'path'; +import { createReadStream } from 'fs'; +import { Readable } from 'stream'; +import { randomUUID } from 'crypto'; + +export class LocalR2Storage { + constructor(basePath) { + this.basePath = basePath; + mkdirSync(basePath, { recursive: true }); + } + + _filePath(key) { + return join(this.basePath, key); + } + + /** + * 获取文件(模拟 R2 的 get 方法) + * 支持 Range 请求 + */ + async get(key, options) { + const filePath = this._filePath(key); + if (!existsSync(filePath)) return null; + + const stats = statSync(filePath); + const size = stats.size; + let body; + let range; + + if (options && options.range) { + const offset = options.range.offset || 0; + const length = options.range.length || (size - offset); + const end = offset + length - 1; + const nodeStream = createReadStream(filePath, { start: offset, end: end }); + body = Readable.toWeb(nodeStream); + range = { offset, length }; + } else { + const nodeStream = createReadStream(filePath); + body = Readable.toWeb(nodeStream); + } + + return { + body, + size, + range, + httpMetadata: {}, + writeHttpMetadata(headers) { + // 本地存储不设置额外的 HTTP 元数据 + } + }; + } + + /** + * 存储文件(模拟 R2 的 put 方法) + * 支持多种输入类型 + */ + async put(key, value) { + const filePath = this._filePath(key); + mkdirSync(dirname(filePath), { recursive: true }); + + let buffer; + if (value instanceof Blob || (typeof File !== 'undefined' && value instanceof File)) { + buffer = Buffer.from(await value.arrayBuffer()); + } else if (value instanceof ArrayBuffer) { + buffer = Buffer.from(value); + } else if (value instanceof Uint8Array || Buffer.isBuffer(value)) { + buffer = Buffer.from(value); + } else if (value instanceof ReadableStream) { + const reader = value.getReader(); + const chunks = []; + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + chunks.push(chunk); + } + buffer = Buffer.concat(chunks); + } else if (typeof value === 'string') { + buffer = Buffer.from(value, 'utf-8'); + } else { + buffer = Buffer.from(String(value), 'utf-8'); + } + + writeFileSync(filePath, buffer); + } + + /** + * 删除文件(模拟 R2 的 delete 方法) + */ + async delete(key) { + const filePath = this._filePath(key); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } + + /** + * 创建分块上传(模拟 R2 的 createMultipartUpload 方法) + */ + async createMultipartUpload(key) { + const uploadId = randomUUID(); + const uploadDir = join(this.basePath, '_multipart', uploadId); + mkdirSync(uploadDir, { recursive: true }); + + // 保存 key 映射 + writeFileSync(join(uploadDir, '_key'), key); + + return { + uploadId, + key, + ...this._createMultipartMethods(key, uploadId) + }; + } + + /** + * 恢复分块上传(模拟 R2 的 resumeMultipartUpload 方法) + */ + resumeMultipartUpload(key, uploadId) { + return { + uploadId, + key, + ...this._createMultipartMethods(key, uploadId) + }; + } + + _createMultipartMethods(key, uploadId) { + const self = this; + const uploadDir = join(this.basePath, '_multipart', uploadId); + + return { + async uploadPart(partNumber, data) { + mkdirSync(uploadDir, { recursive: true }); + const partPath = join(uploadDir, `part_${String(partNumber).padStart(6, '0')}`); + let buffer; + if (data instanceof ArrayBuffer) { + buffer = Buffer.from(data); + } else if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + buffer = Buffer.from(data); + } else if (data instanceof Blob) { + buffer = Buffer.from(await data.arrayBuffer()); + } else if (data instanceof ReadableStream) { + const reader = data.getReader(); + const chunks = []; + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + chunks.push(chunk); + } + buffer = Buffer.concat(chunks); + } else { + buffer = Buffer.from(data); + } + writeFileSync(partPath, buffer); + return { etag: `etag_${partNumber}`, partNumber }; + }, + + async complete(parts) { + // 按 partNumber 排序合并所有分块 + const sortedParts = parts.sort((a, b) => a.partNumber - b.partNumber); + const chunks = []; + for (const part of sortedParts) { + const partPath = join(uploadDir, `part_${String(part.partNumber).padStart(6, '0')}`); + if (existsSync(partPath)) { + chunks.push(readFileSync(partPath)); + } + } + const merged = Buffer.concat(chunks); + await self.put(key, merged); + + // 清理分块目录 + self._cleanupDir(uploadDir); + }, + + async abort() { + // 清理分块目录 + self._cleanupDir(uploadDir); + } + }; + } + + _cleanupDir(dir) { + if (existsSync(dir)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (e) { + console.error('Cleanup error:', e.message); + } + } + } +} diff --git a/server/register.mjs b/server/register.mjs new file mode 100644 index 00000000..4a92e66c --- /dev/null +++ b/server/register.mjs @@ -0,0 +1,7 @@ +/** + * ESM Loader 注册入口 + * 通过 node --import ./server/register.mjs 使用 + */ +import { register } from 'node:module'; + +register('./loader.mjs', import.meta.url); diff --git a/server/sqliteD1.js b/server/sqliteD1.js new file mode 100644 index 00000000..5ac6256e --- /dev/null +++ b/server/sqliteD1.js @@ -0,0 +1,104 @@ +/** + * SQLite D1 适配器 + * 使用 better-sqlite3 模拟 Cloudflare D1 的 API 接口 + * 使得现有的 d1Database.js 无需修改即可在 Node.js 中运行 + */ + +import Database from 'better-sqlite3'; + +export class SqliteD1 { + constructor(dbPath) { + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + } + + /** + * 初始化数据库(执行 SQL 脚本) + */ + exec(sql) { + this.db.exec(sql); + } + + /** + * 模拟 D1 的 prepare 方法 + * 返回一个具有 bind/first/all/run 方法的对象 + */ + prepare(sql) { + const db = this.db; + return new SqliteD1Statement(db, sql); + } +} + +class SqliteD1Statement { + constructor(db, sql) { + this._db = db; + this._sql = sql; + this._params = []; + } + + /** + * 绑定参数(模拟 D1 的 bind 方法) + */ + bind(...params) { + // SQLite 不支持 undefined 和 boolean,转换以兼容 D1 行为 + this._params = params.map(p => { + if (p === undefined) return null; + if (typeof p === 'boolean') return p ? 1 : 0; + return p; + }); + return this; + } + + /** + * 获取第一行结果(模拟 D1 的 first 方法) + * 返回 Promise 以兼容 D1 的异步接口 + */ + async first(column) { + try { + const stmt = this._db.prepare(this._sql); + const row = stmt.get(...this._params); + if (!row) return null; + if (column) return row[column]; + return row; + } catch (e) { + console.error('SQLite first() error:', e.message, 'SQL:', this._sql); + throw e; + } + } + + /** + * 获取所有结果(模拟 D1 的 all 方法) + * 返回 { results: [...] } 格式以兼容 D1 + */ + async all() { + try { + const stmt = this._db.prepare(this._sql); + const rows = stmt.all(...this._params); + return { results: rows }; + } catch (e) { + console.error('SQLite all() error:', e.message, 'SQL:', this._sql); + throw e; + } + } + + /** + * 执行 SQL(模拟 D1 的 run 方法) + */ + async run() { + try { + const stmt = this._db.prepare(this._sql); + const result = stmt.run(...this._params); + return { + success: true, + meta: { + changes: result.changes, + last_row_id: result.lastInsertRowid + } + }; + } catch (e) { + console.error('SQLite run() error:', e.message, 'SQL:', this._sql); + throw e; + } + } +}