neoctl-web 0.1.13 → 0.1.15
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 +34 -216
- package/chunk-uploads.mjs +125 -0
- package/core-runtime.mjs +1 -0
- package/dist/assets/App-DkTv-X6M.js +105 -0
- package/dist/assets/App-DmtsTXw2.css +1 -0
- package/dist/assets/index-BwXWTT2b.js +18 -0
- package/dist/assets/index-Dgfhk2Ym.css +1 -0
- package/dist/index.html +2 -2
- package/execution-backend.mjs +74 -0
- package/isolation-auth.mjs +225 -0
- package/isolation.example.json +8 -0
- package/isolation.mjs +385 -0
- package/package.json +20 -12
- package/plugins/downloads/README.md +40 -0
- package/plugins/downloads/downloads.mjs +81 -120
- package/plugins/downloads/index.mjs +10 -8
- package/plugins/downloads/neo-plugin.json +2 -2
- package/plugins/video-share/README.md +55 -0
- package/plugins/video-share/http.mjs +86 -0
- package/plugins/video-share/index.mjs +65 -0
- package/plugins/video-share/neo-plugin.json +9 -0
- package/plugins/video-share/presentation.mjs +23 -0
- package/plugins/video-share/store.mjs +76 -0
- package/plugins/xhs-artifact/editor-page.mjs +4 -4
- package/runtime-workspaces.mjs +9 -6
- package/scripts/isolation-user.mjs +68 -0
- package/server.mjs +26 -48
- package/control-protocol.mjs +0 -44
- package/control-sync.mjs +0 -420
- package/dist/assets/index-9hY14np_.css +0 -1
- package/dist/assets/index-BnJuxSfa.js +0 -121
- package/plugins/xhs-artifact/version.test.mjs +0 -151
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { workspaceFs, openWorkspaceRead, containerMode } from '../../execution-backend.mjs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const VALID_ID = /^[a-f0-9]{48}$/;
|
|
7
|
+
export const VIDEO_TYPES = Object.freeze({
|
|
8
|
+
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime',
|
|
9
|
+
'.webm': 'video/webm', '.ogv': 'video/ogg',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Each publication has its own atomic directory: no shared mutable index or host imports.
|
|
13
|
+
export class VideoStore {
|
|
14
|
+
constructor(directory) { this.directory = path.resolve(directory); }
|
|
15
|
+
|
|
16
|
+
async publish(source) {
|
|
17
|
+
if (typeof source !== 'string' || !path.isAbsolute(source)) throw new Error('Video path must be absolute');
|
|
18
|
+
const type = VIDEO_TYPES[path.extname(source).toLowerCase()];
|
|
19
|
+
if (!type) throw new Error('Supported video extensions: .mp4, .m4v, .mov, .webm, .ogv');
|
|
20
|
+
const sourceStat = await workspaceFs.stat(source);
|
|
21
|
+
if (!sourceStat.isFile() || sourceStat.size === 0) throw new Error('Video must be a non-empty regular file');
|
|
22
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
23
|
+
const id = randomBytes(24).toString('hex');
|
|
24
|
+
const staging = path.join(this.directory, `.pending-${id}`);
|
|
25
|
+
await fs.mkdir(staging, { mode: 0o700 });
|
|
26
|
+
try {
|
|
27
|
+
// Validate only a small header, then persist the original path, never a copy.
|
|
28
|
+
const header = Buffer.alloc(64);
|
|
29
|
+
if (containerMode) {
|
|
30
|
+
const handle = await openWorkspaceRead(source);
|
|
31
|
+
try { let offset = 0; for await (const chunk of handle.createReadStream({ start: 0, end: 63 })) { chunk.copy(header, offset); offset += chunk.length; } } finally { await handle.close(); }
|
|
32
|
+
} else {
|
|
33
|
+
const handle = await fs.open(source, 'r');
|
|
34
|
+
try { await handle.read(header, 0, header.length, 0); } finally { await handle.close(); }
|
|
35
|
+
}
|
|
36
|
+
const extension = path.extname(source).toLowerCase();
|
|
37
|
+
const valid = extension === '.webm' ? header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
|
|
38
|
+
: extension === '.ogv' ? header.toString('ascii', 0, 4) === 'OggS'
|
|
39
|
+
: ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(header.toString('ascii', 4, 8));
|
|
40
|
+
if (!valid) throw new Error('File does not have a recognized video container signature');
|
|
41
|
+
const entry = { version: 2, id, absolutePath: path.resolve(source), filename: path.basename(source), contentType: type, sizeBytes: sourceStat.size, createdAt: new Date().toISOString() };
|
|
42
|
+
await fs.writeFile(path.join(staging, 'entry.json'), JSON.stringify(entry), { mode: 0o600 });
|
|
43
|
+
await fs.rename(staging, path.join(this.directory, id));
|
|
44
|
+
return entry;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async get(id) {
|
|
52
|
+
if (!VALID_ID.test(id)) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const entry = JSON.parse(await fs.readFile(path.join(this.directory, id, 'entry.json'), 'utf8'));
|
|
55
|
+
// Old v1 snapshots have no source path; do not silently serve a copy.
|
|
56
|
+
if (entry.version === 1) return undefined;
|
|
57
|
+
if (entry.version !== 2 || typeof entry.absolutePath !== 'string' || !path.isAbsolute(entry.absolutePath)
|
|
58
|
+
|| entry.id !== id || typeof entry.filename !== 'string'
|
|
59
|
+
|| !Object.values(VIDEO_TYPES).includes(entry.contentType)) throw new Error('Invalid video metadata');
|
|
60
|
+
return { ...entry, mediaPath: entry.absolutePath };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error.code === 'ENOENT') return undefined;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async revoke(id) {
|
|
68
|
+
if (!VALID_ID.test(id)) throw new Error('Invalid video id');
|
|
69
|
+
const tombstone = path.join(this.directory, `.revoked-${id}-${randomBytes(6).toString('hex')}`);
|
|
70
|
+
try { await fs.rename(path.join(this.directory, id), tombstone); }
|
|
71
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
72
|
+
// Renaming removes the public mapping first; never reuse a revoked token.
|
|
73
|
+
await fs.rm(tombstone, { recursive: true, force: true });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -55,16 +55,16 @@ export function renderXhsEditorPage(artifact, apiUrl) {
|
|
|
55
55
|
</section>
|
|
56
56
|
</main>
|
|
57
57
|
<script>
|
|
58
|
-
const initial=${serialize(artifact)};const
|
|
58
|
+
const initial=${serialize(artifact)};const appBase=${serialize((process.env.NEO_WEB_BASE_PATH || '').replace(/\/$/, ''))};const appUrl=value=>typeof value==='string'&&value.startsWith('/api/')?appBase+value:value;const apiUrl=appUrl(${serialize(apiUrl)});let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
|
|
59
59
|
const params=new URLSearchParams(location.search);const requestedTheme=params.get('theme');document.documentElement.dataset.theme=requestedTheme==='dark'?'dark':requestedTheme==='light'?'light':matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
|
60
60
|
function clone(v){return JSON.parse(JSON.stringify(v))}function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function tags(v){return [...new Set(String(v||'').split(/[\\s,,、]+/).filter(Boolean).map(x=>x.startsWith('#')?x:'#'+x))]}
|
|
61
61
|
function field(id,key){$(id).value=key==='hashtags'?(draft[key]||[]).join(' '):draft[key]||'';$(id).addEventListener('input',e=>{draft[key]=key==='hashtags'?tags(e.target.value):e.target.value;renderPreview();scheduleSave()})}
|
|
62
62
|
field('title','title');field('body','body');field('interactionInput','interaction');field('hashtags','hashtags');field('review','review');
|
|
63
|
-
function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(image.url)+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
|
|
64
|
-
function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(im.url)+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(im.url)+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
|
|
63
|
+
function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(appUrl(image.url))+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
|
|
64
|
+
function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(appUrl(im.url))+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(appUrl(im.url))+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
|
|
65
65
|
$('images').addEventListener('input',e=>{const row=e.target.closest('[data-index]');if(!row||!e.target.dataset.key)return;draft.images[+row.dataset.index][e.target.dataset.key]=e.target.value;renderPreview();scheduleSave()});$('images').addEventListener('click',e=>{const pick=e.target.closest('[data-pick]');if(pick){active=+pick.dataset.pick;renderPreview()}const remove=e.target.closest('[data-remove]');if(remove){draft.images.splice(+remove.dataset.remove,1);renderImages();renderPreview();scheduleSave()}const upload=e.target.closest('[data-upload]');if(upload){$('files').dataset.target=upload.dataset.upload;$('files').click()}});
|
|
66
66
|
$('prev').onclick=()=>{active=(active-1+(draft.images||[]).length)%(draft.images||[]).length;renderPreview()};$('next').onclick=()=>{active=(active+1)%(draft.images||[]).length;renderPreview()};let wheelLock=0;$('mediaStage').addEventListener('wheel',e=>{const images=draft.images||[];if(images.length<2)return;e.preventDefault();const now=Date.now();if(now<wheelLock)return;wheelLock=now+320;const delta=Math.abs(e.deltaY)>=Math.abs(e.deltaX)?e.deltaY:e.deltaX;active=(active+(delta>=0?1:-1)+images.length)%images.length;renderPreview()},{passive:false});$('add').onclick=()=>{(draft.images||(draft.images=[])).push({url:'',caption:'配图 '+((draft.images?.length||0)+1),overlay:'',note:''});renderImages();scheduleSave()};$('upload').onclick=()=>{$('files').dataset.target='';$('files').click()};
|
|
67
|
-
$('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch('/api/uploads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
|
|
67
|
+
$('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch(appUrl('/api/uploads')+new URL(apiUrl,location.href).search,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
|
|
68
68
|
function setMode(mode){localStorage.setItem('neoctl.plugin.xhs.mode.v2',mode);document.querySelectorAll('[data-mode]').forEach(b=>b.classList.toggle('active',b.dataset.mode===mode));$('grid').className='grid '+mode;$('preview').classList.toggle('hidden',mode==='edit');$('form').classList.toggle('hidden',mode==='preview');resize()}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
|
|
69
69
|
$('fullscreen').onclick=async()=>{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()};document.addEventListener('fullscreenchange',()=>{$('fullscreen').textContent=document.fullscreenElement?'退出全屏':'全屏'});
|
|
70
70
|
function payload(){return{title:draft.title||'',body:draft.body||'',interaction:draft.interaction||'',hashtags:draft.hashtags||[],images:draft.images||[],review:draft.review||''}}
|
package/runtime-workspaces.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { mkdir
|
|
2
|
+
import { mkdir as localMkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { workspaceFs, workspaceHome, containerMode } from './execution-backend.mjs';
|
|
4
|
+
const { mkdir, readdir, rmdir, stat } = workspaceFs;
|
|
3
5
|
import os from 'node:os';
|
|
4
6
|
import { QueryEngine, WebRepl } from './core-runtime.mjs';
|
|
5
7
|
|
|
@@ -370,7 +372,7 @@ export class SessionWorkspaceRegistry {
|
|
|
370
372
|
: options.cwdNoticePending === true,
|
|
371
373
|
};
|
|
372
374
|
this.writeQueue = this.writeQueue.then(async () => {
|
|
373
|
-
await
|
|
375
|
+
await localMkdir(path.dirname(this.file), { recursive: true });
|
|
374
376
|
await writeFile(this.file, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
|
|
375
377
|
});
|
|
376
378
|
await this.writeQueue;
|
|
@@ -406,7 +408,7 @@ export async function browseWorkspace(value, currentCwd) {
|
|
|
406
408
|
requested,
|
|
407
409
|
fallback: path.resolve(current) !== path.resolve(requested),
|
|
408
410
|
parent: current === path.parse(current).root ? undefined : path.dirname(current),
|
|
409
|
-
home: os.homedir(),
|
|
411
|
+
home: (workspaceHome() || os.homedir()),
|
|
410
412
|
locations,
|
|
411
413
|
entries: entries
|
|
412
414
|
.filter((entry) => entry.isDirectory())
|
|
@@ -437,8 +439,9 @@ let workspaceLocationsCache;
|
|
|
437
439
|
let workspaceLocationsCachedAt = 0;
|
|
438
440
|
|
|
439
441
|
export async function discoverWorkspaceLocations() {
|
|
442
|
+
if (containerMode) return [{ id: "workspace", label: "workspace", path: "/workspace", kind: "favorite" }, { id: "root", label: "/", path: "/", kind: "root" }, { id: "home", label: "root", path: "/root", kind: "home" }];
|
|
440
443
|
if (workspaceLocationsCache && Date.now() - workspaceLocationsCachedAt < 5000) return workspaceLocationsCache;
|
|
441
|
-
const home = os.homedir();
|
|
444
|
+
const home = (workspaceHome() || os.homedir());
|
|
442
445
|
const candidates = [
|
|
443
446
|
{ id: 'home', label: '主目录', path: home, kind: 'home' },
|
|
444
447
|
...[
|
|
@@ -486,8 +489,8 @@ export async function discoverWorkspaceLocations() {
|
|
|
486
489
|
export function resolveWorkspaceInput(value, currentCwd) {
|
|
487
490
|
let input = String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
488
491
|
if (!input) return path.resolve(currentCwd || process.cwd());
|
|
489
|
-
if (input === '~') input = os.homedir();
|
|
490
|
-
else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join(os.homedir(), input.slice(2));
|
|
492
|
+
if (input === '~') input = (workspaceHome() || os.homedir());
|
|
493
|
+
else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join((workspaceHome() || os.homedir()), input.slice(2));
|
|
491
494
|
input = input.replace(/[\\/]+/g, path.sep);
|
|
492
495
|
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(input)) input += path.sep;
|
|
493
496
|
return path.resolve(currentCwd || process.cwd(), input);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hashPassword, usernameKey, validUsername } from '../isolation-auth.mjs';
|
|
4
|
+
|
|
5
|
+
const [filename, username, role] = process.argv.slice(2);
|
|
6
|
+
if (!filename || !validUsername(username) || (role !== undefined && !['user', 'admin'].includes(role))) {
|
|
7
|
+
console.error('用法: node scripts/isolation-user.mjs <配置文件> <用户名> [user|admin]');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readPassword() {
|
|
12
|
+
if (!process.stdin.isTTY) {
|
|
13
|
+
let text = '';
|
|
14
|
+
for await (const chunk of process.stdin) { text += chunk; if (text.length > 4096) throw new Error('输入过长'); }
|
|
15
|
+
return text.replace(/\r?\n$/, '');
|
|
16
|
+
}
|
|
17
|
+
process.stderr.write('密码: ');
|
|
18
|
+
process.stdin.setRawMode(true);
|
|
19
|
+
process.stdin.resume();
|
|
20
|
+
process.stdin.setEncoding('utf8');
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let value = '';
|
|
23
|
+
const done = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); };
|
|
24
|
+
function onData(chunk) {
|
|
25
|
+
for (const c of chunk) {
|
|
26
|
+
if (c === '\u0003') { done(); reject(new Error('已取消')); return; }
|
|
27
|
+
if (c === '\r' || c === '\n') { done(); resolve(value); return; }
|
|
28
|
+
if (c === '\u007f' || c === '\b') value = value.slice(0, -1);
|
|
29
|
+
else if (value.length < 1024) value += c;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
process.stdin.on('data', onData);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const file = path.resolve(filename);
|
|
37
|
+
let config;
|
|
38
|
+
try { config = JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'ENOENT') throw error;
|
|
41
|
+
config = { enabled: false, secureCookie: false, cookiePath: '/', sessionHours: 12, retiredUsernames: [], users: [] };
|
|
42
|
+
}
|
|
43
|
+
config.users ||= [];
|
|
44
|
+
config.retiredUsernames ||= [];
|
|
45
|
+
const key = usernameKey(username);
|
|
46
|
+
if (config.users.some(user => user.id !== undefined && user.id !== user.username)) throw new Error('旧用户 ID 与用户名不同,请先迁移');
|
|
47
|
+
if (config.retiredUserIds?.length) throw new Error('请先将 retiredUserIds 迁移为 retiredUsernames');
|
|
48
|
+
if (config.retiredUsernames.some(value => usernameKey(value) === key)) throw new Error('已删除的用户名不可复用');
|
|
49
|
+
const existing = config.users.find(user => usernameKey(user.username) === key);
|
|
50
|
+
const nextRole = role || existing?.role || 'user';
|
|
51
|
+
const next = { username, role: nextRole };
|
|
52
|
+
if (nextRole === 'admin') next.passwordHash = await hashPassword(await readPassword());
|
|
53
|
+
else if (existing?.passwordHash) next.passwordHash = existing.passwordHash;
|
|
54
|
+
const users = config.users.map(user => {
|
|
55
|
+
const { id: _oldId, ...account } = user;
|
|
56
|
+
return account;
|
|
57
|
+
});
|
|
58
|
+
const index = users.findIndex(user => usernameKey(user.username) === key);
|
|
59
|
+
if (index >= 0) users[index] = next; else users.push(next);
|
|
60
|
+
const { retiredUserIds: _oldRetiredUserIds, ...stored } = config;
|
|
61
|
+
stored.users = users;
|
|
62
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
63
|
+
const pending = file + `.tmp-${process.pid}`;
|
|
64
|
+
try {
|
|
65
|
+
await fs.writeFile(pending, JSON.stringify(stored, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
66
|
+
await fs.rename(pending, file);
|
|
67
|
+
} finally { await fs.rm(pending, { force: true }); }
|
|
68
|
+
console.log(`用户已保存: ${username}。修改 enabled 后重启 Web 生效。`);
|
package/server.mjs
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import { createChunkUploadHandler } from './chunk-uploads.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import fsp from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
let desktopControlConfig;
|
|
9
|
-
{
|
|
10
|
-
const raw = process.env.NEO_DESKTOP_CONTROL_CONFIG;
|
|
11
|
-
delete process.env.NEO_DESKTOP_CONTROL_CONFIG;
|
|
12
|
-
try { desktopControlConfig = raw ? JSON.parse(raw) : undefined; } catch {}
|
|
13
|
-
}
|
|
14
|
-
|
|
7
|
+
const { containerMode, workspaceFs, deliverUpload, verifyExecutionBackend } = await import('./execution-backend.mjs');
|
|
8
|
+
await verifyExecutionBackend();
|
|
15
9
|
const { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } = await import('./core-runtime.mjs');
|
|
16
10
|
const { createWebPluginHost } = await import('./plugins.mjs');
|
|
17
11
|
const { createWebPluginSettings } = await import('./plugin-settings.mjs');
|
|
@@ -21,12 +15,6 @@ const { installRuntimeRouterIdleCleanup } = await import('./runtime-router-clean
|
|
|
21
15
|
const { createCpaQuotaMonitor } = await import('./cpa-quota.mjs');
|
|
22
16
|
const { createMemoryMonitor } = await import('./memory-monitor.mjs');
|
|
23
17
|
const { resolveWebStorage } = await import('./platform-paths.mjs');
|
|
24
|
-
const { createControlSync, createLoginApplier, validateControlConfig } = await import('./control-sync.mjs');
|
|
25
|
-
|
|
26
|
-
const controlConfig = validateControlConfig(desktopControlConfig);
|
|
27
|
-
desktopControlConfig = undefined;
|
|
28
|
-
const controlEnabled = Boolean(controlConfig);
|
|
29
|
-
|
|
30
18
|
installRuntimeRouterIdleCleanup();
|
|
31
19
|
console.log(`neo core: ${coreRuntimeInfo.source} ${coreRuntimeInfo.version} (${coreRuntimeInfo.location})`);
|
|
32
20
|
process.env.NEO_CORE_VERSION = coreRuntimeInfo.version;
|
|
@@ -37,7 +25,9 @@ const root = path.resolve(process.env.DIST_DIR || path.join(__dirname, 'dist'));
|
|
|
37
25
|
const host = process.env.APP_HOST || '0.0.0.0';
|
|
38
26
|
const port = Number(process.env.APP_PORT || process.env.PORT || 5173);
|
|
39
27
|
const runtimeTarget = new URL(process.env.NEO_RUNTIME_TARGET || 'http://127.0.0.1:3101');
|
|
40
|
-
const
|
|
28
|
+
const storage = resolveWebStorage();
|
|
29
|
+
const dataRoot = storage.dataRoot;
|
|
30
|
+
const workspaceRoot = containerMode ? '/workspace' : storage.workspaceRoot;
|
|
41
31
|
const promptLibraryFile = path.resolve(process.env.NEO_PROMPT_LIBRARY_FILE || path.join(dataRoot, 'prompt-library.json'));
|
|
42
32
|
const uploadsDir = path.resolve(process.env.NEO_UPLOADS_DIR || path.join(dataRoot, 'uploads'));
|
|
43
33
|
const pluginDir = path.resolve(process.env.NEO_WEB_PLUGIN_DIR || path.join(__dirname, 'plugins'));
|
|
@@ -46,7 +36,7 @@ const cpaConfigFile = path.resolve(process.env.NEO_CPA_CONFIG_FILE || path.join(
|
|
|
46
36
|
const memoryMonitorFile = path.resolve(process.env.NEO_MEMORY_MONITOR_FILE || path.join(dataRoot, 'memory-monitor.json'));
|
|
47
37
|
const pluginSettingsFile = path.resolve(process.env.NEO_WEB_PLUGIN_SETTINGS_FILE || path.join(dataRoot, 'plugins.json'));
|
|
48
38
|
const toolSettingsFile = path.resolve(process.env.NEO_WEB_TOOL_SETTINGS_FILE || path.join(dataRoot, 'tools.json'));
|
|
49
|
-
const
|
|
39
|
+
const chunkUploads = createChunkUploadHandler({ uploadsDir, baseDir: __dirname, finalize: containerMode ? (file, url) => deliverUpload(file, url, runtimeTarget) : undefined });
|
|
50
40
|
const pluginSettings = await createWebPluginSettings(pluginSettingsFile);
|
|
51
41
|
const toolSettings = await createWebToolSettings(toolSettingsFile);
|
|
52
42
|
const pluginEnv = process.env.NEO_WEB_PLUGINS;
|
|
@@ -67,17 +57,6 @@ const memoryMonitor = createMemoryMonitor({
|
|
|
67
57
|
maxPersistedSamples: process.env.NEO_MEMORY_MAX_PERSISTED_SAMPLES,
|
|
68
58
|
maxPersistedBytes: process.env.NEO_MEMORY_MAX_PERSISTED_BYTES,
|
|
69
59
|
});
|
|
70
|
-
// Weak references do not defeat the existing router's idle-session cleanup.
|
|
71
|
-
const controlRepls = new Set();
|
|
72
|
-
function activeControlRepls() {
|
|
73
|
-
const active = [];
|
|
74
|
-
for (const reference of controlRepls) {
|
|
75
|
-
const repl = reference.deref();
|
|
76
|
-
if (repl) active.push(repl);
|
|
77
|
-
else controlRepls.delete(reference);
|
|
78
|
-
}
|
|
79
|
-
return active;
|
|
80
|
-
}
|
|
81
60
|
const workspaceRuntime = createWorkspaceRuntimeManager({
|
|
82
61
|
projectRoot: process.cwd(),
|
|
83
62
|
workspaceRoot,
|
|
@@ -94,6 +73,9 @@ const workspaceRuntime = createWorkspaceRuntimeManager({
|
|
|
94
73
|
}),
|
|
95
74
|
});
|
|
96
75
|
|
|
76
|
+
const { createIsolationMode } = await import('./isolation.mjs');
|
|
77
|
+
const isolation = await createIsolationMode({ dataRoot, workspaceRoot, pluginDir, pluginSettings, toolSettings, cpaQuotaMonitor, memoryState: () => memoryMonitor.getPublicState() });
|
|
78
|
+
|
|
97
79
|
const DEFAULT_APP_PROMPT_LIBRARY = [];
|
|
98
80
|
|
|
99
81
|
const mime = {
|
|
@@ -118,9 +100,10 @@ const server = http.createServer((req, res) => {
|
|
|
118
100
|
void routeRequest(req, res);
|
|
119
101
|
});
|
|
120
102
|
|
|
103
|
+
server.once('close', () => isolation.close());
|
|
121
104
|
server.keepAliveTimeout = 70_000;
|
|
122
105
|
server.headersTimeout = 75_000;
|
|
123
|
-
if (embedRuntime) await startEmbeddedRuntime();
|
|
106
|
+
if (embedRuntime && !isolation.enabled) await startEmbeddedRuntime();
|
|
124
107
|
await cpaQuotaMonitor.start();
|
|
125
108
|
await memoryMonitor.start();
|
|
126
109
|
await new Promise((resolve, reject) => {
|
|
@@ -132,32 +115,19 @@ await new Promise((resolve, reject) => {
|
|
|
132
115
|
});
|
|
133
116
|
});
|
|
134
117
|
|
|
135
|
-
// Optional Desktop-only background work starts after HTTP listen and never delays UI.
|
|
136
|
-
if (controlEnabled) {
|
|
137
|
-
const controlSync = createControlSync({
|
|
138
|
-
config: controlConfig,
|
|
139
|
-
dataDir: dataRoot,
|
|
140
|
-
applyProfile: embedRuntime ? createLoginApplier({ runtimeUrl: runtimeTarget, getActiveRepls: activeControlRepls }) : undefined,
|
|
141
|
-
}).start();
|
|
142
|
-
server.once('close', () => { void controlSync.stop(); });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
118
|
async function startEmbeddedRuntime() {
|
|
146
119
|
const runtimeHost = runtimeTarget.hostname || '127.0.0.1';
|
|
147
120
|
const runtimePort = runtimeTarget.port || '3101';
|
|
148
121
|
await runWebServer(['--host', runtimeHost, '--port', runtimePort], {
|
|
149
122
|
createRuntime: workspaceRuntime.createRuntime,
|
|
150
|
-
createRepl
|
|
151
|
-
const repl = workspaceRuntime.createRepl(runtime);
|
|
152
|
-
if (controlEnabled) controlRepls.add(new WeakRef(repl));
|
|
153
|
-
return repl;
|
|
154
|
-
},
|
|
123
|
+
createRepl: workspaceRuntime.createRepl,
|
|
155
124
|
});
|
|
156
125
|
}
|
|
157
126
|
|
|
158
127
|
async function routeRequest(req, res) {
|
|
159
128
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
160
129
|
try {
|
|
130
|
+
if (await isolation.route(req, res, url)) return;
|
|
161
131
|
if (await pluginHost.route(req, res, url, { readJsonBody, sendJson })) return;
|
|
162
132
|
if (req.method === 'GET' && url.pathname === '/api/prompt-library') {
|
|
163
133
|
return sendJson(res, { items: await readPromptLibrary() });
|
|
@@ -213,6 +183,7 @@ async function routeRequest(req, res) {
|
|
|
213
183
|
await writePromptLibrary(ordered);
|
|
214
184
|
return sendJson(res, { ok: true, items: ordered });
|
|
215
185
|
}
|
|
186
|
+
if (await chunkUploads(req, res, url)) return;
|
|
216
187
|
if (req.method === 'GET' && url.pathname.startsWith('/api/uploads/')) {
|
|
217
188
|
const storedName = decodeURIComponent(url.pathname.slice('/api/uploads/'.length));
|
|
218
189
|
return serveUploadedFile(res, storedName);
|
|
@@ -223,7 +194,7 @@ async function routeRequest(req, res) {
|
|
|
223
194
|
}
|
|
224
195
|
if (req.method === 'POST' && url.pathname === '/api/uploads') {
|
|
225
196
|
const body = await readJsonBody(req);
|
|
226
|
-
const file = await storeUploadedFile(body);
|
|
197
|
+
const file = await deliverUpload(await storeUploadedFile(body), url, runtimeTarget);
|
|
227
198
|
return sendJson(res, { ok: true, file });
|
|
228
199
|
}
|
|
229
200
|
if (shouldProxy(url.pathname)) {
|
|
@@ -385,7 +356,6 @@ async function storeUploadedFile(payload) {
|
|
|
385
356
|
if (!data) throw new Error('missing upload data');
|
|
386
357
|
const buffer = Buffer.from(data, 'base64');
|
|
387
358
|
if (!buffer.length) throw new Error('empty upload data');
|
|
388
|
-
if (buffer.length > maxUploadBytes) throw new Error(`upload too large: max ${maxUploadBytes} bytes`);
|
|
389
359
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
390
360
|
const random = Math.random().toString(36).slice(2, 8);
|
|
391
361
|
const storedName = `${stamp}-${random}-${name}`;
|
|
@@ -419,12 +389,14 @@ async function serveUploadedFile(res, storedName) {
|
|
|
419
389
|
return;
|
|
420
390
|
}
|
|
421
391
|
try {
|
|
422
|
-
const
|
|
392
|
+
const stat = await fsp.stat(filePath);
|
|
393
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
423
394
|
res.writeHead(200, {
|
|
395
|
+
'Content-Length': String(stat.size),
|
|
424
396
|
'Content-Type': mime[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
|
|
425
397
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
426
398
|
});
|
|
427
|
-
res.
|
|
399
|
+
fs.createReadStream(filePath).on('error', () => res.destroy()).pipe(res);
|
|
428
400
|
} catch {
|
|
429
401
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
430
402
|
res.end('not found');
|
|
@@ -448,6 +420,12 @@ async function serveLocalImage(res, encodedPath) {
|
|
|
448
420
|
return;
|
|
449
421
|
}
|
|
450
422
|
try {
|
|
423
|
+
if (containerMode) {
|
|
424
|
+
const body = await workspaceFs.readFile(absolutePath);
|
|
425
|
+
res.writeHead(200, { "Content-Type": contentType, "Content-Length": body.length, "Cache-Control": "no-store" });
|
|
426
|
+
res.end(body);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
451
429
|
const fileStat = await fsp.stat(absolutePath);
|
|
452
430
|
if (!fileStat.isFile()) throw new Error('not a file');
|
|
453
431
|
res.writeHead(200, {
|
package/control-protocol.mjs
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Browser + Node >=20 native Web Crypto; no Node-only imports.
|
|
2
|
-
const encoder = new TextEncoder();
|
|
3
|
-
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
4
|
-
const directions = new Set(['up', 'down']);
|
|
5
|
-
export function decodeBase64(value) {
|
|
6
|
-
if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid base64');
|
|
7
|
-
const binary = atob(value);
|
|
8
|
-
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
9
|
-
if (encodeBase64(bytes) !== value) throw new Error('Invalid base64');
|
|
10
|
-
return bytes;
|
|
11
|
-
}
|
|
12
|
-
export function encodeBase64(bytes) {
|
|
13
|
-
let binary = '';
|
|
14
|
-
for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
15
|
-
return btoa(binary);
|
|
16
|
-
}
|
|
17
|
-
function aad(deviceId, direction) {
|
|
18
|
-
if (typeof deviceId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(deviceId) || !directions.has(direction)) throw new Error('Invalid context');
|
|
19
|
-
return encoder.encode(JSON.stringify([deviceId, direction]));
|
|
20
|
-
}
|
|
21
|
-
async function importKey(keyBase64, usage) {
|
|
22
|
-
const bytes = decodeBase64(keyBase64);
|
|
23
|
-
if (bytes.byteLength !== 32) throw new Error('Key must be 32 bytes');
|
|
24
|
-
return crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, [usage]);
|
|
25
|
-
}
|
|
26
|
-
export async function seal(keyBase64, deviceId, direction, payload) {
|
|
27
|
-
const additionalData = aad(deviceId, direction);
|
|
28
|
-
const key = await importKey(keyBase64, 'encrypt');
|
|
29
|
-
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
30
|
-
const plaintext = JSON.stringify(payload);
|
|
31
|
-
if (plaintext === undefined) throw new Error('Payload must be JSON');
|
|
32
|
-
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, encoder.encode(plaintext));
|
|
33
|
-
return { v: 1, nonce: encodeBase64(nonce), ciphertext: encodeBase64(new Uint8Array(encrypted)) };
|
|
34
|
-
}
|
|
35
|
-
export async function open(keyBase64, deviceId, direction, envelope) {
|
|
36
|
-
const additionalData = aad(deviceId, direction);
|
|
37
|
-
if (!envelope || envelope.v !== 1) throw new Error('Invalid envelope');
|
|
38
|
-
const nonce = decodeBase64(envelope.nonce);
|
|
39
|
-
const ciphertext = decodeBase64(envelope.ciphertext);
|
|
40
|
-
if (nonce.length !== 12 || ciphertext.length < 16) throw new Error('Invalid envelope');
|
|
41
|
-
const key = await importKey(keyBase64, 'decrypt');
|
|
42
|
-
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData, tagLength: 128 }, key, ciphertext);
|
|
43
|
-
return JSON.parse(decoder.decode(plaintext));
|
|
44
|
-
}
|