diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..70dd88c --- /dev/null +++ b/server/.env.example @@ -0,0 +1,14 @@ +PORT=3200 +HOST=127.0.0.1 +LOG_LEVEL=info + +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=magnet_app +MYSQL_PASSWORD=change_me +MYSQL_DATABASE=magnet_cloud + +APP_ENCRYPTION_SECRET=change_this_to_a_long_random_secret +SHARED_CACHE_WRITE_TOKEN=change_this_to_a_long_random_write_token +REDIS_URL=redis://127.0.0.1:6379 +REDIS_ENABLED=true diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..62fe968 --- /dev/null +++ b/server/README.md @@ -0,0 +1,27 @@ +# Magnet Cloud Cache Server + +共享缓存池后端最小骨架: + +- Fastify 4 +- MySQL 5.7+/8.0+ +- AES-256-GCM 数据库存储加密 +- 共享线程缓存 / 范围缓存 / 页缓存 +- 为未来账号体系和私有保险箱预留表结构 + +## 快速启动 + +1. 复制 `.env.example` 为 `.env` +2. 填好 MySQL 和加密密钥 +3. 安装依赖:`npm install` +4. 启动:`npm start` + +## 已实现接口 + +- `GET /health` +- `GET /ready` +- `POST /api/shared-cache/threads/lookup` +- `POST /api/shared-cache/threads/upsert` +- `POST /api/shared-cache/coverages/lookup` +- `POST /api/shared-cache/coverages/upsert` +- `POST /api/shared-cache/pages/lookup` +- `POST /api/shared-cache/pages/upsert` diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..128ce4e --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,829 @@ +{ + "name": "magnet-cloud-cache-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "magnet-cloud-cache-server", + "version": "0.1.0", + "dependencies": { + "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^8.1.1", + "dotenv": "^16.4.5", + "fastify": "^4.29.0", + "mysql2": "^3.11.5", + "redis": "^4.7.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", + "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-8.1.1.tgz", + "integrity": "sha512-kTaIBuG7hS26rUPermw1RYsobNHxLcqA9AFUbWR8dEyRR8wknZnpfuD3VaJkrtfxyWLW8xZ5b6/GmQ/gNoEfWA==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^4.0.0", + "ms": "^2.1.3", + "tiny-lru": "^11.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "license": "MIT" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, + "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/mysql2": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz", + "integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "license": "MIT", + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "peer": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..a15879f --- /dev/null +++ b/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "magnet-cloud-cache-server", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node src/index.js", + "check": "node --check src/index.js && node --check src/config.js && node --check src/db.js && node --check src/crypto.js && node --check src/auth.js && node --check src/routes/auth.js && node --check src/routes/vault.js && node --check src/routes/shared-cache.js" + }, + "dependencies": { + "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^8.1.1", + "dotenv": "^16.4.5", + "fastify": "^4.29.0", + "mysql2": "^3.11.5", + "redis": "^4.7.0" + } +} diff --git a/server/sql/001_init.sql b/server/sql/001_init.sql new file mode 100644 index 0000000..2cb4de1 --- /dev/null +++ b/server/sql/001_init.sql @@ -0,0 +1,130 @@ +CREATE DATABASE IF NOT EXISTS magnet_cloud CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE magnet_cloud; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(191) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + status TINYINT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS devices ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_name VARCHAR(191) NOT NULL, + device_fingerprint VARCHAR(191) NOT NULL, + last_seen_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_devices_user_fp (user_id, device_fingerprint), + CONSTRAINT fk_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS auth_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + device_fingerprint VARCHAR(191) DEFAULT NULL, + expires_at TIMESTAMP NOT NULL, + last_seen_at TIMESTAMP NULL DEFAULT NULL, + revoked_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_auth_tokens_hash (token_hash), + KEY idx_auth_tokens_user (user_id), + KEY idx_auth_tokens_exp (expires_at), + CONSTRAINT fk_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_keyrings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + wrapped_dek TEXT NOT NULL, + kdf_salt VARCHAR(255) NOT NULL, + kdf_params JSON NULL, + key_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_keyrings_user (user_id), + CONSTRAINT fk_user_keyrings_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS vault_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + item_type VARCHAR(64) NOT NULL, + item_key VARCHAR(191) NOT NULL, + payload_ciphertext LONGTEXT NOT NULL, + payload_iv VARCHAR(64) NOT NULL, + payload_tag VARCHAR(64) NOT NULL, + payload_hash CHAR(64) NOT NULL, + key_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_vault_user_item (user_id, item_type, item_key), + KEY idx_vault_user_type (user_id, item_type), + CONSTRAINT fk_vault_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS shared_thread_cache ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + forum_key VARCHAR(128) NOT NULL, + thread_key VARCHAR(191) NOT NULL, + url_hash CHAR(64) NOT NULL, + title_hash CHAR(64) NOT NULL, + magnet_count INT NOT NULL DEFAULT 0, + payload_ciphertext LONGTEXT NOT NULL, + payload_iv VARCHAR(64) NOT NULL, + payload_tag VARCHAR(64) NOT NULL, + payload_hash CHAR(64) NOT NULL, + last_seen_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shared_thread (forum_key, thread_key), + KEY idx_shared_thread_seen (forum_key, last_seen_at), + KEY idx_shared_thread_hash (payload_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS shared_coverage_cache ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + forum_key VARCHAR(128) NOT NULL, + start_page INT NOT NULL, + end_page INT NOT NULL, + strategy VARCHAR(64) NOT NULL, + thread_count INT NOT NULL DEFAULT 0, + crawled_at TIMESTAMP NULL DEFAULT NULL, + payload_ciphertext LONGTEXT NOT NULL, + payload_iv VARCHAR(64) NOT NULL, + payload_tag VARCHAR(64) NOT NULL, + payload_hash CHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shared_coverage (forum_key, start_page, end_page, strategy), + KEY idx_shared_coverage_crawled (forum_key, crawled_at), + KEY idx_shared_coverage_hash (payload_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS shared_page_cache ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + forum_key VARCHAR(128) NOT NULL, + page INT NOT NULL, + thread_count INT NOT NULL DEFAULT 0, + crawled_at TIMESTAMP NULL DEFAULT NULL, + payload_ciphertext LONGTEXT NOT NULL, + payload_iv VARCHAR(64) NOT NULL, + payload_tag VARCHAR(64) NOT NULL, + payload_hash CHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shared_page (forum_key, page), + KEY idx_shared_page_crawled (forum_key, crawled_at), + KEY idx_shared_page_hash (payload_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/src/auth.js b/server/src/auth.js new file mode 100644 index 0000000..cef29fe --- /dev/null +++ b/server/src/auth.js @@ -0,0 +1,146 @@ +const crypto = require('crypto'); +const db = require('./db'); + +const TOKEN_TTL_DAYS = 30; + +function parseJsonMaybe(value) { + if (!value) { + return null; + } + if (typeof value === 'object') { + return value; + } + try { + return JSON.parse(value); + } catch (error) { + return null; + } +} + +function sha256(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex'); +} + +function randomToken(size) { + return crypto.randomBytes(size).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function normalizeEmail(email) { + return String(email || '').trim().toLowerCase(); +} + +function hashPassword(password) { + return new Promise(function(resolve, reject) { + var salt = crypto.randomBytes(16).toString('base64'); + crypto.scrypt(String(password), salt, 64, function(error, derivedKey) { + if (error) { + reject(error); + return; + } + resolve('scrypt$' + salt + '$' + derivedKey.toString('base64')); + }); + }); +} + +function verifyPassword(password, storedHash) { + return new Promise(function(resolve, reject) { + var parts = String(storedHash || '').split('$'); + if (parts.length !== 3 || parts[0] !== 'scrypt') { + resolve(false); + return; + } + var salt = parts[1]; + var expected = Buffer.from(parts[2], 'base64'); + crypto.scrypt(String(password), salt, expected.length, function(error, derivedKey) { + if (error) { + reject(error); + return; + } + resolve(crypto.timingSafeEqual(expected, derivedKey)); + }); + }); +} + +async function createAuthToken(userId, deviceFingerprint) { + var rawToken = randomToken(32); + var expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000); + await db.execute( + 'INSERT INTO auth_tokens (user_id, token_hash, device_fingerprint, expires_at, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', + [userId, sha256(rawToken), deviceFingerprint || null, expiresAt] + ); + return rawToken; +} + +async function touchAuthToken(tokenHash) { + await db.execute('UPDATE auth_tokens SET last_seen_at = CURRENT_TIMESTAMP WHERE token_hash = ?', [tokenHash]); +} + +async function revokeAuthToken(tokenHash) { + await db.execute('UPDATE auth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ? AND revoked_at IS NULL', [tokenHash]); +} + +async function getAuthContextFromToken(rawToken) { + if (!rawToken) { + return null; + } + var tokenHash = sha256(rawToken); + var rows = await db.query( + 'SELECT t.id AS token_id, t.user_id, t.token_hash, t.device_fingerprint, t.expires_at, t.revoked_at, u.email, u.status, k.wrapped_dek, k.kdf_salt, k.kdf_params, k.key_version FROM auth_tokens t INNER JOIN users u ON u.id = t.user_id LEFT JOIN user_keyrings k ON k.user_id = u.id WHERE t.token_hash = ? LIMIT 1', + [tokenHash] + ); + if (!rows.length) { + return null; + } + var row = rows[0]; + if (row.revoked_at) { + return null; + } + if (new Date(row.expires_at).getTime() <= Date.now()) { + return null; + } + if (Number(row.status || 0) !== 1) { + return null; + } + await touchAuthToken(tokenHash); + return { + tokenHash: tokenHash, + tokenId: row.token_id, + user: { + id: row.user_id, + email: row.email + }, + keyring: row.wrapped_dek ? { + wrappedDek: row.wrapped_dek, + kdfSalt: row.kdf_salt, + kdfParams: parseJsonMaybe(row.kdf_params), + keyVersion: Number(row.key_version || 1) + } : null + }; +} + +async function requireAuth(request, reply) { + var header = request.headers.authorization || ''; + var match = String(header).match(/^Bearer\s+(.+)$/i); + if (!match) { + reply.code(401); + throw new Error('未登录'); + } + var authContext = await getAuthContextFromToken(match[1]); + if (!authContext) { + reply.code(401); + throw new Error('登录已失效'); + } + request.authContext = authContext; +} + +module.exports = { + createAuthToken, + getAuthContextFromToken, + hashPassword, + normalizeEmail, + parseJsonMaybe, + requireAuth, + revokeAuthToken, + sha256, + verifyPassword +}; diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..286f76e --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); + +const envPath = path.join(__dirname, '..', '.env'); +if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); +} + +function numberFromEnv(name, fallback) { + const value = Number(process.env[name]); + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +module.exports = { + app: { + host: process.env.HOST || '127.0.0.1', + port: numberFromEnv('PORT', 3200), + logLevel: process.env.LOG_LEVEL || 'info' + }, + db: { + host: process.env.MYSQL_HOST || '127.0.0.1', + port: numberFromEnv('MYSQL_PORT', 3306), + user: process.env.MYSQL_USER || '', + password: process.env.MYSQL_PASSWORD || '', + database: process.env.MYSQL_DATABASE || '' + }, + crypto: { + secret: process.env.APP_ENCRYPTION_SECRET || '' + }, + sharedCache: { + writeToken: process.env.SHARED_CACHE_WRITE_TOKEN || '' + }, + redis: { + enabled: String(process.env.REDIS_ENABLED || 'true').toLowerCase() !== 'false', + url: process.env.REDIS_URL || 'redis://127.0.0.1:6379' + } +}; diff --git a/server/src/crypto.js b/server/src/crypto.js new file mode 100644 index 0000000..83ef0f3 --- /dev/null +++ b/server/src/crypto.js @@ -0,0 +1,49 @@ +const crypto = require('crypto'); +const config = require('./config'); + +function getKey() { + if (!config.crypto.secret) { + throw new Error('APP_ENCRYPTION_SECRET 未配置'); + } + return crypto.createHash('sha256').update(String(config.crypto.secret)).digest(); +} + +function encryptJson(value) { + const plainText = JSON.stringify(value); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv); + const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + const payloadHash = crypto.createHash('sha256').update(plainText).digest('hex'); + + return { + ciphertext: encrypted.toString('base64'), + iv: iv.toString('base64'), + tag: tag.toString('base64'), + payloadHash + }; +} + +function decryptJson(record) { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + getKey(), + Buffer.from(record.iv, 'base64') + ); + decipher.setAuthTag(Buffer.from(record.tag, 'base64')); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(record.ciphertext, 'base64')), + decipher.final() + ]); + return JSON.parse(decrypted.toString('utf8')); +} + +function sha256(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex'); +} + +module.exports = { + encryptJson, + decryptJson, + sha256 +}; diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..7799d09 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,49 @@ +const mysql = require('mysql2/promise'); +const config = require('./config'); + +let pool = null; + +function getPool() { + if (pool) { + return pool; + } + + if (!config.db.host || !config.db.user || !config.db.database) { + throw new Error('MySQL 配置不完整'); + } + + pool = mysql.createPool({ + host: config.db.host, + port: config.db.port, + user: config.db.user, + password: config.db.password, + database: config.db.database, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + charset: 'utf8mb4' + }); + + return pool; +} + +async function query(sql, params) { + const [rows] = await getPool().query(sql, params || []); + return rows; +} + +async function execute(sql, params) { + const [result] = await getPool().execute(sql, params || []); + return result; +} + +async function ping() { + await query('SELECT 1 AS ok'); +} + +module.exports = { + getPool, + query, + execute, + ping +}; diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..17fae7e --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,61 @@ +const Fastify = require('fastify'); +const cors = require('@fastify/cors'); +const rateLimit = require('@fastify/rate-limit'); +const config = require('./config'); +const authRoutes = require('./routes/auth'); +const sharedCacheRoutes = require('./routes/shared-cache'); +const vaultRoutes = require('./routes/vault'); + +async function buildApp() { + const app = Fastify({ + logger: { + level: config.app.logLevel + }, + bodyLimit: 1024 * 1024 + }); + + await app.register(cors, { + origin: true, + credentials: false + }); + + await app.register(rateLimit, { + max: 120, + timeWindow: '1 minute' + }); + + app.get('/', async function () { + return { + ok: true, + service: 'magnet-cloud-cache-server', + version: '0.1.0' + }; + }); + + await app.register(authRoutes); + await app.register(sharedCacheRoutes); + await app.register(vaultRoutes); + return app; +} + +async function start() { + const app = await buildApp(); + try { + await app.listen({ + host: config.app.host, + port: config.app.port + }); + app.log.info('server started'); + } catch (error) { + app.log.error(error); + process.exit(1); + } +} + +if (require.main === module) { + start(); +} + +module.exports = { + buildApp +}; diff --git a/server/src/redis.js b/server/src/redis.js new file mode 100644 index 0000000..fb8776a --- /dev/null +++ b/server/src/redis.js @@ -0,0 +1,78 @@ +const { createClient } = require('redis'); + +let clientPromise = null; + +async function getRedisClient(config) { + if (!config || !config.redis || !config.redis.enabled || !config.redis.url) { + return null; + } + if (clientPromise) { + return clientPromise; + } + clientPromise = (async function() { + const client = createClient({ url: config.redis.url }); + client.on('error', function () { + return null; + }); + await client.connect(); + return client; + })().catch(function() { + clientPromise = null; + return null; + }); + return clientPromise; +} + +async function getJson(config, key) { + const client = await getRedisClient(config); + if (!client) { + return null; + } + try { + const value = await client.get(key); + return value ? JSON.parse(value) : null; + } catch (error) { + return null; + } +} + +async function setJson(config, key, value, ttlSeconds) { + const client = await getRedisClient(config); + if (!client) { + return false; + } + try { + await client.set(key, JSON.stringify(value), { + EX: Math.max(1, Number(ttlSeconds) || 60) + }); + return true; + } catch (error) { + return false; + } +} + +async function delByPattern(config, pattern) { + const client = await getRedisClient(config); + if (!client) { + return 0; + } + try { + const keys = []; + for await (const key of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { + keys.push(key); + } + if (keys.length > 0) { + await client.del(keys); + } + return keys.length; + } catch (error) { + return 0; + } +} + +module.exports = { + getRedisClient, + getJson, + setJson, + delByPattern +}; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..a16ed07 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,169 @@ +const db = require('../db'); +const { + createAuthToken, + getAuthContextFromToken, + hashPassword, + normalizeEmail, + parseJsonMaybe, + revokeAuthToken, + verifyPassword +} = require('../auth'); + +function normalizeString(value, limit) { + return String(value || '').trim().slice(0, limit); +} + +function normalizeDeviceFingerprint(value) { + return normalizeString(value, 191); +} + +function normalizeDeviceName(value) { + return normalizeString(value, 191) || 'Chrome Extension'; +} + +async function upsertDevice(userId, deviceName, deviceFingerprint) { + if (!deviceFingerprint) { + return; + } + await db.execute( + 'INSERT INTO devices (user_id, device_name, device_fingerprint, last_seen_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE device_name = VALUES(device_name), last_seen_at = CURRENT_TIMESTAMP', + [userId, deviceName, deviceFingerprint] + ); +} + +async function routes(fastify) { + fastify.post('/api/auth/register', async function (request, reply) { + var body = request.body || {}; + var email = normalizeEmail(body.email); + var password = String(body.password || ''); + var wrappedDek = normalizeString(body.wrappedDek, 5000); + var kdfSalt = normalizeString(body.kdfSalt, 255); + var keyVersion = Math.max(1, Number(body.keyVersion) || 1); + var deviceName = normalizeDeviceName(body.deviceName); + var deviceFingerprint = normalizeDeviceFingerprint(body.deviceFingerprint); + var kdfParams = body.kdfParams && typeof body.kdfParams === 'object' ? body.kdfParams : {}; + + if (!email || email.indexOf('@') === -1) { + reply.code(400); + return { ok: false, error: '请检查邮箱格式后再试' }; + } + if (password.length < 6) { + reply.code(400); + return { ok: false, error: '密码长度还不够,请再确认一下' }; + } + if (!wrappedDek || !kdfSalt) { + reply.code(400); + return { ok: false, error: '当前初始化信息不完整,请稍后再试' }; + } + + var existingRows = await db.query('SELECT id FROM users WHERE email = ? LIMIT 1', [email]); + if (existingRows.length > 0) { + reply.code(409); + return { ok: false, error: '这个邮箱已经可以直接登录了' }; + } + + var passwordHash = await hashPassword(password); + var userResult = await db.execute( + 'INSERT INTO users (email, password_hash, status) VALUES (?, ?, 1)', + [email, passwordHash] + ); + var userId = Number(userResult.insertId); + await db.execute( + 'INSERT INTO user_keyrings (user_id, wrapped_dek, kdf_salt, kdf_params, key_version) VALUES (?, ?, ?, ?, ?)', + [userId, wrappedDek, kdfSalt, JSON.stringify(kdfParams), keyVersion] + ); + await upsertDevice(userId, deviceName, deviceFingerprint); + + var token = await createAuthToken(userId, deviceFingerprint); + return { + ok: true, + token: token, + user: { id: userId, email: email }, + keyring: { + wrappedDek: wrappedDek, + kdfSalt: kdfSalt, + kdfParams: kdfParams, + keyVersion: keyVersion + } + }; + }); + + fastify.post('/api/auth/login', async function (request, reply) { + var body = request.body || {}; + var email = normalizeEmail(body.email); + var password = String(body.password || ''); + var deviceName = normalizeDeviceName(body.deviceName); + var deviceFingerprint = normalizeDeviceFingerprint(body.deviceFingerprint); + + var rows = await db.query( + 'SELECT u.id, u.email, u.password_hash, u.status, k.wrapped_dek, k.kdf_salt, k.kdf_params, k.key_version FROM users u LEFT JOIN user_keyrings k ON k.user_id = u.id WHERE u.email = ? LIMIT 1', + [email] + ); + if (!rows.length) { + reply.code(401); + return { ok: false, error: '账号信息没有对上,请再确认一下' }; + } + + var row = rows[0]; + if (Number(row.status || 0) !== 1) { + reply.code(403); + return { ok: false, error: '当前账号暂时无法使用' }; + } + + var valid = await verifyPassword(password, row.password_hash); + if (!valid) { + reply.code(401); + return { ok: false, error: '账号信息没有对上,请再确认一下' }; + } + + await upsertDevice(row.id, deviceName, deviceFingerprint); + var token = await createAuthToken(row.id, deviceFingerprint); + return { + ok: true, + token: token, + user: { id: row.id, email: row.email }, + keyring: { + wrappedDek: row.wrapped_dek, + kdfSalt: row.kdf_salt, + kdfParams: parseJsonMaybe(row.kdf_params) || {}, + keyVersion: Number(row.key_version || 1) + } + }; + }); + + fastify.get('/api/auth/me', async function (request, reply) { + var header = request.headers.authorization || ''; + var match = String(header).match(/^Bearer\s+(.+)$/i); + if (!match) { + reply.code(401); + return { ok: false, error: '先登录后就可以继续了' }; + } + var authContext = await getAuthContextFromToken(match[1]); + if (!authContext) { + reply.code(401); + return { ok: false, error: '登录状态需要重新确认一下' }; + } + return { + ok: true, + user: authContext.user, + keyring: authContext.keyring + }; + }); + + fastify.post('/api/auth/logout', async function (request, reply) { + var header = request.headers.authorization || ''; + var match = String(header).match(/^Bearer\s+(.+)$/i); + if (!match) { + reply.code(400); + return { ok: false, error: '当前登录信息还没带上' }; + } + var authContext = await getAuthContextFromToken(match[1]); + if (!authContext) { + return { ok: true }; + } + await revokeAuthToken(authContext.tokenHash); + return { ok: true }; + }); +} + +module.exports = routes; diff --git a/server/src/routes/shared-cache.js b/server/src/routes/shared-cache.js new file mode 100644 index 0000000..09b49fa --- /dev/null +++ b/server/src/routes/shared-cache.js @@ -0,0 +1,477 @@ +const db = require('../db'); +const config = require('../config'); +const { encryptJson, decryptJson, sha256 } = require('../crypto'); +const redisCache = require('../redis'); + +function requireSharedCacheWrite(request, reply) { + const token = String(request.headers['x-shared-cache-write-token'] || ''); + if (!config.sharedCache.writeToken || token !== config.sharedCache.writeToken) { + reply.code(401); + return { ok: false, error: 'shared cache write unauthorized' }; + } + return null; +} + +function normalizeThread(thread) { + if (!thread || typeof thread !== 'object') return null; + const forumKey = typeof thread.forumKey === 'string' ? thread.forumKey.trim() : ''; + const threadKey = typeof thread.threadKey === 'string' ? thread.threadKey.trim() : ''; + const url = typeof thread.url === 'string' ? thread.url.trim() : ''; + if (!forumKey || !threadKey) return null; + return { + forumKey, + threadKey, + url, + title: typeof thread.title === 'string' ? thread.title : '', + magnets: Array.isArray(thread.magnets) ? thread.magnets.filter(Boolean) : [], + lastSeenAt: Number(thread.lastSeenAt) || Date.now() + }; +} + +function normalizeCoverage(payload) { + const forumKey = typeof payload.forumKey === 'string' ? payload.forumKey.trim() : ''; + const startPage = Math.max(1, Number(payload.startPage) || 1); + const endPage = Math.max(startPage, Number(payload.endPage) || startPage); + if (!forumKey) return null; + return { + forumKey, + startPage, + endPage, + strategy: typeof payload.strategy === 'string' && payload.strategy ? payload.strategy : 'full_live', + crawledAt: Number(payload.crawledAt) || Date.now(), + threads: Array.isArray(payload.threads) ? payload.threads : [] + }; +} + +function normalizePageCoverage(payload) { + const forumKey = typeof payload.forumKey === 'string' ? payload.forumKey.trim() : ''; + const page = Math.max(1, Number(payload.page) || 1); + if (!forumKey) return null; + return { + forumKey, + page, + crawledAt: Number(payload.crawledAt) || Date.now(), + threads: Array.isArray(payload.threads) ? payload.threads : [] + }; +} + +function normalizePlanPayload(payload) { + const forumKey = typeof payload.forumKey === 'string' ? payload.forumKey.trim() : ''; + const startPage = Math.max(1, Number(payload.startPage) || 1); + const endPage = Math.max(startPage, Number(payload.endPage) || startPage); + const frontRefreshPages = Math.max(0, Number(payload.frontRefreshPages) || 0); + if (!forumKey) return null; + return { forumKey, startPage, endPage, frontRefreshPages }; +} + +function buildPlanCacheKey(payload) { + return [ + 'coverage-plan', + payload.forumKey, + payload.startPage, + payload.endPage, + payload.frontRefreshPages + ].join(':'); +} + +async function getThreadMapForKeys(threadKeys) { + if (!Array.isArray(threadKeys) || threadKeys.length === 0) { + return []; + } + const placeholders = threadKeys.map(function () { return '?'; }).join(','); + const rows = await db.query( + 'SELECT thread_key, payload_ciphertext, payload_iv, payload_tag FROM shared_thread_cache WHERE thread_key IN (' + placeholders + ')', + threadKeys + ); + return rows.map(function (row) { + return decryptJson({ + ciphertext: row.payload_ciphertext, + iv: row.payload_iv, + tag: row.payload_tag + }); + }).filter(Boolean); +} + +async function buildHydratedCoverageBlock(record, clippedStart, clippedEnd) { + const payload = decryptJson({ + ciphertext: record.payload_ciphertext, + iv: record.payload_iv, + tag: record.payload_tag + }); + const threads = Array.isArray(payload.threads) ? payload.threads : []; + if (!threads.length) { + return null; + } + return { + forumKey: record.forum_key, + startPage: clippedStart, + endPage: clippedEnd, + crawledAt: new Date(record.crawled_at).getTime(), + strategy: record.strategy || 'full_live', + frontRefreshPages: 0, + threads: threads + }; +} + +async function buildCoveragePlan(payload) { + const exactRows = await db.query( + 'SELECT forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page = ? AND end_page = ? ORDER BY crawled_at DESC LIMIT 1', + [payload.forumKey, payload.startPage, payload.endPage] + ); + if (exactRows.length) { + const exactCoverage = await buildHydratedCoverageBlock(exactRows[0], payload.startPage, payload.endPage); + if (exactCoverage) { + return { ok: true, exactCoverage, cachedBlocks: [], shiftedCoverage: null, source: 'server_exact' }; + } + } + + const pageRows = await db.query( + 'SELECT forum_key, page, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_page_cache WHERE forum_key = ? AND page BETWEEN ? AND ? ORDER BY page ASC', + [payload.forumKey, payload.startPage, payload.endPage] + ); + const pageBlocks = []; + let currentBlock = null; + pageRows.forEach(function (row) { + const page = Number(row.page || 0); + if (!currentBlock || page !== currentBlock.endPage + 1) { + if (currentBlock) { + pageBlocks.push(currentBlock); + } + currentBlock = { startPage: page, endPage: page, rows: [row] }; + return; + } + currentBlock.endPage = page; + currentBlock.rows.push(row); + }); + if (currentBlock) { + pageBlocks.push(currentBlock); + } + + const hydratedPageBlocks = pageBlocks.map(function (block) { + const merged = Object.create(null); + const threads = []; + let latestCrawledAt = 0; + block.rows.forEach(function (row) { + latestCrawledAt = Math.max(latestCrawledAt, new Date(row.crawled_at).getTime()); + const payloadData = decryptJson({ + ciphertext: row.payload_ciphertext, + iv: row.payload_iv, + tag: row.payload_tag + }); + (Array.isArray(payloadData.threads) ? payloadData.threads : []).forEach(function (thread) { + const key = String(thread.threadKey || thread.url || ''); + if (!key || merged[key]) { + return; + } + merged[key] = true; + threads.push(thread); + }); + }); + return { + forumKey: payload.forumKey, + startPage: block.startPage, + endPage: block.endPage, + crawledAt: latestCrawledAt, + strategy: 'assembled_pages', + frontRefreshPages: 0, + threads: threads + }; + }).filter(function (block) { + return Array.isArray(block.threads) && block.threads.length > 0; + }); + + const coverageRows = await db.query( + 'SELECT forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page <= ? AND end_page >= ? ORDER BY start_page ASC, end_page DESC', + [payload.forumKey, payload.endPage, payload.startPage] + ); + const hydratedCoverageBlocks = []; + for (const row of coverageRows) { + const clippedStart = Math.max(payload.startPage, Number(row.start_page || payload.startPage)); + const clippedEnd = Math.min(payload.endPage, Number(row.end_page || payload.endPage)); + if (clippedStart > clippedEnd) { + continue; + } + const block = await buildHydratedCoverageBlock(row, clippedStart, clippedEnd); + if (block) { + hydratedCoverageBlocks.push(block); + } + } + + const allBlocks = hydratedPageBlocks.concat(hydratedCoverageBlocks).sort(function (a, b) { + const startDiff = Number(a.startPage || 0) - Number(b.startPage || 0); + if (startDiff !== 0) return startDiff; + return Number(b.endPage || 0) - Number(a.endPage || 0); + }); + + let shiftedCoverage = null; + if (payload.startPage === 1 && payload.frontRefreshPages > 0) { + const anchorRows = await db.query( + 'SELECT forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page = 1 ORDER BY crawled_at DESC, end_page DESC LIMIT 1', + [payload.forumKey] + ); + if (anchorRows.length) { + const anchor = await buildHydratedCoverageBlock(anchorRows[0], 1, Math.min(payload.endPage, Number(anchorRows[0].end_page || payload.endPage))); + if (anchor) { + shiftedCoverage = { + forumKey: anchor.forumKey, + sourceStartPage: anchor.startPage, + sourceEndPage: Number(anchorRows[0].end_page || anchor.endPage), + crawledAt: anchor.crawledAt, + strategy: anchor.strategy, + threads: anchor.threads, + reusedStartPage: Math.min(payload.endPage, payload.startPage + payload.frontRefreshPages), + reusedEndPage: Math.min(payload.endPage, Number(anchorRows[0].end_page || payload.endPage) + payload.frontRefreshPages) + }; + if (shiftedCoverage.reusedStartPage > shiftedCoverage.reusedEndPage) { + shiftedCoverage = null; + } + } + } + } + + return { + ok: true, + exactCoverage: null, + cachedBlocks: allBlocks, + shiftedCoverage: shiftedCoverage, + source: 'server_plan' + }; +} + +async function getCloudStats() { + const countsRows = await db.query( + "SELECT (SELECT COUNT(*) FROM shared_thread_cache) AS thread_count, (SELECT COUNT(*) FROM shared_thread_cache WHERE magnet_count > 0) AS magnet_thread_count, (SELECT COUNT(*) FROM shared_coverage_cache) AS coverage_count, (SELECT COUNT(*) FROM shared_page_cache) AS page_count, (SELECT COUNT(*) FROM vault_items) AS vault_count, (SELECT COUNT(*) FROM users) AS user_count" + ); + const latestRows = await db.query( + "SELECT (SELECT MAX(updated_at) FROM shared_thread_cache) AS latest_thread_time, (SELECT MAX(updated_at) FROM shared_coverage_cache) AS latest_coverage_time, (SELECT MAX(updated_at) FROM shared_page_cache) AS latest_page_time" + ); + const sizeRows = await db.query( + "SELECT table_name, table_rows, ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY (data_length + index_length) DESC" + ); + + return { + ok: true, + counts: { + threads: Number(countsRows[0].thread_count || 0), + magnetThreads: Number(countsRows[0].magnet_thread_count || 0), + coverages: Number(countsRows[0].coverage_count || 0), + pages: Number(countsRows[0].page_count || 0), + vaultItems: Number(countsRows[0].vault_count || 0), + users: Number(countsRows[0].user_count || 0) + }, + latest: { + threads: latestRows[0].latest_thread_time, + coverages: latestRows[0].latest_coverage_time, + pages: latestRows[0].latest_page_time + }, + tables: sizeRows.map(function(row) { + return { + tableName: row.table_name, + rowCount: Number(row.table_rows || 0), + sizeMb: Number(row.size_mb || 0) + }; + }) + }; +} + +async function routes(fastify) { + fastify.get('/health', async function () { + return { ok: true, service: 'magnet-cloud-cache-server' }; + }); + + fastify.get('/ready', async function (_, reply) { + try { + await db.ping(); + return { ok: true }; + } catch (error) { + reply.code(500); + return { ok: false, error: error.message }; + } + }); + + fastify.get('/api/shared-cache/stats', async function () { + return getCloudStats(); + }); + + fastify.post('/api/shared-cache/threads/lookup', async function (request) { + const items = Array.isArray(request.body && request.body.threads) ? request.body.threads : []; + const normalized = items.map(normalizeThread).filter(Boolean); + if (normalized.length === 0) { + return { ok: true, threads: [] }; + } + + const forumKey = normalized[0].forumKey; + const threadKeys = normalized.map(function (item) { return item.threadKey; }); + const placeholders = threadKeys.map(function () { return '?'; }).join(','); + const rows = await db.query( + 'SELECT forum_key, thread_key, payload_ciphertext, payload_iv, payload_tag, updated_at FROM shared_thread_cache WHERE forum_key = ? AND thread_key IN (' + placeholders + ')', + [forumKey].concat(threadKeys) + ); + + const result = rows.map(function (row) { + return decryptJson({ + ciphertext: row.payload_ciphertext, + iv: row.payload_iv, + tag: row.payload_tag + }); + }); + + return { ok: true, threads: result }; + }); + + fastify.post('/api/shared-cache/threads/upsert', async function (request, reply) { + const denied = requireSharedCacheWrite(request, reply); + if (denied) return denied; + const items = Array.isArray(request.body && request.body.threads) ? request.body.threads : []; + const normalized = items.map(normalizeThread).filter(Boolean); + let saved = 0; + + for (const item of normalized) { + const encrypted = encryptJson(item); + await db.execute( + 'INSERT INTO shared_thread_cache (forum_key, thread_key, url_hash, title_hash, magnet_count, payload_ciphertext, payload_iv, payload_tag, payload_hash, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(? / 1000)) ON DUPLICATE KEY UPDATE magnet_count = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(magnet_count), magnet_count), payload_ciphertext = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_ciphertext), payload_ciphertext), payload_iv = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_iv), payload_iv), payload_tag = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_tag), payload_tag), payload_hash = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_hash), payload_hash), last_seen_at = GREATEST(last_seen_at, VALUES(last_seen_at)), updated_at = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, CURRENT_TIMESTAMP, updated_at)', + [ + item.forumKey, + item.threadKey, + sha256(item.url), + sha256(item.title), + item.magnets.length, + encrypted.ciphertext, + encrypted.iv, + encrypted.tag, + encrypted.payloadHash, + item.lastSeenAt + ] + ); + saved += 1; + } + + return { ok: true, savedCount: saved }; + }); + + fastify.post('/api/shared-cache/coverages/lookup', async function (request) { + const payload = normalizeCoverage(request.body || {}); + if (!payload) { + return { ok: true, coverage: null }; + } + + const rows = await db.query( + 'SELECT payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page = ? AND end_page = ? AND strategy = ? LIMIT 1', + [payload.forumKey, payload.startPage, payload.endPage, payload.strategy] + ); + + if (!rows.length) { + return { ok: true, coverage: null }; + } + + return { + ok: true, + coverage: decryptJson({ + ciphertext: rows[0].payload_ciphertext, + iv: rows[0].payload_iv, + tag: rows[0].payload_tag + }) + }; + }); + + fastify.post('/api/shared-cache/coverages/upsert', async function (request, reply) { + const denied = requireSharedCacheWrite(request, reply); + if (denied) return denied; + const payload = normalizeCoverage(request.body || {}); + if (!payload) { + throw new Error('无效的 coverage 参数'); + } + + const encrypted = encryptJson(payload); + await db.execute( + 'INSERT INTO shared_coverage_cache (forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag, payload_hash) VALUES (?, ?, ?, ?, ?, FROM_UNIXTIME(? / 1000), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE thread_count = IF(VALUES(crawled_at) >= crawled_at, VALUES(thread_count), thread_count), crawled_at = GREATEST(crawled_at, VALUES(crawled_at)), payload_ciphertext = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_ciphertext), payload_ciphertext), payload_iv = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_iv), payload_iv), payload_tag = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_tag), payload_tag), payload_hash = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_hash), payload_hash), updated_at = IF(VALUES(crawled_at) >= crawled_at, CURRENT_TIMESTAMP, updated_at)', + [ + payload.forumKey, + payload.startPage, + payload.endPage, + payload.strategy, + payload.threads.length, + payload.crawledAt, + encrypted.ciphertext, + encrypted.iv, + encrypted.tag, + encrypted.payloadHash + ] + ); + + redisCache.delByPattern(config, 'coverage-plan:' + payload.forumKey + ':*').catch(function () {}); + + return { ok: true }; + }); + + fastify.post('/api/shared-cache/pages/lookup', async function (request) { + const payload = normalizePageCoverage(request.body || {}); + if (!payload) { + return { ok: true, coverage: null }; + } + + const rows = await db.query( + 'SELECT payload_ciphertext, payload_iv, payload_tag FROM shared_page_cache WHERE forum_key = ? AND page = ? LIMIT 1', + [payload.forumKey, payload.page] + ); + + if (!rows.length) { + return { ok: true, coverage: null }; + } + + return { + ok: true, + coverage: decryptJson({ + ciphertext: rows[0].payload_ciphertext, + iv: rows[0].payload_iv, + tag: rows[0].payload_tag + }) + }; + }); + + fastify.post('/api/shared-cache/pages/upsert', async function (request, reply) { + const denied = requireSharedCacheWrite(request, reply); + if (denied) return denied; + const payload = normalizePageCoverage(request.body || {}); + if (!payload) { + throw new Error('无效的 page coverage 参数'); + } + + const encrypted = encryptJson(payload); + await db.execute( + 'INSERT INTO shared_page_cache (forum_key, page, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag, payload_hash) VALUES (?, ?, ?, FROM_UNIXTIME(? / 1000), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE thread_count = IF(VALUES(crawled_at) >= crawled_at, VALUES(thread_count), thread_count), crawled_at = GREATEST(crawled_at, VALUES(crawled_at)), payload_ciphertext = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_ciphertext), payload_ciphertext), payload_iv = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_iv), payload_iv), payload_tag = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_tag), payload_tag), payload_hash = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_hash), payload_hash), updated_at = IF(VALUES(crawled_at) >= crawled_at, CURRENT_TIMESTAMP, updated_at)', + [ + payload.forumKey, + payload.page, + payload.threads.length, + payload.crawledAt, + encrypted.ciphertext, + encrypted.iv, + encrypted.tag, + encrypted.payloadHash + ] + ); + + redisCache.delByPattern(config, 'coverage-plan:' + payload.forumKey + ':*').catch(function () {}); + + return { ok: true }; + }); + + fastify.post('/api/shared-cache/coverages/plan', async function (request) { + const payload = normalizePlanPayload(request.body || {}); + const cacheKey = payload ? buildPlanCacheKey(payload) : ''; + let cachedPlan = null; + if (!payload) { + return { ok: false, error: 'invalid coverage plan payload' }; + } + cachedPlan = await redisCache.getJson(config, cacheKey); + if (cachedPlan) { + return Object.assign({}, cachedPlan, { source: 'redis_plan' }); + } + const plan = await buildCoveragePlan(payload); + await redisCache.setJson(config, cacheKey, plan, 90); + return plan; + }); +} + +module.exports = routes; diff --git a/server/src/routes/vault.js b/server/src/routes/vault.js new file mode 100644 index 0000000..a3dbbb6 --- /dev/null +++ b/server/src/routes/vault.js @@ -0,0 +1,96 @@ +const db = require('../db'); +const { requireAuth } = require('../auth'); + +function normalizeVaultItem(item) { + if (!item || typeof item !== 'object') { + return null; + } + var itemType = String(item.itemType || '').trim().slice(0, 64); + var itemKey = String(item.itemKey || '').trim().slice(0, 191); + var payloadCiphertext = String(item.payloadCiphertext || '').trim(); + var payloadIv = String(item.payloadIv || '').trim().slice(0, 128); + var payloadTag = String(item.payloadTag || '').trim().slice(0, 128); + var payloadHash = String(item.payloadHash || '').trim().slice(0, 64); + var keyVersion = Math.max(1, Number(item.keyVersion) || 1); + + if (!itemType || !itemKey || !payloadCiphertext || !payloadIv || !payloadTag || !payloadHash) { + return null; + } + + return { + itemType: itemType, + itemKey: itemKey, + payloadCiphertext: payloadCiphertext, + payloadIv: payloadIv, + payloadTag: payloadTag, + payloadHash: payloadHash, + keyVersion: keyVersion + }; +} + +async function routes(fastify) { + fastify.addHook('preHandler', requireAuth); + + fastify.post('/api/vault/push', async function (request, reply) { + var items = Array.isArray(request.body && request.body.items) ? request.body.items : []; + var normalized = items.map(normalizeVaultItem).filter(Boolean); + var index = 0; + var item = null; + if (normalized.length === 0) { + reply.code(400); + return { ok: false, error: '没有可保存的保险柜项目' }; + } + + for (index = 0; index < normalized.length; index++) { + item = normalized[index]; + await db.execute( + 'INSERT INTO vault_items (user_id, item_type, item_key, payload_ciphertext, payload_iv, payload_tag, payload_hash, key_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE payload_ciphertext = VALUES(payload_ciphertext), payload_iv = VALUES(payload_iv), payload_tag = VALUES(payload_tag), payload_hash = VALUES(payload_hash), key_version = VALUES(key_version), updated_at = CURRENT_TIMESTAMP', + [ + request.authContext.user.id, + item.itemType, + item.itemKey, + item.payloadCiphertext, + item.payloadIv, + item.payloadTag, + item.payloadHash, + item.keyVersion + ] + ); + } + + return { ok: true, savedCount: normalized.length }; + }); + + fastify.post('/api/vault/pull', async function (request) { + var itemTypes = Array.isArray(request.body && request.body.itemTypes) ? request.body.itemTypes.map(function (itemType) { + return String(itemType || '').trim().slice(0, 64); + }).filter(Boolean) : []; + + var sql = 'SELECT item_type, item_key, payload_ciphertext, payload_iv, payload_tag, payload_hash, key_version, updated_at FROM vault_items WHERE user_id = ?'; + var params = [request.authContext.user.id]; + if (itemTypes.length > 0) { + sql += ' AND item_type IN (' + itemTypes.map(function () { return '?'; }).join(',') + ')'; + params = params.concat(itemTypes); + } + sql += ' ORDER BY updated_at DESC'; + + var rows = await db.query(sql, params); + return { + ok: true, + items: rows.map(function (row) { + return { + itemType: row.item_type, + itemKey: row.item_key, + payloadCiphertext: row.payload_ciphertext, + payloadIv: row.payload_iv, + payloadTag: row.payload_tag, + payloadHash: row.payload_hash, + keyVersion: Number(row.key_version || 1), + updatedAt: row.updated_at + }; + }) + }; + }); +} + +module.exports = routes;