MeowPanel: 字体库 tar.gz 导入/导出 + 文件管理自然排序修复 + multipart 二进制边界修复
- 字体库导入/导出(用户 2026-08-31 定案):放弃 zip,与 .mpk 同构走 busybox 'gzip -dc | tar' 管道 (设备必带 tar/gzip,零新增依赖)。导入 POST /api/fonts/import(multipart file=<.tar.gz>,上限32MB, ?force=1 覆盖):tar -t 成员预校验(sanitize_member 拒绝对/../shell 危险字符,恰为三件套平铺)→ 解压到临时目录 → opendir/lstat 锁(拒软链/目录/设备/多出条目/解压后超大)→ font.json 解析 family/weight (safe_dir_segment 消毒)→ 覆盖冲突 409 → sha256_png/idx 仅 WARN → rename 到 MeowOS/fonts/<family>/<weight>/。 导出 GET /api/fonts/export?family=&weight=:tar -cf - | gzip -c 打包 <family>-<weight>.font.tar.gz 流式下载。 列表 GET /api/fonts:扫描三件套齐。前端设置页加「字体库」卡(导入 zip/tar.gz + 409 confirm 后 force=1 重试 + 导出按钮)。 - 文件管理排序修复:/api/files 排序改为目录优先 + 名称自然排序(数字感知 track2<track10,中文按无符号 字节序),替换原 strcasecmp 词法排序(用户报「很离谱」)。 - multipart 二进制边界修复(latent bug):strstr 遇数据内 NUL 提前停止 → memmem 语义 mem_find 字节安全查找, 修复字体 tar.gz/.mpk/二进制上传可能误吞后段的问题。 - 文档同步:docs/MEOWOS_SPEC_REF.md §5.2/§5.7.6、patch_v2/meowpanel/README.md 改 tar.gz 定案; FONT_SYSTEM.md 无 zip 表述无需改。meow_font_import_zip/export_zip 语义注为 tar.gz(_tgz 可更名)。 - 宿主编译 0 warning(-Werror 亦过);宿主端到端:导入→导出→逐字节比对、损坏/缺件/坏json/穿越/软链/非gzip全拒、 自然排序样本全过;真机已部署(meowpanel md5 51ffc183,服务 running)并 curl 验证 font 端点 + 排序。
This commit is contained in:
1 parent
4e3f41a5a3
commit
d8b95c0aab
7 files changed
+580
-23
No files matched your search
@@ -28,12 +28,14 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
|
||||
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。
|
||||
- 产出(本次构建实测):
|
||||
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
|
||||
体积约 **206 KB**(固件阈值内)。md5:`24f53b40fd556f758e5eba3417a52c3c`(本次含通知 IPC 实时化 + 增量帧)。
|
||||
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **146 KB**。
|
||||
体积约 **218 KB**(固件阈值内)。md5:`be74e2ac0423718073a5a27fc7bbf96a`(本次含字体库 tar.gz 导入/导出 + 排序修复)。
|
||||
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **148 KB**。
|
||||
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
|
||||
`meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。
|
||||
阶段 C 实时画面用**自研最小 baseline JPEG 编码器** `mp_jpeg.c`(顺序 DCT + 4:2:0 + 标准 Huffman 表 Annex K;
|
||||
无 libm、零依赖,仅 malloc/realloc),质量 70 下 480×720 帧约 41 KB。
|
||||
**字体库导入/导出(用户 2026-08-31 定案)**:与 `.mpk` 同构、**放弃 zip,改走 busybox `gzip -dc | tar` 管道**
|
||||
(设备必带 tar/gzip,零新增依赖、不引入 zlib/miniz/自研 zip,见 §5.11)。
|
||||
**性能**:设备为 mipsel muslsf **无 FPU**,初版逐块 `double` 矩阵 DCT 编码 480×720 一帧约 **3.96 s**(掉帧到 5-6 s/帧的根因)。
|
||||
已改为**整数定点 DCT + 整数 YCbCr**(FSCALE=13,BT.601 256 系数,纯整型乘加),
|
||||
实测编码降至约 **0.19 s(≈20×)**,单请求端到端(本机 localhost)约 0.20 s、LAN 约 0.22-0.31 s,
|
||||
@@ -107,6 +109,9 @@ http://<host>.local:8080/
|
||||
| POST | `/api/files/mkdir` | 新建目录(已存在→409) | ✅ |
|
||||
| POST | `/api/files/rename` | 重命名 | ✅ |
|
||||
| DELETE | `/api/files?path=` | 删除(可 recursive=1;非空未 recursive→409) | ✅ |
|
||||
| GET | `/api/fonts` | 字体库列表(扫描 `MeowOS/fonts/<family>/<weight>/` 三件套齐) | ✅(无卡→503) |
|
||||
| POST | `/api/fonts/import` | 上传字体库 `.tar.gz`(multipart `file`,可带 `?force=1` 覆盖) | ✅(缺件/坏json/穿越→400,冲突→409,超限→413) |
|
||||
| GET | `/api/fonts/export` | 导出 `<family>/<weight>/` 三件套为 `.tar.gz`(`?family=&weight=`) | ✅(缺件→404) |
|
||||
| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ |
|
||||
| GET | `/api/apps/:pkg` | 应用详情 | ✅ |
|
||||
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
|
||||
@@ -274,6 +279,33 @@ http://<host>.local:8080/
|
||||
防重叠 + 旧 URL 回收;后端不可达提示「后端未连接」);默认 **1fps**,滑条 **0.2–2fps**(存 `screen.fps`,页面隐藏/切走即停刷,
|
||||
`visibilitychange` 回前台续刷);点击画面按 480×720 比例换算坐标 → `POST /api/input/tap`,`dispatched:false` 时 toast「本机触摸中」。
|
||||
|
||||
### 5.11 字体库 tar.gz 导入 / 导出(`/api/fonts*`,新增)
|
||||
- **定位**(MEOWOS_SPEC_REF §5.7.6 / FONT_SYSTEM.md,**2026-08-31 用户定案:放弃 zip,与 .mpk 同构走 tar.gz**):
|
||||
系统字库按 `MeowOS/fonts/<family>/<weight>/` 三件套存放(schema 见 SPEC_REF §5.2:family/weight/name/
|
||||
glyph_count/charset/tier/size_list/bit_depth/sha256_png/sha256_idx…)。**设备 busybox tar/gzip 必定存在**,
|
||||
后端全部走 `gzip -dc | tar` 管道(nyai .mpk 同款),**零新增依赖、不引入 zlib/miniz/自研 zip**。
|
||||
- **导入 `POST /api/fonts/import`**(multipart `file`=<.tar.gz>,上限 **`MAX_FONT_UPLOAD`=32MB**,可带 `?force=1`):
|
||||
1. multipart `file` 落临时 `<TF_CARD>/tmp/mp_font_...tar.gz`(`multipart_extract_file`,二进制安全 `mem_find`)。
|
||||
2. **成员预校验**:`gzip -dc '<tmp>' | tar -t`,每成员经 `sanitize_member`(去前导 `/`/`./`,拒 `..` 组件与
|
||||
shell 危险字符)且**恰为三件套平铺**(多出条目/子目录/绝对/`..` → 400)。
|
||||
3. **解压到临时目录**:`gzip -dc | tar -x -C stg` → opendir/lstat 锁:**恰为 3 个普通文件**
|
||||
(拒软链/目录/设备;总大小 ≤ `FONT_EXTRACT_MAX`=32MB 防 gzip 炸弹)→ 缺件/坏 json 400。
|
||||
4. `font.json` 手写 JSON 解析 → `family`(缺省回退 `name`)/`weight`(缺省 `regular`),`safe_dir_segment`
|
||||
消毒(拒 `/`、`\`、控制符、`.`/`..`)→ 非法 422。
|
||||
5. **覆盖冲突**:目标目录任一文件已存在且非 `force=1` → **409 `CONFLICT`**(前端 confirm 后带 `force=1` 重试)。
|
||||
6. **sha256 仅 WARN**(对齐 font.json 约定):流式重算 `sha256_png`/`sha256_idx` 与声明比对,不符写 `warning`
|
||||
字段、仍导入成功(**不硬失败**);响应含 `family`/`weight`/`imported`/`hint`(提示进设备「设置→字体」页
|
||||
列表自动重扫生效——**后端不触发 launcher 重扫**,扫描时机=启动/进设置页,勿加热路径)。
|
||||
7. `mkdir`(系统调用,**不用 `system()`**,防注入)→ `rename` 三件套到目标目录(原子替换)。
|
||||
- **导出 `GET /api/fonts/export?family=&weight=`**:校验 family/weight 段 → 目标目录三件套须齐(缺件 404)→
|
||||
`tar -cf - -C <font_dir> font.png font.idx font.json | gzip -c > <tmp>` 落临时文件(求 `Content-Length`)→
|
||||
`Content-Disposition: attachment; filename="<family>-<weight>.font.tar.gz"` + `application/gzip` → 流式下发。
|
||||
- **列表 `GET /api/fonts`**:扫描 `MeowOS/fonts/<family>/<weight>/`,三件套齐才列出,读 `font.json` 取
|
||||
name/tier/glyph_count(容错)。无卡 → 503。
|
||||
- **往返验证**(宿主):Python tarfile 生成的平铺三件套 `.tar.gz` 导入→导出→逐字节比对源文件一致;本后端导出
|
||||
再导入→逐字节一致;GNU tar/`tarfile` 均能读;目录穿越 `../`/绝对路径/软链成员/缺件/坏 font.json/多出条目/
|
||||
子目录包裹/非 gzip——全部拒绝;打包文件名 `<family>-<weight>.font.tar.gz`。见 §7 宿主测试备注。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
|
||||
@@ -304,12 +336,25 @@ http://<host>.local:8080/
|
||||
11. **实时画面/触摸注入(阶段 C)**:协议 §6 待定案项称「实时画面(`/api/frame` 等,本文件暂未定义)」。本面板落地为
|
||||
**`GET /api/screen.jpeg` + `POST /api/input/tap`**(命名自定:`.jpeg` 表达二进制帧 + `input/tap` 表达触摸注入),
|
||||
见 §5.10。回改协议时可补这两端点(以及 `frames` 语义、`/api/input/tap` 的 `dispatched:false/reason` 软失败约定)。
|
||||
12. **multipart 二进制查找修正(strstr→memmem,本次修复,属 bug 修复非出入变更)**:`parse_multipart`/`multipart_extract_file`
|
||||
均用 `strstr(data_start, boundary)` 定位 part 数据边界——**strstr 遇到数据中的 NUL 字节即提前停止**(含 NUL 的二进制
|
||||
.tar.gz/.mpk/图会误把后续 boundary 与拖尾算进数据 → 解压出错)。已改**二进制安全**的 `mem_find`(memmem 语义,手写字节扫描,
|
||||
不依赖 glibc/musl 扩展)。这修复了字体库 tar.gz 导入,也顺带修复了二进制 `.mpk`/文件上传的潜在同病。**实现优先**,供回改协议/审计。
|
||||
13. **`/api/fonts*`(新增,§5.11)**:协议 §2 端点总览无此组;按 SPEC_REF §5.7.6「MeowPanel 预留 .zip 导入/导出」落地——**但用户
|
||||
2026-08-31 定案:改 tar.gz、放弃 zip**(与 .mpk 同构)。落地为 `/api/fonts`(列表)/`/api/fonts/import`(导入,POST multipart,
|
||||
上限 32MB,`?force=1` 覆盖)/`/api/fonts/export`(导出,GET,`<family>-<weight>.font.tar.gz`)。前两者由前端设置页「字体库」卡接入。
|
||||
还修正了 `/api/files` 排序:**目录优先 + 名称自然排序**(数字感知 track2<track10,中文按无符号字节序,见 §5.4)。
|
||||
**导入不触发 launcher 重扫**(扫描时机=启动/进设置页),响应用 `hint` 提示。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知边界 / 风险
|
||||
|
||||
- **单线程串行**:面板访问量极小,但并发请求会被排队(长上传/下载期间其它请求等待)。对面板用途可接受。
|
||||
- **字体导入内存/体积上限**:`POST /api/fonts/import` 上传体上限 **32MB**(`MAX_FONT_UPLOAD`,仅此端点放宽;其余
|
||||
multipart 仍 8MB);tar 解压到临时目录的总大小 ≤ `FONT_EXTRACT_MAX`=32MB(防 gzip 炸弹)。**全程不做整读进内存**
|
||||
(写临时文件 + `gzip -dc | tar` 管道 + rename),峰值内存很小,不依赖 dma/font.png 大件缓冲——相比内存解压方案的 14MB 约束更稳健。
|
||||
⚠️ 依赖设备 busybox `gzip`/`tar`(必带);若某内核裁剪过二者,导入/导出会失败(400/500),列表不受影响。
|
||||
- **无鉴权(默认)**:设备内网信任模型(协议 §1.3)。**高危端点**(`reboot/shutdown/ota/apps/删除/上传)在默认关鉴权时无鉴权,
|
||||
请勿把 8080 暴露到不可信网络。`meow_svc_power` 服务端做 `SO_PEERCRED uid==0` 基础校验(root 才受理动作),后端以 root 运行可过;launcher 电源菜单/后端同链路。
|
||||
**可选鉴权**(§5.9)开启后可对内网作加固(登录密码 + 会话),但密码经**局域网明文 HTTP** 传输,不提供 TLS。
|
||||
@@ -347,11 +392,11 @@ http://<host>.local:8080/
|
||||
|
||||
```
|
||||
patch_v2/meowpanel/
|
||||
├── meowpanel.c # 后端源码(约 4100 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化)
|
||||
├── meowpanel.c # 后端源码(约 4560 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序)
|
||||
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning)
|
||||
├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG)
|
||||
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning)
|
||||
├── meowpanel # 交叉产物(mipsel 静态,约 206 KB,md5 24f53b40…)
|
||||
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c)
|
||||
├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 be74e2ac…)
|
||||
├── meowpanel_host # 宿主编译(本机 ld,调试用)
|
||||
├── services.d/
|
||||
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
# build_meowpanel.sh — MeowPanel 后端交叉编译(mipsel 静态)+ 宿主编译可选。
|
||||
#
|
||||
# 目标:R3 Pro II / MeowOS(mipsel-linux-muslsf,静态)。后端仅 POSIX + Linux 基接口
|
||||
# (复用 meow_ipc.h 帧格式,无 launcher 依赖),单文件满足「卡上、静态、零第三方依赖」。
|
||||
# (复用 meow_ipc.h 帧格式,无 launcher 依赖),多文件满足「卡上、静态、零第三方依赖」。
|
||||
#
|
||||
# 用法:
|
||||
# ./build_meowpanel.sh # 交叉编译 → patch_v2/meowpanel/meowpanel(mipsel 静态)
|
||||
# ./build_meowpanel.sh host # 宿主编译 → patch_v2/meowpanel/meowpanel_host
|
||||
# 源码:meowpanel.c + mp_jpeg.c(最小 baseline JPEG 编码器,阶段 C 实时画面)。
|
||||
# 字体库导入/导出走 busybox `gzip -dc | tar` 管道(与 nyai .mpk 同构),零额外源文件。
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
ROOT="$HOME/Project/R3ProII"
|
||||
|
||||
Binary file not shown.
+465
-10
@@ -19,7 +19,9 @@
|
||||
* - 文件/应用/日志/通知:白名单根 /usr/data/mnt/sd_0 下 MeowOS/Music/MeowData/MeowAPPs/book。
|
||||
*
|
||||
* 编译(mipsel 静态):build_meowpanel.sh;宿主编译 build_meowpanel.sh host。
|
||||
* 依赖:仅 POSIX + libc(含 linux/input.h 内核头),零第三方库。设备无 python3/lua。
|
||||
* 依赖:仅 POSIX + libc(含 linux/input.h 内核头),零第三方库(mp_jpeg.c 自研最小 JPEG;
|
||||
* 字体库导入/导出走 busybox `gzip -dc | tar` 管道——与 nyai .mpk 同构,设备必带 tar/gzip)。
|
||||
* 设备无 python3/lua。
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -100,6 +102,7 @@ static const char *g_notif_log = NULL; /* 通知转储日志:<card>/notificat
|
||||
#define MAX_HTTP_HEADER (64 * 1024)
|
||||
#define MAX_HTTP_BODY (16 * 1024 * 1024) /* 通用 body 上限(JSON/小件) */
|
||||
#define MAX_UPLOAD_BODY (8 * 1024 * 1024) /* multipart 上传上限(14MB 内存约束:不整读超 8MB) */
|
||||
#define MAX_FONT_UPLOAD (32 * 1024 * 1024) /* 字体库 tar.gz 上传上限(gbk 字库 png+idx 可能 10-20MB) */
|
||||
#define MAX_JSON_IN 65536 /* JSON body 解析上限 */
|
||||
#define MAX_STATIC_FREEBUF (2 * 1024 * 1024)
|
||||
#define LOG_MAX_LINES 1000
|
||||
@@ -1039,13 +1042,48 @@ static void fmt_json_item(sbuf_t *s, const char *name, int isdir, long long size
|
||||
}
|
||||
|
||||
struct fent { char name[256]; int isdir; long long size, mtime; };
|
||||
/* 目录优先 + 名称不区分大小写(再按大小写敏感定序保证稳定) */
|
||||
|
||||
/* 自然字符串比较:数字段按数值比较(track2 < track10),ASCII 字母大小写不敏感
|
||||
* (同字母时原始字节作 tie-break),其余按**无符号字节序**(UTF-8 中文等直接按字节序,
|
||||
* 设备无 locale,避免 strcasecmp 对高位字节的 locale 依赖导致乱序)。
|
||||
* 实现:逐字节;遇到连续数字则各取整段,比「有效位数→数值→原始长度」;否则比字节。 */
|
||||
static int nat_strcmp(const char *a, const char *b)
|
||||
{
|
||||
const unsigned char *pa = (const unsigned char *)a;
|
||||
const unsigned char *pb = (const unsigned char *)b;
|
||||
while (*pa && *pb) {
|
||||
unsigned char ca = *pa, cb = *pb;
|
||||
if (ca >= '0' && ca <= '9' && cb >= '0' && cb <= '9') {
|
||||
const unsigned char *na = pa, *nb = pb;
|
||||
while (*na == '0') na++;
|
||||
while (*nb == '0') nb++;
|
||||
const unsigned char *da = na, *db = nb;
|
||||
while (*da >= '0' && *da <= '9') da++;
|
||||
while (*db >= '0' && *db <= '9') db++;
|
||||
int la = (int)(da - na), lb = (int)(db - nb); /* 有效数字位数(去前导0) */
|
||||
if (la != lb) return la - lb; /* 位数小者数小 */
|
||||
int c = memcmp(na, nb, (size_t)la);
|
||||
if (c) return c;
|
||||
int ta = (int)(da - pa), tb = (int)(db - pb); /* 原始数字串长(含前导0) */
|
||||
if (ta != tb) return ta - tb;
|
||||
pa = da; pb = db;
|
||||
continue; /* 数字段完全相同,继续 */
|
||||
}
|
||||
unsigned char la = (ca >= 'A' && ca <= 'Z') ? (unsigned char)(ca + 32) : ca;
|
||||
unsigned char lbc = (cb >= 'A' && cb <= 'Z') ? (unsigned char)(cb + 32) : cb;
|
||||
if (la != lbc) return (int)la - (int)lbc;
|
||||
if (ca != cb) return (int)ca - (int)cb; /* 大小写 tie-break:大写在前 */
|
||||
pa++; pb++;
|
||||
}
|
||||
return (int)*pa - (int)*pb;
|
||||
}
|
||||
|
||||
/* 目录优先 + 名称自然排序(数字感知 + 无符号字节序) */
|
||||
static int fent_cmp(const void *a, const void *b)
|
||||
{
|
||||
const struct fent *fa = a, *fb = b;
|
||||
if (fa->isdir != fb->isdir) return fb->isdir - fa->isdir;
|
||||
int c = strcasecmp(fa->name, fb->name);
|
||||
return c ? c : strcmp(fa->name, fb->name);
|
||||
return nat_strcmp(fa->name, fb->name);
|
||||
}
|
||||
|
||||
static int files_list(int sock, const char *relpath)
|
||||
@@ -1303,6 +1341,7 @@ typedef struct {
|
||||
} req_t;
|
||||
|
||||
static void req_free(req_t *r) { free(r->body); r->body = NULL; r->body_len = 0; }
|
||||
static int multipart_extract_file(const req_t *r, const char *outpath); /* 定义在文件后部;字体导入用(写 multipart file 到临时文件) */
|
||||
|
||||
static int is_hex(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }
|
||||
static int hexval(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; return c - 'A' + 10; }
|
||||
@@ -1451,9 +1490,11 @@ 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;
|
||||
/* 上传(multipart)走更严上限:14MB 内存约束下不整读超 MAX_UPLOAD_BODY。超限 → -2(413)。 */
|
||||
/* 上传(multipart)走更严上限:14MB 内存约束下不整读超 MAX_UPLOAD_BODY。超限 → -2(413)。
|
||||
* 字体库 tar.gz 端点例外:gbk/full 字库 png+idx 可能 10-20MB,放宽到 MAX_FONT_UPLOAD(32MB)。 */
|
||||
size_t cap = MAX_HTTP_BODY;
|
||||
if (strncasecmp(r->content_type, "multipart/form-data", 19) == 0) cap = MAX_UPLOAD_BODY;
|
||||
if (strncasecmp(r->content_type, "multipart/form-data", 19) == 0)
|
||||
cap = (!strcmp(r->path, "/api/fonts/import")) ? MAX_FONT_UPLOAD : 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);
|
||||
@@ -1974,6 +2015,22 @@ static int send_file(int sock, int status, const char *ctype, const char *abspat
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 二进制安全子串查找:strstr 会因数据中的 NUL 提前停止,二进制(zip/mpk/图)必须用 memmem。
|
||||
* 返回 needle 在 hay[0..hlen) 中首次出现的指针;找不到 NULL。
|
||||
* 仅当 mot 出现,用 memmem(_GNU_SOURCE 已定义,glibc/musl 均提供)。
|
||||
* 若无 memmem(极端环境),退回手写字节扫描,保证可移植。 */
|
||||
static const char *mem_find(const void *hay, size_t hlen, const char *needle)
|
||||
{
|
||||
size_t nl = strlen(needle);
|
||||
if (!nl || hlen < nl) return NULL;
|
||||
const char *h = (const char *)hay;
|
||||
for (size_t i = 0; i + nl <= hlen; i++) {
|
||||
if (h[i] == needle[0] && memcmp(h + i, needle, nl) == 0)
|
||||
return h + i;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---------------- multipart/form-data 解析(文件上传) ----------------
|
||||
* 解析 body,提取 name=<path> 字段与 name=file 字段(可多个)。写到 target dir(白名单校验)。
|
||||
* 返回 0=成功(把写入的 item 用 items_json 回填),负错误码语义同协议。
|
||||
@@ -2023,7 +2080,7 @@ static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles)
|
||||
int nfiles = 0;
|
||||
char curpath[1024] = "";
|
||||
while (p < end) {
|
||||
const char *fstart = strstr(p, dmark);
|
||||
const char *fstart = mem_find(p, (size_t)(end - p), dmark);
|
||||
if (!fstart) break;
|
||||
fstart += strlen(dmark);
|
||||
if (*fstart == '-') break; /* 结束标记 --boundary-- */
|
||||
@@ -2051,8 +2108,8 @@ static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles)
|
||||
if (nm) { nm += 6; char *q = strchr(nm, '"'); if (q) { *q = 0; name = nm; } }
|
||||
}
|
||||
}
|
||||
/* 数据结束:下一个 "--boundary" 之前 */
|
||||
const char *data_end = strstr(data_start, dmark);
|
||||
/* 数据结束:下一个 "--boundary" 之前(二进制安全) */
|
||||
const char *data_end = mem_find(data_start, (size_t)(end - data_start), dmark);
|
||||
if (!data_end) data_end = end;
|
||||
/* 去掉数据尾部 \r\n */
|
||||
while (data_end > data_start && (data_end[-1] == '\r' || data_end[-1] == '\n')) data_end--;
|
||||
@@ -3333,6 +3390,399 @@ static int api_files_delete(int sock, const req_t *r)
|
||||
return send_json(sock, 200, "{\"removed\":true}");
|
||||
}
|
||||
|
||||
/* ---------------- 字体库 tar.gz 导入 / 导出(MeowOS/fonts/<family>/<weight>/) ----------------
|
||||
* 定案(用户 2026-08-31):**与 .mpk 同构,放弃 zip**——设备 busybox tar/gzip 必定存在,
|
||||
* 后端全部走 `gzip -dc | tar` 管道(nyai .mpk 同款),零新增依赖、不再自实现 zip 读写/miniz。
|
||||
* 导入(POST /api/fonts/import,multipart file=<.tar.gz> [&force=1]):
|
||||
* - 上传 tar.gz(上限 MAX_FONT_UPLOAD=32MB,gbk/full 字库 png+idx 可能 10-20MB)→
|
||||
* `gzip -dc | tar -t` 预校验成员名(sanitize_member:拒绝对路径/`..`/shell 危险字符,含三个以上条目疑拒);
|
||||
* 解压到临时目录 → opendir/lstat 逐个校验:恰为 font.png/font.idx/font.json 三个**普通文件**
|
||||
* (拒软链/目录/设备,拒解压后超大)→ 缺件/坏 json 硬失败。
|
||||
* - family/weight 取 font.json 的 family(缺省回退 name)/weight(缺省 regular),safe_dir_segment 消毒。
|
||||
* - rename 到 MeowOS/fonts/<family>/<weight>/;目标已存在则 409(前端确认后带 force=1 重试)。
|
||||
* - sha256_png/sha256_idx 仅 WARN(含在响应 warning 里);导入后**不触发 launcher 重扫**
|
||||
* (扫描时机=启动/进设置页,勿加热路径),响应用 hint 提示。
|
||||
* 导出(GET /api/fonts/export?family=&weight=):
|
||||
* - `tar -cf - -C <font_dir> font.png font.idx font.json | gzip -c > <tmp>` 打包为
|
||||
* `<family>-<weight>.font.tar.gz` 下载流(先落临时文件求 Content-Length 再流式)。
|
||||
* 列表(GET /api/fonts):扫描 fonts/<family>/<weight>/(三件套齐)供导出选择。
|
||||
*/
|
||||
#define FONT_EXTRACT_MAX (32u * 1024u * 1024u) /* 三件套解压后总大小上限(防 gzip 炸弹;gbk/full 亦在界内) */
|
||||
|
||||
static void fonts_root(char *buf, size_t cap) { snprintf(buf, cap, "%s/MeowOS/fonts", TF_ROOT); }
|
||||
|
||||
static int safe_dir_segment(const char *s, char *out, size_t cap)
|
||||
{
|
||||
if (!s || !s[0]) return -1;
|
||||
if (!strcmp(s, ".") || !strcmp(s, "..")) return -1;
|
||||
for (const char *p = s; *p; p++) {
|
||||
unsigned char c = (unsigned char)*p;
|
||||
if (c == '/' || c == '\\' || c < 0x20 || c == 0x7f) return -1;
|
||||
}
|
||||
if (strlen(s) + 1 > cap) return -1;
|
||||
snprintf(out, cap, "%s", s);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- tar 成员 sanitize(沿用 nyai 语义:拒绝对路径/`..` 组件/shell 危险字符) ---- */
|
||||
static int unsafe_path(const char *s)
|
||||
{
|
||||
return s && strpbrk(s, " ;`$'\"&|<>") != NULL;
|
||||
}
|
||||
static int has_dotdot_comp(const char *p)
|
||||
{
|
||||
const char *s = p;
|
||||
while (*s) {
|
||||
if (s[0] == '.' && s[1] == '.' &&
|
||||
(s == p || s[-1] == '/') && (s[2] == 0 || s[2] == '/'))
|
||||
return 1;
|
||||
s++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
/* 校验并相对化为平铺成员名:去前导 '/' 与重复 './',禁绝对/`..`/危险字符。返回 0=ok 写 out。 */
|
||||
static int sanitize_member(const char *in, char *out, int n)
|
||||
{
|
||||
if (!in || !*in) return -1;
|
||||
const char *p = in;
|
||||
while (*p == '/' || (p[0] == '.' && p[1] == '/')) p++;
|
||||
if (*p == '/' || !*p) return -1;
|
||||
if (has_dotdot_comp(p)) return -1;
|
||||
if (unsafe_path(p)) return -1;
|
||||
size_t l = strlen(p);
|
||||
if (l + 1 > (size_t)n) return -1;
|
||||
memcpy(out, p, l + 1); /* memcpy 而非 snprintf:p 可能来自长行缓冲,防 -Wformat-truncation 误报 */
|
||||
return 0;
|
||||
}
|
||||
/* 递归建目录(mkdir 系统调用;已存在 EEXIST 忽略)。 */
|
||||
static int mkdir_p(const char *path)
|
||||
{
|
||||
char buf[1600];
|
||||
snprintf(buf, sizeof buf, "%s", path);
|
||||
for (char *p = buf + 1; *p; p++) {
|
||||
if (*p == '/') { *p = 0; mkdir(buf, 0755); *p = '/'; }
|
||||
}
|
||||
return mkdir(buf, 0755) == 0 || errno == EEXIST ? 0 : -1;
|
||||
}
|
||||
|
||||
/* 逐一手写从 font.json 取字段(family/weight/name/tier/glyph_count/sha256_png/sha256_idx)。
|
||||
* 返回 0=ok;*pf 指向解析出的 J_STR(供复制)。 */
|
||||
static const jv_t *font_json_str(const jv_t *root, const char *key)
|
||||
{
|
||||
const jv_t *v = jv_obj_get(root, key);
|
||||
if (v && v->type == J_STR && v->str) return v;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 得到已装载字体库(family/weight 三级扫描 + 三件套齐)JSON 数组。 */
|
||||
static int api_fonts_list(int sock)
|
||||
{
|
||||
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
|
||||
char root[1100];
|
||||
fonts_root(root, sizeof root);
|
||||
DIR *fd = opendir(root);
|
||||
if (!fd) return send_json(sock, 200, "{\"fonts\":[]}");
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "{\"fonts\":[");
|
||||
int first = 1;
|
||||
struct dirent *fe;
|
||||
while ((fe = readdir(fd))) {
|
||||
if (fe->d_name[0] == '.') continue;
|
||||
char famdir[1700];
|
||||
snprintf(famdir, sizeof famdir, "%s/%s", root, fe->d_name);
|
||||
struct stat fst;
|
||||
if (stat(famdir, &fst) < 0 || !S_ISDIR(fst.st_mode)) continue;
|
||||
DIR *wd = opendir(famdir);
|
||||
if (!wd) continue;
|
||||
struct dirent *we;
|
||||
while ((we = readdir(wd))) {
|
||||
if (we->d_name[0] == '.') continue;
|
||||
char wdir[2200];
|
||||
snprintf(wdir, sizeof wdir, "%s/%s", famdir, we->d_name);
|
||||
struct stat wst;
|
||||
if (stat(wdir, &wst) < 0 || !S_ISDIR(wst.st_mode)) continue;
|
||||
char pp[2560], pi[2560], pj[2560];
|
||||
snprintf(pp, sizeof pp, "%s/font.png", wdir);
|
||||
snprintf(pi, sizeof pi, "%s/font.idx", wdir);
|
||||
snprintf(pj, sizeof pj, "%s/font.json", wdir);
|
||||
if (access(pp, R_OK) || access(pi, R_OK) || access(pj, R_OK)) continue;
|
||||
/* 解析 font.json(可选增强字段) */
|
||||
char name[256] = "", tier[64] = "";
|
||||
long long gc = 0;
|
||||
char jbuf[4096];
|
||||
if (read_small_file(pj, jbuf, sizeof jbuf) >= 0) {
|
||||
jv_t *jr = jv_parse(jbuf, (int)strlen(jbuf));
|
||||
if (jr) {
|
||||
const jv_t *v = font_json_str(jr, "name");
|
||||
if (v) snprintf(name, sizeof name, "%s", v->str);
|
||||
v = font_json_str(jr, "tier");
|
||||
if (v) snprintf(tier, sizeof tier, "%s", v->str);
|
||||
else { v = font_json_str(jr, "charset"); if (v) snprintf(tier, sizeof tier, "%s", v->str); }
|
||||
v = jv_obj_get(jr, "glyph_count");
|
||||
if (v && v->type == J_NUM) gc = v->inum;
|
||||
jv_free(jr);
|
||||
}
|
||||
}
|
||||
if (!name[0]) snprintf(name, sizeof name, "%s", fe->d_name);
|
||||
if (!first) sA(&s, ",");
|
||||
sA(&s, "{\"family\":");
|
||||
sA_str(&s, fe->d_name);
|
||||
sA(&s, ",\"weight\":");
|
||||
sA_str(&s, we->d_name);
|
||||
sA(&s, ",\"name\":");
|
||||
sA_str(&s, name);
|
||||
sA(&s, ",\"tier\":");
|
||||
sA_str(&s, tier);
|
||||
sA(&s, ",\"glyph_count\":%lld,\"mtime\":%lld}", gc, (long long)wst.st_mtime);
|
||||
first = 0;
|
||||
}
|
||||
closedir(wd);
|
||||
}
|
||||
closedir(fd);
|
||||
sA(&s, "]}");
|
||||
return send_json(sock, 200, s.b ? s.b : "{\"fonts\":[]}");
|
||||
}
|
||||
|
||||
/* 在 multipart body 中定位 name="file" 字段数据区间(r->body 内)。 */
|
||||
/* 对文件算 sha256 hex(64 字符,流式,不整读进内存)。返回 0=ok;-1=打开/读失败。 */
|
||||
static int sha256_file_hex(const char *path, char out[65])
|
||||
{
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) return -1;
|
||||
mp_sha256_ctx c;
|
||||
mp_sha256_init(&c);
|
||||
uint8_t buf[8192];
|
||||
ssize_t n;
|
||||
while ((n = read(fd, buf, sizeof buf)) > 0) mp_sha256_update(&c, buf, (size_t)n);
|
||||
close(fd);
|
||||
if (n < 0) return -1;
|
||||
uint8_t h[32];
|
||||
mp_sha256_final(&c, h);
|
||||
auth_hex(h, sizeof h, out);
|
||||
out[64] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int api_fonts_import(int sock, const req_t *r)
|
||||
{
|
||||
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
|
||||
char force[8] = "0";
|
||||
qparam(r->query, "force", force, sizeof force);
|
||||
long uni = (long)time(NULL);
|
||||
char tmpdir[1500], tmpgz[1500], stg[1500];
|
||||
snprintf(tmpdir, sizeof tmpdir, "%s/tmp", TF_CARD_DIR);
|
||||
if (mkdir_p(tmpdir) != 0) return send_err(sock, 500, "INTERNAL", "建临时目录失败");
|
||||
snprintf(tmpgz, sizeof tmpgz, "%s/tmp/mp_font_%d_%ld.tar.gz", TF_CARD_DIR, (int)getpid(), uni);
|
||||
snprintf(stg, sizeof stg, "%s/tmp/mp_font_stg_%d_%ld", TF_CARD_DIR, (int)getpid(), uni);
|
||||
|
||||
/* ① 写 multipart file 到临时 tar.gz */
|
||||
if (multipart_extract_file(r, tmpgz) < 0)
|
||||
return send_err(sock, 400, "BAD_REQUEST", "缺字体库 tar.gz(multipart 字段 file)");
|
||||
|
||||
/* ② 成员名预校验:gzip -dc | tar -t → 每名 sanitize,且恰为三件套平铺(拒绝对/.. / 多出条目) */
|
||||
{
|
||||
char cmd[3200];
|
||||
snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -t 2>/dev/null", tmpgz);
|
||||
FILE *pf = popen(cmd, "r");
|
||||
if (!pf) { unlink(tmpgz); return send_err(sock, 500, "INTERNAL", "无法列 tar 成员"); }
|
||||
int bad = 0, have_png = 0, have_idx = 0, have_json = 0, nent = 0;
|
||||
char line[512];
|
||||
while (fgets(line, sizeof line, pf)) {
|
||||
char *e = strchr(line, '\n'); if (e) *e = 0;
|
||||
if (!line[0]) continue;
|
||||
char san[128];
|
||||
if (sanitize_member(line, san, sizeof san) < 0) { bad = 1; break; }
|
||||
if (!strcmp(san, "font.png")) have_png = 1;
|
||||
else if (!strcmp(san, "font.idx")) have_idx = 1;
|
||||
else if (!strcmp(san, "font.json")) have_json = 1;
|
||||
else { bad = 1; break; }
|
||||
nent++;
|
||||
}
|
||||
pclose(pf);
|
||||
if (bad || nent != 3 || !have_png || !have_idx || !have_json) {
|
||||
unlink(tmpgz);
|
||||
return send_err(sock, 400, "BAD_REQUEST", "tar 缺三件套或含非法成员(拒绝绝对路径/../多出条目)");
|
||||
}
|
||||
}
|
||||
|
||||
/* ③ 解压到临时目录 */
|
||||
rm_recursive(stg);
|
||||
if (mkdir_p(stg) != 0) { unlink(tmpgz); return send_err(sock, 500, "INTERNAL", "建临时目录失败"); }
|
||||
{
|
||||
char cmd[3200];
|
||||
snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -x -C '%s' 2>/dev/null", tmpgz, stg);
|
||||
if (system(cmd) != 0) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 400, "BAD_REQUEST", "tar 解压失败(非 gzip/tar?)"); }
|
||||
}
|
||||
|
||||
/* ④ 目录锁:恰为 3 个普通文件(lstat 拒软链/目录/设备),总大小 ≤ FONT_EXTRACT_MAX(防炸弹) */
|
||||
{
|
||||
static const char *names[3] = { "font.png", "font.idx", "font.json" };
|
||||
int seen[3] = {0,0,0}, cnt = 0, ok = 1;
|
||||
DIR *d = opendir(stg);
|
||||
if (!d) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 500, "INTERNAL", "读临时目录失败"); }
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d))) {
|
||||
if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
|
||||
int idx = -1;
|
||||
for (int i = 0; i < 3; i++) if (!strcmp(de->d_name, names[i])) { idx = i; break; }
|
||||
if (idx < 0) { ok = 0; break; }
|
||||
char fp[1800]; snprintf(fp, sizeof fp, "%s/%s", stg, de->d_name);
|
||||
struct stat st;
|
||||
if (lstat(fp, &st) != 0 || !S_ISREG(st.st_mode)) { ok = 0; break; } /* 拒软链/目录/设备 */
|
||||
seen[idx] = 1; cnt++;
|
||||
}
|
||||
closedir(d);
|
||||
long long tot = 0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (!seen[i]) { ok = 0; break; }
|
||||
char fp[1600]; snprintf(fp, sizeof fp, "%s/%s", stg, names[i]);
|
||||
struct stat st;
|
||||
if (stat(fp, &st) != 0) { ok = 0; break; }
|
||||
tot += (long long)st.st_size;
|
||||
}
|
||||
if (!ok || cnt != 3 || tot > (long long)FONT_EXTRACT_MAX) {
|
||||
rm_recursive(stg); unlink(tmpgz);
|
||||
return send_err(sock, 400, "BAD_REQUEST", "tar 内容非法(缺件/软链/多出条目/解压后超大)");
|
||||
}
|
||||
}
|
||||
|
||||
/* ⑤ 读 font.json → family/weight(可解析硬失败) */
|
||||
char jpath[1600]; snprintf(jpath, sizeof jpath, "%s/font.json", stg);
|
||||
char jbuf[8192];
|
||||
if (read_small_file(jpath, jbuf, sizeof jbuf) < 0) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 400, "BAD_REQUEST", "font.json 缺失"); }
|
||||
jv_t *jr = jv_parse(jbuf, (int)strlen(jbuf));
|
||||
if (!jr) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 400, "BAD_REQUEST", "font.json 解析失败"); }
|
||||
char family[128] = "font", weight[64] = "regular";
|
||||
const jv_t *v = font_json_str(jr, "family");
|
||||
if (v) snprintf(family, sizeof family, "%s", v->str);
|
||||
else { v = font_json_str(jr, "name"); if (v) snprintf(family, sizeof family, "%s", v->str); }
|
||||
v = font_json_str(jr, "weight");
|
||||
if (v) snprintf(weight, sizeof weight, "%s", v->str);
|
||||
char fam_s[128], wt_s[64];
|
||||
if (safe_dir_segment(family, fam_s, sizeof fam_s) < 0) { jv_free(jr); rm_recursive(stg); unlink(tmpgz); return send_err(sock, 422, "UNSUPPORTED", "font.json family 非法"); }
|
||||
if (safe_dir_segment(weight, wt_s, sizeof wt_s) < 0) { jv_free(jr); rm_recursive(stg); unlink(tmpgz); return send_err(sock, 422, "UNSUPPORTED", "font.json weight 非法"); }
|
||||
snprintf(family, sizeof family, "%s", fam_s);
|
||||
snprintf(weight, sizeof weight, "%s", wt_s);
|
||||
|
||||
/* ⑥ 目标目录 + 覆盖冲突(409,除非 force=1) */
|
||||
char root[1100], target[1500];
|
||||
fonts_root(root, sizeof root);
|
||||
snprintf(target, sizeof target, "%s/%s/%s", root, family, weight);
|
||||
if (strcmp(force, "1") != 0) {
|
||||
static const char *names3[] = { "font.png", "font.idx", "font.json" };
|
||||
char tp[1600]; int conflicted = 0;
|
||||
for (int i = 0; i < 3; i++) { snprintf(tp, sizeof tp, "%s/%s", target, names3[i]); if (access(tp, F_OK) == 0) { conflicted = 1; break; } }
|
||||
if (conflicted) { jv_free(jr); rm_recursive(stg); unlink(tmpgz); return send_err(sock, 409, "CONFLICT", "目标字库已存在,请确认覆盖"); }
|
||||
}
|
||||
|
||||
/* ⑦ sha256 仅 WARN(对齐 font.json 约定,不硬失败) */
|
||||
char warn[256] = "";
|
||||
{
|
||||
const jv_t *wp = font_json_str(jr, "sha256_png");
|
||||
const jv_t *wi = font_json_str(jr, "sha256_idx");
|
||||
char need[65], got[65];
|
||||
if (wp && wp->str[0]) {
|
||||
snprintf(need, sizeof need, "%s", wp->str);
|
||||
char fp[1600]; snprintf(fp, sizeof fp, "%s/font.png", stg);
|
||||
if (sha256_file_hex(fp, got) == 0 && strcasecmp(need, got) != 0)
|
||||
snprintf(warn, sizeof warn, "sha256_png 不匹配(源库校验失败,但已导入)");
|
||||
}
|
||||
if (wi && wi->str[0] && !warn[0]) {
|
||||
snprintf(need, sizeof need, "%s", wi->str);
|
||||
char fp[1600]; snprintf(fp, sizeof fp, "%s/font.idx", stg);
|
||||
if (sha256_file_hex(fp, got) == 0 && strcasecmp(need, got) != 0)
|
||||
snprintf(warn, sizeof warn, "sha256_idx 不匹配(源库校验失败,但已导入)");
|
||||
}
|
||||
}
|
||||
|
||||
/* ⑧ 建目标目录 + rename 三件套(原子替换) */
|
||||
{
|
||||
char p1[1400];
|
||||
snprintf(p1, sizeof p1, "%s/%s", root, family);
|
||||
mkdir(p1, 0755);
|
||||
mkdir(target, 0755);
|
||||
}
|
||||
{
|
||||
static const char *names3[] = { "font.png", "font.idx", "font.json" };
|
||||
int ok = 1;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char sp[1600], tp[1600];
|
||||
snprintf(sp, sizeof sp, "%s/%s", stg, names3[i]);
|
||||
snprintf(tp, sizeof tp, "%s/%s", target, names3[i]);
|
||||
if (rename(sp, tp) < 0) ok = 0;
|
||||
}
|
||||
if (!ok) { rm_recursive(stg); unlink(tmpgz); jv_free(jr); return send_err(sock, 500, "INTERNAL", "写入字体文件失败(磁盘满?)"); }
|
||||
}
|
||||
rm_recursive(stg);
|
||||
unlink(tmpgz);
|
||||
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "{\"family\":");
|
||||
sA_str(&s, family);
|
||||
sA(&s, ",\"weight\":");
|
||||
sA_str(&s, weight);
|
||||
sA(&s, ",\"imported\":true,\"hint\":\"字体已导入。请进设备「设置 → 字体」页,字体列表在进入时自动重扫生效。\",\"warning\":");
|
||||
sA_str(&s, warn[0] ? warn : "");
|
||||
sA(&s, "}");
|
||||
jv_free(jr);
|
||||
return send_json(sock, 200, s.b ? s.b : "");
|
||||
}
|
||||
|
||||
static int api_fonts_export(int sock, const req_t *r)
|
||||
{
|
||||
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
|
||||
char family[128], weight[64];
|
||||
if (!qparam(r->query, "family", family, sizeof family)) family[0] = 0;
|
||||
if (!qparam(r->query, "weight", weight, sizeof weight)) weight[0] = 0;
|
||||
char f[128], w[64];
|
||||
if (safe_dir_segment(family, f, sizeof f) < 0) return send_err(sock, 400, "BAD_REQUEST", "family 非法");
|
||||
if (!weight[0]) snprintf(w, sizeof w, "%s", "regular");
|
||||
else if (safe_dir_segment(weight, w, sizeof w) < 0) return send_err(sock, 400, "BAD_REQUEST", "weight 非法");
|
||||
char root[1100], dir[1500];
|
||||
fonts_root(root, sizeof root);
|
||||
snprintf(dir, sizeof dir, "%s/%s/%s", root, f, w);
|
||||
static const char *names3[] = { "font.png", "font.idx", "font.json" };
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char ap[1600];
|
||||
snprintf(ap, sizeof ap, "%s/%s", dir, names3[i]);
|
||||
struct stat st;
|
||||
if (stat(ap, &st) < 0) {
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "font."); sA_str(&s, names3[i]);
|
||||
sA(&s, " 缺失(字库 <");
|
||||
sA_str(&s, f); sA(&s, "/"); sA_str(&s, w); sA(&s, "> 不完整)");
|
||||
int rc = send_err(sock, 404, "NOT_FOUND", s.b ? s.b : "字体三件套缺失");
|
||||
sbuf_free(&s);
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
/* 打包:tar -cf - -C <dir> 三件套 | gzip -c > <tmp>,落临时文件求 Content-Length 再流式下发 */
|
||||
long uni = (long)time(NULL);
|
||||
char tmpdir[1500], tmp[1500];
|
||||
snprintf(tmpdir, sizeof tmpdir, "%s/tmp", TF_CARD_DIR);
|
||||
if (mkdir_p(tmpdir) != 0) return send_err(sock, 500, "INTERNAL", "建临时目录失败");
|
||||
snprintf(tmp, sizeof tmp, "%s/tmp/mp_font_exp_%d_%ld.tar.gz", TF_CARD_DIR, (int)getpid(), uni);
|
||||
char cmd[3600];
|
||||
snprintf(cmd, sizeof cmd, "tar -cf - -C '%s' font.png font.idx font.json 2>/dev/null | gzip -c > '%s' 2>/dev/null", dir, tmp);
|
||||
if (system(cmd) != 0) { unlink(tmp); return send_err(sock, 500, "INTERNAL", "打包失败"); }
|
||||
struct stat st;
|
||||
if (stat(tmp, &st) < 0 || !S_ISREG(st.st_mode)) { unlink(tmp); return send_err(sock, 500, "INTERNAL", "打包文件不可读"); }
|
||||
char fname[220];
|
||||
snprintf(fname, sizeof fname, "%s-%s.font.tar.gz", f, w);
|
||||
char extra[300];
|
||||
snprintf(extra, sizeof extra, "Content-Disposition: attachment; filename=\"%s\"", fname);
|
||||
if (send_head(sock, 200, "application/gzip", (long long)st.st_size, extra) < 0) { unlink(tmp); return -1; }
|
||||
int fd = open(tmp, O_RDONLY);
|
||||
if (fd < 0) { unlink(tmp); return -1; }
|
||||
char buf[8192];
|
||||
ssize_t n;
|
||||
while ((n = read(fd, buf, sizeof buf)) > 0)
|
||||
if (write_full(sock, buf, (size_t)n) < 0) { close(fd); unlink(tmp); return -1; }
|
||||
close(fd);
|
||||
unlink(tmp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------------- 应用(MeowAPPs 扫描 + nyai) ---------------- */
|
||||
/* 执行命令(sh -c),捕获 stdout(上限 cap-1),返回退出状态(-1=无法启动)。 */
|
||||
static int run_cmd(const char *cmd, char *out, int outcap)
|
||||
@@ -3433,7 +3883,7 @@ static int multipart_extract_file(const req_t *r, const char *outpath)
|
||||
}
|
||||
}
|
||||
const char *ds = he + 4;
|
||||
const char *de = strstr(ds, dmark);
|
||||
const char *de = mem_find(ds, (size_t)((r->body + r->body_len) - ds), dmark);
|
||||
if (!de) de = r->body + r->body_len;
|
||||
while (de > ds && (de[-1] == '\r' || de[-1] == '\n')) de--;
|
||||
if (isfile) {
|
||||
@@ -3930,6 +4380,11 @@ static int handle(int sock, req_t *r)
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/system"))
|
||||
return api_system(sock);
|
||||
|
||||
/* ---- 字体库(导入/导出/列表) ---- */
|
||||
if (!strcmp(r->method, "POST") && !strcmp(p, "/api/fonts/import")) return api_fonts_import(sock, r);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts")) return api_fonts_list(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts/export")) return api_fonts_export(sock, r);
|
||||
|
||||
/* ---- 实时画面(阶段 C) ---- */
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/screen.jpeg"))
|
||||
return api_screen_jpeg(sock, r);
|
||||
|
||||
Binary file not shown.
@@ -608,8 +608,49 @@
|
||||
if (!g[1].length) return;
|
||||
h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
|
||||
});
|
||||
// 字体库(.zip 导入 / 导出)
|
||||
h += '<div class="card" id="fonts-card"><h3 class="card-title">字体库(.tar.gz 导入 / 导出)</h3>' +
|
||||
'<div class="field-row"><button class="btn" id="font-import">导入字体库 .tar.gz</button>' +
|
||||
'<input type="file" id="font-file" accept=".tar.gz,.tgz,application/gzip" hidden></div>' +
|
||||
'<div id="font-list"><p class="muted">加载字体库列表…</p></div>' +
|
||||
'<p class="muted" style="margin-top:8px">导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。</p></div>';
|
||||
h += '</div>';
|
||||
$('#content').innerHTML = h;
|
||||
loadFonts();
|
||||
}
|
||||
async function loadFonts() {
|
||||
const box = $('#font-list'); if (!box) return;
|
||||
let d;
|
||||
try { d = await api('GET', '/api/fonts'); }
|
||||
catch (e) { box.innerHTML = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
|
||||
const fs = (d && d.fonts) || [];
|
||||
if (!fs.length) { box.innerHTML = '<p class="muted">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。</p>'; return; }
|
||||
box.innerHTML = fs.map(f => {
|
||||
const nm = esc(f.name || f.family || '');
|
||||
const meta = esc((f.family || '') + '/' + (f.weight || '') + (f.tier ? ' · ' + f.tier : '') + (f.glyph_count ? ' · ' + f.glyph_count + ' 字' : ''));
|
||||
return '<div class="field-row"><div style="flex:1;overflow:hidden;text-overflow:ellipsis">' +
|
||||
'<div>' + nm + '</div><div class="muted">' + meta + '</div></div>' +
|
||||
'<button class="btn" data-fexport="' + esc(f.family || '') + '" data-fweight="' + esc(f.weight || '') + '">导出</button></div>';
|
||||
}).join('');
|
||||
}
|
||||
async function importFont(files) {
|
||||
if (!files || !files.length) return;
|
||||
const f = files[0];
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
toast('正在导入 ' + f.name + '…');
|
||||
let res;
|
||||
try { res = await api('POST', '/api/fonts/import', fd); }
|
||||
catch (err) {
|
||||
if (err.code === 'CONFLICT') {
|
||||
const ok = await confirmDlg('覆盖字库', '目标字库已存在,覆盖导入吗?', true);
|
||||
if (!ok) return;
|
||||
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
|
||||
catch (e2) { toast('导入失败:' + errText(e2)); return; }
|
||||
} else { toast('导入失败:' + errText(err)); return; }
|
||||
}
|
||||
toast('已导入 ' + ((res && res.family) || '') + '/' + ((res && res.weight) || ''));
|
||||
if (res && res.hint) toast(res.hint);
|
||||
loadFonts();
|
||||
}
|
||||
function bindSettings() {
|
||||
const c = $('#content');
|
||||
@@ -619,6 +660,7 @@
|
||||
updateSetting(r.dataset.skey, +r.value, 'int');
|
||||
});
|
||||
c.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'font-file') { importFont(e.target.files); return; }
|
||||
const t = e.target;
|
||||
const sk = t.dataset.skey; if (!sk) return;
|
||||
if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool');
|
||||
@@ -626,6 +668,13 @@
|
||||
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
|
||||
});
|
||||
c.addEventListener('click', async function (e) {
|
||||
if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; }
|
||||
const fe = e.target.closest('[data-fexport]');
|
||||
if (fe) {
|
||||
const f = fe.dataset.fexport, w = fe.dataset.fweight;
|
||||
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz');
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
|
||||
if (e.target.closest('#set-restore')) {
|
||||
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
|
||||
|
||||
Reference in new issue
Block a user