Spaces:
Paused
Paused
Remove D1 and add SQLite GitHub backup for HF
Browse files- .env.example +21 -8
- Dockerfile +1 -1
- HUGGINGFACE-SPACES.md +48 -39
- server/app.js +15 -13
- server/db/index.js +126 -67
- server/index.js +33 -9
- server/lib/backup/sqlite-github-backup.js +450 -0
- server/lib/config.js +38 -21
- server/lib/container.js +74 -89
- server/lib/d1/client.js +0 -129
- server/lib/d1/guest-store.js +0 -33
- server/lib/d1/schema.sql +0 -45
- server/lib/d1/session-store.js +0 -34
- server/lib/repos/storage-config-repo-d1.js +0 -244
- server/lib/settings/d1-store.js +0 -80
- server/lib/settings/factory.js +4 -9
.env.example
CHANGED
|
@@ -74,11 +74,24 @@ WEBDAV_PASSWORD=
|
|
| 74 |
WEBDAV_BEARER_TOKEN=
|
| 75 |
WEBDAV_ROOT_PATH=
|
| 76 |
|
| 77 |
-
# GitHub bootstrap
|
| 78 |
-
GITHUB_REPO=
|
| 79 |
-
GITHUB_TOKEN=
|
| 80 |
-
GITHUB_MODE=releases
|
| 81 |
-
GITHUB_PREFIX=
|
| 82 |
-
GITHUB_RELEASE_TAG=
|
| 83 |
-
GITHUB_BRANCH=
|
| 84 |
-
GITHUB_API_BASE=https://api.github.com
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
WEBDAV_BEARER_TOKEN=
|
| 75 |
WEBDAV_ROOT_PATH=
|
| 76 |
|
| 77 |
+
# GitHub bootstrap
|
| 78 |
+
GITHUB_REPO=
|
| 79 |
+
GITHUB_TOKEN=
|
| 80 |
+
GITHUB_MODE=releases
|
| 81 |
+
GITHUB_PREFIX=
|
| 82 |
+
GITHUB_RELEASE_TAG=
|
| 83 |
+
GITHUB_BRANCH=
|
| 84 |
+
GITHUB_API_BASE=https://api.github.com
|
| 85 |
+
|
| 86 |
+
# SQLite backup to GitHub (recommended for HuggingFace Spaces without persistent disk)
|
| 87 |
+
# Auto-enables when both repo and token are set, or set SQLITE_BACKUP_ENABLED=true explicitly.
|
| 88 |
+
SQLITE_BACKUP_ENABLED=
|
| 89 |
+
SQLITE_BACKUP_GITHUB_REPO=
|
| 90 |
+
SQLITE_BACKUP_GITHUB_TOKEN=
|
| 91 |
+
SQLITE_BACKUP_GITHUB_BRANCH=main
|
| 92 |
+
SQLITE_BACKUP_PATH=backups/k-vault.db
|
| 93 |
+
SQLITE_BACKUP_INTERVAL_MS=15000
|
| 94 |
+
SQLITE_BACKUP_IDLE_MS=120000
|
| 95 |
+
SQLITE_BACKUP_GIT_LFS=true
|
| 96 |
+
SQLITE_BACKUP_GIT_USER_NAME=K-Vault Backup Bot
|
| 97 |
+
SQLITE_BACKUP_GIT_USER_EMAIL=k-vault-backup@users.noreply.github.com
|
Dockerfile
CHANGED
|
@@ -9,7 +9,7 @@ RUN npm ci --omit=dev
|
|
| 9 |
FROM node:22-alpine
|
| 10 |
WORKDIR /app
|
| 11 |
|
| 12 |
-
RUN apk add --no-cache nginx
|
| 13 |
|
| 14 |
COPY --from=api-deps /app/server/node_modules /app/server/node_modules
|
| 15 |
COPY server /app/server
|
|
|
|
| 9 |
FROM node:22-alpine
|
| 10 |
WORKDIR /app
|
| 11 |
|
| 12 |
+
RUN apk add --no-cache nginx git git-lfs
|
| 13 |
|
| 14 |
COPY --from=api-deps /app/server/node_modules /app/server/node_modules
|
| 15 |
COPY server /app/server
|
HUGGINGFACE-SPACES.md
CHANGED
|
@@ -9,11 +9,11 @@ To make Spaces deployment possible, this repository now includes a root-level `D
|
|
| 9 |
|
| 10 |
## Recommended Space Setup
|
| 11 |
|
| 12 |
-
1. Create a new **Docker Space**.
|
| 13 |
-
2. Use the root `Dockerfile`.
|
| 14 |
-
3. Copy the contents of `README-HF-SPACES.md` to the root `README.md` in your Space repository.
|
| 15 |
-
4. Add the runtime secrets and variables listed below.
|
| 16 |
-
5.
|
| 17 |
|
| 18 |
## Required Secrets
|
| 19 |
|
|
@@ -99,48 +99,57 @@ If you do not set any backend variables, the app can still boot, and you can cre
|
|
| 99 |
|
| 100 |
Use either `WEBDAV_USERNAME` + `WEBDAV_PASSWORD`, or `WEBDAV_BEARER_TOKEN`.
|
| 101 |
|
| 102 |
-
### GitHub
|
| 103 |
-
|
| 104 |
-
| Name | Where to store | Required |
|
| 105 |
-
| :--- | :--- | :---: |
|
| 106 |
-
| `GITHUB_REPO` | Variable | yes |
|
| 107 |
| `GITHUB_TOKEN` | Secret | yes |
|
| 108 |
| `GITHUB_MODE` | Variable | no |
|
| 109 |
| `GITHUB_PREFIX` | Variable | no |
|
| 110 |
| `GITHUB_RELEASE_TAG` | Variable | no |
|
| 111 |
-
| `GITHUB_BRANCH` | Variable | no |
|
| 112 |
-
| `GITHUB_API_BASE` | Variable | no |
|
| 113 |
-
|
| 114 |
-
##
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
- `PORT=8787`
|
| 119 |
- `APP_PORT=7860`
|
| 120 |
- `DATA_DIR=/data`
|
| 121 |
-
- `DB_PATH=/data/k-vault.db`
|
| 122 |
-
- `CHUNK_DIR=/data/chunks`
|
| 123 |
-
|
| 124 |
-
##
|
| 125 |
-
|
| 126 |
-
When configured, K-Vault uses Cloudflare D1 for persistent storage of `storage_configs`, `app_settings`, `sessions`, and `guest_upload_counters`. Files metadata stays in local SQLite.
|
| 127 |
-
|
| 128 |
-
| Name | Where | Required | Purpose |
|
| 129 |
-
| :--- | :---: | :---: | :--- |
|
| 130 |
-
| `CF_ACCOUNT_ID` | Variable | yes* | Cloudflare account ID |
|
| 131 |
-
| `CF_D1_DATABASE_ID` | Variable | yes* | D1 database ID |
|
| 132 |
-
| `CF_API_TOKEN` | Secret | yes* | API token with D1 Edit permission |
|
| 133 |
-
| `CF_D1_API_BASE` | Variable | no | Override API base (default: `https://api.cloudflare.com/client/v4`) |
|
| 134 |
-
|
| 135 |
-
\* All three (`CF_ACCOUNT_ID`, `CF_D1_DATABASE_ID`, `CF_API_TOKEN`) must be set to enable D1. Otherwise SQLite is used.
|
| 136 |
-
|
| 137 |
-
D1 tables are created automatically on first run. You can also run the schema manually:
|
| 138 |
-
|
| 139 |
-
```sh
|
| 140 |
-
npx wrangler d1 execute <db-name> --remote --file=server/lib/d1/schema.sql
|
| 141 |
-
```
|
| 142 |
-
|
| 143 |
-
## Minimum Working Configuration
|
| 144 |
|
| 145 |
If you want the smallest possible setup, start with:
|
| 146 |
|
|
|
|
| 9 |
|
| 10 |
## Recommended Space Setup
|
| 11 |
|
| 12 |
+
1. Create a new **Docker Space**.
|
| 13 |
+
2. Use the root `Dockerfile`.
|
| 14 |
+
3. Copy the contents of `README-HF-SPACES.md` to the root `README.md` in your Space repository.
|
| 15 |
+
4. Add the runtime secrets and variables listed below.
|
| 16 |
+
5. Configure SQLite GitHub backup if you need data to survive Space restarts.
|
| 17 |
|
| 18 |
## Required Secrets
|
| 19 |
|
|
|
|
| 99 |
|
| 100 |
Use either `WEBDAV_USERNAME` + `WEBDAV_PASSWORD`, or `WEBDAV_BEARER_TOKEN`.
|
| 101 |
|
| 102 |
+
### GitHub
|
| 103 |
+
|
| 104 |
+
| Name | Where to store | Required |
|
| 105 |
+
| :--- | :--- | :---: |
|
| 106 |
+
| `GITHUB_REPO` | Variable | yes |
|
| 107 |
| `GITHUB_TOKEN` | Secret | yes |
|
| 108 |
| `GITHUB_MODE` | Variable | no |
|
| 109 |
| `GITHUB_PREFIX` | Variable | no |
|
| 110 |
| `GITHUB_RELEASE_TAG` | Variable | no |
|
| 111 |
+
| `GITHUB_BRANCH` | Variable | no |
|
| 112 |
+
| `GITHUB_API_BASE` | Variable | no |
|
| 113 |
+
|
| 114 |
+
## SQLite GitHub Backup
|
| 115 |
+
|
| 116 |
+
If your Space filesystem is ephemeral, use a separate GitHub repository as the SQLite backup target. K-Vault will:
|
| 117 |
+
|
| 118 |
+
- `git pull` the latest backup before the API starts
|
| 119 |
+
- restore `k-vault.db` from that repository
|
| 120 |
+
- start periodic backup only after SQLite writes happen
|
| 121 |
+
- stop the backup timer automatically after an idle period
|
| 122 |
+
- `git push` the latest SQLite snapshot back to GitHub
|
| 123 |
+
|
| 124 |
+
The backup flow uses `git-lfs` by default and the image now includes both `git` and `git-lfs`.
|
| 125 |
+
Make sure Git LFS is enabled for the backup repository before first use.
|
| 126 |
+
|
| 127 |
+
| Name | Where | Required | Purpose |
|
| 128 |
+
| :--- | :---: | :---: | :--- |
|
| 129 |
+
| `SQLITE_BACKUP_ENABLED` | Variable | no | Force enable/disable backup. If omitted, backup auto-enables when repo + token are present |
|
| 130 |
+
| `SQLITE_BACKUP_GITHUB_REPO` | Variable | yes* | Backup repo, e.g. `owner/repo` or full `https://github.com/...git` URL |
|
| 131 |
+
| `SQLITE_BACKUP_GITHUB_TOKEN` | Secret | yes* | GitHub token with push access to the backup repo |
|
| 132 |
+
| `SQLITE_BACKUP_GITHUB_BRANCH` | Variable | no | Branch to restore from and push to. Default: `main` |
|
| 133 |
+
| `SQLITE_BACKUP_PATH` | Variable | no | SQLite snapshot path inside the repo. Default: `backups/k-vault.db` |
|
| 134 |
+
| `SQLITE_BACKUP_INTERVAL_MS` | Variable | no | Backup interval after activity. Default: `15000` |
|
| 135 |
+
| `SQLITE_BACKUP_IDLE_MS` | Variable | no | Stop the timer after this much idle time. Default: `120000` |
|
| 136 |
+
| `SQLITE_BACKUP_GIT_LFS` | Variable | no | Use Git LFS for the SQLite snapshot. Default: `true` |
|
| 137 |
+
| `SQLITE_BACKUP_GIT_USER_NAME` | Variable | no | Commit author name |
|
| 138 |
+
| `SQLITE_BACKUP_GIT_USER_EMAIL` | Variable | no | Commit author email |
|
| 139 |
+
|
| 140 |
+
\* `SQLITE_BACKUP_GITHUB_REPO` and `SQLITE_BACKUP_GITHUB_TOKEN` are both required when backup is enabled.
|
| 141 |
+
|
| 142 |
+
## Variables You Usually Do Not Need To Override
|
| 143 |
+
|
| 144 |
+
These are already baked into the root `Dockerfile` for Spaces:
|
| 145 |
|
| 146 |
- `PORT=8787`
|
| 147 |
- `APP_PORT=7860`
|
| 148 |
- `DATA_DIR=/data`
|
| 149 |
+
- `DB_PATH=/data/k-vault.db`
|
| 150 |
+
- `CHUNK_DIR=/data/chunks`
|
| 151 |
+
|
| 152 |
+
## Minimum Working Configuration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
If you want the smallest possible setup, start with:
|
| 155 |
|
server/app.js
CHANGED
|
@@ -8,9 +8,10 @@ const { normalizeFolderPath } = require('./lib/repos/file-repo');
|
|
| 8 |
const { toStorageErrorPayload } = require('./lib/utils/storage-error');
|
| 9 |
const { createShareSignature, verifyShareSignature } = require('./lib/utils/share-link');
|
| 10 |
|
| 11 |
-
async function createApp() {
|
| 12 |
-
const app = new Hono();
|
| 13 |
-
const container = await createContainer(process.env);
|
|
|
|
| 14 |
|
| 15 |
app.use('*', cors({
|
| 16 |
origin: (origin) => origin || '*',
|
|
@@ -730,8 +731,8 @@ async function createApp() {
|
|
| 730 |
});
|
| 731 |
|
| 732 |
// --- Status ---
|
| 733 |
-
app.get('/api/status', async (c) => {
|
| 734 |
-
const { storageRepo, storageFactory, authService, guestService, settingsStore } = getServices(c);
|
| 735 |
|
| 736 |
const status = {
|
| 737 |
telegram: {
|
|
@@ -748,14 +749,15 @@ async function createApp() {
|
|
| 748 |
huggingface: { connected: false, enabled: false, configured: false, layer: 'direct', message: 'Not configured' },
|
| 749 |
webdav: { connected: false, enabled: false, configured: false, layer: 'mounted', message: 'Not configured' },
|
| 750 |
github: { connected: false, enabled: false, configured: false, layer: 'direct', message: 'Not configured' },
|
| 751 |
-
auth: {
|
| 752 |
-
enabled: authService.isAuthRequired(),
|
| 753 |
-
message: authService.isAuthRequired() ? 'Password auth enabled' : 'No auth required',
|
| 754 |
-
},
|
| 755 |
-
guestUpload: guestService.getConfig(),
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
|
|
|
| 759 |
|
| 760 |
status.settings = await settingsStore.healthCheck();
|
| 761 |
|
|
|
|
| 8 |
const { toStorageErrorPayload } = require('./lib/utils/storage-error');
|
| 9 |
const { createShareSignature, verifyShareSignature } = require('./lib/utils/share-link');
|
| 10 |
|
| 11 |
+
async function createApp() {
|
| 12 |
+
const app = new Hono();
|
| 13 |
+
const container = await createContainer(process.env);
|
| 14 |
+
app.container = container;
|
| 15 |
|
| 16 |
app.use('*', cors({
|
| 17 |
origin: (origin) => origin || '*',
|
|
|
|
| 731 |
});
|
| 732 |
|
| 733 |
// --- Status ---
|
| 734 |
+
app.get('/api/status', async (c) => {
|
| 735 |
+
const { storageRepo, storageFactory, authService, guestService, settingsStore, sqliteBackup } = getServices(c);
|
| 736 |
|
| 737 |
const status = {
|
| 738 |
telegram: {
|
|
|
|
| 749 |
huggingface: { connected: false, enabled: false, configured: false, layer: 'direct', message: 'Not configured' },
|
| 750 |
webdav: { connected: false, enabled: false, configured: false, layer: 'mounted', message: 'Not configured' },
|
| 751 |
github: { connected: false, enabled: false, configured: false, layer: 'direct', message: 'Not configured' },
|
| 752 |
+
auth: {
|
| 753 |
+
enabled: authService.isAuthRequired(),
|
| 754 |
+
message: authService.isAuthRequired() ? 'Password auth enabled' : 'No auth required',
|
| 755 |
+
},
|
| 756 |
+
guestUpload: guestService.getConfig(),
|
| 757 |
+
sqliteBackup: sqliteBackup?.getStatus?.() || { enabled: false, running: false, dirty: false, message: 'Disabled' },
|
| 758 |
+
settings: { connected: false, message: 'Unknown' },
|
| 759 |
+
diagnostics: {},
|
| 760 |
+
};
|
| 761 |
|
| 762 |
status.settings = await settingsStore.healthCheck();
|
| 763 |
|
server/db/index.js
CHANGED
|
@@ -1,67 +1,126 @@
|
|
| 1 |
-
|
| 2 |
-
const path = require('node:path');
|
| 3 |
-
const { DatabaseSync } = require('node:sqlite');
|
| 4 |
-
|
| 5 |
-
function
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('node:fs');
|
| 2 |
+
const path = require('node:path');
|
| 3 |
+
const { DatabaseSync } = require('node:sqlite');
|
| 4 |
+
|
| 5 |
+
function isMutationSql(sql) {
|
| 6 |
+
const normalized = String(sql || '').trim().toUpperCase();
|
| 7 |
+
return (
|
| 8 |
+
normalized.startsWith('INSERT') ||
|
| 9 |
+
normalized.startsWith('UPDATE') ||
|
| 10 |
+
normalized.startsWith('DELETE') ||
|
| 11 |
+
normalized.startsWith('REPLACE') ||
|
| 12 |
+
normalized.startsWith('ALTER') ||
|
| 13 |
+
normalized.startsWith('CREATE') ||
|
| 14 |
+
normalized.startsWith('DROP') ||
|
| 15 |
+
normalized.startsWith('VACUUM')
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function isSchemaMutationSql(sql) {
|
| 20 |
+
const normalized = String(sql || '').trim().toUpperCase();
|
| 21 |
+
return (
|
| 22 |
+
normalized.startsWith('ALTER') ||
|
| 23 |
+
normalized.startsWith('CREATE') ||
|
| 24 |
+
normalized.startsWith('DROP') ||
|
| 25 |
+
normalized.startsWith('VACUUM')
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function registerMutationObserver(db, observer) {
|
| 30 |
+
if (!db || typeof observer !== 'function') return () => {};
|
| 31 |
+
|
| 32 |
+
if (!db.__mutationObservers) {
|
| 33 |
+
Object.defineProperty(db, '__mutationObservers', {
|
| 34 |
+
configurable: false,
|
| 35 |
+
enumerable: false,
|
| 36 |
+
writable: false,
|
| 37 |
+
value: new Set(),
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
db.__mutationObservers.add(observer);
|
| 42 |
+
|
| 43 |
+
return () => {
|
| 44 |
+
db.__mutationObservers?.delete(observer);
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function notifyMutationObservers(db, sql, result) {
|
| 49 |
+
if (!db?.__mutationObservers || !isMutationSql(sql)) return;
|
| 50 |
+
if (!isSchemaMutationSql(sql) && Number(result?.changes || 0) <= 0) return;
|
| 51 |
+
|
| 52 |
+
for (const observer of db.__mutationObservers) {
|
| 53 |
+
try {
|
| 54 |
+
observer(sql);
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.warn('[db] Mutation observer failed:', error?.message || error);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function executeStatement(stmt, method, params) {
|
| 62 |
+
if (params == null) {
|
| 63 |
+
return stmt[method]();
|
| 64 |
+
}
|
| 65 |
+
if (Array.isArray(params)) {
|
| 66 |
+
return stmt[method](...params);
|
| 67 |
+
}
|
| 68 |
+
return stmt[method](params);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function initDatabase(dbPath) {
|
| 72 |
+
const fullPath = path.resolve(dbPath);
|
| 73 |
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
| 74 |
+
|
| 75 |
+
const db = new DatabaseSync(fullPath);
|
| 76 |
+
const schemaPath = path.resolve(__dirname, 'schema.sql');
|
| 77 |
+
const schema = fs.readFileSync(schemaPath, 'utf8');
|
| 78 |
+
db.exec(schema);
|
| 79 |
+
|
| 80 |
+
return db;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function run(db, sql, params) {
|
| 84 |
+
const stmt = db.prepare(sql);
|
| 85 |
+
const result = executeStatement(stmt, 'run', params);
|
| 86 |
+
notifyMutationObservers(db, sql, result);
|
| 87 |
+
return result;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function get(db, sql, params) {
|
| 91 |
+
const stmt = db.prepare(sql);
|
| 92 |
+
return executeStatement(stmt, 'get', params);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function all(db, sql, params) {
|
| 96 |
+
const stmt = db.prepare(sql);
|
| 97 |
+
return executeStatement(stmt, 'all', params);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function transaction(db, callback) {
|
| 101 |
+
db.exec('BEGIN');
|
| 102 |
+
try {
|
| 103 |
+
const result = callback();
|
| 104 |
+
db.exec('COMMIT');
|
| 105 |
+
return result;
|
| 106 |
+
} catch (error) {
|
| 107 |
+
db.exec('ROLLBACK');
|
| 108 |
+
throw error;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function cleanupExpiredState(db) {
|
| 113 |
+
const now = Date.now();
|
| 114 |
+
run(db, 'DELETE FROM sessions WHERE expires_at <= ?', [now]);
|
| 115 |
+
run(db, 'DELETE FROM chunk_uploads WHERE expires_at <= ?', [now]);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
module.exports = {
|
| 119 |
+
initDatabase,
|
| 120 |
+
run,
|
| 121 |
+
get,
|
| 122 |
+
all,
|
| 123 |
+
transaction,
|
| 124 |
+
cleanupExpiredState,
|
| 125 |
+
registerMutationObserver,
|
| 126 |
+
};
|
server/index.js
CHANGED
|
@@ -1,12 +1,36 @@
|
|
| 1 |
-
const { serve } = require('@hono/node-server');
|
| 2 |
-
const { createApp } = require('./app');
|
| 3 |
-
|
| 4 |
-
async function main() {
|
| 5 |
-
const app = await createApp();
|
| 6 |
-
const
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
main().catch((err) => {
|
| 12 |
console.error('[k-vault] Startup failed:', err);
|
|
|
|
| 1 |
+
const { serve } = require('@hono/node-server');
|
| 2 |
+
const { createApp } = require('./app');
|
| 3 |
+
|
| 4 |
+
async function main() {
|
| 5 |
+
const app = await createApp();
|
| 6 |
+
const container = app.container;
|
| 7 |
+
const port = Number(process.env.PORT || 8787);
|
| 8 |
+
console.log(`[k-vault] Starting Docker runtime on :${port}`);
|
| 9 |
+
serve({ fetch: app.fetch, port });
|
| 10 |
+
|
| 11 |
+
let shuttingDown = false;
|
| 12 |
+
const shutdown = async (signal) => {
|
| 13 |
+
if (shuttingDown) return;
|
| 14 |
+
shuttingDown = true;
|
| 15 |
+
|
| 16 |
+
console.log(`[k-vault] Received ${signal}, flushing state...`);
|
| 17 |
+
try {
|
| 18 |
+
await container?.close?.();
|
| 19 |
+
} catch (error) {
|
| 20 |
+
console.error('[k-vault] Shutdown failed:', error);
|
| 21 |
+
process.exitCode = 1;
|
| 22 |
+
} finally {
|
| 23 |
+
process.exit();
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
process.on('SIGINT', () => {
|
| 28 |
+
void shutdown('SIGINT');
|
| 29 |
+
});
|
| 30 |
+
process.on('SIGTERM', () => {
|
| 31 |
+
void shutdown('SIGTERM');
|
| 32 |
+
});
|
| 33 |
+
}
|
| 34 |
|
| 35 |
main().catch((err) => {
|
| 36 |
console.error('[k-vault] Startup failed:', err);
|
server/lib/backup/sqlite-github-backup.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('node:fs');
|
| 2 |
+
const fsp = require('node:fs/promises');
|
| 3 |
+
const path = require('node:path');
|
| 4 |
+
const { spawn } = require('node:child_process');
|
| 5 |
+
|
| 6 |
+
function normalizeRepo(repo) {
|
| 7 |
+
const value = String(repo || '').trim();
|
| 8 |
+
if (!value) return '';
|
| 9 |
+
if (/^https?:\/\//i.test(value)) return value.replace(/\/+$/, '');
|
| 10 |
+
return `https://github.com/${value.replace(/^\/+/, '').replace(/\.git$/i, '')}.git`;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function sqliteLiteral(value) {
|
| 14 |
+
return String(value || '').replace(/'/g, "''");
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function delay(ms) {
|
| 18 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function isLikelySqliteFile(filePath) {
|
| 22 |
+
try {
|
| 23 |
+
const handle = fs.openSync(filePath, 'r');
|
| 24 |
+
const header = Buffer.alloc(16);
|
| 25 |
+
fs.readSync(handle, header, 0, 16, 0);
|
| 26 |
+
fs.closeSync(handle);
|
| 27 |
+
return header.toString('utf8') === 'SQLite format 3\u0000';
|
| 28 |
+
} catch {
|
| 29 |
+
return false;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
class SqliteGitHubBackup {
|
| 34 |
+
constructor({ config, dbPath }) {
|
| 35 |
+
this.config = config || {};
|
| 36 |
+
this.dbPath = path.resolve(dbPath);
|
| 37 |
+
this.repoUrl = normalizeRepo(this.config.repo);
|
| 38 |
+
this.repoDir = path.resolve(this.config.repoDir || path.dirname(this.dbPath));
|
| 39 |
+
this.snapshotPath = String(this.config.snapshotPath || 'backups/k-vault.db').replace(/^\/+/, '');
|
| 40 |
+
this.snapshotTempPath = path.join(path.dirname(this.dbPath), '.sqlite-backup.tmp');
|
| 41 |
+
this.enabled = Boolean(this.config.enabled);
|
| 42 |
+
this.requiredConfigured = Boolean(this.repoUrl && this.config.token);
|
| 43 |
+
|
| 44 |
+
this.db = null;
|
| 45 |
+
this.intervalHandle = null;
|
| 46 |
+
this.idleHandle = null;
|
| 47 |
+
this.flushPromise = null;
|
| 48 |
+
this.dirty = false;
|
| 49 |
+
this.activityCounter = 0;
|
| 50 |
+
this.lastActivityAt = 0;
|
| 51 |
+
this.lastBackupAt = 0;
|
| 52 |
+
this.lastRestoreAt = 0;
|
| 53 |
+
this.lastError = '';
|
| 54 |
+
this.stoppedReason = 'idle';
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
validateConfig() {
|
| 58 |
+
if (!this.enabled) return;
|
| 59 |
+
if (!this.requiredConfigured) {
|
| 60 |
+
throw new Error('SQLite GitHub backup is enabled but SQLITE_BACKUP_GITHUB_REPO/SQLITE_BACKUP_GITHUB_TOKEN is incomplete.');
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
attachDatabase(db) {
|
| 65 |
+
this.db = db;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
getStatus() {
|
| 69 |
+
if (!this.enabled) {
|
| 70 |
+
return {
|
| 71 |
+
enabled: false,
|
| 72 |
+
running: false,
|
| 73 |
+
dirty: false,
|
| 74 |
+
message: 'SQLite GitHub backup disabled',
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
enabled: true,
|
| 80 |
+
running: Boolean(this.intervalHandle),
|
| 81 |
+
dirty: this.dirty,
|
| 82 |
+
branch: this.config.branch,
|
| 83 |
+
snapshotPath: this.snapshotPath,
|
| 84 |
+
lastActivityAt: this.lastActivityAt || null,
|
| 85 |
+
lastBackupAt: this.lastBackupAt || null,
|
| 86 |
+
lastRestoreAt: this.lastRestoreAt || null,
|
| 87 |
+
message: this.lastError || (this.intervalHandle ? 'SQLite GitHub backup active' : `SQLite GitHub backup idle (${this.stoppedReason})`),
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
buildGitEnv() {
|
| 92 |
+
const env = {
|
| 93 |
+
...process.env,
|
| 94 |
+
GIT_TERMINAL_PROMPT: '0',
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
if (!this.config.token) {
|
| 98 |
+
return env;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const remoteUrl = new URL(this.repoUrl);
|
| 102 |
+
env.GIT_CONFIG_COUNT = '1';
|
| 103 |
+
env.GIT_CONFIG_KEY_0 = `http.${remoteUrl.protocol}//${remoteUrl.host}/.extraheader`;
|
| 104 |
+
env.GIT_CONFIG_VALUE_0 = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${this.config.token}`).toString('base64')}`;
|
| 105 |
+
return env;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
runGit(args, options = {}) {
|
| 109 |
+
const { cwd, allowFailure = false } = options;
|
| 110 |
+
const env = this.buildGitEnv();
|
| 111 |
+
|
| 112 |
+
return new Promise((resolve, reject) => {
|
| 113 |
+
const child = spawn('git', args, {
|
| 114 |
+
cwd,
|
| 115 |
+
env,
|
| 116 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
let stdout = '';
|
| 120 |
+
let stderr = '';
|
| 121 |
+
|
| 122 |
+
child.stdout.on('data', (chunk) => {
|
| 123 |
+
stdout += chunk.toString();
|
| 124 |
+
});
|
| 125 |
+
child.stderr.on('data', (chunk) => {
|
| 126 |
+
stderr += chunk.toString();
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
child.on('error', (error) => {
|
| 130 |
+
if (allowFailure) {
|
| 131 |
+
resolve({ ok: false, code: -1, stdout, stderr: error.message });
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
reject(error);
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
child.on('close', (code) => {
|
| 138 |
+
const result = {
|
| 139 |
+
ok: code === 0,
|
| 140 |
+
code,
|
| 141 |
+
stdout: stdout.trim(),
|
| 142 |
+
stderr: stderr.trim(),
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
if (!result.ok && !allowFailure) {
|
| 146 |
+
const message = result.stderr || result.stdout || `git ${args.join(' ')} failed with exit code ${code}`;
|
| 147 |
+
reject(new Error(message));
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
resolve(result);
|
| 152 |
+
});
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
async remoteBranchExists() {
|
| 157 |
+
const result = await this.runGit(
|
| 158 |
+
['ls-remote', '--heads', this.repoUrl, this.config.branch],
|
| 159 |
+
{ allowFailure: true }
|
| 160 |
+
);
|
| 161 |
+
return result.ok && Boolean(result.stdout);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
async ensureGitLfs(repoDir) {
|
| 165 |
+
if (!this.config.lfsEnabled) return;
|
| 166 |
+
|
| 167 |
+
const version = await this.runGit(['lfs', 'version'], { allowFailure: true });
|
| 168 |
+
if (!version.ok) {
|
| 169 |
+
throw new Error('git-lfs is required for SQLite GitHub backup but is not available.');
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
await this.runGit(['-C', repoDir, 'lfs', 'install', '--local']);
|
| 173 |
+
await this.runGit(['-C', repoDir, 'lfs', 'track', this.snapshotPath]);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
async ensureRepoReady({ syncRemote = false } = {}) {
|
| 177 |
+
await fsp.mkdir(path.dirname(this.repoDir), { recursive: true });
|
| 178 |
+
|
| 179 |
+
if (!fs.existsSync(path.join(this.repoDir, '.git'))) {
|
| 180 |
+
await this.runGit(['clone', this.repoUrl, this.repoDir]);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
await this.runGit(['-C', this.repoDir, 'config', 'user.name', this.config.gitUserName]);
|
| 184 |
+
await this.runGit(['-C', this.repoDir, 'config', 'user.email', this.config.gitUserEmail]);
|
| 185 |
+
await this.ensureGitLfs(this.repoDir);
|
| 186 |
+
|
| 187 |
+
const remoteBranchExists = await this.remoteBranchExists();
|
| 188 |
+
const currentBranch = await this.runGit(['-C', this.repoDir, 'branch', '--show-current'], { allowFailure: true });
|
| 189 |
+
const currentName = currentBranch.stdout.trim();
|
| 190 |
+
|
| 191 |
+
if (remoteBranchExists) {
|
| 192 |
+
const localBranch = await this.runGit(
|
| 193 |
+
['-C', this.repoDir, 'rev-parse', '--verify', `refs/heads/${this.config.branch}`],
|
| 194 |
+
{ allowFailure: true }
|
| 195 |
+
);
|
| 196 |
+
|
| 197 |
+
if (currentName !== this.config.branch) {
|
| 198 |
+
if (localBranch.ok) {
|
| 199 |
+
await this.runGit(['-C', this.repoDir, 'switch', this.config.branch]);
|
| 200 |
+
} else {
|
| 201 |
+
await this.runGit(['-C', this.repoDir, 'switch', '--track', `origin/${this.config.branch}`]);
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
if (syncRemote) {
|
| 206 |
+
await this.runGit(['-C', this.repoDir, 'pull', '--ff-only', 'origin', this.config.branch]);
|
| 207 |
+
if (this.config.lfsEnabled) {
|
| 208 |
+
await this.runGit(['-C', this.repoDir, 'lfs', 'pull', 'origin', this.config.branch]);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
} else if (currentName !== this.config.branch) {
|
| 212 |
+
const localBranch = await this.runGit(
|
| 213 |
+
['-C', this.repoDir, 'rev-parse', '--verify', `refs/heads/${this.config.branch}`],
|
| 214 |
+
{ allowFailure: true }
|
| 215 |
+
);
|
| 216 |
+
|
| 217 |
+
if (localBranch.ok) {
|
| 218 |
+
await this.runGit(['-C', this.repoDir, 'switch', this.config.branch]);
|
| 219 |
+
} else {
|
| 220 |
+
await this.runGit(['-C', this.repoDir, 'switch', '-c', this.config.branch]);
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
async restoreIfAvailable() {
|
| 226 |
+
if (!this.enabled) {
|
| 227 |
+
return { restored: false, skipped: true, reason: 'disabled' };
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
this.validateConfig();
|
| 231 |
+
await this.ensureRepoReady({ syncRemote: true });
|
| 232 |
+
|
| 233 |
+
const sourcePath = path.join(this.repoDir, this.snapshotPath);
|
| 234 |
+
if (!fs.existsSync(sourcePath)) {
|
| 235 |
+
return { restored: false, skipped: true, reason: 'missing-backup' };
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
if (!isLikelySqliteFile(sourcePath)) {
|
| 239 |
+
throw new Error(`Backup file is not a valid SQLite snapshot: ${this.snapshotPath}`);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
await fsp.mkdir(path.dirname(this.dbPath), { recursive: true });
|
| 243 |
+
await fsp.copyFile(sourcePath, this.dbPath);
|
| 244 |
+
this.lastRestoreAt = Date.now();
|
| 245 |
+
this.lastError = '';
|
| 246 |
+
|
| 247 |
+
console.log(`[sqlite-backup] Restored ${this.snapshotPath} from ${this.config.branch}`);
|
| 248 |
+
|
| 249 |
+
return { restored: true, sourcePath };
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
recordActivity() {
|
| 253 |
+
if (!this.enabled) return;
|
| 254 |
+
|
| 255 |
+
this.lastActivityAt = Date.now();
|
| 256 |
+
this.activityCounter += 1;
|
| 257 |
+
this.dirty = true;
|
| 258 |
+
this.lastError = '';
|
| 259 |
+
|
| 260 |
+
if (!this.intervalHandle) {
|
| 261 |
+
this.intervalHandle = setInterval(() => {
|
| 262 |
+
void this.flushNow('interval');
|
| 263 |
+
}, this.config.intervalMs);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
if (this.idleHandle) {
|
| 267 |
+
clearTimeout(this.idleHandle);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
this.idleHandle = setTimeout(() => {
|
| 271 |
+
void this.handleIdleTimeout();
|
| 272 |
+
}, this.config.idleMs);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
async handleIdleTimeout() {
|
| 276 |
+
try {
|
| 277 |
+
if (this.dirty) {
|
| 278 |
+
await this.flushNow('idle-timeout');
|
| 279 |
+
}
|
| 280 |
+
} catch (error) {
|
| 281 |
+
this.lastError = error?.message || String(error);
|
| 282 |
+
} finally {
|
| 283 |
+
if (this.dirty) {
|
| 284 |
+
this.idleHandle = setTimeout(() => {
|
| 285 |
+
void this.handleIdleTimeout();
|
| 286 |
+
}, this.config.idleMs);
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
this.stop('idle-timeout');
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
stop(reason = 'manual') {
|
| 294 |
+
this.stoppedReason = reason;
|
| 295 |
+
|
| 296 |
+
if (this.intervalHandle) {
|
| 297 |
+
clearInterval(this.intervalHandle);
|
| 298 |
+
this.intervalHandle = null;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
if (this.idleHandle) {
|
| 302 |
+
clearTimeout(this.idleHandle);
|
| 303 |
+
this.idleHandle = null;
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
async exportSnapshot() {
|
| 308 |
+
await fsp.mkdir(path.dirname(this.snapshotTempPath), { recursive: true });
|
| 309 |
+
await fsp.rm(this.snapshotTempPath, { force: true });
|
| 310 |
+
|
| 311 |
+
if (this.db) {
|
| 312 |
+
this.db.exec(`VACUUM INTO '${sqliteLiteral(this.snapshotTempPath)}'`);
|
| 313 |
+
return;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
await fsp.copyFile(this.dbPath, this.snapshotTempPath);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
async stageSnapshot() {
|
| 320 |
+
await this.exportSnapshot();
|
| 321 |
+
|
| 322 |
+
const repoSnapshotPath = path.join(this.repoDir, this.snapshotPath);
|
| 323 |
+
await fsp.mkdir(path.dirname(repoSnapshotPath), { recursive: true });
|
| 324 |
+
await fsp.copyFile(this.snapshotTempPath, repoSnapshotPath);
|
| 325 |
+
|
| 326 |
+
const stageTargets = [this.snapshotPath];
|
| 327 |
+
const gitattributesPath = path.join(this.repoDir, '.gitattributes');
|
| 328 |
+
if (fs.existsSync(gitattributesPath)) {
|
| 329 |
+
stageTargets.push('.gitattributes');
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
await this.runGit(['-C', this.repoDir, 'add', '--', ...stageTargets]);
|
| 333 |
+
const status = await this.runGit(
|
| 334 |
+
['-C', this.repoDir, 'status', '--porcelain', '--', ...stageTargets],
|
| 335 |
+
{ allowFailure: true }
|
| 336 |
+
);
|
| 337 |
+
|
| 338 |
+
if (!status.ok) {
|
| 339 |
+
throw new Error(status.stderr || 'git status failed');
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
if (!status.stdout) {
|
| 343 |
+
return false;
|
| 344 |
+
}
|
| 345 |
+
return true;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
async pushSnapshot(reason) {
|
| 349 |
+
const commitMessage = `chore: backup sqlite (${reason}) ${new Date().toISOString()}`;
|
| 350 |
+
|
| 351 |
+
await this.runGit(['-C', this.repoDir, 'commit', '-m', commitMessage]);
|
| 352 |
+
|
| 353 |
+
const firstPush = await this.runGit(
|
| 354 |
+
['-C', this.repoDir, 'push', '-u', 'origin', this.config.branch],
|
| 355 |
+
{ allowFailure: true }
|
| 356 |
+
);
|
| 357 |
+
if (firstPush.ok) {
|
| 358 |
+
return;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
await this.runGit(['-C', this.repoDir, 'pull', '--rebase', 'origin', this.config.branch]);
|
| 362 |
+
if (this.config.lfsEnabled) {
|
| 363 |
+
await this.runGit(['-C', this.repoDir, 'lfs', 'pull', 'origin', this.config.branch], { allowFailure: true });
|
| 364 |
+
}
|
| 365 |
+
await this.runGit(['-C', this.repoDir, 'push', '-u', 'origin', this.config.branch]);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
async performBackup(reason) {
|
| 369 |
+
if (!this.dirty) return false;
|
| 370 |
+
|
| 371 |
+
this.validateConfig();
|
| 372 |
+
await this.ensureRepoReady();
|
| 373 |
+
const snapshotActivity = this.activityCounter;
|
| 374 |
+
|
| 375 |
+
const changed = await this.stageSnapshot();
|
| 376 |
+
if (!changed) {
|
| 377 |
+
this.dirty = this.activityCounter > snapshotActivity;
|
| 378 |
+
return false;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
await this.pushSnapshot(reason);
|
| 382 |
+
this.dirty = this.activityCounter > snapshotActivity;
|
| 383 |
+
this.lastBackupAt = Date.now();
|
| 384 |
+
this.lastError = '';
|
| 385 |
+
|
| 386 |
+
console.log(`[sqlite-backup] Pushed ${this.snapshotPath} to ${this.config.branch}`);
|
| 387 |
+
|
| 388 |
+
return true;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
async flushNow(reason = 'manual') {
|
| 392 |
+
if (!this.enabled) return false;
|
| 393 |
+
|
| 394 |
+
if (this.flushPromise) {
|
| 395 |
+
return this.flushPromise;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
this.flushPromise = (async () => {
|
| 399 |
+
try {
|
| 400 |
+
return await this.performBackup(reason);
|
| 401 |
+
} catch (error) {
|
| 402 |
+
this.lastError = error?.message || String(error);
|
| 403 |
+
console.error('[sqlite-backup] Backup failed:', this.lastError);
|
| 404 |
+
throw error;
|
| 405 |
+
} finally {
|
| 406 |
+
this.flushPromise = null;
|
| 407 |
+
}
|
| 408 |
+
})();
|
| 409 |
+
|
| 410 |
+
return this.flushPromise;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
async shutdown() {
|
| 414 |
+
if (!this.enabled) return;
|
| 415 |
+
|
| 416 |
+
this.stop('shutdown');
|
| 417 |
+
|
| 418 |
+
if (this.flushPromise) {
|
| 419 |
+
await this.flushPromise.catch(() => {});
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
if (this.dirty) {
|
| 423 |
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
| 424 |
+
try {
|
| 425 |
+
await this.flushNow('shutdown');
|
| 426 |
+
break;
|
| 427 |
+
} catch (error) {
|
| 428 |
+
if (attempt === 1) {
|
| 429 |
+
throw error;
|
| 430 |
+
}
|
| 431 |
+
await delay(1000);
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
function createSqliteGitHubBackup(config, dbPath) {
|
| 439 |
+
const backup = new SqliteGitHubBackup({
|
| 440 |
+
config,
|
| 441 |
+
dbPath,
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
return backup.enabled ? backup : null;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
module.exports = {
|
| 448 |
+
SqliteGitHubBackup,
|
| 449 |
+
createSqliteGitHubBackup,
|
| 450 |
+
};
|
server/lib/config.js
CHANGED
|
@@ -27,13 +27,18 @@ function pickEnvAlias(env, aliases = [], fallback = '') {
|
|
| 27 |
return { value: fallback, source: '' };
|
| 28 |
}
|
| 29 |
|
| 30 |
-
function loadConfig(env = process.env) {
|
| 31 |
-
const dataDir = env.DATA_DIR
|
| 32 |
-
? path.resolve(env.DATA_DIR)
|
| 33 |
-
: resolveDataPath('data');
|
| 34 |
-
const
|
| 35 |
-
const
|
| 36 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
return {
|
| 39 |
port: toInt(env.PORT, 8787),
|
|
@@ -56,20 +61,32 @@ function loadConfig(env = process.env) {
|
|
| 56 |
configEncryptionKey: env.CONFIG_ENCRYPTION_KEY || env.FILE_URL_SECRET || env.SESSION_SECRET || '',
|
| 57 |
sessionSecret: env.SESSION_SECRET || env.FILE_URL_SECRET || env.CONFIG_ENCRYPTION_KEY || '',
|
| 58 |
|
| 59 |
-
dataDir,
|
| 60 |
-
dbPath: env.DB_PATH ? path.resolve(env.DB_PATH) : path.join(dataDir, 'k-vault.db'),
|
| 61 |
-
chunkDir: env.CHUNK_DIR ? path.resolve(env.CHUNK_DIR) : path.join(dataDir, 'chunks'),
|
| 62 |
-
settingsStore: (env.SETTINGS_STORE || 'sqlite').toLowerCase(),
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
// Optional bootstrap default storage from env.
|
| 75 |
bootstrapDefaultStorage: {
|
|
|
|
| 27 |
return { value: fallback, source: '' };
|
| 28 |
}
|
| 29 |
|
| 30 |
+
function loadConfig(env = process.env) {
|
| 31 |
+
const dataDir = env.DATA_DIR
|
| 32 |
+
? path.resolve(env.DATA_DIR)
|
| 33 |
+
: resolveDataPath('data');
|
| 34 |
+
const sqliteBackupRepo = env.SQLITE_BACKUP_GITHUB_REPO || env.SQLITE_BACKUP_REPO || '';
|
| 35 |
+
const sqliteBackupToken = env.SQLITE_BACKUP_GITHUB_TOKEN || env.SQLITE_BACKUP_TOKEN || '';
|
| 36 |
+
const sqliteBackupExplicit = env.SQLITE_BACKUP_ENABLED;
|
| 37 |
+
const sqliteBackupIntervalMs = Math.max(10000, toInt(env.SQLITE_BACKUP_INTERVAL_MS, 15000));
|
| 38 |
+
const sqliteBackupIdleMs = Math.max(sqliteBackupIntervalMs, toInt(env.SQLITE_BACKUP_IDLE_MS, 120000));
|
| 39 |
+
const telegramToken = pickEnvAlias(env, ['TG_BOT_TOKEN', 'TG_Bot_Token']);
|
| 40 |
+
const telegramChatId = pickEnvAlias(env, ['TG_CHAT_ID', 'TG_Chat_ID']);
|
| 41 |
+
const telegramApiBase = pickEnvAlias(env, ['CUSTOM_BOT_API_URL'], 'https://api.telegram.org');
|
| 42 |
|
| 43 |
return {
|
| 44 |
port: toInt(env.PORT, 8787),
|
|
|
|
| 61 |
configEncryptionKey: env.CONFIG_ENCRYPTION_KEY || env.FILE_URL_SECRET || env.SESSION_SECRET || '',
|
| 62 |
sessionSecret: env.SESSION_SECRET || env.FILE_URL_SECRET || env.CONFIG_ENCRYPTION_KEY || '',
|
| 63 |
|
| 64 |
+
dataDir,
|
| 65 |
+
dbPath: env.DB_PATH ? path.resolve(env.DB_PATH) : path.join(dataDir, 'k-vault.db'),
|
| 66 |
+
chunkDir: env.CHUNK_DIR ? path.resolve(env.CHUNK_DIR) : path.join(dataDir, 'chunks'),
|
| 67 |
+
settingsStore: (env.SETTINGS_STORE || 'sqlite').toLowerCase(),
|
| 68 |
+
settingsRedisUrl: env.SETTINGS_REDIS_URL || env.REDIS_URL || '',
|
| 69 |
+
settingsRedisPrefix: env.SETTINGS_REDIS_PREFIX || 'k-vault',
|
| 70 |
+
settingsRedisConnectTimeoutMs: toInt(env.SETTINGS_REDIS_CONNECT_TIMEOUT_MS, 5000),
|
| 71 |
+
sqliteBackup: {
|
| 72 |
+
enabled: sqliteBackupExplicit == null || sqliteBackupExplicit === ''
|
| 73 |
+
? Boolean(sqliteBackupRepo && sqliteBackupToken)
|
| 74 |
+
: toBool(sqliteBackupExplicit, false),
|
| 75 |
+
repo: sqliteBackupRepo,
|
| 76 |
+
token: sqliteBackupToken,
|
| 77 |
+
branch: env.SQLITE_BACKUP_GITHUB_BRANCH || env.SQLITE_BACKUP_BRANCH || 'main',
|
| 78 |
+
repoDir: env.SQLITE_BACKUP_REPO_DIR
|
| 79 |
+
? path.resolve(env.SQLITE_BACKUP_REPO_DIR)
|
| 80 |
+
: path.join(dataDir, 'sqlite-backup-repo'),
|
| 81 |
+
snapshotPath: env.SQLITE_BACKUP_PATH || 'backups/k-vault.db',
|
| 82 |
+
intervalMs: sqliteBackupIntervalMs,
|
| 83 |
+
idleMs: sqliteBackupIdleMs,
|
| 84 |
+
gitUserName: env.SQLITE_BACKUP_GIT_USER_NAME || 'K-Vault Backup Bot',
|
| 85 |
+
gitUserEmail: env.SQLITE_BACKUP_GIT_USER_EMAIL || 'k-vault-backup@users.noreply.github.com',
|
| 86 |
+
lfsEnabled: toBool(env.SQLITE_BACKUP_GIT_LFS, true),
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
telegramApiBase: telegramApiBase.value,
|
| 90 |
|
| 91 |
// Optional bootstrap default storage from env.
|
| 92 |
bootstrapDefaultStorage: {
|
server/lib/container.js
CHANGED
|
@@ -1,89 +1,74 @@
|
|
| 1 |
-
const { initDatabase, cleanupExpiredState } = require('../db');
|
| 2 |
-
const { loadConfig } = require('./config');
|
| 3 |
-
const { AuthService } = require('./utils/auth');
|
| 4 |
-
const { GuestService } = require('./utils/guest');
|
| 5 |
-
const { StorageFactory } = require('./storage/factory');
|
| 6 |
-
const { StorageConfigRepository } = require('./repos/storage-config-repo');
|
| 7 |
-
const {
|
| 8 |
-
const {
|
| 9 |
-
const {
|
| 10 |
-
const {
|
| 11 |
-
const {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
const
|
| 15 |
-
const
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
const
|
| 22 |
-
const
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
storageRepo
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
d1Client,
|
| 76 |
-
authService,
|
| 77 |
-
guestService,
|
| 78 |
-
storageRepo,
|
| 79 |
-
fileRepo,
|
| 80 |
-
storageFactory,
|
| 81 |
-
settingsStore,
|
| 82 |
-
uploadService,
|
| 83 |
-
chunkService,
|
| 84 |
-
};
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
module.exports = {
|
| 88 |
-
createContainer,
|
| 89 |
-
};
|
|
|
|
| 1 |
+
const { initDatabase, cleanupExpiredState, registerMutationObserver } = require('../db');
|
| 2 |
+
const { loadConfig } = require('./config');
|
| 3 |
+
const { AuthService } = require('./utils/auth');
|
| 4 |
+
const { GuestService } = require('./utils/guest');
|
| 5 |
+
const { StorageFactory } = require('./storage/factory');
|
| 6 |
+
const { StorageConfigRepository } = require('./repos/storage-config-repo');
|
| 7 |
+
const { FileRepository } = require('./repos/file-repo');
|
| 8 |
+
const { UploadService } = require('./services/upload-service');
|
| 9 |
+
const { ChunkUploadService } = require('./services/chunk-service');
|
| 10 |
+
const { createSettingsStore } = require('./settings/factory');
|
| 11 |
+
const { createSqliteGitHubBackup } = require('./backup/sqlite-github-backup');
|
| 12 |
+
|
| 13 |
+
async function createContainer(env = process.env) {
|
| 14 |
+
const config = loadConfig(env);
|
| 15 |
+
const sqliteBackup = createSqliteGitHubBackup(config.sqliteBackup, config.dbPath);
|
| 16 |
+
|
| 17 |
+
if (sqliteBackup) {
|
| 18 |
+
await sqliteBackup.restoreIfAvailable();
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const db = initDatabase(config.dbPath);
|
| 22 |
+
const unregisterBackupObserver = sqliteBackup
|
| 23 |
+
? registerMutationObserver(db, () => sqliteBackup.recordActivity())
|
| 24 |
+
: () => {};
|
| 25 |
+
|
| 26 |
+
sqliteBackup?.attachDatabase(db);
|
| 27 |
+
|
| 28 |
+
const fileRepo = new FileRepository(db);
|
| 29 |
+
const storageRepo = new StorageConfigRepository(db, config);
|
| 30 |
+
const settingsStore = createSettingsStore({ db, config });
|
| 31 |
+
const authService = new AuthService(db, config);
|
| 32 |
+
const guestService = new GuestService(db, config);
|
| 33 |
+
|
| 34 |
+
await storageRepo.ensureBootstrapStorage();
|
| 35 |
+
cleanupExpiredState(db);
|
| 36 |
+
|
| 37 |
+
const storageFactory = new StorageFactory();
|
| 38 |
+
|
| 39 |
+
const uploadService = new UploadService({
|
| 40 |
+
storageRepo,
|
| 41 |
+
fileRepo,
|
| 42 |
+
storageFactory,
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
const chunkService = new ChunkUploadService({
|
| 46 |
+
db,
|
| 47 |
+
config,
|
| 48 |
+
uploadService,
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
config,
|
| 53 |
+
db,
|
| 54 |
+
authService,
|
| 55 |
+
guestService,
|
| 56 |
+
storageRepo,
|
| 57 |
+
fileRepo,
|
| 58 |
+
storageFactory,
|
| 59 |
+
settingsStore,
|
| 60 |
+
uploadService,
|
| 61 |
+
chunkService,
|
| 62 |
+
sqliteBackup,
|
| 63 |
+
close: async () => {
|
| 64 |
+
unregisterBackupObserver();
|
| 65 |
+
await sqliteBackup?.shutdown();
|
| 66 |
+
await settingsStore?.close?.();
|
| 67 |
+
db.close?.();
|
| 68 |
+
},
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
module.exports = {
|
| 73 |
+
createContainer,
|
| 74 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/d1/client.js
DELETED
|
@@ -1,129 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Cloudflare D1 REST API client.
|
| 3 |
-
* Uses: POST https://api.cloudflare.com/client/v4/accounts/{account_id}/d1/database/{database_id}/query
|
| 4 |
-
* Body: { "sql": "...", "params": [] }
|
| 5 |
-
* Auth: Authorization: Bearer {CF_API_TOKEN}
|
| 6 |
-
*/
|
| 7 |
-
const DEFAULT_API_BASE = 'https://api.cloudflare.com/client/v4';
|
| 8 |
-
|
| 9 |
-
class D1Client {
|
| 10 |
-
constructor({ accountId, databaseId, apiToken, apiBase = DEFAULT_API_BASE }) {
|
| 11 |
-
this.accountId = accountId;
|
| 12 |
-
this.databaseId = databaseId;
|
| 13 |
-
this.apiToken = apiToken;
|
| 14 |
-
this.baseUrl = `${apiBase.replace(/\/$/, '')}/accounts/${accountId}/d1/database/${databaseId}/query`;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
async _request(sql, params = []) {
|
| 18 |
-
const res = await fetch(this.baseUrl, {
|
| 19 |
-
method: 'POST',
|
| 20 |
-
headers: {
|
| 21 |
-
'Content-Type': 'application/json',
|
| 22 |
-
Authorization: `Bearer ${this.apiToken}`,
|
| 23 |
-
},
|
| 24 |
-
body: JSON.stringify({ sql, params }),
|
| 25 |
-
});
|
| 26 |
-
|
| 27 |
-
const json = await res.json();
|
| 28 |
-
if (!res.ok) {
|
| 29 |
-
const err = new Error(json.errors?.[0]?.message || `D1 API error: ${res.status}`);
|
| 30 |
-
err.status = res.status;
|
| 31 |
-
err.d1Errors = json.errors;
|
| 32 |
-
throw err;
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
if (!json.success) {
|
| 36 |
-
const err = new Error(json.errors?.[0]?.message || 'D1 request failed');
|
| 37 |
-
err.d1Errors = json.errors;
|
| 38 |
-
throw err;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
return json;
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
/**
|
| 45 |
-
* Execute a query that returns rows. Returns array of row objects.
|
| 46 |
-
*/
|
| 47 |
-
async all(sql, params = []) {
|
| 48 |
-
const json = await this._request(sql, params);
|
| 49 |
-
const first = json.result?.[0];
|
| 50 |
-
if (!first) return [];
|
| 51 |
-
const results = first.results;
|
| 52 |
-
return Array.isArray(results) ? results : [];
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
/**
|
| 56 |
-
* Execute a query and return the first row or null.
|
| 57 |
-
*/
|
| 58 |
-
async get(sql, params = []) {
|
| 59 |
-
const rows = await this.all(sql, params);
|
| 60 |
-
return rows[0] ?? null;
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
/**
|
| 64 |
-
* Execute a statement (INSERT/UPDATE/DELETE). Returns meta with changes.
|
| 65 |
-
*/
|
| 66 |
-
async run(sql, params = []) {
|
| 67 |
-
const json = await this._request(sql, params);
|
| 68 |
-
const first = json.result?.[0];
|
| 69 |
-
const meta = first?.meta || {};
|
| 70 |
-
return {
|
| 71 |
-
changes: meta.changes ?? 0,
|
| 72 |
-
last_row_id: meta.last_row_id ?? 0,
|
| 73 |
-
};
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
/**
|
| 77 |
-
* Execute multiple statements sequentially. D1 REST API does not support
|
| 78 |
-
* true transactions; this is best-effort sequential execution.
|
| 79 |
-
*/
|
| 80 |
-
async transaction(fn) {
|
| 81 |
-
const self = this;
|
| 82 |
-
const tx = {
|
| 83 |
-
run: (sql, params) => self.run(sql, params),
|
| 84 |
-
get: (sql, params) => self.get(sql, params),
|
| 85 |
-
all: (sql, params) => self.all(sql, params),
|
| 86 |
-
};
|
| 87 |
-
return fn(tx);
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
async healthCheck() {
|
| 91 |
-
try {
|
| 92 |
-
await this.get('SELECT 1');
|
| 93 |
-
return { ok: true };
|
| 94 |
-
} catch (e) {
|
| 95 |
-
return { ok: false, error: String(e?.message || e) };
|
| 96 |
-
}
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
/**
|
| 100 |
-
* Initialize D1 schema. Run each statement separately (D1 REST API).
|
| 101 |
-
*/
|
| 102 |
-
async initSchema(schemaSql) {
|
| 103 |
-
const stmts = schemaSql
|
| 104 |
-
.split(';')
|
| 105 |
-
.map((s) => s.trim())
|
| 106 |
-
.filter((s) => s && !s.startsWith('--'));
|
| 107 |
-
for (const sql of stmts) {
|
| 108 |
-
if (sql) await this.run(sql);
|
| 109 |
-
}
|
| 110 |
-
}
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
function createD1Client(env = process.env) {
|
| 114 |
-
const accountId = env.CF_ACCOUNT_ID || env.CLOUDFLARE_ACCOUNT_ID;
|
| 115 |
-
const databaseId = env.CF_D1_DATABASE_ID || env.CLOUDFLARE_D1_DATABASE_ID;
|
| 116 |
-
const apiToken = env.CF_API_TOKEN || env.CLOUDFLARE_API_TOKEN;
|
| 117 |
-
const apiBase = env.CF_D1_API_BASE || env.CLOUDFLARE_D1_API_BASE || DEFAULT_API_BASE;
|
| 118 |
-
|
| 119 |
-
if (!accountId || !databaseId || !apiToken) {
|
| 120 |
-
return null;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
return new D1Client({ accountId, databaseId, apiToken, apiBase });
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
module.exports = {
|
| 127 |
-
D1Client,
|
| 128 |
-
createD1Client,
|
| 129 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/d1/guest-store.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* D1-backed guest upload counter store.
|
| 3 |
-
*/
|
| 4 |
-
class D1GuestStore {
|
| 5 |
-
constructor(d1Client) {
|
| 6 |
-
this.d1 = d1Client;
|
| 7 |
-
}
|
| 8 |
-
|
| 9 |
-
async getCount(id) {
|
| 10 |
-
const row = await this.d1.get('SELECT count FROM guest_upload_counters WHERE id = ?', [id]);
|
| 11 |
-
return row ? Number(row.count) : 0;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
async increment(id, ip, day) {
|
| 15 |
-
const now = Date.now();
|
| 16 |
-
const existing = await this.d1.get('SELECT count FROM guest_upload_counters WHERE id = ?', [id]);
|
| 17 |
-
if (!existing) {
|
| 18 |
-
await this.d1.run(
|
| 19 |
-
`INSERT INTO guest_upload_counters(id, ip, day, count, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
| 20 |
-
[id, ip, day, 1, now]
|
| 21 |
-
);
|
| 22 |
-
} else {
|
| 23 |
-
await this.d1.run(
|
| 24 |
-
`UPDATE guest_upload_counters SET count = ?, updated_at = ? WHERE id = ?`,
|
| 25 |
-
[Number(existing.count) + 1, now, id]
|
| 26 |
-
);
|
| 27 |
-
}
|
| 28 |
-
}
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
module.exports = {
|
| 32 |
-
D1GuestStore,
|
| 33 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/d1/schema.sql
DELETED
|
@@ -1,45 +0,0 @@
|
|
| 1 |
-
-- D1 schema for K-Vault (minimum D1 tables)
|
| 2 |
-
-- Run this in D1 before using: wrangler d1 execute <db> --remote --file=server/lib/d1/schema.sql
|
| 3 |
-
-- Or via REST API / Console
|
| 4 |
-
|
| 5 |
-
CREATE TABLE IF NOT EXISTS storage_configs (
|
| 6 |
-
id TEXT PRIMARY KEY,
|
| 7 |
-
name TEXT NOT NULL,
|
| 8 |
-
type TEXT NOT NULL,
|
| 9 |
-
encrypted_payload TEXT NOT NULL,
|
| 10 |
-
is_default INTEGER NOT NULL DEFAULT 0,
|
| 11 |
-
enabled INTEGER NOT NULL DEFAULT 1,
|
| 12 |
-
metadata_json TEXT NOT NULL DEFAULT '{}',
|
| 13 |
-
created_at INTEGER NOT NULL,
|
| 14 |
-
updated_at INTEGER NOT NULL
|
| 15 |
-
);
|
| 16 |
-
|
| 17 |
-
CREATE INDEX IF NOT EXISTS idx_storage_configs_type ON storage_configs(type);
|
| 18 |
-
CREATE INDEX IF NOT EXISTS idx_storage_configs_default ON storage_configs(is_default);
|
| 19 |
-
|
| 20 |
-
CREATE TABLE IF NOT EXISTS sessions (
|
| 21 |
-
token TEXT PRIMARY KEY,
|
| 22 |
-
user_name TEXT NOT NULL,
|
| 23 |
-
created_at INTEGER NOT NULL,
|
| 24 |
-
expires_at INTEGER NOT NULL
|
| 25 |
-
);
|
| 26 |
-
|
| 27 |
-
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
| 28 |
-
|
| 29 |
-
CREATE TABLE IF NOT EXISTS guest_upload_counters (
|
| 30 |
-
id TEXT PRIMARY KEY,
|
| 31 |
-
ip TEXT NOT NULL,
|
| 32 |
-
day TEXT NOT NULL,
|
| 33 |
-
count INTEGER NOT NULL DEFAULT 0,
|
| 34 |
-
updated_at INTEGER NOT NULL
|
| 35 |
-
);
|
| 36 |
-
|
| 37 |
-
CREATE INDEX IF NOT EXISTS idx_guest_upload_counters_day ON guest_upload_counters(day);
|
| 38 |
-
|
| 39 |
-
CREATE TABLE IF NOT EXISTS app_settings (
|
| 40 |
-
key TEXT PRIMARY KEY,
|
| 41 |
-
value_json TEXT NOT NULL,
|
| 42 |
-
updated_at INTEGER NOT NULL
|
| 43 |
-
);
|
| 44 |
-
|
| 45 |
-
CREATE INDEX IF NOT EXISTS idx_app_settings_updated_at ON app_settings(updated_at DESC);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/d1/session-store.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* D1-backed session store for AuthService.
|
| 3 |
-
*/
|
| 4 |
-
class D1SessionStore {
|
| 5 |
-
constructor(d1Client) {
|
| 6 |
-
this.d1 = d1Client;
|
| 7 |
-
}
|
| 8 |
-
|
| 9 |
-
async createSession(userName, token, createdAt, expiresAt) {
|
| 10 |
-
await this.d1.run(
|
| 11 |
-
`INSERT INTO sessions(token, user_name, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
| 12 |
-
[token, userName, createdAt, expiresAt]
|
| 13 |
-
);
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
async getSession(token) {
|
| 17 |
-
if (!token) return null;
|
| 18 |
-
const row = await this.d1.get('SELECT * FROM sessions WHERE token = ?', [token]);
|
| 19 |
-
return row;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
async deleteSession(token) {
|
| 23 |
-
if (!token) return;
|
| 24 |
-
await this.d1.run('DELETE FROM sessions WHERE token = ?', [token]);
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
async cleanupExpired() {
|
| 28 |
-
await this.d1.run('DELETE FROM sessions WHERE expires_at <= ?', [Date.now()]);
|
| 29 |
-
}
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
module.exports = {
|
| 33 |
-
D1SessionStore,
|
| 34 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/repos/storage-config-repo-d1.js
DELETED
|
@@ -1,244 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* D1-backed StorageConfigRepository. All methods are async.
|
| 3 |
-
*/
|
| 4 |
-
const { encryptJson, decryptJson, randomId } = require('../utils/crypto');
|
| 5 |
-
const { normalizeStorageType } = require('../storage/common');
|
| 6 |
-
|
| 7 |
-
class StorageConfigRepositoryD1 {
|
| 8 |
-
constructor(d1Client, appConfig, fileRepo) {
|
| 9 |
-
this.d1 = d1Client;
|
| 10 |
-
this.appConfig = appConfig;
|
| 11 |
-
this.fileRepo = fileRepo;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
parseRow(row, includeSecrets = false) {
|
| 15 |
-
if (!row) return null;
|
| 16 |
-
|
| 17 |
-
const payloadBlob = JSON.parse(row.encrypted_payload || '{}');
|
| 18 |
-
let decrypted = {};
|
| 19 |
-
try {
|
| 20 |
-
decrypted = decryptJson(payloadBlob, this.appConfig.configEncryptionKey);
|
| 21 |
-
} catch (error) {
|
| 22 |
-
throw new Error(`Failed to decrypt storage config "${row.name}". Check CONFIG_ENCRYPTION_KEY.`);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
return {
|
| 26 |
-
id: row.id,
|
| 27 |
-
name: row.name,
|
| 28 |
-
type: row.type,
|
| 29 |
-
enabled: Boolean(row.enabled),
|
| 30 |
-
isDefault: Boolean(row.is_default),
|
| 31 |
-
metadata: JSON.parse(row.metadata_json || '{}'),
|
| 32 |
-
createdAt: row.created_at,
|
| 33 |
-
updatedAt: row.updated_at,
|
| 34 |
-
config: includeSecrets ? decrypted : this.maskSensitiveFields(row.type, decrypted),
|
| 35 |
-
};
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
maskSensitiveFields(type, config) {
|
| 39 |
-
const cloned = { ...(config || {}) };
|
| 40 |
-
const secretFieldsByType = {
|
| 41 |
-
telegram: ['botToken'],
|
| 42 |
-
r2: ['accessKeyId', 'secretAccessKey'],
|
| 43 |
-
s3: ['accessKeyId', 'secretAccessKey'],
|
| 44 |
-
discord: ['botToken', 'webhookUrl'],
|
| 45 |
-
huggingface: ['token'],
|
| 46 |
-
webdav: ['password', 'bearerToken', 'token'],
|
| 47 |
-
github: ['token'],
|
| 48 |
-
};
|
| 49 |
-
(secretFieldsByType[type] || []).forEach((field) => {
|
| 50 |
-
if (cloned[field]) cloned[field] = '********';
|
| 51 |
-
});
|
| 52 |
-
return cloned;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
mergeConfigPreserveSecrets(type, currentConfig, patchConfig) {
|
| 56 |
-
if (!patchConfig || typeof patchConfig !== 'object') return { ...(currentConfig || {}) };
|
| 57 |
-
const merged = { ...(currentConfig || {}) };
|
| 58 |
-
const incoming = { ...patchConfig };
|
| 59 |
-
const secretFieldsByType = {
|
| 60 |
-
telegram: ['botToken'],
|
| 61 |
-
r2: ['accessKeyId', 'secretAccessKey'],
|
| 62 |
-
s3: ['accessKeyId', 'secretAccessKey'],
|
| 63 |
-
discord: ['botToken', 'webhookUrl'],
|
| 64 |
-
huggingface: ['token'],
|
| 65 |
-
webdav: ['password', 'bearerToken', 'token'],
|
| 66 |
-
github: ['token'],
|
| 67 |
-
};
|
| 68 |
-
(secretFieldsByType[type] || []).forEach((field) => {
|
| 69 |
-
if (incoming[field] === '********') delete incoming[field];
|
| 70 |
-
});
|
| 71 |
-
return { ...merged, ...incoming };
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
async list(includeSecrets = false) {
|
| 75 |
-
const rows = await this.d1.all(
|
| 76 |
-
`SELECT * FROM storage_configs ORDER BY is_default DESC, type ASC, created_at ASC`
|
| 77 |
-
);
|
| 78 |
-
return rows.map((row) => this.parseRow(row, includeSecrets));
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
async getById(id, includeSecrets = true) {
|
| 82 |
-
const row = await this.d1.get('SELECT * FROM storage_configs WHERE id = ?', [id]);
|
| 83 |
-
return this.parseRow(row, includeSecrets);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
async getDefault() {
|
| 87 |
-
const row = await this.d1.get(
|
| 88 |
-
'SELECT * FROM storage_configs WHERE is_default = 1 ORDER BY updated_at DESC LIMIT 1'
|
| 89 |
-
);
|
| 90 |
-
return this.parseRow(row, true);
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
async findEnabledByType(type) {
|
| 94 |
-
const normalized = normalizeStorageType(type);
|
| 95 |
-
const rows = await this.d1.all(
|
| 96 |
-
`SELECT * FROM storage_configs WHERE type = ? AND enabled = 1 ORDER BY is_default DESC, updated_at DESC`,
|
| 97 |
-
[normalized]
|
| 98 |
-
);
|
| 99 |
-
return rows.map((row) => this.parseRow(row, true));
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
async resolveStorageSelection({ storageId, storageMode }) {
|
| 103 |
-
if (storageId) {
|
| 104 |
-
const byId = await this.getById(storageId, true);
|
| 105 |
-
if (!byId || !byId.enabled) throw new Error('Selected storage config not found or disabled.');
|
| 106 |
-
return byId;
|
| 107 |
-
}
|
| 108 |
-
if (storageMode) {
|
| 109 |
-
const typed = await this.findEnabledByType(storageMode);
|
| 110 |
-
if (typed.length > 0) return typed[0];
|
| 111 |
-
}
|
| 112 |
-
const defaultConfig = await this.getDefault();
|
| 113 |
-
if (defaultConfig && defaultConfig.enabled) return defaultConfig;
|
| 114 |
-
const rows = await this.d1.all(
|
| 115 |
-
`SELECT * FROM storage_configs WHERE enabled = 1 ORDER BY is_default DESC, created_at ASC LIMIT 1`
|
| 116 |
-
);
|
| 117 |
-
return this.parseRow(rows[0] || null, true);
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
async create({ name, type, config, enabled = true, isDefault = false, metadata = {} }) {
|
| 121 |
-
const normalizedType = normalizeStorageType(type);
|
| 122 |
-
const now = Date.now();
|
| 123 |
-
const id = randomId('sc');
|
| 124 |
-
const encrypted = encryptJson(config, this.appConfig.configEncryptionKey);
|
| 125 |
-
|
| 126 |
-
await this.d1.transaction(async (tx) => {
|
| 127 |
-
if (isDefault) {
|
| 128 |
-
await tx.run('UPDATE storage_configs SET is_default = 0');
|
| 129 |
-
}
|
| 130 |
-
await tx.run(
|
| 131 |
-
`INSERT INTO storage_configs(
|
| 132 |
-
id, name, type, encrypted_payload, is_default, enabled, metadata_json, created_at, updated_at
|
| 133 |
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
| 134 |
-
[
|
| 135 |
-
id,
|
| 136 |
-
name,
|
| 137 |
-
normalizedType,
|
| 138 |
-
JSON.stringify(encrypted),
|
| 139 |
-
isDefault ? 1 : 0,
|
| 140 |
-
enabled ? 1 : 0,
|
| 141 |
-
JSON.stringify(metadata || {}),
|
| 142 |
-
now,
|
| 143 |
-
now,
|
| 144 |
-
]
|
| 145 |
-
);
|
| 146 |
-
});
|
| 147 |
-
|
| 148 |
-
return this.getById(id, true);
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
async update(id, patch) {
|
| 152 |
-
const current = await this.getById(id, true);
|
| 153 |
-
if (!current) return null;
|
| 154 |
-
|
| 155 |
-
const nextType = normalizeStorageType(patch.type || current.type);
|
| 156 |
-
const nextConfig = this.mergeConfigPreserveSecrets(nextType, current.config, patch.config);
|
| 157 |
-
const encrypted = encryptJson(nextConfig, this.appConfig.configEncryptionKey);
|
| 158 |
-
const now = Date.now();
|
| 159 |
-
|
| 160 |
-
await this.d1.transaction(async (tx) => {
|
| 161 |
-
if (patch.isDefault) {
|
| 162 |
-
await tx.run('UPDATE storage_configs SET is_default = 0 WHERE id != ?', [id]);
|
| 163 |
-
}
|
| 164 |
-
await tx.run(
|
| 165 |
-
`UPDATE storage_configs SET name = ?, type = ?, encrypted_payload = ?, is_default = ?, enabled = ?, metadata_json = ?, updated_at = ? WHERE id = ?`,
|
| 166 |
-
[
|
| 167 |
-
patch.name || current.name,
|
| 168 |
-
nextType,
|
| 169 |
-
JSON.stringify(encrypted),
|
| 170 |
-
patch.isDefault != null ? (patch.isDefault ? 1 : 0) : (current.isDefault ? 1 : 0),
|
| 171 |
-
patch.enabled != null ? (patch.enabled ? 1 : 0) : (current.enabled ? 1 : 0),
|
| 172 |
-
JSON.stringify(patch.metadata || current.metadata || {}),
|
| 173 |
-
now,
|
| 174 |
-
id,
|
| 175 |
-
]
|
| 176 |
-
);
|
| 177 |
-
});
|
| 178 |
-
|
| 179 |
-
return this.getById(id, true);
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
async setDefault(id) {
|
| 183 |
-
await this.d1.transaction(async (tx) => {
|
| 184 |
-
await tx.run('UPDATE storage_configs SET is_default = 0');
|
| 185 |
-
await tx.run('UPDATE storage_configs SET is_default = 1, updated_at = ? WHERE id = ?', [Date.now(), id]);
|
| 186 |
-
});
|
| 187 |
-
return this.getById(id, true);
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
async delete(id) {
|
| 191 |
-
const inUse = this.fileRepo.countByStorageConfigId(id);
|
| 192 |
-
if (inUse > 0) {
|
| 193 |
-
throw new Error('Storage config is in use by existing files and cannot be deleted.');
|
| 194 |
-
}
|
| 195 |
-
const result = await this.d1.run('DELETE FROM storage_configs WHERE id = ?', [id]);
|
| 196 |
-
return Number(result.changes || 0) > 0;
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
async ensureBootstrapStorage() {
|
| 200 |
-
const rows = await this.d1.all('SELECT COUNT(1) AS c FROM storage_configs');
|
| 201 |
-
const count = rows[0] ? Number(rows[0].c) : 0;
|
| 202 |
-
if (count > 0) return;
|
| 203 |
-
|
| 204 |
-
const bootstrap = this.appConfig.bootstrapDefaultStorage;
|
| 205 |
-
const type = normalizeStorageType(bootstrap.type || 'telegram');
|
| 206 |
-
const byType = bootstrap[type] || {};
|
| 207 |
-
|
| 208 |
-
const hasRequired = {
|
| 209 |
-
telegram: Boolean(byType.botToken && byType.chatId),
|
| 210 |
-
r2: Boolean(byType.endpoint && byType.bucket && byType.accessKeyId && byType.secretAccessKey),
|
| 211 |
-
s3: Boolean(byType.endpoint && byType.bucket && byType.accessKeyId && byType.secretAccessKey),
|
| 212 |
-
discord: Boolean(byType.webhookUrl || (byType.botToken && byType.channelId)),
|
| 213 |
-
huggingface: Boolean(byType.token && byType.repo),
|
| 214 |
-
webdav: Boolean(byType.baseUrl && (byType.bearerToken || (byType.username && byType.password))),
|
| 215 |
-
github: Boolean(byType.repo && byType.token),
|
| 216 |
-
};
|
| 217 |
-
|
| 218 |
-
if (!hasRequired[type]) {
|
| 219 |
-
if (!hasRequired.telegram) return;
|
| 220 |
-
await this.create({
|
| 221 |
-
name: 'Telegram (Env Bootstrap)',
|
| 222 |
-
type: 'telegram',
|
| 223 |
-
config: bootstrap.telegram,
|
| 224 |
-
enabled: true,
|
| 225 |
-
isDefault: true,
|
| 226 |
-
metadata: { source: 'env-bootstrap', envSource: bootstrap.telegram?.envSource || {} },
|
| 227 |
-
});
|
| 228 |
-
return;
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
await this.create({
|
| 232 |
-
name: `${type.toUpperCase()} (Env Bootstrap)`,
|
| 233 |
-
type,
|
| 234 |
-
config: byType,
|
| 235 |
-
enabled: true,
|
| 236 |
-
isDefault: true,
|
| 237 |
-
metadata: { source: 'env-bootstrap', envSource: byType?.envSource || {} },
|
| 238 |
-
});
|
| 239 |
-
}
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
module.exports = {
|
| 243 |
-
StorageConfigRepositoryD1,
|
| 244 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/settings/d1-store.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* D1-backed app_settings store.
|
| 3 |
-
*/
|
| 4 |
-
function serializeValue(value) {
|
| 5 |
-
return JSON.stringify(value === undefined ? null : value);
|
| 6 |
-
}
|
| 7 |
-
|
| 8 |
-
function deserializeValue(valueJson) {
|
| 9 |
-
if (typeof valueJson !== 'string') return null;
|
| 10 |
-
try {
|
| 11 |
-
return JSON.parse(valueJson);
|
| 12 |
-
} catch {
|
| 13 |
-
return valueJson;
|
| 14 |
-
}
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
class D1SettingsStore {
|
| 18 |
-
constructor(d1Client) {
|
| 19 |
-
this.d1 = d1Client;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
async getAll() {
|
| 23 |
-
const rows = await this.d1.all('SELECT key, value_json FROM app_settings');
|
| 24 |
-
const output = {};
|
| 25 |
-
for (const row of rows) {
|
| 26 |
-
output[row.key] = deserializeValue(row.value_json);
|
| 27 |
-
}
|
| 28 |
-
return output;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
async getMany(keys = []) {
|
| 32 |
-
if (!Array.isArray(keys) || keys.length === 0) return {};
|
| 33 |
-
const result = {};
|
| 34 |
-
for (const key of keys) {
|
| 35 |
-
const normalizedKey = String(key || '').trim();
|
| 36 |
-
if (!normalizedKey) continue;
|
| 37 |
-
const row = await this.d1.get('SELECT value_json FROM app_settings WHERE key = ?', [normalizedKey]);
|
| 38 |
-
if (row) result[normalizedKey] = deserializeValue(row.value_json);
|
| 39 |
-
}
|
| 40 |
-
return result;
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
async setMany(values = {}) {
|
| 44 |
-
if (!values || typeof values !== 'object' || Array.isArray(values)) return;
|
| 45 |
-
const now = Date.now();
|
| 46 |
-
for (const [rawKey, value] of Object.entries(values)) {
|
| 47 |
-
const key = String(rawKey || '').trim();
|
| 48 |
-
if (!key) continue;
|
| 49 |
-
await this.d1.run(
|
| 50 |
-
`INSERT INTO app_settings(key, value_json, updated_at) VALUES (?, ?, ?)
|
| 51 |
-
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
|
| 52 |
-
[key, serializeValue(value), now]
|
| 53 |
-
);
|
| 54 |
-
}
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
async deleteMany(keys = []) {
|
| 58 |
-
if (!Array.isArray(keys) || keys.length === 0) return;
|
| 59 |
-
for (const rawKey of keys) {
|
| 60 |
-
const key = String(rawKey || '').trim();
|
| 61 |
-
if (!key) continue;
|
| 62 |
-
await this.d1.run('DELETE FROM app_settings WHERE key = ?', [key]);
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
async healthCheck() {
|
| 67 |
-
try {
|
| 68 |
-
await this.d1.get('SELECT 1');
|
| 69 |
-
return { backend: 'd1', connected: true, message: 'Cloudflare D1 app settings store enabled' };
|
| 70 |
-
} catch (e) {
|
| 71 |
-
return { backend: 'd1', connected: false, message: String(e?.message || e) };
|
| 72 |
-
}
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
async close() {}
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
module.exports = {
|
| 79 |
-
D1SettingsStore,
|
| 80 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/lib/settings/factory.js
CHANGED
|
@@ -1,12 +1,7 @@
|
|
| 1 |
-
const { SqliteSettingsStore } = require('./sqlite-store');
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
if (d1Client) {
|
| 6 |
-
return new D1SettingsStore(d1Client);
|
| 7 |
-
}
|
| 8 |
-
|
| 9 |
-
const mode = String(config.settingsStore || 'sqlite').toLowerCase();
|
| 10 |
|
| 11 |
if (mode === 'sqlite') {
|
| 12 |
return new SqliteSettingsStore(db);
|
|
|
|
| 1 |
+
const { SqliteSettingsStore } = require('./sqlite-store');
|
| 2 |
+
|
| 3 |
+
function createSettingsStore({ db, config }) {
|
| 4 |
+
const mode = String(config.settingsStore || 'sqlite').toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
if (mode === 'sqlite') {
|
| 7 |
return new SqliteSettingsStore(db);
|