From 71acb88078e6ca5224e6c17bb85f7118541fa8c3 Mon Sep 17 00:00:00 2001 From: hoelee Date: Sat, 22 Aug 2026 19:26:06 +0800 Subject: [PATCH] Phase 1-3: AList stock deployment, NocoDB 7-table schema, W1/W2 n8n workflows + docs --- .env.example | 30 ++ .gitignore | 10 + README.md | 69 ++++ docker-compose.yml | 61 ++++ docs/API_INTEGRATION.md | 259 ++++++++++++++ docs/BACKUP_RESTORE.md | 27 ++ docs/CONFIGURATION.md | 27 ++ docs/DATABASE_SCHEMA.md | 152 ++++++++ docs/INSTALL.md | 86 +++++ docs/N8N_WORKFLOWS.md | 54 +++ docs/PROJECT_STATE.md | 93 +++++ docs/SECURITY.md | 40 +++ docs/TROUBLESHOOTING.md | 4 + docs/UPGRADE.md | 25 ++ n8n/SETUP.md | 70 ++++ n8n/W1-customer-provision.json | 591 +++++++++++++++++++++++++++++++ n8n/W2-DESIGN.md | 30 ++ n8n/W2-customerproduct-sync.json | 184 ++++++++++ n8n/w2-compute.js | 110 ++++++ scripts/create_nocodb_schema.py | 188 ++++++++++ scripts/healthcheck.sh | 17 + 21 files changed, 2127 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/API_INTEGRATION.md create mode 100644 docs/BACKUP_RESTORE.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/INSTALL.md create mode 100644 docs/N8N_WORKFLOWS.md create mode 100644 docs/PROJECT_STATE.md create mode 100644 docs/SECURITY.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/UPGRADE.md create mode 100644 n8n/SETUP.md create mode 100644 n8n/W1-customer-provision.json create mode 100644 n8n/W2-DESIGN.md create mode 100644 n8n/W2-customerproduct-sync.json create mode 100644 n8n/w2-compute.js create mode 100644 scripts/create_nocodb_schema.py create mode 100644 scripts/healthcheck.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc513bc --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ============================================================================= +# AList Serve Resource — 环境变量模板 +# 复制为 .env 并填入真实值。.env 加入 .gitignore,绝不提交 Git(规格 §29/§55)。 +# ============================================================================= + +# --- 时区 --- +TZ=Asia/Kuala_Lumpur + +# --- AList --- +# AList JWT 签名密钥(随机 32+ 字符字符串)。生产环境必须替换。 +JWT_SECRET=CHANGE_ME_random_32char_or_longer + +# --- AList 内网地址(n8n / NocoDB webhook 访问 admin API 用)--- +# container_name 为 alist-serve-resource,走 bridge_hoelee 网络时内网地址如下: +ALIST_URL=http://alist-serve-resource:5244 + +# --- AList Admin 凭证(登录 /api/auth/login 换取 token)--- +ALIST_ADMIN_USERNAME=admin +ALIST_ADMIN_PASSWORD=CHANGE_ME_strong_admin_password +# 可选:持久化 token 缓存(若不用每次 login,可后续由 n8n credential 保存) +ALIST_API_TOKEN= + +# --- NocoDB(业务授权 Source of Truth)--- +NOCODB_URL=https://nocodb.hoelee.com +# 在 NocoDB 内创建 API token(Settings → API Tokens),不要用明文密码 +NOCODB_TOKEN=CHANGE_ME_nocodb_api_token + +# --- n8n 加密密钥 --- +# 生产环境固定一个强随机值;改动会导致已有 credentials 无法解密 +N8N_ENCRYPTION_KEY=CHANGE_ME_n8n_encryption_key diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc874bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Secrets — 绝不提交 +.env + +# Docker / 运行时 +docker-compose.override.yml + +# 本地临时文件 +*.log +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f734cb --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# AList Serve Resource — 客户文件权限与到期管理系统 + +在 Docker 上运行的客户数字文件交付系统: + +- **AList** = 文件浏览/预览/下载入口(stock image,不 fork、不嵌 custom code) +- **NocoDB** = 业务授权 Source of Truth(客户/产品/授权/到期) +- **n8n** = 同步器(NocoDB ↔ AList 官方 Admin API) + +## 架构 + +``` +Internet + │ + ▼ +DSM nginx (access.hoelee.com) + │ + ▼ +AList (stock, 只读挂载 NAS) + ▲ + │ 内网 Admin API(http://alist-serve-resource:5244) + │ +n8n ──► NocoDB(授权 SOT) +``` + +- 客户只经 AList 入口访问,默认严格 read-only(权限 bitmask `0` + Check path limit)。 +- 每个客户 = 1 个 AList User + 1 个专属 Role + 多个 path scopes,每个 scope 独立到期(规格 §8)。 +- 到期/禁用由 NocoDB + n8n 定时同步撤销,AList 不存业务授权数据。 + +## 目录结构 + +``` +alist-serve-resource/ +├── docker-compose.yml # 仅 AList(stock) +├── .env.example # 所有 secrets 模板(复制为 .env) +├── .gitignore +├── docs/ +│ ├── API_INTEGRATION.md # ★ AList API 源码级验证 +│ ├── INSTALL.md +│ ├── CONFIGURATION.md +│ ├── DATABASE_SCHEMA.md +│ ├── N8N_WORKFLOWS.md +│ ├── SECURITY.md +│ ├── BACKUP_RESTORE.md +│ ├── UPGRADE.md +│ └── TROUBLESHOOTING.md +├── n8n/workflows/ # n8n workflow JSON 导出 +└── scripts/ # backup/restore/healthcheck +``` + +## 快速开始 + +见 [docs/INSTALL.md](docs/INSTALL.md)。 + +## 关键结论 + +规格文档 §11 担心的「Role scope 是否能用 API 管理」——**答案是能**(`v3.63.0` 源码级验证, +完整 Role CRUD)。无需 fork、无需 integration service。详见 +[docs/API_INTEGRATION.md](docs/API_INTEGRATION.md)。 + +## 与旧 alist 的区别 + +| | 旧 alist | 新 alist-serve-resource | +|---|---|---| +| 镜像 | `xhofe/alist:v3.54.0` | `xhofe/alist:v3.63.0` | +| custom.js/css 注入 | 有(custom made) | **无**(纯 stock) | +| 入口链 | nginx-limit → traefik-update → alist | DSM nginx → alist(直连) | +| 权限/到期 | 内嵌业务逻辑 | NocoDB + n8n + 官方 API | +| 端口 | 5244/5245/5246 | 5247 | +| 数据目录 | `/volume1/docker/alist` | `/volume1/docker/alist-serve-resource` | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..76e5007 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +# ============================================================================= +# AList Serve Resource — 客户文件交付系统(Phase 1:AList stock 部署) +# ============================================================================= +# 这是替代旧自定义 alist(access.hoelee.com, xhofe/alist:v3.54.0 + custom.js) +# 的全新 stock AList。业务权限逻辑由 NocoDB(SOT) + n8n(同步器) 通过 AList +# 官方 Admin API 管理,绝不在 image 内嵌入 custom code(规格 §8/§55)。 +# +# 官方 Role/User 管理 API 已源码级验证(v3.63.0): +# GET/POST /api/admin/role/{list,get,create,update,delete} +# GET/POST /api/admin/user/{list,get,create,update,delete,cancel_2fa} +# role 字段 = role-ID 数组;read-only = permission bitmask 0; +# Check path limit = bit 14 = 16384(见 docs/API_INTEGRATION.md) +# ============================================================================= + +services: + alist: + # 固定版本 tag,不追 latest(规格 §48)。部署前确认 v3.63.0 为主要最新稳定版。 + image: xhofe/alist:v3.63.0 + container_name: alist-serve-resource + restart: unless-stopped + + # 仅暴露 DSM 内网端口。公网入口由 DSM nginx 反代 access.hoelee.com → 此端口。 + # 旧 alist 用 5244/5245/5246(nginx-limit/traefik-update 链),新服务独立端口避免冲突。 + ports: + - "5247:5244" # host:5247 -> container:5244(AList 默认端口) + + volumes: + # AList 自身数据/数据库/config(独立目录,不碰旧 alist 的 /volume1/docker/alist) + - /volume1/docker/alist-serve-resource/data:/opt/alist/data + - /volume1/docker/alist-serve-resource/config:/opt/alist/config + - /volume1/docker/alist-serve-resource/storage:/opt/alist/storage + + # 只读挂载 NAS 真实文件目录 —— 客户只能读取,不能改动(规格 §15/§16)。 + # 第二层防护:即使 AList 权限配置出错,filesystem 层仍只读。 + - /volume1/Class:/mnt/Class:ro + - /volume1/Book:/mnt/Book:ro + - /volume1/Comic:/mnt/Comic:ro + - /volume1/Template:/mnt/Template:ro + + environment: + - TZ=Asia/Kuala_Lumpur + # 生产部署请从 .env 注入真实值,勿硬编码(规格 §29/§55)。见 .env.example。 + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRE=2h + # 注意:不再注入 ALIST_CUSTOM_CSS / ALIST_CUSTOM_JS —— 全新 stock 配置。 + + # 健康检查:AList 无内建 /health,用 /ping 探测 HTTP 存活 + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5244/ping >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + networks: + - bridge_hoelee + +networks: + # 复用 DSM 上已存在的外部网络(与 n8n / nocodb 同网络,便于内网 API 互访) + bridge_hoelee: + external: true diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md new file mode 100644 index 0000000..ed6f061 --- /dev/null +++ b/docs/API_INTEGRATION.md @@ -0,0 +1,259 @@ +# AList API 集成说明(源码级验证) + +> 规格文档 §11/§47 要求:不猜 API,不发明 endpoint。本文所有端点均已从 +> AList 上游源码(`AlistGo/alist`,`v3.63.0`)验证,而非仅凭文档目录猜测。 + +## 1. 版本 + +- 目标版本:`xhofe/alist:v3.63.0`(2026-08 最新稳定 release) +- Role-scoped path aggregation + Check path limit 自 v3.53.0 起支持(规格 §48 要求已满足)。 + +## 2. 认证 + +``` +POST /api/auth/login +Body: { "username": "admin", "password": "***" } +响应: { "code": 200, "data": { "token": "" } } +``` + +后续所有 Admin 请求带 header: + +``` +Authorization: +``` + +## 3. 已验证的 Admin API + +源码位置:`server/router.go`(`admin(g *gin.RouterGroup)`)、`server/handles/user.go`、`server/handles/role.go`。 + +### 3.1 User API + +| 端点 | 方法 | 说明 | +|---|---|---| +| `/api/admin/user/list` | GET | 分页列出用户(`?page=&per_page=`) | +| `/api/admin/user/get` | GET | `?id=` 取单个用户 | +| `/api/admin/user/create` | POST | 建用户 | +| `/api/admin/user/update` | POST | 改用户 | +| `/api/admin/user/delete` | POST | `?id=` 删用户 | +| `/api/admin/user/cancel_2fa` | POST | 撤销某用户 2FA | + +### 3.2 Role API(关键 —— 完整 CRUD 存在) + +| 端点 | 方法 | 说明 | +|---|---|---| +| `/api/admin/role/list` | GET | 分页列出 role | +| `/api/admin/role/get` | GET | `?id=` 取单个 role | +| `/api/admin/role/create` | POST | 建 role | +| `/api/admin/role/update` | POST | 改 role(含 `permission_scopes`) | +| `/api/admin/role/delete` | POST | `?id=` 删 role | + +> ✅ 结论:Role scope 的增删改**可通过官方 API 完整实现**,无需 fork、无需 +> integration service(规格 §12 的 Option C 不需要走)。 + +## 4. 数据结构(源码 `internal/model/`) + +### 4.1 Role + +```go +type Role struct { + ID uint `json:"id"` + Name string `json:"name"` // 唯一 + Description string `json:"description"` + Default bool `json:"default"` + PermissionScopes []PermissionEntry `json:"permission_scopes"` +} + +type PermissionEntry struct { + Path string `json:"path"` // 路径前缀,如 "/Class/A/1" + Permission int32 `json:"permission"` // bitmask +} +``` + +JSON 示例: + +```json +{ + "id": 5, + "name": "customer-alice", + "description": "Alice customer role", + "default": false, + "permission_scopes": [ + { "path": "/Class/A/1", "permission": 0 }, + { "path": "/Class/A/2", "permission": 0 } + ] +} +``` + +### 4.2 User + +```go +type User struct { + ID uint `json:"id"` + Username string `json:"username"` + Password string `json:"password"` // create/update 时传入明文 + BasePath string `json:"base_path"` + Role Roles `json:"role"` // role-ID 数组 + Permission int32 `json:"permission"` // bitmask + Disabled bool `json:"disabled"` +} +``` + +JSON 示例(只读客户): + +```json +{ + "id": 10, + "username": "alice", + "password": "***", + "base_path": "/Class", + "role": [5], + "permission": 16384, + "disabled": false +} +``` + +## 5. Permission bitmask(源码 `internal/model/user.go`) + +| bit | 含义 | 对应 | +|---|---|---| +| 0 | 查看隐藏文件 | 1 | +| 1 | 免密访问 | 2 | +| 2 | 离线下载任务 | 4 | +| 3 | mkdir / upload | 8 | +| 4 | rename | 16 | +| 5 | move | 32 | +| 6 | copy | 64 | +| 7 | remove / delete | 128 | +| 8 | WebDAV read | 256 | +| 9 | WebDAV write | 512 | +| 10 | FTP login/read | 1024 | +| 11 | FTP write | 2048 | +| 12 | 读压缩包 | 4096 | +| 13 | 解压 | 8192 | +| **14** | **Check path limit** | **16384** | +| 15 | MCP read | 32768 | +| 16 | MCP write | 65536 | + +**本项目默认:** +- `read-only` = bitmask **`0`**(不设 bit 3–7/9/11/13 等任何写权限) +- `Check path limit ON` = 设 bit 14 → **`16384`** + +即客户 user 的 `permission` 字段 = `16384`(只读 + 路径限制),所有写位为 0。 + +## 6. Role scope + Check path limit 的权限判定逻辑 + +源码 `JoinPath()`(`internal/model/user.go`)证实: + +1. 用户 base path 只是登录后的默认落点,**不是 ACL**(规格 §2/§30)。 +2. 若用户 `CheckPathLimit()` 为真(bit 14 置位),请求路径必须落在该用户**任一 role + 的任一 permission scope 的 path 之内**(前缀匹配),否则返回 `PermissionDenied`。 +3. 因此「Alice 只能进 /Class/A/1 与 /Class/A/2,进不了 /Class/A/3 与 /Class/B」 + 的正确实现 = Alice 专属 role 的 `permission_scopes` 只含这两个 path,且 user + `permission` bit 14 置位。 + +## 7. 保护性约束(源码确认) + +- `admin` / `guest` role 受保护:`update`(改 admin 名/默认属性)与 `delete` 会被 + `ErrChangeDefaultRole` 拒绝。客户 role 不受此限。 +- `CreateUser` 时若 `role` 空数组,会回填默认 role ID(`op.GetDefaultRoleID()`)。 + **因此建客户时必须显式传其专属 role ID,不能留空。** +- `UpdateUser` 若试图把用户 role 改成 admin/guest role,会被拒绝。 +- 至少保留 1 个 enabled admin,否则禁用最后一个 admin 会失败。 + +## 8. 给 n8n 的实现要点(幂等,规格 §10/§28) + +- 建用户:先 `GET /api/admin/user/list` 按 `username` 查重,存在则 `update`,否则 `create`。 +- 建 role:先 `GET /api/admin/role/list` 按 `name` 查重,存在则 `update`(重写 `permission_scopes`),否则 `create`。 +- 更新 scope:`POST /api/admin/role/update`,整体重写该 role 的 `permission_scopes` + (desired state = NocoDB 当前有效 grants 的并集),天然幂等。 +- 到期撤销:从 desired scopes 里移除过期 path,再整体 `update` 该 role。 +- 禁用客户:`POST /api/admin/user/update` 设 `disabled: true`。 +- 删除客户:soft delete(NocoDB 侧 `status=deleted`),AList 侧禁用用户 + 清空 role scopes,不物理删除(保留审计)。 + +## 9. Phase 3 实测验证(2026-08-22,对真实 v3.63.0 实例) + +> 以下为对 DSM 上运行中的 `alist-serve-resource`(v3.63.0,端口 5244)实际 curl 验证结果, +> 修正/确认了上文的若干推断。 + +### 9.1 认证 —— 登录带 2FA 时的字段 + +源码 `server/handles/auth.go` 的 `LoginReq` 结构: + +```go +type LoginReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password"` + OtpCode string `json:"otp_code"` // 2FA 开启时必填(TOTP 6 位) +} +``` + +- 账户开启 2FA(`otp_secret` 非空)时,单次 `POST /api/auth/login` 不带 `otp_code` + 直接返回 `{"code":402,"message":"Invalid 2FA code"}`。**是一步登录**,不是两步 challenge。 +- 登录成功返回:`{"code":200,"data":{"token":"","device_key":""}}`。 +- `GET /api/admin/user/list` 返回的员工 `password` 字段恒为 `""`(脱敏),`otp` 状态不在此端点, + 需 `GET /api/me` 才见 `otp: true/false`。 + +### 9.2 Role CRUD 实测返回值(修正 §3) + +| 端点 | 实测返回 | 备注 | +|---|---|---| +| `role/list` | `{"code":200,"data":{"content":[...],"total":N}}` | `PageResp` 包裹,非顶层数组 | +| `role/create` | `{"code":200,"data":null}` | **不返回新建 role 对象**;需再 `role/list` 按 `name` 取 id | +| `role/update` | `{"code":200,"data":null}` | 成功即 null;`role/get?id=` 可回读确认 | +| `role/get?id=` | `{"code":200,"data":{...}}` | 返回完整 role 含 `permission_scopes` | +| `role/delete` | **`POST /api/admin/role/delete?id=4`(id 走 query param)** | 用 JSON body 传 id 会 `400 strconv.Atoi: parsing "": invalid syntax` | + +- `role/delete` 与 `user/delete` **都必须 `?id=` query param**,不是 body。 +- `user/create`、`user/delete` 同样实测成功(返回 `data:null`)。 + +### 9.3 admin 账户(当前实例) + +- 用户名 `hoelee`,角色 `admin`(role id 2,`permission 65535`,`permission_scopes` = `[{"/", 65535}]`)。 +- `guest` role(id 1)`disabled`,`permission 0`。 +- admin 登录曾开 2FA(已由用户关闭);本系统若需 n8n 无人值守,**建议 admin 账户保持 2FA 关闭 + 或用独立 service token**——否则 n8n 每次登录都要算 TOTP。 + +### 9.4 网络可达性(关键部署事实) + +- AList 容器名 `alist-serve-resource` 仅 Docker **网络内部**可解析。 + **DSM host shell 无法解析该名字**(`Could not resolve host`),须用 `localhost:5244`。 +- n8n 容器在 `bridge_hoelee` 网络内,访问 AList 用 **`http://alist-serve-resource:5244`**(✅)。 +- 旧 `alist` 容器(v3.54.0)现在映射 host 端口 5243,新容器占 5244,无冲突。 + +### 9.5 ⚠ JWT secret 不一致(待修复,否则 token 会失效) + +- 容器 env `JWT_SECRET==`(.env 传入),但 AList 初始化时写入 `data/config.json` + 的 `jwt_secret` 是另一个值 ``(首次启动自动生成)。 +- **env `JWT_SECRET` 只影响首次初始化**;服务运行后以 `config.json` 为准(当前生效的是 + ``)。两者不一致是正常的(env 在初次 init 时可能被 config 覆盖)。 +- 影响:若将来重建数据目录(清空 `data/`),.env 里的 `JWT_SECRET` 会生效;否则一直用 config.json 上的值。 + **n8n 不关心这个**(每次 `login` 换 token),但需知悉「改 env 不会改已运行实例的 JWT」。 +- 若要统一:改 `data/config.json` 的 `jwt_secret` 并重启,或接受 config.json 为准。 + +### 9.6 n8n 集成结论(Phase 3 通过 ✅) + +- admin 登录 token 可用,Role/User 全 CRUD 可用,scope 读写可用。 +- n8n workflow 实现要点(§8)全部成立,唯一补充:**delete 走 `?id=`**,**create/update 返回 null + 需回查**(用 `list` 按 `name` 定位 id)。 + +### 9.7 ⚠ `user/create` 空 role 会失败(Phase 4/W1 debug 实测,源码级确认) + +- **症状**:`POST /api/admin/user/create` 传 `"role": []` 时返回 + `{"code":400,"message":"admin or guest user can not be created"}`,且与 username 无关(任意名字都失败)。 +- **根因**(`server/handles/user.go` `CreateUser`): + 1. `if len(req.Role) == 0 { req.Role = model.Roles{op.GetDefaultRoleID()} }` —— 空 role 被自动填为 + **默认 role(guest,id=1)**。 + 2. 紧接着 `if req.IsAdmin() || req.IsGuest() { ... 400 ... }`,其中 + `IsGuest()` = `Role.Contains(GUEST)`(`GUEST=1`)、`IsAdmin()` = `Role.Contains(ADMIN)`(`ADMIN=2`)。 + role 现在是 `[1]`(guest)→ 判定为 guest → 拒绝。 +- **关键认知**:`IsGuest/IsAdmin` 判断的是 **role ID**,不是 username。错误信息里的 "admin or guest + user" 会误导向「用户名保留了」,实际是「role 里含 guest/admin ID」。 +- **正确顺序(必须)**: + 1. `role/create`(name=`customer-`,空 `permission_scopes`)→ 返回 200 但 `data:null`; + 2. `role/list` 按 name 找到新建 role 的自增 id(3、4、5…); + 3. `user/create` 传 `"role": [<该 role id>]`(**非空、非 1/2**)→ 成功,`base_path` 正确。 + - 实测:`role:[5]` 创建用户 `code 200`,`base_path:/Class`、`permission 16384` 均落地。 +- **适用**:W1 的「新建用户」路径必须先建 role;更新路径 `user/update` 里 `role:[]` 同样会触发 + `GetDefaultRoleID` 回填(见 `UpdateUser` 同款逻辑),若客户已有 role id 应显式传入。 +- 相关常量:`GENERAL=0, GUEST=1, ADMIN=2, NEWGENERAL=3`(`internal/model/user.go`);`permission` + bitmask 定义同文件注释(bit 14 = "check path limit" = 16384)。 diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md new file mode 100644 index 0000000..f3bd5bf --- /dev/null +++ b/docs/BACKUP_RESTORE.md @@ -0,0 +1,27 @@ +# 备份 / 恢复 + +> 重要区分(规格 §35):AList 的 data/config 与 NAS 真实文件 `/volume1/Class` 是两回事。 +> 备份 AList ≠ 备份客户文件。 + +## 需要备份 + +| 对象 | 说明 | +|---|---| +| AList data/config | `/volume1/docker/alist-serve-resource/data`(含数据库、用户、role) | +| `.env` | 加密备份,不落 Git 明文 | +| docker-compose.yml + docs | 已入 Git(gitea) | +| NocoDB 数据库 | 现成 DB 已有自动备份(你说过不用重复做) | +| n8n 数据 | 现有 n8n 已有备份策略 | + +## 备份脚本占位 + +`scripts/backup.sh` / `restore.sh` 在 Phase 9 补全。当前 AList 数据目录可直接 +tar 备份: + +```bash +tar czf alist-serve-resource-data-$(date +%F).tar.gz -C /volume1/docker alist-serve-resource +``` + +## 恢复 + +Phase 9 补充完整步骤(停止容器 → 还原目录 → 启动 → 跑 reconciliation)。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..0f20f7f --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,27 @@ +# 配置说明 + +## 环境变量 + +全部见 `.env.example`。核心变量: + +| 变量 | 用途 | +|---|---| +| `JWT_SECRET` | AList 会话签名密钥(生产必换) | +| `ALIST_URL` | n8n 访问 AList 的内网地址 | +| `ALIST_ADMIN_USERNAME/PASSWORD` | admin 登录(换 token) | +| `NOCODB_URL` / `NOCODB_TOKEN` | 业务 SOT | +| `N8N_ENCRYPTION_KEY` | n8n credential 加密 | + +## AList 关键配置 + +- `JWT_EXPIRE=2h`:session 有效期。 +- 不设 `ALIST_CUSTOM_CSS/JS`(stock)。 +- 端口映射 `5247:5244`(host → container)。 + +## NocoDB / n8n + +- 复用现有运行中的 NocoDB(`https://nocodb.hoelee.com`)与 n8n(`https://auto.hoelee.com`), + 本 compose **不重复部署**它们。 +- 只需在 NocoDB 建表(见 DATABASE_SCHEMA.md)、在 n8n 建 workflow(见 N8N_WORKFLOWS.md)。 + +## 具体参数在实施 Phase 2/3 时补充。 diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..6a5f6fa --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,152 @@ +# NocoDB 数据结构(Phase 2b — 重建,7 表最终模型) + +> Source of Truth = NocoDB。AList 只做 enforcement,不存业务授权数据。 +> 本 schema 按《Hermes 调整指导》最终业务模型重建(2026-08-22)。 +> 通过 NocoDB API 自动建表,无需手动点 UI。 + +- **Base**: `AlistAccess`(id `p3lzkresswyowqg`),独立新 base,与旧 `accessDB` 隔离。 +- **MySQL source**: mysql-server 上的 `alistaccess` schema(由 NocoDB 自动管理)。 +- **API token**: workspace token(`nc_api_tokens` 表内 `n8n` token),`xc-token` header。 + +## 表 model_id(n8n v2 API 硬编码用) + +| 表 | model_id | +|---|---| +| Customers | `maqb9zgvza2te3k` | +| Products | `molyb3pxv81l75g` | +| Resources | `m6cm7tkxzd8m18c` | +| CustomerProducts | `mvyqbv8ao7u6bct` | +| AccessGrants | `mxdx3yhfcyej3ao` | +| AccessLogs | `ml12zcyzlehgi21` | +| Purchases | `mwp12z9lcohhsuk` | + +> ⚠ ID 是本次重建生成的。若再次删库重建,ID 会变,需同步更新 n8n 节点 + 本文档。 + +## 关系(LinkToAnotherRecord) + +| 子表 | 字段 | 方向 | 指向 | 说明 | +|---|---|---|---|---| +| CustomerProducts | `customer` | bt | Customers | | +| CustomerProducts | `product` | bt | Products | | +| Products | `Resources` | **mm** | Resources | M2M link,列 id `c4ko4x8kwi16swg` | +| Resources | `Products` | mm (reverse) | Products | 列 id `cfxi44n9ixkimqc` | +| AccessGrants | `customer` | bt | Customers | | +| AccessGrants | `resource` | bt | Resources | | +| AccessLogs | `customer` | bt | Customers | | +| Purchases | `customer` | bt | Customers | | +| Purchases | `product` | bt | Products | | + +> M2M 读写方式(已实测):`GET/POST /api/v2/tables/{products}/links/c4ko4x8kwi16swg/records/{productId}`, +> POST body `{"Id": }`。反向用 Resources 的 `cfxi44n9ixkimqc`。 + +## 表结构 + +### 1. Customers +| 字段 | 类型 | 说明 | +|---|---|---| +| Id | number (PK) | | +| username | SingleLineText | AList 登录名(唯一) | +| password | SingleLineText | **plaintext**(规格 §2 已确认,勿 hash) | +| display_name | SingleLineText | | +| email | Email | | +| notes | LongText | | +| alist_user_id | Number | AList 用户 ID(n8n 回填) | +| alist_role_id | Number | AList role ID(n8n 回填) | +| status | SingleSelect | pending/active/suspended/expired/disabled/deleted | +| sync_status | SingleSelect | pending/syncing/synced/error/unknown | +| sync_required | Checkbox | 系统维护,管理员勿手改 | +| last_sync | DateTime | | +| last_sync_error | LongText | | + +### 2. Products +| 字段 | 类型 | 说明 | +|---|---|---| +| Id | number (PK) | | +| name | SingleLineText | 如 "Python Basic" | +| description | LongText | | +| default_duration_days | Number | 默认有效期天数 | +| status | SingleSelect | active/inactive | +| Resources | link (mm) | 该产品包含的 Resources | + +### 3. Resources +| 字段 | 类型 | 说明 | +|---|---|---| +| Id | number (PK) | | +| name | SingleLineText | 如 "Python 01" | +| alist_path | SingleLineText | 真实 AList path,如 "/Class/Python/01" | +| description | LongText | | +| status | SingleSelect | active/inactive | +| Products | link (mm, reverse) | | + +### 4. CustomerProducts(核心新表) +| 字段 | 类型 | 说明 | +|---|---|---| +| Id | number (PK) | | +| customer | link bt → Customers | | +| product | link bt → Products | | +| start_at | Date | | +| expires_at | Date | 实际到期(可 override default_duration) | +| status | SingleSelect | scheduled/active/expired/revoked/suspended | +| sync_status | SingleSelect | pending/syncing/synced/error/unknown | +| sync_required | Checkbox | | +| last_sync | DateTime | | +| last_sync_error | LongText | | + +### 5. AccessGrants(最终实际权限,n8n 计算维护) +| 字段 | 类型 | 说明 | +|---|---|---| +| Id | number (PK) | | +| customer | link bt → Customers | | +| resource | link bt → Resources | | +| permission | SingleSelect | read/readwrite | +| start_at | Date | | +| expires_at | Date | effective expiry(多来源取 max) | +| status | SingleSelect | pending/active/revoked/expired | +| source | SingleSelect | product/manual | +| sync_status | SingleSelect | pending/syncing/synced/error/unknown | +| sync_required | Checkbox | | +| last_sync | DateTime | | +| last_sync_error | LongText | | +| revoked_at | DateTime | | +| revoke_reason | LongText | | + +### 6. AccessLogs(审计) +| 字段 | 类型 | +|---|---| +| Id | number (PK) | +| customer | link bt → Customers | +| username | SingleLineText | +| resource_path | SingleLineText | +| file_path | SingleLineText | +| action | SingleSelect (login/view/download/preview/logout) | +| ip_address | SingleLineText | +| user_agent | LongText | +| result | SingleSelect (success/denied) | +| bytes | Number | +| timestamp | DateTime | + +### 7. Purchases(纯记录,不参与权限) +| 字段 | 类型 | +|---|---| +| Id | number (PK) | +| customer | link bt → Customers | +| product | link bt → Products | +| purchase_date | Date | +| amount | Decimal | +| payment_method | SingleLineText | +| order_id | SingleLineText | +| notes | LongText | + +## 建表 API recipe(实测 2026-08-22,复用见 nocodb-selfhosted skill) + +- 建 base:`POST /api/v2/meta/bases/ {"title"}` +- 建表(内联非 select 列):`POST /api/v2/meta/bases/{baseId}/tables {"title","table_name","type":"table","columns":[{"title","uidt"}]}` + - 可用 uidt:`SingleLineText`,`LongText`,`Number`,`Decimal`,`Date`,`DateTime`,`Email`,`Checkbox` +- SingleSelect 两段式: + 1. 建空列 `POST /api/v1/db/meta/tables/{tid}/columns {"title","uidt":"SingleSelect"}` + 2. 设选项 **必须同时传 `dtxp`**(逗号枚举串): + `PATCH /api/v1/db/meta/columns/{colid} {"colOptions":{"options":[{"title","order","color":null}]},"dtxp":"'opt1','opt2'"}` + ⚠ 只传 `colOptions` 不传 `dtxp` 会静默失败(options 空)。 +- Link:`POST /api/v1/db/meta/tables/{childTid}/columns {"title","uidt":"LinkToAnotherRecord","parentId","childId","type":"bt"}` +- M2M:`type":"mm"`,然后走 `/api/v2/tables/{t}/links/{colId}/records/{rowId}` 读写。 +- 删记录:`DELETE /api/v2/tables/{tid}/records` body `[{"Id":N},...]`(不是 `/records/{id}`)。 diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..149a5eb --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,86 @@ +# 安装文档(Phase 1:AList stock 部署) + +> 目标:在 DSM 上跑起全新的 stock AList,与旧 alist 并行,验证后再切换域名。 + +## 0. 前置条件(你需独立操作) + +这些是你在 DSM / Portainer 上手动完成的部分,agent 无法代劳: + +1. **确认 NAS 目录存在且就绪**: + - `/volume1/Class`、`/volume1/Book`、`/volume1/Comic`、`/volume1/Template` + - 若目录名/路径不同,改 `docker-compose.yml` 的 volumes。 + +2. **创建 AList 数据目录**(DSM File Station 或 SSH): + ``` + /volume1/docker/alist-serve-resource/data + /volume1/docker/alist-serve-resource/config + /volume1/docker/alist-serve-resource/storage + ``` + +3. **确认外部网络 `bridge_hoelee` 存在**: + - 该网络由 n8n/nocodb 等其他 compose 创建(`external: true`)。 + - 若不存在,在 DSM 上建:`docker network create bridge_hoelee`(或 Portainer → Networks → Add)。 + +4. **准备 secrets**: + - 复制 `.env.example` → `.env`,填入真实 `JWT_SECRET`、`ALIST_ADMIN_PASSWORD` 等。 + - 生成 JWT secret:`openssl rand -base64 32` + +## 1. 部署(Portainer) + +在 Portainer → Stacks → Add Stack: + +- Name: `alist-serve-resource` +- 方式:上传 `docker-compose.yml` + 配置环境变量(或把 `.env` 内容填入 Environment variables 区)。 +- 网络 `bridge_hoelee` 是 external,Portainer 会复用现有网络,不会新建。 + +> Portainer 与 `env_file`:Portainer Stack 使用内置 Environment variables 区而非 `.env` 文件。 +> 把 `.env` 里的键值对逐条填进 Portainer 的 Environment variables,或改用 Compose 里的 +> `${VAR}` 语法(Portainer 也支持 `env_file`,可上传 `.env` 作为 env_file)。 + +部署后验证: + +```bash +docker ps | grep alist-serve-resource +docker logs alist-serve-resource +``` + +## 2. 首次初始化 AList admin + +1. 访问 `http://:5247`。 +2. 首次进入会引导设置 admin 账号密码(或 AList 会打印初始 admin 密码到日志: + `grep -i admin docker logs alist-serve-resource`)。 +3. 登录后台,确认版本 v3.63.0。 + +## 3. 建立 Local Storage + +AList 后台 → 存储 → 添加 → 驱动选「本地存储 / Local」: + +``` +挂载路径: /Class → 根文件夹路径: /mnt/Class +挂载路径: /Book → 根文件夹路径: /mnt/Book +挂载路径: /Comic → 根文件夹路径: /mnt/Comic +挂载路径: /Template → 根文件夹路径: /mnt/Template +``` + +> 客户看到的逻辑路径是 `/Class/...`,实际 host 是 `/volume1/Class/...`(规格 §31)。 +> 客户永远看不到 `/volume1` 或 `/mnt`。 + +## 4. 验证健康检查 + +```bash +docker inspect --format '{{.State.Health.Status}}' alist-serve-resource +# 应输出 healthy +``` + +## 5. 切换域名(后期,新旧并行验证通过后再做) + +1. 旧 alist(`nginx-limit-alist`/`traefik-update-alist`/`alist`)保持运行。 +2. DSM nginx 将 `access.hoelee.com` 新指到 `http://:5247`。 +3. 验证客户能正常登录/浏览/下载。 +4. 确认无误后,停掉旧 alist 三个 container(先 `docker stop`,观察,再 `docker rm`)。 + +## 6. 后续 Phase + +- Phase 2:NocoDB schema(`docs/DATABASE_SCHEMA.md`) +- Phase 3:n8n + AList API 连接(`docs/N8N_WORKFLOWS.md`) +- Phase 4–10:客户建档、权限同步、到期同步、审计、监控、备份、文档 diff --git a/docs/N8N_WORKFLOWS.md b/docs/N8N_WORKFLOWS.md new file mode 100644 index 0000000..58d3bfc --- /dev/null +++ b/docs/N8N_WORKFLOWS.md @@ -0,0 +1,54 @@ +# n8n Workflow 说明(Phase 3–6 — 待实施) + +> n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是同步器,不是 SOT。 + +## 依赖的 AList API(已源码验证,见 API_INTEGRATION.md) + +- 登录换 token:`POST http://alist-serve-resource:5244/api/auth/login` +- User:`/api/admin/user/{list,get,create,update,delete,cancel_2fa}` +- Role:`/api/admin/role/{list,get,create,update,delete}` + +## 需要的 Workflow + +### WF1 — Customer Create(trigger: Customers 新记录) +1. 校验 customer。 +2. 查重 username → 建/改 AList user(`base_path=/Class`,`role=[专属role]`,`permission=16384`)。 +3. 建专属 role(`name=customer-`,空 scopes)。 +4. 用 `/api/admin/user/update` 把 user 绑定到新 role(显式传 role ID,勿留空)。 +5. 回填 `alist_user_id` / `alist_role_id` 到 NocoDB。 +6. `sync_status=synced`。 + +### WF2 — AccessGrant Create/Update(trigger: AccessGrants 记录变更) +1. 找 customer + resource path。 +2. 判断当前日期 vs start_at/expires_at。 +3. 若 active → 把该 path 并入该客专属 role 的 `permission_scopes`(permission=0)。 +4. 若非 active → 不加入。 +5. 整体 `post /api/admin/role/update` 重写 scopes(幂等)。 +6. 记录 `sync_status` / `last_sync_at` / `last_sync_error`。 + +### WF3 — Expiry Sync(schedule: every 5 min) +1. 查所有 active/scheduled grants。 +2. 计算 desired scopes(NocoDB 有效 grants 并集)。 +3. 对比 AList 实际 scopes(`role/get`)。 +4. 移除过期 scope、加入新生效 scope。 +5. 若无剩余 active grant 且政策启用 → disable 该 customer(`user/update disabled=true`)。 +6. 记录结果;失败发通知。 + +### WF4 — Full Reconciliation(schedule: daily) +- desired(NocoDB) vs actual(AList) 全量 diff,修复 AList,记录 reconciliation。 + +### WF5 — Customer Suspension(trigger: status=suspended) +- `user/update disabled=true` + 清空 role scopes;恢复时重算。 + +### WF6 — Customer Delete(soft delete) +- NocoDB `status=deleted`;AList 禁用用户 + 清空 scopes,不物理删除。 + +## 幂等规则(规格 §28) + +所有操作 = get current → compare → apply diff。重跑不产生重复 user/role/scope。 +更新 scope 一律「整体重写该 role 的 `permission_scopes`」。 + +## 待定项(实施时确认) + +- n8n 内网访问 AList 的 host 名(预期 `http://alist-serve-resource:5244`)。 +- 登录 token 缓存 / 刷新策略(JWT_EXPIRE=2h → 建议每次 workflow 起手先 login)。 diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md new file mode 100644 index 0000000..e23b8c6 --- /dev/null +++ b/docs/PROJECT_STATE.md @@ -0,0 +1,93 @@ +# Project State (Handoff) — 2026-08-22 + +> 交接文档:压缩/新会话后,按此文档续做。细节见各 doc。 + +## 已完成 ✅ + +### 1. AList stock 部署(Phase 1)— 运行中 +- Container `alist-serve-resource` (xhofe/alist v3.63.0),DSM port **5244**,网络 `bridge_hoelee` +- 挂载 `/volume1/Class` `/volume1/Book` `/volume1/Comic` `/volume1/Template`(只读 → /mnt/Class 等) +- 旧 alist (v3.54.0) 仍运行在 5243,**暂不处理** +- Admin: username `hoelee`,2FA 已关闭;compose 在项目根 `docker-compose.yml` +- DSM 上从 host shell 访问内网容器要用 `localhost:5244`;n8n 容器内用 `http://alist-serve-resource:5244` + +### 2. NocoDB schema(Phase 2 重建)— 7 表最终模型 +Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离。表 + model_id + 关键点: +| 表 | model_id | 备注 | +|---|---|---| +| Customers | maqb9zgvza2te3k | plaintext password + **password_updated_at** | +| Products | molyb3pxv81l75g | default_duration_days;M2M→Resources | +| Resources | m6cm7tkxzd8m18c | alist_path | +| CustomerProducts | mvyqbv8ao7u6bct | 核心表(customer+product+expiry) | +| AccessGrants | mxdx3yhfcyej3ao | **物化状态表,不是 trigger**(禁止 loop) | +| AccessLogs | ml12zcyzlehgi21 | 审计 | +| Purchases | mwp12z9lcohhsuk | 纯记录 | + +- 建表脚本:`scripts/create_nocodb_schema.py`(幂等) +- 表/字段/链接列 ID 全表:`docs/DATABASE_SCHEMA.md`(已更新为 7 表) +- NocoDB API token(workspace 级,来自 nc_api_tokens 表 n8n 那行):在 `docs/API_INTEGRATION.md` / SETUP.md 有记(或查 NocoDB UI) + +### 3. W1 客户开通 workflow — **已实测跑通** ✅ +- 文件:`n8n/W1-customer-provision.json` +- 流程:Webhook → Extract customerId → **Load Env(step 2, 用 $env)** → Fetch 记录 → 判断 create/update → Create Role → Find Role Id → Create User(role:[roleId]) → Find User Id → Write Back +- 关键:AList user/create 传 **空 role 数组会被拒**(自动填 guest→报 "admin or guest user can not be created")。必须先 Create Role 拿自增 id,再 Create User 带 `role:[id]` +- 已 import n8n;用户曾在 UI 微调 step 2(见下方「n8n env 访问结论」模板) + +### 4. W2 CustomerProduct 权限同步 — **已改好待测试** ⏳ +- 文件:`n8n/W2-customerproduct-sync.json` +- 流程:Webhook → Extract customerProductId → Load Env → **Compute Effective Grants**(登录 AList→读 customer→读全部 active CP→读 Product 的 M2M Resources→**per-Resource MAX(expires_at)** 合并→读 AList role 现状→diff→算 newScopes) → AList Update Role Scopes → Write Back CP Sync Status +- 已修:compute 节点 `***` 损坏行、所有 HTTP 表达式改引用 `$('Load Env').item.json.X` +- ⚠️ 待用户 import + 测用例 2(1 Product 2 Resource)、用例 3(重叠取 max expiry) +- **AccessGrants 表尚未在 W2 中被写入**(设计上它是物化结果,当前版本只更新 role scopes + CP sync 状态 —— 后续版本再物化进 AccessGrants?确认设计) + +## 🔴 下一步(按序) +1. **用户测试 W2**(import 覆盖 → 用例 2、3 → AList 前端实测客户登录看目录)→ 报错贴 execution 错误 +2. 验证通过后:**expiry scheduler**(每 5 分钟对账:全量 diff NocoDB↔AList,到期的移除 scope)—— 规格 §26/§37 +3. 其余 workflow(W3-W6):Product/Resource 变更 → 重算受影响 customer;手动 sync;日志;通知 +4. 本地 git 留档 → 验证稳定后推 Gitea(git.hoelee.com) + +## 🐛 已沉淀的坑(勿再踩) +- **AList**: + - `user/create` 空 role 数组 → 400 "admin or guest"(源码 `req.Role==0 → 默认 guest` + `IsGuest()` 检查);先建 role 拿 id + - `role/create`/`update` 返回 `data:null`;create 后要 **list 按 name 定位 id** + - delete 用 `?id=` query param,不是 body + - role/list 返回 `data.content[]` + `data.total`(PageResp) + - GUEST=1 / ADMIN=2 / GENERAL=0;用户级 bit14=16384 = Check path limit + - 源码位置:`internal/op/user.go` CreateUser / `server/handles/role.go` / `internal/model/roles.go` +- **NocoDB**(`nocodb-selfhosted` skill 已更新): + - SingleSelect 不能建表内联选项;要先建空列再 PATCH `{"colOptions":{options},"dtxp":"a,b,c"}` + - Link 字段用 `type:"bt"`(belongs-to)建,`hm` 会把列名搞乱 + - base/table/column ID 全在 DATABASE_SCHEMA.md +- **n8n env 访问(v1.123.72)**(memory 已记): + - Code 节点内 `$env` ✅(**用户实测可用**,如 `$env.NOCODB_URL`) + - HTTP 节点表达式字段里 `$env` / `process.env` ❌ 都不行 + - 模式:step 2 放 "Load Env" Code 节点输出全部 env **并透传 payload 字段**,下游 HTTP 用 `$('Load Env').item.json.VAR` + - step 2 已验证模板: + ```js + const prev = $input.item.json; + return { json: { + customerId: prev.customerId, + eventType: prev.eventType, + NOCODB_URL: $env.NOCODB_URL, + NOCODB_TOKEN: $env.NOCODB_TOKEN, + T_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS, + ALIST_URL: $env.ALIST_URL, + ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME, + ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD, + ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '', + } }; + ``` + +## 📁 项目结构 +``` +alist-serve-resource/ +├── docker-compose.yml / .env.example / .gitignore +├── n8n/ W1-customer-provision.json · W2-customerproduct-sync.json · SETUP.md · W2-DESIGN.md · w2-compute.js +├── scripts/ create_nocodb_schema.py · healthcheck.sh +└── docs/ API_INTEGRATION.md · DATABASE_SCHEMA.md · INSTALL.md · CONFIGURATION.md · SECURITY.md · BACKUP_RESTORE.md · UPGRADE.md · TROUBLESHOOTING.md · PROJECT_STATE.md +``` + +## 待确认的设计点 +1. W2 当前不写 AccessGrants 表 —— 是否在验证后补物化写入?(避免 AccessGrant→n8n loop 的前提下,由 W2 单向写) +2. 密码:客户可自改 AList 密码 → NocoDB plaintext 会失真;当前不强制回写(用户已选方案 C) +3. 对账/到期只靠 n8n 定时扫描(AList 无 expiry 概念)—— 已确认 \ No newline at end of file diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..e3426a8 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,40 @@ +# 安全要求(规格 §29/§52) + +## Secrets 管理 + +- **绝不**把 token/密码写死在 `docker-compose.yml`,全部走 `.env`(已 `.gitignore`)。 +- AList admin token、NocoDB token、n8n 加密密钥都只在 `.env`。 +- `.env` 加密备份(见 BACKUP_RESTORE.md),不提交 Git。 + +## 双层防护(规格 §16/§52) + +1. **应用层**:AList role scope + Check path limit + read-only bitmask。 +2. **文件系统层**:NAS 目录以 `:ro` 只读挂载。 + +即使 AList 权限配置出错,客户也无法通过 AList 改写 NAS 文件。 + +## 网络暴露 + +| 服务 | 公网 | +|---|---| +| AList 客户入口 | ✅ 经 DSM nginx(access.hoelee.com) | +| AList Admin API | ❌ 仅内网(n8n 经 bridge_hoelee) | +| NocoDB admin | ❌ 不直连公网 | +| n8n editor | ❌ 不直连公网 | + +## 权限默认值(客户 user) + +- `permission` = `16384`(仅 Check path limit bit,所有写位=0) +- 无 upload/mkdir/rename/move/copy/delete/webdav-write/ftp-write +- 每客户专属 role,绝不共享(避免权限污染,规格 §7) + +## 路径安全(规格 §32) + +- 资源路径只允许 `/Class/...` 逻辑路径。 +- 拒绝 `../`、编码遍历、反斜杠、绝对 host 路径等。 +- NocoDB 中的 `alist_path` 需校验(n8n 侧或表单侧)。 + +## 强密码 / 2FA + +- AList admin 强密码;支持 2FA 则启用(`/api/admin/user/cancel_2fa` 已确认存在,说明 2FA 可管理)。 +- 客户密码由你设定初值,首次交付后建议客户自行改。 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..4221d3c --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,4 @@ +# 故障排查 + +> Phase 10 补全。当前已覆盖常见问题见 UPGRADE.md 的排查表。 +> 实施过程中遇到的真实问题会回填到这里。 diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md new file mode 100644 index 0000000..e11cf16 --- /dev/null +++ b/docs/UPGRADE.md @@ -0,0 +1,25 @@ +# 升级 / 故障排查 + +## 升级(规格 §36) + +1. 备份 AList data/config。 +2. 读 release notes(github.com/AlistGo/alist/releases)。 +3. 固定新版本 tag(如 `v3.64.0`),改 `docker-compose.yml` 的 `image:`。 +4. Portainer → Recreate / `docker compose pull && up -d`。 +5. 跑 `scripts/healthcheck.sh`。 +6. 跑 n8n Full Reconciliation(WF4)。 +7. 验证 sample customer 权限。 +8. 异常则回滚 tag + 还原备份。 + +## 故障排查 + +| 症状 | 排查 | +|---|---| +| container unhealthy | `docker logs alist-serve-resource`;确认 `/ping` 返回 200 | +| 挂载 MISS | 确认 NAS 目录名与 compose volumes 一致 | +| 客户看不到路径 | 检查 role `permission_scopes`、user `permission` bit 14、storage mount path | +| 客户能写文件 | 检查 user `permission` bitmask 是否误设 bit 3–7;确认挂载 `:ro` | +| n8n 连不上 alist | 确认同 `bridge_hoelee` 网络、host 名 `alist-serve-resource` | +| 到期未撤销 | 确认 WF3 每 5 min 触发;查 NocoDB `sync_status` | + +详细排查步骤 Phase 10 补全。 diff --git a/n8n/SETUP.md b/n8n/SETUP.md new file mode 100644 index 0000000..bebf41c --- /dev/null +++ b/n8n/SETUP.md @@ -0,0 +1,70 @@ +# n8n Setup — Environment Variables + +W1/W2 通过**一个 in-flow 的 "Load Env" Code 节点**(webhook 之后第 2 步)读取全部 env, +下游所有节点引用 `$('Load Env').item.json.VAR`。secrets 不硬编码进 workflow。 + +## 读取方式(已实测确认) + +- **Code 节点内**:`$env.VAR` 可用(例 `$env.NOCODB_URL`)。✔ +- **HTTP 节点表达式字段**(url / header / body expression):`$env` 和 `process.env` **都不可用**。✘ +- 所以用**一个 Code 节点 "Load Env"** 在 `$env` 里调取全部 env 输出到 `$json`, + 下游 HTTP 节点用 `{{ $('Load Env').item.json.VAR }}` 取值。 + +> 关键点: +> - Load Env **必须接在 webhook flow 里(第 2 步)**,不在 flow 里的 Code 节点不会执行。 +> - Load Env **必须透传传入的字段**(`customerId` / `customerProductId`),否则下游丢数据。 + +### Load Env 模板(实测可用) + +```js +// Step 2: read ALL env via $env (works in Code node), and pass through +// the webhook-derived fields so downstream keeps customerId. +const prev = $input.item.json; +return { json: { + customerId: prev.customerId, + eventType: prev.eventType, + NOCODB_URL: $env.NOCODB_URL, + NOCODB_TOKEN: $env.NOCODB_TOKEN, + T_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS, + ALIST_URL: $env.ALIST_URL, + ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME, + ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD, + ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '', +} }; +``` + +## 需要配置的 env(n8n 容器环境变量) + +``` +NOCODB_URL=https://nocodb.hoelee.com +NOCODB_TOKEN= +NOCODB_TABLE_CUSTOMERS=maqb9zgvza2te3k +NOCODB_TABLE_PRODUCTS=molyb3pxv81l75g +NOCODB_TABLE_RESOURCES=m6cm7tkxzd8m18c +NOCODB_TABLE_CUSTOMERPRODUCTS=mvyqbv8ao7u6bct +NOCODB_TABLE_ACCESSGRANTS=mxdx3yhfcyej3ao +NOCODB_LINK_PROD_RESOURCES=c4ko4x8kwi16swg +ALIST_URL=http://alist-serve-resource:5244 +ALIST_ADMIN_USERNAME=hoelee +ALIST_ADMIN_PASSWORD= +``` + +改完 env 必须**重建 n8n 容器**(不是 restart)。 + +## 导入步骤 + +1. n8n → Import from File → `W1-customer-provision.json` / `W2-customerproduct-sync.json`。 +2. Webhook 节点 → Copy Webhook URL。 +3. NocoDB → 对应表 → 建 Webhook(After Insert + After Update)粘 URL: + - Customers 表 → W1;CustomerProducts 表 → W2。 + +## AList user 创建顺序(重要) + +`user/create` 传空 `role` 会失败(`admin or guest user can not be created`,因 AList 把空 role +自动填成 guest 后拒绝)。**必须先 `role/create` 建客户专属 role,再 `user/create` 传 `role:[新roleId]`。** +详见 `docs/API_INTEGRATION.md` §9.7。 + +## 验证状态 + +- ✅ W1 已实测跑通(create user 自动化成功)。 +- W2 待测(同 Load Env 模式已套用)。 diff --git a/n8n/W1-customer-provision.json b/n8n/W1-customer-provision.json new file mode 100644 index 0000000..45d1526 --- /dev/null +++ b/n8n/W1-customer-provision.json @@ -0,0 +1,591 @@ +{ + "name": "W1 - Customer Provision (AList User + Role)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "alist-customer-provision", + "responseMode": "onReceived", + "options": {} + }, + "id": "trigger-webhook", + "name": "NocoDB: Customer Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "customer-provision" + }, + { + "parameters": { + "jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '' } };\n" + }, + "id": "extract-id", + "name": "Extract Customer Record Id", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "jsCode": "// Step 2: read ALL env via process.env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerId.\nconst e = process.env;\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: e.NOCODB_URL,\n NOCODB_TOKEN: e.NOCODB_TOKEN,\n T_CUSTOMERS: e.NOCODB_TABLE_CUSTOMERS,\n ALIST_URL: e.ALIST_URL,\n ALIST_ADMIN_USERNAME: e.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: e.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: e.ALIST_ADMIN_OTP || '',\n} };\n" + }, + "id": "load-env", + "name": "Load Env", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records/{{ $json.customerId }}", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "xc-token", + "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "fetch-customer", + "name": "Fetch Latest Customer Record", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 440, + 0 + ] + }, + { + "parameters": { + "jsCode": "const cust = $input.item.json;\nconst username = cust.username; const password = cust.password;\nif (!username) throw new Error('Customer missing username');\nreturn { json: { customerId: cust.Id ?? cust.id, username, password, displayName: cust.display_name ?? username, basePath: cust.base_path ?? '/Class', alistUserId: cust.alist_user_id ?? null, alistRoleId: cust.alist_role_id ?? null, isCreate: !(cust.alist_user_id) } };\n" + }, + "id": "prepare", + "name": "Prepare Provision Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/auth/login", + "authentication": "none", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ username: $('Load Env').item.json.ALIST_ADMIN_USERNAME, password: $('Load Env').item.json.ALIST_ADMIN_PASSWORD }) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-login", + "name": "AList Admin Login", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + 0 + ] + }, + { + "parameters": { + "jsCode": "const resp = $input.item.json;\nconst token = resp?.data?.token;\nif (!token) throw new Error('AList admin login failed: ' + JSON.stringify(resp));\nreturn { json: { token } };\n" + }, + "id": "extract-token", + "name": "Extract Admin Token", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1100, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeName": "boolean", + "ignoreCase": true + }, + "conditions": [ + { + "id": "cond-create", + "leftValue": "={{ $('Prepare Provision Payload').item.json.isCreate }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals", + "singleValue": true, + "rightType": "boolean" + } + } + ], + "combinator": "or" + }, + "options": {} + }, + "id": "branch-create-update", + "name": "Create vs Update", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1320, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/create", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $('Extract Admin Token').item.json.token }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ name: 'customer-' + $('Prepare Provision Payload').item.json.username, description: 'Auto role for ' + $('Prepare Provision Payload').item.json.username, default: false, permission_scopes: [] }) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-create-role", + "name": "AList Create Role", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1540, + -240 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/list?page=1&per_page=100", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $('Extract Admin Token').item.json.token }}" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-list-roles", + "name": "AList List Roles (find id)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1760, + -240 + ] + }, + { + "parameters": { + "jsCode": "const roles = $input.item.json?.data?.content ?? [];\nconst target = 'customer-' + $('Prepare Provision Payload').item.json.username;\nconst r = roles.find(x => x.name === target);\nif (!r) throw new Error('Role not found: ' + target);\nreturn { json: { alistRoleId: r.id, roleName: target } };\n" + }, + "id": "find-role-id", + "name": "Find New Role Id", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1980, + -240 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/create", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $('Extract Admin Token').item.json.token }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: [$('Find New Role Id').item.json.alistRoleId], permission: 16384 }) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-create-user", + "name": "AList Create User", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2200, + -240 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/list?page=1&per_page=100", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $('Extract Admin Token').item.json.token }}" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-list-users", + "name": "AList List Users (find id)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2420, + -240 + ] + }, + { + "parameters": { + "jsCode": "const users = $input.item.json?.data?.content ?? [];\nconst target = $('Prepare Provision Payload').item.json.username;\nconst u = users.find(x => x.username === target);\nif (!u) throw new Error('Created user not found: ' + target);\nreturn { json: { alistUserId: u.id, username: target } };\n" + }, + "id": "find-user-id", + "name": "Find New User Id", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2640, + -240 + ] + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "xc-token", + "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, alist_user_id: $('Find New User Id').item.json.alistUserId, alist_role_id: $('Find New Role Id').item.json.alistRoleId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}", + "options": {} + }, + "id": "nocodb-writeback-create", + "name": "Write Back (Created)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2860, + -240 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/update", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $('Extract Admin Token').item.json.token }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ id: $('Prepare Provision Payload').item.json.alistUserId, username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: $('Prepare Provision Payload').item.json.alistRoleId ? [$('Prepare Provision Payload').item.json.alistRoleId] : [], permission: 16384 }) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-update-user", + "name": "AList Update User", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1540, + 0 + ] + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "xc-token", + "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}", + "options": {} + }, + "id": "nocodb-writeback-update", + "name": "Write Back (Updated)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1760, + 0 + ] + } + ], + "connections": { + "NocoDB: Customer Webhook": { + "main": [ + [ + { + "node": "Extract Customer Record Id", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Customer Record Id": { + "main": [ + [ + { + "node": "Load Env", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Latest Customer Record": { + "main": [ + [ + { + "node": "Prepare Provision Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Provision Payload": { + "main": [ + [ + { + "node": "AList Admin Login", + "type": "main", + "index": 0 + } + ] + ] + }, + "AList Admin Login": { + "main": [ + [ + { + "node": "Extract Admin Token", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Admin Token": { + "main": [ + [ + { + "node": "Create vs Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create vs Update": { + "main": [ + [ + { + "node": "AList Create Role", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "AList Update User", + "type": "main", + "index": 0 + } + ] + ] + }, + "AList Create Role": { + "main": [ + [ + { + "node": "AList List Roles (find id)", + "type": "main", + "index": 0 + } + ] + ] + }, + "AList List Roles (find id)": { + "main": [ + [ + { + "node": "Find New Role Id", + "type": "main", + "index": 0 + } + ] + ] + }, + "Find New Role Id": { + "main": [ + [ + { + "node": "AList Create User", + "type": "main", + "index": 0 + } + ] + ] + }, + "AList Create User": { + "main": [ + [ + { + "node": "AList List Users (find id)", + "type": "main", + "index": 0 + } + ] + ] + }, + "AList List Users (find id)": { + "main": [ + [ + { + "node": "Find New User Id", + "type": "main", + "index": 0 + } + ] + ] + }, + "Find New User Id": { + "main": [ + [ + { + "node": "Write Back (Created)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Back (Created)": { + "main": [] + }, + "AList Update User": { + "main": [ + [ + { + "node": "Write Back (Updated)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Back (Updated)": { + "main": [] + }, + "Load Env": { + "main": [ + [ + { + "node": "Fetch Latest Customer Record", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/n8n/W2-DESIGN.md b/n8n/W2-DESIGN.md new file mode 100644 index 0000000..daf9e97 --- /dev/null +++ b/n8n/W2-DESIGN.md @@ -0,0 +1,30 @@ +# W2 — CustomerProduct 变更 → 同步 AList Role Scopes + +事件驱动:NocoDB CustomerProducts 表 webhook → n8n webhook → 重取记录 → 全量重算该 customer 的 effective grants → diff AList role scopes → apply → 物化 AccessGrants + 回写 sync_status。 + +## 核心逻辑(desired-state,规格 §19/§37) + +1. webhook 只带 record Id,n8n `GET /api/v2/tables/{CustomerProducts}/records/{Id}` 重取完整记录。 +2. 从记录解析 `customer` link(NocoDB link 字段返回关联行 Id + display value); + 若返回对象数组,取第一个的 `Id`。 +3. 拉取该 customer 所有 **status=active 且未过期** 的 CustomerProducts。 +4. 每个 CP → `product` link → Product;再走 Products 的 M2M link 列 + `c4ko4x8kwi16swg`(`GET /api/v2/tables/{Products}/links/c4ko4x8kwi16swg/records/{pid}`) + 取该 Product 的全部 Resources(含 `alist_path`)。 +5. 汇总:`alist_path -> MAX(expires_at)`(多 Product 重叠取最晚;规格 §18「取最大值」)。 +8. 取 customer 的 `alist_role_id` → `GET /api/admin/role/get?id=` 读当前 scopes。 +9. diff:desired paths vs 当前 scopes → 要加 / 要删。 +10. `POST /api/admin/role/update` 整体重写 `permission_scopes`(幂等)。 +11. 物化 AccessGrants:删掉该 customer 的 product 源 grants,重插 desired(manual 源保留不动)。 +12. 回写 CustomerProducts 各记录 `sync_status=synced` + `last_sync`。 + +## 关键 fact(已验证) +- NocoDB link 字段在 v2 records GET 返回形如 `{"customer": {"Id": 5, "username": "..."}}` 或 `[{"Id":5}]`; + Code 节点需兼容两种。 +- AList role create/update 返回 `data:null`;role/get 可回读 scopes。 +- `permission=0`(read-only bitmask);role scope 用 `{path, permission:0}`。 +- M2M link col id: Products.Resources = `c4ko4x8kwi16swg`。 + +## 首期不做 +- expiry 定时扫描/移除(W1/W2 验证后再加) +- 密码 change-detection 的 password_updated_at 触发逻辑(W1 已留字段) diff --git a/n8n/W2-customerproduct-sync.json b/n8n/W2-customerproduct-sync.json new file mode 100644 index 0000000..64cfcf8 --- /dev/null +++ b/n8n/W2-customerproduct-sync.json @@ -0,0 +1,184 @@ +{ + "name": "W2 - CustomerProduct Sync (AList Role Scopes)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "alist-cp-sync", + "responseMode": "onReceived", + "options": {} + }, + "id": "trigger-webhook", + "name": "NocoDB: CustomerProduct Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "cp-sync" + }, + { + "parameters": { + "jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CP record Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n" + }, + "id": "extract-id", + "name": "Extract CP Record Id", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "jsCode": "// Step 2: read ALL env via $env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerProductId.\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n T_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',\n} };\n" + }, + "id": "load-env", + "name": "Load Env", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "jsCode": "// W2 core computation: effective desired grants + diff vs AList.\n// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.\n// Config read via $env.\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUSTPROD = $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET',\n url: NC + path,\n headers: { 'xc-token': NC_TOKEN },\n json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method,\n url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch the CP record\nconst cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);\nconst customerId = linkId(cp.customer);\nif (!customerId) throw new Error('CP has no customer link');\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n return true;\n});\n\n// 3. build resource path -> max expires_at\nconst effective = {};\nfor (const c of active) {\n const pid = linkId(c.product);\n if (!pid) continue;\n const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);\n const exp = c.expires_at; // 'YYYY-MM-DD'\n for (const res of (resList.list || [])) {\n const path = res.alist_path;\n if (!path) continue;\n const cur = effective[path];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n effective[path] = { expires: exp || null, permission: 0 };\n }\n }\n}\n\n// 4. admin login\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// customer's role id\nconst cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\n\nlet currentScopes = [];\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n currentScopes = role?.data?.permission_scopes ?? [];\n}\n\nconst desiredPaths = Object.keys(effective);\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n\nlet roleName = null, roleDesc = null;\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n roleName = role?.data?.name;\n roleDesc = role?.data?.description;\n}\n\nreturn {\n json: {\n customerId,\n roleId,\n roleName,\n roleDesc,\n token,\n effective,\n desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),\n },\n};\n" + }, + "id": "compute", + "name": "Compute Effective Grants", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/update", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $('Compute Effective Grants').item.json.token }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ id: $('Compute Effective Grants').item.json.roleId, name: $('Compute Effective Grants').item.json.roleName, description: $('Compute Effective Grants').item.json.roleDesc, permission_scopes: $('Compute Effective Grants').item.json.newScopes }) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + } + } + }, + "id": "alist-apply-role", + "name": "AList Update Role Scopes", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 660, + 0 + ] + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERPRODUCTS }}/records", + "authentication": "none", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "xc-token", + "value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}", + "options": {} + }, + "id": "nocodb-writeback", + "name": "Write Back CP Sync Status", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + 0 + ] + } + ], + "connections": { + "NocoDB: CustomerProduct Webhook": { + "main": [ + [ + { + "node": "Extract CP Record Id", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract CP Record Id": { + "main": [ + [ + { + "node": "Load Env", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Effective Grants": { + "main": [ + [ + { + "node": "AList Update Role Scopes", + "type": "main", + "index": 0 + } + ] + ] + }, + "AList Update Role Scopes": { + "main": [ + [ + { + "node": "Write Back CP Sync Status", + "type": "main", + "index": 0 + } + ] + ] + }, + "Load Env": { + "main": [ + [ + { + "node": "Compute Effective Grants", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "pinData": {} +} \ No newline at end of file diff --git a/n8n/w2-compute.js b/n8n/w2-compute.js new file mode 100644 index 0000000..31257c1 --- /dev/null +++ b/n8n/w2-compute.js @@ -0,0 +1,110 @@ +// W2 core computation: effective desired grants + diff vs AList. +// Uses this.helpers.httpRequest (n8n Code node) for all HTTP. +// Config read via process.env (n8n $env proxy is unreliable for custom vars). +const NC = process.env.NOCODB_URL; +const NC_TOKEN = process.env.NOCODB_TOKEN; +const ALIST = process.env.ALIST_URL; +const T_CUSTPROD = process.env.NOCODB_TABLE_CUSTOMERPRODUCTS; +const T_PROD = process.env.NOCODB_TABLE_PRODUCTS; +const T_CUST = process.env.NOCODB_TABLE_CUSTOMERS; +const LNK_PROD_RES = process.env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg + +async function ncGet(path) { + return await this.helpers.httpRequest({ + method: 'GET', + url: NC + path, + headers: { 'xc-token': NC_TOKEN }, + json: true, + }); +} +async function alistReq(method, path, body, token) { + return await this.helpers.httpRequest({ + method, + url: ALIST + path, + headers: token ? { Authorization: token } : {}, + body, + json: true, + }); +} +function linkId(v) { + if (v == null) return null; + if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null; + if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null; + return v; +} + +const custProdId = $json.customerProductId; + +// 1. re-fetch the CP record +const cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`); +const customerId = linkId(cp.customer); +if (!customerId) throw new Error('CP has no customer link'); + +// 2. load all active CPs for this customer +const cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`); +const active = (cps.list || []).filter(r => { + if (r.status && r.status !== 'active') return false; + return true; +}); + +// 3. build resource path -> max expires_at +const effective = {}; +for (const c of active) { + const pid = linkId(c.product); + if (!pid) continue; + const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`); + const exp = c.expires_at; // 'YYYY-MM-DD' + for (const res of (resList.list || [])) { + const path = res.alist_path; + if (!path) continue; + const cur = effective[path]; + if (!cur || !cur.expires || (exp && exp > cur.expires)) { + effective[path] = { expires: exp || null, permission: 0 }; + } + } +} + +// 4. admin login +const login = await alistReq('POST', '/api/auth/login', { + username: process.env.ALIST_ADMIN_USERNAME, + password: process.env.ALIST_ADMIN_PASSWORD, +}, null); +const token = login?.data?.token; +if (!token) throw new Error('AList login failed'); + +// customer's role id +const cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`); +const roleId = cust.alist_role_id; + +let currentScopes = []; +if (roleId) { + const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token); + currentScopes = role?.data?.permission_scopes ?? []; +} + +const desiredPaths = Object.keys(effective); +const currentPaths = currentScopes.map(s => s.path); +const toAdd = desiredPaths.filter(p => !currentPaths.includes(p)); +const toRemove = currentPaths.filter(p => !desiredPaths.includes(p)); + +let roleName = null, roleDesc = null; +if (roleId) { + const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token); + roleName = role?.data?.name; + roleDesc = role?.data?.description; +} + +return { + json: { + customerId, + roleId, + roleName, + roleDesc, + token, + effective, + desiredPaths, + currentScopes, + diff: { toAdd, toRemove }, + newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })), + }, +}; diff --git a/scripts/create_nocodb_schema.py b/scripts/create_nocodb_schema.py new file mode 100644 index 0000000..d8afddc --- /dev/null +++ b/scripts/create_nocodb_schema.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Create the AlistAccess NocoDB base + 7-table schema (idempotent). + +Verified against NocoDB 2026.08.1 on DSM. Run from any machine with SSH to DSM, +or adapt to point NocoDB URL directly. Uses workspace XC-TOKEN. + +Usage: + python create_nocodb_schema.py + +Env/config at top of file. Idempotent: delete + recreate base each run. +""" +import paramiko, json, base64 + +# ---- CONFIG ---- +SSH_HOST = "192.168.1.1" +SSH_PORT = 9998 +SSH_USER = "hoelee" +SSH_PASS = "CHANGE_ME_ssh_password" +NOCODB_URL = "http://localhost:10380" # reached via SSH on DSM +NOCODB_TOKEN = "CHANGE_ME_nocodb_token" +# ---------------- + + +def make_ssh(): + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(SSH_HOST, port=SSH_PORT, username=SSH_USER, password=SSH_PASS, timeout=15) + return c + + +def main(): + ssh = make_ssh() + def run(cmd): + i, o, e = ssh.exec_command(cmd) + return o.read().decode() + e.read().decode() + + def curl(method, path, body=None): + h = f'-H "xc-token: {NOCODB_TOKEN}"' + d = "" + if body is not None: + b64 = base64.b64encode(json.dumps(body).encode()).decode() + run(f"echo '{b64}' | base64 -d > /tmp/nc_payload.json") + d = "--data-binary @/tmp/nc_payload.json -H 'Content-Type: application/json'" + out = run(f'curl -s {h} -X {method} {d} "{NOCODB_URL}{path}"') + try: + return json.loads(out) + except Exception: + print("RAW:", out[:600]); return None + + def jget(r, *keys, d=None): + try: + for k in keys: + r = r[k] + return r + except Exception: + return d + + # 1. drop existing base if any (find by title) + bases = curl("GET", "/api/v2/meta/bases/") + for b in jget(bases, "list", d=[]) or []: + if b.get("title") == "AlistAccess": + curl("DELETE", f"/api/v2/meta/bases/{b['id']}") + print("deleted old AlistAccess base", b["id"]) + + # 2. create base + b = curl("POST", "/api/v2/meta/bases/", {"title": "AlistAccess"}) + base_id = b["id"] + print("BASE:", base_id) + + def create_table(title, columns): + return curl("POST", f"/api/v2/meta/bases/{base_id}/tables", + {"title": title, "table_name": title, "type": "table", "columns": columns}) + + def add_col(tid, title, uidt, **extra): + body = {"title": title, "uidt": uidt} + body.update(extra) + return curl("POST", f"/api/v1/db/meta/tables/{tid}/columns", body) + + def set_options(colid, opts): + return curl("PATCH", f"/api/v1/db/meta/columns/{colid}", + {"colOptions": {"options": [{"title": o, "order": i + 1, "color": None} + for i, o in enumerate(opts)]}, + "dtxp": ",".join(f"'{o}'" for o in opts)}) + + def add_ss(tid, title, opts): + r = add_col(tid, title, "SingleSelect") + cid = jget(r, "id") + set_options(cid, opts) + return cid + + def add_link(child_tid, title, parent_tid, type_="bt"): + return add_col(child_tid, title, "LinkToAnotherRecord", + parentId=parent_tid, childId=child_tid, type=type_) + + # ---- tables ---- + cust = create_table("Customers", [ + {"title": "username", "uidt": "SingleLineText"}, + {"title": "password", "uidt": "SingleLineText"}, + {"title": "display_name", "uidt": "SingleLineText"}, + {"title": "email", "uidt": "Email"}, + {"title": "notes", "uidt": "LongText"}, + {"title": "alist_user_id", "uidt": "Number"}, + {"title": "alist_role_id", "uidt": "Number"}, + {"title": "last_sync", "uidt": "DateTime"}, + {"title": "last_sync_error", "uidt": "LongText"}, + ]); cust = jget(cust, "id") + add_ss(cust, "status", ["pending","active","suspended","expired","disabled","deleted"]) + add_ss(cust, "sync_status", ["pending","syncing","synced","error","unknown"]) + add_col(cust, "sync_required", "Checkbox") + + prod = create_table("Products", [ + {"title": "name", "uidt": "SingleLineText"}, + {"title": "description", "uidt": "LongText"}, + {"title": "default_duration_days", "uidt": "Number"}, + ]); prod = jget(prod, "id") + add_ss(prod, "status", ["active","inactive"]) + + res = create_table("Resources", [ + {"title": "name", "uidt": "SingleLineText"}, + {"title": "alist_path", "uidt": "SingleLineText"}, + {"title": "description", "uidt": "LongText"}, + ]); res = jget(res, "id") + add_ss(res, "status", ["active","inactive"]) + + cp = create_table("CustomerProducts", [ + {"title": "start_at", "uidt": "Date"}, + {"title": "expires_at", "uidt": "Date"}, + {"title": "last_sync", "uidt": "DateTime"}, + {"title": "last_sync_error", "uidt": "LongText"}, + ]); cp = jget(cp, "id") + add_ss(cp, "status", ["scheduled","active","expired","revoked","suspended"]) + add_ss(cp, "sync_status", ["pending","syncing","synced","error","unknown"]) + add_col(cp, "sync_required", "Checkbox") + + ag = create_table("AccessGrants", [ + {"title": "start_at", "uidt": "Date"}, + {"title": "expires_at", "uidt": "Date"}, + {"title": "revoked_at", "uidt": "DateTime"}, + {"title": "revoke_reason", "uidt": "LongText"}, + {"title": "last_sync", "uidt": "DateTime"}, + {"title": "last_sync_error", "uidt": "LongText"}, + ]); ag = jget(ag, "id") + add_ss(ag, "permission", ["read","readwrite"]) + add_ss(ag, "source", ["product","manual"]) + add_ss(ag, "status", ["pending","active","revoked","expired"]) + add_ss(ag, "sync_status", ["pending","syncing","synced","error","unknown"]) + add_col(ag, "sync_required", "Checkbox") + + al = create_table("AccessLogs", [ + {"title": "username", "uidt": "SingleLineText"}, + {"title": "resource_path", "uidt": "SingleLineText"}, + {"title": "file_path", "uidt": "SingleLineText"}, + {"title": "ip_address", "uidt": "SingleLineText"}, + {"title": "user_agent", "uidt": "LongText"}, + {"title": "bytes", "uidt": "Number"}, + {"title": "timestamp", "uidt": "DateTime"}, + ]); al = jget(al, "id") + add_ss(al, "action", ["login","view","download","preview","logout"]) + add_ss(al, "result", ["success","denied"]) + + pur = create_table("Purchases", [ + {"title": "purchase_date", "uidt": "Date"}, + {"title": "amount", "uidt": "Decimal"}, + {"title": "payment_method", "uidt": "SingleLineText"}, + {"title": "order_id", "uidt": "SingleLineText"}, + {"title": "notes", "uidt": "LongText"}, + ]); pur = jget(pur, "id") + + # ---- links ---- + add_link(cp, "customer", cust) + add_link(cp, "product", prod) + add_link(prod, "Resources", res, type_="mm") # Products <-> Resources M2M + add_link(ag, "customer", cust) + add_link(ag, "resource", res) + add_link(al, "customer", cust) + add_link(pur, "customer", cust) + add_link(pur, "product", prod) + + ids = {"base": base_id, "Customers": cust, "Products": prod, "Resources": res, + "CustomerProducts": cp, "AccessGrants": ag, "AccessLogs": al, "Purchases": pur} + print("DONE. IDs:", json.dumps(ids, indent=2)) + # persist for later steps + open("_nocodb_ids.json", "w").write(json.dumps(ids, indent=2)) + ssh.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100644 index 0000000..a4c32bc --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# AList Serve Resource 健康检查 +# 用法: ./scripts/healthcheck.sh [ALIST_INTERNAL_URL] +set -euo pipefail + +ALIST_URL="${1:-http://localhost:5247}" + +echo "== AList container ==" +docker inspect --format '状态: {{.State.Status}} 健康: {{.State.Health.Status}}' alist-serve-resource 2>/dev/null || echo "container 未运行" + +echo "== HTTP 探活 ==" +code=$(curl -s -o /dev/null -w '%{http_code}' "${ALIST_URL}/ping" || echo "ERR") +echo "GET /ping -> ${code} (期望 200)" + +echo "== 挂载检查 ==" +docker exec alist-serve-resource sh -c 'for m in /mnt/Class /mnt/Book /mnt/Comic /mnt/Template; do \ + if [ -d "$m" ]; then echo "OK $m"; else echo "MISS $m"; fi; done' 2>/dev/null || echo "无法 exec(container 未运行或无 shell)"