ca015da
commit ca015da
27 files changed
+1594 -2290
Showing 27 changed files+1594-2287
.env.exampleAGENTS.mdbun.lockdocs-site/src/content/docs/administration/configuration.mddocs-site/src/content/docs/administration/deploy-cloudflare.mddocs-site/src/content/docs/administration/deploy-cpanel.mddocs-site/src/content/docs/administration/deploy-cyberpanel.mddocs-site/src/content/docs/administration/deployment.mddocs-site/src/content/docs/administration/expose-nas.mddocs-site/src/content/docs/getting-started/installation.mddocs-site/src/content/docs/guides/storage-adapters.mddocs/administration/configuration.mddocs/administration/deploy-cpanel.mddocs/administration/deploy-cyberpanel.mddocs/administration/deployment.mddocs/administration/expose-nas.mddocs/development/architecture.mddocs/getting-started/installation.mddocs/guides/storage-adapters.mdpackage.jsonscripts/verify-env-config.tssrc/lib/env-validation.tssrc/lib/git-storage.tssrc/lib/storage.tssrc/lib/validation.tssrc/pages/admin/_storage-disabled.txtsrc/pages/api/admin/config/storage.ts
▼.env.example
+18-23
| @@ -64,31 +64,26 @@ GIT_SSH_PORT=2222 | ||
64 | 64 | GIT_SSH_HOST_KEY=./data/ssh/host_key |
65 | 65 | |
66 | 66 | # Storage Configuration |
67 | -# Supported: local, s3, gcs, azure, dropbox, gdrive, ftp, rclone | |
68 | -STORAGE_DRIVER=local | |
67 | +# Supported backends: local, s3 | |
68 | +# local — filesystem rooted at STORAGE_PATH (default ./data/storage). | |
69 | +# s3 — any S3-compatible object store: AWS S3, MinIO, Cloudflare R2, | |
70 | +# Garage, SeaweedFS, Ceph RGW, Wasabi, Backblaze B2, etc. | |
71 | +# Set STORAGE_ENDPOINT for non-AWS vendors. Path-style is | |
72 | +# enabled automatically when an endpoint is provided. | |
73 | +# | |
74 | +# Previously supported: gcs, azure, dropbox, gdrive, ftp, rclone-as-adapter. | |
75 | +# These were removed in favour of S3-compatible object storage and the | |
76 | +# rclone backup utility (see docs/administration/storage-adapters.md). | |
77 | +STORAGE_TYPE=local | |
69 | 78 | STORAGE_PATH=./data/storage |
70 | 79 | |
71 | -# S3 Configuration (if STORAGE_DRIVER=s3) | |
72 | -S3_BUCKET= | |
73 | -S3_REGION= | |
74 | -S3_ACCESS_KEY= | |
75 | -S3_SECRET_KEY= | |
76 | -S3_ENDPOINT= | |
77 | - | |
78 | -# Google Cloud Storage (if STORAGE_DRIVER=gcs) | |
79 | -GCS_BUCKET= | |
80 | -GCS_PROJECT_ID= | |
81 | -GCS_KEY_FILE= | |
82 | - | |
83 | -# FTP Configuration (if STORAGE_DRIVER=ftp) | |
84 | -FTP_HOST= | |
85 | -FTP_PORT=21 | |
86 | -FTP_USER= | |
87 | -FTP_PASSWORD= | |
88 | - | |
89 | -# Rclone Configuration (if STORAGE_DRIVER=rclone) | |
90 | -RCLONE_REMOTE= | |
91 | -RCLONE_CONFIG_PATH= | |
80 | +# S3 Configuration (required when STORAGE_TYPE=s3) | |
81 | +STORAGE_BUCKET= | |
82 | +STORAGE_REGION=us-east-1 | |
83 | +STORAGE_ENDPOINT= | |
84 | +STORAGE_ACCESS_KEY_ID= | |
85 | +STORAGE_SECRET_ACCESS_KEY= | |
86 | +# STORAGE_PATH= (left empty for s3; only used for local) | |
92 | 87 | |
93 | 88 | # CI/CD Runner Configuration |
94 | 89 | RUNNER_ENABLED=true |
95 | 90 | |
▼AGENTS.md
+8-12
| @@ -12,7 +12,7 @@ | ||
12 | 12 | - Stack-first PR workflows (Graphite-style stacked PRs) |
13 | 13 | - Merge queue with speculative builds and priority lanes |
14 | 14 | - GitHub Actions-compatible CI/CD pipeline engine + Docker-based runner |
15 | -- Pluggable storage (local, S3, GCS, Azure, Google Drive, OneDrive, Dropbox, FTP, rclone) | |
15 | +- Pluggable storage (`local` filesystem or `s3` — any S3-compatible object store: AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, Wasabi, Backblaze B2, etc.) | |
16 | 16 | - Multi-database support (PostgreSQL, SQLite, Turso/LibSQL) |
17 | 17 | - Built-in AI code review, automations, webhooks |
18 | 18 | |
| @@ -66,7 +66,7 @@ | ||
66 | 66 | | Git | Native `git` CLI + `simple-git` + `isomorphic-git` + `nodegit` | |
67 | 67 | | SSH | `ssh2` library | |
68 | 68 | | CI/CD | Dockerode (Docker API) | |
69 | -| Storage | Abstract adapter pattern with 8+ backends | | |
69 | +| Storage | Abstract adapter pattern; 2 backends (`local`, `s3`-compatible) | | |
70 | 70 | | Realtime | Custom (see `src/lib/realtime.ts`) | |
71 | 71 | | Queue | BullMQ + Redis | |
72 | 72 | | CLI | Commander.js + Inquirer + Chalk + `simple-git` | |
| @@ -250,16 +250,12 @@ Browser/Git CLI → Astro middleware (auth, rate limit, CSRF) | ||
250 | 250 | |
251 | 251 | ### 6.4 Storage Abstraction |
252 | 252 | All blob storage goes through `StorageAdapter` abstract class: |
253 | -- `LocalStorageAdapter` — filesystem | |
254 | -- `S3StorageAdapter` — S3/MinIO/R2 | |
255 | -- `GCSStorageAdapter` — Google Cloud Storage | |
256 | -- `AzureStorageAdapter` — Azure Blob | |
257 | -- `GoogleDriveStorageAdapter` — Google Drive | |
258 | -- `OneDriveStorageAdapter` — Microsoft OneDrive | |
259 | -- `DropboxStorageAdapter` — Dropbox | |
260 | -- `RcloneStorageAdapter` — Any rclone remote | |
253 | +- `LocalStorageAdapter` — filesystem rooted at `STORAGE_PATH` | |
254 | +- `S3StorageAdapter` — any S3-compatible object store (AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, Wasabi, Backblaze B2, ...). Path-style addressing is enabled automatically when `STORAGE_ENDPOINT` is set. | |
261 | 255 | |
262 | -Configured via `STORAGE_DRIVER` env var. | |
256 | +> Previous releases also shipped GCS, Azure, Google Drive, OneDrive, Dropbox, FTP, and rclone-as-adapter backends. These were removed in favour of S3-compatible object storage (one code path, multipart semantics, vendor-neutral). rclone remains available as a separate optional backup utility (`scripts/sync-storage.ts` + `/api/admin/sync`). | |
257 | + | |
258 | +Configured via `STORAGE_TYPE` env var (`local` or `s3`). | |
263 | 259 | |
264 | 260 | ### 6.5 Database Flexibility |
265 | 261 | `src/db/index.ts` provides a factory pattern: |
| @@ -328,7 +324,7 @@ och focus # Terminal UI for PRs/queue/reviews | ||
328 | 324 | | `RUNNER_SECRET` | Runner-server shared secret | |
329 | 325 | | `AI_CONFIG_ENCRYPTION_KEY` | AI provider key encryption | |
330 | 326 | | `WORKFLOW_SECRET_ENCRYPTION_KEY` | CI secret encryption | |
331 | -| `STORAGE_DRIVER` | Blob storage backend | | |
327 | +| `STORAGE_TYPE` | Blob storage backend (`local` or `s3`) | | |
332 | 328 | | `REDIS_URL` | Redis for sessions/queues | |
333 | 329 | | `GIT_REPOS_PATH` | Local git repo storage | |
334 | 330 | | `GIT_SSH_PORT` | SSH git server port | |
335 | 331 | |
▼bun.lock
+15-177
| @@ -13,11 +13,9 @@ | ||
13 | 13 | "@aws-sdk/client-s3": "^3.948.0", |
14 | 14 | "@aws-sdk/lib-storage": "^3.972.0", |
15 | 15 | "@aws-sdk/s3-request-presigner": "^3.948.0", |
16 | - "@azure/storage-blob": "^12.30.0", | |
17 | 16 | "@dnd-kit/core": "^6.3.1", |
18 | 17 | "@dnd-kit/sortable": "^10.0.0", |
19 | 18 | "@dnd-kit/utilities": "^3.2.2", |
20 | - "@google-cloud/storage": "^7.18.0", | |
21 | 19 | "@libsql/client": "^0.15.15", |
22 | 20 | "@monaco-editor/react": "^4.6.0", |
23 | 21 | "@opentelemetry/auto-instrumentations-node": "^0.76.0", |
| @@ -53,7 +51,6 @@ | ||
53 | 51 | "@upstash/redis": "^1.36.0", |
54 | 52 | "arctic": "^3.7.0", |
55 | 53 | "astro": "^4.11.0", |
56 | - "basic-ftp": "^5.1.0", | |
57 | 54 | "bcryptjs": "^2.4.3", |
58 | 55 | "better-sqlite3": "^11.1.2", |
59 | 56 | "bullmq": "^5.65.1", |
| @@ -66,7 +63,6 @@ | ||
66 | 63 | "drizzle-orm": "^0.45.2", |
67 | 64 | "framer-motion": "^11.18.2", |
68 | 65 | "github-markdown-css": "^5.8.1", |
69 | - "googleapis": "^169.0.0", | |
70 | 66 | "graphql": "^16.12.0", |
71 | 67 | "graphql-yoga": "^5.18.0", |
72 | 68 | "ioredis": "^5.9.2", |
| @@ -298,32 +294,6 @@ | ||
298 | 294 | |
299 | 295 | "@axe-core/playwright": ["@axe-core/[email protected]", "https://registry.npmmirror.com/@axe-core/playwright/-/playwright-4.11.3.tgz", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="], |
300 | 296 | |
301 | - "@azure/abort-controller": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/abort-controller/-/abort-controller-2.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], | |
302 | - | |
303 | - "@azure/core-auth": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-auth/-/core-auth-1.10.1.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], | |
304 | - | |
305 | - "@azure/core-client": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-client/-/core-client-1.10.1.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], | |
306 | - | |
307 | - "@azure/core-http-compat": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2" }, "peerDependencies": { "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA=="], | |
308 | - | |
309 | - "@azure/core-lro": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-lro/-/core-lro-2.7.2.tgz", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="], | |
310 | - | |
311 | - "@azure/core-paging": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-paging/-/core-paging-1.6.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA=="], | |
312 | - | |
313 | - "@azure/core-rest-pipeline": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="], | |
314 | - | |
315 | - "@azure/core-tracing": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-tracing/-/core-tracing-1.3.1.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], | |
316 | - | |
317 | - "@azure/core-util": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-util/-/core-util-1.13.1.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], | |
318 | - | |
319 | - "@azure/core-xml": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/core-xml/-/core-xml-1.5.1.tgz", { "dependencies": { "fast-xml-parser": "^5.5.9", "tslib": "^2.8.1" } }, "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w=="], | |
320 | - | |
321 | - "@azure/logger": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/logger/-/logger-1.3.0.tgz", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], | |
322 | - | |
323 | - "@azure/storage-blob": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/storage-blob/-/storage-blob-12.31.0.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.3.0", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg=="], | |
324 | - | |
325 | - "@azure/storage-common": ["@azure/[email protected]", "https://registry.npmmirror.com/@azure/storage-common/-/storage-common-12.3.0.tgz", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", "events": "^3.3.0", "tslib": "^2.8.1" } }, "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ=="], | |
326 | - | |
327 | 297 | "@babel/code-frame": ["@babel/[email protected]", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], |
328 | 298 | |
329 | 299 | "@babel/compat-data": ["@babel/[email protected]", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], |
| @@ -498,14 +468,6 @@ | ||
498 | 468 | |
499 | 469 | "@floating-ui/utils": ["@floating-ui/[email protected]", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], |
500 | 470 | |
501 | - "@google-cloud/paginator": ["@google-cloud/[email protected]", "https://registry.npmmirror.com/@google-cloud/paginator/-/paginator-5.0.2.tgz", { "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" } }, "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg=="], | |
502 | - | |
503 | - "@google-cloud/projectify": ["@google-cloud/[email protected]", "https://registry.npmmirror.com/@google-cloud/projectify/-/projectify-4.0.0.tgz", {}, "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA=="], | |
504 | - | |
505 | - "@google-cloud/promisify": ["@google-cloud/[email protected]", "https://registry.npmmirror.com/@google-cloud/promisify/-/promisify-4.0.0.tgz", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="], | |
506 | - | |
507 | - "@google-cloud/storage": ["@google-cloud/[email protected]", "https://registry.npmmirror.com/@google-cloud/storage/-/storage-7.19.0.tgz", { "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "<4.1.0", "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", "teeny-request": "^9.0.0", "uuid": "^8.0.0" } }, "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ=="], | |
508 | - | |
509 | 471 | "@graphql-tools/executor": ["@graphql-tools/[email protected]", "https://registry.npmmirror.com/@graphql-tools/executor/-/executor-1.5.3.tgz", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA=="], |
510 | 472 | |
511 | 473 | "@graphql-tools/merge": ["@graphql-tools/[email protected]", "https://registry.npmmirror.com/@graphql-tools/merge/-/merge-9.1.9.tgz", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q=="], |
| @@ -1096,8 +1058,6 @@ | ||
1096 | 1058 | |
1097 | 1059 | "@tailwindcss/typography": ["@tailwindcss/[email protected]", "https://registry.npmmirror.com/@tailwindcss/typography/-/typography-0.5.19.tgz", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], |
1098 | 1060 | |
1099 | - "@tootallnate/once": ["@tootallnate/[email protected]", "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.1.tgz", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], | |
1100 | - | |
1101 | 1061 | "@turf/boolean-point-in-polygon": ["@turf/[email protected]", "https://registry.npmmirror.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@types/geojson": "^7946.0.10", "point-in-polygon-hao": "^1.1.0", "tslib": "^2.8.1" } }, "sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg=="], |
1102 | 1062 | |
1103 | 1063 | "@turf/helpers": ["@turf/[email protected]", "https://registry.npmmirror.com/@turf/helpers/-/helpers-7.3.5.tgz", { "dependencies": { "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg=="], |
| @@ -1126,8 +1086,6 @@ | ||
1126 | 1086 | |
1127 | 1087 | "@types/bunyan": ["@types/[email protected]", "https://registry.npmmirror.com/@types/bunyan/-/bunyan-1.8.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="], |
1128 | 1088 | |
1129 | - "@types/caseless": ["@types/[email protected]", "https://registry.npmmirror.com/@types/caseless/-/caseless-0.12.5.tgz", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], | |
1130 | - | |
1131 | 1089 | "@types/chai": ["@types/[email protected]", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], |
1132 | 1090 | |
1133 | 1091 | "@types/cli-progress": ["@types/[email protected]", "https://registry.npmmirror.com/@types/cli-progress/-/cli-progress-3.11.6.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA=="], |
| @@ -1208,8 +1166,6 @@ | ||
1208 | 1166 | |
1209 | 1167 | "@types/react-dom": ["@types/[email protected]", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], |
1210 | 1168 | |
1211 | - "@types/request": ["@types/[email protected]", "https://registry.npmmirror.com/@types/request/-/request-2.48.13.tgz", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], | |
1212 | - | |
1213 | 1169 | "@types/ssh2": ["@types/[email protected]", "https://registry.npmmirror.com/@types/ssh2/-/ssh2-1.15.5.tgz", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], |
1214 | 1170 | |
1215 | 1171 | "@types/stats.js": ["@types/[email protected]", "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], |
| @@ -1222,8 +1178,6 @@ | ||
1222 | 1178 | |
1223 | 1179 | "@types/tinycolor2": ["@types/[email protected]", "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="], |
1224 | 1180 | |
1225 | - "@types/tough-cookie": ["@types/[email protected]", "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], | |
1226 | - | |
1227 | 1181 | "@types/trusted-types": ["@types/[email protected]", "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], |
1228 | 1182 | |
1229 | 1183 | "@types/unist": ["@types/[email protected]", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], |
| @@ -1234,8 +1188,6 @@ | ||
1234 | 1188 | |
1235 | 1189 | "@types/ws": ["@types/[email protected]", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], |
1236 | 1190 | |
1237 | - "@typespec/ts-http-runtime": ["@typespec/[email protected]", "https://registry.npmmirror.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], | |
1238 | - | |
1239 | 1191 | "@ungap/structured-clone": ["@ungap/[email protected]", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], |
1240 | 1192 | |
1241 | 1193 | "@upstash/redis": ["@upstash/[email protected]", "https://registry.npmmirror.com/@upstash/redis/-/redis-1.38.0.tgz", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], |
| @@ -1336,8 +1288,6 @@ | ||
1336 | 1288 | |
1337 | 1289 | "array-iterate": ["[email protected]", "https://registry.npmmirror.com/array-iterate/-/array-iterate-2.0.1.tgz", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], |
1338 | 1290 | |
1339 | - "arrify": ["[email protected]", "https://registry.npmmirror.com/arrify/-/arrify-2.0.1.tgz", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="], | |
1340 | - | |
1341 | 1291 | "asn1": ["[email protected]", "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], |
1342 | 1292 | |
1343 | 1293 | "assertion-error": ["[email protected]", "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| @@ -1348,12 +1298,8 @@ | ||
1348 | 1298 | |
1349 | 1299 | "async-lock": ["[email protected]", "https://registry.npmmirror.com/async-lock/-/async-lock-1.4.1.tgz", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], |
1350 | 1300 | |
1351 | - "async-retry": ["[email protected]", "https://registry.npmmirror.com/async-retry/-/async-retry-1.3.3.tgz", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], | |
1352 | - | |
1353 | 1301 | "async-sema": ["[email protected]", "https://registry.npmmirror.com/async-sema/-/async-sema-3.1.1.tgz", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="], |
1354 | 1302 | |
1355 | - "asynckit": ["[email protected]", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], | |
1356 | - | |
1357 | 1303 | "atomic-sleep": ["[email protected]", "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], |
1358 | 1304 | |
1359 | 1305 | "atomically": ["[email protected]", "https://registry.npmmirror.com/atomically/-/atomically-2.1.1.tgz", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], |
| @@ -1392,8 +1338,6 @@ | ||
1392 | 1338 | |
1393 | 1339 | "baseline-browser-mapping": ["[email protected]", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], |
1394 | 1340 | |
1395 | - "basic-ftp": ["[email protected]", "https://registry.npmmirror.com/basic-ftp/-/basic-ftp-5.3.1.tgz", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], | |
1396 | - | |
1397 | 1341 | "bcrypt-pbkdf": ["[email protected]", "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], |
1398 | 1342 | |
1399 | 1343 | "bcryptjs": ["[email protected]", "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], |
| @@ -1424,8 +1368,6 @@ | ||
1424 | 1368 | |
1425 | 1369 | "buffer": ["[email protected]", "https://registry.npmmirror.com/buffer/-/buffer-5.6.0.tgz", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], |
1426 | 1370 | |
1427 | - "buffer-equal-constant-time": ["[email protected]", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], | |
1428 | - | |
1429 | 1371 | "buffer-from": ["[email protected]", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], |
1430 | 1372 | |
1431 | 1373 | "buildcheck": ["[email protected]", "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], |
| @@ -1512,8 +1454,6 @@ | ||
1512 | 1454 | |
1513 | 1455 | "colorette": ["[email protected]", "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], |
1514 | 1456 | |
1515 | - "combined-stream": ["[email protected]", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], | |
1516 | - | |
1517 | 1457 | "comma-separated-tokens": ["[email protected]", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], |
1518 | 1458 | |
1519 | 1459 | "commander": ["[email protected]", "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], |
| @@ -1614,8 +1554,6 @@ | ||
1614 | 1554 | |
1615 | 1555 | "delaunator": ["[email protected]", "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], |
1616 | 1556 | |
1617 | - "delayed-stream": ["[email protected]", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], | |
1618 | - | |
1619 | 1557 | "denque": ["[email protected]", "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], |
1620 | 1558 | |
1621 | 1559 | "depd": ["[email protected]", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], |
| @@ -1660,14 +1598,10 @@ | ||
1660 | 1598 | |
1661 | 1599 | "dunder-proto": ["[email protected]", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], |
1662 | 1600 | |
1663 | - "duplexify": ["[email protected]", "https://registry.npmmirror.com/duplexify/-/duplexify-4.1.3.tgz", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], | |
1664 | - | |
1665 | 1601 | "earcut": ["[email protected]", "https://registry.npmmirror.com/earcut/-/earcut-3.0.2.tgz", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], |
1666 | 1602 | |
1667 | 1603 | "eastasianwidth": ["[email protected]", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], |
1668 | 1604 | |
1669 | - "ecdsa-sig-formatter": ["[email protected]", "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], | |
1670 | - | |
1671 | 1605 | "ee-first": ["[email protected]", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], |
1672 | 1606 | |
1673 | 1607 | "electron-to-chromium": ["[email protected]", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", {}, "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg=="], |
| @@ -1698,8 +1632,6 @@ | ||
1698 | 1632 | |
1699 | 1633 | "es-object-atoms": ["[email protected]", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], |
1700 | 1634 | |
1701 | - "es-set-tostringtag": ["[email protected]", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], | |
1702 | - | |
1703 | 1635 | "es-toolkit": ["[email protected]", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.47.0.tgz", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="], |
1704 | 1636 | |
1705 | 1637 | "esbuild": ["[email protected]", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], |
| @@ -1778,8 +1710,6 @@ | ||
1778 | 1710 | |
1779 | 1711 | "foreground-child": ["[email protected]", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], |
1780 | 1712 | |
1781 | - "form-data": ["[email protected]", "https://registry.npmmirror.com/form-data/-/form-data-2.5.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="], | |
1782 | - | |
1783 | 1713 | "form-data-encoder": ["[email protected]", "https://registry.npmmirror.com/form-data-encoder/-/form-data-encoder-4.1.0.tgz", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="], |
1784 | 1714 | |
1785 | 1715 | "formdata-polyfill": ["[email protected]", "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], |
| @@ -1806,9 +1736,9 @@ | ||
1806 | 1736 | |
1807 | 1737 | "function-bind": ["[email protected]", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], |
1808 | 1738 | |
1809 | - "gaxios": ["[email protected]", "https://registry.npmmirror.com/gaxios/-/gaxios-6.7.1.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], | |
1739 | + "gaxios": ["[email protected]", "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], | |
1810 | 1740 | |
1811 | - "gcp-metadata": ["[email protected]", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-6.1.1.tgz", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], | |
1741 | + "gcp-metadata": ["[email protected]", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], | |
1812 | 1742 | |
1813 | 1743 | "generate-function": ["[email protected]", "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], |
1814 | 1744 | |
| @@ -1840,14 +1770,8 @@ | ||
1840 | 1770 | |
1841 | 1771 | "globe.gl": ["[email protected]", "https://registry.npmmirror.com/globe.gl/-/globe.gl-2.46.1.tgz", { "dependencies": { "@tweenjs/tween.js": "18 - 25", "accessor-fn": "1", "kapsule": "^1.16", "three": ">=0.179 <1", "three-globe": "^2.45", "three-render-objects": "^1.41" } }, "sha512-h+OvX52EBIPLtM0/2JkM+JZ9gPAhPJ4y3+hxUwD5Ey/O0Zk2ockuTiJ71bZbnNBGmNiIZzA5Vr3TMT0b3d35IQ=="], |
1842 | 1772 | |
1843 | - "google-auth-library": ["[email protected]", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-9.15.1.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], | |
1844 | - | |
1845 | 1773 | "google-logging-utils": ["[email protected]", "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], |
1846 | 1774 | |
1847 | - "googleapis": ["[email protected]", "https://registry.npmmirror.com/googleapis/-/googleapis-169.0.0.tgz", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-IOGMG8tljCZSLvYgdojRu6mB10KEsK0J7X62sXXlQz9koe5BUAW+rqkY3qhQM9wXM6hVL3/Hase7XbxoMyeYiQ=="], | |
1848 | - | |
1849 | - "googleapis-common": ["[email protected]", "https://registry.npmmirror.com/googleapis-common/-/googleapis-common-8.0.1.tgz", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], | |
1850 | - | |
1851 | 1775 | "gopd": ["[email protected]", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], |
1852 | 1776 | |
1853 | 1777 | "got": ["[email protected]", "https://registry.npmmirror.com/got/-/got-14.6.6.tgz", { "dependencies": { "@sindresorhus/is": "^7.0.1", "byte-counter": "^0.1.0", "cacheable-lookup": "^7.0.0", "cacheable-request": "^13.0.12", "decompress-response": "^10.0.0", "form-data-encoder": "^4.0.2", "http2-wrapper": "^2.2.1", "keyv": "^5.5.3", "lowercase-keys": "^3.0.0", "p-cancelable": "^4.0.1", "responselike": "^4.0.2", "type-fest": "^4.26.1" } }, "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg=="], |
| @@ -1862,8 +1786,6 @@ | ||
1862 | 1786 | |
1863 | 1787 | "gray-matter": ["[email protected]", "https://registry.npmmirror.com/gray-matter/-/gray-matter-4.0.3.tgz", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], |
1864 | 1788 | |
1865 | - "gtoken": ["[email protected]", "https://registry.npmmirror.com/gtoken/-/gtoken-7.1.0.tgz", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], | |
1866 | - | |
1867 | 1789 | "h3-js": ["[email protected]", "https://registry.npmmirror.com/h3-js/-/h3-js-4.4.0.tgz", {}, "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog=="], |
1868 | 1790 | |
1869 | 1791 | "has-flag": ["[email protected]", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], |
| @@ -1902,8 +1824,6 @@ | ||
1902 | 1824 | |
1903 | 1825 | "html-encoding-sniffer": ["[email protected]", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], |
1904 | 1826 | |
1905 | - "html-entities": ["[email protected]", "https://registry.npmmirror.com/html-entities/-/html-entities-2.6.0.tgz", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], | |
1906 | - | |
1907 | 1827 | "html-escaper": ["[email protected]", "https://registry.npmmirror.com/html-escaper/-/html-escaper-3.0.3.tgz", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], |
1908 | 1828 | |
1909 | 1829 | "html-void-elements": ["[email protected]", "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], |
| @@ -1978,7 +1898,7 @@ | ||
1978 | 1898 | |
1979 | 1899 | "is-property": ["[email protected]", "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], |
1980 | 1900 | |
1981 | - "is-stream": ["[email protected]", "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], | |
1901 | + "is-stream": ["[email protected]", "https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], | |
1982 | 1902 | |
1983 | 1903 | "is-typed-array": ["[email protected]", "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], |
1984 | 1904 | |
| @@ -2032,10 +1952,6 @@ | ||
2032 | 1952 | |
2033 | 1953 | "jsonfile": ["[email protected]", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], |
2034 | 1954 | |
2035 | - "jwa": ["[email protected]", "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], | |
2036 | - | |
2037 | - "jws": ["[email protected]", "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], | |
2038 | - | |
2039 | 1955 | "kapsule": ["[email protected]", "https://registry.npmmirror.com/kapsule/-/kapsule-1.16.3.tgz", { "dependencies": { "lodash-es": "4" } }, "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg=="], |
2040 | 1956 | |
2041 | 1957 | "keyv": ["[email protected]", "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], |
| @@ -2210,11 +2126,7 @@ | ||
2210 | 2126 | |
2211 | 2127 | "micromatch": ["[email protected]", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], |
2212 | 2128 | |
2213 | - "mime": ["[email protected]", "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], | |
2214 | - | |
2215 | - "mime-db": ["[email protected]", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], | |
2216 | - | |
2217 | - "mime-types": ["[email protected]", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], | |
2129 | + "mime": ["[email protected]", "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], | |
2218 | 2130 | |
2219 | 2131 | "mimic-fn": ["[email protected]", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-4.0.0.tgz", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], |
2220 | 2132 | |
| @@ -2288,7 +2200,7 @@ | ||
2288 | 2200 | |
2289 | 2201 | "node-domexception": ["[email protected]", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], |
2290 | 2202 | |
2291 | - "node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], | |
2203 | + "node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], | |
2292 | 2204 | |
2293 | 2205 | "node-gyp": ["[email protected]", "https://registry.npmmirror.com/node-gyp/-/node-gyp-11.5.0.tgz", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], |
2294 | 2206 | |
| @@ -2314,8 +2226,6 @@ | ||
2314 | 2226 | |
2315 | 2227 | "object-hash": ["[email protected]", "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], |
2316 | 2228 | |
2317 | - "object-inspect": ["[email protected]", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], | |
2318 | - | |
2319 | 2229 | "obug": ["[email protected]", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], |
2320 | 2230 | |
2321 | 2231 | "on-exit-leak-free": ["[email protected]", "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], |
| @@ -2342,7 +2252,7 @@ | ||
2342 | 2252 | |
2343 | 2253 | "p-cancelable": ["[email protected]", "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-4.0.1.tgz", {}, "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg=="], |
2344 | 2254 | |
2345 | - "p-limit": ["[email protected]", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], | |
2255 | + "p-limit": ["[email protected]", "https://registry.npmmirror.com/p-limit/-/p-limit-6.2.0.tgz", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], | |
2346 | 2256 | |
2347 | 2257 | "p-locate": ["[email protected]", "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], |
2348 | 2258 | |
| @@ -2488,8 +2398,6 @@ | ||
2488 | 2398 | |
2489 | 2399 | "qrcode": ["[email protected]", "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], |
2490 | 2400 | |
2491 | - "qs": ["[email protected]", "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], | |
2492 | - | |
2493 | 2401 | "queue-microtask": ["[email protected]", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], |
2494 | 2402 | |
2495 | 2403 | "quick-format-unescaped": ["[email protected]", "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], |
| @@ -2596,9 +2504,7 @@ | ||
2596 | 2504 | |
2597 | 2505 | "retext-stringify": ["[email protected]", "https://registry.npmmirror.com/retext-stringify/-/retext-stringify-4.0.0.tgz", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], |
2598 | 2506 | |
2599 | - "retry": ["[email protected]", "https://registry.npmmirror.com/retry/-/retry-0.13.1.tgz", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], | |
2600 | - | |
2601 | - "retry-request": ["[email protected]", "https://registry.npmmirror.com/retry-request/-/retry-request-7.0.2.tgz", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="], | |
2507 | + "retry": ["[email protected]", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], | |
2602 | 2508 | |
2603 | 2509 | "reusify": ["[email protected]", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], |
2604 | 2510 | |
| @@ -2650,14 +2556,6 @@ | ||
2650 | 2556 | |
2651 | 2557 | "shiki": ["[email protected]", "https://registry.npmmirror.com/shiki/-/shiki-1.29.2.tgz", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], |
2652 | 2558 | |
2653 | - "side-channel": ["[email protected]", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], | |
2654 | - | |
2655 | - "side-channel-list": ["[email protected]", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], | |
2656 | - | |
2657 | - "side-channel-map": ["[email protected]", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], | |
2658 | - | |
2659 | - "side-channel-weakmap": ["[email protected]", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], | |
2660 | - | |
2661 | 2559 | "siginfo": ["[email protected]", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], |
2662 | 2560 | |
2663 | 2561 | "signal-exit": ["[email protected]", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], |
| @@ -2718,10 +2616,6 @@ | ||
2718 | 2616 | |
2719 | 2617 | "stream-browserify": ["[email protected]", "https://registry.npmmirror.com/stream-browserify/-/stream-browserify-3.0.0.tgz", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], |
2720 | 2618 | |
2721 | - "stream-events": ["[email protected]", "https://registry.npmmirror.com/stream-events/-/stream-events-1.0.5.tgz", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], | |
2722 | - | |
2723 | - "stream-shift": ["[email protected]", "https://registry.npmmirror.com/stream-shift/-/stream-shift-1.0.3.tgz", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], | |
2724 | - | |
2725 | 2619 | "streamx": ["[email protected]", "https://registry.npmmirror.com/streamx/-/streamx-2.26.0.tgz", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A=="], |
2726 | 2620 | |
2727 | 2621 | "string-width": ["[email protected]", "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], |
| @@ -2748,8 +2642,6 @@ | ||
2748 | 2642 | |
2749 | 2643 | "stubborn-utils": ["[email protected]", "https://registry.npmmirror.com/stubborn-utils/-/stubborn-utils-1.0.2.tgz", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], |
2750 | 2644 | |
2751 | - "stubs": ["[email protected]", "https://registry.npmmirror.com/stubs/-/stubs-3.0.0.tgz", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="], | |
2752 | - | |
2753 | 2645 | "sucrase": ["[email protected]", "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], |
2754 | 2646 | |
2755 | 2647 | "supports-color": ["[email protected]", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], |
| @@ -2772,8 +2664,6 @@ | ||
2772 | 2664 | |
2773 | 2665 | "tdigest": ["[email protected]", "https://registry.npmmirror.com/tdigest/-/tdigest-0.1.2.tgz", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], |
2774 | 2666 | |
2775 | - "teeny-request": ["[email protected]", "https://registry.npmmirror.com/teeny-request/-/teeny-request-9.0.0.tgz", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="], | |
2776 | - | |
2777 | 2667 | "teex": ["[email protected]", "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], |
2778 | 2668 | |
2779 | 2669 | "text-decoder": ["[email protected]", "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], |
| @@ -2890,8 +2780,6 @@ | ||
2890 | 2780 | |
2891 | 2781 | "update-browserslist-db": ["[email protected]", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], |
2892 | 2782 | |
2893 | - "url-template": ["[email protected]", "https://registry.npmmirror.com/url-template/-/url-template-2.0.8.tgz", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], | |
2894 | - | |
2895 | 2783 | "urlpattern-polyfill": ["[email protected]", "https://registry.npmmirror.com/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], |
2896 | 2784 | |
2897 | 2785 | "use-callback-ref": ["[email protected]", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], |
| @@ -2902,7 +2790,7 @@ | ||
2902 | 2790 | |
2903 | 2791 | "util-deprecate": ["[email protected]", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], |
2904 | 2792 | |
2905 | - "uuid": ["[email protected]", "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], | |
2793 | + "uuid": ["[email protected]", "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], | |
2906 | 2794 | |
2907 | 2795 | "vfile": ["[email protected]", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], |
2908 | 2796 | |
| @@ -3014,7 +2902,7 @@ | ||
3014 | 2902 | |
3015 | 2903 | "yargs-parser": ["[email protected]", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], |
3016 | 2904 | |
3017 | - "yocto-queue": ["[email protected]", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], | |
2905 | + "yocto-queue": ["[email protected]", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-1.2.2.tgz", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], | |
3018 | 2906 | |
3019 | 2907 | "yoctocolors-cjs": ["[email protected]", "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], |
3020 | 2908 | |
| @@ -3056,10 +2944,10 @@ | ||
3056 | 2944 | |
3057 | 2945 | "@isaacs/cliui/strip-ansi": ["[email protected]", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
3058 | 2946 | |
3059 | - "@libsql/hrana-client/node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], | |
3060 | - | |
3061 | 2947 | "@mapbox/node-pre-gyp/detect-libc": ["[email protected]", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], |
3062 | 2948 | |
2949 | + "@mapbox/node-pre-gyp/node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], | |
2950 | + | |
3063 | 2951 | "@opentelemetry/instrumentation-pg/@types/pg": ["@types/[email protected]", "https://registry.npmmirror.com/@types/pg/-/pg-8.15.6.tgz", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], |
3064 | 2952 | |
3065 | 2953 | "@opentelemetry/resource-detector-aws/@opentelemetry/core": ["@opentelemetry/[email protected]", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.2.0.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], |
| @@ -3070,8 +2958,6 @@ | ||
3070 | 2958 | |
3071 | 2959 | "@opentelemetry/resource-detector-container/@opentelemetry/resources": ["@opentelemetry/[email protected]", "https://registry.npmmirror.com/@opentelemetry/resources/-/resources-2.2.0.tgz", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], |
3072 | 2960 | |
3073 | - "@opentelemetry/resource-detector-gcp/gcp-metadata": ["[email protected]", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], | |
3074 | - | |
3075 | 2961 | "@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/[email protected]", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.2.0.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], |
3076 | 2962 | |
3077 | 2963 | "@oslojs/jwt/@oslojs/encoding": ["@oslojs/[email protected]", "https://registry.npmmirror.com/@oslojs/encoding/-/encoding-0.4.1.tgz", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], |
| @@ -3128,8 +3014,6 @@ | ||
3128 | 3014 | |
3129 | 3015 | "astro/magicast": ["[email protected]", "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], |
3130 | 3016 | |
3131 | - "astro/p-limit": ["[email protected]", "https://registry.npmmirror.com/p-limit/-/p-limit-6.2.0.tgz", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], | |
3132 | - | |
3133 | 3017 | "astro/tinyexec": ["[email protected]", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], |
3134 | 3018 | |
3135 | 3019 | "astro/vite": ["[email protected]", "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], |
| @@ -3164,32 +3048,16 @@ | ||
3164 | 3048 | |
3165 | 3049 | "docker-modem/readable-stream": ["[email protected]", "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], |
3166 | 3050 | |
3167 | - "dockerode/uuid": ["[email protected]", "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], | |
3168 | - | |
3169 | 3051 | "dot-prop/type-fest": ["[email protected]", "https://registry.npmmirror.com/type-fest/-/type-fest-3.13.1.tgz", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], |
3170 | 3052 | |
3171 | - "duplexify/readable-stream": ["[email protected]", "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], | |
3172 | - | |
3173 | 3053 | "encoding/iconv-lite": ["[email protected]", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], |
3174 | 3054 | |
3175 | 3055 | "fast-glob/glob-parent": ["[email protected]", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], |
3176 | 3056 | |
3177 | 3057 | "figlet/commander": ["[email protected]", "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], |
3178 | 3058 | |
3179 | - "gaxios/uuid": ["[email protected]", "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], | |
3180 | - | |
3181 | - "gcp-metadata/google-logging-utils": ["[email protected]", "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-0.0.2.tgz", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], | |
3182 | - | |
3183 | - "get-stream/is-stream": ["[email protected]", "https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], | |
3184 | - | |
3185 | 3059 | "glob/minimatch": ["[email protected]", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], |
3186 | 3060 | |
3187 | - "googleapis/google-auth-library": ["[email protected]", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.6.2.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], | |
3188 | - | |
3189 | - "googleapis-common/gaxios": ["[email protected]", "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], | |
3190 | - | |
3191 | - "googleapis-common/google-auth-library": ["[email protected]", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.6.2.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], | |
3192 | - | |
3193 | 3061 | "got/decompress-response": ["[email protected]", "https://registry.npmmirror.com/decompress-response/-/decompress-response-10.0.0.tgz", { "dependencies": { "mimic-response": "^4.0.0" } }, "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q=="], |
3194 | 3062 | |
3195 | 3063 | "got/type-fest": ["[email protected]", "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], |
| @@ -3224,8 +3092,6 @@ | ||
3224 | 3092 | |
3225 | 3093 | "minipass-sized/minipass": ["[email protected]", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], |
3226 | 3094 | |
3227 | - "node-fetch/whatwg-url": ["[email protected]", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], | |
3228 | - | |
3229 | 3095 | "node-gyp/env-paths": ["[email protected]", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], |
3230 | 3096 | |
3231 | 3097 | "node-gyp-build-optional-packages/detect-libc": ["[email protected]", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], |
| @@ -3248,8 +3114,6 @@ | ||
3248 | 3114 | |
3249 | 3115 | "prebuild-install/detect-libc": ["[email protected]", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], |
3250 | 3116 | |
3251 | - "promise-retry/retry": ["[email protected]", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], | |
3252 | - | |
3253 | 3117 | "prompts/kleur": ["[email protected]", "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], |
3254 | 3118 | |
3255 | 3119 | "qrcode/yargs": ["[email protected]", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], |
| @@ -3264,8 +3128,6 @@ | ||
3264 | 3128 | |
3265 | 3129 | "send/debug": ["[email protected]", "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], |
3266 | 3130 | |
3267 | - "send/mime": ["[email protected]", "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], | |
3268 | - | |
3269 | 3131 | "sharp/detect-libc": ["[email protected]", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], |
3270 | 3132 | |
3271 | 3133 | "ssh2/nan": ["[email protected]", "https://registry.npmmirror.com/nan/-/nan-2.27.0.tgz", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], |
| @@ -3284,12 +3146,6 @@ | ||
3284 | 3146 | |
3285 | 3147 | "tar-stream/readable-stream": ["[email protected]", "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], |
3286 | 3148 | |
3287 | - "teeny-request/http-proxy-agent": ["[email protected]", "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], | |
3288 | - | |
3289 | - "teeny-request/https-proxy-agent": ["[email protected]", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], | |
3290 | - | |
3291 | - "teeny-request/uuid": ["[email protected]", "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], | |
3292 | - | |
3293 | 3149 | "thread-stream/real-require": ["[email protected]", "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], |
3294 | 3150 | |
3295 | 3151 | "tsx/esbuild": ["[email protected]", "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], |
| @@ -3408,7 +3264,7 @@ | ||
3408 | 3264 | |
3409 | 3265 | "@isaacs/cliui/strip-ansi/ansi-regex": ["[email protected]", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], |
3410 | 3266 | |
3411 | - "@opentelemetry/resource-detector-gcp/gcp-metadata/gaxios": ["[email protected]", "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], | |
3267 | + "@mapbox/node-pre-gyp/node-fetch/whatwg-url": ["[email protected]", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], | |
3412 | 3268 | |
3413 | 3269 | "@types/ssh2/@types/node/undici-types": ["[email protected]", "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], |
3414 | 3270 | |
| @@ -3472,8 +3328,6 @@ | ||
3472 | 3328 | |
3473 | 3329 | "astro/esbuild/@esbuild/win32-x64": ["@esbuild/[email protected]", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], |
3474 | 3330 | |
3475 | - "astro/p-limit/yocto-queue": ["[email protected]", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-1.2.2.tgz", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], | |
3476 | - | |
3477 | 3331 | "bullmq/ioredis/@ioredis/commands": ["@ioredis/[email protected]", "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.5.1.tgz", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], |
3478 | 3332 | |
3479 | 3333 | "cli-progress/string-width/emoji-regex": ["[email protected]", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], |
| @@ -3486,14 +3340,6 @@ | ||
3486 | 3340 | |
3487 | 3341 | "cross-spawn/which/isexe": ["[email protected]", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], |
3488 | 3342 | |
3489 | - "googleapis-common/gaxios/node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], | |
3490 | - | |
3491 | - "googleapis-common/google-auth-library/gcp-metadata": ["[email protected]", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], | |
3492 | - | |
3493 | - "googleapis/google-auth-library/gaxios": ["[email protected]", "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], | |
3494 | - | |
3495 | - "googleapis/google-auth-library/gcp-metadata": ["[email protected]", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], | |
3496 | - | |
3497 | 3343 | "got/decompress-response/mimic-response": ["[email protected]", "https://registry.npmmirror.com/mimic-response/-/mimic-response-4.0.0.tgz", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], |
3498 | 3344 | |
3499 | 3345 | "gradient-string/chalk/ansi-styles": ["[email protected]", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], |
| @@ -3526,10 +3372,6 @@ | ||
3526 | 3372 | |
3527 | 3373 | "minipass-sized/minipass/yallist": ["[email protected]", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], |
3528 | 3374 | |
3529 | - "node-fetch/whatwg-url/tr46": ["[email protected]", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], | |
3530 | - | |
3531 | - "node-fetch/whatwg-url/webidl-conversions": ["[email protected]", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], | |
3532 | - | |
3533 | 3375 | "nodegit/tar-fs/tar-stream": ["[email protected]", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], |
3534 | 3376 | |
3535 | 3377 | "ora/string-width/emoji-regex": ["[email protected]", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| @@ -3548,10 +3390,6 @@ | ||
3548 | 3390 | |
3549 | 3391 | "string-width/strip-ansi/ansi-regex": ["[email protected]", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], |
3550 | 3392 | |
3551 | - "teeny-request/http-proxy-agent/agent-base": ["[email protected]", "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], | |
3552 | - | |
3553 | - "teeny-request/https-proxy-agent/agent-base": ["[email protected]", "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], | |
3554 | - | |
3555 | 3393 | "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], |
3556 | 3394 | |
3557 | 3395 | "tsx/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], |
| @@ -3656,7 +3494,9 @@ | ||
3656 | 3494 | |
3657 | 3495 | "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/[email protected]", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], |
3658 | 3496 | |
3659 | - "@opentelemetry/resource-detector-gcp/gcp-metadata/gaxios/node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], | |
3497 | + "@mapbox/node-pre-gyp/node-fetch/whatwg-url/tr46": ["[email protected]", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], | |
3498 | + | |
3499 | + "@mapbox/node-pre-gyp/node-fetch/whatwg-url/webidl-conversions": ["[email protected]", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], | |
3660 | 3500 | |
3661 | 3501 | "@vitejs/plugin-react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], |
3662 | 3502 | |
| @@ -3710,8 +3550,6 @@ | ||
3710 | 3550 | |
3711 | 3551 | "astro/boxen/wrap-ansi/strip-ansi": ["[email protected]", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
3712 | 3552 | |
3713 | - "googleapis/google-auth-library/gaxios/node-fetch": ["[email protected]", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], | |
3714 | - | |
3715 | 3553 | "inquirer/ora/chalk/ansi-styles": ["[email protected]", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], |
3716 | 3554 | |
3717 | 3555 | "inquirer/ora/cli-cursor/restore-cursor": ["[email protected]", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], |
3718 | 3556 | |
▼docs-site/src/content/docs/administration/configuration.md
+12-7
| @@ -33,13 +33,18 @@ OpenCodeHub is configured exclusively via environment variables. | ||
33 | 33 | |
34 | 34 | | Variable | Description | Default | |
35 | 35 | |----------|-------------|---------| |
36 | -| `STORAGE_TYPE` | `local`, `s3`, `gdrive`, `azure` | `local` | | |
37 | -| `STORAGE_PATH` | Path for local storage | `./data` | | |
38 | -| `STORAGE_BUCKET` | Bucket name (S3/GCP) | | | |
39 | -| `STORAGE_REGION` | AWS Region | `us-east-1` | | |
40 | -| `STORAGE_ENDPOINT` | Custom S3 endpoint | | | |
41 | -| `S3_ACCESS_KEY` | AWS Access Key | | | |
42 | -| `S3_SECRET_KEY` | AWS Secret Key | | | |
36 | +| `STORAGE_TYPE` | `local` (filesystem) or `s3` (any S3-compatible object store) | `local` | | |
37 | +| `STORAGE_PATH` | Path for local storage | `./data/storage` | | |
38 | +| `STORAGE_BUCKET` | Bucket name (required for `s3`) | | | |
39 | +| `STORAGE_REGION` | S3 region | `us-east-1` | | |
40 | +| `STORAGE_ENDPOINT` | Custom S3 endpoint (MinIO, R2, Garage, SeaweedFS, Ceph RGW, etc.) | | | |
41 | +| `STORAGE_ACCESS_KEY_ID` | S3 access key | | | |
42 | +| `STORAGE_SECRET_ACCESS_KEY` | S3 secret key | | | |
43 | + | |
44 | +> Only `local` and `s3` storage backends are supported. Previous releases | |
45 | +> also supported `gdrive`, `azure`, `gcs`, `dropbox`, `onedrive`, `ftp`, and | |
46 | +> an rclone-as-adapter option; these have been removed in favour of | |
47 | +> S3-compatible object storage. See `docs/guides/storage-adapters.md`. | |
43 | 48 | |
44 | 49 | ## AI Review |
45 | 50 | |
46 | 51 | |
▼docs-site/src/content/docs/administration/deploy-cloudflare.md
+4-4
| @@ -130,8 +130,8 @@ STORAGE_TYPE=s3 | ||
130 | 130 | STORAGE_BUCKET=opencodehub-repos |
131 | 131 | STORAGE_REGION=auto |
132 | 132 | STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com |
133 | -S3_ACCESS_KEY=<your-r2-access-key-id> | |
134 | -S3_SECRET_KEY=<your-r2-secret-access-key> | |
133 | +STORAGE_ACCESS_KEY_ID=<your-r2-access-key-id> | |
134 | +STORAGE_SECRET_ACCESS_KEY=<your-r2-secret-access-key> | |
135 | 135 | ``` |
136 | 136 | |
137 | 137 | Find your account ID in Cloudflare Dashboard → R2 → Overview. |
| @@ -298,8 +298,8 @@ STORAGE_TYPE=s3 | ||
298 | 298 | STORAGE_BUCKET=opencodehub-repos |
299 | 299 | STORAGE_REGION=auto |
300 | 300 | STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com |
301 | -S3_ACCESS_KEY=<r2-access-key> | |
302 | -S3_SECRET_KEY=<r2-secret-key> | |
301 | +STORAGE_ACCESS_KEY_ID=<r2-access-key> | |
302 | +STORAGE_SECRET_ACCESS_KEY=<r2-secret-key> | |
303 | 303 | ``` |
304 | 304 | |
305 | 305 | --- |
306 | 306 | |
▼docs-site/src/content/docs/administration/deploy-cpanel.md
+1-1
| @@ -153,7 +153,7 @@ SITE_URL=https://git.yourdomain.com | ||
153 | 153 | NODE_ENV=production |
154 | 154 | |
155 | 155 | # Storage (local is fine for single-server cPanel) |
156 | -STORAGE_DRIVER=local | |
156 | +STORAGE_TYPE=local | |
157 | 157 | STORAGE_PATH=./data/storage |
158 | 158 | REPOS_PATH=./data/repos |
159 | 159 | |
160 | 160 | |
▼docs-site/src/content/docs/administration/deploy-cyberpanel.md
+1-1
| @@ -143,7 +143,7 @@ SITE_URL=https://git.yourdomain.com | ||
143 | 143 | NODE_ENV=production |
144 | 144 | |
145 | 145 | # Storage |
146 | -STORAGE_DRIVER=local | |
146 | +STORAGE_TYPE=local | |
147 | 147 | STORAGE_PATH=/data/storage |
148 | 148 | REPOS_PATH=/data/repos |
149 | 149 | SSH_HOST_KEY_PATH=/data/ssh/host_key |
150 | 150 | |
▼docs-site/src/content/docs/administration/deployment.md
+9-6
| @@ -90,18 +90,21 @@ DATABASE_DRIVER=postgres | ||
90 | 90 | DATABASE_URL=postgresql://user:password@host:5432/opencodehub |
91 | 91 | ``` |
92 | 92 | |
93 | -**Storage (Use cloud storage, not local):** | |
93 | +**Storage (S3-compatible; works with AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, etc.):** | |
94 | 94 | ```bash |
95 | 95 | STORAGE_TYPE=s3 |
96 | 96 | STORAGE_BUCKET=opencodehub-production |
97 | 97 | STORAGE_REGION=us-east-1 |
98 | -S3_ACCESS_KEY=<your-access-key> | |
99 | -S3_ACCESS_KEY=<your-access-key> | |
100 | -S3_SECRET_KEY=<your-secret-key> | |
98 | +STORAGE_ACCESS_KEY_ID=<your-access-key> | |
99 | +STORAGE_SECRET_ACCESS_KEY=<your-secret-key> | |
100 | +# STORAGE_ENDPOINT=https://<account>.r2.cloudflarestorage.com # set for non-AWS vendors | |
101 | 101 | ``` |
102 | 102 | |
103 | -**Google Drive + Turso Stack:** | |
104 | -See [docs/GDRIVE_STACK.md](docs/GDRIVE_STACK.md) for detailed setup. | |
103 | +> Only `local` and `s3` storage backends are supported. Google Drive, | |
104 | +> OneDrive, Dropbox, FTP, GCS, Azure, and rclone-as-adapter have been | |
105 | +> removed; use the `s3` driver with a vendor-specific `STORAGE_ENDPOINT` | |
106 | +> for object storage. See `docs/guides/storage-adapters.md` for the | |
107 | +> full list of compatible object stores. | |
105 | 108 | ```bash |
106 | 109 | STORAGE_TYPE=gdrive |
107 | 110 | GOOGLE_CLIENT_ID=... |
108 | 111 | |
▼docs-site/src/content/docs/administration/expose-nas.md
+430-0
| @@ -0,0 +1,430 @@ | ||
1 | +--- | |
2 | +title: "Exposing OpenCodeHub from a NAS or home server" | |
3 | +--- | |
4 | + | |
5 | +# Exposing OpenCodeHub from a NAS or home server | |
6 | + | |
7 | +> **Difficulty:** Intermediate | **Time:** 30-60 minutes | **Audience:** self-hosters on a NAS, home lab, Raspberry Pi, mini-PC, or VPS without a public IP | |
8 | + | |
9 | +This guide covers the two most popular ways to put an OpenCodeHub | |
10 | +instance running on a NAS or home server on the public internet **without | |
11 | +opening inbound ports on your router** and **without exposing your home | |
12 | +IP**: | |
13 | + | |
14 | +- **[Tailscale Funnel](https://tailscale.com/kb/1223/funnel/)** — WireGuard-based mesh VPN with a public ingress; the simplest option if you already use Tailscale. | |
15 | +- **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)** — an outbound-only `cloudflared` daemon that fronts your app with Cloudflare's edge. | |
16 | + | |
17 | +Both options work whether your ISP gives you a public IP, CGNAT, a | |
18 | +double-NAT, or a captive portal. Both terminate TLS at the edge | |
19 | +(Cloudflare's network or Tailscale's edge), so you do not need to | |
20 | +manage certificates or open `:80`/`:443` on your router. | |
21 | + | |
22 | +If you do have a public IP and a domain, the | |
23 | +[deploy-nas.md](./deploy-nas.md) guide covers the traditional | |
24 | +reverse-proxy + Let's Encrypt path. The two methods below are | |
25 | +preferred because they require **zero port forwarding**. | |
26 | + | |
27 | +--- | |
28 | + | |
29 | +## At a glance | |
30 | + | |
31 | +| Method | Outbound from home? | Inbound to home? | Free tier | Custom domain | Best for | | |
32 | +|---|---|---|---|---|---| | |
33 | +| **Tailscale Funnel** | yes (WireGuard) | none | yes (personal use) | yes, but only one hostname per funnel | Already using Tailscale; simple, single-user / small-team setups | | |
34 | +| **Cloudflare Tunnel** | yes (HTTP/2) | none | yes (unlimited tunnels) | yes, full DNS support | Public-facing deployments, larger teams, custom domains, edge caching | | |
35 | + | |
36 | +Both methods are **outbound-only** from your NAS. Your router's | |
37 | +firewall never sees an inbound connection to OpenCodeHub ports. | |
38 | + | |
39 | +--- | |
40 | + | |
41 | +## 0. Prerequisites | |
42 | + | |
43 | +Whatever exposure method you pick, you need: | |
44 | + | |
45 | +1. **A working OpenCodeHub instance** on your NAS, listening on a local port (default `3000`). | |
46 | +2. **A reachable hostname** (Tailscale or Cloudflare) pointed at the tunnel. | |
47 | +3. **A persistent `cloudflared` or `tailscaled`** process — running on the NAS, in Docker, or as a system service. | |
48 | +4. **Trusted Proxies** correctly set in OpenCodeHub (so the real client IP is preserved and rate limits are not bypassed). See `TRUSTED_PROXIES` in `.env.example`. | |
49 | + | |
50 | +> If you do not yet have OpenCodeHub running, see | |
51 | +> [deploy-nas.md](./deploy-nas.md) for the Docker setup first. | |
52 | + | |
53 | +--- | |
54 | + | |
55 | +## 1. Tailscale Funnel | |
56 | + | |
57 | +[Tailscale](https://tailscale.com/) is a WireGuard-based mesh VPN. | |
58 | +**Tailscale Funnel** is the public-ingress feature: it lets you expose | |
59 | +a service running on any node in your tailnet to the public internet | |
60 | +through Tailscale's edge, using a stable HTTPS URL. | |
61 | + | |
62 | +### Why Tailscale Funnel | |
63 | + | |
64 | +- Single binary, single auth key. | |
65 | +- TLS terminated at Tailscale's edge — your NAS never sees the public | |
66 | + traffic in the clear. | |
67 | +- Stable hostname: `https://<node-name>.<tailnet>.ts.net`. | |
68 | +- No port forwarding, no firewall changes, no certificate management. | |
69 | +- Works on CGNAT, mobile broadband, hotel Wi-Fi. | |
70 | + | |
71 | +### Step 1.1 — Install Tailscale on the NAS | |
72 | + | |
73 | +On the NAS (or in a container with `--net=host` and `tailscaled` running | |
74 | +on the host), install Tailscale: | |
75 | + | |
76 | +```bash | |
77 | +# Synology DSM 7: install from Package Center (community package), or: | |
78 | +curl -fsSL https://tailscale.com/install.sh | sh | |
79 | + | |
80 | +# TrueNAS SCALE: install the TrueNAS Tailscale app from the catalog | |
81 | +# QNAP: install via Container Station as a Linux container | |
82 | +# Generic Linux / Raspberry Pi OS: | |
83 | +curl -fsSL https://tailscale.com/install.sh | sh | |
84 | +``` | |
85 | + | |
86 | +Authenticate: | |
87 | + | |
88 | +```bash | |
89 | +sudo tailscale up | |
90 | +``` | |
91 | + | |
92 | +Follow the URL it prints, sign in, and approve the device. Confirm | |
93 | +with: | |
94 | + | |
95 | +```bash | |
96 | +tailscale status # should show your NAS as a node | |
97 | +tailscale ip -4 # 100.x.y.z | |
98 | +``` | |
99 | + | |
100 | +### Step 1.2 — Expose OpenCodeHub via Funnel | |
101 | + | |
102 | +```bash | |
103 | +# Tailscale Funnel is currently in beta and must be enabled per tailnet | |
104 | +# in the Tailscale admin console: https://login.tailscale.com/admin | |
105 | +# (turn on the "Funnel" feature flag). | |
106 | +# | |
107 | +# Then on the NAS, expose the port OpenCodeHub listens on (default 3000): | |
108 | +sudo tailscale funnel 3000 on | |
109 | + | |
110 | +# Or expose a specific path with HTTPS-only: | |
111 | +sudo tailscale funnel --https=443 http://localhost:3000 | |
112 | +``` | |
113 | + | |
114 | +You will get a public URL like `https://nas-hostname.tail-net.ts.net`. | |
115 | +Funnel enforces HTTPS; you do not need to manage certificates. | |
116 | + | |
117 | +### Step 1.3 — Lock the funnel to your hostname only | |
118 | + | |
119 | +By default, Funnel exposes the service to the public. You can restrict | |
120 | +it to your account with `tailscale set --operator`: | |
121 | + | |
122 | +```bash | |
123 | +sudo tailscale set --operator=$USER | |
124 | +``` | |
125 | + | |
126 | +For ACLs (which users in your tailnet can enable Funnel), see the | |
127 | +[Tailscale ACL docs](https://tailscale.com/kb/1018/acls/). | |
128 | + | |
129 | +### Step 1.4 — Persistent funnel | |
130 | + | |
131 | +`tailscale funnel` does not persist across reboots by itself. To | |
132 | +auto-start it on boot, create a small systemd unit or add the command | |
133 | +to your NAS's startup scripts. | |
134 | + | |
135 | +For Synology DSM, use **Task Scheduler → Triggered task → Boot-up** and | |
136 | +run: | |
137 | + | |
138 | +```bash | |
139 | +sudo tailscale funnel 3000 on | |
140 | +``` | |
141 | + | |
142 | +For TrueNAS SCALE, configure the Tailscale app's "Post-Start" command | |
143 | +in the app config. | |
144 | + | |
145 | +### Step 1.5 — OpenCodeHub environment | |
146 | + | |
147 | +Set the public URL in `.env`: | |
148 | + | |
149 | +```env | |
150 | +SITE_URL=https://nas-hostname.tail-net.ts.net | |
151 | +TRUSTED_PROXIES=127.0.0.1,100.64.0.0/10 # Tailscale CGNAT range | |
152 | +``` | |
153 | + | |
154 | +Tailscale's edge IPs all live in `100.64.0.0/10` (CGNAT). Adding that | |
155 | +to `TRUSTED_PROXIES` lets OpenCodeHub read the real client IP from | |
156 | +`X-Forwarded-For`. | |
157 | + | |
158 | +### Step 1.6 — Verify | |
159 | + | |
160 | +```bash | |
161 | +curl -sI https://nas-hostname.tail-net.ts.net | |
162 | +# HTTP/2 200 | |
163 | +# server: Caddy (Tailscale's edge) | |
164 | +``` | |
165 | + | |
166 | +Open the URL in a browser; you should land on the OpenCodeHub login | |
167 | +page. `git clone https://nas-hostname.tail-net.ts.net/<owner>/<repo>.git` | |
168 | +should also work. | |
169 | + | |
170 | +--- | |
171 | + | |
172 | +## 2. Cloudflare Tunnel | |
173 | + | |
174 | +[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) | |
175 | +uses a lightweight daemon (`cloudflared`) running on your NAS to | |
176 | +establish an outbound tunnel to Cloudflare's edge. Cloudflare | |
177 | +terminates TLS and proxies requests to your service. | |
178 | + | |
179 | +### Why Cloudflare Tunnel | |
180 | + | |
181 | +- Free, unlimited tunnels. | |
182 | +- Use any hostname you own on Cloudflare (or bring a partial-setup | |
183 | + domain). | |
184 | +- Edge caching, rate limiting, WAF rules, analytics, all available | |
185 | + out-of-the-box. | |
186 | +- No port forwarding, no public IP required. | |
187 | +- Zero-trust access controls (Cloudflare Access) for | |
188 | + password-protecting `/admin` paths. | |
189 | +- Production-grade — same plumbing Cloudflare uses for its own apps. | |
190 | + | |
191 | +### Step 2.1 — Add your domain to Cloudflare | |
192 | + | |
193 | +If you have not already, sign up at [dash.cloudflare.com](https://dash.cloudflare.com/), | |
194 | +add your domain (`git.example.com`), and let Cloudflare manage its | |
195 | +DNS. Free tier is enough. | |
196 | + | |
197 | +### Step 2.2 — Create the tunnel | |
198 | + | |
199 | +In the Cloudflare Zero Trust dashboard: | |
200 | + | |
201 | +1. **Networks → Tunnels → Create a tunnel** | |
202 | +2. Pick a name (e.g. `opencodehub-home`). | |
203 | +3. Cloudflare shows you a `cloudflared` install command and a token | |
204 | + that looks like: | |
205 | + | |
206 | + ``` | |
207 | + eyJhIjoixxxxxxxxxxxxx... # ~64 bytes, base64 | |
208 | + ``` | |
209 | + | |
210 | + Save this token; you will pass it as `TUNNEL_TOKEN`. | |
211 | + | |
212 | +Alternatively, you can use the CLI: | |
213 | + | |
214 | +```bash | |
215 | +cloudflared tunnel login # opens a browser | |
216 | +cloudflared tunnel create opencodehub-home | |
217 | +cloudflared tunnel token opencodehub-home | |
218 | +``` | |
219 | + | |
220 | +### Step 2.3 — Run `cloudflared` on the NAS | |
221 | + | |
222 | +#### Option A — Docker (recommended on Synology / QNAP / generic) | |
223 | + | |
224 | +Create a `docker-compose.cloudflared.yml` on the NAS: | |
225 | + | |
226 | +```yaml | |
227 | +services: | |
228 | + cloudflared: | |
229 | + image: cloudflare/cloudflared:2024.12.2 | |
230 | + container_name: cloudflared | |
231 | + restart: unless-stopped | |
232 | + command: tunnel --no-autoupdate run | |
233 | + environment: | |
234 | + - TUNNEL_TOKEN=${TUNNEL_TOKEN} | |
235 | + # Uncomment if you want host networking (lets cloudflared reach | |
236 | + # services on localhost without the docker bridge): | |
237 | + # network_mode: host | |
238 | +``` | |
239 | + | |
240 | +```bash | |
241 | +echo "TUNNEL_TOKEN=eyJhIjoixxxxxx..." > .env.cloudflared | |
242 | +docker compose -f docker-compose.cloudflared.yml --env-file .env.cloudflared up -d | |
243 | +``` | |
244 | + | |
245 | +#### Option B — Bare-metal / systemd | |
246 | + | |
247 | +```bash | |
248 | +curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | |
249 | + | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null | |
250 | +echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \ | |
251 | + | sudo tee /etc/apt/sources.list.d/cloudflared.list | |
252 | +sudo apt update && sudo apt install -y cloudflared | |
253 | +sudo cloudflared service install <TUNNEL_TOKEN> | |
254 | +``` | |
255 | + | |
256 | +#### Option C — TrueNAS SCALE | |
257 | + | |
258 | +Install the **Cloudflare Tunnel** community app, paste the tunnel | |
259 | +token in the app config, and start it. | |
260 | + | |
261 | +### Step 2.4 — Route a public hostname to OpenCodeHub | |
262 | + | |
263 | +In the Cloudflare dashboard → your tunnel → **Public Hostname**: | |
264 | + | |
265 | +| Setting | Value | | |
266 | +|---|---| | |
267 | +| Subdomain | `git` (or anything) | | |
268 | +| Domain | `example.com` | | |
269 | +| **Service** | Type: `HTTP`, URL: `opencodehub:3000` (or `localhost:3000` if `cloudflared` is host-networking) | | |
270 | + | |
271 | +If you used Option A (Docker bridge network), `opencodehub` must be the | |
272 | +service name in your OpenCodeHub `docker-compose.yml`. If you used | |
273 | +host networking, use `localhost:3000`. | |
274 | + | |
275 | +Save. After a few seconds, `https://git.example.com` should reach your | |
276 | +OpenCodeHub instance. | |
277 | + | |
278 | +### Step 2.5 — OpenCodeHub environment | |
279 | + | |
280 | +```env | |
281 | +SITE_URL=https://git.example.com | |
282 | +TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12 # add 100.64.0.0/10 if also using Tailscale | |
283 | +``` | |
284 | + | |
285 | +Cloudflare's edge IPs are public; the requests arriving at your NAS | |
286 | +come from the `cloudflared` daemon, which is local. So `127.0.0.1` in | |
287 | +`TRUSTED_PROXIES` is sufficient. Cloudflare's real client IP is in | |
288 | +`CF-Connecting-IP`; OpenCodeHub's middleware reads | |
289 | +`X-Forwarded-For` / `CF-Connecting-IP` to set the real client IP. | |
290 | + | |
291 | +### Step 2.6 — Optional: password-protect `/admin` with Cloudflare Access | |
292 | + | |
293 | +1. **Access → Applications → Add an application → Self-hosted** | |
294 | +2. Name: `OpenCodeHub admin` | |
295 | +3. Domain: `git.example.com/admin*` | |
296 | +4. Policy: allow only your email / a specific group. | |
297 | + | |
298 | +Now reaching the admin UI requires a one-time code from Cloudflare — | |
299 | +no OpenCodeHub credentials are needed for the attacker, but they | |
300 | +cannot reach the page at all. | |
301 | + | |
302 | +### Step 2.7 — Verify | |
303 | + | |
304 | +```bash | |
305 | +curl -sI https://git.example.com | |
306 | +# HTTP/2 200 | |
307 | +# server: cloudflare | |
308 | +# cf-ray: ... | |
309 | +``` | |
310 | + | |
311 | +And test git: | |
312 | + | |
313 | +```bash | |
314 | +git clone https://git.example.com/<owner>/<repo>.git | |
315 | +``` | |
316 | + | |
317 | +--- | |
318 | + | |
319 | +## 3. Hardening checklist | |
320 | + | |
321 | +Once your instance is reachable from the internet, lock it down: | |
322 | + | |
323 | +1. **Set strong secrets.** Rotate `JWT_SECRET`, `SESSION_SECRET`, | |
324 | + `INTERNAL_HOOK_SECRET`, and `RUNNER_SECRET` with | |
325 | + `openssl rand -hex 32`. Never reuse a value across environments. | |
326 | +2. **Enable rate limiting** with `RATE_LIMIT_ENABLED=true` and set | |
327 | + `RATE_LIMIT_REDIS_URL` to a Redis (or Upstash) instance — without | |
328 | + Redis, rate-limit state is per-process and bypassable by horizontal | |
329 | + scale-out. | |
330 | +3. **Put the database behind the same tunnel.** PostgreSQL, Redis, and | |
331 | + the MinIO S3 endpoint should **not** be exposed on the public | |
332 | + internet. If you must use a public Postgres, require SSL and a | |
333 | + strong password. | |
334 | +4. **SSH git is optional.** Tailscale Funnel and Cloudflare Tunnel | |
335 | + expose HTTP/S only. If you need SSH git (`git@...`), expose a | |
336 | + separate Tailscale node (no Funnel) and have your devs join the | |
337 | + tailnet, **or** terminate SSH at the Cloudflare edge with | |
338 | + [Cloudflare Spectrum](https://developers.cloudflare.com/spectrum/). | |
339 | +5. **Set `TRUSTED_PROXIES` correctly** — see the two example values | |
340 | + above. Without this, attackers can spoof their client IP by | |
341 | + sending `X-Forwarded-For` themselves and bypass IP-based rate | |
342 | + limits. | |
343 | +6. **Enable 2FA** for every account (especially admins). OpenCodeHub | |
344 | + supports TOTP-based 2FA in user settings. | |
345 | +7. **Back up the storage path and database.** Even with S3-compatible | |
346 | + storage, the SQLite/Postgres database and `.env` need offsite | |
347 | + backups. `scripts/sync-storage.ts` will mirror the storage root to | |
348 | + a second bucket; `pg_dump` (or `litestream` for SQLite) handles the | |
349 | + database. | |
350 | +8. **Monitor.** Subscribe to the `/api/health` endpoint with a free | |
351 | + uptime monitor (UptimeRobot, Healthchecks.io, BetterStack). Tailscale | |
352 | + and Cloudflare both expose access logs you can ship to a SIEM. | |
353 | + | |
354 | +--- | |
355 | + | |
356 | +## 4. When to use which method | |
357 | + | |
358 | +- **Pick Tailscale Funnel** if you already use Tailscale for | |
359 | + other services on your home network, want zero DNS hassle, and are | |
360 | + happy with a `*.ts.net` URL (or one custom domain). | |
361 | +- **Pick Cloudflare Tunnel** if you need a real custom domain | |
362 | + (`git.example.com`), want Cloudflare's edge features (caching, | |
363 | + WAF, rate limiting, Access), or expect any kind of public traffic. | |
364 | +- **Pick plain reverse proxy** (`deploy-nas.md`) if you have a | |
365 | + static public IP, are comfortable opening ports 80/443, and want to | |
366 | + avoid the third-party dependency. | |
367 | + | |
368 | +All three are production-acceptable; the difference is operational, | |
369 | +not security. Tailscale Funnel and Cloudflare Tunnel have the edge | |
370 | +on security because they do not require a publicly routable address | |
371 | +on the NAS itself. | |
372 | + | |
373 | +--- | |
374 | + | |
375 | +## 5. Troubleshooting | |
376 | + | |
377 | +### "TLS handshake failed" from your domain | |
378 | + | |
379 | +- Confirm the tunnel daemon is running: | |
380 | + - Tailscale: `tailscale status` shows the node as **active** and | |
381 | + `tailscale funnel status` shows the port. | |
382 | + - Cloudflare: the tunnel status is **HEALTHY** in the Zero Trust | |
383 | + dashboard. | |
384 | +- Confirm OpenCodeHub is reachable from the tunnel's network namespace: | |
385 | + - Tailscale (host networking): `curl http://localhost:3000` | |
386 | + - Cloudflare (Docker bridge): `docker exec cloudflared wget -qO- http://opencodehub:3000/` | |
387 | + | |
388 | +### Real client IP is wrong | |
389 | + | |
390 | +`TRUSTED_PROXIES` is not set, or it does not include the proxy IP. | |
391 | +OpenCodeHub's `src/middleware.ts` and `src/middleware/rate-limit.ts` | |
392 | +only honour `X-Forwarded-For` from IPs in `TRUSTED_PROXIES`. | |
393 | + | |
394 | +### "git push" hangs | |
395 | + | |
396 | +Tailscale Funnel and Cloudflare Tunnel both have a default timeout of | |
397 | +100 seconds per request. Large initial pushes that take longer than | |
398 | +that will time out. Solutions: | |
399 | +- Use `git push --depth=...` and shallow-clone for the first push. | |
400 | +- Run `git gc` on the local clone before pushing. | |
401 | +- For Cloudflare: configure a higher timeout under | |
402 | + **Tunnels → your tunnel → Timeout**. | |
403 | + | |
404 | +### SSH git does not work | |
405 | + | |
406 | +Tailscale Funnel does not support arbitrary TCP — only HTTP/S. For | |
407 | +SSH, either: | |
408 | +- Have your devs install Tailscale on their machines and SSH directly | |
409 | + to the NAS via the tailnet (`ssh user@nas-hostname`), **or** | |
410 | +- Use Cloudflare Spectrum (paid) to proxy TCP/22 to the NAS. | |
411 | + | |
412 | +### Local network is slow after enabling the tunnel | |
413 | + | |
414 | +The tunnel daemon proxies every request through the edge, so local | |
415 | +LAN clients may see higher latency. For LAN use, add a local DNS | |
416 | +record (`git.example.com` → `192.168.x.y`) and let LAN clients hit | |
417 | +OpenCodeHub directly. | |
418 | + | |
419 | +--- | |
420 | + | |
421 | +## 6. See also | |
422 | + | |
423 | +- [deploy-nas.md](./deploy-nas.md) — full NAS deployment guide | |
424 | + (Docker, storage, ssh, etc.). | |
425 | +- [storage-adapters.md](../guides/storage-adapters.md) — pick a | |
426 | + storage backend for your repositories. | |
427 | +- [security.md](./security.md) — accepted vulnerabilities, secret | |
428 | + rotation, audit log review. | |
429 | +- [Tailscale Funnel docs](https://tailscale.com/kb/1223/funnel/) | |
430 | +- [Cloudflare Tunnel docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) | |
0 | 431 | |
▼docs-site/src/content/docs/getting-started/installation.md
+3-2
| @@ -59,8 +59,9 @@ DATABASE_URL=postgresql://opencodehub:securepassword@postgres:5432/opencodehub | ||
59 | 59 | STORAGE_TYPE=s3 |
60 | 60 | STORAGE_BUCKET=my-git-bucket |
61 | 61 | STORAGE_REGION=us-east-1 |
62 | -S3_ACCESS_KEY=... | |
63 | -S3_SECRET_KEY=... | |
62 | +STORAGE_ACCESS_KEY_ID=... | |
63 | +STORAGE_SECRET_ACCESS_KEY=... | |
64 | +# STORAGE_ENDPOINT=https://<account>.r2.cloudflarestorage.com # set for non-AWS vendors | |
64 | 65 | ``` |
65 | 66 | |
66 | 67 | ### 4. Start Services |
67 | 68 | |
▼docs-site/src/content/docs/guides/storage-adapters.md
+135-62
| @@ -2,101 +2,182 @@ | ||
2 | 2 | title: "Storage Adapters" |
3 | 3 | --- |
4 | 4 | |
5 | -OpenCodeHub supports a pluggable storage system, allowing you to store repository data (git objects, LFS files, artifacts) on various backends. | |
6 | - | |
7 | -## Supported Adapters | |
8 | - | |
9 | -- **Local Filesystem**: Default, stores data on the server's disk. | |
10 | -- **S3 Compatible**: AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces. | |
11 | -- **Google Drive**: Ideal for personal/low-cost deployments. | |
12 | -- **Azure Blob Storage**: Microsoft Azure storage. | |
5 | +OpenCodeHub stores git objects, LFS files, package-registry blobs (npm/OCI), | |
6 | +and any artifacts produced by the platform on a pluggable storage backend. | |
7 | +Two backends are supported: | |
8 | + | |
9 | +- **`local`** — the server's filesystem. The default. Suitable for | |
10 | + single-host deployments and for development. | |
11 | +- **`s3`** — any S3-compatible object store. The same code path serves | |
12 | + AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, Wasabi, | |
13 | + Backblaze B2, and any other S3-v4 implementation. | |
14 | + | |
15 | +> **Removed backends.** Previous releases also supported Google Drive, | |
16 | +> Microsoft OneDrive, Dropbox, FTP, Google Cloud Storage, Azure Blob | |
17 | +> Storage, and an rclone-as-adapter option. They were removed because | |
18 | +> none of them support the S3-style multipart semantics OpenCodeHub | |
19 | +> needs for efficient large-blob operations; rclone as a backup target | |
20 | +> remains as a separate, optional utility (`/api/admin/sync`, | |
21 | +> `scripts/sync-storage.ts`). | |
13 | 22 | |
14 | 23 | --- |
15 | 24 | |
16 | -## 📂 Local Storage (Default) | |
17 | - | |
18 | -Data is stored in the `data/` directory relative to the application root. | |
25 | +## 📂 Local storage (default) | |
19 | 26 | |
20 | -**Configuration:** | |
27 | +Data is written under the directory pointed to by `STORAGE_PATH` | |
28 | +(default `./data/storage`). A `.meta` sidecar file is written next to | |
29 | +each uploaded blob to persist content-type metadata. | |
21 | 30 | |
22 | 31 | ```bash |
23 | 32 | STORAGE_TYPE=local |
24 | -STORAGE_PATH=./data/storage # Optional, default is ./data | |
33 | +STORAGE_PATH=./data/storage | |
25 | 34 | ``` |
26 | 35 | |
36 | +**When to choose local:** single-server deployments, self-hosting on a | |
37 | +home server or NAS, development, and CI runners that do not need | |
38 | +distributed blob storage. | |
39 | + | |
27 | 40 | --- |
28 | 41 | |
29 | -## ☁️ S3 Compatible Storage | |
42 | +## ☁️ S3-compatible storage | |
43 | + | |
44 | +The `s3` driver speaks the S3 v4 API. Any vendor that implements it | |
45 | +works by setting `STORAGE_ENDPOINT` to the vendor's endpoint URL and | |
46 | +`STORAGE_REGION` to its documented region string. Path-style addressing | |
47 | +is enabled automatically when an endpoint is provided, which is required | |
48 | +by MinIO, Garage, SeaweedFS, and most self-hosted stacks. | |
49 | + | |
50 | +### Common configuration | |
51 | + | |
52 | +```bash | |
53 | +STORAGE_TYPE=s3 | |
54 | +STORAGE_BUCKET=opencodehub | |
55 | +STORAGE_REGION=us-east-1 | |
56 | +STORAGE_ENDPOINT= # leave empty for AWS S3 | |
57 | +STORAGE_ACCESS_KEY_ID=... | |
58 | +STORAGE_SECRET_ACCESS_KEY=... | |
59 | +``` | |
30 | 60 | |
31 | -Store data in any S3-compatible bucket. This is recommended for production scalablity. | |
61 | +### Preset configurations | |
32 | 62 | |
33 | -**Configuration:** | |
63 | +#### AWS S3 (managed) | |
34 | 64 | |
35 | 65 | ```bash |
36 | 66 | STORAGE_TYPE=s3 |
37 | -STORAGE_BUCKET=my-opencodehub-bucket | |
38 | -STORAGE_REGION=us-east-1 # or auto | |
39 | -STORAGE_ENDPOINT=https://s3.amazonaws.com # or your custom endpoint | |
40 | -S3_ACCESS_KEY=your-access-key | |
41 | -S3_SECRET_KEY=your-secret-key | |
67 | +STORAGE_BUCKET=opencodehub | |
68 | +STORAGE_REGION=us-east-1 | |
69 | +# STORAGE_ENDPOINT left empty | |
70 | +STORAGE_ACCESS_KEY_ID=AKIA... | |
71 | +STORAGE_SECRET_ACCESS_KEY=... | |
42 | 72 | ``` |
43 | 73 | |
44 | -### Examples | |
74 | +Create the bucket and an IAM user with `s3:ListBucket`, `s3:GetObject`, | |
75 | +`s3:PutObject`, `s3:DeleteObject`, and `s3:GetObjectVersion` | |
76 | +permissions. Enable bucket versioning for cheap backups. | |
77 | + | |
78 | +#### MinIO (self-hosted) | |
45 | 79 | |
46 | -**MinIO (Self-hosted):** | |
80 | +MinIO is the most popular S3-compatible server; it works as a drop-in | |
81 | +replacement for AWS S3 and is easy to run on a NAS, home server, or | |
82 | +Kubernetes cluster. | |
47 | 83 | |
48 | 84 | ```bash |
49 | -STORAGE_ENDPOINT=http://minio:9000 | |
85 | +STORAGE_TYPE=s3 | |
86 | +STORAGE_BUCKET=opencodehub | |
50 | 87 | STORAGE_REGION=us-east-1 |
51 | -S3_FORCE_PATH_STYLE=true | |
88 | +STORAGE_ENDPOINT=http://minio.local:9000 | |
89 | +STORAGE_ACCESS_KEY_ID=minioadmin | |
90 | +STORAGE_SECRET_ACCESS_KEY=minioadmin | |
91 | +``` | |
92 | + | |
93 | +The bundled `docker-compose.yml` includes a MinIO service under the | |
94 | +`with-minio` profile: | |
95 | + | |
96 | +```bash | |
97 | +docker compose --profile with-minio up -d | |
52 | 98 | ``` |
53 | 99 | |
54 | -**Cloudflare R2:** | |
100 | +The MinIO web console is exposed on `:9001`; create the bucket | |
101 | +`opencodehub` and an access key before starting the app. | |
102 | + | |
103 | +#### Cloudflare R2 | |
104 | + | |
105 | +R2 is S3-compatible, has zero egress fees, and is well-suited to | |
106 | +self-hosters who already use Cloudflare for DNS / Tunnel. | |
55 | 107 | |
56 | 108 | ```bash |
57 | -STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com | |
109 | +STORAGE_TYPE=s3 | |
110 | +STORAGE_BUCKET=opencodehub | |
58 | 111 | STORAGE_REGION=auto |
112 | +STORAGE_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com | |
113 | +STORAGE_ACCESS_KEY_ID=... | |
114 | +STORAGE_SECRET_ACCESS_KEY=... | |
59 | 115 | ``` |
60 | 116 | |
117 | +Create an R2 API token with **Object Read & Write** scope on the bucket | |
118 | +in the Cloudflare dashboard. | |
61 | 119 | |
62 | -## 🚗 Google Drive Stack | |
120 | +#### Garage (self-hosted, lightweight) | |
63 | 121 | |
64 | -This stack is ideal for cost-effective, serverless-style deployments where you want to minimize persistent volume usage. | |
122 | +[Garage](https://garagehq.deuxfleurs.fr/) is an S3-compatible | |
123 | +distributed object store designed for home labs and small clusters. | |
124 | +It's lighter than MinIO and well suited to multi-NAS setups. | |
65 | 125 | |
66 | -### Prerequisites | |
126 | +```bash | |
127 | +STORAGE_TYPE=s3 | |
128 | +STORAGE_BUCKET=opencodehub | |
129 | +STORAGE_REGION=garage | |
130 | +STORAGE_ENDPOINT=http://garage.local:3900 | |
131 | +STORAGE_ACCESS_KEY_ID=... | |
132 | +STORAGE_SECRET_ACCESS_KEY=... | |
133 | +``` | |
67 | 134 | |
68 | -1. **Google Cloud Project**: Enable **Google Drive API**. | |
69 | -2. **OAuth Credentials**: Create "Web Application" credentials. | |
70 | -3. **Refresh Token**: Obtain a long-lived refresh token (e.g., via OAuth Playground). | |
135 | +#### SeaweedFS (self-hosted, very lightweight) | |
71 | 136 | |
72 | -### Configuration | |
137 | +[SeaweedFS](https://github.com/seaweedfs/seaweedfs) is an S3-compatible | |
138 | +distributed object store optimised for small files; great for NAS | |
139 | +deployments where every byte counts. | |
73 | 140 | |
74 | 141 | ```bash |
75 | -STORAGE_TYPE=gdrive | |
76 | -GOOGLE_CLIENT_ID=your-client-id | |
77 | -GOOGLE_CLIENT_SECRET=your-client-secret | |
78 | -GOOGLE_REFRESH_TOKEN=your-refresh-token | |
79 | -GOOGLE_FOLDER_ID=your-folder-id | |
142 | +STORAGE_TYPE=s3 | |
143 | +STORAGE_BUCKET=opencodehub | |
144 | +STORAGE_REGION=us-east-1 | |
145 | +STORAGE_ENDPOINT=http://seaweedfs.local:8333 | |
146 | +STORAGE_ACCESS_KEY_ID=... | |
147 | +STORAGE_SECRET_ACCESS_KEY=... | |
80 | 148 | ``` |
81 | 149 | |
82 | -**How to get Credentials:** | |
150 | +#### Ceph RGW (S3 gateway) | |
83 | 151 | |
84 | -1. Go to [Google Cloud Console](https://console.cloud.google.com). | |
85 | -2. Create a project -> Enable **Google Drive API**. | |
86 | -3. Create **OAuth Client ID**. | |
87 | -4. Get Refresh Token via [OAuth Playground](https://developers.google.com/oauthplayground) with scope `https://www.googleapis.com/auth/drive.file`. | |
152 | +If you already run Ceph for block or file storage, the RADOS Gateway | |
153 | +provides a fully S3-compatible HTTP frontend. | |
88 | 154 | |
155 | +```bash | |
156 | +STORAGE_TYPE=s3 | |
157 | +STORAGE_BUCKET=opencodehub | |
158 | +STORAGE_REGION=default | |
159 | +STORAGE_ENDPOINT=https://rgw.example.com | |
160 | +STORAGE_ACCESS_KEY_ID=... | |
161 | +STORAGE_SECRET_ACCESS_KEY=... | |
162 | +``` | |
89 | 163 | |
90 | -## 🔷 Azure Blob Storage | |
164 | +#### Wasabi, Backblaze B2, DigitalOcean Spaces | |
91 | 165 | |
92 | -**Configuration:** | |
166 | +All expose an S3-compatible endpoint. Use the S3 access key the vendor | |
167 | +issues and the endpoint URL it documents. | |
93 | 168 | |
94 | 169 | ```bash |
95 | -STORAGE_TYPE=azure | |
96 | -AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=... | |
97 | -AZURE_CONTAINER_NAME=opencodehub | |
170 | +# Wasabi | |
171 | +STORAGE_ENDPOINT=https://s3.wasabisys.com | |
172 | +STORAGE_REGION=us-east-1 | |
173 | + | |
174 | +# Backblaze B2 (S3-compatible API) | |
175 | +STORAGE_ENDPOINT=https://s3.<region>.backblazeb2.com | |
176 | +STORAGE_REGION=us-west-004 | |
177 | + | |
178 | +# DigitalOcean Spaces | |
179 | +STORAGE_ENDPOINT=https://<region>.digitaloceanspaces.com | |
180 | +STORAGE_REGION=nyc3 | |
98 | 181 | ``` |
99 | 182 | |
100 | 183 | --- |
| @@ -104,7 +185,7 @@ AZURE_CONTAINER_NAME=opencodehub | ||
104 | 185 | ## ⚠️ Performance Considerations |
105 | 186 | |
106 | 187 | :::caution[Important for Production] |
107 | -Object storage (S3/R2/MinIO) introduces **significant latency** compared to local disk storage. This is a fundamental architectural trade-off you should understand before deploying. | |
188 | +Object storage (S3/R2/MinIO) introduces **significant latency** compared to local disk storage for serving live git traffic. This is a fundamental architectural trade-off you should understand before deploying. | |
108 | 189 | ::: |
109 | 190 | |
110 | 191 | ### Latency Comparison |
| @@ -156,16 +237,6 @@ ENABLE_REPO_CACHE=true | ||
156 | 237 | CACHE_TTL_SECONDS=300 |
157 | 238 | ``` |
158 | 239 | |
159 | -#### 💰 Budget-Conscious (Google Drive) | |
160 | - | |
161 | -**Trade-off:** Slower but very cheap | |
162 | - | |
163 | -```bash | |
164 | -STORAGE_TYPE=gdrive | |
165 | -``` | |
166 | - | |
167 | -Google Drive has similar latency characteristics to S3. | |
168 | - | |
169 | 240 | ### How GitHub/GitLab Solve This |
170 | 241 | |
171 | 242 | | Company | Approach | |
| @@ -196,7 +267,7 @@ To migrate from local to S3: | ||
196 | 267 | tar -czf repos-backup.tar.gz data/repos/ |
197 | 268 | |
198 | 269 | # 2. Upload to S3 |
199 | -aws s3 sync data/repos/ s3://your-bucket/repos/ | |
270 | +aws s3 sync data/repos/ s3://your-bucket/repos/ --endpoint-url $STORAGE_ENDPOINT | |
200 | 271 | |
201 | 272 | # 3. Update .env |
202 | 273 | STORAGE_TYPE=s3 |
| @@ -210,7 +281,7 @@ To migrate from S3 to local: | ||
210 | 281 | |
211 | 282 | ```bash |
212 | 283 | # 1. Download from S3 |
213 | -aws s3 sync s3://your-bucket/repos/ data/repos/ | |
284 | +aws s3 sync s3://your-bucket/repos/ data/repos/ --endpoint-url $STORAGE_ENDPOINT | |
214 | 285 | |
215 | 286 | # 2. Update .env |
216 | 287 | STORAGE_TYPE=local |
217 | 288 | |
▼docs/administration/configuration.md
+12-7
| @@ -30,13 +30,18 @@ OpenCodeHub is configured exclusively via environment variables. | ||
30 | 30 | |
31 | 31 | | Variable | Description | Default | |
32 | 32 | |----------|-------------|---------| |
33 | -| `STORAGE_TYPE` | `local`, `s3`, `gdrive`, `azure` | `local` | | |
34 | -| `STORAGE_PATH` | Path for local storage | `./data` | | |
35 | -| `STORAGE_BUCKET` | Bucket name (S3/GCP) | | | |
36 | -| `STORAGE_REGION` | AWS Region | `us-east-1` | | |
37 | -| `STORAGE_ENDPOINT` | Custom S3 endpoint | | | |
38 | -| `S3_ACCESS_KEY` | AWS Access Key | | | |
39 | -| `S3_SECRET_KEY` | AWS Secret Key | | | |
33 | +| `STORAGE_TYPE` | `local` (filesystem) or `s3` (any S3-compatible object store) | `local` | | |
34 | +| `STORAGE_PATH` | Path for local storage | `./data/storage` | | |
35 | +| `STORAGE_BUCKET` | Bucket name (required for `s3`) | | | |
36 | +| `STORAGE_REGION` | S3 region | `us-east-1` | | |
37 | +| `STORAGE_ENDPOINT` | Custom S3 endpoint (MinIO, R2, Garage, SeaweedFS, Ceph RGW, etc.) | | | |
38 | +| `STORAGE_ACCESS_KEY_ID` | S3 access key | | | |
39 | +| `STORAGE_SECRET_ACCESS_KEY` | S3 secret key | | | |
40 | + | |
41 | +> Only `local` and `s3` storage backends are supported. Previous releases | |
42 | +> also supported `gdrive`, `azure`, `gcs`, `dropbox`, `onedrive`, `ftp`, and | |
43 | +> an rclone-as-adapter option; these have been removed in favour of | |
44 | +> S3-compatible object storage. See `docs/guides/storage-adapters.md`. | |
40 | 45 | |
41 | 46 | ## AI Review |
42 | 47 | |
43 | 48 | |
▼docs/administration/deploy-cpanel.md
+1-1
| @@ -150,7 +150,7 @@ SITE_URL=https://git.yourdomain.com | ||
150 | 150 | NODE_ENV=production |
151 | 151 | |
152 | 152 | # Storage (local is fine for single-server cPanel) |
153 | -STORAGE_DRIVER=local | |
153 | +STORAGE_TYPE=local | |
154 | 154 | STORAGE_PATH=./data/storage |
155 | 155 | REPOS_PATH=./data/repos |
156 | 156 | |
157 | 157 | |
▼docs/administration/deploy-cyberpanel.md
+1-1
| @@ -140,7 +140,7 @@ SITE_URL=https://git.yourdomain.com | ||
140 | 140 | NODE_ENV=production |
141 | 141 | |
142 | 142 | # Storage |
143 | -STORAGE_DRIVER=local | |
143 | +STORAGE_TYPE=local | |
144 | 144 | STORAGE_PATH=/data/storage |
145 | 145 | REPOS_PATH=/data/repos |
146 | 146 | SSH_HOST_KEY_PATH=/data/ssh/host_key |
147 | 147 | |
▼docs/administration/deployment.md
+12-14
| @@ -98,23 +98,21 @@ DATABASE_DRIVER=postgres | ||
98 | 98 | DATABASE_URL=postgresql://user:password@host:5432/opencodehub |
99 | 99 | ``` |
100 | 100 | |
101 | -**Storage (Use cloud storage, not local):** | |
101 | +**Storage (S3-compatible; works with AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, etc.):** | |
102 | 102 | ```bash |
103 | -STORAGE_DRIVER=s3 | |
104 | -S3_BUCKET=opencodehub-production | |
105 | -S3_REGION=us-east-1 | |
106 | -S3_ACCESS_KEY=<your-access-key> | |
107 | -S3_SECRET_KEY=<your-secret-key> | |
103 | +STORAGE_TYPE=s3 | |
104 | +STORAGE_BUCKET=opencodehub-production | |
105 | +STORAGE_REGION=us-east-1 | |
106 | +STORAGE_ACCESS_KEY_ID=<your-access-key> | |
107 | +STORAGE_SECRET_ACCESS_KEY=<your-secret-key> | |
108 | +# STORAGE_ENDPOINT=https://<account>.r2.cloudflarestorage.com # set for non-AWS vendors | |
108 | 109 | ``` |
109 | 110 | |
110 | -**Google Drive Stack:** | |
111 | -```bash | |
112 | -STORAGE_DRIVER=gdrive | |
113 | -GOOGLE_CLIENT_ID=... | |
114 | -GOOGLE_CLIENT_SECRET=... | |
115 | -GOOGLE_REFRESH_TOKEN=... | |
116 | -GOOGLE_FOLDER_ID=... | |
117 | -``` | |
111 | +> Only `local` and `s3` storage backends are supported. Google Drive, | |
112 | +> OneDrive, Dropbox, FTP, GCS, Azure, and rclone-as-adapter have been | |
113 | +> removed; use the `s3` driver with a vendor-specific `STORAGE_ENDPOINT` | |
114 | +> for object storage. See `docs/guides/storage-adapters.md` for the | |
115 | +> full list of compatible object stores. | |
118 | 116 | |
119 | 117 | **Optional but Recommended:** |
120 | 118 | ```bash |
121 | 119 | |
▼docs/administration/expose-nas.md
+431-0
| @@ -0,0 +1,431 @@ | ||
1 | +--- | |
2 | +title: "Exposing OpenCodeHub from a NAS or home server" | |
3 | +slug: "administration/expose-nas-home-server" | |
4 | +--- | |
5 | + | |
6 | +# Exposing OpenCodeHub from a NAS or home server | |
7 | + | |
8 | +> **Difficulty:** Intermediate | **Time:** 30-60 minutes | **Audience:** self-hosters on a NAS, home lab, Raspberry Pi, mini-PC, or VPS without a public IP | |
9 | + | |
10 | +This guide covers the two most popular ways to put an OpenCodeHub | |
11 | +instance running on a NAS or home server on the public internet **without | |
12 | +opening inbound ports on your router** and **without exposing your home | |
13 | +IP**: | |
14 | + | |
15 | +- **[Tailscale Funnel](https://tailscale.com/kb/1223/funnel/)** — WireGuard-based mesh VPN with a public ingress; the simplest option if you already use Tailscale. | |
16 | +- **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)** — an outbound-only `cloudflared` daemon that fronts your app with Cloudflare's edge. | |
17 | + | |
18 | +Both options work whether your ISP gives you a public IP, CGNAT, a | |
19 | +double-NAT, or a captive portal. Both terminate TLS at the edge | |
20 | +(Cloudflare's network or Tailscale's edge), so you do not need to | |
21 | +manage certificates or open `:80`/`:443` on your router. | |
22 | + | |
23 | +If you do have a public IP and a domain, the | |
24 | +[deploy-nas.md](./deploy-nas.md) guide covers the traditional | |
25 | +reverse-proxy + Let's Encrypt path. The two methods below are | |
26 | +preferred because they require **zero port forwarding**. | |
27 | + | |
28 | +--- | |
29 | + | |
30 | +## At a glance | |
31 | + | |
32 | +| Method | Outbound from home? | Inbound to home? | Free tier | Custom domain | Best for | | |
33 | +|---|---|---|---|---|---| | |
34 | +| **Tailscale Funnel** | yes (WireGuard) | none | yes (personal use) | yes, but only one hostname per funnel | Already using Tailscale; simple, single-user / small-team setups | | |
35 | +| **Cloudflare Tunnel** | yes (HTTP/2) | none | yes (unlimited tunnels) | yes, full DNS support | Public-facing deployments, larger teams, custom domains, edge caching | | |
36 | + | |
37 | +Both methods are **outbound-only** from your NAS. Your router's | |
38 | +firewall never sees an inbound connection to OpenCodeHub ports. | |
39 | + | |
40 | +--- | |
41 | + | |
42 | +## 0. Prerequisites | |
43 | + | |
44 | +Whatever exposure method you pick, you need: | |
45 | + | |
46 | +1. **A working OpenCodeHub instance** on your NAS, listening on a local port (default `3000`). | |
47 | +2. **A reachable hostname** (Tailscale or Cloudflare) pointed at the tunnel. | |
48 | +3. **A persistent `cloudflared` or `tailscaled`** process — running on the NAS, in Docker, or as a system service. | |
49 | +4. **Trusted Proxies** correctly set in OpenCodeHub (so the real client IP is preserved and rate limits are not bypassed). See `TRUSTED_PROXIES` in `.env.example`. | |
50 | + | |
51 | +> If you do not yet have OpenCodeHub running, see | |
52 | +> [deploy-nas.md](./deploy-nas.md) for the Docker setup first. | |
53 | + | |
54 | +--- | |
55 | + | |
56 | +## 1. Tailscale Funnel | |
57 | + | |
58 | +[Tailscale](https://tailscale.com/) is a WireGuard-based mesh VPN. | |
59 | +**Tailscale Funnel** is the public-ingress feature: it lets you expose | |
60 | +a service running on any node in your tailnet to the public internet | |
61 | +through Tailscale's edge, using a stable HTTPS URL. | |
62 | + | |
63 | +### Why Tailscale Funnel | |
64 | + | |
65 | +- Single binary, single auth key. | |
66 | +- TLS terminated at Tailscale's edge — your NAS never sees the public | |
67 | + traffic in the clear. | |
68 | +- Stable hostname: `https://<node-name>.<tailnet>.ts.net`. | |
69 | +- No port forwarding, no firewall changes, no certificate management. | |
70 | +- Works on CGNAT, mobile broadband, hotel Wi-Fi. | |
71 | + | |
72 | +### Step 1.1 — Install Tailscale on the NAS | |
73 | + | |
74 | +On the NAS (or in a container with `--net=host` and `tailscaled` running | |
75 | +on the host), install Tailscale: | |
76 | + | |
77 | +```bash | |
78 | +# Synology DSM 7: install from Package Center (community package), or: | |
79 | +curl -fsSL https://tailscale.com/install.sh | sh | |
80 | + | |
81 | +# TrueNAS SCALE: install the TrueNAS Tailscale app from the catalog | |
82 | +# QNAP: install via Container Station as a Linux container | |
83 | +# Generic Linux / Raspberry Pi OS: | |
84 | +curl -fsSL https://tailscale.com/install.sh | sh | |
85 | +``` | |
86 | + | |
87 | +Authenticate: | |
88 | + | |
89 | +```bash | |
90 | +sudo tailscale up | |
91 | +``` | |
92 | + | |
93 | +Follow the URL it prints, sign in, and approve the device. Confirm | |
94 | +with: | |
95 | + | |
96 | +```bash | |
97 | +tailscale status # should show your NAS as a node | |
98 | +tailscale ip -4 # 100.x.y.z | |
99 | +``` | |
100 | + | |
101 | +### Step 1.2 — Expose OpenCodeHub via Funnel | |
102 | + | |
103 | +```bash | |
104 | +# Tailscale Funnel is currently in beta and must be enabled per tailnet | |
105 | +# in the Tailscale admin console: https://login.tailscale.com/admin | |
106 | +# (turn on the "Funnel" feature flag). | |
107 | +# | |
108 | +# Then on the NAS, expose the port OpenCodeHub listens on (default 3000): | |
109 | +sudo tailscale funnel 3000 on | |
110 | + | |
111 | +# Or expose a specific path with HTTPS-only: | |
112 | +sudo tailscale funnel --https=443 http://localhost:3000 | |
113 | +``` | |
114 | + | |
115 | +You will get a public URL like `https://nas-hostname.tail-net.ts.net`. | |
116 | +Funnel enforces HTTPS; you do not need to manage certificates. | |
117 | + | |
118 | +### Step 1.3 — Lock the funnel to your hostname only | |
119 | + | |
120 | +By default, Funnel exposes the service to the public. You can restrict | |
121 | +it to your account with `tailscale set --operator`: | |
122 | + | |
123 | +```bash | |
124 | +sudo tailscale set --operator=$USER | |
125 | +``` | |
126 | + | |
127 | +For ACLs (which users in your tailnet can enable Funnel), see the | |
128 | +[Tailscale ACL docs](https://tailscale.com/kb/1018/acls/). | |
129 | + | |
130 | +### Step 1.4 — Persistent funnel | |
131 | + | |
132 | +`tailscale funnel` does not persist across reboots by itself. To | |
133 | +auto-start it on boot, create a small systemd unit or add the command | |
134 | +to your NAS's startup scripts. | |
135 | + | |
136 | +For Synology DSM, use **Task Scheduler → Triggered task → Boot-up** and | |
137 | +run: | |
138 | + | |
139 | +```bash | |
140 | +sudo tailscale funnel 3000 on | |
141 | +``` | |
142 | + | |
143 | +For TrueNAS SCALE, configure the Tailscale app's "Post-Start" command | |
144 | +in the app config. | |
145 | + | |
146 | +### Step 1.5 — OpenCodeHub environment | |
147 | + | |
148 | +Set the public URL in `.env`: | |
149 | + | |
150 | +```env | |
151 | +SITE_URL=https://nas-hostname.tail-net.ts.net | |
152 | +TRUSTED_PROXIES=127.0.0.1,100.64.0.0/10 # Tailscale CGNAT range | |
153 | +``` | |
154 | + | |
155 | +Tailscale's edge IPs all live in `100.64.0.0/10` (CGNAT). Adding that | |
156 | +to `TRUSTED_PROXIES` lets OpenCodeHub read the real client IP from | |
157 | +`X-Forwarded-For`. | |
158 | + | |
159 | +### Step 1.6 — Verify | |
160 | + | |
161 | +```bash | |
162 | +curl -sI https://nas-hostname.tail-net.ts.net | |
163 | +# HTTP/2 200 | |
164 | +# server: Caddy (Tailscale's edge) | |
165 | +``` | |
166 | + | |
167 | +Open the URL in a browser; you should land on the OpenCodeHub login | |
168 | +page. `git clone https://nas-hostname.tail-net.ts.net/<owner>/<repo>.git` | |
169 | +should also work. | |
170 | + | |
171 | +--- | |
172 | + | |
173 | +## 2. Cloudflare Tunnel | |
174 | + | |
175 | +[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) | |
176 | +uses a lightweight daemon (`cloudflared`) running on your NAS to | |
177 | +establish an outbound tunnel to Cloudflare's edge. Cloudflare | |
178 | +terminates TLS and proxies requests to your service. | |
179 | + | |
180 | +### Why Cloudflare Tunnel | |
181 | + | |
182 | +- Free, unlimited tunnels. | |
183 | +- Use any hostname you own on Cloudflare (or bring a partial-setup | |
184 | + domain). | |
185 | +- Edge caching, rate limiting, WAF rules, analytics, all available | |
186 | + out-of-the-box. | |
187 | +- No port forwarding, no public IP required. | |
188 | +- Zero-trust access controls (Cloudflare Access) for | |
189 | + password-protecting `/admin` paths. | |
190 | +- Production-grade — same plumbing Cloudflare uses for its own apps. | |
191 | + | |
192 | +### Step 2.1 — Add your domain to Cloudflare | |
193 | + | |
194 | +If you have not already, sign up at [dash.cloudflare.com](https://dash.cloudflare.com/), | |
195 | +add your domain (`git.example.com`), and let Cloudflare manage its | |
196 | +DNS. Free tier is enough. | |
197 | + | |
198 | +### Step 2.2 — Create the tunnel | |
199 | + | |
200 | +In the Cloudflare Zero Trust dashboard: | |
201 | + | |
202 | +1. **Networks → Tunnels → Create a tunnel** | |
203 | +2. Pick a name (e.g. `opencodehub-home`). | |
204 | +3. Cloudflare shows you a `cloudflared` install command and a token | |
205 | + that looks like: | |
206 | + | |
207 | + ``` | |
208 | + eyJhIjoixxxxxxxxxxxxx... # ~64 bytes, base64 | |
209 | + ``` | |
210 | + | |
211 | + Save this token; you will pass it as `TUNNEL_TOKEN`. | |
212 | + | |
213 | +Alternatively, you can use the CLI: | |
214 | + | |
215 | +```bash | |
216 | +cloudflared tunnel login # opens a browser | |
217 | +cloudflared tunnel create opencodehub-home | |
218 | +cloudflared tunnel token opencodehub-home | |
219 | +``` | |
220 | + | |
221 | +### Step 2.3 — Run `cloudflared` on the NAS | |
222 | + | |
223 | +#### Option A — Docker (recommended on Synology / QNAP / generic) | |
224 | + | |
225 | +Create a `docker-compose.cloudflared.yml` on the NAS: | |
226 | + | |
227 | +```yaml | |
228 | +services: | |
229 | + cloudflared: | |
230 | + image: cloudflare/cloudflared:2024.12.2 | |
231 | + container_name: cloudflared | |
232 | + restart: unless-stopped | |
233 | + command: tunnel --no-autoupdate run | |
234 | + environment: | |
235 | + - TUNNEL_TOKEN=${TUNNEL_TOKEN} | |
236 | + # Uncomment if you want host networking (lets cloudflared reach | |
237 | + # services on localhost without the docker bridge): | |
238 | + # network_mode: host | |
239 | +``` | |
240 | + | |
241 | +```bash | |
242 | +echo "TUNNEL_TOKEN=eyJhIjoixxxxxx..." > .env.cloudflared | |
243 | +docker compose -f docker-compose.cloudflared.yml --env-file .env.cloudflared up -d | |
244 | +``` | |
245 | + | |
246 | +#### Option B — Bare-metal / systemd | |
247 | + | |
248 | +```bash | |
249 | +curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | |
250 | + | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null | |
251 | +echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \ | |
252 | + | sudo tee /etc/apt/sources.list.d/cloudflared.list | |
253 | +sudo apt update && sudo apt install -y cloudflared | |
254 | +sudo cloudflared service install <TUNNEL_TOKEN> | |
255 | +``` | |
256 | + | |
257 | +#### Option C — TrueNAS SCALE | |
258 | + | |
259 | +Install the **Cloudflare Tunnel** community app, paste the tunnel | |
260 | +token in the app config, and start it. | |
261 | + | |
262 | +### Step 2.4 — Route a public hostname to OpenCodeHub | |
263 | + | |
264 | +In the Cloudflare dashboard → your tunnel → **Public Hostname**: | |
265 | + | |
266 | +| Setting | Value | | |
267 | +|---|---| | |
268 | +| Subdomain | `git` (or anything) | | |
269 | +| Domain | `example.com` | | |
270 | +| **Service** | Type: `HTTP`, URL: `opencodehub:3000` (or `localhost:3000` if `cloudflared` is host-networking) | | |
271 | + | |
272 | +If you used Option A (Docker bridge network), `opencodehub` must be the | |
273 | +service name in your OpenCodeHub `docker-compose.yml`. If you used | |
274 | +host networking, use `localhost:3000`. | |
275 | + | |
276 | +Save. After a few seconds, `https://git.example.com` should reach your | |
277 | +OpenCodeHub instance. | |
278 | + | |
279 | +### Step 2.5 — OpenCodeHub environment | |
280 | + | |
281 | +```env | |
282 | +SITE_URL=https://git.example.com | |
283 | +TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12 # add 100.64.0.0/10 if also using Tailscale | |
284 | +``` | |
285 | + | |
286 | +Cloudflare's edge IPs are public; the requests arriving at your NAS | |
287 | +come from the `cloudflared` daemon, which is local. So `127.0.0.1` in | |
288 | +`TRUSTED_PROXIES` is sufficient. Cloudflare's real client IP is in | |
289 | +`CF-Connecting-IP`; OpenCodeHub's middleware reads | |
290 | +`X-Forwarded-For` / `CF-Connecting-IP` to set the real client IP. | |
291 | + | |
292 | +### Step 2.6 — Optional: password-protect `/admin` with Cloudflare Access | |
293 | + | |
294 | +1. **Access → Applications → Add an application → Self-hosted** | |
295 | +2. Name: `OpenCodeHub admin` | |
296 | +3. Domain: `git.example.com/admin*` | |
297 | +4. Policy: allow only your email / a specific group. | |
298 | + | |
299 | +Now reaching the admin UI requires a one-time code from Cloudflare — | |
300 | +no OpenCodeHub credentials are needed for the attacker, but they | |
301 | +cannot reach the page at all. | |
302 | + | |
303 | +### Step 2.7 — Verify | |
304 | + | |
305 | +```bash | |
306 | +curl -sI https://git.example.com | |
307 | +# HTTP/2 200 | |
308 | +# server: cloudflare | |
309 | +# cf-ray: ... | |
310 | +``` | |
311 | + | |
312 | +And test git: | |
313 | + | |
314 | +```bash | |
315 | +git clone https://git.example.com/<owner>/<repo>.git | |
316 | +``` | |
317 | + | |
318 | +--- | |
319 | + | |
320 | +## 3. Hardening checklist | |
321 | + | |
322 | +Once your instance is reachable from the internet, lock it down: | |
323 | + | |
324 | +1. **Set strong secrets.** Rotate `JWT_SECRET`, `SESSION_SECRET`, | |
325 | + `INTERNAL_HOOK_SECRET`, and `RUNNER_SECRET` with | |
326 | + `openssl rand -hex 32`. Never reuse a value across environments. | |
327 | +2. **Enable rate limiting** with `RATE_LIMIT_ENABLED=true` and set | |
328 | + `RATE_LIMIT_REDIS_URL` to a Redis (or Upstash) instance — without | |
329 | + Redis, rate-limit state is per-process and bypassable by horizontal | |
330 | + scale-out. | |
331 | +3. **Put the database behind the same tunnel.** PostgreSQL, Redis, and | |
332 | + the MinIO S3 endpoint should **not** be exposed on the public | |
333 | + internet. If you must use a public Postgres, require SSL and a | |
334 | + strong password. | |
335 | +4. **SSH git is optional.** Tailscale Funnel and Cloudflare Tunnel | |
336 | + expose HTTP/S only. If you need SSH git (`git@...`), expose a | |
337 | + separate Tailscale node (no Funnel) and have your devs join the | |
338 | + tailnet, **or** terminate SSH at the Cloudflare edge with | |
339 | + [Cloudflare Spectrum](https://developers.cloudflare.com/spectrum/). | |
340 | +5. **Set `TRUSTED_PROXIES` correctly** — see the two example values | |
341 | + above. Without this, attackers can spoof their client IP by | |
342 | + sending `X-Forwarded-For` themselves and bypass IP-based rate | |
343 | + limits. | |
344 | +6. **Enable 2FA** for every account (especially admins). OpenCodeHub | |
345 | + supports TOTP-based 2FA in user settings. | |
346 | +7. **Back up the storage path and database.** Even with S3-compatible | |
347 | + storage, the SQLite/Postgres database and `.env` need offsite | |
348 | + backups. `scripts/sync-storage.ts` will mirror the storage root to | |
349 | + a second bucket; `pg_dump` (or `litestream` for SQLite) handles the | |
350 | + database. | |
351 | +8. **Monitor.** Subscribe to the `/api/health` endpoint with a free | |
352 | + uptime monitor (UptimeRobot, Healthchecks.io, BetterStack). Tailscale | |
353 | + and Cloudflare both expose access logs you can ship to a SIEM. | |
354 | + | |
355 | +--- | |
356 | + | |
357 | +## 4. When to use which method | |
358 | + | |
359 | +- **Pick Tailscale Funnel** if you already use Tailscale for | |
360 | + other services on your home network, want zero DNS hassle, and are | |
361 | + happy with a `*.ts.net` URL (or one custom domain). | |
362 | +- **Pick Cloudflare Tunnel** if you need a real custom domain | |
363 | + (`git.example.com`), want Cloudflare's edge features (caching, | |
364 | + WAF, rate limiting, Access), or expect any kind of public traffic. | |
365 | +- **Pick plain reverse proxy** (`deploy-nas.md`) if you have a | |
366 | + static public IP, are comfortable opening ports 80/443, and want to | |
367 | + avoid the third-party dependency. | |
368 | + | |
369 | +All three are production-acceptable; the difference is operational, | |
370 | +not security. Tailscale Funnel and Cloudflare Tunnel have the edge | |
371 | +on security because they do not require a publicly routable address | |
372 | +on the NAS itself. | |
373 | + | |
374 | +--- | |
375 | + | |
376 | +## 5. Troubleshooting | |
377 | + | |
378 | +### "TLS handshake failed" from your domain | |
379 | + | |
380 | +- Confirm the tunnel daemon is running: | |
381 | + - Tailscale: `tailscale status` shows the node as **active** and | |
382 | + `tailscale funnel status` shows the port. | |
383 | + - Cloudflare: the tunnel status is **HEALTHY** in the Zero Trust | |
384 | + dashboard. | |
385 | +- Confirm OpenCodeHub is reachable from the tunnel's network namespace: | |
386 | + - Tailscale (host networking): `curl http://localhost:3000` | |
387 | + - Cloudflare (Docker bridge): `docker exec cloudflared wget -qO- http://opencodehub:3000/` | |
388 | + | |
389 | +### Real client IP is wrong | |
390 | + | |
391 | +`TRUSTED_PROXIES` is not set, or it does not include the proxy IP. | |
392 | +OpenCodeHub's `src/middleware.ts` and `src/middleware/rate-limit.ts` | |
393 | +only honour `X-Forwarded-For` from IPs in `TRUSTED_PROXIES`. | |
394 | + | |
395 | +### "git push" hangs | |
396 | + | |
397 | +Tailscale Funnel and Cloudflare Tunnel both have a default timeout of | |
398 | +100 seconds per request. Large initial pushes that take longer than | |
399 | +that will time out. Solutions: | |
400 | +- Use `git push --depth=...` and shallow-clone for the first push. | |
401 | +- Run `git gc` on the local clone before pushing. | |
402 | +- For Cloudflare: configure a higher timeout under | |
403 | + **Tunnels → your tunnel → Timeout**. | |
404 | + | |
405 | +### SSH git does not work | |
406 | + | |
407 | +Tailscale Funnel does not support arbitrary TCP — only HTTP/S. For | |
408 | +SSH, either: | |
409 | +- Have your devs install Tailscale on their machines and SSH directly | |
410 | + to the NAS via the tailnet (`ssh user@nas-hostname`), **or** | |
411 | +- Use Cloudflare Spectrum (paid) to proxy TCP/22 to the NAS. | |
412 | + | |
413 | +### Local network is slow after enabling the tunnel | |
414 | + | |
415 | +The tunnel daemon proxies every request through the edge, so local | |
416 | +LAN clients may see higher latency. For LAN use, add a local DNS | |
417 | +record (`git.example.com` → `192.168.x.y`) and let LAN clients hit | |
418 | +OpenCodeHub directly. | |
419 | + | |
420 | +--- | |
421 | + | |
422 | +## 6. See also | |
423 | + | |
424 | +- [deploy-nas.md](./deploy-nas.md) — full NAS deployment guide | |
425 | + (Docker, storage, ssh, etc.). | |
426 | +- [storage-adapters.md](../guides/storage-adapters.md) — pick a | |
427 | + storage backend for your repositories. | |
428 | +- [security.md](./security.md) — accepted vulnerabilities, secret | |
429 | + rotation, audit log review. | |
430 | +- [Tailscale Funnel docs](https://tailscale.com/kb/1223/funnel/) | |
431 | +- [Cloudflare Tunnel docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) | |
0 | 432 | |
▼docs/development/architecture.md
+17-17
| @@ -224,32 +224,32 @@ export function getDatabase() { | ||
224 | 224 | |
225 | 225 | ## Storage Layer |
226 | 226 | |
227 | -All blob storage goes through an abstract `StorageAdapter` class: | |
227 | +All blob storage goes through an abstract `StorageAdapter` class. Two | |
228 | +implementations are supported: | |
228 | 229 | |
229 | 230 | ``` |
230 | 231 | StorageAdapter (abstract) |
231 | 232 | ├── LocalStorageAdapter # Filesystem |
232 | -├── S3StorageAdapter # S3, MinIO, R2 | |
233 | -├── GCSStorageAdapter # Google Cloud Storage | |
234 | -├── AzureStorageAdapter # Azure Blob | |
235 | -├── GoogleDriveStorageAdapter # Google Drive | |
236 | -├── OneDriveStorageAdapter # Microsoft OneDrive | |
237 | -├── DropboxStorageAdapter # Dropbox | |
238 | -└── RcloneStorageAdapter # Any rclone remote | |
233 | +└── S3StorageAdapter # S3 + any S3-compatible object store | |
234 | + # (AWS S3, MinIO, Cloudflare R2, Garage, | |
235 | + # SeaweedFS, Ceph RGW, Wasabi, B2, ...) | |
239 | 236 | ``` |
240 | 237 | |
238 | +Previous releases also shipped GCS, Azure, Google Drive, OneDrive, | |
239 | +Dropbox, FTP, and rclone-as-adapter backends. These were removed in | |
240 | +favour of S3-compatible object storage, which all modern vendors | |
241 | +implement and which gives OpenCodeHub the multipart semantics it needs | |
242 | +for large-blob operations. rclone remains available as a separate | |
243 | +optional backup utility (`scripts/sync-storage.ts` + `/api/admin/sync`). | |
244 | + | |
241 | 245 | ### Configuration |
242 | 246 | |
243 | -Set `STORAGE_DRIVER` environment variable: | |
247 | +Set `STORAGE_TYPE` environment variable: | |
244 | 248 | |
245 | -| Driver | Required Env Vars | | |
246 | -|--------|-------------------| | |
247 | -| `local` | `STORAGE_PATH` | | |
248 | -| `s3` | `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | | |
249 | -| `gcs` | `GCS_BUCKET`, `GCS_PROJECT_ID`, `GCS_KEY_FILE` | | |
250 | -| `azure` | `AZURE_ACCOUNT_NAME`, `AZURE_ACCOUNT_KEY`, `AZURE_CONTAINER_NAME` | | |
251 | -| `gdrive` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | | |
252 | -| `rclone` | `STORAGE_RCLONE_REMOTE` | | |
249 | +| Driver | Required Env Vars | | |
250 | +|---------|-------------------| | |
251 | +| `local` | `STORAGE_PATH` (default `./data/storage`) | | |
252 | +| `s3` | `STORAGE_BUCKET`, `STORAGE_REGION`, `STORAGE_ACCESS_KEY_ID`, `STORAGE_SECRET_ACCESS_KEY`, optionally `STORAGE_ENDPOINT` (required for non-AWS) | | |
253 | 253 | |
254 | 254 | --- |
255 | 255 | |
256 | 256 | |
▼docs/getting-started/installation.md
+6-5
| @@ -53,11 +53,12 @@ PORT=3000 | ||
53 | 53 | DATABASE_URL=postgresql://opencodehub:securepassword@postgres:5432/opencodehub |
54 | 54 | |
55 | 55 | # Object Storage (Highly Recommended for Production) |
56 | -STORAGE_DRIVER=s3 | |
57 | -S3_BUCKET=my-git-bucket | |
58 | -S3_REGION=us-east-1 | |
59 | -S3_ACCESS_KEY=... | |
60 | -S3_SECRET_KEY=... | |
56 | +STORAGE_TYPE=s3 | |
57 | +STORAGE_BUCKET=my-git-bucket | |
58 | +STORAGE_REGION=us-east-1 | |
59 | +STORAGE_ACCESS_KEY_ID=... | |
60 | +STORAGE_SECRET_ACCESS_KEY=... | |
61 | +# STORAGE_ENDPOINT=https://<account>.r2.cloudflarestorage.com # set for non-AWS vendors | |
61 | 62 | ``` |
62 | 63 | |
63 | 64 | ### 4. Start Services |
64 | 65 | |
▼docs/guides/storage-adapters.md
+170-50
| @@ -1,103 +1,222 @@ | ||
1 | 1 | --- |
2 | -title: "Legacy: Storage Adapters" | |
3 | -slug: "legacy/guides/storage-adapters" | |
2 | +title: "Storage Adapters" | |
3 | +slug: "guides/storage-adapters" | |
4 | 4 | --- |
5 | 5 | |
6 | 6 | # Storage Adapters |
7 | 7 | |
8 | -OpenCodeHub supports a pluggable storage system, allowing you to store repository data (git objects, LFS files, artifacts) on various backends. | |
8 | +OpenCodeHub stores git objects, LFS files, package-registry blobs (npm/OCI), | |
9 | +and any artifacts produced by the platform on a pluggable storage backend. | |
10 | +Two backends are supported: | |
9 | 11 | |
10 | -## Supported Adapters | |
12 | +- **`local`** — the server's filesystem. The default. Suitable for | |
13 | + single-host deployments and for development. | |
14 | +- **`s3`** — any S3-compatible object store. The same code path serves | |
15 | + AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, Wasabi, | |
16 | + Backblaze B2, and any other S3-v4 implementation. | |
11 | 17 | |
12 | -- **Local Filesystem**: Default, stores data on the server's disk. | |
13 | -- **S3 Compatible**: AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces. | |
14 | -- **Google Drive**: Ideal for personal/low-cost deployments. | |
15 | -- **Azure Blob Storage**: Microsoft Azure storage. | |
18 | +> **Removed backends.** Previous releases also supported Google Drive, | |
19 | +> Microsoft OneDrive, Dropbox, FTP, Google Cloud Storage, Azure Blob | |
20 | +> Storage, and an rclone-as-adapter option. They were removed because | |
21 | +> none of them support the S3-style multipart semantics OpenCodeHub | |
22 | +> needs for efficient large-blob operations; rclone as a backup target | |
23 | +> remains as a separate, optional utility (`/api/admin/sync`, | |
24 | +> `scripts/sync-storage.ts`). | |
16 | 25 | |
17 | 26 | --- |
18 | 27 | |
19 | -## 📂 Local Storage (Default) | |
28 | +## 📂 Local storage (default) | |
20 | 29 | |
21 | -Data is stored in the `data/` directory relative to the application root. | |
22 | - | |
23 | -**Configuration:** | |
30 | +Data is written under the directory pointed to by `STORAGE_PATH` | |
31 | +(default `./data/storage`). A `.meta` sidecar file is written next to | |
32 | +each uploaded blob to persist content-type metadata. | |
24 | 33 | |
25 | 34 | ```env |
26 | 35 | STORAGE_TYPE=local |
27 | -STORAGE_PATH=./data/storage # Optional, default is ./data | |
36 | +STORAGE_PATH=./data/storage | |
28 | 37 | ``` |
29 | 38 | |
39 | +**When to choose local:** single-server deployments, self-hosting on a | |
40 | +home server or NAS, development, and CI runners that do not need | |
41 | +distributed blob storage. | |
42 | + | |
30 | 43 | --- |
31 | 44 | |
32 | -## ☁️ S3 Compatible Storage | |
45 | +## ☁️ S3-compatible storage | |
33 | 46 | |
34 | -Store data in any S3-compatible bucket. This is recommended for production scalablity. | |
47 | +The `s3` driver speaks the S3 v4 API. Any vendor that implements it | |
48 | +works by setting `STORAGE_ENDPOINT` to the vendor's endpoint URL and | |
49 | +`STORAGE_REGION` to its documented region string. Path-style addressing | |
50 | +is enabled automatically when an endpoint is provided, which is required | |
51 | +by MinIO, Garage, SeaweedFS, and most self-hosted stacks. | |
35 | 52 | |
36 | -**Configuration:** | |
53 | +### Common configuration | |
37 | 54 | |
38 | 55 | ```env |
39 | 56 | STORAGE_TYPE=s3 |
40 | -STORAGE_BUCKET=my-opencodehub-bucket | |
41 | -STORAGE_REGION=us-east-1 # or auto | |
42 | -STORAGE_ENDPOINT=https://s3.amazonaws.com # or your custom endpoint | |
43 | -S3_ACCESS_KEY=your-access-key | |
44 | -S3_SECRET_KEY=your-secret-key | |
57 | +STORAGE_BUCKET=opencodehub | |
58 | +STORAGE_REGION=us-east-1 | |
59 | +STORAGE_ENDPOINT= # leave empty for AWS S3 | |
60 | +STORAGE_ACCESS_KEY_ID=... | |
61 | +STORAGE_SECRET_ACCESS_KEY=... | |
45 | 62 | ``` |
46 | 63 | |
47 | -### Examples | |
64 | +### Preset configurations | |
48 | 65 | |
49 | -**MinIO (Self-hosted):** | |
66 | +#### AWS S3 (managed) | |
50 | 67 | |
51 | 68 | ```env |
52 | -STORAGE_ENDPOINT=http://minio:9000 | |
69 | +STORAGE_TYPE=s3 | |
70 | +STORAGE_BUCKET=opencodehub | |
53 | 71 | STORAGE_REGION=us-east-1 |
54 | -S3_FORCE_PATH_STYLE=true | |
72 | +# STORAGE_ENDPOINT left empty | |
73 | +STORAGE_ACCESS_KEY_ID=AKIA... | |
74 | +STORAGE_SECRET_ACCESS_KEY=... | |
55 | 75 | ``` |
56 | 76 | |
57 | -**Cloudflare R2:** | |
77 | +Create the bucket and an IAM user with `s3:ListBucket`, `s3:GetObject`, | |
78 | +`s3:PutObject`, `s3:DeleteObject`, and `s3:GetObjectVersion` | |
79 | +permissions. Enable bucket versioning for cheap backups. | |
80 | + | |
81 | +#### MinIO (self-hosted) | |
82 | + | |
83 | +MinIO is the most popular S3-compatible server; it works as a drop-in | |
84 | +replacement for AWS S3 and is easy to run on a NAS, home server, or | |
85 | +Kubernetes cluster. | |
58 | 86 | |
59 | 87 | ```env |
60 | -STORAGE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com | |
88 | +STORAGE_TYPE=s3 | |
89 | +STORAGE_BUCKET=opencodehub | |
90 | +STORAGE_REGION=us-east-1 | |
91 | +STORAGE_ENDPOINT=http://minio.local:9000 | |
92 | +STORAGE_ACCESS_KEY_ID=minioadmin | |
93 | +STORAGE_SECRET_ACCESS_KEY=minioadmin | |
94 | +``` | |
95 | + | |
96 | +The bundled `docker-compose.yml` includes a MinIO service under the | |
97 | +`with-minio` profile: | |
98 | + | |
99 | +```bash | |
100 | +docker compose --profile with-minio up -d | |
101 | +``` | |
102 | + | |
103 | +The MinIO web console is exposed on `:9001`; create the bucket | |
104 | +`opencodehub` and an access key before starting the app. | |
105 | + | |
106 | +#### Cloudflare R2 | |
107 | + | |
108 | +R2 is S3-compatible, has zero egress fees, and is well-suited to | |
109 | +self-hosters who already use Cloudflare for DNS / Tunnel. | |
110 | + | |
111 | +```env | |
112 | +STORAGE_TYPE=s3 | |
113 | +STORAGE_BUCKET=opencodehub | |
61 | 114 | STORAGE_REGION=auto |
115 | +STORAGE_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com | |
116 | +STORAGE_ACCESS_KEY_ID=... | |
117 | +STORAGE_SECRET_ACCESS_KEY=... | |
62 | 118 | ``` |
63 | 119 | |
120 | +Create an R2 API token with **Object Read & Write** scope on the bucket | |
121 | +in the Cloudflare dashboard. | |
122 | + | |
123 | +#### Garage (self-hosted, lightweight) | |
124 | + | |
125 | +[Garage](https://garagehq.deuxfleurs.fr/) is an S3-compatible | |
126 | +distributed object store designed for home labs and small clusters. | |
127 | +It's lighter than MinIO and well suited to multi-NAS setups. | |
64 | 128 | |
65 | -## 🚗 Google Drive Stack | |
129 | +```env | |
130 | +STORAGE_TYPE=s3 | |
131 | +STORAGE_BUCKET=opencodehub | |
132 | +STORAGE_REGION=garage | |
133 | +STORAGE_ENDPOINT=http://garage.local:3900 | |
134 | +STORAGE_ACCESS_KEY_ID=... | |
135 | +STORAGE_SECRET_ACCESS_KEY=... | |
136 | +``` | |
66 | 137 | |
67 | -This stack is ideal for cost-effective, serverless-style deployments where you want to minimize persistent volume usage. | |
138 | +#### SeaweedFS (self-hosted, very lightweight) | |
68 | 139 | |
69 | -### Prerequisites | |
140 | +[SeaweedFS](https://github.com/seaweedfs/seaweedfs) is an S3-compatible | |
141 | +distributed object store optimised for small files; great for NAS | |
142 | +deployments where every byte counts. | |
70 | 143 | |
71 | -1. **Google Cloud Project**: Enable **Google Drive API**. | |
72 | -2. **OAuth Credentials**: Create "Web Application" credentials. | |
73 | -3. **Refresh Token**: Obtain a long-lived refresh token (e.g., via OAuth Playground). | |
144 | +```env | |
145 | +STORAGE_TYPE=s3 | |
146 | +STORAGE_BUCKET=opencodehub | |
147 | +STORAGE_REGION=us-east-1 | |
148 | +STORAGE_ENDPOINT=http://seaweedfs.local:8333 | |
149 | +STORAGE_ACCESS_KEY_ID=... | |
150 | +STORAGE_SECRET_ACCESS_KEY=... | |
151 | +``` | |
152 | + | |
153 | +#### Ceph RGW (S3 gateway) | |
74 | 154 | |
75 | -### Configuration | |
155 | +If you already run Ceph for block or file storage, the RADOS Gateway | |
156 | +provides a fully S3-compatible HTTP frontend. | |
76 | 157 | |
77 | 158 | ```env |
78 | -STORAGE_TYPE=gdrive | |
79 | -GOOGLE_CLIENT_ID=your-client-id | |
80 | -GOOGLE_CLIENT_SECRET=your-client-secret | |
81 | -GOOGLE_REFRESH_TOKEN=your-refresh-token | |
82 | -GOOGLE_FOLDER_ID=your-folder-id | |
159 | +STORAGE_TYPE=s3 | |
160 | +STORAGE_BUCKET=opencodehub | |
161 | +STORAGE_REGION=default | |
162 | +STORAGE_ENDPOINT=https://rgw.example.com | |
163 | +STORAGE_ACCESS_KEY_ID=... | |
164 | +STORAGE_SECRET_ACCESS_KEY=... | |
83 | 165 | ``` |
84 | 166 | |
85 | -**How to get Credentials:** | |
167 | +#### Wasabi, Backblaze B2, DigitalOcean Spaces | |
86 | 168 | |
87 | -1. Go to [Google Cloud Console](https://console.cloud.google.com). | |
88 | -2. Create a project -> Enable **Google Drive API**. | |
89 | -3. Create **OAuth Client ID**. | |
90 | -4. Get Refresh Token via [OAuth Playground](https://developers.google.com/oauthplayground) with scope `https://www.googleapis.com/auth/drive.file`. | |
169 | +All expose an S3-compatible endpoint. Use the S3 access key the vendor | |
170 | +issues and the endpoint URL it documents. | |
171 | + | |
172 | +```env | |
173 | +# Wasabi | |
174 | +STORAGE_ENDPOINT=https://s3.wasabisys.com | |
175 | +STORAGE_REGION=us-east-1 | |
176 | + | |
177 | +# Backblaze B2 (S3-compatible API) | |
178 | +STORAGE_ENDPOINT=https://s3.<region>.backblazeb2.com | |
179 | +STORAGE_REGION=us-west-004 | |
180 | + | |
181 | +# DigitalOcean Spaces | |
182 | +STORAGE_ENDPOINT=https://<region>.digitaloceanspaces.com | |
183 | +STORAGE_REGION=nyc3 | |
184 | +``` | |
91 | 185 | |
92 | 186 | --- |
93 | 187 | |
94 | -## 🔷 Azure Blob Storage | |
188 | +## Performance considerations | |
95 | 189 | |
96 | -**Configuration:** | |
190 | +- **Multipart uploads.** OpenCodeHub uploads blobs in 5 MiB parts with | |
191 | + four parts in flight (`@aws-sdk/lib-storage`). Tunable in | |
192 | + `src/lib/storage.ts:S3StorageAdapter.writeStream`. | |
193 | +- **Signed URLs.** When `STORAGE_TYPE=s3`, LFS and the storage proxy | |
194 | + issue real S3 presigned URLs (V4) for client-side download/upload, | |
195 | + offloading bandwidth from the OpenCodeHub process. When | |
196 | + `STORAGE_TYPE=local`, the proxy streams blobs through the | |
197 | + OpenCodeHub HTTP server with an HMAC signature check. | |
198 | +- **Disk vs. network.** Local storage is bounded by the host disk I/O; | |
199 | + S3 storage is bounded by the upstream link. For a home server with | |
200 | + gigabit uplink, S3 to R2 or MinIO on a second NAS box is often | |
201 | + faster than writing to the same disk that hosts the database. | |
97 | 202 | |
98 | -```env | |
99 | -STORAGE_TYPE=azure | |
100 | -AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=... | |
101 | -AZURE_CONTAINER_NAME=opencodehub | |
203 | +## Migration | |
204 | + | |
205 | +```bash | |
206 | +# local -> S3: stream every blob under STORAGE_PATH into the bucket | |
207 | +aws s3 sync ./data/storage s3://opencodehub/ --endpoint-url $STORAGE_ENDPOINT | |
208 | + | |
209 | +# S3 -> local: reverse | |
210 | +aws s3 sync s3://opencodehub/ ./data/storage --endpoint-url $STORAGE_ENDPOINT | |
211 | + | |
212 | +# Then flip STORAGE_TYPE and restart. | |
102 | 213 | ``` |
214 | + | |
215 | +## Disaster recovery | |
216 | + | |
217 | +The `scripts/sync-storage.ts` module (and the `/api/admin/sync` | |
218 | +endpoint) can stream a copy of the storage root to any rclone-supported | |
219 | +target (S3 to a different bucket, B2, an SFTP server, an external | |
220 | +USB drive mounted on the host, etc.). It is orthogonal to the | |
221 | +primary storage driver and can be scheduled via cron. See | |
222 | +`docs/administration/backup-recovery.md` for the cron template. | |
103 | 223 | |
▼package.json
+0-4
| @@ -52,11 +52,9 @@ | ||
52 | 52 | "@aws-sdk/client-s3": "^3.948.0", |
53 | 53 | "@aws-sdk/lib-storage": "^3.972.0", |
54 | 54 | "@aws-sdk/s3-request-presigner": "^3.948.0", |
55 | - "@azure/storage-blob": "^12.30.0", | |
56 | 55 | "@dnd-kit/core": "^6.3.1", |
57 | 56 | "@dnd-kit/sortable": "^10.0.0", |
58 | 57 | "@dnd-kit/utilities": "^3.2.2", |
59 | - "@google-cloud/storage": "^7.18.0", | |
60 | 58 | "@libsql/client": "^0.15.15", |
61 | 59 | "@monaco-editor/react": "^4.6.0", |
62 | 60 | "@opentelemetry/auto-instrumentations-node": "^0.76.0", |
| @@ -92,7 +90,6 @@ | ||
92 | 90 | "@upstash/redis": "^1.36.0", |
93 | 91 | "arctic": "^3.7.0", |
94 | 92 | "astro": "^4.11.0", |
95 | - "basic-ftp": "^5.1.0", | |
96 | 93 | "bcryptjs": "^2.4.3", |
97 | 94 | "better-sqlite3": "^11.1.2", |
98 | 95 | "bullmq": "^5.65.1", |
| @@ -105,7 +102,6 @@ | ||
105 | 102 | "drizzle-orm": "^0.45.2", |
106 | 103 | "framer-motion": "^11.18.2", |
107 | 104 | "github-markdown-css": "^5.8.1", |
108 | - "googleapis": "^169.0.0", | |
109 | 105 | "graphql": "^16.12.0", |
110 | 106 | "graphql-yoga": "^5.18.0", |
111 | 107 | "ioredis": "^5.9.2", |
112 | 108 | |
▼scripts/verify-env-config.ts
+75-55
| @@ -1,67 +1,87 @@ | ||
1 | - | |
1 | +/** | |
2 | + * Verify the two supported storage backends (`local` and `s3`) end-to-end: | |
3 | + * 1. Environment validation accepts a valid S3 config and rejects an incomplete one. | |
4 | + * 2. `getStorageConfig()` returns a clean, S3-shaped object from env vars. | |
5 | + * 3. `createStorageAdapter` produces an `S3StorageAdapter` for `type=s3` and a | |
6 | + * `LocalStorageAdapter` for `type=local`. | |
7 | + * | |
8 | + * Usage: bun run scripts/verify-env-config.ts | |
9 | + */ | |
2 | 10 | import { validateEnvironment } from "../src/lib/env-validation"; |
3 | -import { getStorage } from "../src/lib/storage"; | |
11 | +import { createStorageAdapter, getStorageConfig } from "../src/lib/storage"; | |
12 | + | |
13 | +process.env.JWT_SECRET ??= "a".repeat(32); | |
14 | +process.env.SESSION_SECRET ??= "a".repeat(32); | |
15 | +process.env.INTERNAL_HOOK_SECRET ??= "a".repeat(32); | |
16 | +process.env.SITE_URL ??= "http://localhost:3000"; | |
4 | 17 | |
5 | -// Mock process.env | |
6 | -process.env.STORAGE_TYPE = "gdrive"; | |
7 | -process.env.GOOGLE_CLIENT_ID = "test-client-id"; | |
8 | -process.env.GOOGLE_CLIENT_SECRET = "test-client-secret"; | |
9 | -process.env.GOOGLE_REFRESH_TOKEN = "test-refresh-token"; | |
10 | -process.env.GOOGLE_FOLDER_ID = "test-folder-id"; | |
11 | -// Mock required vars to pass validation | |
12 | -process.env.JWT_SECRET = "a".repeat(32); | |
13 | -process.env.SESSION_SECRET = "a".repeat(32); | |
14 | -process.env.INTERNAL_HOOK_SECRET = "a".repeat(32); | |
15 | -process.env.SITE_URL = "http://localhost:3000"; | |
18 | +function assert(cond: boolean, msg: string) { | |
19 | + if (!cond) { | |
20 | + console.error(`❌ ${msg}`); | |
21 | + process.exit(1); | |
22 | + } | |
23 | + console.log(`✅ ${msg}`); | |
24 | +} | |
25 | + | |
26 | +function clearStorageEnv() { | |
27 | + for (const k of [ | |
28 | + "STORAGE_TYPE", | |
29 | + "STORAGE_PATH", | |
30 | + "STORAGE_BUCKET", | |
31 | + "STORAGE_REGION", | |
32 | + "STORAGE_ENDPOINT", | |
33 | + "STORAGE_ACCESS_KEY_ID", | |
34 | + "STORAGE_SECRET_ACCESS_KEY", | |
35 | + ]) { | |
36 | + delete process.env[k]; | |
37 | + } | |
38 | +} | |
16 | 39 | |
17 | 40 | async function verify() { |
18 | - console.log("Running verification..."); | |
41 | + console.log("Running storage verification...\n"); | |
19 | 42 | |
20 | - // 1. Test Environment Validation | |
21 | - console.log("\n1. Testing Environment Validation:"); | |
22 | - const isValid = validateEnvironment(false); // Don't exit on error | |
23 | - if (isValid) { | |
24 | - console.log("✅ Validation passed with valid Google Drive vars"); | |
25 | - } else { | |
26 | - console.error("❌ Validation failed unexpectedly"); | |
27 | - process.exit(1); | |
28 | - } | |
43 | + // 1. local default | |
44 | + console.log("[1/3] Default (local) config:"); | |
45 | + clearStorageEnv(); | |
46 | + const localCfg = getStorageConfig(); | |
47 | + assert(localCfg.type === "local", "default type is 'local'"); | |
48 | + assert(localCfg.basePath === "./data/storage", "default basePath is './data/storage'"); | |
29 | 49 | |
30 | - // 2. Test Missing Vars | |
31 | - console.log("\n2. Testing Missing Google Drive Vars:"); | |
32 | - const originalClientId = process.env.GOOGLE_CLIENT_ID; | |
33 | - delete process.env.GOOGLE_CLIENT_ID; | |
34 | - const isInvalid = !validateEnvironment(false); | |
35 | - if (isInvalid) { | |
36 | - console.log("✅ Validation correctly failed when GOOGLE_CLIENT_ID is missing"); | |
37 | - } else { | |
38 | - console.error("❌ Validation passed despite missing GOOGLE_CLIENT_ID"); | |
39 | - process.exit(1); | |
40 | - } | |
41 | - process.env.GOOGLE_CLIENT_ID = originalClientId; // Restore | |
50 | + // 2. s3 valid | |
51 | + console.log("\n[2/3] Valid S3 config:"); | |
52 | + clearStorageEnv(); | |
53 | + process.env.STORAGE_TYPE = "s3"; | |
54 | + process.env.STORAGE_BUCKET = "my-bucket"; | |
55 | + process.env.STORAGE_ACCESS_KEY_ID = "AKIA-test"; | |
56 | + process.env.STORAGE_SECRET_ACCESS_KEY = "secret"; | |
57 | + process.env.STORAGE_REGION = "us-west-2"; | |
58 | + process.env.STORAGE_ENDPOINT = "https://s3.us-west-2.example.com"; | |
59 | + const valid = validateEnvironment(false); | |
60 | + assert(valid, "validation passes for a complete S3 config"); | |
61 | + const s3Cfg = getStorageConfig(); | |
62 | + assert(s3Cfg.type === "s3", "getStorageConfig returns type='s3'"); | |
63 | + assert(s3Cfg.bucket === "my-bucket", "bucket is read from env"); | |
64 | + assert(s3Cfg.region === "us-west-2", "region is read from env"); | |
65 | + assert(s3Cfg.endpoint === "https://s3.us-west-2.example.com", "endpoint is read from env"); | |
66 | + const adapter = createStorageAdapter(s3Cfg); | |
67 | + assert(adapter.constructor.name === "S3StorageAdapter", "factory returns S3StorageAdapter"); | |
42 | 68 | |
43 | - // 3. Test Storage Config Loading | |
44 | - console.log("\n3. Testing Storage Configuration Loading:"); | |
45 | - try { | |
46 | - const storage = await getStorage(); | |
47 | - // @ts-ignore - Accessing protected/private property for verification or just checking type | |
48 | - const config = (storage as any).config; | |
69 | + // 3. s3 missing required | |
70 | + console.log("\n[3/3] Missing required S3 vars (validation should reject):"); | |
71 | + clearStorageEnv(); | |
72 | + process.env.STORAGE_TYPE = "s3"; | |
73 | + // no bucket / creds | |
74 | + const invalid = validateEnvironment(false); | |
75 | + assert(!invalid, "validation fails when S3 vars are missing"); | |
49 | 76 | |
50 | - if (config.type === 'gdrive' && | |
51 | - config.googleClientId === 'test-client-id' && | |
52 | - config.googleClientSecret === 'test-client-secret' && | |
53 | - config.googleFolderId === 'test-folder-id') { | |
54 | - console.log("✅ Storage adapter loaded with correct configuration from env"); | |
55 | - } else { | |
56 | - console.error("❌ Storage adapter loaded with incorrect configuration:", config); | |
57 | - process.exit(1); | |
58 | - } | |
59 | - } catch (error) { | |
60 | - console.error("❌ Failed to load storage adapter:", error); | |
61 | - process.exit(1); | |
62 | - } | |
77 | + // local adapter round-trip | |
78 | + clearStorageEnv(); | |
79 | + process.env.STORAGE_TYPE = "local"; | |
80 | + process.env.STORAGE_PATH = "/tmp/opencodehub-test-storage"; | |
81 | + const localAdapter = createStorageAdapter(getStorageConfig()); | |
82 | + assert(localAdapter.constructor.name === "LocalStorageAdapter", "factory returns LocalStorageAdapter for type=local"); | |
63 | 83 | |
64 | - console.log("\n🎉 ALL VERIFICATION CHECKS PASSED!"); | |
84 | + console.log("\n🎉 ALL VERIFICATION CHECKS PASSED!"); | |
65 | 85 | } |
66 | 86 | |
67 | 87 | verify(); |
68 | 88 | |
▼src/lib/env-validation.ts
+4-37
| @@ -95,6 +95,9 @@ const envVars: EnvConfig[] = [ | ||
95 | 95 | name: "STORAGE_TYPE", |
96 | 96 | required: false, |
97 | 97 | defaultValue: "local", |
98 | + validator: (v) => ["local", "s3"].includes(v.toLowerCase()), | |
99 | + errorMessage: | |
100 | + "STORAGE_TYPE must be one of: local, s3. (Other backends like Google Drive, OneDrive, Dropbox, FTP, Azure, GCS, rclone-as-adapter have been removed; use the s3 driver with a compatible endpoint for object storage, or local for a filesystem path.)", | |
98 | 101 | }, |
99 | 102 | { |
100 | 103 | name: "STORAGE_PATH", |
| @@ -123,29 +126,6 @@ const envVars: EnvConfig[] = [ | ||
123 | 126 | required: false, |
124 | 127 | defaultValue: "us-east-1", |
125 | 128 | }, |
126 | - // Rclone Storage (Required if STORAGE_TYPE=rclone) | |
127 | - { | |
128 | - name: "STORAGE_RCLONE_REMOTE", | |
129 | - required: false, | |
130 | - }, | |
131 | - | |
132 | - // Google Drive | |
133 | - { | |
134 | - name: "GOOGLE_CLIENT_ID", | |
135 | - required: false, | |
136 | - }, | |
137 | - { | |
138 | - name: "GOOGLE_CLIENT_SECRET", | |
139 | - required: false, | |
140 | - }, | |
141 | - { | |
142 | - name: "GOOGLE_REFRESH_TOKEN", | |
143 | - required: false, | |
144 | - }, | |
145 | - { | |
146 | - name: "GOOGLE_FOLDER_ID", | |
147 | - required: false, | |
148 | - }, | |
149 | 129 | |
150 | 130 | |
151 | 131 | // Git |
| @@ -261,25 +241,12 @@ export function validateEnvironment(exitOnError: boolean = true): boolean { | ||
261 | 241 | warnings.push("⚠️ AIR_GAPPED_MODE is enabled: external integrations (AI/cloud/third-party webhooks) should be disabled"); |
262 | 242 | } |
263 | 243 | |
264 | - // Google Drive Validation | |
265 | - if (process.env.STORAGE_TYPE === "gdrive") { | |
266 | - if (!process.env.GOOGLE_CLIENT_ID) errors.push("❌ GOOGLE_CLIENT_ID is required when STORAGE_TYPE is 'gdrive'"); | |
267 | - if (!process.env.GOOGLE_CLIENT_SECRET) errors.push("❌ GOOGLE_CLIENT_SECRET is required when STORAGE_TYPE is 'gdrive'"); | |
268 | - if (!process.env.GOOGLE_REFRESH_TOKEN) errors.push("❌ GOOGLE_REFRESH_TOKEN is required when STORAGE_TYPE is 'gdrive'"); | |
269 | - if (!process.env.GOOGLE_FOLDER_ID) errors.push("❌ GOOGLE_FOLDER_ID is required when STORAGE_TYPE is 'gdrive'"); | |
270 | - } | |
271 | - | |
272 | 244 | // S3 Validation |
273 | 245 | if (process.env.STORAGE_TYPE === "s3") { |
274 | 246 | if (!process.env.STORAGE_BUCKET) errors.push("❌ STORAGE_BUCKET is required when STORAGE_TYPE is 's3'"); |
275 | 247 | if (!process.env.STORAGE_ACCESS_KEY_ID) errors.push("❌ STORAGE_ACCESS_KEY_ID is required when STORAGE_TYPE is 's3'"); |
276 | 248 | if (!process.env.STORAGE_SECRET_ACCESS_KEY) errors.push("❌ STORAGE_SECRET_ACCESS_KEY is required when STORAGE_TYPE is 's3'"); |
277 | - // Endpoint might be optional for AWS S3 but usually required for others. | |
278 | - } | |
279 | - | |
280 | - // Rclone Validation | |
281 | - if (process.env.STORAGE_TYPE === "rclone") { | |
282 | - if (!process.env.STORAGE_RCLONE_REMOTE) errors.push("❌ STORAGE_RCLONE_REMOTE is required when STORAGE_TYPE is 'rclone'"); | |
249 | + // Endpoint is optional for AWS S3 but required for MinIO/R2/Garage/etc. | |
283 | 250 | } |
284 | 251 | |
285 | 252 | // Print results |
286 | 253 | |
▼src/lib/git-storage.ts
+4-3
| @@ -32,15 +32,16 @@ function getTempBase(): string { | ||
32 | 32 | } |
33 | 33 | |
34 | 34 | /** |
35 | - * Check if we're using cloud storage that requires sync | |
35 | + * Check if we're using S3-compatible (or other) object storage that requires | |
36 | + * sync between the local repo cache and the remote bucket. Local filesystem | |
37 | + * storage is treated as the canonical store and does not need syncing. | |
36 | 38 | */ |
37 | 39 | export async function isCloudStorage(): Promise<boolean> { |
38 | - // Use import.meta.env for Astro compatibility, fallback to process.env for CLI/scripts | |
39 | 40 | const importMetaEnvType = import.meta.env?.STORAGE_TYPE; |
40 | 41 | const processEnvType = process.env.STORAGE_TYPE; |
41 | 42 | const type = importMetaEnvType || processEnvType || "local"; |
42 | 43 | logger.info(`[isCloudStorage] import.meta.env.STORAGE_TYPE=${importMetaEnvType}, process.env.STORAGE_TYPE=${processEnvType}, using=${type}`); |
43 | - return ["gdrive", "s3", "gcs", "azure", "rclone", "onedrive"].includes(type); | |
44 | + return type === "s3"; | |
44 | 45 | } |
45 | 46 | |
46 | 47 | /** |
47 | 48 | |
▼src/lib/storage.ts
+200-1769
| @@ -1,12 +1,14 @@ | ||
1 | 1 | /** |
2 | 2 | * OpenCodeHub Storage System |
3 | 3 | * |
4 | - * Flexible storage adapter supporting: | |
5 | - * - Local filesystem | |
6 | - * - S3/MinIO | |
7 | - * - Google Cloud Storage | |
8 | - * - Azure Blob Storage | |
9 | - * - rclone-compatible remotes | |
4 | + * Two storage backends are supported: | |
5 | + * - local : filesystem storage rooted at STORAGE_PATH (default ./data/storage) | |
6 | + * - s3 : any S3-compatible object store (AWS S3, MinIO, Cloudflare R2, | |
7 | + * Garage, SeaweedFS, Ceph RGW, Wasabi, Backblaze B2, etc.) | |
8 | + * | |
9 | + * The S3 adapter speaks the S3 v4 API; any vendor that implements it works | |
10 | + * by setting STORAGE_ENDPOINT to the vendor's endpoint URL and STORAGE_REGION | |
11 | + * to its region string. No vendor-specific code paths are required. | |
10 | 12 | */ |
11 | 13 | |
12 | 14 | import { logger } from "@/lib/logger"; |
| @@ -18,55 +20,16 @@ import path from "path"; | ||
18 | 20 | import { Readable } from "stream"; |
19 | 21 | import { pipeline } from "stream/promises"; |
20 | 22 | |
21 | -// Types | |
23 | +export type StorageType = "local" | "s3"; | |
24 | + | |
22 | 25 | export interface StorageConfig { |
23 | - type: | |
24 | - | "local" | |
25 | - | "s3" | |
26 | - | "gcs" | |
27 | - | "azure" | |
28 | - | "rclone" | |
29 | - | "gdrive" | |
30 | - | "onedrive" | |
31 | - | "dropbox" | |
32 | - | "ftp"; | |
26 | + type: StorageType; | |
33 | 27 | basePath: string; |
34 | 28 | bucket?: string; |
35 | 29 | region?: string; |
36 | 30 | endpoint?: string; |
37 | 31 | accessKeyId?: string; |
38 | 32 | secretAccessKey?: string; |
39 | - projectId?: string; | |
40 | - containerName?: string; | |
41 | - accountName?: string; | |
42 | - accountKey?: string; | |
43 | - rcloneRemote?: string; | |
44 | - // Google Drive | |
45 | - googleClientId?: string; | |
46 | - googleClientSecret?: string; | |
47 | - googleRefreshToken?: string; | |
48 | - googleFolderId?: string; | |
49 | - // OneDrive | |
50 | - onedriveTenantId?: string; | |
51 | - onedriveClientId?: string; | |
52 | - onedriveClientSecret?: string; | |
53 | - onedriveRefreshToken?: string; | |
54 | - onedriveFolderId?: string; | |
55 | - // FTP | |
56 | - ftpHost?: string; | |
57 | - ftpPort?: number; | |
58 | - ftpUser?: string; | |
59 | - ftpPassword?: string; | |
60 | - ftpSecure?: boolean; | |
61 | - // GCS | |
62 | - gcsProjectId?: string; | |
63 | - gcsKeyFile?: string; | |
64 | - // Azure | |
65 | - azureAccountName?: string; | |
66 | - azureAccountKey?: string; | |
67 | - azureContainerName?: string; | |
68 | - // Dropbox | |
69 | - dropboxAccessToken?: string; | |
70 | 33 | } |
71 | 34 | |
72 | 35 | export interface StorageObject { |
| @@ -275,7 +238,6 @@ export class LocalStorageAdapter extends StorageAdapter { | ||
275 | 238 | } |
276 | 239 | |
277 | 240 | async getSignedUrl(key: string, _expiresIn?: number): Promise<string> { |
278 | - // For local storage, return a file:// URL | |
279 | 241 | return `file://${this.getFullPath(key)}`; |
280 | 242 | } |
281 | 243 | |
| @@ -320,16 +282,18 @@ export class LocalStorageAdapter extends StorageAdapter { | ||
320 | 282 | } |
321 | 283 | |
322 | 284 | /** |
323 | - * S3-compatible storage adapter (S3, MinIO, R2, etc.) | |
285 | + * S3-compatible storage adapter. | |
286 | + * Works with AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, | |
287 | + * Wasabi, Backblaze B2, and any other S3 v4 implementation. | |
288 | + * | |
289 | + * Set STORAGE_ENDPOINT to the vendor endpoint (omit for AWS S3). | |
290 | + * Set STORAGE_REGION to the region string the vendor documents. | |
291 | + * forcePathStyle is enabled automatically when STORAGE_ENDPOINT is set, | |
292 | + * which is required by MinIO, Garage, SeaweedFS, and most self-hosted stacks. | |
324 | 293 | */ |
325 | 294 | export class S3StorageAdapter extends StorageAdapter { |
326 | 295 | private client: any; // S3Client from @aws-sdk/client-s3 |
327 | 296 | |
328 | - constructor(config: StorageConfig) { | |
329 | - super(config); | |
330 | - // Initialize S3 client lazily | |
331 | - } | |
332 | - | |
333 | 297 | private async getClient() { |
334 | 298 | if (!this.client) { |
335 | 299 | const { S3Client } = await import("@aws-sdk/client-s3"); |
| @@ -342,7 +306,7 @@ export class S3StorageAdapter extends StorageAdapter { | ||
342 | 306 | secretAccessKey: this.config.secretAccessKey!, |
343 | 307 | } |
344 | 308 | : undefined, |
345 | - forcePathStyle: !!this.config.endpoint, // Required for MinIO | |
309 | + forcePathStyle: !!this.config.endpoint, | |
346 | 310 | }); |
347 | 311 | } |
348 | 312 | return this.client; |
| @@ -387,8 +351,8 @@ export class S3StorageAdapter extends StorageAdapter { | ||
387 | 351 | CacheControl: options?.cacheControl, |
388 | 352 | ContentDisposition: options?.contentDisposition, |
389 | 353 | }, |
390 | - queueSize: 4, // Concurrent upload parts | |
391 | - partSize: 5 * 1024 * 1024, // 5MB chunks | |
354 | + queueSize: 4, | |
355 | + partSize: 5 * 1024 * 1024, | |
392 | 356 | }); |
393 | 357 | |
394 | 358 | await upload.done(); |
| @@ -482,7 +446,6 @@ export class S3StorageAdapter extends StorageAdapter { | ||
482 | 446 | |
483 | 447 | return { |
484 | 448 | objects: (response.Contents || []).map((obj: any) => ({ |
485 | - // Only remove basePath if it's not empty | |
486 | 449 | key: this.config.basePath |
487 | 450 | ? obj.Key!.replace(this.config.basePath + "/", "") |
488 | 451 | : obj.Key!, |
| @@ -504,7 +467,6 @@ export class S3StorageAdapter extends StorageAdapter { | ||
504 | 467 | const { CopyObjectCommand } = await import("@aws-sdk/client-s3"); |
505 | 468 | const client = await this.getClient(); |
506 | 469 | |
507 | - // CopySource must be key, potentially URL encoded | |
508 | 470 | const sourceFullPath = path.join(this.config.basePath, sourceKey); |
509 | 471 | const copySource = `${this.config.bucket}/${encodeURIComponent(sourceFullPath)}`; |
510 | 472 | |
| @@ -574,1742 +536,222 @@ export class S3StorageAdapter extends StorageAdapter { | ||
574 | 536 | } |
575 | 537 | |
576 | 538 | /** |
577 | - * Rclone-based storage adapter | |
578 | - * Supports any rclone-compatible remote (gdrive, dropbox, onedrive, s3, etc.) | |
539 | + * Storage factory. | |
540 | + * Throws if an unknown type is requested. Only `local` and `s3` are valid. | |
579 | 541 | */ |
580 | -export class RcloneStorageAdapter extends StorageAdapter { | |
581 | - private remote: string; | |
582 | - | |
583 | - constructor(config: StorageConfig) { | |
584 | - super(config); | |
585 | - this.remote = config.rcloneRemote || "remote"; | |
586 | - } | |
587 | - | |
588 | - private getRemotePath(key: string): string { | |
589 | - return `${this.remote}:${path.join(this.config.basePath, key)}`; | |
590 | - } | |
591 | - | |
592 | - private async execRclone( | |
593 | - args: string[], | |
594 | - ): Promise<{ stdout: string; stderr: string }> { | |
595 | - const { spawn } = await import("child_process"); | |
596 | - | |
597 | - return new Promise((resolve, reject) => { | |
598 | - const proc = spawn("rclone", args); | |
599 | - let stdout = ""; | |
600 | - let stderr = ""; | |
601 | - | |
602 | - proc.stdout.on("data", (data) => { | |
603 | - stdout += data.toString(); | |
604 | - }); | |
605 | - proc.stderr.on("data", (data) => { | |
606 | - stderr += data.toString(); | |
607 | - }); | |
608 | - | |
609 | - proc.on("close", (code) => { | |
610 | - if (code === 0) { | |
611 | - resolve({ stdout, stderr }); | |
612 | - } else { | |
613 | - reject(new Error(`rclone failed: ${stderr || stdout}`)); | |
614 | - } | |
615 | - }); | |
616 | - | |
617 | - proc.on("error", (err) => { | |
618 | - reject(new Error(`rclone not found: ${err.message}`)); | |
619 | - }); | |
620 | - }); | |
542 | +export function createStorageAdapter(config: StorageConfig): StorageAdapter { | |
543 | + logger.info( | |
544 | + `[createStorageAdapter] Creating adapter for type: ${config.type}`, | |
545 | + ); | |
546 | + switch (config.type) { | |
547 | + case "local": | |
548 | + return new LocalStorageAdapter(config); | |
549 | + case "s3": | |
550 | + return new S3StorageAdapter(config); | |
551 | + default: | |
552 | + throw new Error( | |
553 | + `Unknown storage type: ${(config as { type: string }).type}. ` + | |
554 | + `Supported types are: local, s3.`, | |
555 | + ); | |
621 | 556 | } |
557 | +} | |
622 | 558 | |
623 | - async put( | |
624 | - key: string, | |
625 | - data: Buffer | Readable, | |
626 | - options?: PutOptions, | |
627 | - ): Promise<void> { | |
628 | - // For rclone, we need to write to a temp file first, then rclone copy | |
629 | - const tempDir = path.join(process.cwd(), "data", "temp"); | |
630 | - await fs.mkdir(tempDir, { recursive: true }); | |
631 | - const tempFile = path.join( | |
632 | - tempDir, | |
633 | - `upload-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`, | |
634 | - ); | |
635 | - | |
636 | - try { | |
637 | - if (Buffer.isBuffer(data)) { | |
638 | - await fs.writeFile(tempFile, data); | |
639 | - } else { | |
640 | - const writeStream = createWriteStream(tempFile); | |
641 | - // @ts-ignore | |
642 | - await pipeline(Readable.from(data as any), writeStream); | |
643 | - } | |
644 | - | |
645 | - // Use rclone copyto for single file | |
646 | - await this.execRclone(["copyto", tempFile, this.getRemotePath(key)]); | |
647 | - } finally { | |
648 | - await fs.unlink(tempFile).catch(() => {}); | |
649 | - } | |
650 | - } | |
559 | +// Singleton instance | |
560 | +let storageInstance: StorageAdapter | null = null; | |
561 | +let lastConfigHash: string = ""; | |
651 | 562 | |
652 | - async writeStream( | |
653 | - key: string, | |
654 | - stream: Readable | ReadableStream, | |
655 | - options?: PutOptions, | |
656 | - ): Promise<void> { | |
657 | - // Ensure we have a Node.js Readable stream | |
658 | - const readable = | |
659 | - stream instanceof Readable ? stream : Readable.from(stream as any); | |
660 | - return this.put(key, readable, options); | |
661 | - } | |
563 | +/** | |
564 | + * Reset storage singleton - forces re-initialization on next getStorage() call | |
565 | + * Useful after changing environment variables or for testing | |
566 | + */ | |
567 | +export function resetStorage(): void { | |
568 | + logger.info("[resetStorage] Clearing storage singleton"); | |
569 | + storageInstance = null; | |
570 | + lastConfigHash = ""; | |
571 | +} | |
662 | 572 | |
663 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
664 | - const tempDir = path.join(process.cwd(), "data", "temp"); | |
665 | - await fs.mkdir(tempDir, { recursive: true }); | |
666 | - const tempFile = path.join( | |
667 | - tempDir, | |
668 | - `download-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`, | |
669 | - ); | |
573 | +/** | |
574 | + * Check storage health/connectivity | |
575 | + * Returns true if storage is accessible, false otherwise | |
576 | + */ | |
577 | +export async function checkStorageHealth(): Promise<{ | |
578 | + healthy: boolean; | |
579 | + type: string; | |
580 | + error?: string; | |
581 | +}> { | |
582 | + try { | |
583 | + const storage = await getStorage(); | |
584 | + const config = getStorageConfig(); | |
670 | 585 | |
671 | - try { | |
672 | - await this.execRclone(["copyto", this.getRemotePath(key), tempFile]); | |
673 | - | |
674 | - if (options?.range) { | |
675 | - const fd = await fs.open(tempFile, "r"); | |
676 | - const buffer = Buffer.alloc( | |
677 | - options.range.end - options.range.start + 1, | |
678 | - ); | |
679 | - await fd.read(buffer, 0, buffer.length, options.range.start); | |
680 | - await fd.close(); | |
681 | - return buffer; | |
682 | - } | |
586 | + // Try to list root - if this works, storage is accessible | |
587 | + await storage.list({ maxKeys: 1 }); | |
683 | 588 | |
684 | - return fs.readFile(tempFile); | |
685 | - } finally { | |
686 | - await fs.unlink(tempFile).catch(() => {}); | |
687 | - } | |
589 | + return { healthy: true, type: config.type }; | |
590 | + } catch (error: any) { | |
591 | + return { healthy: false, type: "unknown", error: error.message }; | |
688 | 592 | } |
593 | +} | |
689 | 594 | |
690 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
691 | - // For streaming, download to temp then stream | |
692 | - const buffer = await this.get(key, options); | |
693 | - return Readable.from(buffer); | |
694 | - } | |
595 | +/** | |
596 | + * Get current storage configuration from environment. | |
597 | + * Reads only the variables relevant to the `local` and `s3` drivers. | |
598 | + */ | |
599 | +export function getStorageConfig(): StorageConfig { | |
600 | + const storageType = ((import.meta.env?.STORAGE_TYPE || | |
601 | + process.env.STORAGE_TYPE || | |
602 | + "local") as StorageType); | |
695 | 603 | |
696 | - async delete(key: string): Promise<void> { | |
697 | - await this.execRclone(["deletefile", this.getRemotePath(key)]).catch( | |
698 | - () => {}, | |
699 | - ); | |
604 | + let basePath = ""; | |
605 | + if (storageType === "local") { | |
606 | + basePath = | |
607 | + import.meta.env?.STORAGE_PATH || | |
608 | + process.env.STORAGE_PATH || | |
609 | + "./data/storage"; | |
700 | 610 | } |
701 | 611 | |
702 | - async exists(key: string): Promise<boolean> { | |
703 | - try { | |
704 | - await this.execRclone(["lsf", this.getRemotePath(key)]); | |
705 | - return true; | |
706 | - } catch { | |
707 | - return false; | |
708 | - } | |
709 | - } | |
612 | + return { | |
613 | + type: storageType, | |
614 | + basePath, | |
615 | + bucket: import.meta.env?.STORAGE_BUCKET || process.env.STORAGE_BUCKET, | |
616 | + region: import.meta.env?.STORAGE_REGION || process.env.STORAGE_REGION, | |
617 | + endpoint: import.meta.env?.STORAGE_ENDPOINT || process.env.STORAGE_ENDPOINT, | |
618 | + accessKeyId: | |
619 | + import.meta.env?.STORAGE_ACCESS_KEY_ID || | |
620 | + process.env.STORAGE_ACCESS_KEY_ID, | |
621 | + secretAccessKey: | |
622 | + import.meta.env?.STORAGE_SECRET_ACCESS_KEY || | |
623 | + process.env.STORAGE_SECRET_ACCESS_KEY, | |
624 | + }; | |
625 | +} | |
710 | 626 | |
711 | - async list(options?: ListOptions): Promise<ListResult> { | |
712 | - const remotePath = `${this.remote}:${path.join(this.config.basePath, options?.prefix || "")}`; | |
627 | +export async function getStorage(): Promise<StorageAdapter> { | |
628 | + const envPriority = | |
629 | + import.meta.env?.STORAGE_ENV_PRIORITY === "true" || | |
630 | + process.env.STORAGE_ENV_PRIORITY === "true"; | |
713 | 631 | |
632 | + if (!envPriority) { | |
714 | 633 | try { |
715 | - const { stdout } = await this.execRclone( | |
716 | - [ | |
717 | - "lsjson", | |
718 | - remotePath, | |
719 | - options?.delimiter === "/" ? "" : "-R", | |
720 | - "--no-modtime=false", | |
721 | - ].filter(Boolean), | |
722 | - ); | |
634 | + const { getDatabase, schema } = await import("@/db"); | |
635 | + const { eq } = await import("drizzle-orm"); | |
636 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
723 | 637 | |
724 | - const items = JSON.parse(stdout || "[]"); | |
725 | - const objects: StorageObject[] = []; | |
726 | - const prefixes = new Set<string>(); | |
638 | + const configRow = await db.query.systemConfig.findFirst({ | |
639 | + where: eq(schema.systemConfig.key, "storage_config"), | |
640 | + }); | |
727 | 641 | |
728 | - for (const item of items) { | |
729 | - if (item.IsDir) { | |
730 | - if (options?.delimiter === "/") { | |
731 | - prefixes.add(item.Path + "/"); | |
732 | - } | |
733 | - } else { | |
734 | - objects.push({ | |
735 | - key: item.Path, | |
736 | - size: item.Size || 0, | |
737 | - lastModified: new Date(item.ModTime || Date.now()), | |
738 | - etag: item.ID || undefined, | |
739 | - }); | |
642 | + if (configRow) { | |
643 | + if (configRow.value !== lastConfigHash) { | |
644 | + const config = JSON.parse(configRow.value) as StorageConfig; | |
645 | + logger.info(`[getStorage] Using DB config: type=${config.type}`); | |
646 | + storageInstance = createStorageAdapter(config); | |
647 | + lastConfigHash = configRow.value; | |
740 | 648 | } |
649 | + return storageInstance!; | |
741 | 650 | } |
742 | - | |
743 | - return { | |
744 | - objects: objects.slice(0, options?.maxKeys || 1000), | |
745 | - prefixes: options?.delimiter ? Array.from(prefixes) : undefined, | |
746 | - isTruncated: objects.length > (options?.maxKeys || 1000), | |
747 | - }; | |
748 | - } catch { | |
749 | - return { objects: [], isTruncated: false }; | |
651 | + } catch (e) { | |
652 | + logger.warn( | |
653 | + "[getStorage] Failed to fetch DB config, using env fallback", | |
654 | + e, | |
655 | + ); | |
750 | 656 | } |
657 | + } else { | |
658 | + logger.info("[getStorage] STORAGE_ENV_PRIORITY=true, skipping DB config"); | |
751 | 659 | } |
752 | 660 | |
753 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
754 | - await this.execRclone([ | |
755 | - "copyto", | |
756 | - this.getRemotePath(sourceKey), | |
757 | - this.getRemotePath(destKey), | |
758 | - ]); | |
661 | + if (!storageInstance) { | |
662 | + const config = getStorageConfig(); | |
663 | + logger.info( | |
664 | + `[getStorage] Using env config: type=${config.type}, basePath="${config.basePath}"`, | |
665 | + ); | |
666 | + storageInstance = createStorageAdapter(config); | |
667 | + lastConfigHash = JSON.stringify(config); | |
759 | 668 | } |
669 | + return storageInstance; | |
670 | +} | |
760 | 671 | |
761 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
762 | - await this.execRclone([ | |
763 | - "moveto", | |
764 | - this.getRemotePath(sourceKey), | |
765 | - this.getRemotePath(destKey), | |
766 | - ]); | |
767 | - } | |
672 | +export async function updateStorageConfig( | |
673 | + config: StorageConfig, | |
674 | + userId?: string, | |
675 | +): Promise<void> { | |
676 | + const { getDatabase, schema } = await import("@/db"); | |
677 | + const { eq } = await import("drizzle-orm"); | |
678 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
768 | 679 | |
769 | - async getSignedUrl(key: string, _expiresIn?: number): Promise<string> { | |
770 | - // Rclone link command for supported remotes | |
771 | - try { | |
772 | - const { stdout } = await this.execRclone([ | |
773 | - "link", | |
774 | - this.getRemotePath(key), | |
775 | - ]); | |
776 | - return stdout.trim(); | |
777 | - } catch { | |
778 | - // Fall back to local proxy URL | |
779 | - return `/api/storage/download/${encodeURIComponent(key)}`; | |
780 | - } | |
680 | + if (!config.type) throw new Error("Storage type required"); | |
681 | + if (config.type !== "local" && config.type !== "s3") { | |
682 | + throw new Error( | |
683 | + `Unsupported storage type '${config.type}'. Supported types are: local, s3.`, | |
684 | + ); | |
781 | 685 | } |
782 | 686 | |
783 | - async getSignedUploadUrl(key: string, _expiresIn?: number): Promise<string> { | |
784 | - // Rclone doesn't support signed upload URLs, use proxy | |
785 | - return `/api/storage/upload/${encodeURIComponent(key)}`; | |
786 | - } | |
687 | + const value = JSON.stringify(config); | |
787 | 688 | |
788 | - async stat(key: string): Promise<StorageObject> { | |
789 | - const { stdout } = await this.execRclone([ | |
790 | - "lsjson", | |
791 | - this.getRemotePath(key), | |
792 | - ]); | |
793 | - const items = JSON.parse(stdout || "[]"); | |
794 | - | |
795 | - if (items.length === 0) { | |
796 | - throw new Error("File not found"); | |
797 | - } | |
689 | + await db | |
690 | + .insert(schema.systemConfig) | |
691 | + .values({ | |
692 | + key: "storage_config", | |
693 | + value, | |
694 | + updatedById: userId, | |
695 | + }) | |
696 | + .onConflictDoUpdate({ | |
697 | + target: schema.systemConfig.key, | |
698 | + set: { | |
699 | + value, | |
700 | + updatedAt: new Date(), | |
701 | + updatedById: userId, | |
702 | + }, | |
703 | + }); | |
798 | 704 | |
799 | - const item = items[0]; | |
800 | - return { | |
801 | - key, | |
802 | - size: item.Size || 0, | |
803 | - lastModified: new Date(item.ModTime || Date.now()), | |
804 | - etag: item.ID, | |
805 | - contentType: item.MimeType, | |
806 | - }; | |
807 | - } | |
705 | + lastConfigHash = ""; | |
706 | + storageInstance = null; | |
808 | 707 | } |
809 | 708 | |
810 | 709 | /** |
811 | - * Google Drive storage adapter | |
710 | + * Helper functions for common operations | |
812 | 711 | */ |
813 | -export class GoogleDriveStorageAdapter extends StorageAdapter { | |
814 | - private drive: any; | |
815 | - private folderId: string; | |
816 | - /** LRU cache for folder/file ID resolution (path → { id, expiresAt }) */ | |
817 | - private pathCache = new Map<string, { id: string; expiresAt: number }>(); | |
818 | - private static readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes | |
819 | - private static readonly MAX_CACHE_SIZE = 500; | |
820 | - | |
821 | - constructor(config: StorageConfig) { | |
822 | - super(config); | |
823 | - this.folderId = config.googleFolderId || "root"; | |
824 | - } | |
825 | - | |
826 | - private async getDrive() { | |
827 | - if (this.drive) return this.drive; | |
828 | - | |
829 | - const { google } = await import("googleapis"); | |
830 | - const oauth2Client = new google.auth.OAuth2( | |
831 | - this.config.googleClientId, | |
832 | - this.config.googleClientSecret, | |
833 | - ); | |
834 | - oauth2Client.setCredentials({ | |
835 | - refresh_token: this.config.googleRefreshToken, | |
836 | - }); | |
837 | - | |
838 | - this.drive = google.drive({ version: "v3", auth: oauth2Client }); | |
839 | - return this.drive; | |
840 | - } | |
841 | 712 | |
842 | - private async findFileByPath(filePath: string): Promise<string | null> { | |
843 | - // Check cache first | |
844 | - const cached = this.pathCache.get(filePath); | |
845 | - if (cached && cached.expiresAt > Date.now()) { | |
846 | - return cached.id; | |
847 | - } | |
848 | - | |
849 | - const drive = await this.getDrive(); | |
850 | - const parts = filePath.split("/").filter(Boolean); | |
851 | - let parentId = this.folderId; | |
852 | - | |
853 | - for (let i = 0; i < parts.length; i++) { | |
854 | - const name = parts[i]; | |
855 | - const isLast = i === parts.length - 1; | |
856 | - const mimeType = isLast | |
857 | - ? undefined | |
858 | - : "application/vnd.google-apps.folder"; | |
859 | - | |
860 | - // Check cache for intermediate paths too | |
861 | - const partialPath = parts.slice(0, i + 1).join("/"); | |
862 | - const partialCached = this.pathCache.get(partialPath); | |
863 | - if (partialCached && partialCached.expiresAt > Date.now()) { | |
864 | - parentId = partialCached.id; | |
865 | - continue; | |
866 | - } | |
713 | +export async function uploadFile( | |
714 | + key: string, | |
715 | + filePath: string, | |
716 | + options?: PutOptions, | |
717 | +): Promise<void> { | |
718 | + const storage = await getStorage(); | |
719 | + const stream = createReadStream(filePath); | |
720 | + await storage.put(key, stream, options); | |
721 | +} | |
867 | 722 | |
868 | - const q = `name='${name}' and '${parentId}' in parents and trashed=false${mimeType ? ` and mimeType='${mimeType}'` : ""}`; | |
869 | - const res = await drive.files.list({ q, fields: "files(id,name)" }); | |
723 | +export async function downloadFile( | |
724 | + key: string, | |
725 | + destPath: string, | |
726 | +): Promise<void> { | |
727 | + const storage = await getStorage(); | |
728 | + const data = await storage.get(key); | |
729 | + await fs.mkdir(path.dirname(destPath), { recursive: true }); | |
730 | + await fs.writeFile(destPath, data); | |
731 | +} | |
870 | 732 | |
871 | - if (res.data.files?.length === 0) return null; | |
872 | - parentId = res.data.files[0].id; | |
733 | +export async function getFileAsBase64(key: string): Promise<string> { | |
734 | + const storage = await getStorage(); | |
735 | + const data = await storage.get(key); | |
736 | + return data.toString("base64"); | |
737 | +} | |
873 | 738 | |
874 | - // Cache intermediate + final path | |
875 | - this.setCacheEntry(partialPath, parentId); | |
876 | - } | |
739 | +export async function getFileHash( | |
740 | + key: string, | |
741 | + algorithm: string = "sha256", | |
742 | +): Promise<string> { | |
743 | + const storage = await getStorage(); | |
744 | + const data = await storage.get(key); | |
745 | + return crypto.createHash(algorithm).update(data).digest("hex"); | |
746 | +} | |
877 | 747 | |
878 | - // Cache the full path result | |
879 | - this.setCacheEntry(filePath, parentId); | |
880 | - return parentId; | |
881 | - } | |
882 | - | |
883 | - /** Set a cache entry, evicting oldest if over limit */ | |
884 | - private setCacheEntry(key: string, id: string): void { | |
885 | - if (this.pathCache.size >= GoogleDriveStorageAdapter.MAX_CACHE_SIZE) { | |
886 | - // Evict oldest entry (first key in Map iteration order) | |
887 | - const firstKey = this.pathCache.keys().next().value; | |
888 | - if (firstKey) this.pathCache.delete(firstKey); | |
889 | - } | |
890 | - this.pathCache.set(key, { | |
891 | - id, | |
892 | - expiresAt: Date.now() + GoogleDriveStorageAdapter.CACHE_TTL_MS, | |
893 | - }); | |
894 | - } | |
895 | - | |
896 | - private async ensureFolderPath(folderPath: string): Promise<string> { | |
897 | - const drive = await this.getDrive(); | |
898 | - const parts = folderPath.split("/").filter(Boolean); | |
899 | - let parentId = this.folderId; | |
900 | - | |
901 | - for (const name of parts) { | |
902 | - if (name === ".") continue; // Skip current directory dot | |
903 | - | |
904 | - const q = `name='${name}' and '${parentId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`; | |
905 | - const res = await drive.files.list({ q, fields: "files(id)" }); | |
906 | - | |
907 | - if (res.data.files?.length > 0) { | |
908 | - parentId = res.data.files[0].id; | |
909 | - // logger.info(`[GoogleDrive] Found existing folder '${name}', id=${parentId}`); | |
910 | - } else { | |
911 | - logger.info( | |
912 | - `[GoogleDrive] Creating folder '${name}' in parent '${parentId}'`, | |
913 | - ); | |
914 | - const folder = await drive.files.create({ | |
915 | - requestBody: { | |
916 | - name, | |
917 | - mimeType: "application/vnd.google-apps.folder", | |
918 | - parents: [parentId], | |
919 | - }, | |
920 | - fields: "id", | |
921 | - }); | |
922 | - parentId = folder.data.id; | |
923 | - logger.info(`[GoogleDrive] Created folder '${name}', id=${parentId}`); | |
924 | - } | |
925 | - } | |
926 | - | |
927 | - return parentId; | |
928 | - } | |
929 | - | |
930 | - async put( | |
931 | - key: string, | |
932 | - data: Buffer | Readable, | |
933 | - options?: PutOptions, | |
934 | - ): Promise<void> { | |
935 | - const drive = await this.getDrive(); | |
936 | - // For Google Drive, use key directly as path relative to the configured folder | |
937 | - // basePath is for local storage, not needed for cloud folder structure | |
938 | - const fullPath = key; | |
939 | - const fileName = path.basename(fullPath); | |
940 | - const folderPath = path.dirname(fullPath); | |
941 | - | |
942 | - const parentId = await this.ensureFolderPath(folderPath); | |
943 | - const existingId = await this.findFileByPath(fullPath); | |
944 | - | |
945 | - const media = { | |
946 | - mimeType: options?.contentType || "application/octet-stream", | |
947 | - body: Buffer.isBuffer(data) ? Readable.from(data) : data, | |
948 | - }; | |
949 | - | |
950 | - if (existingId) { | |
951 | - await drive.files.update({ fileId: existingId, media }); | |
952 | - } else { | |
953 | - await drive.files.create({ | |
954 | - requestBody: { name: fileName, parents: [parentId] }, | |
955 | - media, | |
956 | - fields: "id", | |
957 | - }); | |
958 | - } | |
959 | - } | |
960 | - | |
961 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
962 | - const drive = await this.getDrive(); | |
963 | - const fullPath = key; // Use key directly, not basePath prefix | |
964 | - const fileId = await this.findFileByPath(fullPath); | |
965 | - | |
966 | - if (!fileId) throw new Error(`File not found: ${key}`); | |
967 | - | |
968 | - const res = await drive.files.get( | |
969 | - { fileId, alt: "media" }, | |
970 | - { responseType: "arraybuffer" }, | |
971 | - ); | |
972 | - | |
973 | - const buffer = Buffer.from(res.data); | |
974 | - if (options?.range) { | |
975 | - return buffer.subarray(options.range.start, options.range.end + 1); | |
976 | - } | |
977 | - return buffer; | |
978 | - } | |
979 | - | |
980 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
981 | - const buffer = await this.get(key, options); | |
982 | - return Readable.from(buffer); | |
983 | - } | |
984 | - | |
985 | - async delete(key: string): Promise<void> { | |
986 | - const drive = await this.getDrive(); | |
987 | - const fullPath = key; | |
988 | - const fileId = await this.findFileByPath(fullPath); | |
989 | - if (fileId) await drive.files.delete({ fileId }); | |
990 | - } | |
991 | - | |
992 | - async exists(key: string): Promise<boolean> { | |
993 | - const fullPath = key; | |
994 | - return (await this.findFileByPath(fullPath)) !== null; | |
995 | - } | |
996 | - | |
997 | - async list(options?: ListOptions): Promise<ListResult> { | |
998 | - const drive = await this.getDrive(); | |
999 | - // Do not use basePath for Google Drive, use prefix directly | |
1000 | - const prefix = options?.prefix || ""; | |
1001 | - | |
1002 | - // If prefix is provided, we MUST find that specific folder. | |
1003 | - // If it doesn't exist, return empty list (or throw). | |
1004 | - // Only use root folderId if prefix is empty. | |
1005 | - let folderId: string | null = null; | |
1006 | - | |
1007 | - if (prefix) { | |
1008 | - folderId = await this.findFileByPath(prefix); | |
1009 | - if (!folderId) { | |
1010 | - // Folder not found, return empty list | |
1011 | - return { objects: [], prefixes: [], isTruncated: false }; | |
1012 | - } | |
1013 | - } else { | |
1014 | - folderId = this.folderId; | |
1015 | - } | |
1016 | - | |
1017 | - const res = await drive.files.list({ | |
1018 | - q: `'${folderId}' in parents and trashed=false`, | |
1019 | - fields: "files(id,name,size,modifiedTime,mimeType)", | |
1020 | - pageSize: options?.maxKeys || 1000, | |
1021 | - }); | |
1022 | - | |
1023 | - const objects: StorageObject[] = []; | |
1024 | - const prefixes: string[] = []; | |
1025 | - | |
1026 | - for (const file of res.data.files || []) { | |
1027 | - if (file.mimeType === "application/vnd.google-apps.folder") { | |
1028 | - prefixes.push(file.name + "/"); | |
1029 | - } else { | |
1030 | - const key = options?.prefix | |
1031 | - ? path.join(options.prefix, file.name) | |
1032 | - : file.name; | |
1033 | - objects.push({ | |
1034 | - key, | |
1035 | - size: parseInt(file.size || "0"), | |
1036 | - lastModified: new Date(file.modifiedTime), | |
1037 | - }); | |
1038 | - } | |
1039 | - } | |
1040 | - | |
1041 | - return { objects, prefixes, isTruncated: false }; | |
1042 | - } | |
1043 | - | |
1044 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
1045 | - const drive = await this.getDrive(); | |
1046 | - const sourcePath = path.join(this.config.basePath, sourceKey); | |
1047 | - const destPath = path.join(this.config.basePath, destKey); | |
1048 | - const sourceId = await this.findFileByPath(sourcePath); | |
1049 | - if (!sourceId) throw new Error("Source file not found"); | |
1050 | - | |
1051 | - const parentId = await this.ensureFolderPath(path.dirname(destPath)); | |
1052 | - await drive.files.copy({ | |
1053 | - fileId: sourceId, | |
1054 | - requestBody: { name: path.basename(destPath), parents: [parentId] }, | |
1055 | - }); | |
1056 | - } | |
1057 | - | |
1058 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
1059 | - await this.copy(sourceKey, destKey); | |
1060 | - await this.delete(sourceKey); | |
1061 | - } | |
1062 | - | |
1063 | - async getSignedUrl(key: string, _expiresIn?: number): Promise<string> { | |
1064 | - const fullPath = path.join(this.config.basePath, key); | |
1065 | - const fileId = await this.findFileByPath(fullPath); | |
1066 | - if (!fileId) throw new Error("File not found"); | |
1067 | - return `https://drive.google.com/uc?id=${fileId}&export=download`; | |
1068 | - } | |
1069 | - | |
1070 | - async getSignedUploadUrl(key: string, _expiresIn?: number): Promise<string> { | |
1071 | - return `/api/storage/upload/${encodeURIComponent(key)}`; | |
1072 | - } | |
1073 | - | |
1074 | - async stat(key: string): Promise<StorageObject> { | |
1075 | - const drive = await this.getDrive(); | |
1076 | - const fullPath = path.join(this.config.basePath, key); | |
1077 | - const fileId = await this.findFileByPath(fullPath); | |
1078 | - if (!fileId) throw new Error("File not found"); | |
1079 | - | |
1080 | - const res = await drive.files.get({ | |
1081 | - fileId, | |
1082 | - fields: "id,name,size,modifiedTime,mimeType", | |
1083 | - }); | |
1084 | - | |
1085 | - return { | |
1086 | - key, | |
1087 | - size: parseInt(res.data.size || "0"), | |
1088 | - lastModified: new Date(res.data.modifiedTime), | |
1089 | - contentType: res.data.mimeType, | |
1090 | - }; | |
1091 | - } | |
1092 | -} | |
1093 | - | |
1094 | -/** | |
1095 | - * OneDrive storage adapter | |
1096 | - */ | |
1097 | -export class OneDriveStorageAdapter extends StorageAdapter { | |
1098 | - private accessToken: string | null = null; | |
1099 | - private tokenExpiry: number = 0; | |
1100 | - | |
1101 | - constructor(config: StorageConfig) { | |
1102 | - super(config); | |
1103 | - } | |
1104 | - | |
1105 | - private async getAccessToken(): Promise<string> { | |
1106 | - if (this.accessToken && Date.now() < this.tokenExpiry) { | |
1107 | - return this.accessToken; | |
1108 | - } | |
1109 | - | |
1110 | - const params = new URLSearchParams({ | |
1111 | - client_id: this.config.onedriveClientId!, | |
1112 | - client_secret: this.config.onedriveClientSecret!, | |
1113 | - refresh_token: this.config.onedriveRefreshToken!, | |
1114 | - grant_type: "refresh_token", | |
1115 | - }); | |
1116 | - | |
1117 | - const tenantId = this.config.onedriveTenantId || "common"; | |
1118 | - const res = await fetch( | |
1119 | - `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, | |
1120 | - { method: "POST", body: params }, | |
1121 | - ); | |
1122 | - | |
1123 | - const data = await res.json(); | |
1124 | - this.accessToken = data.access_token; | |
1125 | - this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000; | |
1126 | - return this.accessToken!; | |
1127 | - } | |
1128 | - | |
1129 | - private async graphRequest( | |
1130 | - endpoint: string, | |
1131 | - options: RequestInit = {}, | |
1132 | - ): Promise<Response> { | |
1133 | - const token = await this.getAccessToken(); | |
1134 | - const baseUrl = this.config.onedriveFolderId | |
1135 | - ? `https://graph.microsoft.com/v1.0/me/drive/items/${this.config.onedriveFolderId}` | |
1136 | - : "https://graph.microsoft.com/v1.0/me/drive/root"; | |
1137 | - | |
1138 | - return fetch(`${baseUrl}${endpoint}`, { | |
1139 | - ...options, | |
1140 | - headers: { | |
1141 | - Authorization: `Bearer ${token}`, | |
1142 | - ...options.headers, | |
1143 | - }, | |
1144 | - }); | |
1145 | - } | |
1146 | - | |
1147 | - async put( | |
1148 | - key: string, | |
1149 | - data: Buffer | Readable, | |
1150 | - options?: PutOptions, | |
1151 | - ): Promise<void> { | |
1152 | - const fullPath = path.join(this.config.basePath, key); | |
1153 | - const buffer = Buffer.isBuffer(data) | |
1154 | - ? data | |
1155 | - : await this.streamToBuffer(data); | |
1156 | - | |
1157 | - // Use simple upload for files < 4MB, session upload for larger | |
1158 | - if (buffer.length < 4 * 1024 * 1024) { | |
1159 | - await this.graphRequest(`:/${encodeURIComponent(fullPath)}:/content`, { | |
1160 | - method: "PUT", | |
1161 | - headers: { | |
1162 | - "Content-Type": options?.contentType || "application/octet-stream", | |
1163 | - }, | |
1164 | - body: buffer as any, | |
1165 | - }); | |
1166 | - } else { | |
1167 | - // Create upload session for large files | |
1168 | - const sessionRes = await this.graphRequest( | |
1169 | - `:/${encodeURIComponent(fullPath)}:/createUploadSession`, | |
1170 | - { | |
1171 | - method: "POST", | |
1172 | - headers: { "Content-Type": "application/json" }, | |
1173 | - body: JSON.stringify({}), | |
1174 | - }, | |
1175 | - ); | |
1176 | - const session = await sessionRes.json(); | |
1177 | - | |
1178 | - // Upload in 10MB chunks | |
1179 | - const chunkSize = 10 * 1024 * 1024; | |
1180 | - for (let i = 0; i < buffer.length; i += chunkSize) { | |
1181 | - const chunk = buffer.subarray( | |
1182 | - i, | |
1183 | - Math.min(i + chunkSize, buffer.length), | |
1184 | - ); | |
1185 | - await fetch(session.uploadUrl, { | |
1186 | - method: "PUT", | |
1187 | - headers: { | |
1188 | - "Content-Length": String(chunk.length), | |
1189 | - "Content-Range": `bytes ${i}-${i + chunk.length - 1}/${buffer.length}`, | |
1190 | - }, | |
1191 | - body: chunk as any, | |
1192 | - }); | |
1193 | - } | |
1194 | - } | |
1195 | - } | |
1196 | - | |
1197 | - private async streamToBuffer(stream: Readable): Promise<Buffer> { | |
1198 | - const chunks: Buffer[] = []; | |
1199 | - for await (const chunk of stream) { | |
1200 | - chunks.push(Buffer.from(chunk)); | |
1201 | - } | |
1202 | - return Buffer.concat(chunks); | |
1203 | - } | |
1204 | - | |
1205 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
1206 | - const fullPath = path.join(this.config.basePath, key); | |
1207 | - const res = await this.graphRequest( | |
1208 | - `:/${encodeURIComponent(fullPath)}:/content`, | |
1209 | - { | |
1210 | - headers: options?.range | |
1211 | - ? { Range: `bytes=${options.range.start}-${options.range.end}` } | |
1212 | - : {}, | |
1213 | - }, | |
1214 | - ); | |
1215 | - | |
1216 | - if (!res.ok) throw new Error(`File not found: ${key}`); | |
1217 | - return Buffer.from(await res.arrayBuffer()); | |
1218 | - } | |
1219 | - | |
1220 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
1221 | - const buffer = await this.get(key, options); | |
1222 | - return Readable.from(buffer); | |
1223 | - } | |
1224 | - | |
1225 | - async delete(key: string): Promise<void> { | |
1226 | - const fullPath = path.join(this.config.basePath, key); | |
1227 | - await this.graphRequest(`:/${encodeURIComponent(fullPath)}`, { | |
1228 | - method: "DELETE", | |
1229 | - }); | |
1230 | - } | |
1231 | - | |
1232 | - async exists(key: string): Promise<boolean> { | |
1233 | - const fullPath = path.join(this.config.basePath, key); | |
1234 | - const res = await this.graphRequest(`:/${encodeURIComponent(fullPath)}`); | |
1235 | - return res.ok; | |
1236 | - } | |
1237 | - | |
1238 | - async list(options?: ListOptions): Promise<ListResult> { | |
1239 | - const prefix = path.join(this.config.basePath, options?.prefix || ""); | |
1240 | - const res = await this.graphRequest( | |
1241 | - `:/${encodeURIComponent(prefix)}:/children`, | |
1242 | - ); | |
1243 | - | |
1244 | - if (!res.ok) return { objects: [], isTruncated: false }; | |
1245 | - | |
1246 | - const data = await res.json(); | |
1247 | - const objects: StorageObject[] = []; | |
1248 | - const prefixes: string[] = []; | |
1249 | - | |
1250 | - for (const item of data.value || []) { | |
1251 | - if (item.folder) { | |
1252 | - prefixes.push(item.name + "/"); | |
1253 | - } else { | |
1254 | - objects.push({ | |
1255 | - key: item.name, | |
1256 | - size: item.size || 0, | |
1257 | - lastModified: new Date(item.lastModifiedDateTime), | |
1258 | - }); | |
1259 | - } | |
1260 | - } | |
1261 | - | |
1262 | - return { objects, prefixes, isTruncated: !!data["@odata.nextLink"] }; | |
1263 | - } | |
1264 | - | |
1265 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
1266 | - const sourcePath = path.join(this.config.basePath, sourceKey); | |
1267 | - const destPath = path.join(this.config.basePath, destKey); | |
1268 | - const destFolder = path.dirname(destPath); | |
1269 | - const destName = path.basename(destPath); | |
1270 | - | |
1271 | - // Get destination folder ID | |
1272 | - const folderRes = await this.graphRequest( | |
1273 | - `:/${encodeURIComponent(destFolder)}`, | |
1274 | - ); | |
1275 | - const folder = await folderRes.json(); | |
1276 | - | |
1277 | - await this.graphRequest(`:/${encodeURIComponent(sourcePath)}:/copy`, { | |
1278 | - method: "POST", | |
1279 | - headers: { "Content-Type": "application/json" }, | |
1280 | - body: JSON.stringify({ | |
1281 | - parentReference: { id: folder.id }, | |
1282 | - name: destName, | |
1283 | - }), | |
1284 | - }); | |
1285 | - } | |
1286 | - | |
1287 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
1288 | - const sourcePath = path.join(this.config.basePath, sourceKey); | |
1289 | - const destPath = path.join(this.config.basePath, destKey); | |
1290 | - const destFolder = path.dirname(destPath); | |
1291 | - const destName = path.basename(destPath); | |
1292 | - | |
1293 | - const folderRes = await this.graphRequest( | |
1294 | - `:/${encodeURIComponent(destFolder)}`, | |
1295 | - ); | |
1296 | - const folder = await folderRes.json(); | |
1297 | - | |
1298 | - await this.graphRequest(`:/${encodeURIComponent(sourcePath)}`, { | |
1299 | - method: "PATCH", | |
1300 | - headers: { "Content-Type": "application/json" }, | |
1301 | - body: JSON.stringify({ | |
1302 | - parentReference: { id: folder.id }, | |
1303 | - name: destName, | |
1304 | - }), | |
1305 | - }); | |
1306 | - } | |
1307 | - | |
1308 | - async getSignedUrl(key: string, expiresIn?: number): Promise<string> { | |
1309 | - const fullPath = path.join(this.config.basePath, key); | |
1310 | - const res = await this.graphRequest( | |
1311 | - `:/${encodeURIComponent(fullPath)}:/createLink`, | |
1312 | - { | |
1313 | - method: "POST", | |
1314 | - headers: { "Content-Type": "application/json" }, | |
1315 | - body: JSON.stringify({ type: "view", scope: "anonymous" }), | |
1316 | - }, | |
1317 | - ); | |
1318 | - const data = await res.json(); | |
1319 | - return ( | |
1320 | - data.link?.webUrl || `/api/storage/download/${encodeURIComponent(key)}` | |
1321 | - ); | |
1322 | - } | |
1323 | - | |
1324 | - async getSignedUploadUrl(key: string, _expiresIn?: number): Promise<string> { | |
1325 | - return `/api/storage/upload/${encodeURIComponent(key)}`; | |
1326 | - } | |
1327 | - | |
1328 | - async stat(key: string): Promise<StorageObject> { | |
1329 | - const fullPath = path.join(this.config.basePath, key); | |
1330 | - const res = await this.graphRequest(`:/${encodeURIComponent(fullPath)}`); | |
1331 | - if (!res.ok) throw new Error("File not found"); | |
1332 | - | |
1333 | - const data = await res.json(); | |
1334 | - return { | |
1335 | - key, | |
1336 | - size: data.size || 0, | |
1337 | - lastModified: new Date(data.lastModifiedDateTime), | |
1338 | - contentType: data.file?.mimeType, | |
1339 | - }; | |
1340 | - } | |
1341 | -} | |
1342 | - | |
1343 | -/** | |
1344 | - * Google Cloud Storage adapter | |
1345 | - */ | |
1346 | -export class GCSStorageAdapter extends StorageAdapter { | |
1347 | - private storage: any; | |
1348 | - | |
1349 | - constructor(config: StorageConfig) { | |
1350 | - super(config); | |
1351 | - } | |
1352 | - | |
1353 | - private async getStorage() { | |
1354 | - if (!this.storage) { | |
1355 | - const { Storage } = await import("@google-cloud/storage"); | |
1356 | - this.storage = new Storage({ | |
1357 | - projectId: this.config.gcsProjectId, | |
1358 | - keyFilename: this.config.gcsKeyFile, | |
1359 | - }); | |
1360 | - } | |
1361 | - return this.storage; | |
1362 | - } | |
1363 | - | |
1364 | - async put( | |
1365 | - key: string, | |
1366 | - data: Buffer | Readable, | |
1367 | - options?: PutOptions, | |
1368 | - ): Promise<void> { | |
1369 | - const storage = await this.getStorage(); | |
1370 | - const bucket = storage.bucket(this.config.bucket); | |
1371 | - const file = bucket.file(path.join(this.config.basePath, key)); | |
1372 | - | |
1373 | - if (Buffer.isBuffer(data)) { | |
1374 | - await file.save(data, { contentType: options?.contentType }); | |
1375 | - } else { | |
1376 | - await pipeline( | |
1377 | - data as Readable, | |
1378 | - file.createWriteStream({ contentType: options?.contentType }), | |
1379 | - ); | |
1380 | - } | |
1381 | - } | |
1382 | - | |
1383 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
1384 | - const storage = await this.getStorage(); | |
1385 | - const bucket = storage.bucket(this.config.bucket); | |
1386 | - const file = bucket.file(path.join(this.config.basePath, key)); | |
1387 | - | |
1388 | - const [contents] = await file.download(); | |
1389 | - if (options?.range) { | |
1390 | - return contents.subarray(options.range.start, options.range.end + 1); | |
1391 | - } | |
1392 | - return contents; | |
1393 | - } | |
1394 | - | |
1395 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
1396 | - const storage = await this.getStorage(); | |
1397 | - const bucket = storage.bucket(this.config.bucket); | |
1398 | - const file = bucket.file(path.join(this.config.basePath, key)); | |
1399 | - return file.createReadStream( | |
1400 | - options?.range | |
1401 | - ? { start: options.range.start, end: options.range.end } | |
1402 | - : undefined, | |
1403 | - ); | |
1404 | - } | |
1405 | - | |
1406 | - async delete(key: string): Promise<void> { | |
1407 | - const storage = await this.getStorage(); | |
1408 | - const bucket = storage.bucket(this.config.bucket); | |
1409 | - await bucket | |
1410 | - .file(path.join(this.config.basePath, key)) | |
1411 | - .delete() | |
1412 | - .catch(() => {}); | |
1413 | - } | |
1414 | - | |
1415 | - async exists(key: string): Promise<boolean> { | |
1416 | - const storage = await this.getStorage(); | |
1417 | - const bucket = storage.bucket(this.config.bucket); | |
1418 | - const [exists] = await bucket | |
1419 | - .file(path.join(this.config.basePath, key)) | |
1420 | - .exists(); | |
1421 | - return exists; | |
1422 | - } | |
1423 | - | |
1424 | - async list(options?: ListOptions): Promise<ListResult> { | |
1425 | - const storage = await this.getStorage(); | |
1426 | - const bucket = storage.bucket(this.config.bucket); | |
1427 | - const [files] = await bucket.getFiles({ | |
1428 | - prefix: path.join(this.config.basePath, options?.prefix || ""), | |
1429 | - maxResults: options?.maxKeys, | |
1430 | - }); | |
1431 | - | |
1432 | - return { | |
1433 | - objects: files.map((f: any) => ({ | |
1434 | - key: f.name.replace(this.config.basePath + "/", ""), | |
1435 | - size: parseInt(f.metadata.size || "0"), | |
1436 | - lastModified: new Date(f.metadata.updated), | |
1437 | - })), | |
1438 | - isTruncated: false, | |
1439 | - }; | |
1440 | - } | |
1441 | - | |
1442 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
1443 | - const storage = await this.getStorage(); | |
1444 | - const bucket = storage.bucket(this.config.bucket); | |
1445 | - await bucket | |
1446 | - .file(path.join(this.config.basePath, sourceKey)) | |
1447 | - .copy(bucket.file(path.join(this.config.basePath, destKey))); | |
1448 | - } | |
1449 | - | |
1450 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
1451 | - await this.copy(sourceKey, destKey); | |
1452 | - await this.delete(sourceKey); | |
1453 | - } | |
1454 | - | |
1455 | - async getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> { | |
1456 | - const storage = await this.getStorage(); | |
1457 | - const bucket = storage.bucket(this.config.bucket); | |
1458 | - const [url] = await bucket | |
1459 | - .file(path.join(this.config.basePath, key)) | |
1460 | - .getSignedUrl({ | |
1461 | - action: "read", | |
1462 | - expires: Date.now() + expiresIn * 1000, | |
1463 | - }); | |
1464 | - return url; | |
1465 | - } | |
1466 | - | |
1467 | - async getSignedUploadUrl( | |
1468 | - key: string, | |
1469 | - expiresIn: number = 3600, | |
1470 | - ): Promise<string> { | |
1471 | - const storage = await this.getStorage(); | |
1472 | - const bucket = storage.bucket(this.config.bucket); | |
1473 | - const [url] = await bucket | |
1474 | - .file(path.join(this.config.basePath, key)) | |
1475 | - .getSignedUrl({ | |
1476 | - action: "write", | |
1477 | - expires: Date.now() + expiresIn * 1000, | |
1478 | - }); | |
1479 | - return url; | |
1480 | - } | |
1481 | - | |
1482 | - async stat(key: string): Promise<StorageObject> { | |
1483 | - const storage = await this.getStorage(); | |
1484 | - const bucket = storage.bucket(this.config.bucket); | |
1485 | - const [metadata] = await bucket | |
1486 | - .file(path.join(this.config.basePath, key)) | |
1487 | - .getMetadata(); | |
1488 | - return { | |
1489 | - key, | |
1490 | - size: parseInt(metadata.size || "0"), | |
1491 | - lastModified: new Date(metadata.updated), | |
1492 | - contentType: metadata.contentType, | |
1493 | - }; | |
1494 | - } | |
1495 | -} | |
1496 | - | |
1497 | -/** | |
1498 | - * Azure Blob Storage adapter | |
1499 | - */ | |
1500 | -export class AzureStorageAdapter extends StorageAdapter { | |
1501 | - private client: any; | |
1502 | - | |
1503 | - constructor(config: StorageConfig) { | |
1504 | - super(config); | |
1505 | - } | |
1506 | - | |
1507 | - private async getClient() { | |
1508 | - if (!this.client) { | |
1509 | - const { BlobServiceClient, StorageSharedKeyCredential } = | |
1510 | - await import("@azure/storage-blob"); | |
1511 | - const credential = new StorageSharedKeyCredential( | |
1512 | - this.config.azureAccountName!, | |
1513 | - this.config.azureAccountKey!, | |
1514 | - ); | |
1515 | - this.client = new BlobServiceClient( | |
1516 | - `https://${this.config.azureAccountName}.blob.core.windows.net`, | |
1517 | - credential, | |
1518 | - ); | |
1519 | - } | |
1520 | - return this.client; | |
1521 | - } | |
1522 | - | |
1523 | - private async getContainerClient() { | |
1524 | - const client = await this.getClient(); | |
1525 | - return client.getContainerClient( | |
1526 | - this.config.azureContainerName || "default", | |
1527 | - ); | |
1528 | - } | |
1529 | - | |
1530 | - async put( | |
1531 | - key: string, | |
1532 | - data: Buffer | Readable, | |
1533 | - options?: PutOptions, | |
1534 | - ): Promise<void> { | |
1535 | - const container = await this.getContainerClient(); | |
1536 | - const blob = container.getBlockBlobClient( | |
1537 | - path.join(this.config.basePath, key), | |
1538 | - ); | |
1539 | - | |
1540 | - if (Buffer.isBuffer(data)) { | |
1541 | - await blob.upload(data, data.length, { | |
1542 | - blobHTTPHeaders: { blobContentType: options?.contentType }, | |
1543 | - }); | |
1544 | - } else { | |
1545 | - const chunks: Buffer[] = []; | |
1546 | - for await (const chunk of data as Readable) { | |
1547 | - chunks.push(Buffer.from(chunk)); | |
1548 | - } | |
1549 | - const buffer = Buffer.concat(chunks); | |
1550 | - await blob.upload(buffer, buffer.length, { | |
1551 | - blobHTTPHeaders: { blobContentType: options?.contentType }, | |
1552 | - }); | |
1553 | - } | |
1554 | - } | |
1555 | - | |
1556 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
1557 | - const container = await this.getContainerClient(); | |
1558 | - const blob = container.getBlockBlobClient( | |
1559 | - path.join(this.config.basePath, key), | |
1560 | - ); | |
1561 | - const response = await blob.download( | |
1562 | - options?.range?.start, | |
1563 | - options?.range ? options.range.end - options.range.start + 1 : undefined, | |
1564 | - ); | |
1565 | - | |
1566 | - const chunks: Buffer[] = []; | |
1567 | - for await (const chunk of response.readableStreamBody as any) { | |
1568 | - chunks.push(Buffer.from(chunk)); | |
1569 | - } | |
1570 | - return Buffer.concat(chunks); | |
1571 | - } | |
1572 | - | |
1573 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
1574 | - const container = await this.getContainerClient(); | |
1575 | - const blob = container.getBlockBlobClient( | |
1576 | - path.join(this.config.basePath, key), | |
1577 | - ); | |
1578 | - const response = await blob.download( | |
1579 | - options?.range?.start, | |
1580 | - options?.range ? options.range.end - options.range.start + 1 : undefined, | |
1581 | - ); | |
1582 | - return Readable.from(response.readableStreamBody as any); | |
1583 | - } | |
1584 | - | |
1585 | - async delete(key: string): Promise<void> { | |
1586 | - const container = await this.getContainerClient(); | |
1587 | - await container | |
1588 | - .getBlockBlobClient(path.join(this.config.basePath, key)) | |
1589 | - .delete() | |
1590 | - .catch(() => {}); | |
1591 | - } | |
1592 | - | |
1593 | - async exists(key: string): Promise<boolean> { | |
1594 | - const container = await this.getContainerClient(); | |
1595 | - return container | |
1596 | - .getBlockBlobClient(path.join(this.config.basePath, key)) | |
1597 | - .exists(); | |
1598 | - } | |
1599 | - | |
1600 | - async list(options?: ListOptions): Promise<ListResult> { | |
1601 | - const container = await this.getContainerClient(); | |
1602 | - const objects: StorageObject[] = []; | |
1603 | - | |
1604 | - for await (const blob of container.listBlobsFlat({ | |
1605 | - prefix: path.join(this.config.basePath, options?.prefix || ""), | |
1606 | - })) { | |
1607 | - objects.push({ | |
1608 | - key: blob.name.replace(this.config.basePath + "/", ""), | |
1609 | - size: blob.properties.contentLength || 0, | |
1610 | - lastModified: blob.properties.lastModified || new Date(), | |
1611 | - }); | |
1612 | - if (options?.maxKeys && objects.length >= options.maxKeys) break; | |
1613 | - } | |
1614 | - | |
1615 | - return { objects, isTruncated: false }; | |
1616 | - } | |
1617 | - | |
1618 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
1619 | - const container = await this.getContainerClient(); | |
1620 | - const sourceBlob = container.getBlockBlobClient( | |
1621 | - path.join(this.config.basePath, sourceKey), | |
1622 | - ); | |
1623 | - const destBlob = container.getBlockBlobClient( | |
1624 | - path.join(this.config.basePath, destKey), | |
1625 | - ); | |
1626 | - await destBlob.beginCopyFromURL(sourceBlob.url); | |
1627 | - } | |
1628 | - | |
1629 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
1630 | - await this.copy(sourceKey, destKey); | |
1631 | - await this.delete(sourceKey); | |
1632 | - } | |
1633 | - | |
1634 | - async getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> { | |
1635 | - const { | |
1636 | - generateBlobSASQueryParameters, | |
1637 | - BlobSASPermissions, | |
1638 | - StorageSharedKeyCredential, | |
1639 | - } = await import("@azure/storage-blob"); | |
1640 | - const credential = new StorageSharedKeyCredential( | |
1641 | - this.config.azureAccountName!, | |
1642 | - this.config.azureAccountKey!, | |
1643 | - ); | |
1644 | - const sas = generateBlobSASQueryParameters( | |
1645 | - { | |
1646 | - containerName: this.config.azureContainerName!, | |
1647 | - blobName: path.join(this.config.basePath, key), | |
1648 | - permissions: BlobSASPermissions.parse("r"), | |
1649 | - expiresOn: new Date(Date.now() + expiresIn * 1000), | |
1650 | - }, | |
1651 | - credential, | |
1652 | - ); | |
1653 | - return `https://${this.config.azureAccountName}.blob.core.windows.net/${this.config.azureContainerName}/${path.join(this.config.basePath, key)}?${sas}`; | |
1654 | - } | |
1655 | - | |
1656 | - async getSignedUploadUrl( | |
1657 | - key: string, | |
1658 | - expiresIn: number = 3600, | |
1659 | - ): Promise<string> { | |
1660 | - return `/api/storage/upload/${encodeURIComponent(key)}`; | |
1661 | - } | |
1662 | - | |
1663 | - async stat(key: string): Promise<StorageObject> { | |
1664 | - const container = await this.getContainerClient(); | |
1665 | - const blob = container.getBlockBlobClient( | |
1666 | - path.join(this.config.basePath, key), | |
1667 | - ); | |
1668 | - const props = await blob.getProperties(); | |
1669 | - return { | |
1670 | - key, | |
1671 | - size: props.contentLength || 0, | |
1672 | - lastModified: props.lastModified || new Date(), | |
1673 | - contentType: props.contentType, | |
1674 | - }; | |
1675 | - } | |
1676 | -} | |
1677 | - | |
1678 | -/** | |
1679 | - * Dropbox storage adapter | |
1680 | - */ | |
1681 | -export class DropboxStorageAdapter extends StorageAdapter { | |
1682 | - private accessToken: string; | |
1683 | - | |
1684 | - constructor(config: StorageConfig) { | |
1685 | - super(config); | |
1686 | - this.accessToken = config.dropboxAccessToken || ""; | |
1687 | - } | |
1688 | - | |
1689 | - private async dropboxRequest( | |
1690 | - endpoint: string, | |
1691 | - options: RequestInit = {}, | |
1692 | - isContent = false, | |
1693 | - ): Promise<Response> { | |
1694 | - const baseUrl = isContent | |
1695 | - ? "https://content.dropboxapi.com/2" | |
1696 | - : "https://api.dropboxapi.com/2"; | |
1697 | - return fetch(`${baseUrl}${endpoint}`, { | |
1698 | - ...options, | |
1699 | - headers: { | |
1700 | - Authorization: `Bearer ${this.accessToken}`, | |
1701 | - ...options.headers, | |
1702 | - }, | |
1703 | - }); | |
1704 | - } | |
1705 | - | |
1706 | - async put( | |
1707 | - key: string, | |
1708 | - data: Buffer | Readable, | |
1709 | - options?: PutOptions, | |
1710 | - ): Promise<void> { | |
1711 | - const fullPath = "/" + path.join(this.config.basePath, key); | |
1712 | - const buffer = Buffer.isBuffer(data) | |
1713 | - ? data | |
1714 | - : await this.streamToBuffer(data as Readable); | |
1715 | - | |
1716 | - await this.dropboxRequest( | |
1717 | - "/files/upload", | |
1718 | - { | |
1719 | - method: "POST", | |
1720 | - headers: { | |
1721 | - "Content-Type": "application/octet-stream", | |
1722 | - "Dropbox-API-Arg": JSON.stringify({ | |
1723 | - path: fullPath, | |
1724 | - mode: "overwrite", | |
1725 | - }), | |
1726 | - }, | |
1727 | - body: buffer as any, | |
1728 | - }, | |
1729 | - true, | |
1730 | - ); | |
1731 | - } | |
1732 | - | |
1733 | - private async streamToBuffer(stream: Readable): Promise<Buffer> { | |
1734 | - const chunks: Buffer[] = []; | |
1735 | - for await (const chunk of stream) { | |
1736 | - chunks.push(Buffer.from(chunk)); | |
1737 | - } | |
1738 | - return Buffer.concat(chunks); | |
1739 | - } | |
1740 | - | |
1741 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
1742 | - const fullPath = "/" + path.join(this.config.basePath, key); | |
1743 | - const res = await this.dropboxRequest( | |
1744 | - "/files/download", | |
1745 | - { | |
1746 | - method: "POST", | |
1747 | - headers: { "Dropbox-API-Arg": JSON.stringify({ path: fullPath }) }, | |
1748 | - }, | |
1749 | - true, | |
1750 | - ); | |
1751 | - | |
1752 | - if (!res.ok) throw new Error(`File not found: ${key}`); | |
1753 | - const buffer = Buffer.from(await res.arrayBuffer()); | |
1754 | - return options?.range | |
1755 | - ? buffer.slice(options.range.start, options.range.end + 1) | |
1756 | - : buffer; | |
1757 | - } | |
1758 | - | |
1759 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
1760 | - return Readable.from(await this.get(key, options)); | |
1761 | - } | |
1762 | - | |
1763 | - async delete(key: string): Promise<void> { | |
1764 | - const fullPath = "/" + path.join(this.config.basePath, key); | |
1765 | - await this.dropboxRequest("/files/delete_v2", { | |
1766 | - method: "POST", | |
1767 | - headers: { "Content-Type": "application/json" }, | |
1768 | - body: JSON.stringify({ path: fullPath }), | |
1769 | - }).catch(() => {}); | |
1770 | - } | |
1771 | - | |
1772 | - async exists(key: string): Promise<boolean> { | |
1773 | - try { | |
1774 | - await this.stat(key); | |
1775 | - return true; | |
1776 | - } catch { | |
1777 | - return false; | |
1778 | - } | |
1779 | - } | |
1780 | - | |
1781 | - async list(options?: ListOptions): Promise<ListResult> { | |
1782 | - const fullPath = | |
1783 | - "/" + path.join(this.config.basePath, options?.prefix || ""); | |
1784 | - const res = await this.dropboxRequest("/files/list_folder", { | |
1785 | - method: "POST", | |
1786 | - headers: { "Content-Type": "application/json" }, | |
1787 | - body: JSON.stringify({ | |
1788 | - path: fullPath === "/" ? "" : fullPath, | |
1789 | - limit: options?.maxKeys || 100, | |
1790 | - }), | |
1791 | - }); | |
1792 | - | |
1793 | - if (!res.ok) return { objects: [], isTruncated: false }; | |
1794 | - const data = await res.json(); | |
1795 | - | |
1796 | - return { | |
1797 | - objects: (data.entries || []) | |
1798 | - .filter((e: any) => e[".tag"] === "file") | |
1799 | - .map((e: any) => ({ | |
1800 | - key: e.path_display.replace("/" + this.config.basePath + "/", ""), | |
1801 | - size: e.size || 0, | |
1802 | - lastModified: new Date(e.server_modified), | |
1803 | - })), | |
1804 | - prefixes: (data.entries || []) | |
1805 | - .filter((e: any) => e[".tag"] === "folder") | |
1806 | - .map((e: any) => e.name + "/"), | |
1807 | - isTruncated: data.has_more, | |
1808 | - }; | |
1809 | - } | |
1810 | - | |
1811 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
1812 | - await this.dropboxRequest("/files/copy_v2", { | |
1813 | - method: "POST", | |
1814 | - headers: { "Content-Type": "application/json" }, | |
1815 | - body: JSON.stringify({ | |
1816 | - from_path: "/" + path.join(this.config.basePath, sourceKey), | |
1817 | - to_path: "/" + path.join(this.config.basePath, destKey), | |
1818 | - }), | |
1819 | - }); | |
1820 | - } | |
1821 | - | |
1822 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
1823 | - await this.dropboxRequest("/files/move_v2", { | |
1824 | - method: "POST", | |
1825 | - headers: { "Content-Type": "application/json" }, | |
1826 | - body: JSON.stringify({ | |
1827 | - from_path: "/" + path.join(this.config.basePath, sourceKey), | |
1828 | - to_path: "/" + path.join(this.config.basePath, destKey), | |
1829 | - }), | |
1830 | - }); | |
1831 | - } | |
1832 | - | |
1833 | - async getSignedUrl(key: string, expiresIn?: number): Promise<string> { | |
1834 | - const fullPath = "/" + path.join(this.config.basePath, key); | |
1835 | - const res = await this.dropboxRequest("/files/get_temporary_link", { | |
1836 | - method: "POST", | |
1837 | - headers: { "Content-Type": "application/json" }, | |
1838 | - body: JSON.stringify({ path: fullPath }), | |
1839 | - }); | |
1840 | - const data = await res.json(); | |
1841 | - return data.link || `/api/storage/download/${encodeURIComponent(key)}`; | |
1842 | - } | |
1843 | - | |
1844 | - async getSignedUploadUrl(key: string, expiresIn?: number): Promise<string> { | |
1845 | - return `/api/storage/upload/${encodeURIComponent(key)}`; | |
1846 | - } | |
1847 | - | |
1848 | - async stat(key: string): Promise<StorageObject> { | |
1849 | - const fullPath = "/" + path.join(this.config.basePath, key); | |
1850 | - const res = await this.dropboxRequest("/files/get_metadata", { | |
1851 | - method: "POST", | |
1852 | - headers: { "Content-Type": "application/json" }, | |
1853 | - body: JSON.stringify({ path: fullPath }), | |
1854 | - }); | |
1855 | - if (!res.ok) throw new Error("File not found"); | |
1856 | - const data = await res.json(); | |
1857 | - return { | |
1858 | - key, | |
1859 | - size: data.size || 0, | |
1860 | - lastModified: new Date(data.server_modified), | |
1861 | - }; | |
1862 | - } | |
1863 | -} | |
1864 | - | |
1865 | -/** | |
1866 | - * FTP storage adapter | |
1867 | - */ | |
1868 | -export class FTPStorageAdapter extends StorageAdapter { | |
1869 | - private client: any; | |
1870 | - private lastActivity: number = 0; | |
1871 | - private static readonly IDLE_TIMEOUT_MS = 30_000; // 30s idle → reconnect | |
1872 | - | |
1873 | - constructor(config: StorageConfig) { | |
1874 | - super(config); | |
1875 | - } | |
1876 | - | |
1877 | - private async getClient() { | |
1878 | - const now = Date.now(); | |
1879 | - // Reconnect if client is stale or missing | |
1880 | - if ( | |
1881 | - this.client && | |
1882 | - now - this.lastActivity > FTPStorageAdapter.IDLE_TIMEOUT_MS | |
1883 | - ) { | |
1884 | - try { | |
1885 | - this.client.close(); | |
1886 | - } catch { | |
1887 | - /* ignore */ | |
1888 | - } | |
1889 | - this.client = null; | |
1890 | - } | |
1891 | - if (!this.client) { | |
1892 | - const { Client } = await import("basic-ftp"); | |
1893 | - this.client = new Client(); | |
1894 | - this.client.ftp.verbose = false; | |
1895 | - await this.client.access({ | |
1896 | - host: this.config.ftpHost, | |
1897 | - port: this.config.ftpPort || 21, | |
1898 | - user: this.config.ftpUser, | |
1899 | - password: this.config.ftpPassword, | |
1900 | - secure: this.config.ftpSecure || false, | |
1901 | - }); | |
1902 | - } | |
1903 | - this.lastActivity = now; | |
1904 | - return this.client; | |
1905 | - } | |
1906 | - | |
1907 | - /** Wrapper that retries once on connection error */ | |
1908 | - private async withClient<T>(fn: (client: any) => Promise<T>): Promise<T> { | |
1909 | - try { | |
1910 | - const client = await this.getClient(); | |
1911 | - return await fn(client); | |
1912 | - } catch (error: any) { | |
1913 | - // On connection-related errors, reset client and retry once | |
1914 | - if ( | |
1915 | - error?.code === "ECONNRESET" || | |
1916 | - error?.code === "ENOTFOUND" || | |
1917 | - error?.code === "ETIMEDOUT" || | |
1918 | - error?.message?.includes("closed") | |
1919 | - ) { | |
1920 | - logger.warn({ err: error }, "[FTP] Connection lost, reconnecting..."); | |
1921 | - try { | |
1922 | - this.client?.close(); | |
1923 | - } catch { | |
1924 | - /* ignore */ | |
1925 | - } | |
1926 | - this.client = null; | |
1927 | - const client = await this.getClient(); | |
1928 | - return await fn(client); | |
1929 | - } | |
1930 | - throw error; | |
1931 | - } | |
1932 | - } | |
1933 | - | |
1934 | - async put( | |
1935 | - key: string, | |
1936 | - data: Buffer | Readable, | |
1937 | - options?: PutOptions, | |
1938 | - ): Promise<void> { | |
1939 | - await this.withClient(async (client) => { | |
1940 | - const fullPath = path.join(this.config.basePath, key); | |
1941 | - const dir = path.dirname(fullPath); | |
1942 | - await client.ensureDir(dir); | |
1943 | - if (Buffer.isBuffer(data)) { | |
1944 | - await client.uploadFrom(Readable.from(data), fullPath); | |
1945 | - } else { | |
1946 | - await client.uploadFrom(data as Readable, fullPath); | |
1947 | - } | |
1948 | - }); | |
1949 | - } | |
1950 | - | |
1951 | - async get(key: string, options?: GetOptions): Promise<Buffer> { | |
1952 | - return this.withClient(async (client) => { | |
1953 | - const fullPath = path.join(this.config.basePath, key); | |
1954 | - const tempDir = path.join(process.cwd(), "data", "temp"); | |
1955 | - await fs.mkdir(tempDir, { recursive: true }); | |
1956 | - const tempFile = path.join( | |
1957 | - tempDir, | |
1958 | - `ftp-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`, | |
1959 | - ); | |
1960 | - try { | |
1961 | - await client.downloadTo(tempFile, fullPath); | |
1962 | - const buffer = await fs.readFile(tempFile); | |
1963 | - return options?.range | |
1964 | - ? buffer.slice(options.range.start, options.range.end + 1) | |
1965 | - : buffer; | |
1966 | - } finally { | |
1967 | - await fs.unlink(tempFile).catch(() => {}); | |
1968 | - } | |
1969 | - }); | |
1970 | - } | |
1971 | - | |
1972 | - async getStream(key: string, options?: GetOptions): Promise<Readable> { | |
1973 | - return Readable.from(await this.get(key, options)); | |
1974 | - } | |
1975 | - | |
1976 | - async delete(key: string): Promise<void> { | |
1977 | - await this.withClient(async (client) => { | |
1978 | - await client.remove(path.join(this.config.basePath, key)).catch(() => {}); | |
1979 | - }); | |
1980 | - } | |
1981 | - | |
1982 | - async exists(key: string): Promise<boolean> { | |
1983 | - return this.withClient(async (client) => { | |
1984 | - try { | |
1985 | - await client.size(path.join(this.config.basePath, key)); | |
1986 | - return true; | |
1987 | - } catch { | |
1988 | - return false; | |
1989 | - } | |
1990 | - }); | |
1991 | - } | |
1992 | - | |
1993 | - async list(options?: ListOptions): Promise<ListResult> { | |
1994 | - return this.withClient(async (client) => { | |
1995 | - const fullPath = path.join(this.config.basePath, options?.prefix || ""); | |
1996 | - const files = await client.list(fullPath); | |
1997 | - return { | |
1998 | - objects: files | |
1999 | - .filter((f: any) => !f.isDirectory) | |
2000 | - .map((f: any) => ({ | |
2001 | - key: path.join(options?.prefix || "", f.name), | |
2002 | - size: f.size || 0, | |
2003 | - lastModified: f.modifiedAt || new Date(), | |
2004 | - })), | |
2005 | - prefixes: files | |
2006 | - .filter((f: any) => f.isDirectory) | |
2007 | - .map((f: any) => f.name + "/"), | |
2008 | - isTruncated: false, | |
2009 | - }; | |
2010 | - }); | |
2011 | - } | |
2012 | - | |
2013 | - async copy(sourceKey: string, destKey: string): Promise<void> { | |
2014 | - const data = await this.get(sourceKey); | |
2015 | - await this.put(destKey, data); | |
2016 | - } | |
2017 | - | |
2018 | - async move(sourceKey: string, destKey: string): Promise<void> { | |
2019 | - await this.withClient(async (client) => { | |
2020 | - await client.rename( | |
2021 | - path.join(this.config.basePath, sourceKey), | |
2022 | - path.join(this.config.basePath, destKey), | |
2023 | - ); | |
2024 | - }); | |
2025 | - } | |
2026 | - | |
2027 | - async getSignedUrl(key: string, expiresIn?: number): Promise<string> { | |
2028 | - return `/api/storage/download/${encodeURIComponent(key)}`; | |
2029 | - } | |
2030 | - | |
2031 | - async getSignedUploadUrl(key: string, expiresIn?: number): Promise<string> { | |
2032 | - return `/api/storage/upload/${encodeURIComponent(key)}`; | |
2033 | - } | |
2034 | - | |
2035 | - async stat(key: string): Promise<StorageObject> { | |
2036 | - return this.withClient(async (client) => { | |
2037 | - const size = await client.size(path.join(this.config.basePath, key)); | |
2038 | - return { key, size, lastModified: new Date() }; | |
2039 | - }); | |
2040 | - } | |
2041 | -} | |
2042 | - | |
2043 | -/** | |
2044 | - * Storage factory | |
2045 | - */ | |
2046 | -export function createStorageAdapter(config: StorageConfig): StorageAdapter { | |
2047 | - logger.info( | |
2048 | - `[createStorageAdapter] Creating adapter for type: ${config.type}`, | |
2049 | - ); | |
2050 | - switch (config.type) { | |
2051 | - case "local": | |
2052 | - return new LocalStorageAdapter(config); | |
2053 | - case "s3": | |
2054 | - return new S3StorageAdapter(config); | |
2055 | - case "gcs": | |
2056 | - return new GCSStorageAdapter(config); | |
2057 | - case "azure": | |
2058 | - return new AzureStorageAdapter(config); | |
2059 | - case "dropbox": | |
2060 | - return new DropboxStorageAdapter(config); | |
2061 | - case "ftp": | |
2062 | - return new FTPStorageAdapter(config); | |
2063 | - case "rclone": | |
2064 | - return new RcloneStorageAdapter(config); | |
2065 | - case "gdrive": | |
2066 | - return new GoogleDriveStorageAdapter(config); | |
2067 | - case "onedrive": | |
2068 | - return new OneDriveStorageAdapter(config); | |
2069 | - default: | |
2070 | - throw new Error(`Unknown storage type: ${config.type}`); | |
2071 | - } | |
2072 | -} | |
2073 | - | |
2074 | -// Singleton instance | |
2075 | -let storageInstance: StorageAdapter | null = null; | |
2076 | -let lastConfigHash: string = ""; | |
2077 | - | |
2078 | -/** | |
2079 | - * Reset storage singleton - forces re-initialization on next getStorage() call | |
2080 | - * Useful after changing environment variables or for testing | |
2081 | - */ | |
2082 | -export function resetStorage(): void { | |
2083 | - logger.info("[resetStorage] Clearing storage singleton"); | |
2084 | - storageInstance = null; | |
2085 | - lastConfigHash = ""; | |
2086 | -} | |
2087 | - | |
2088 | -/** | |
2089 | - * Check storage health/connectivity | |
2090 | - * Returns true if storage is accessible, false otherwise | |
2091 | - */ | |
2092 | -export async function checkStorageHealth(): Promise<{ | |
2093 | - healthy: boolean; | |
2094 | - type: string; | |
2095 | - error?: string; | |
2096 | -}> { | |
2097 | - try { | |
2098 | - const storage = await getStorage(); | |
2099 | - const config = getStorageConfig(); | |
2100 | - | |
2101 | - // Try to list root - if this works, storage is accessible | |
2102 | - await storage.list({ maxKeys: 1 }); | |
2103 | - | |
2104 | - return { healthy: true, type: config.type }; | |
2105 | - } catch (error: any) { | |
2106 | - return { healthy: false, type: "unknown", error: error.message }; | |
2107 | - } | |
2108 | -} | |
2109 | - | |
2110 | -/** | |
2111 | - * Get current storage configuration from environment | |
2112 | - */ | |
2113 | -export function getStorageConfig(): StorageConfig { | |
2114 | - const storageType = (import.meta.env?.STORAGE_TYPE || | |
2115 | - process.env.STORAGE_TYPE || | |
2116 | - "local") as StorageConfig["type"]; | |
2117 | - | |
2118 | - // BasePath should be empty for cloud storage (S3, GCS, etc) | |
2119 | - // Only use directory path for local storage | |
2120 | - let basePath = ""; | |
2121 | - if (storageType === "local") { | |
2122 | - basePath = | |
2123 | - import.meta.env?.STORAGE_PATH || | |
2124 | - process.env.STORAGE_PATH || | |
2125 | - "./data/storage"; | |
2126 | - } | |
2127 | - | |
2128 | - return { | |
2129 | - type: storageType, | |
2130 | - basePath, | |
2131 | - bucket: import.meta.env?.STORAGE_BUCKET || process.env.STORAGE_BUCKET, | |
2132 | - region: import.meta.env?.STORAGE_REGION || process.env.STORAGE_REGION, | |
2133 | - endpoint: import.meta.env?.STORAGE_ENDPOINT || process.env.STORAGE_ENDPOINT, | |
2134 | - accessKeyId: | |
2135 | - import.meta.env?.STORAGE_ACCESS_KEY_ID || | |
2136 | - process.env.STORAGE_ACCESS_KEY_ID, | |
2137 | - secretAccessKey: | |
2138 | - import.meta.env?.STORAGE_SECRET_ACCESS_KEY || | |
2139 | - process.env.STORAGE_SECRET_ACCESS_KEY, | |
2140 | - rcloneRemote: import.meta.env?.RCLONE_REMOTE || process.env.RCLONE_REMOTE, | |
2141 | - // Google Drive | |
2142 | - googleClientId: | |
2143 | - import.meta.env?.OAUTH_GOOGLE_CLIENT_ID || | |
2144 | - process.env.OAUTH_GOOGLE_CLIENT_ID || | |
2145 | - import.meta.env?.GOOGLE_CLIENT_ID || | |
2146 | - process.env.GOOGLE_CLIENT_ID, | |
2147 | - googleClientSecret: | |
2148 | - import.meta.env?.OAUTH_GOOGLE_CLIENT_SECRET || | |
2149 | - process.env.OAUTH_GOOGLE_CLIENT_SECRET || | |
2150 | - import.meta.env?.GOOGLE_CLIENT_SECRET || | |
2151 | - process.env.GOOGLE_CLIENT_SECRET, | |
2152 | - googleRefreshToken: | |
2153 | - import.meta.env?.GOOGLE_REFRESH_TOKEN || process.env.GOOGLE_REFRESH_TOKEN, | |
2154 | - googleFolderId: | |
2155 | - import.meta.env?.GOOGLE_FOLDER_ID || process.env.GOOGLE_FOLDER_ID, | |
2156 | - // FTP | |
2157 | - ftpHost: import.meta.env?.FTP_HOST || process.env.FTP_HOST, | |
2158 | - ftpPort: parseInt( | |
2159 | - import.meta.env?.FTP_PORT || process.env.FTP_PORT || "21", | |
2160 | - ), | |
2161 | - ftpUser: import.meta.env?.FTP_USER || process.env.FTP_USER, | |
2162 | - ftpPassword: import.meta.env?.FTP_PASSWORD || process.env.FTP_PASSWORD, | |
2163 | - // GCS | |
2164 | - gcsProjectId: import.meta.env?.GCS_PROJECT_ID || process.env.GCS_PROJECT_ID, | |
2165 | - gcsKeyFile: import.meta.env?.GCS_KEY_FILE || process.env.GCS_KEY_FILE, | |
2166 | - // Azure | |
2167 | - azureAccountName: | |
2168 | - import.meta.env?.AZURE_STORAGE_ACCOUNT || | |
2169 | - process.env.AZURE_STORAGE_ACCOUNT, | |
2170 | - azureAccountKey: | |
2171 | - import.meta.env?.AZURE_STORAGE_KEY || process.env.AZURE_STORAGE_KEY, | |
2172 | - azureContainerName: | |
2173 | - import.meta.env?.AZURE_CONTAINER_NAME || process.env.AZURE_CONTAINER_NAME, | |
2174 | - // Dropbox | |
2175 | - dropboxAccessToken: | |
2176 | - import.meta.env?.DROPBOX_ACCESS_TOKEN || process.env.DROPBOX_ACCESS_TOKEN, | |
2177 | - }; | |
2178 | -} | |
2179 | - | |
2180 | -export async function getStorage(): Promise<StorageAdapter> { | |
2181 | - // Check if we should prioritize environment variables over database | |
2182 | - const envPriority = | |
2183 | - import.meta.env?.STORAGE_ENV_PRIORITY === "true" || | |
2184 | - process.env.STORAGE_ENV_PRIORITY === "true"; | |
2185 | - | |
2186 | - // Check DB for config override (unless env priority is set) | |
2187 | - if (!envPriority) { | |
2188 | - try { | |
2189 | - const { getDatabase, schema } = await import("@/db"); | |
2190 | - const { eq } = await import("drizzle-orm"); | |
2191 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
2192 | - | |
2193 | - const configRow = await db.query.systemConfig.findFirst({ | |
2194 | - where: eq(schema.systemConfig.key, "storage_config"), | |
2195 | - }); | |
2196 | - | |
2197 | - if (configRow) { | |
2198 | - // If config changed, re-init | |
2199 | - if (configRow.value !== lastConfigHash) { | |
2200 | - const config = JSON.parse(configRow.value) as StorageConfig; | |
2201 | - logger.info(`[getStorage] Using DB config: type=${config.type}`); | |
2202 | - storageInstance = createStorageAdapter(config); | |
2203 | - lastConfigHash = configRow.value; | |
2204 | - } | |
2205 | - return storageInstance!; | |
2206 | - } | |
2207 | - } catch (e) { | |
2208 | - logger.warn( | |
2209 | - "[getStorage] Failed to fetch DB config, using env fallback", | |
2210 | - e, | |
2211 | - ); | |
2212 | - } | |
2213 | - } else { | |
2214 | - logger.info("[getStorage] STORAGE_ENV_PRIORITY=true, skipping DB config"); | |
2215 | - } | |
2216 | - | |
2217 | - if (!storageInstance) { | |
2218 | - const config = getStorageConfig(); | |
2219 | - logger.info( | |
2220 | - `[getStorage] Using env config: type=${config.type}, basePath="${config.basePath}"`, | |
2221 | - ); | |
2222 | - storageInstance = createStorageAdapter(config); | |
2223 | - lastConfigHash = JSON.stringify(config); | |
2224 | - } | |
2225 | - return storageInstance; | |
2226 | -} | |
2227 | - | |
2228 | -export async function updateStorageConfig( | |
2229 | - config: StorageConfig, | |
2230 | - userId?: string, | |
2231 | -): Promise<void> { | |
2232 | - const { getDatabase, schema } = await import("@/db"); | |
2233 | - const { eq } = await import("drizzle-orm"); | |
2234 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
2235 | - | |
2236 | - // Validate config (basic) | |
2237 | - if (!config.type) throw new Error("Storage type required"); | |
2238 | - | |
2239 | - // Save to DB | |
2240 | - const value = JSON.stringify(config); | |
2241 | - | |
2242 | - await db | |
2243 | - .insert(schema.systemConfig) | |
2244 | - .values({ | |
2245 | - key: "storage_config", | |
2246 | - value, | |
2247 | - updatedById: userId, | |
2248 | - }) | |
2249 | - .onConflictDoUpdate({ | |
2250 | - target: schema.systemConfig.key, | |
2251 | - set: { | |
2252 | - value, | |
2253 | - updatedAt: new Date(), | |
2254 | - updatedById: userId, | |
2255 | - }, | |
2256 | - }); | |
2257 | - | |
2258 | - // Force reload next time | |
2259 | - lastConfigHash = ""; | |
2260 | - storageInstance = null; | |
2261 | -} | |
2262 | - | |
2263 | -/** | |
2264 | - * Helper functions for common operations | |
2265 | - */ | |
2266 | - | |
2267 | -// Upload a file | |
2268 | -export async function uploadFile( | |
2269 | - key: string, | |
2270 | - filePath: string, | |
2271 | - options?: PutOptions, | |
2272 | -): Promise<void> { | |
2273 | - const storage = await getStorage(); | |
2274 | - const stream = createReadStream(filePath); | |
2275 | - await storage.put(key, stream, options); | |
2276 | -} | |
2277 | - | |
2278 | -// Download a file | |
2279 | -export async function downloadFile( | |
2280 | - key: string, | |
2281 | - destPath: string, | |
2282 | -): Promise<void> { | |
2283 | - const storage = await getStorage(); | |
2284 | - const data = await storage.get(key); | |
2285 | - await fs.mkdir(path.dirname(destPath), { recursive: true }); | |
2286 | - await fs.writeFile(destPath, data); | |
2287 | -} | |
2288 | - | |
2289 | -// Get file as base64 | |
2290 | -export async function getFileAsBase64(key: string): Promise<string> { | |
2291 | - const storage = await getStorage(); | |
2292 | - const data = await storage.get(key); | |
2293 | - return data.toString("base64"); | |
2294 | -} | |
2295 | - | |
2296 | -// Calculate file hash | |
2297 | -export async function getFileHash( | |
2298 | - key: string, | |
2299 | - algorithm: string = "sha256", | |
2300 | -): Promise<string> { | |
2301 | - const storage = await getStorage(); | |
2302 | - const data = await storage.get(key); | |
2303 | - return crypto.createHash(algorithm).update(data).digest("hex"); | |
2304 | -} | |
2305 | - | |
2306 | -export async function getSignedUploadUrl( | |
2307 | - key: string, | |
2308 | - expiresIn: number = 3600, | |
2309 | -): Promise<string> { | |
2310 | - const storage = await getStorage(); | |
2311 | - return storage.getSignedUploadUrl(key, expiresIn); | |
2312 | -} | |
748 | +export async function getSignedUploadUrl( | |
749 | + key: string, | |
750 | + expiresIn: number = 3600, | |
751 | +): Promise<string> { | |
752 | + const storage = await getStorage(); | |
753 | + return storage.getSignedUploadUrl(key, expiresIn); | |
754 | +} | |
2313 | 755 | |
2314 | 756 | export async function getSignedDownloadUrl( |
2315 | 757 | key: string, |
| @@ -2318,14 +760,3 @@ export async function getSignedDownloadUrl( | ||
2318 | 760 | const storage = await getStorage(); |
2319 | 761 | return storage.getSignedUrl(key, expiresIn); |
2320 | 762 | } |
2321 | - | |
2322 | -export default { | |
2323 | - createStorageAdapter, | |
2324 | - getStorage, | |
2325 | - uploadFile, | |
2326 | - downloadFile, | |
2327 | - getFileAsBase64, | |
2328 | - getFileHash, | |
2329 | - getSignedUploadUrl, | |
2330 | - getSignedDownloadUrl, | |
2331 | -}; | |
2332 | 763 | |
▼src/lib/validation.ts
+1-2
| @@ -65,14 +65,13 @@ export const CreatePullRequestSchema = z.object({ | ||
65 | 65 | |
66 | 66 | // Storage Configuration |
67 | 67 | export const StorageConfigSchema = z.object({ |
68 | - type: z.enum(["local", "s3", "gcs", "azure", "rclone", "gdrive", "onedrive"]), | |
68 | + type: z.enum(["local", "s3"]), | |
69 | 69 | basePath: z.string().min(1).max(1000).optional(), |
70 | 70 | bucket: z.string().max(255).optional(), |
71 | 71 | region: z.string().max(100).optional(), |
72 | 72 | endpoint: z.string().url().optional().or(z.literal("")), |
73 | 73 | accessKeyId: z.string().max(255).optional(), |
74 | 74 | secretAccessKey: z.string().max(255).optional(), |
75 | - rcloneRemote: z.string().max(255).optional(), | |
76 | 75 | }); |
77 | 76 | |
78 | 77 | // General Config |
79 | 78 | |
▼src/pages/admin/_storage-disabled.txt
+11-24
| @@ -22,46 +22,33 @@ if (!user?.isAdmin) { | ||
22 | 22 | } |
23 | 23 | |
24 | 24 | // Current storage config from env |
25 | +// NOTE: only `local` and `s3` storage backends are supported. The | |
26 | +// rclone-as-adapter option was removed in favour of S3-compatible | |
27 | +// object storage and a separate rclone backup utility | |
28 | +// (see docs/administration/storage-adapters.md). | |
25 | 29 | const currentConfig = { |
26 | 30 | type: process.env.STORAGE_TYPE || 'local', |
27 | 31 | basePath: process.env.STORAGE_PATH || './data/storage', |
28 | 32 | bucket: process.env.STORAGE_BUCKET || '', |
29 | 33 | region: process.env.STORAGE_REGION || '', |
30 | 34 | endpoint: process.env.STORAGE_ENDPOINT || '', |
31 | - rcloneRemote: process.env.STORAGE_RCLONE_REMOTE || '', | |
32 | 35 | }; |
33 | 36 | |
34 | 37 | const storageTypes = [ |
35 | - { | |
36 | - value: 'local', | |
37 | - label: 'Local Filesystem', | |
38 | + { | |
39 | + value: 'local', | |
40 | + label: 'Local Filesystem', | |
38 | 41 | icon: HardDrive, |
39 | 42 | description: 'Store files on the local server disk', |
40 | 43 | fields: ['basePath'] |
41 | 44 | }, |
42 | - { | |
43 | - value: 's3', | |
44 | - label: 'S3 / MinIO / R2', | |
45 | + { | |
46 | + value: 's3', | |
47 | + label: 'S3 / MinIO / R2 / Garage / SeaweedFS / Ceph RGW', | |
45 | 48 | icon: Cloud, |
46 | - description: 'Amazon S3, MinIO, Cloudflare R2, or compatible', | |
49 | + description: 'Any S3-compatible object store (AWS S3, MinIO, Cloudflare R2, Garage, SeaweedFS, Ceph RGW, Wasabi, Backblaze B2)', | |
47 | 50 | fields: ['bucket', 'region', 'endpoint', 'accessKeyId', 'secretAccessKey'] |
48 | 51 | }, |
49 | - { | |
50 | - value: 'rclone', | |
51 | - label: 'Rclone Remote', | |
52 | - icon: FolderSync, | |
53 | - description: 'Google Drive, Dropbox, OneDrive, etc. via rclone', | |
54 | - fields: ['rcloneRemote', 'basePath'] | |
55 | - }, | |
56 | -]; | |
57 | - | |
58 | -const rcloneRemotes = [ | |
59 | - { name: 'gdrive', label: 'Google Drive', command: 'rclone config create gdrive drive' }, | |
60 | - { name: 'dropbox', label: 'Dropbox', command: 'rclone config create dropbox dropbox' }, | |
61 | - { name: 'onedrive', label: 'OneDrive', command: 'rclone config create onedrive onedrive' }, | |
62 | - { name: 's3', label: 'S3 Compatible', command: 'rclone config create mys3 s3' }, | |
63 | - { name: 'b2', label: 'Backblaze B2', command: 'rclone config create b2 b2' }, | |
64 | - { name: 'sftp', label: 'SFTP', command: 'rclone config create mysftp sftp' }, | |
65 | 52 | ]; |
66 | 53 | --- |
67 | 54 | |
68 | 55 | |
▼src/pages/api/admin/config/storage.ts
+13-3
| @@ -80,16 +80,26 @@ export const POST: APIRoute = withErrorHandler(async ({ request, locals }) => { | ||
80 | 80 | if (!cfg.type) { |
81 | 81 | throw new Error("Storage type required"); |
82 | 82 | } |
83 | + if (cfg.type !== "local" && cfg.type !== "s3") { | |
84 | + throw new Error( | |
85 | + `Unsupported storage type '${cfg.type}'. Supported types are: local, s3.`, | |
86 | + ); | |
87 | + } | |
83 | 88 | |
84 | - return { | |
85 | - ...cfg, | |
89 | + const sanitized: StorageConfig = { | |
86 | 90 | type: cfg.type, |
87 | 91 | basePath: typeof cfg.basePath === "string" |
88 | 92 | ? cfg.basePath |
89 | 93 | : cfg.type === "local" |
90 | 94 | ? "./data/storage" |
91 | 95 | : "", |
92 | - } as StorageConfig; | |
96 | + bucket: typeof cfg.bucket === "string" ? cfg.bucket : undefined, | |
97 | + region: typeof cfg.region === "string" ? cfg.region : undefined, | |
98 | + endpoint: typeof cfg.endpoint === "string" ? cfg.endpoint : undefined, | |
99 | + accessKeyId: typeof cfg.accessKeyId === "string" ? cfg.accessKeyId : undefined, | |
100 | + secretAccessKey: typeof cfg.secretAccessKey === "string" ? cfg.secretAccessKey : undefined, | |
101 | + }; | |
102 | + return sanitized; | |
93 | 103 | }; |
94 | 104 | |
95 | 105 | if (action === "test") { |
96 | 106 | |