termsearch 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/termsearch.js +433 -0
- package/config.example.json +31 -0
- package/frontend/dist/app.js +1051 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/icon.svg +8 -0
- package/frontend/dist/index.html +28 -0
- package/frontend/dist/manifest.json +40 -0
- package/frontend/dist/opensearch.xml +8 -0
- package/frontend/dist/style.css +756 -0
- package/package.json +48 -0
- package/scripts/postinstall.js +84 -0
- package/src/ai/orchestrator.js +163 -0
- package/src/ai/providers/openai-compat.js +255 -0
- package/src/ai/query.js +54 -0
- package/src/ai/summary.js +120 -0
- package/src/api/middleware.js +91 -0
- package/src/api/routes.js +461 -0
- package/src/autostart/manager.js +207 -0
- package/src/config/defaults.js +62 -0
- package/src/config/manager.js +188 -0
- package/src/fetch/document.js +297 -0
- package/src/fetch/ssrf-guard.js +40 -0
- package/src/profiler/scanner.js +212 -0
- package/src/search/cache.js +119 -0
- package/src/search/engine.js +231 -0
- package/src/search/providers/brave.js +57 -0
- package/src/search/providers/duckduckgo.js +148 -0
- package/src/search/providers/mojeek.js +56 -0
- package/src/search/providers/searxng.js +53 -0
- package/src/search/providers/wikipedia.js +70 -0
- package/src/search/ranking.js +155 -0
- package/src/server.js +68 -0
- package/src/social/scrapers.js +356 -0
- package/src/social/search.js +77 -0
- package/src/torrent/scrapers.js +125 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Autostart manager — enable/disable TermSearch at boot
|
|
2
|
+
// Supports: Termux (Termux:Boot), Linux (systemd --user), macOS (launchd)
|
|
3
|
+
|
|
4
|
+
import { execSync, execFileSync } from 'child_process';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
// ── Platform detection ────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export function detectPlatform() {
|
|
12
|
+
if (process.env.PREFIX?.includes('com.termux') || fs.existsSync('/data/data/com.termux')) {
|
|
13
|
+
return 'termux';
|
|
14
|
+
}
|
|
15
|
+
if (process.platform === 'darwin') return 'macos';
|
|
16
|
+
if (process.platform === 'linux') return 'linux';
|
|
17
|
+
return 'unsupported';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Binary path ───────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function findBin() {
|
|
23
|
+
try {
|
|
24
|
+
return execSync('which termsearch', { encoding: 'utf8' }).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return 'termsearch'; // fallback: hope it's in PATH
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Termux (Termux:Boot) ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const TERMUX_BOOT_DIR = path.join(os.homedir(), '.termux', 'boot');
|
|
33
|
+
const TERMUX_BOOT_FILE = path.join(TERMUX_BOOT_DIR, 'termsearch.sh');
|
|
34
|
+
|
|
35
|
+
function termuxStatus() {
|
|
36
|
+
const bootAppInstalled = fs.existsSync(TERMUX_BOOT_DIR);
|
|
37
|
+
const enabled = fs.existsSync(TERMUX_BOOT_FILE);
|
|
38
|
+
return {
|
|
39
|
+
platform: 'termux',
|
|
40
|
+
enabled,
|
|
41
|
+
method: 'Termux:Boot',
|
|
42
|
+
config_path: TERMUX_BOOT_FILE,
|
|
43
|
+
note: bootAppInstalled
|
|
44
|
+
? null
|
|
45
|
+
: 'Install Termux:Boot from F-Droid to enable autostart',
|
|
46
|
+
available: bootAppInstalled,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function termuxEnable() {
|
|
51
|
+
fs.mkdirSync(TERMUX_BOOT_DIR, { recursive: true });
|
|
52
|
+
const bin = findBin();
|
|
53
|
+
const sh = `#!/data/data/com.termux/files/usr/bin/sh\n# TermSearch autostart\n${bin} &\n`;
|
|
54
|
+
fs.writeFileSync(TERMUX_BOOT_FILE, sh, { mode: 0o755 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function termuxDisable() {
|
|
58
|
+
if (fs.existsSync(TERMUX_BOOT_FILE)) fs.unlinkSync(TERMUX_BOOT_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Linux (systemd --user) ────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const SYSTEMD_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
64
|
+
const SYSTEMD_FILE = path.join(SYSTEMD_DIR, 'termsearch.service');
|
|
65
|
+
|
|
66
|
+
function systemdAvailable() {
|
|
67
|
+
try {
|
|
68
|
+
execSync('systemctl --user status > /dev/null 2>&1', { stdio: 'ignore' });
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function systemdEnabled() {
|
|
76
|
+
try {
|
|
77
|
+
const out = execSync('systemctl --user is-enabled termsearch 2>/dev/null', { encoding: 'utf8' });
|
|
78
|
+
return out.trim() === 'enabled';
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function linuxStatus() {
|
|
85
|
+
const available = systemdAvailable();
|
|
86
|
+
return {
|
|
87
|
+
platform: 'linux',
|
|
88
|
+
enabled: available ? systemdEnabled() : fs.existsSync(SYSTEMD_FILE),
|
|
89
|
+
method: 'systemd --user',
|
|
90
|
+
config_path: SYSTEMD_FILE,
|
|
91
|
+
note: available ? null : 'systemd --user not available on this system',
|
|
92
|
+
available,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function linuxEnable() {
|
|
97
|
+
const bin = findBin();
|
|
98
|
+
fs.mkdirSync(SYSTEMD_DIR, { recursive: true });
|
|
99
|
+
const unit = [
|
|
100
|
+
'[Unit]',
|
|
101
|
+
'Description=TermSearch personal search engine',
|
|
102
|
+
'After=network.target',
|
|
103
|
+
'',
|
|
104
|
+
'[Service]',
|
|
105
|
+
'Type=simple',
|
|
106
|
+
`ExecStart=${bin}`,
|
|
107
|
+
'Restart=on-failure',
|
|
108
|
+
'RestartSec=5',
|
|
109
|
+
'',
|
|
110
|
+
'[Install]',
|
|
111
|
+
'WantedBy=default.target',
|
|
112
|
+
].join('\n') + '\n';
|
|
113
|
+
fs.writeFileSync(SYSTEMD_FILE, unit);
|
|
114
|
+
try {
|
|
115
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
116
|
+
execFileSync('systemctl', ['--user', 'enable', 'termsearch'], { stdio: 'ignore' });
|
|
117
|
+
execFileSync('systemctl', ['--user', 'start', 'termsearch'], { stdio: 'ignore' });
|
|
118
|
+
} catch { /* daemon-reload may fail in containers — file is written */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function linuxDisable() {
|
|
122
|
+
try {
|
|
123
|
+
execFileSync('systemctl', ['--user', 'stop', 'termsearch'], { stdio: 'ignore' });
|
|
124
|
+
execFileSync('systemctl', ['--user', 'disable', 'termsearch'], { stdio: 'ignore' });
|
|
125
|
+
} catch { /* ignore */ }
|
|
126
|
+
if (fs.existsSync(SYSTEMD_FILE)) fs.unlinkSync(SYSTEMD_FILE);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── macOS (launchd) ───────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const LAUNCHD_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
132
|
+
const LAUNCHD_FILE = path.join(LAUNCHD_DIR, 'com.termsearch.plist');
|
|
133
|
+
const PLIST_LABEL = 'com.termsearch';
|
|
134
|
+
|
|
135
|
+
function macosStatus() {
|
|
136
|
+
const enabled = fs.existsSync(LAUNCHD_FILE);
|
|
137
|
+
return {
|
|
138
|
+
platform: 'macos',
|
|
139
|
+
enabled,
|
|
140
|
+
method: 'launchd (LaunchAgent)',
|
|
141
|
+
config_path: LAUNCHD_FILE,
|
|
142
|
+
note: null,
|
|
143
|
+
available: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function macosEnable() {
|
|
148
|
+
const bin = findBin();
|
|
149
|
+
fs.mkdirSync(LAUNCHD_DIR, { recursive: true });
|
|
150
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
151
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
152
|
+
<plist version="1.0">
|
|
153
|
+
<dict>
|
|
154
|
+
<key>Label</key>
|
|
155
|
+
<string>${PLIST_LABEL}</string>
|
|
156
|
+
<key>ProgramArguments</key>
|
|
157
|
+
<array>
|
|
158
|
+
<string>${bin}</string>
|
|
159
|
+
</array>
|
|
160
|
+
<key>RunAtLoad</key>
|
|
161
|
+
<true/>
|
|
162
|
+
<key>KeepAlive</key>
|
|
163
|
+
<true/>
|
|
164
|
+
<key>StandardOutPath</key>
|
|
165
|
+
<string>${os.homedir()}/.termsearch/termsearch.log</string>
|
|
166
|
+
<key>StandardErrorPath</key>
|
|
167
|
+
<string>${os.homedir()}/.termsearch/termsearch.log</string>
|
|
168
|
+
</dict>
|
|
169
|
+
</plist>
|
|
170
|
+
`;
|
|
171
|
+
fs.writeFileSync(LAUNCHD_FILE, plist);
|
|
172
|
+
// Try modern bootstrap API first (macOS Ventura+), fallback to legacy load
|
|
173
|
+
try {
|
|
174
|
+
const uid = execSync('id -u', { encoding: 'utf8' }).trim();
|
|
175
|
+
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, LAUNCHD_FILE], { stdio: 'ignore' });
|
|
176
|
+
} catch {
|
|
177
|
+
try { execFileSync('launchctl', ['load', '-w', LAUNCHD_FILE], { stdio: 'ignore' }); } catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function macosDisable() {
|
|
182
|
+
try {
|
|
183
|
+
const uid = execSync('id -u', { encoding: 'utf8' }).trim();
|
|
184
|
+
execFileSync('launchctl', ['bootout', `gui/${uid}`, LAUNCHD_FILE], { stdio: 'ignore' });
|
|
185
|
+
} catch {
|
|
186
|
+
try { execFileSync('launchctl', ['unload', '-w', LAUNCHD_FILE], { stdio: 'ignore' }); } catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
if (fs.existsSync(LAUNCHD_FILE)) fs.unlinkSync(LAUNCHD_FILE);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export function getStatus() {
|
|
194
|
+
const platform = detectPlatform();
|
|
195
|
+
if (platform === 'termux') return termuxStatus();
|
|
196
|
+
if (platform === 'linux') return linuxStatus();
|
|
197
|
+
if (platform === 'macos') return macosStatus();
|
|
198
|
+
return { platform: 'unsupported', enabled: false, method: null, note: 'Autostart not supported on this platform', available: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function setEnabled(enable) {
|
|
202
|
+
const platform = detectPlatform();
|
|
203
|
+
if (platform === 'termux') { enable ? termuxEnable() : termuxDisable(); return getStatus(); }
|
|
204
|
+
if (platform === 'linux') { enable ? linuxEnable() : linuxDisable(); return getStatus(); }
|
|
205
|
+
if (platform === 'macos') { enable ? macosEnable() : macosDisable(); return getStatus(); }
|
|
206
|
+
throw new Error('Autostart not supported on this platform');
|
|
207
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Default configuration values for TermSearch
|
|
2
|
+
// These are deep-merged with user config on load, so new keys auto-appear on upgrade.
|
|
3
|
+
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
port: 3000,
|
|
6
|
+
host: '127.0.0.1',
|
|
7
|
+
|
|
8
|
+
search: {
|
|
9
|
+
providers: ['duckduckgo', 'wikipedia'],
|
|
10
|
+
timeout_ms: 15000,
|
|
11
|
+
max_query_length: 240,
|
|
12
|
+
result_count: 10,
|
|
13
|
+
fallback_min_results: 5,
|
|
14
|
+
cache_ttl_search_ms: 720_000, // 12 min
|
|
15
|
+
cache_ttl_doc_ms: 2_700_000, // 45 min
|
|
16
|
+
cache_l1_max_search: 200,
|
|
17
|
+
cache_l1_max_docs: 150,
|
|
18
|
+
// Disk cache — conservative defaults for Termux/low-end devices
|
|
19
|
+
disk_max_search_entries: 1000,
|
|
20
|
+
disk_max_search_bytes: 50 * 1024 * 1024, // 50 MB
|
|
21
|
+
disk_max_doc_entries: 1500,
|
|
22
|
+
disk_max_doc_bytes: 100 * 1024 * 1024, // 100 MB
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
ai: {
|
|
26
|
+
enabled: false,
|
|
27
|
+
api_base: '', // e.g. http://localhost:11434/v1
|
|
28
|
+
api_key: '', // optional
|
|
29
|
+
model: '', // e.g. qwen3:1.7b
|
|
30
|
+
max_tokens: 1200,
|
|
31
|
+
timeout_ms: 90_000,
|
|
32
|
+
rate_limit: 20, // per hour per IP
|
|
33
|
+
rate_window_ms: 3_600_000,
|
|
34
|
+
fetch_soft_cap: 15,
|
|
35
|
+
fetch_hard_cap: 25,
|
|
36
|
+
fetch_max_per_domain: 2,
|
|
37
|
+
fetch_min_per_engine: 3,
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
brave: {
|
|
41
|
+
enabled: false,
|
|
42
|
+
api_key: '',
|
|
43
|
+
api_base: 'https://api.search.brave.com/res/v1',
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
mojeek: {
|
|
47
|
+
enabled: false,
|
|
48
|
+
api_key: '',
|
|
49
|
+
api_base: 'https://api.mojeek.com',
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
searxng: {
|
|
53
|
+
enabled: false,
|
|
54
|
+
url: '', // e.g. http://localhost:9090
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
rate_limit: {
|
|
58
|
+
general_per_min: 45,
|
|
59
|
+
search_per_min: 30,
|
|
60
|
+
window_ms: 60_000,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { DEFAULTS } from './defaults.js';
|
|
5
|
+
|
|
6
|
+
// Data directory: $TERMSEARCH_DATA_DIR or ~/.termsearch/
|
|
7
|
+
function resolveDataDir() {
|
|
8
|
+
return process.env.TERMSEARCH_DATA_DIR
|
|
9
|
+
? path.resolve(process.env.TERMSEARCH_DATA_DIR)
|
|
10
|
+
: path.join(os.homedir(), '.termsearch');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Deep-merge: src values override dst, recursively for plain objects
|
|
14
|
+
function deepMerge(dst, src) {
|
|
15
|
+
const out = { ...dst };
|
|
16
|
+
for (const [k, v] of Object.entries(src || {})) {
|
|
17
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v) && typeof dst[k] === 'object' && dst[k] !== null && !Array.isArray(dst[k])) {
|
|
18
|
+
out[k] = deepMerge(dst[k], v);
|
|
19
|
+
} else if (v !== undefined) {
|
|
20
|
+
out[k] = v;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMaskedValue(value) {
|
|
27
|
+
const s = String(value || '').trim();
|
|
28
|
+
return s.includes('*') || s.includes('•');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class ConfigManager {
|
|
32
|
+
constructor() {
|
|
33
|
+
this._dataDir = null;
|
|
34
|
+
this._config = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getDataDir() {
|
|
38
|
+
if (!this._dataDir) this._init();
|
|
39
|
+
return this._dataDir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getConfig() {
|
|
43
|
+
if (!this._config) this._init();
|
|
44
|
+
return this._config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_init() {
|
|
48
|
+
this._dataDir = resolveDataDir();
|
|
49
|
+
// Create data dir and cache subdirs on first run
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(this._dataDir, { recursive: true, mode: 0o700 });
|
|
52
|
+
fs.mkdirSync(path.join(this._dataDir, 'cache', 'search'), { recursive: true, mode: 0o700 });
|
|
53
|
+
fs.mkdirSync(path.join(this._dataDir, 'cache', 'docs'), { recursive: true, mode: 0o700 });
|
|
54
|
+
} catch { /* ignore */ }
|
|
55
|
+
|
|
56
|
+
const configPath = path.join(this._dataDir, 'config.json');
|
|
57
|
+
let userConfig = {};
|
|
58
|
+
if (fs.existsSync(configPath)) {
|
|
59
|
+
try {
|
|
60
|
+
userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
61
|
+
try { fs.chmodSync(configPath, 0o600); } catch { /* ignore */ }
|
|
62
|
+
} catch {
|
|
63
|
+
console.warn('[config] Warning: could not parse config.json, using defaults');
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// First run: write defaults
|
|
67
|
+
this._writeFile(configPath, DEFAULTS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Env var overrides (for power users / CI)
|
|
71
|
+
const envOverrides = this._readEnvOverrides();
|
|
72
|
+
this._config = deepMerge(deepMerge(DEFAULTS, userConfig), envOverrides);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_readEnvOverrides() {
|
|
76
|
+
const overrides = {};
|
|
77
|
+
if (process.env.TERMSEARCH_PORT) overrides.port = Number(process.env.TERMSEARCH_PORT);
|
|
78
|
+
if (process.env.TERMSEARCH_HOST) overrides.host = process.env.TERMSEARCH_HOST;
|
|
79
|
+
if (process.env.TERMSEARCH_AI_API_BASE) {
|
|
80
|
+
overrides.ai = { ...overrides.ai, api_base: process.env.TERMSEARCH_AI_API_BASE, enabled: true };
|
|
81
|
+
}
|
|
82
|
+
if (process.env.TERMSEARCH_AI_API_KEY) {
|
|
83
|
+
overrides.ai = { ...overrides.ai, api_key: process.env.TERMSEARCH_AI_API_KEY };
|
|
84
|
+
}
|
|
85
|
+
if (process.env.TERMSEARCH_AI_MODEL) {
|
|
86
|
+
overrides.ai = { ...overrides.ai, model: process.env.TERMSEARCH_AI_MODEL };
|
|
87
|
+
}
|
|
88
|
+
if (process.env.TERMSEARCH_BRAVE_API_KEY) {
|
|
89
|
+
overrides.brave = { api_key: process.env.TERMSEARCH_BRAVE_API_KEY, enabled: true };
|
|
90
|
+
}
|
|
91
|
+
if (process.env.TERMSEARCH_MOJEEK_API_KEY) {
|
|
92
|
+
overrides.mojeek = { api_key: process.env.TERMSEARCH_MOJEEK_API_KEY, enabled: true };
|
|
93
|
+
}
|
|
94
|
+
if (process.env.TERMSEARCH_SEARXNG_URL) {
|
|
95
|
+
overrides.searxng = { url: process.env.TERMSEARCH_SEARXNG_URL, enabled: true };
|
|
96
|
+
}
|
|
97
|
+
return overrides;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Update config in-memory and persist to disk
|
|
101
|
+
update(partial) {
|
|
102
|
+
if (!this._config) this._init();
|
|
103
|
+
const safePartial = JSON.parse(JSON.stringify(partial || {}));
|
|
104
|
+
this._sanitizeSensitiveKeys(safePartial);
|
|
105
|
+
this._config = deepMerge(this._config, safePartial);
|
|
106
|
+
// Auto-enable AI/providers when keys are provided
|
|
107
|
+
if (safePartial?.ai?.api_base && !safePartial?.ai?.hasOwnProperty('enabled')) {
|
|
108
|
+
this._config.ai.enabled = Boolean(this._config.ai.api_base);
|
|
109
|
+
}
|
|
110
|
+
if (safePartial?.brave?.api_key && !safePartial?.brave?.hasOwnProperty('enabled')) {
|
|
111
|
+
this._config.brave.enabled = Boolean(this._config.brave.api_key);
|
|
112
|
+
}
|
|
113
|
+
if (safePartial?.mojeek?.api_key && !safePartial?.mojeek?.hasOwnProperty('enabled')) {
|
|
114
|
+
this._config.mojeek.enabled = Boolean(this._config.mojeek.api_key);
|
|
115
|
+
}
|
|
116
|
+
if (safePartial?.searxng?.url && !safePartial?.searxng?.hasOwnProperty('enabled')) {
|
|
117
|
+
this._config.searxng.enabled = Boolean(this._config.searxng.url);
|
|
118
|
+
}
|
|
119
|
+
this._persist();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_sanitizeSensitiveKeys(partial) {
|
|
123
|
+
const sections = ['ai', 'brave', 'mojeek'];
|
|
124
|
+
for (const section of sections) {
|
|
125
|
+
const block = partial?.[section];
|
|
126
|
+
if (!block || typeof block !== 'object' || !Object.prototype.hasOwnProperty.call(block, 'api_key')) continue;
|
|
127
|
+
const incoming = block.api_key;
|
|
128
|
+
if (incoming == null) {
|
|
129
|
+
delete block.api_key;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const raw = String(incoming);
|
|
133
|
+
const trimmed = raw.trim();
|
|
134
|
+
if (trimmed === '') {
|
|
135
|
+
block.api_key = '';
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// UI placeholders like sk-****1234 must never overwrite the stored secret
|
|
139
|
+
if (isMaskedValue(trimmed)) {
|
|
140
|
+
block.api_key = this._config?.[section]?.api_key || '';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_persist() {
|
|
146
|
+
const configPath = path.join(this._dataDir, 'config.json');
|
|
147
|
+
// Don't persist env overrides — only persist what the user explicitly set via web UI
|
|
148
|
+
const toSave = this._stripSensitiveForPersist(this._config);
|
|
149
|
+
this._writeFile(configPath, toSave);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_stripSensitiveForPersist(config) {
|
|
153
|
+
// Persist everything — env overrides will re-apply on next startup
|
|
154
|
+
return JSON.parse(JSON.stringify(config));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_writeFile(filePath, data) {
|
|
158
|
+
const tmp = filePath + '.tmp';
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
161
|
+
fs.renameSync(tmp, filePath);
|
|
162
|
+
fs.chmodSync(filePath, 0o600);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.warn('[config] Could not write config file:', e.message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Returns config with API keys masked for public display
|
|
169
|
+
getPublicConfig() {
|
|
170
|
+
const c = this.getConfig();
|
|
171
|
+
return {
|
|
172
|
+
...c,
|
|
173
|
+
ai: { ...c.ai, api_key: maskKey(c.ai.api_key) },
|
|
174
|
+
brave: { ...c.brave, api_key: maskKey(c.brave.api_key) },
|
|
175
|
+
mojeek: { ...c.mojeek, api_key: maskKey(c.mojeek.api_key) },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function maskKey(key) {
|
|
181
|
+
if (!key) return '';
|
|
182
|
+
if (key.length <= 8) return '****';
|
|
183
|
+
return `${key.slice(0, 4)}****${key.slice(-4)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Singleton
|
|
187
|
+
export const config = new ConfigManager();
|
|
188
|
+
export default config;
|