@walkhi/code-relax 0.1.0-beta.10 → 0.1.0-beta.12
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/README.md +44 -54
- package/dist/bin/codex-remote.mjs +34 -60
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/app-server-events.cjs +15 -3
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +6 -11
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +51 -8
- package/dist/src/managed-app-server.mjs +21 -10
- package/dist/src/onboarding.mjs +3 -3
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-host.mjs +1 -2
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/platform/windows/service-host.mjs +0 -9
- package/dist/src/self-relay/admin-page.mjs +1 -1
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/connector.mjs +84 -12
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +52 -100
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/p2p-probe.mjs +1 -1
- package/dist/src/self-relay/server.mjs +155 -38
- package/dist/src/server.mjs +736 -610
- package/dist/src/service-doctor.mjs +1 -1
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/shared-app-server.mjs +274 -272
- package/dist/src/shared-catalog.mjs +1 -11
- package/dist/src/shared-recovery.mjs +5 -5
- package/dist/src/skill-install.mjs +0 -13
- package/package.json +2 -1
- package/dist/src/debug-ui.mjs +0 -131
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
- package/dist/web/activity-view.js +0 -283
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -50
- package/dist/web/chat-transport.js +0 -196
- package/dist/web/chat.css +0 -778
- package/dist/web/chat.js +0 -3815
- package/dist/web/client-platform.js +0 -11
- package/dist/web/community.css +0 -82
- package/dist/web/community.html +0 -36
- package/dist/web/composer-controller.js +0 -97
- package/dist/web/conversation-controller.js +0 -99
- package/dist/web/disclosure-state-controller.js +0 -95
- package/dist/web/draft-controller.js +0 -103
- package/dist/web/harmony-platform.js +0 -52
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -240
- package/dist/web/history-controller.js +0 -167
- package/dist/web/index.html +0 -288
- package/dist/web/lan-session.js +0 -122
- package/dist/web/link-action-controller.js +0 -212
- package/dist/web/message-send-controller.js +0 -177
- package/dist/web/message-view.js +0 -98
- package/dist/web/p2p-data-channel.js +0 -50
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -123
- package/dist/web/page-resume.js +0 -28
- package/dist/web/pending-message-store.js +0 -108
- package/dist/web/queue-controller.js +0 -82
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -129
- package/dist/web/server-requests.js +0 -67
- package/dist/web/station-connection-controller.js +0 -75
- package/dist/web/task-list-view.js +0 -289
- package/dist/web/thread-attention-controller.js +0 -124
- package/dist/web/thread-context-controller.js +0 -30
- package/dist/web/thread-list-controller.js +0 -61
- package/dist/web/thread-list-sync.js +0 -86
- package/dist/web/thread-title-controller.js +0 -57
- package/dist/web/timeline-formatters.js +0 -249
- package/dist/web/timeline-reducer.js +0 -77
- package/dist/web/timeline-renderer.js +0 -161
- package/dist/web/timeline-scroll-controller.js +0 -50
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/usage-controller.js +0 -258
- package/dist/web/vendor/README.md +0 -14
- package/dist/web/vendor/highlight-powershell.min.js +0 -39
- package/dist/web/vendor/highlight.LICENSE.txt +0 -29
- package/dist/web/vendor/highlight.min.js +0 -1262
- package/dist/web/vendor/lucide.LICENSE.txt +0 -17
|
@@ -3,6 +3,8 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import { promisify } from 'node:util';
|
|
7
9
|
import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
|
|
8
10
|
|
|
@@ -11,7 +13,130 @@ const requiredMethods = ['thread/start', 'thread/resume', 'thread/list', 'thread
|
|
|
11
13
|
'thread/items/list', 'turn/start', 'turn/steer', 'turn/interrupt', 'model/list', 'project/list',
|
|
12
14
|
'thread/settings/update', 'configRequirements/read', 'thread/queue/list', 'thread/queue/add',
|
|
13
15
|
'thread/queue/delete', 'thread/queue/start', 'thread/read', 'thread/unsubscribe', 'config/read', 'account/rateLimits/read'];
|
|
14
|
-
const
|
|
16
|
+
export const codeModeHostPath = executable => path.join(path.dirname(executable), 'codex-code-mode-host.exe');
|
|
17
|
+
export const runtimeFilesAvailable = executable => fs.existsSync(executable) && fs.existsSync(codeModeHostPath(executable));
|
|
18
|
+
const fingerprint = executable => [executable, codeModeHostPath(executable)]
|
|
19
|
+
.map(file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; }).join('|');
|
|
20
|
+
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
21
|
+
const desktopRuntimeScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'desktop-runtime.ps1');
|
|
22
|
+
|
|
23
|
+
async function fileHash(file) {
|
|
24
|
+
const hash = createHash('sha256');
|
|
25
|
+
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
|
26
|
+
return hash.digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function installedDesktopRuntime() {
|
|
30
|
+
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
31
|
+
'-File', desktopRuntimeScript], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
|
|
32
|
+
return JSON.parse(stdout.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function matchesInstalledDesktop(executable, installed) {
|
|
36
|
+
if (!installed || !runtimeFilesAvailable(executable) || !runtimeFilesAvailable(installed.executable)) return false;
|
|
37
|
+
const files = [[executable, installed.executable], [codeModeHostPath(executable), installed.codeModeHost]];
|
|
38
|
+
for (const [candidate, bundled] of files) {
|
|
39
|
+
if (fs.statSync(candidate).size !== fs.statSync(bundled).size || await fileHash(candidate) !== await fileHash(bundled)) return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runtimeFiles(directory, relative = '') {
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) {
|
|
47
|
+
const name = path.join(relative, entry.name);
|
|
48
|
+
if (entry.isDirectory()) files.push(...runtimeFiles(directory, name));
|
|
49
|
+
else if (entry.isFile()) files.push(name);
|
|
50
|
+
else throw new Error(`Codex 内核目录包含不支持的文件类型:${name}`);
|
|
51
|
+
}
|
|
52
|
+
return files.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runtimeDirectories(files) {
|
|
56
|
+
const directories = new Set();
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
let directory = path.dirname(file);
|
|
59
|
+
while (directory !== '.') { directories.add(directory); directory = path.dirname(directory); }
|
|
60
|
+
}
|
|
61
|
+
return [...directories].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function retainDesktopRuntime(executable, installed, stateRoot) {
|
|
65
|
+
if (!/^[\w.-]+$/.test(installed.package)) throw new Error('Codex Desktop 安装包名称无效。');
|
|
66
|
+
const source = path.dirname(executable);
|
|
67
|
+
const files = runtimeFiles(source);
|
|
68
|
+
if (files.includes('runtime.json')) throw new Error('Codex 内核目录与保留清单文件名冲突。');
|
|
69
|
+
const directories = runtimeDirectories(files);
|
|
70
|
+
const root = path.join(stateRoot, 'desktop-runtimes');
|
|
71
|
+
const target = path.join(root, installed.package);
|
|
72
|
+
const retained = path.join(target, 'codex.exe');
|
|
73
|
+
if (fs.existsSync(target)) {
|
|
74
|
+
const manifest = readRecord(path.join(target, 'runtime.json'));
|
|
75
|
+
if (manifest?.package !== installed.package || JSON.stringify(manifest.files) !== JSON.stringify(files)
|
|
76
|
+
|| JSON.stringify(runtimeFiles(target)) !== JSON.stringify([...files, 'runtime.json'].sort())
|
|
77
|
+
|| !await matchesInstalledDesktop(retained, installed)) {
|
|
78
|
+
throw new Error(`已保留的 Codex 内核文件不完整:${target}`);
|
|
79
|
+
}
|
|
80
|
+
return retained;
|
|
81
|
+
}
|
|
82
|
+
fs.mkdirSync(root, { recursive: true });
|
|
83
|
+
const staging = path.join(root, `.${installed.package}.${randomUUID()}.tmp`);
|
|
84
|
+
fs.mkdirSync(staging);
|
|
85
|
+
const created = [];
|
|
86
|
+
try {
|
|
87
|
+
for (const directory of directories) fs.mkdirSync(path.join(staging, directory));
|
|
88
|
+
for (const name of files) {
|
|
89
|
+
const from = path.join(source, name), to = path.join(staging, name);
|
|
90
|
+
created.push(to);
|
|
91
|
+
try { fs.linkSync(from, to); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code !== 'EXDEV') throw error;
|
|
94
|
+
fs.copyFileSync(from, to);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!await matchesInstalledDesktop(path.join(staging, 'codex.exe'), installed)) {
|
|
98
|
+
throw new Error('保留的 Codex 内核与当前安装包不一致。');
|
|
99
|
+
}
|
|
100
|
+
const manifestPath = path.join(staging, 'runtime.json');
|
|
101
|
+
writeRecord(manifestPath, { package: installed.package, files });
|
|
102
|
+
created.push(manifestPath);
|
|
103
|
+
fs.renameSync(staging, target);
|
|
104
|
+
return retained;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
for (const file of created) fs.rmSync(file, { force: true });
|
|
107
|
+
for (const directory of directories.reverse()) {
|
|
108
|
+
const nested = path.join(staging, directory);
|
|
109
|
+
if (fs.existsSync(nested)) fs.rmdirSync(nested);
|
|
110
|
+
}
|
|
111
|
+
fs.rmdirSync(staging);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function pruneRetainedDesktopRuntimes(stateRoot, keepExecutable) {
|
|
117
|
+
const root = path.join(stateRoot, 'desktop-runtimes');
|
|
118
|
+
if (!fs.existsSync(root)) return;
|
|
119
|
+
const keep = path.resolve(path.dirname(keepExecutable)).toLowerCase();
|
|
120
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
121
|
+
if (!entry.isDirectory() || !/^OpenAI\.Codex_[\w.-]+$/.test(entry.name)) continue;
|
|
122
|
+
const directory = path.join(root, entry.name);
|
|
123
|
+
if (path.resolve(directory).toLowerCase() === keep || fs.lstatSync(directory).isSymbolicLink()) continue;
|
|
124
|
+
const manifest = readRecord(path.join(directory, 'runtime.json'));
|
|
125
|
+
const files = manifest?.files;
|
|
126
|
+
if (manifest?.package !== entry.name || !Array.isArray(files) || !files.includes('codex.exe')
|
|
127
|
+
|| !files.includes('codex-code-mode-host.exe') || new Set(files).size !== files.length
|
|
128
|
+
|| !files.every(name => typeof name === 'string' && name !== 'runtime.json'
|
|
129
|
+
&& path.resolve(directory, name).toLowerCase().startsWith(`${path.resolve(directory).toLowerCase()}${path.sep}`))) continue;
|
|
130
|
+
const expected = [...files, 'runtime.json'].sort();
|
|
131
|
+
if (JSON.stringify(runtimeFiles(directory)) !== JSON.stringify(expected)) continue;
|
|
132
|
+
if (!fs.lstatSync(path.join(directory, 'runtime.json')).isFile()
|
|
133
|
+
|| !files.every(name => fs.lstatSync(path.join(directory, name)).isFile())) continue;
|
|
134
|
+
for (const name of files) fs.unlinkSync(path.join(directory, name));
|
|
135
|
+
fs.unlinkSync(path.join(directory, 'runtime.json'));
|
|
136
|
+
for (const name of runtimeDirectories(files).reverse()) fs.rmdirSync(path.join(directory, name));
|
|
137
|
+
fs.rmdirSync(directory);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
15
140
|
|
|
16
141
|
// Resolve native binaries, never execute a shell shim from a background task.
|
|
17
142
|
export function discoverCodex(environment = process.env) {
|
|
@@ -92,11 +217,18 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
|
|
|
92
217
|
environment = { ...environment,
|
|
93
218
|
CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
|
|
94
219
|
CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
|
|
95
|
-
if (!refresh && saved?.executable &&
|
|
220
|
+
if (!refresh && saved?.executable && runtimeFilesAvailable(saved.executable)
|
|
96
221
|
&& saved.fingerprint === fingerprint(saved.executable)) return saved;
|
|
97
222
|
const candidates = [], rejected = [];
|
|
223
|
+
let installed = null;
|
|
224
|
+
try { installed = await installedDesktopRuntime(); }
|
|
225
|
+
catch (error) { rejected.push(`无法读取当前 Codex Desktop 安装包:${error.message}`); }
|
|
98
226
|
for (const candidate of discoverCodex(environment)) {
|
|
99
227
|
try {
|
|
228
|
+
if (!runtimeFilesAvailable(candidate.executable)) throw new Error('缺少同目录的 codex-code-mode-host.exe');
|
|
229
|
+
if (candidate.source === 'desktop' && !await matchesInstalledDesktop(candidate.executable, installed)) {
|
|
230
|
+
throw new Error('不是当前 Codex Desktop 安装包配套的 CLI');
|
|
231
|
+
}
|
|
100
232
|
const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
|
|
101
233
|
const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
|
|
102
234
|
if (!version) throw new Error('无法识别版本');
|
|
@@ -106,8 +238,10 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
|
|
|
106
238
|
candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
|
|
107
239
|
for (const candidate of candidates) {
|
|
108
240
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
241
|
+
const executable = candidate.source === 'desktop'
|
|
242
|
+
? await retainDesktopRuntime(candidate.executable, installed, stateRoot) : candidate.executable;
|
|
243
|
+
await verifyProtocol(executable, stateRoot);
|
|
244
|
+
const result = { ...candidate, executable, fingerprint: fingerprint(executable),
|
|
111
245
|
codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
|
|
112
246
|
writeRecord(recordFile, result);
|
|
113
247
|
return result;
|
|
@@ -37,10 +37,9 @@ export const desktopHost = {
|
|
|
37
37
|
};
|
|
38
38
|
export const resolveStateRoot = stateRoot => runScript('resolve-state-root.ps1', ['-StateRoot', stateRoot]);
|
|
39
39
|
|
|
40
|
-
export async function
|
|
40
|
+
export async function removeDesktopShortcut({ serviceRoot, nodeExecutable }, destination) {
|
|
41
41
|
const entry = path.join(serviceRoot, 'dist/bin/codex-remote.mjs');
|
|
42
42
|
const args = ['-NodePath', nodeExecutable, '-Entry', entry];
|
|
43
|
-
if (remove) args.push('-Remove');
|
|
44
43
|
if (destination) args.push('-Destination', destination);
|
|
45
44
|
return JSON.parse(await runScript('desktop-shortcuts.ps1', args));
|
|
46
45
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
3
|
+
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
4
|
+
if (-not $package) { 'null'; exit }
|
|
5
|
+
$resources = Join-Path $package.InstallLocation 'app\resources'
|
|
6
|
+
@{
|
|
7
|
+
package = $package.PackageFullName
|
|
8
|
+
executable = Join-Path $resources 'codex.exe'
|
|
9
|
+
codeModeHost = Join-Path $resources 'codex-code-mode-host.exe'
|
|
10
|
+
} | ConvertTo-Json -Compress
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
param([string]$NodePath, [string]$Entry, [
|
|
1
|
+
param([string]$NodePath, [string]$Entry, [string]$Destination)
|
|
2
2
|
$ErrorActionPreference = 'Stop'
|
|
3
3
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
4
|
$shell = New-Object -ComObject WScript.Shell
|
|
5
5
|
if (-not $Destination) { $Destination = $shell.SpecialFolders.Item('Desktop') }
|
|
6
|
-
|
|
6
|
+
# Only identify and remove the retired shortcut; never create a new launcher.
|
|
7
|
+
$launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
|
|
7
8
|
$items = @(@{Name='Code Relax - Codex 内核'; Command='shared-start'})
|
|
8
9
|
$results = @()
|
|
9
10
|
foreach ($item in $items) {
|
|
@@ -11,18 +12,10 @@ foreach ($item in $items) {
|
|
|
11
12
|
$arguments = '-NoProfile -NoExit -ExecutionPolicy Bypass -File "' + $launcher + '" -NodePath "' + $NodePath + '" -Entry "' + $Entry + '" -Action ' + $item.Command
|
|
12
13
|
if (Test-Path -LiteralPath $target) {
|
|
13
14
|
$existing = $shell.CreateShortcut($target)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if ($Remove) {
|
|
17
|
-
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target }
|
|
18
|
-
} else {
|
|
19
|
-
$link = $shell.CreateShortcut($target)
|
|
20
|
-
$link.TargetPath = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
|
|
21
|
-
$link.Arguments = $arguments
|
|
22
|
-
$link.WorkingDirectory = Split-Path $Entry -Parent
|
|
23
|
-
$link.Description = $item.Name
|
|
24
|
-
$link.Save()
|
|
15
|
+
$powershell = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
|
|
16
|
+
if ($existing.Arguments -ne $arguments -or $existing.TargetPath -ne $powershell) { continue }
|
|
25
17
|
}
|
|
18
|
+
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target }
|
|
26
19
|
$results += $target
|
|
27
20
|
}
|
|
28
21
|
ConvertTo-Json -InputObject $results -Compress
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Windows Script Host has no console. Keep the login task alive until the
|
|
2
|
+
// short-lived CLI has handed the resident service to the background broker.
|
|
3
|
+
if (WScript.Arguments.length !== 2) WScript.Quit(2);
|
|
4
|
+
var shell = new ActiveXObject('WScript.Shell');
|
|
5
|
+
var node = WScript.Arguments.Item(0);
|
|
6
|
+
var entry = WScript.Arguments.Item(1);
|
|
7
|
+
var command = '"' + node + '" "' + entry + '" start --json';
|
|
8
|
+
WScript.Quit(shell.Run(command, 0, true));
|
|
@@ -5,7 +5,9 @@ $ErrorActionPreference = 'Stop'
|
|
|
5
5
|
$taskName = 'Code Relax Bridge'
|
|
6
6
|
$taskSource = '@walkhi/code-relax'
|
|
7
7
|
$taskPath = '\' + $taskName
|
|
8
|
-
$
|
|
8
|
+
$launcher = Join-Path $PSScriptRoot 'resident-start.js'
|
|
9
|
+
$wscript = Join-Path $env:WINDIR 'System32\wscript.exe'
|
|
10
|
+
$arguments = '//B //Nologo "' + $launcher + '" "' + $NodePath + '" "' + $Entry + '"'
|
|
9
11
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
10
12
|
|
|
11
13
|
$scheduler = New-Object -ComObject 'Schedule.Service'
|
|
@@ -30,7 +32,8 @@ if ($Action -ne 'status' -and $legacyExists) {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
if ($Action -eq 'enable') {
|
|
33
|
-
if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)
|
|
35
|
+
if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry) -or
|
|
36
|
+
-not (Test-Path -LiteralPath $launcher) -or -not (Test-Path -LiteralPath $wscript)) { throw 'Installed runtime is missing.' }
|
|
34
37
|
$definition = $scheduler.NewTask(0)
|
|
35
38
|
$definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
|
|
36
39
|
$definition.RegistrationInfo.Source = $taskSource
|
|
@@ -48,7 +51,7 @@ if ($Action -eq 'enable') {
|
|
|
48
51
|
$trigger.UserId = $currentUser
|
|
49
52
|
$trigger.Enabled = $true
|
|
50
53
|
$exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
|
|
51
|
-
$exec.Path = $
|
|
54
|
+
$exec.Path = $wscript
|
|
52
55
|
$exec.Arguments = $arguments
|
|
53
56
|
$exec.WorkingDirectory = $env:USERPROFILE
|
|
54
57
|
[void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE
|
|
@@ -20,21 +20,12 @@ async function detect(script) {
|
|
|
20
20
|
export const sharedServiceUrl = () => detect('get-shared-service-url.ps1');
|
|
21
21
|
export const lanAddress = async () => (await lanNetworks())[0]?.address || '';
|
|
22
22
|
export { lanNetworks };
|
|
23
|
-
export function migrateState(stateRoot) {
|
|
24
|
-
fs.mkdirSync(stateRoot, { recursive: true });
|
|
25
|
-
for (const name of ['bridge-config.json', 'bridge-session.json']) {
|
|
26
|
-
const target = path.join(stateRoot, name);
|
|
27
|
-
const old = path.join(bridgeRoot, '../.codex/bridge', name);
|
|
28
|
-
if (!fs.existsSync(target) && fs.existsSync(old)) fs.copyFileSync(old, target, fs.constants.COPYFILE_EXCL);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
23
|
export async function bridgeEnvironment() {
|
|
32
24
|
const environment = { ...process.env };
|
|
33
25
|
const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
|
|
34
26
|
const executable = installation?.nodeExecutable;
|
|
35
27
|
if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新安装 Code Relax。');
|
|
36
28
|
environment.CODEX_MCP_NODE_PATH = executable;
|
|
37
|
-
environment.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS = JSON.stringify(installation.legacyUploadsRoots || []);
|
|
38
29
|
delete environment.CODEX_APP_TOOLS_PIPE_PATH;
|
|
39
30
|
return { executable, environment };
|
|
40
31
|
}
|
|
@@ -47,7 +47,7 @@ const gb=value=>value==null?'—':number(Number(value)/1073741824,2)+' GB';
|
|
|
47
47
|
el('trafficUsage').textContent=trafficFresh?'已用 '+gb(cloud.trafficUsedBytes)+' / '+gb(cloud.trafficTotalBytes):'流量暂不可用';
|
|
48
48
|
el('trafficNote').textContent=trafficFresh?'更新时间 '+new Date(cloud.trafficSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'暂不可用';
|
|
49
49
|
renderTrafficTimeline(cloud,trafficFresh);
|
|
50
|
-
el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'
|
|
50
|
+
el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动移动端'))}
|
|
51
51
|
let editorItem=null,editorMode='new',writing=false;
|
|
52
52
|
function openEditor(item=null,duplicate=false){editorItem=duplicate?null:item;editorMode=duplicate?'new':item?.status==='draft'?'draft':item?'view':'new';el('editorHeading').textContent=duplicate?'复制为新公告':editorMode==='draft'?'编辑草稿':editorMode==='view'?'查看公告':'新建公告';el('title').value=item?.title||'';el('message').value=item?.message||'';el('title').disabled=editorMode==='view';el('message').disabled=editorMode==='view';el('editorActions').hidden=editorMode==='view';el('editorNote').textContent=editorMode==='view'?'已发布内容只读;可复制为新公告再修改。':'发布后会立即替换当前公告并通知在线设备。';el('editorError').hidden=true;el('announcementEditor').showModal();if(editorMode!=='view')el('title').focus()}
|
|
53
53
|
function action(label,className,handler,disabled=false){const button=node('button',className,label);button.type='button';button.disabled=disabled;button.addEventListener('click',handler);return button}
|
|
@@ -2,19 +2,20 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
5
|
-
export function relayAdminState(file, initialMaxRelaySessions) {
|
|
6
|
-
let value = { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
5
|
+
export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
|
|
6
|
+
let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
7
7
|
if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
8
8
|
|
|
9
|
-
function save(next) {
|
|
10
|
-
|
|
11
|
-
if (
|
|
9
|
+
async function save(next) {
|
|
10
|
+
const validated = validate(next);
|
|
11
|
+
if (persist) await persist(validated);
|
|
12
|
+
if (!file) { value = validated; return value; }
|
|
12
13
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
13
14
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
14
15
|
let descriptor;
|
|
15
16
|
try {
|
|
16
17
|
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
17
|
-
fs.writeFileSync(descriptor, JSON.stringify(
|
|
18
|
+
fs.writeFileSync(descriptor, JSON.stringify(validated));
|
|
18
19
|
fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
|
|
19
20
|
fs.renameSync(temporary, file);
|
|
20
21
|
if (process.platform !== 'win32') {
|
|
@@ -25,6 +26,7 @@ export function relayAdminState(file, initialMaxRelaySessions) {
|
|
|
25
26
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
26
27
|
fs.rmSync(temporary, { force: true });
|
|
27
28
|
}
|
|
29
|
+
value = validated;
|
|
28
30
|
return value;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -15,19 +15,65 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
15
15
|
serverNoContextTakeover: true, clientNoContextTakeover: true,
|
|
16
16
|
threshold: 1024, zlibDeflateOptions: { level: 3 },
|
|
17
17
|
} });
|
|
18
|
-
const locals = new Map();
|
|
19
|
-
|
|
18
|
+
const locals = new Map();
|
|
19
|
+
const fileLocals = new Map();
|
|
20
|
+
let fileRemote, fileRetryTimer, heartbeat, operation, lastClose, closing = false;
|
|
20
21
|
const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => remote.terminate(), 75000); };
|
|
21
22
|
remote.on('open', alive); remote.on('ping', alive); remote.on('message', alive);
|
|
22
23
|
function dropLocal(session) {
|
|
23
24
|
const current = locals.get(session);
|
|
24
25
|
if (!current) return;
|
|
25
26
|
locals.delete(session); clearTimeout(current.timer); current.socket?.terminate();
|
|
26
|
-
current.probe?.then(probe => probe.close());
|
|
27
|
-
|
|
28
|
-
function dropLocals() {
|
|
29
|
-
for (const session of [...locals.keys()]) dropLocal(session);
|
|
27
|
+
current.probe?.then(probe => probe.close());
|
|
28
|
+
current.diagnosticProbe?.then(probe => probe.close());
|
|
30
29
|
}
|
|
30
|
+
function dropLocals() {
|
|
31
|
+
for (const session of [...locals.keys()]) dropLocal(session);
|
|
32
|
+
for (const socket of fileLocals.values()) socket.terminate();
|
|
33
|
+
fileLocals.clear(); clearTimeout(fileRetryTimer); fileRemote?.terminate(); fileRemote = undefined;
|
|
34
|
+
}
|
|
35
|
+
function startFileReceiver() {
|
|
36
|
+
if (!identity || closing || remote.readyState !== WebSocket.OPEN) return;
|
|
37
|
+
const receiver = fileRemote = new WebSocket(relayUrl, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
38
|
+
receiver.on('open', () => send(receiver, { type: 'file-host', deviceId: identity.id, deviceSecret: identity.secret }));
|
|
39
|
+
receiver.on('error', error => events.emit('diagnostic', `File receiver: ${error.message}`));
|
|
40
|
+
receiver.on('close', () => {
|
|
41
|
+
for (const socket of fileLocals.values()) socket.terminate();
|
|
42
|
+
fileLocals.clear();
|
|
43
|
+
if (fileRemote === receiver) {
|
|
44
|
+
fileRemote = undefined;
|
|
45
|
+
if (!closing && remote.readyState === WebSocket.OPEN) fileRetryTimer = setTimeout(startFileReceiver, 3000);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
receiver.on('message', (bytes, binary) => {
|
|
49
|
+
try {
|
|
50
|
+
const message = decode(bytes, binary);
|
|
51
|
+
if (message.type === 'file-host-ready') return;
|
|
52
|
+
if (message.type === 'file-open') {
|
|
53
|
+
const connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken };
|
|
54
|
+
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
55
|
+
loopbackUrl(url.href, '/api/file-transfer');
|
|
56
|
+
const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
57
|
+
fileLocals.set(message.session, socket);
|
|
58
|
+
socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
|
|
59
|
+
socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
|
|
60
|
+
socket.on('close', () => {
|
|
61
|
+
if (fileLocals.get(message.session) !== socket) return;
|
|
62
|
+
fileLocals.delete(message.session);
|
|
63
|
+
send(receiver, { type: 'file-close', session: message.session });
|
|
64
|
+
});
|
|
65
|
+
socket.on('message', (data, isBinary) => {
|
|
66
|
+
if (isBinary || fileLocals.get(message.session) !== socket) return socket.terminate();
|
|
67
|
+
send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
|
|
68
|
+
});
|
|
69
|
+
} else if (message.type === 'file-frame') {
|
|
70
|
+
const socket = fileLocals.get(message.session);
|
|
71
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(message.payload);
|
|
72
|
+
} else if (message.type === 'file-close') fileLocals.get(message.session)?.terminate();
|
|
73
|
+
else throw new Error('Invalid file receiver frame');
|
|
74
|
+
} catch (error) { events.emit('diagnostic', error.message); receiver.close(1008, 'Invalid file frame'); }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
31
77
|
const ready = new Promise((resolve, reject) => {
|
|
32
78
|
let registered = false;
|
|
33
79
|
const timer = setTimeout(() => { reject(new Error('Relay registration timeout')); remote.terminate(); }, 5000);
|
|
@@ -44,7 +90,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
44
90
|
remote.on('message', (bytes, binary) => {
|
|
45
91
|
try {
|
|
46
92
|
const message = decode(bytes, binary);
|
|
47
|
-
if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); resolve(message); return; }
|
|
93
|
+
if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); startFileReceiver(); resolve(message); return; }
|
|
48
94
|
if (!registered) throw new Error('Relay not registered');
|
|
49
95
|
if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
|
|
50
96
|
if (operation && message.commandId === operation.id && message.type === operation.responseType) {
|
|
@@ -102,10 +148,36 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
102
148
|
}
|
|
103
149
|
} catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
|
|
104
150
|
});
|
|
105
|
-
} else if (message.type === 'p2p-probe' && locals.has(message.session)) {
|
|
106
|
-
const current = locals.get(message.session);
|
|
107
|
-
if (!current.ready || !current.p2pProbe || !validProbeSignal(message)) throw new Error('Invalid probe signal');
|
|
108
|
-
if (message.
|
|
151
|
+
} else if (message.type === 'p2p-probe' && locals.has(message.session)) {
|
|
152
|
+
const current = locals.get(message.session);
|
|
153
|
+
if (!current.ready || !current.p2pProbe || !validProbeSignal(message)) throw new Error('Invalid probe signal');
|
|
154
|
+
if (message.purpose === 'diagnostic') {
|
|
155
|
+
if (message.kind === 'start' && current.diagnosticProbeId !== message.probeId) {
|
|
156
|
+
void current.diagnosticProbe?.then(probe => probe.close());
|
|
157
|
+
current.diagnosticProbeId = message.probeId;
|
|
158
|
+
const probeId = message.probeId;
|
|
159
|
+
current.diagnosticProbe = createP2pProbe({ iceServers,
|
|
160
|
+
signal: signal => {
|
|
161
|
+
if (locals.get(message.session) === current && current.diagnosticProbeId === probeId) {
|
|
162
|
+
send(remote, { type: 'p2p-probe', purpose: 'diagnostic', session: message.session,
|
|
163
|
+
version: 1, probeId, ...signal });
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
|
|
167
|
+
}).catch(() => {
|
|
168
|
+
if (locals.get(message.session) === current && current.diagnosticProbeId === probeId) {
|
|
169
|
+
send(remote, { type: 'p2p-probe', purpose: 'diagnostic', session: message.session,
|
|
170
|
+
version: 1, probeId, kind: 'close' });
|
|
171
|
+
}
|
|
172
|
+
return { receive() {}, close() {} };
|
|
173
|
+
});
|
|
174
|
+
} else if (current.diagnosticProbe && current.diagnosticProbeId === message.probeId) {
|
|
175
|
+
void current.diagnosticProbe.then(probe => probe.receive(message));
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (message.purpose) throw new Error('Invalid probe purpose');
|
|
180
|
+
if (message.kind === 'start' && current.probeId !== message.probeId) {
|
|
109
181
|
void current.probe?.then(probe => probe.close());
|
|
110
182
|
current.probeId = message.probeId;
|
|
111
183
|
current.routeSignals = Promise.resolve();
|
|
@@ -157,6 +229,6 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
157
229
|
updateLanCandidates(candidates) {
|
|
158
230
|
if (!send(remote, { type: 'lan-candidates', candidates })) throw new Error('中继未连接');
|
|
159
231
|
},
|
|
160
|
-
close() { clearTimeout(heartbeat); dropLocals(); remote.terminate(); }
|
|
232
|
+
close() { closing = true; clearTimeout(heartbeat); dropLocals(); remote.terminate(); }
|
|
161
233
|
};
|
|
162
234
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createHash, createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { tencentAccountProvider } from '../../relay-admin/tencent-account-provider.mjs';
|
|
4
|
+
|
|
5
|
+
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
6
|
+
const isoTime = value => value.replace(' ', 'T') + 'Z';
|
|
7
|
+
const error = (status, message) => Object.assign(new Error(message), { status });
|
|
8
|
+
const tokenHash = token => createHash('sha256').update(token).digest('hex');
|
|
9
|
+
const normalizeEmail = value => {
|
|
10
|
+
if (typeof value !== 'string') throw error(400, 'Invalid email address');
|
|
11
|
+
const email = value.trim().toLowerCase();
|
|
12
|
+
if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(email)) {
|
|
13
|
+
throw error(400, 'Invalid email address');
|
|
14
|
+
}
|
|
15
|
+
return email;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function openEmailAccounts(pool, configFile, providerOverride) {
|
|
19
|
+
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
20
|
+
if (![config.secretId, config.secretKey, config.captchaAppSecretKey, config.fromEmailAddress, config.sesRegion]
|
|
21
|
+
.every(value => typeof value === 'string' && value.length > 0) ||
|
|
22
|
+
!Number.isSafeInteger(config.captchaAppId) || config.captchaAppId < 1 ||
|
|
23
|
+
!Number.isSafeInteger(config.emailTemplateId) || config.emailTemplateId < 1 ||
|
|
24
|
+
typeof config.codePepper !== 'string' || config.codePepper.length < 32) {
|
|
25
|
+
throw new Error('Incomplete relay account configuration');
|
|
26
|
+
}
|
|
27
|
+
const provider = providerOverride || tencentAccountProvider(config);
|
|
28
|
+
const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
|
|
29
|
+
const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
|
|
30
|
+
|
|
31
|
+
async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
|
|
32
|
+
const email = normalizeEmail(rawEmail);
|
|
33
|
+
if (typeof ticket !== 'string' || !ticket || ticket.length > 2048 ||
|
|
34
|
+
typeof randstr !== 'string' || !randstr || randstr.length > 128) throw error(400, 'Invalid human verification');
|
|
35
|
+
if (!ip || ip.length > 64) throw error(400, 'Invalid client address');
|
|
36
|
+
// A cheap pre-check precedes the billable human verification.
|
|
37
|
+
const [recent] = await pool.execute('SELECT sent_at FROM email_login_challenges WHERE email = ? AND sent_at > UTC_TIMESTAMP(3) - INTERVAL 60 SECOND', [email]);
|
|
38
|
+
if (recent.length) throw error(429, 'Please wait before requesting another code');
|
|
39
|
+
const [ipLimit] = await pool.execute(`SELECT request_count FROM email_send_limits
|
|
40
|
+
WHERE scope_hash=? AND window_start>=UTC_TIMESTAMP() - INTERVAL 1 HOUR ORDER BY window_start DESC LIMIT 1`, [scopeHash(`ip:${ip}`)]);
|
|
41
|
+
if (ipLimit[0]?.request_count >= 20) throw error(429, 'Too many email requests');
|
|
42
|
+
if (!await provider.verifyHuman(ticket, randstr, ip)) throw error(403, 'Human verification failed');
|
|
43
|
+
const code = String(randomInt(0, 1000000)).padStart(6, '0');
|
|
44
|
+
const hash = codeHash(email, code);
|
|
45
|
+
const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
|
|
46
|
+
const db = await pool.getConnection();
|
|
47
|
+
try {
|
|
48
|
+
await db.beginTransaction();
|
|
49
|
+
for (const [scope, limit] of [[`email:${email}`, 5], [`ip:${ip}`, 20]]) {
|
|
50
|
+
const key = scopeHash(scope);
|
|
51
|
+
await db.execute('INSERT INTO email_send_limits (scope_hash,window_start,request_count) VALUES (?,?,1) ON DUPLICATE KEY UPDATE request_count=request_count+1', [key, windowStart]);
|
|
52
|
+
const [rows] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
|
|
53
|
+
if (rows[0].request_count > limit) throw error(429, 'Too many email requests');
|
|
54
|
+
}
|
|
55
|
+
const [existing] = await db.execute('SELECT sent_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
|
|
56
|
+
if (existing.length && Date.now() - new Date(`${existing[0].sent_at.replace(' ', 'T')}Z`).getTime() < 60000) {
|
|
57
|
+
throw error(429, 'Please wait before requesting another code');
|
|
58
|
+
}
|
|
59
|
+
await db.execute(`INSERT INTO email_login_challenges (email,code_hash,expires_at,attempts,sent_at)
|
|
60
|
+
VALUES (?, ?, UTC_TIMESTAMP(3) + INTERVAL 10 MINUTE, 0, UTC_TIMESTAMP(3))
|
|
61
|
+
ON DUPLICATE KEY UPDATE code_hash=VALUES(code_hash),expires_at=VALUES(expires_at),attempts=0,sent_at=VALUES(sent_at)`, [email, hash]);
|
|
62
|
+
await db.commit();
|
|
63
|
+
} catch (failure) { await db.rollback(); throw failure; }
|
|
64
|
+
finally { db.release(); }
|
|
65
|
+
try { await provider.sendLoginCode(email, code); }
|
|
66
|
+
catch (failure) {
|
|
67
|
+
await pool.execute('DELETE FROM email_login_challenges WHERE email=? AND code_hash=?', [email, hash]);
|
|
68
|
+
throw error(503, 'Email delivery unavailable');
|
|
69
|
+
}
|
|
70
|
+
return { sent: true, expiresInSeconds: 600 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function verifyCode({ email: rawEmail, code }) {
|
|
74
|
+
const email = normalizeEmail(rawEmail);
|
|
75
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) throw error(400, 'Invalid code');
|
|
76
|
+
const db = await pool.getConnection();
|
|
77
|
+
try {
|
|
78
|
+
await db.beginTransaction();
|
|
79
|
+
const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
|
|
80
|
+
const challenge = challenges[0];
|
|
81
|
+
if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
|
|
82
|
+
const supplied = Buffer.from(codeHash(email, code), 'hex');
|
|
83
|
+
if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
|
|
84
|
+
if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
85
|
+
else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
|
|
86
|
+
await db.commit();
|
|
87
|
+
throw error(401, 'Code expired or incorrect');
|
|
88
|
+
}
|
|
89
|
+
await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
|
|
90
|
+
const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
|
|
91
|
+
JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
|
|
92
|
+
let userId = identities[0]?.id;
|
|
93
|
+
if (identities[0]?.status === 'disabled') throw error(403, 'Account unavailable');
|
|
94
|
+
if (!userId) {
|
|
95
|
+
userId = randomUUID();
|
|
96
|
+
await db.execute('INSERT INTO users (id,status,created_at,updated_at) VALUES (?,\'active\',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))', [userId]);
|
|
97
|
+
await db.execute(`INSERT INTO user_login_identities (user_id,kind,normalized_value,verified_at,created_at)
|
|
98
|
+
VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
|
|
99
|
+
}
|
|
100
|
+
const token = randomBytes(32).toString('base64url');
|
|
101
|
+
await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
|
|
102
|
+
VALUES (?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + INTERVAL 30 DAY)`, [tokenHash(token), userId]);
|
|
103
|
+
await db.commit();
|
|
104
|
+
return { token, user: { id: userId, email }, expiresInSeconds: 30 * 86400 };
|
|
105
|
+
} catch (failure) { await db.rollback(); throw failure; }
|
|
106
|
+
finally { db.release(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function current(token) {
|
|
110
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw error(401, 'Unauthorized');
|
|
111
|
+
const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,s.expires_at FROM user_sessions s
|
|
112
|
+
JOIN users u ON u.id=s.user_id JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
113
|
+
WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3) AND u.status='active'`, [tokenHash(token)]);
|
|
114
|
+
if (!rows.length) throw error(401, 'Unauthorized');
|
|
115
|
+
return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function logout(token) {
|
|
119
|
+
await current(token);
|
|
120
|
+
await pool.execute('UPDATE user_sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE token_hash=? AND revoked_at IS NULL', [tokenHash(token)]);
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, logout };
|
|
125
|
+
}
|