neoctl-web 0.1.12 → 0.1.14

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 CHANGED
@@ -13,6 +13,46 @@ neow
13
13
 
14
14
  当前版本目标:先复刻 `neo web` 的能力;绘图工具等待后续 `neoctl` 更新后再接入。
15
15
 
16
+ ## 隔离模式
17
+
18
+ 默认关闭,仅通过文件配置。配置文件为用户数据目录下的 `isolation.json`,或由 `NEO_ISOLATION_CONFIG` 指定绝对路径;指定文件缺失或格式错误时拒绝启动。
19
+
20
+ 在 `web` 目录执行。超管交互设密,普通用户仅分配用户名:
21
+
22
+ ```bash
23
+ node scripts/isolation-user.mjs /absolute/path/isolation.json admin admin
24
+ node scripts/isolation-user.mjs /absolute/path/isolation.json alice user
25
+ ```
26
+
27
+ 普通用户首次登录输入的密码保存为后续密码,仅保存 scrypt 哈希。新密码至少 1 位,仅允许英文字母和数字,不设业务长度上限。已有密码保持有效。用户名同时用于数据目录,不应改名或复用。编辑文件:
28
+
29
+ ```json
30
+ {
31
+ "enabled": true,
32
+ "secureCookie": true,
33
+ "cookiePath": "/neo/",
34
+ "sessionHours": 12,
35
+ "retiredUsernames": [],
36
+ "users": [
37
+ { "username": "admin", "role": "admin", "passwordHash": "保留脚本生成的哈希" },
38
+ { "username": "alice", "role": "user" }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ 本地 HTTP 使用 `secureCookie: false`、`cookiePath: "/"`。HTTPS 部署使用 `secureCookie: true`,反向代理保留原始 `Host`。修改配置或账号后重启 Web;重启会清除全部登录态。关闭时改为 `enabled: false`,无需前端操作。
44
+
45
+ - 后台按用户分组会话,列表、恢复、删除、SSE 和详情均校验归属;不向新用户分配原有公共会话。
46
+ - 超管可在页面创建、删除普通用户并读取全部用户会话。超管查看会话时前端隐藏输入、新建和删除入口;底层会话接口不额外限制。删除账号不删除历史数据,用户名不可复用。
47
+ - 会话、上传及内置插件记录保存在 `isolated-users/<用户名>/`,工作目录位于 `workspaces/users/<用户名>/`。旧模式数据不迁移、不删除。
48
+ - 登录前不创建用户运行时;开启时直接嵌入运行时路由,不另开无认证 core HTTP 端口。
49
+ - 超管显示完整模型配置页:模型、CPA、工具、插件、系统提示词。模型和工具保存后同步全部用户;插件按原逻辑重启生效,系统提示词按原逻辑在后续请求生效。
50
+ - 普通用户不显示模型配置、提示词管理,配置接口拒绝访问。所有用户显示服务端内存,有有效额度时显示 CPA 额度卡片。
51
+ - Cookie 使用 HttpOnly、SameSite=Strict、过期时间;登录有限流,退出或过期关闭对应 SSE。凭据文件不放工程公开目录或挂进工作容器。
52
+ - 此处隔离的是 Web 账号和会话访问,不是 OS 沙箱。唯一 root 工作容器仍共享文件与进程;恶意 Agent 的跨用户文件访问需要额外执行层隔离。本地执行同样继承运行服务的系统权限。
53
+
54
+ 源码部署先运行 `npm --prefix ../engine run build`,生产页面运行 `npm run build`。`npm run dev` 和 `server.mjs` 均支持该配置。第三方插件需自行遵守传入的用户专属 `appDataDir`,不要使用共享数据目录。
55
+
16
56
  ## 开发启动
17
57
 
18
58
  ```bash
@@ -70,7 +110,7 @@ Vite 会把以下路径代理到 Neo 运行时,确保本应用使用与 `neo w
70
110
  - `/api/login`:模型供应商配置
71
111
  - `/vendor/*`:neo web 运行时静态资源
72
112
 
73
- `expose_downloads` 可暴露任意现有绝对文件路径,不受当前工作目录限制;下载链接仍为临时链接并按注册表有效期失效。
113
+ `expose_downloads` 可暴露任意现有绝对文件路径,不受当前工作目录限制;下载链接无自动过期,仅持久保存原始路径映射、不复制文件;原文件移动、删除或不可读后链接失效。详见 `plugins/downloads/README.md`。独立视频播放插件见 `plugins/video-share/README.md`。
74
114
 
75
115
  如果只想启动纯前端 Vite:
76
116
 
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ export const UPLOAD_CHUNK_BYTES = 4 * 1024 * 1024
6
+ const prefix = '/api/uploads/chunks'
7
+
8
+ // No total file-size limit. Only individual requests are bounded, keeping memory
9
+ // usage constant. Each chunk is appended to one file; completion is an atomic rename.
10
+ export function createChunkUploadHandler({ uploadsDir, baseDir, finalize }) {
11
+ const sessions = new Map()
12
+ const partialDir = path.join(uploadsDir, '.partial')
13
+ const reply = (res, value, status = 200) => {
14
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
15
+ res.end(JSON.stringify(value))
16
+ }
17
+ const fail = (message, status = 400) => Object.assign(new Error(message), { status })
18
+ async function metadata(req) {
19
+ const chunks = []
20
+ let bytes = 0
21
+ for await (const chunk of req) {
22
+ bytes += chunk.length
23
+ if (bytes > 16384) throw fail('上传元数据过大')
24
+ chunks.push(chunk)
25
+ }
26
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
27
+ }
28
+ async function discard(id, session) {
29
+ await fs.rm(session.partialPath, { force: true })
30
+ sessions.delete(id)
31
+ }
32
+ // Only abandoned incomplete uploads are cleaned up; completed files never expire.
33
+ const cleanup = setInterval(() => {
34
+ for (const [id, session] of sessions) {
35
+ if (!session.busy && Date.now() - session.updated > 24 * 60 * 60 * 1000) {
36
+ session.busy = true
37
+ void discard(id, session).catch(() => { session.busy = false })
38
+ }
39
+ }
40
+ }, 60 * 60 * 1000)
41
+ cleanup.unref()
42
+
43
+ const handle = async (req, res, url) => {
44
+ if (url.pathname !== prefix && !url.pathname.startsWith(prefix + '/')) return false
45
+ try {
46
+ if (url.pathname === prefix && req.method === 'POST') {
47
+ const body = await metadata(req)
48
+ const name = path.basename(String(body.name || '').replace(/\\/g, '/')).replace(/[<>:"/\\|?*\u0000-\u001f]+/g, '-').trim().slice(0, 180)
49
+ if (!name || name === '.' || name === '..') throw fail('文件名无效')
50
+ if (!Number.isSafeInteger(body.size) || body.size < 0) throw fail('文件大小无效')
51
+ const id = randomUUID()
52
+ await fs.mkdir(partialDir, { recursive: true })
53
+ const partialPath = path.join(partialDir, id + '.part')
54
+ const file = await fs.open(partialPath, 'wx')
55
+ await file.close()
56
+ sessions.set(id, { name, size: body.size, mimeType: String(body.mimeType || 'application/octet-stream'), partialPath, offset: 0, busy: false, updated: Date.now() })
57
+ reply(res, { ok: true, uploadId: id, offset: 0, chunkBytes: UPLOAD_CHUNK_BYTES })
58
+ return true
59
+ }
60
+ const match = url.pathname.slice(prefix.length).match(/^\/([a-f0-9-]{36})(\/complete)?$/)
61
+ if (!match) throw fail('上传地址无效', 404)
62
+ const id = match[1]
63
+ const session = sessions.get(id)
64
+ if (!session) throw fail('未完成的上传不存在,请重新上传', 404)
65
+ session.updated = Date.now()
66
+ if (session.busy) throw fail('上一分片仍在处理中,请重试', 409)
67
+ if (req.method === 'GET' && !match[2]) {
68
+ reply(res, { ok: true, offset: session.offset })
69
+ return true
70
+ }
71
+ session.busy = true
72
+ try {
73
+ if (req.method === 'DELETE' && !match[2]) {
74
+ await discard(id, session)
75
+ reply(res, { ok: true })
76
+ } else if (req.method === 'PATCH' && !match[2]) {
77
+ const offset = Number(req.headers['x-upload-offset'])
78
+ if (!Number.isSafeInteger(offset) || offset !== session.offset) throw fail('分片偏移不匹配', 409)
79
+ const file = await fs.open(session.partialPath, 'r+')
80
+ let received = 0
81
+ try {
82
+ for await (const chunk of req) {
83
+ if (received + chunk.length > UPLOAD_CHUNK_BYTES || offset + received + chunk.length > session.size) throw fail('分片大小无效', 413)
84
+ let written = 0
85
+ while (written < chunk.length) {
86
+ const result = await file.write(chunk, written, chunk.length - written, offset + received + written)
87
+ if (!result.bytesWritten) throw new Error('磁盘写入失败')
88
+ written += result.bytesWritten
89
+ }
90
+ received += chunk.length
91
+ }
92
+ if (!received) throw fail('分片为空')
93
+ session.offset += received
94
+ } catch (error) {
95
+ await file.truncate(offset)
96
+ throw error
97
+ } finally {
98
+ await file.close()
99
+ }
100
+ reply(res, { ok: true, offset: session.offset })
101
+ } else if (req.method === 'POST' && match[2]) {
102
+ if (session.offset !== session.size) throw fail('文件尚未上传完整', 409)
103
+ const storedName = `${new Date().toISOString().replace(/[:.]/g, '-')}-${id}-${session.name}`
104
+ const absolutePath = path.join(uploadsDir, storedName)
105
+ await fs.rename(session.partialPath, absolutePath)
106
+ sessions.delete(id)
107
+ const file = {
108
+ id: `upload-${id}`, name: session.name, storedName, size: session.size,
109
+ mimeType: session.mimeType, absolutePath,
110
+ relativePath: path.relative(baseDir, absolutePath) || storedName,
111
+ url: `/api/uploads/${encodeURIComponent(storedName)}`,
112
+ };
113
+ reply(res, { ok: true, file: finalize ? await finalize(file, url) : file });
114
+ } else throw fail('不支持的上传操作', 405)
115
+ } finally {
116
+ session.busy = false
117
+ }
118
+ } catch (error) {
119
+ if (!res.destroyed && !res.headersSent) reply(res, { ok: false, error: error.message || '上传失败' }, error.status || 500)
120
+ }
121
+ return true
122
+ }
123
+ handle.close = () => clearInterval(cleanup)
124
+ return handle
125
+ }
package/core-runtime.mjs CHANGED
@@ -23,6 +23,7 @@ export const WebRepl = webModule.WebRepl;
23
23
  export const WebRuntimeRouter = webModule.WebRuntimeRouter;
24
24
  export const createWebRuntime = webModule.createWebRuntime;
25
25
  export const runWebServer = webModule.runWebServer;
26
+ export const handleWebRequest = webModule.handleWebRequest;
26
27
  export const coreRuntimeInfo = Object.freeze({
27
28
  source,
28
29
  version: await readCoreVersion(),