spongyicybulk commited on
Commit
e09abbb
·
verified ·
1 Parent(s): 67dd44a

Remove D1 and add SQLite GitHub backup for HF

Browse files
.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. If your Space supports persistent storage, keep it enabled so SQLite data survives restarts.
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
- ## Variables You Usually Do Not Need To Override
115
-
116
- These are already baked into the root `Dockerfile` for Spaces:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ## Cloudflare D1 (Optional)
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
- settings: { connected: false, message: 'Unknown' },
757
- diagnostics: {},
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
- const fs = require('node:fs');
2
- const path = require('node:path');
3
- const { DatabaseSync } = require('node:sqlite');
4
-
5
- function executeStatement(stmt, method, params) {
6
- if (params == null) {
7
- return stmt[method]();
8
- }
9
- if (Array.isArray(params)) {
10
- return stmt[method](...params);
11
- }
12
- return stmt[method](params);
13
- }
14
-
15
- function initDatabase(dbPath) {
16
- const fullPath = path.resolve(dbPath);
17
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
18
-
19
- const db = new DatabaseSync(fullPath);
20
- const schemaPath = path.resolve(__dirname, 'schema.sql');
21
- const schema = fs.readFileSync(schemaPath, 'utf8');
22
- db.exec(schema);
23
-
24
- return db;
25
- }
26
-
27
- function run(db, sql, params) {
28
- const stmt = db.prepare(sql);
29
- return executeStatement(stmt, 'run', params);
30
- }
31
-
32
- function get(db, sql, params) {
33
- const stmt = db.prepare(sql);
34
- return executeStatement(stmt, 'get', params);
35
- }
36
-
37
- function all(db, sql, params) {
38
- const stmt = db.prepare(sql);
39
- return executeStatement(stmt, 'all', params);
40
- }
41
-
42
- function transaction(db, callback) {
43
- db.exec('BEGIN');
44
- try {
45
- const result = callback();
46
- db.exec('COMMIT');
47
- return result;
48
- } catch (error) {
49
- db.exec('ROLLBACK');
50
- throw error;
51
- }
52
- }
53
-
54
- function cleanupExpiredState(db) {
55
- const now = Date.now();
56
- run(db, 'DELETE FROM sessions WHERE expires_at <= ?', [now]);
57
- run(db, 'DELETE FROM chunk_uploads WHERE expires_at <= ?', [now]);
58
- }
59
-
60
- module.exports = {
61
- initDatabase,
62
- run,
63
- get,
64
- all,
65
- transaction,
66
- cleanupExpiredState,
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 port = Number(process.env.PORT || 8787);
7
- console.log(`[k-vault] Starting Docker runtime on :${port}`);
8
- serve({ fetch: app.fetch, port });
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 telegramToken = pickEnvAlias(env, ['TG_BOT_TOKEN', 'TG_Bot_Token']);
35
- const telegramChatId = pickEnvAlias(env, ['TG_CHAT_ID', 'TG_Chat_ID']);
36
- const telegramApiBase = pickEnvAlias(env, ['CUSTOM_BOT_API_URL'], 'https://api.telegram.org');
 
 
 
 
 
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
- useD1: Boolean(
64
- (env.CF_ACCOUNT_ID || env.CLOUDFLARE_ACCOUNT_ID) &&
65
- (env.CF_D1_DATABASE_ID || env.CLOUDFLARE_D1_DATABASE_ID) &&
66
- (env.CF_API_TOKEN || env.CLOUDFLARE_API_TOKEN)
67
- ),
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
-
72
- telegramApiBase: telegramApiBase.value,
 
 
 
 
 
 
 
 
 
 
 
 
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 { StorageConfigRepositoryD1 } = require('./repos/storage-config-repo-d1');
8
- const { FileRepository } = require('./repos/file-repo');
9
- const { UploadService } = require('./services/upload-service');
10
- const { ChunkUploadService } = require('./services/chunk-service');
11
- const { createSettingsStore } = require('./settings/factory');
12
- const fs = require('node:fs');
13
- const path = require('node:path');
14
- const { createD1Client } = require('./d1/client');
15
- const { D1SessionStore } = require('./d1/session-store');
16
- const { D1GuestStore } = require('./d1/guest-store');
17
-
18
- const D1_SCHEMA_PATH = path.join(__dirname, 'd1', 'schema.sql');
19
-
20
- async function createContainer(env = process.env) {
21
- const config = loadConfig(env);
22
- const db = initDatabase(config.dbPath);
23
-
24
- const d1Client = createD1Client(env);
25
- const useD1 = Boolean(d1Client);
26
- const fileRepo = new FileRepository(db);
27
-
28
- let storageRepo;
29
- let settingsStore;
30
- let authService;
31
- let guestService;
32
-
33
- if (useD1) {
34
- try {
35
- const schemaSql = fs.readFileSync(D1_SCHEMA_PATH, 'utf8');
36
- await d1Client.initSchema(schemaSql);
37
- } catch (e) {
38
- console.warn('[d1] Schema init warning:', e?.message || e);
39
- }
40
- storageRepo = new StorageConfigRepositoryD1(d1Client, config, fileRepo);
41
- settingsStore = createSettingsStore({ db, config, d1Client });
42
- const sessionStore = new D1SessionStore(d1Client);
43
- const guestStore = new D1GuestStore(d1Client);
44
- authService = new AuthService(sessionStore, config);
45
- guestService = new GuestService(guestStore, config);
46
-
47
- await storageRepo.ensureBootstrapStorage();
48
- await sessionStore.cleanupExpired?.();
49
- } else {
50
- storageRepo = new StorageConfigRepository(db, config);
51
- settingsStore = createSettingsStore({ db, config });
52
- authService = new AuthService(db, config);
53
- guestService = new GuestService(db, config);
54
-
55
- await storageRepo.ensureBootstrapStorage();
56
- cleanupExpiredState(db);
57
- }
58
- const storageFactory = new StorageFactory();
59
-
60
- const uploadService = new UploadService({
61
- storageRepo,
62
- fileRepo,
63
- storageFactory,
64
- });
65
-
66
- const chunkService = new ChunkUploadService({
67
- db,
68
- config,
69
- uploadService,
70
- });
71
-
72
- return {
73
- config,
74
- db,
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
- const { D1SettingsStore } = require('./d1-store');
3
-
4
- function createSettingsStore({ db, config, d1Client }) {
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);