Swadhin Biswas

[email protected]
2026-06-04 14:07:32 +0600 committed NaN years ago
ca015da
commit ca015da
27 files changed
+1594 -2290
▼.env.example
+18-23
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
-
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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