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:
MeowOS committed 2026-08-31 20:45:52 +08:00
1 parent 4e3f41a5a3
commit d8b95c0aab
7 files changed
+580 -23

No files matched your search

+465 -10
View File
@@ -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);