diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md index 5228d89..47b67dd 100644 --- a/patch_v2/meowpanel/README.md +++ b/patch_v2/meowpanel/README.md @@ -102,18 +102,22 @@ http://.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://.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://.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://.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://.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 diff --git a/patch_v2/meowpanel/meowpanel b/patch_v2/meowpanel/meowpanel index d53e301..f25ca32 100755 Binary files a/patch_v2/meowpanel/meowpanel and b/patch_v2/meowpanel/meowpanel differ diff --git a/patch_v2/meowpanel/meowpanel.c b/patch_v2/meowpanel/meowpanel.c index 7520476..94a0a14 100644 --- a/patch_v2/meowpanel/meowpanel.c +++ b/patch_v2/meowpanel/meowpanel.c @@ -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; /* 派生:/MeowOS */ +static const char *g_apps_dir = NULL; /* 派生:/MeowAPPs */ +static const char *g_logs_dir = NULL; /* 派生:/logs */ +static const char *g_notif_log = NULL; /* 通知转储日志:/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 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= 字段与 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"); diff --git a/patch_v2/meowpanel/meowpanel_host b/patch_v2/meowpanel/meowpanel_host index 604dde0..1096255 100755 Binary files a/patch_v2/meowpanel/meowpanel_host and b/patch_v2/meowpanel/meowpanel_host differ diff --git a/patch_v2/meowpanel/web/meowpanel.js b/patch_v2/meowpanel/web/meowpanel.js index c38a9b4..58e284d 100644 --- a/patch_v2/meowpanel/web/meowpanel.js +++ b/patch_v2/meowpanel/web/meowpanel.js @@ -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; } });