前言

在开发飞牛 fnOS 应用商店的过程中,我们为 fn-memos 应用新增了 CGI 升级面板。整个过程踩了不少坑,从环境变量不可用到路径解析错误,再到 stderr 污染 CGI 响应。本文将完整记录使用 CGI + HTML 开发飞牛应用的正确姿势和常见陷阱。

为什么需要 CGI?

fn-memos 是一个直接运行 CLI 二进制文件的飞牛应用,没有独立的 Web 界面。但用户希望能通过 Web 页面管理 Memos 的升级,这时就需要用到飞牛的 CGI 机制——在应用内提供一个 Web 入口,用户点击桌面图标即可打开升级管理界面。

CGI 文件结构

应用目录结构如下:

fn-memos/
├── app/
│   ├── bin/
│   │   └── memos              # Memos CLI 二进制文件
│   ├── ui/
│   │   ├── images/
│   │   │   ├── icon_64.png
│   │   │   └── icon_256.png
│   │   ├── config              # 桌面入口配置
│   │   ├── index.cgi           # CGI 可执行文件(必须叫这个名字)
│   │   └── api.cgi             # 升级 API 接口
│   └── www/
│       ├── index.html          # 页面结构
│       ├── index.css           # 样式
│       └── index.js            # 交互逻辑
├── cmd/
│   ├── main                    # 应用启停脚本
│   ├── install_callback        # 安装回调
│   └── ...
├── wizard/
│   └── install                 # 安装向导配置
├── manifest
└── ...

入口配置

app/ui/config 中定义桌面图标入口:

{
  ".url": {
    "fn-memos.upgrade": {
      "title": "Memos 管理",
      "icon": "images/icon_{0}.png",
      "type": "iframe",
      "protocol": "http",
      "url": "/cgi/ThirdParty/fn-memos/index.cgi/",
      "allUsers": false
    }
  }
}

关键点:url 必须以 index.cgi/ 结尾。系统会将请求路由到 /cgi/ThirdParty/{appname}/index.cgi/ 下。

index.cgi:必须叫这个名字

CGI 可执行文件必须命名为 index.cgi。系统只识别这个固定文件名,不能叫 upgrade.cgi 或其他名字。

最简单的 index.cgi 模板:

#!/bin/bash

# 全局抑制 stderr,避免污染 CGI 响应
exec 2>/dev/null

# 输出 HTTP 头
echo "Content-Type: text/html; charset=utf-8"
echo ""

HTML_PATH="/var/apps/fn-memos/target/www/index.html"

if [ ! -f "$HTML_PATH" ]; then
    echo "<h1>错误:index.html 不存在</h1>"
    exit 1
fi

cat "$HTML_PATH"

如果需要支持 CSS、JS 等静态资源,需要加上路径解析和 MIME 分发:

#!/bin/bash
exec 2>/dev/null

BASE="/var/apps/fn-memos/target/www"

# 从 REQUEST_URI 提取 index.cgi 后面的路径
URI_NO_QUERY="${REQUEST_URI%%\?*}"
REL_PATH="/"
case "$URI_NO_QUERY" in
  *index.cgi*)
    REL_PATH="${URI_NO_QUERY#*index.cgi}"
    ;;
esac

# 缺省返回 index.html
[ -z "$REL_PATH" ] || [ "$REL_PATH" = "/" ] && REL_PATH="/index.html"

# 防 .. 越级
case "$REL_PATH" in *..*) 
    echo "Status: 400"
    echo "Content-Type: text/plain"
    echo ""
    echo "Bad Request"
    exit 0 ;;
esac

FILE="${BASE}${REL_PATH}"
[ ! -f "$FILE" ] && exit 0

# MIME 分发
case "${FILE##*.}" in
  html) mime="text/html; charset=utf-8" ;;
  css)  mime="text/css; charset=utf-8" ;;
  js)   mime="application/javascript; charset=utf-8" ;;
  *)    mime="application/octet-stream" ;;
esac

echo "Content-Type: $mime"
echo ""
cat "$FILE"

坑一:TRIM_* 环境变量在 CGI 中不可用

飞牛在 cmd/ 生命周期脚本中提供了大量 TRIM_* 环境变量(如 TRIM_APPDESTTRIM_PKGTMP),详见官方文档《环境变量》。但这些变量在 CGI 脚本中不存在

CGI 由飞牛的 Web 服务器执行,只注入标准 CGI 变量(REQUEST_URIQUERY_STRINGREQUEST_METHODCONTENT_LENGTH 等)。

错误写法:

BIN_DIR="${TRIM_APPDEST}/bin"  # ❌ CGI 中为空

正确写法:

# 硬编码系统路径,对应 target 目录
BIN_DIR="/var/apps/fn-memos/target/bin"
MEMOS_BIN="${BIN_DIR}/memos"
TEMP_DIR="/var/apps/fn-memos/tmp"

坑二:stderr 污染 CGI 响应

CGI 脚本的任何 stderr 输出都会混入 HTTP 响应体,导致前端收到非 JSON 内容而报 Unexpected token 错误。

必须在脚本开头全局抑制:

exec 2>/dev/null  # 在脚本开头加上这一行

所有子命令的错误输出也要小心处理,建议重定向到日志文件:

curl ... 2>>/var/apps/fn-memos/var/upgrade.log

坑三:前端 fetch 的相对路径陷阱

页面 URL 是 /cgi/ThirdParty/fn-memos/index.cgi/,如果 JS 中写相对路径 fetch("api.cgi"),浏览器会解析为:

/cgi/ThirdParty/fn-memos/index.cgi/api.cgi  ❌ 错误!

因为 Web 服务器把 index.cgi/ 当成目录前缀,相对路径会追加到后面。必须使用绝对路径:

fetch("/cgi/ThirdParty/fn-memos/api.cgi?action=check")  ✅ 正确

坑四:向导配置在 CGI 中不可用

安装向导(wizard/install)中用户选择的端口号、存储类型等配置,只在 cmd/install_callback 等生命周期脚本中作为环境变量可用(如 $wizard_port)。CGI 中无法直接获取。

解决方案:在 install_callback 中将配置持久化到文件,CGI 中读取:

#!/bin/bash
# cmd/install_callback

CNF_FILE="${TRIM_APPDEST}/wizard/install.cnf"
mkdir -p "$(dirname "$CNF_FILE")"

cat > "$CNF_FILE" <<EOF
MEMOS_PORT="${wizard_port:-5230}"
MEMOS_STORAGE="${wizard_storage_type:-local}"
MEMOS_DSN="${wizard_dsn:-}"
EOF

CGI 中读取:

CNF_FILE="/var/apps/fn-memos/target/wizard/install.cnf"
if [ -r "$CNF_FILE" ]; then
    . "$CNF_FILE"
fi

坑五:CGI 中不能调用 cmd/main 启停应用

cmd/main 脚本依赖飞牛注入的环境变量(如 $wizard_port$wizard_storage_type),在 CGI 中直接调用会导致端口和存储配置丢失。

正确做法:读取持久化的配置文件,自行拼装启动命令:

case "$MEMOS_STORAGE" in
    mysql)
        CMD="${MEMOS_BIN} --port ${MEMOS_PORT} --data ${DATA_DIR} --driver mysql --dsn \"${MEMOS_DSN}\""
        ;;
    postgres)
        CMD="${MEMOS_BIN} --port ${MEMOS_PORT} --data ${DATA_DIR} --driver postgres --dsn \"${MEMOS_DSN}\""
        ;;
    *)
        CMD="${MEMOS_BIN} --port ${MEMOS_PORT} --data ${DATA_DIR}"
        ;;
esac

bash -c "${CMD}" >> "${INFO_LOG}" 2>&1 &
printf "%s" "$!" > "${PID_FILE}"

升级面板实现效果

最终实现的升级面板包含以下功能:

  • 自动检测当前版本和 GitHub 最新版本
  • 一键下载最新 Memos 二进制文件并替换
  • 替换后自动重启进程(读取 PID 文件 → TERM → KILL → 新进程 → 写 PID)
  • 终端风格的升级日志面板,实时展示操作日志
  • Dialog 弹窗确认升级和结果反馈

总结:CGI 开发要点清单

要点 说明
文件名 必须是 index.cgi
路径 硬编码 /var/apps/{appname}/target/...,不用 TRIM 变量
stderr 开头加 exec 2>/dev/null
fetch 路径 用绝对路径 /cgi/ThirdParty/{app}/api.cgi
权限 chmod +x *.cgi
URL 配置 /index.cgi/ 结尾
向导配置 install_callback 中写入文件,CGI 中 source 读取
启停应用 读取持久化配置自行启停,不要直接调用 cmd/main

完整的 fn-memos 源码可以在 GitHub 查看。飞牛开发文档详见 飞牛应用开放平台