feat(meowpanel): 通知中心+文件管理后端补全 (agent-182) — /api/notifications(读TF归档,环形200条)+单删+dismiss_all(保留媒体/进度活动通知); 文件域修4bug(normalize_path丢前导/致白名单必失败、upload multipart 文件名截断恒NULL、rename拼错路径恒ENOTDIR、read>1MB改truncated) + 上传8MB上限+文件名净化+双重白名单防逃逸

meowpanel 8108852f→58a7b186; mipsel/host -Wall -Wextra 0 warning; 宿主 curl 自测全过
需launcher配合项(已记录): MSG_NOTIFY_QUERY 查询帧 + dismiss 广播 IPC
This commit is contained in:
MeowOS committed 2026-08-31 04:14:00 +08:00
1 parent c187b7bce3
commit 563a9be1d6
5 files changed
+438 -75

No files matched your search

+44 -9
View File
@@ -102,18 +102,22 @@ http://<host>.local:8080/
| POST | `/api/key` | 批量/顺序注入 | ✅ |
| GET | `/api/logs` | `MeowOS/logs` 文件列表 | ✅(无卡→503) |
| GET | `/api/logs/read?file=&lines=` | 读日志(上限 1000 行;`..`/绝对→403;敏感名→403) | ✅ |
| GET | `/api/notifications` | **501** | ⚠️ |
| DELETE | `/api/notifications/:id` | **501** | ⚠️ |
| POST | `/api/notifications/dismiss_all` | **501** | ⚠️ |
| GET | `/api/notifications` | 通知列表(读 TF 转储日志;见 §5.8) | ✅(无源→空态 `source:none`,不 503) |
| DELETE | `/api/notifications/:id` | 单条删除(重写日志移除该 id) | ✅(不存在→404) |
| POST | `/api/notifications/dismiss_all`(别名 `/api/notifications/clear`) | 一键清空:**保留活动中的媒体/进度通知**,移除其余 | ✅ |
| GET | `/api/files/read?path=` | 读文本:预览上限 1MB,超限 `truncated:true`(不再 404);二进制→415 | ✅ |
### 返回 501 `NOT_IMPLEMENTED` 的端点(诚实标注)
| 端点 | 原因 |
|---|---|
| `/api/notifications`、`/api/notifications/:id`、`/api/notifications/dismiss_all` | 通知存储为 **launcher 进程内存**(`meow_notif.c g_slots[]`,NOTIFICATION_SYSTEM §4),后端为独立 svcd 子进程,**跨进程不可达**那批内存槽;框架只在关机前 dump 到 TF `MeowOS/notifications/notifications.log`(CSV 追加,非活跃快照)。**需为通知后端另开 IPC 查询/清撤通道**(如 `MSG_NOTIFY_LIST` 应答)或持久化活跃集,见 MEOWPANEL_PROTOCOL §6 待定案。 |
| `/api/key/:code` 的 `press:"hold"` | `hold` 需常驻持续注入通道(阶段 D),后端未实现;`tap`/`long` 已实现。 |
| OTA 升级模式(`/api/system/ota` 带 `url`) | 升级为草案(MEOWPANEL_PROTOCOL §6 待定案);当前 `POST /api/system/ota` 空 body 触发 `meow_svc_power` 的 `POWER_UPGRADE`(写 mtd5 进升级模式),带 `url` 的下载+升级未实现。 |
> 通知端点已从 501 落地为「读 TF 卡通知转储日志」实现。因 launcher 侧 **`MSG_NOTIFY_LIST` 为下行推送(守护→App),
> 无「客户端查询通知清单」请求类型**(launcher `util.c` `case MSG_NOTIFY_LIST: "not dispatched here"`),后端(svcd 独立进程)
> 无法经 IPC 取活跃清单,故回退到 TF 转储文件。详见 §5.8 与输出「需 launcher 侧配合项」。
> 前端对 `501` 显示「该功能后端尚未实现」(`ok:false,error.code=NOT_IMPLEMENTED`)。
---
@@ -189,9 +193,23 @@ http://<host>.local:8080/
- `GET /api/logs/read?file=&lines=`:`file` 必须相对 `MeowOS/logs/`,**禁 `..`/绝对路径 → 403**,敏感名 → 403;
缺省 `lines=200`,上限 `1000`;返回 `{file,lines[],total,truncated}`。
### 5.8 通知(501 及其依据)
见 §4 501 表:通知存储为 launcher 进程内存,后端跨进程不可读/清撤。已在源码 `api_notifications*` 注释「为何未实现 +
后续如何接」(通知后端加 `MSG_NOTIFY_LIST` 查询/清撤 IPC,或持久化活跃集到 TF `MeowOS/notifications/` 供只读)。
### 5.8 通知(数据源 = TF 卡通知转储日志)
- **数据源优先级**:① launcher IPC 查询 →② TF 转储文件 →③ 空态。当前 ① 不可用(launcher 侧 `MSG_NOTIFY_LIST` 仅下行推送、
无客户端查询请求类型,见「需 launcher 侧配合项」),故以 ② 为准。
- **TF 转储文件**:`<卡根>/MeowOS/notifications/notifications.log`(launcher `meow_notif_dump_tf` 一行一条 CSV,
字段 `timestamp,id,app_id,app_icon,category,urgency,progress,title,body,target_activity,ongoing,media,dismissed`;
`meow_notif_clear_tf` 开机清空 → 会话级归档)。后端逐行流式读取(环形保留最新 `NOTIF_PREVIEW_MAX=200` 条,不全量进内存)。
- **`GET /api/notifications`**:返回 `{count, items:[...], notifications:[...], source}`;`items`= `notifications` 别名
(兼容前端读 `.notifications` 与任务约定空态 `items:[]`)。字段含 `id/app_id/app_name/app_icon/category/summary/body/
urgency/ts/progress/ongoing/media/target_activity/dismissed`。`progress<0`→`null`;`app_name` 对 `system`→`系统`,其余用 `app_id` 兜底。
无源(无卡/文件不存在)→ `{count:0,items:[],notifications:[],source:"none"}`(**不 503、不伪造数据**)。
- **`DELETE /api/notifications/:id`**:从日志移除该行(重写临时文件后 rename)。不存在 → 404。
- **`POST /api/notifications/dismiss_all`(别名 `/api/notifications/clear`)**:重写日志,**只保留活动中的媒体/进度通知**
(`ongoing||media||progress>=0||category=="media"`),移除其余;返回 `{dismissed:<移除数>}`。
- **语义边界**:日志是**归档快照**(归档时 `dismissed` 尚未置 1,故该列恒 ≈0 不可靠),非活跃快照;且面板清空只改
日志视图,**不联动 launcher 进程内存**(launcher 通知中心仍显示其内存活跃集)。无 launcher 查询/清撤 IPC 前的诚实降级。
- **并发**:面板清空/删除用「写临时 + rename」重写日志;launcher 侧仍以 `O_APPEND` 追加,二者为竞写(低并发、会话级日志,
可接受),已写入「已知边界」(§7)。
---
@@ -208,6 +226,17 @@ http://<host>.local:8080/
5. **`/api/files` 上传/删除/重命名等 path**:前端对这些操作传**绝对路径**(`d.path/子项`),列表传**相对 root**;
后端 `normalize_path` 对两者均容错(相对则拼 root,绝对则直用),符合协议「白名单根 + 规范化」。
6. **`GET /api/files` 返回值**:`files_list` 返回 `500` 语义时以 500 封装(协议未定义 `root` 之外错误枚举,用通用错误信封)。
7. **通知清单数据源**:协议 §3.8 称后端「经通知后端读取」;因 launcher 无「客户端查询通知清单」IPC(`MSG_NOTIFY_LIST` 仅下行),
后端**改读 TF 卡通知转储日志**(`MeowOS/notifications/notifications.log`)。响应额外带 `source`(`tf`/`none`)与 `items` 别名
(与 `notifications` 同内容);`items` 为任务约定空态形状,`notifications` 为前端实际读取字段,二者并存。`clear` 语义为
「保留活动中的媒体/进度通知」(`ongoing||media||progress>=0`),对应通知中心既定语义。
8. **`/api/files/read` 文本截断**:协议 §3.4 称「仅文本(<1MB)→ 二进制 415」;现实现为「预览上限 1MB,超限 `truncated:true`,
不再整体 404」,二进制仍 415(比协议更友好,返回文件真实 `size` + `truncated`)。
9. **`/api/files/upload`/`rename` 修正**:上传文件名经 `safe_filename` 取 basename、拒绝 `..`/分隔符(防 `../../etc/passwd` 逃逸),
落盘前对 dir 与拼接后路径双重 `normalize_path`+白名单校验;rename 的新路径 = **原路径父目录** + `new_name`(原实现误拼到文件尾部,
恒 ENOTDIR 失败)。上传体上限 `MAX_UPLOAD_BODY`=8MB,超限 → 413 `PAYLOAD_TOO_LARGE`。
10. **TF 卡根可覆盖(宿主测试)**:`env MEOW_TF_ROOT` 覆盖卡根(设备上不设,恒用 `/usr/data/mnt/sd_0`);`env MEOW_NOTIF_LOG`
覆盖通知转储日志路径。设 `MEOW_TF_ROOT` 时 `tf_present()` 恒真(测试用)。
---
@@ -223,6 +252,12 @@ http://<host>.local:8080/
- **`/api/files/upload` 上传路径**:multipart 需要 `path` 字段(目标目录);缺失时上传文件会被忽略(前端总是带着 `path`)。
- **数据一致性**:设置直接写 `settings.conf`;`display.brightness`/`boot.default` 的 clamp/同步与 launcher `meow_settings_*` 同口径。
若 launcher 未来改 clamp 逻辑,需同步本表。
- **通知视图为「会话级归档」**:TF 转储日志由 launcher 开机清空、每通知归档时追加一次;非活动(非 ongoing/无进度)通知
10s TTL 自动归档后仍留在日志,故面板会**显示已自动过期但未手动清理的历史通知**(直到 clear)。媒体/进度/ongoing 通知
持续保留。无 launcher 查询 IPC 前的诚实降级(见 §5.8)。
- **通知清空不联动 launcher 内存**:面板 clear/delete 只重写日志视图,launcher 通知中心内存活跃集不受影响(两者数据源分离)。
- **上传为「限长缓冲」非全流式**:multipart 请求体整体读入(上限 8MB,超限 413),下载为流式(8KB 分块)。严格「socket 逐字节流式上传
解析」未实现(需增量 multipart 解析器),列为后续;14MB 内存约束下 8MB 上限 + 流式下载已覆盖大文件读侧。
- **存储为 `meowpanel` 编译时刻写死**的 socket/路径会因 `--sock-perf/--sock-power`(qemu sim)可覆盖,但默认走 `/usr/data/meow_*.sock`。
---
@@ -231,9 +266,9 @@ http://<host>.local:8080/
```
patch_v2/meowpanel/
├── meowpanel.c # 后端源码(单文件,约 2700 行)
├── meowpanel.c # 后端源码(单文件,约 3150 行,-Wall -Wextra 0 warning)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning)
├── meowpanel # 交叉产物(mipsel 静态,约 166 KB,md5 11ed76ec…)
├── meowpanel # 交叉产物(mipsel 静态,约 174 KB,md5 58a7b186…)
├── meowpanel_host # 宿主编译(本机 ld,调试用)
├── services.d/
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
Binary file not shown.
+382 -64
View File
@@ -56,10 +56,21 @@
#define DEFAULT_PORT 8080
#define SETTINGS_FILE "/usr/data/meow/settings.conf"
#define SETTINGS_HDR "# meow-settings 1"
#define TF_ROOT "/usr/data/mnt/sd_0"
#define TF_CARD_DIR "/usr/data/mnt/sd_0/MeowOS"
#define MEOWAPPS_DIR "/usr/data/mnt/sd_0/MeowAPPs"
#define LOGS_DIR "/usr/data/mnt/sd_0/MeowOS/logs"
/* TF 卡路径(运行期可覆盖,宿主测试用 env MEOW_TF_ROOT;默认设备卡挂载点)。
* TF_ROOT 保持为「数组宏」而非指针——让 -Wformat-truncation 能按 1024 尺寸分析拼接,避免误报。 */
static char g_tf_root_buf[1024] = "/usr/data/mnt/sd_0";
static char g_tf_card_buf[2048];
static char g_apps_dir_buf[2048];
static char g_logs_dir_buf[2048];
static char g_notif_log_buf[2048];
static const char *g_tf_card = NULL; /* 派生:<root>/MeowOS */
static const char *g_apps_dir = NULL; /* 派生:<root>/MeowAPPs */
static const char *g_logs_dir = NULL; /* 派生:<card>/logs */
static const char *g_notif_log = NULL; /* 通知转储日志:<card>/notifications/notifications.log */
#define TF_ROOT g_tf_root_buf
#define TF_CARD_DIR g_tf_card
#define MEOWAPPS_DIR g_apps_dir
#define LOGS_DIR g_logs_dir
#define PERF_SOCK "/usr/data/meow_perf.sock"
#define POWER_SOCK "/usr/data/meow_power.sock"
#define NYAI_PATH "/usr/bin/nyai" /* 固件内置(权威) */
@@ -70,10 +81,13 @@
#define IPC_TIMEOUT_MS 2000
#define MAX_HTTP_HEADER (64 * 1024)
#define MAX_HTTP_BODY (16 * 1024 * 1024) /* 上传上限 */
#define MAX_HTTP_BODY (16 * 1024 * 1024) /* 通用 body 上限(JSON/小件) */
#define MAX_UPLOAD_BODY (8 * 1024 * 1024) /* multipart 上传上限(14MB 内存约束:不整读超 8MB) */
#define MAX_JSON_IN 65536 /* JSON body 解析上限 */
#define MAX_STATIC_FREEBUF (2 * 1024 * 1024)
#define LOG_MAX_LINES 1000
#define NOTIF_PREVIEW_MAX 200 /* 通知列表返回上限(每条约 0.2KB,总 <50KB) */
#define TEXT_PREVIEW_MAX (1024 * 1024) /* 文本预览上限(超出截断,truncated=true;14MB 约束下 1MB 缓冲安全) */
/* 白名单子目录(TF 卡根下) */
static const char *g_allowed_dirs[] = { "MeowOS", "Music", "MeowData", "MeowAPPs", "book" };
@@ -89,6 +103,29 @@ static const char *g_sensitive_names[] = {
static const char *g_sock_perf = PERF_SOCK;
static const char *g_sock_power = POWER_SOCK;
/* 初始化 TF 卡派生路径(main 开头调用)。env MEOW_TF_ROOT 可覆盖卡根(宿主测试用);
* env MEOW_NOTIF_LOG 可覆盖通知转储日志路径(宿主测试用)。默认设备卡路径。 */
static void init_tf_paths(void)
{
const char *env = getenv("MEOW_TF_ROOT");
if (env && env[0]) {
snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", env);
/* 去掉末尾 `/`(规范化处统一按无尾斜杠处理) */
size_t l = strlen(g_tf_root_buf);
while (l > 1 && g_tf_root_buf[l - 1] == '/') g_tf_root_buf[--l] = 0;
}
snprintf(g_tf_card_buf, sizeof g_tf_card_buf, "%s/MeowOS", TF_ROOT);
snprintf(g_apps_dir_buf, sizeof g_apps_dir_buf, "%s/MeowAPPs", TF_ROOT);
snprintf(g_logs_dir_buf, sizeof g_logs_dir_buf, "%s/MeowOS/logs", TF_ROOT);
snprintf(g_notif_log_buf, sizeof g_notif_log_buf, "%s/MeowOS/notifications/notifications.log", TF_ROOT);
env = getenv("MEOW_NOTIF_LOG");
if (env && env[0]) snprintf(g_notif_log_buf, sizeof g_notif_log_buf, "%s", env);
g_tf_card = g_tf_card_buf;
g_apps_dir = g_apps_dir_buf;
g_logs_dir = g_logs_dir_buf;
g_notif_log = g_notif_log_buf;
}
/* ---------------- 有界动态字符串(sbuf) ---------------- */
typedef struct { char *b; size_t n; size_t cap; } sbuf_t;
@@ -146,6 +183,16 @@ static void sA_str(sbuf_t *s, const char *v)
sA(s, "\"");
}
/* 追加一段原始字节到 sbuf(长度明确,绕过 %s 的固定缓冲限制;用于大 JSON 数组复用)。 */
static void sA_append(sbuf_t *s, const char *b, size_t n)
{
if (!b || n == 0) return;
if (sb_reserve(s, n) < 0) return;
memcpy(s->b + s->n, b, n);
s->n += n;
s->b[s->n] = 0;
}
static void sbuf_free(sbuf_t *s) { free(s->b); s->b = NULL; s->n = s->cap = 0; }
/* ---------------- 响应函数前向声明(文件列表等先于定义处使用) ---------------- */
@@ -156,6 +203,7 @@ static int send_ok(int sock, const char *data);
static int send_err(int sock, int status, const char *code, const char *msg);
static int send_json(int sock, int status, const char *data);
static int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename);
static void panel_log(const char *fmt, ...);
/* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */
static const char *csv_at(const char *s, int idx, char *buf, int buflen);
@@ -795,6 +843,7 @@ done:
/* ---------------- 路径安全 ---------------- */
static int tf_present(void)
{
if (getenv("MEOW_TF_ROOT")) return 1; /* 宿主测试显式指定卡根(env 覆盖),信任其存在 */
FILE *f = fopen("/proc/mounts", "r");
if (!f) return 0;
char line[512];
@@ -836,7 +885,8 @@ static int is_sensitive_name(const char *base)
static int normalize_path(const char *in, char *buf, size_t buflen)
{
size_t rl = strlen(TF_ROOT);
char joined[1024];
if (strlen(in) > 900) return -1; /* 拒绝超长路径(防御;真实路径远小于此) */
char joined[2048];
if (in[0] == '/') {
if (strncmp(in, TF_ROOT, rl) == 0 && (in[rl] == 0 || in[rl] == '/')) {
snprintf(joined, sizeof joined, "%s", in);
@@ -849,10 +899,13 @@ static int normalize_path(const char *in, char *buf, size_t buflen)
} else {
snprintf(joined, sizeof joined, "%s/%s", TF_ROOT, in);
}
/* 逐分量规范化:/a/b/../c → /a/c;拒绝逃逸出 root */
char out[1024];
/* 逐分量规范化成**绝对**路径:/a/b/../c → /a/c;拒绝逃逸出 root。
* 用绝对 out(以 '/' 开头)构建:首段即带前导 '/', 保证 path_in_whitelist 的绝对前缀比对成立。 */
char out[2048];
size_t n = 0;
out[0] = 0;
out[0] = '/';
out[1] = 0;
n = 1;
char *p = joined;
while (*p) {
while (*p == '/') p++;
@@ -863,24 +916,27 @@ static int normalize_path(const char *in, char *buf, size_t buflen)
seg[k] = 0;
if (!strcmp(seg, ".") || !strcmp(seg, "")) continue;
if (!strcmp(seg, "..")) {
/* 弹出一级;若已在 root 再 .. 则逃逸 */
if (n == 0) return -1;
while (n > 0 && out[n - 1] != '/') n--;
if (n > 0) n--; /* 去掉结尾 / */
/* 弹出一级;已在根(n==1)再 .. → 逃逸出 root,拒绝 */
if (n <= 1) return -1;
while (n > 1 && out[n - 1] != '/') n--;
if (n > 1) n--; /* 去掉结尾 /;out[n] 归属下一段或结束 */
out[n] = 0;
continue;
}
if (k > 0) {
size_t need = strlen(out) + 1 + k + 1;
size_t need = n + ((n > 1) ? 1 : 0) + (size_t)k + 1;
if (need >= sizeof out) return -1;
if (out[0]) strncat(out, "/", sizeof out - strlen(out) - 1);
strncat(out, seg, sizeof out - strlen(out) - 1);
if (n > 1) { out[n++] = '/'; out[n] = 0; }
memcpy(out + n, seg, (size_t)k);
n += (size_t)k;
out[n] = 0;
}
}
/* 空 → root */
if (!out[0]) snprintf(out, sizeof out, "%s", TF_ROOT);
if (strlen(out) >= buflen) return -1;
snprintf(buf, buflen, "%s", out);
/* 空/根(n==1,仅 "/")→ 卡根 */
if (n <= 1) snprintf(out, sizeof out, "%s", TF_ROOT);
size_t ol = strlen(out);
if (ol >= buflen) return -1;
memcpy(buf, out, ol + 1); /* 已证明 ol<buflen,用 memcpy 避免 %s 截断告警 */
return 0;
}
@@ -921,16 +977,22 @@ static int files_list(int sock, const char *relpath)
{
char abs[1024], rel[1024];
if (normalize_path(relpath, abs, sizeof abs) < 0)
return 403;
if (!path_in_whitelist(abs)) return 403;
return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸");
if (!path_in_whitelist(abs))
return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸");
struct stat st;
if (stat(abs, &st) < 0) return 404;
if (!S_ISDIR(st.st_mode)) return 404;
if (stat(abs, &st) < 0)
return send_err(sock, 404, "NOT_FOUND", "路径不存在");
if (!S_ISDIR(st.st_mode))
return send_err(sock, 404, "NOT_FOUND", "非目录");
DIR *d = opendir(abs);
if (!d) return 500;
if (!d)
return send_err(sock, 500, "INTERNAL", "读目录失败");
make_rel(abs, rel, sizeof rel);
sbuf_t s = {0};
sA(&s, "{\"root\":\"" TF_ROOT "\",\"path\":");
sA(&s, "{\"root\":");
sA_str(&s, TF_ROOT);
sA(&s, ",\"path\":");
sA_str(&s, abs);
sA(&s, ",\"rel\":");
sA_str(&s, rel);
@@ -962,17 +1024,19 @@ static int files_list(int sock, const char *relpath)
/* send_json 前置声明 */
static int send_json(int sock, int status, const char *data);
/* 读文本文件(<1MB),返回 malloc 内容;二进制检测 -2;太大 -3;打不开 -1。 */
/* 读文本文件(预览上限 TEXT_PREVIEW_MAX,超出截断返回 truncated):
* 返回 malloc 内容(NUL 结尾);二进制检测 -2;打不开 -1。size=文件真实字节数(非截断后)。 */
static char *read_text_file_limited(const char *abs, long long *size, int *truncated)
{
int fd = open_safe_for_read(abs, 0);
if (fd < 0) return NULL;
struct stat st;
if (fstat(fd, &st) < 0 || st.st_size > 1024 * 1024) { close(fd); return NULL; }
if (st.st_size < 0) { close(fd); return NULL; }
char *buf = malloc((size_t)st.st_size + 1);
if (fstat(fd, &st) < 0 || st.st_size < 0) { close(fd); return NULL; }
long long total = (long long)st.st_size;
size_t cap = total > TEXT_PREVIEW_MAX ? (size_t)TEXT_PREVIEW_MAX : (size_t)total;
char *buf = malloc(cap + 1);
if (!buf) { close(fd); return NULL; }
ssize_t n = read(fd, buf, (size_t)st.st_size);
ssize_t n = read(fd, buf, cap);
close(fd);
if (n < 0) { free(buf); return NULL; }
buf[n] = 0;
@@ -984,8 +1048,8 @@ static char *read_text_file_limited(const char *abs, long long *size, int *trunc
if (c < 0x09) ctrl++;
}
if (ctrl > n / 10) { free(buf); return (char *)-2; }
if (size) *size = (long long)n;
if (truncated) *truncated = 0;
if (size) *size = total;
if (truncated) *truncated = (total > (long long)cap) ? 1 : 0;
return buf;
}
@@ -1286,7 +1350,10 @@ static int read_request(int sock, req_t *r)
}
char *body_start = buf + hend + 4; /* body 实际起点 */
size_t buffered = (hn > (size_t)(body_start - buf)) ? (hn - (size_t)(body_start - buf)) : 0;
if (clen > MAX_HTTP_BODY) { free(buf); return -1; }
/* 上传(multipart)走更严上限:14MB 内存约束下不整读超 MAX_UPLOAD_BODY。超限 → -2(413)。 */
size_t cap = MAX_HTTP_BODY;
if (strncasecmp(r->content_type, "multipart/form-data", 19) == 0) cap = MAX_UPLOAD_BODY;
if (clen > cap) { free(buf); return -2; }
if (clen == 0) { free(buf); r->body = NULL; r->body_len = 0; return 0; }
char *body = malloc(clen + 1);
if (!body) { free(buf); return -1; }
@@ -1425,7 +1492,28 @@ static int send_file(int sock, int status, const char *ctype, const char *abspat
* 解析 body,提取 name=<path> 字段与 name=file 字段(可多个)。写到 target dir(白名单校验)。
* 返回 0=成功(把写入的 item 用 items_json 回填),负错误码语义同协议。
*/
typedef struct { char path[1400]; char filename[256]; } upfile_t;
typedef struct { char path[1400]; char filename[320]; } upfile_t;
/* 安全文件名:取 basename(去目录分隔符),拒绝空/`.`/`..`/含控制字符或分隔符。
* 返回 0=ok 写入 out;-1=拒绝。用于防上传文件名逃逸白名单(如 `../../etc/passwd`)。 */
static int safe_filename(const char *name, char *out, size_t cap)
{
if (!name || !name[0]) return -1;
const char *b = name;
/* 取最后一个路径分隔符之后(同时兼容 '/' 与 '\\') */
const char *slash = strrchr(b, '/');
const char *bsl = strrchr(b, '\\');
if (bsl && (!slash || bsl > slash)) slash = bsl;
if (slash) b = slash + 1;
if (!b[0]) return -1;
if (!strcmp(b, ".") || !strcmp(b, "..")) return -1;
for (const char *p = b; *p; p++) {
unsigned char c = (unsigned char)*p;
if (c == '/' || c == '\\' || c < 0x20 || c == 0x7f) return -1; /* 禁止分隔符/控制字符 */
}
snprintf(out, cap, "%s", b);
return 0;
}
static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles)
{
@@ -1469,10 +1557,12 @@ static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles)
char *l = ln;
while (*l == '\r' || *l == ' ' || *l == '\t') l++;
if (strncasecmp(l, "Content-Disposition:", 20) == 0) {
char *nm = strstr(l, "name=\"");
if (nm) { nm += 6; char *q = strchr(nm, '"'); if (q) { *q = 0; name = nm; } }
/* name 与 filename 常同行(name="file"; filename="up.txt")。
* 先取 filename 再取 name——若先截断 name 收尾引号会把同行的 filename 一并截断。 */
char *fn = strstr(l, "filename=\"");
if (fn) { fn += 10; char *q = strchr(fn, '"'); if (q) { *q = 0; fname = fn; } }
char *nm = strstr(l, "name=\"");
if (nm) { nm += 6; char *q = strchr(nm, '"'); if (q) { *q = 0; name = nm; } }
}
}
/* 数据结束:下一个 "--boundary" 之前 */
@@ -1487,18 +1577,25 @@ static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles)
curpath[dl] = 0;
} else if (name && !strcmp(name, "file") && fname && nfiles < maxfiles) {
size_t dl = (size_t)(data_end - data_start);
snprintf(files[nfiles].filename, sizeof files[nfiles].filename, "%s", fname);
snprintf(files[nfiles].path, sizeof files[nfiles].path, "%s", curpath);
/* 写文件 */
char abs[1024];
if (normalize_path(files[nfiles].path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { nfiles++; continue; }
snprintf(files[nfiles].path, sizeof files[nfiles].path, "%s/%s", abs, fname);
int fd = open(files[nfiles].path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
char safe[260];
if (safe_filename(fname, safe, sizeof safe) < 0) { p = data_end; continue; } /* 文件名非法:跳过该 part */
/* 目标目录规范化 + 白名单校验 */
char absdir[1024];
if (normalize_path(curpath, absdir, sizeof absdir) < 0 || !path_in_whitelist(absdir)) { p = data_end; continue; }
/* 拼绝对路径后再规范化 + 白名单复校(防拼接后残留逃逸) */
char joined[1400];
snprintf(joined, sizeof joined, "%s/%s", absdir, safe);
char abs[1400];
if (normalize_path(joined, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { p = data_end; continue; }
snprintf(files[nfiles].filename, sizeof files[nfiles].filename, "%s", safe);
snprintf(files[nfiles].path, sizeof files[nfiles].path, "%s", abs);
int fd = open(abs, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) {
write_full(fd, data_start, dl);
close(fd);
nfiles++;
}
nfiles++;
/* fd<0(不可写)→ 不计数,跳过该文件 */
}
p = data_end;
}
@@ -2065,7 +2162,9 @@ static int api_logs_list(int sock)
DIR *d = opendir(LOGS_DIR);
if (!d) return send_err(sock, 404, "NOT_FOUND", "日志目录不存在");
sbuf_t s = {0};
sA(&s, "{\"dir\":\"" LOGS_DIR "\",\"files\":[");
sA(&s, "{\"dir\":");
sA_str(&s, LOGS_DIR);
sA(&s, ",\"files\":[");
int first = 1;
struct dirent *e;
while ((e = readdir(d))) {
@@ -2164,23 +2263,224 @@ static int api_logs_read(int sock, const req_t *r)
return rc;
}
/* ---------------- 通知(后端跨进程不可读 → 501) ---------------- */
/* 注:通知存储为 launcher 进程内存(meow_notif.c g_slots[],NOTIFICATION_SYSTEM §4),
* 后端为独立 svcd 子进程,跨进程无法读取/清撤那批内存槽;框架仅在关机前把通知 dump 到
* TF 卡 MeowOS/notifications/notifications.log(CSV 追加,非活跃快照)。故本后端无法实现
* 活跃通知列表/清撤 —— 需为通知后端另开 IPC 查询(如 MSG_NOTIFY_LIST 应答)或持久化活跃集,
* 见 MEOWPANEL_PROTOCOL §6 待定案。当前一律 501。 */
/* ---------------- 通知(数据源 = TF 卡通知转储日志) ----------------
* 数据源优先级(MEOWPANEL_PROTOCOL §3.8 / 任务):
* 1) launcher IPC 查询 —— meow_ipc.h 已定义 MSG_NOTIFY_LIST(0x23),但 launcher 侧仅作
* 守护→App 下发行(util.c:1079「not dispatched here」),**无客户端查询请求类型**,
* 后端(svcd 独立进程)无法据此取到活跃清单 → 跳过,回退 ②。详见报告「需 launcher 侧配合项」。
* 2) TF 卡通知转储文件 `<卡根>/MeowOS/notifications/notifications.log`:
* launcher 把每条通知归档时追加一行 CSV(meow_notif.c tf_line:CSV 追加 + 10MB 滚动,开机清空)。
* 字段顺序:timestamp,id,app_id,app_icon,category,urgency,progress,title,body,target_activity,
* ongoing,media,dismissed(前 6/7/11/12/13 为裸整数,其余引号包裹 + 内部 " 翻倍)。
* 注意:**归档发生在置 dismissed=1 之前(meow_notif_dismiss 先 archive_one 后置 dismissed)**,
* 故日志的 dismissed 列恒 ≈0(不可靠),且日志是「会话级归档」,非活跃快照。
* 3) 都没有 → 明确空态 {items:[],notifications:[],source:"none"},**不伪造数据**。
* clear(dismiss_all)语义:保留活动中的媒体/进度通知(ongoing||media||progress>=0||category=="media"),
* 其余从日志移除。单条删除:按 id 移除该行。 */
typedef struct {
long long ms; /* created_ms(epoch ms) */
unsigned id;
char app_id[40];
char app_icon[64];
char category[40];
int urgency;
int progress; /* -1=无 */
char summary[96];
char body[128];
char target[64];
int ongoing, media, dismissed;
} notif_rec_t;
/* 取一个 CSV 字段(引号包裹 → 解转义;裸字段 → 原样;末后跳过 ',')。返回 0。 */
static int notif_csv_field(const char **pp, char *out, int outcap)
{
const char *p = *pp;
int o = 0;
if (*p == '"') {
p++;
while (*p && *p != '"') {
if (*p == '"' && p[1] == '"') {
if (o < outcap - 1) out[o++] = '"';
p += 2;
} else {
if (o < outcap - 1) out[o++] = *p;
p++;
}
}
if (*p == '"') p++;
} else {
while (*p && *p != ',' && *p != '\n' && *p != '\r') {
if (o < outcap - 1) out[o++] = *p;
p++;
}
}
if (o < outcap) { out[o] = 0; }
else if (outcap > 0) { out[outcap - 1] = 0; }
if (*p == ',') p++;
*pp = p;
return 0;
}
static int notif_parse_line(const char *line, notif_rec_t *r)
{
const char *p = line;
char f1[32], f2[16], f6[8], f7[16], f11[8], f12[8], f13[8];
char app[40], icon[64], cat[40], sum[96], body[128], tgt[64];
notif_csv_field(&p, f1, sizeof f1);
notif_csv_field(&p, f2, sizeof f2);
notif_csv_field(&p, app, sizeof app);
notif_csv_field(&p, icon, sizeof icon);
notif_csv_field(&p, cat, sizeof cat);
notif_csv_field(&p, f6, sizeof f6);
notif_csv_field(&p, f7, sizeof f7);
notif_csv_field(&p, sum, sizeof sum);
notif_csv_field(&p, body, sizeof body);
notif_csv_field(&p, tgt, sizeof tgt);
notif_csv_field(&p, f11, sizeof f11);
notif_csv_field(&p, f12, sizeof f12);
notif_csv_field(&p, f13, sizeof f13);
r->ms = strtoll(f1, NULL, 10);
r->id = (unsigned)strtoul(f2, NULL, 10);
r->urgency = atoi(f6);
r->progress = atoi(f7);
r->ongoing = atoi(f11);
r->media = atoi(f12);
r->dismissed = atoi(f13);
snprintf(r->app_id, sizeof r->app_id, "%s", app);
snprintf(r->app_icon, sizeof r->app_icon, "%s", icon);
snprintf(r->category, sizeof r->category, "%s", cat);
snprintf(r->summary, sizeof r->summary, "%s", sum);
snprintf(r->body, sizeof r->body, "%s", body);
snprintf(r->target, sizeof r->target, "%s", tgt);
return 0;
}
/* 读通知日志,保留最新 maxrec 条(环形缓冲,避免全量进内存)。返回条数;-1=文件不存在/无卡。 */
static int notif_load_list(notif_rec_t *recs, int maxrec)
{
FILE *f = fopen(g_notif_log, "r");
if (!f) return -1;
int total = 0, head = 0;
char line[1400];
notif_rec_t r;
while (fgets(line, sizeof line, f)) {
if (notif_parse_line(line, &r) != 0) continue;
recs[head] = r;
head = (head + 1) % maxrec;
if (total < maxrec) total++;
}
fclose(f);
if (total < maxrec) return total; /* 未满窗:recs[0..total-1] 即 oldest..newest */
/* 满窗:oldest 在 recs[head],重排为 oldest..newest 连续 */
notif_rec_t tmp[NOTIF_PREVIEW_MAX];
for (int i = 0; i < maxrec; i++) tmp[i] = recs[(head + i) % maxrec];
memcpy(recs, tmp, (size_t)maxrec * sizeof recs[0]);
return total;
}
/* app_id → 展示名("system" → "系统";其余用 app_id 兜底,前端可回退)。 */
static const char *notif_app_name(const char *app_id)
{
if (!strcmp(app_id, "system")) return "系统";
return app_id;
}
static void notif_emit_item(sbuf_t *out, const notif_rec_t *r)
{
sA(out, "{\"id\":%u,\"app_id\":", r->id);
sA_str(out, r->app_id);
sA(out, ",\"app_name\":");
sA_str(out, notif_app_name(r->app_id));
sA(out, ",\"app_icon\":");
sA_str(out, r->app_icon);
sA(out, ",\"category\":");
sA_str(out, r->category);
sA(out, ",\"summary\":");
sA_str(out, r->summary);
sA(out, ",\"body\":");
sA_str(out, r->body);
sA(out, ",\"urgency\":%d,\"ts\":%lld,\"progress\":", r->urgency, r->ms > 0 ? r->ms / 1000 : 0);
if (r->progress >= 0) sA(out, "%d", r->progress); else sA(out, "null");
sA(out, ",\"ongoing\":%s,\"media\":%s,\"target_activity\":",
r->ongoing ? "true" : "false", r->media ? "true" : "false");
sA_str(out, r->target);
sA(out, ",\"dismissed\":%s}", r->dismissed ? "true" : "false");
}
static int api_notifications(int sock)
{
return send_err(sock, 501, "NOT_IMPLEMENTED", "通知存储为 launcher 进程内存,后端跨进程不可读(需通知后端暴露 IPC 查询/清撤)");
notif_rec_t recs[NOTIF_PREVIEW_MAX];
int n = notif_load_list(recs, NOTIF_PREVIEW_MAX);
if (n < 0) return send_json(sock, 200, "{\"count\":0,\"items\":[],\"notifications\":[],\"source\":\"none\"}");
sbuf_t arr = {0};
int first = 1;
for (int i = n - 1; i >= 0; i--) { /* newest first */
if (!first) sA(&arr, ",");
first = 0;
notif_emit_item(&arr, &recs[i]);
}
sbuf_t s = {0};
sA(&s, "{\"count\":%d,\"source\":\"tf\",\"items\":[", n);
if (arr.n) sA_append(&s, arr.b, arr.n);
sA(&s, "],\"notifications\":[");
if (arr.n) sA_append(&s, arr.b, arr.n);
sA(&s, "]}");
sbuf_free(&arr);
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
static int api_notification_delete(int sock)
/* 重写通知日志。mode=1:保留活动媒体/进度(ongoing||media||progress>=0||category=="media"),
* 其余删除(dismiss_all 语义,对应通知中心既定语义);mode=0:删除指定 id(单条删除)。
* 返回删除行数;-1=IO 错;0=无文件(无可删)。写临时文件后 rename(避免与 launcher append 竞写)。 */
static int notif_rewrite(int mode, unsigned del_id)
{
return send_err(sock, 501, "NOT_IMPLEMENTED", "通知清撤需 launcher 通知后端 IPC,后端未实现");
if (!g_notif_log || !g_notif_log[0]) return -1;
char tmppath[1400];
snprintf(tmppath, sizeof tmppath, "%s.panel.tmp", g_notif_log);
FILE *in = fopen(g_notif_log, "r");
if (!in) return 0;
FILE *out = fopen(tmppath, "w");
if (!out) { fclose(in); return -1; }
int removed = 0;
char line[1400];
while (fgets(line, sizeof line, in)) {
notif_rec_t r;
int parsed = (notif_parse_line(line, &r) == 0);
int drop = 0;
if (mode == 1) {
if (!parsed) drop = 1;
else drop = !(r.ongoing || r.media || r.progress >= 0 || !strcmp(r.category, "media"));
} else {
if (parsed && r.id == del_id) drop = 1;
}
if (drop) removed++;
else fputs(line, out);
}
fclose(in);
if (fclose(out) != 0) return -1;
if (rename(tmppath, g_notif_log) < 0) { unlink(tmppath); return -1; }
return removed;
}
static int api_notification_delete(int sock, unsigned id)
{
int removed = notif_rewrite(0, id);
if (removed < 0) return send_err(sock, 500, "INTERNAL", "写通知存储失败");
if (removed == 0) return send_err(sock, 404, "NOT_FOUND", "通知不存在");
char d[64];
snprintf(d, sizeof d, "{\"id\":%u,\"dismissed\":true}", id);
return send_json(sock, 200, d);
}
static int api_notification_dismiss_all(int sock)
{
return send_err(sock, 501, "NOT_IMPLEMENTED", "通知清空需 launcher 通知后端 IPC,后端未实现");
int removed = notif_rewrite(1, 0);
if (removed < 0) return send_err(sock, 500, "INTERNAL", "写通知存储失败");
char d[64];
snprintf(d, sizeof d, "{\"dismissed\":%d}", removed);
return send_json(sock, 200, d);
}
/* ---------------- 文件操作 ---------------- */
@@ -2208,7 +2508,7 @@ static int api_files_read(int sock, const req_t *r)
sA_str(&s, abs);
sA(&s, ",\"content\":");
sA_str(&s, txt);
sA(&s, ",\"size\":%lld,\"truncated\":false}", size);
sA(&s, ",\"size\":%lld,\"truncated\":%s}", size, trunc ? "true" : "false");
free(txt);
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
@@ -2318,8 +2618,16 @@ static int api_files_rename(int sock, const req_t *r)
if (strchr(pnn->str, '/')) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "new_name 仅文件名"); }
char abs[1024], newabs[1400];
if (normalize_path(pj->str, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); }
snprintf(newabs, sizeof newabs, "%s/%s", abs, pnn->str);
/* newabs 也须在白名单(new_name 不含 /,父目录已校验,无需再 normalize) */
/* 新路径 = 原路径的**父目录** + new_name(new_name 仅文件名,不含分隔符) */
char parent[1024];
snprintf(parent, sizeof parent, "%s", abs);
char *sl = strrchr(parent, '/');
if (!sl || sl == parent) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "无法重命名根路径"); }
*sl = 0;
if (!parent[0]) snprintf(parent, sizeof parent, "%s", TF_ROOT); /* abs 顶层(如 /tmp/mptf/x)→ 父=根 */
snprintf(newabs, sizeof newabs, "%s/%s", parent, pnn->str);
/* newabs 复校白名单(new_name 无 /,父目录已校验;防御) */
if (!path_in_whitelist(newabs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); }
if (rename(abs, newabs) < 0) { jv_free(root); return send_err(sock, 500, "INTERNAL", "重命名失败"); }
sbuf_t s = {0};
sA(&s, "{\"item\":{\"name\":");
@@ -2557,7 +2865,9 @@ static int api_apps_export(int sock, const char *pkg)
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char tmp[1100];
mkdir(TF_CARD_DIR "/tmp", 0777);
char card_tmp[1200];
snprintf(card_tmp, sizeof card_tmp, "%s/tmp", TF_CARD_DIR);
mkdir(card_tmp, 0777);
snprintf(tmp, sizeof tmp, "%s/tmp/%s.mpk", TF_CARD_DIR, pkg);
unlink(tmp);
char cmd[1400];
@@ -2668,7 +2978,7 @@ static int handle(int sock, req_t *r)
if (!strcmp(p, "/api/files/mkdir")) return api_files_mkdir(sock, r);
if (!strcmp(p, "/api/files/rename")) return api_files_rename(sock, r);
if (!strcmp(p, "/api/apps/install")) return api_apps_install(sock, r);
if (!strcmp(p, "/api/notifications/dismiss_all")) return api_notification_dismiss_all(sock);
if (!strcmp(p, "/api/notifications/dismiss_all") || !strcmp(p, "/api/notifications/clear")) return api_notification_dismiss_all(sock);
if (!strcmp(p, "/api/key")) return api_key_batch(sock, r);
char seg[256];
if (path_segment("/api/key/", p, seg, sizeof seg))
@@ -2721,8 +3031,10 @@ static int handle(int sock, req_t *r)
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/notifications")) return api_notifications(sock);
if (!strcmp(r->method, "DELETE")) {
char seg[256];
if (path_segment("/api/notifications/", p, seg, sizeof seg))
return api_notification_delete(sock);
if (path_segment("/api/notifications/", p, seg, sizeof seg)) {
unsigned nid = (unsigned)strtoul(seg, NULL, 10);
return api_notification_delete(sock, nid);
}
}
return send_err(sock, 404, "NOT_FOUND", "端点不存在");
@@ -2757,7 +3069,12 @@ static void handle_conn(int sock)
{
req_t r;
memset(&r, 0, sizeof r);
if (read_request(sock, &r) != 0) { close(sock); return; }
int rr = read_request(sock, &r);
if (rr != 0) {
if (rr == -2) send_err(sock, 413, "PAYLOAD_TOO_LARGE", "请求体过大(超过上传上限)");
close(sock);
return;
}
handle(sock, &r);
req_free(&r);
close(sock);
@@ -2778,6 +3095,7 @@ int main(int argc, char **argv)
return 0;
}
}
init_tf_paths(); /* 先定卡根/派生路径(env 可覆盖,宿主测试用) */
const char *env = getenv("MEOW_IPC_PERF_SOCK");
if (env && env[0]) g_sock_perf = env;
env = getenv("MEOW_IPC_POWER_SOCK");
Binary file not shown.
+12 -2
View File
@@ -339,7 +339,7 @@
});
}
function doDownload(url, name) {
apiDownload(url).then(blobUrl => {
return apiDownload(url).then(blobUrl => {
const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'file';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
@@ -432,7 +432,17 @@
if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; }
if (refresh) { renderApps(); return; }
if (info) { appInfo(info.dataset.ainfo); return; }
if (exp) { e.preventDefault(); doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk'); return; }
if (exp) {
e.preventDefault();
if (exp.dataset.busy) return; /* 打包期间防连点 */
exp.dataset.busy = '1';
const oldTxt = exp.textContent;
exp.textContent = '打包中…';
toast('正在打包导出 ' + exp.dataset.aexport + ',请稍候…');
doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk')
.finally(() => { delete exp.dataset.busy; exp.textContent = oldTxt; });
return;
}
if (uninst) { appUninstall(uninst.dataset.auninst); return; }
if (goPath) { installFromPath($('#app-install-path').value); return; }
});