Files
2026-10-29 18:46:39 +08:00

3934 lines
145 KiB
Bash
Executable File

#!/bin/sh
. /usr/share/libubox/jshn.sh
readonly ICON_PATH="/www/luci-static/aurora/images"
readonly FONT_PATH="/www/luci-static/aurora/fonts"
readonly FONT_CSS_FILE="$FONT_PATH/aurora-font.css"
readonly FONT_JOB_PATH="/tmp/aurora_font_jobs"
readonly FONT_TMP_UPLOAD="/tmp/aurora_font.tmp"
readonly FONT_CUSTOM_PATH="$FONT_PATH/custom"
readonly MAX_UPLOAD=8388608
# 分享一张图片的上限,与 hub 的 OTHER_ASSET_LIMIT 同为 2 MiB。刻意不是
# MAX_UPLOAD —— 那是 receive_upload 的上传闸门,图片和字体共用它,收紧它会连
# 字体一起收紧(字体在 hub 那边有自己的 8 MiB 额度)。分开之后,一张 3 MiB 的
# 登录背景仍然能上传、能在本机用,只是不随主题发出去。
readonly MAX_SHARED_IMAGE=2097152
# 工具栏图标自己一档,见 toolbar_icon_shareable。槽位数与 hub 的
# TOOLBAR_MAX_ITEMS / build_share_payload 的 12 项上限对齐。
readonly MAX_SHARED_TOOLBAR_ICON=262144
readonly MAX_SHARED_TOOLBAR_ICONS=12
readonly TMP_UPLOAD_PATH="/tmp/aurora_icon.tmp"
readonly CONFIG_FILE="/etc/config/aurora"
readonly DEFAULT_TEMPLATE="/usr/share/aurora/default.template"
readonly SAGE_GREEN_TEMPLATE="/usr/share/aurora/sage-green.template"
readonly AMBER_SAND_TEMPLATE="/usr/share/aurora/amber-sand.template"
readonly MONOCHROME_TEMPLATE="/usr/share/aurora/monochrome.template"
readonly SKY_BLUE_TEMPLATE="/usr/share/aurora/sky-blue.template"
readonly FONT_PRESETS_FILE="/usr/share/aurora/font-presets.conf"
readonly IMPORT_CONFIG_TMP="/tmp/aurora_config_import.tmp"
readonly EXPORT_CONFIG_TMP="/tmp/aurora_config_export.conf"
readonly COLOR_TOKENS_FILE="/usr/share/aurora/color-tokens.conf"
readonly HUB_BASE="https://themes.eamonxg.fun"
readonly AURORA_FEED_HOST="openwrt.eamonxg.fun"
# opkg names a trusted key file after its keynum: the hex of bytes 3-10 of the
# base64-decoded key line. Computed once with openwrt-cloud's usign-fpr.sh so
# devices need no usign binary. Rotating the key means changing this too.
readonly AURORA_FEED_FPR="82d72f5ededb6163"
readonly AURORA_FEED_CHANNEL="snapshots"
readonly AURORA_FEED_KEYS="/usr/share/aurora/feed"
readonly DEVICE_DIR="/etc/aurora"
readonly HUB_JOB_PATH="/tmp/aurora_hub_jobs"
# Editable inputs + engine-derived tokens. The list ships as a data file
# generated by scripts/sync-tokens.mjs from the theme's token spec, alongside
# the preset templates emitted from the same registry, so template contents and
# this validation list cannot drift apart. expected_count below derives from it
# (count * 2 for light_/dark_).
readonly COLOR_TOKEN_KEYS="$(grep -v '^#' "$COLOR_TOKENS_FILE" 2>/dev/null)"
# Number of color options a valid preset template must contain (every token in
# both light_ and dark_ variants). Derived once from COLOR_TOKEN_KEYS.
PRESET_EXPECTED_COUNT=$(( $(printf '%s\n' $COLOR_TOKEN_KEYS | grep -c .) * 2 ))
# Shared gate for uploaded tmp files: size cap, per-kind content checks,
# and tmp cleanup on every rejection. Callers get return 0 only when the
# tmp file is safe to move into place; on failure the JSON error has
# already been emitted, so call sites just `|| exit 0`.
receive_upload() {
ru_tmp="$1"; ru_kind="$2"; ru_name="$3"
[ -f "$ru_tmp" ] || {
echo '{ "result": 1, "error": "No uploaded file" }'; return 1; }
ru_size=$(wc -c < "$ru_tmp")
[ "$ru_size" -le "$MAX_UPLOAD" ] || {
rm -f "$ru_tmp"
echo '{ "result": 1, "error": "File exceeds 8MB limit" }'; return 1; }
case "$ru_kind" in
font)
[ "$(head -c 4 "$ru_tmp")" = "wOF2" ] || {
rm -f "$ru_tmp"
echo '{ "result": 1, "error": "Not a woff2 file" }'; return 1; }
;;
image)
case "$ru_name" in
""|*/*|*..*)
rm -f "$ru_tmp"
echo '{ "result": 255 }'; return 1 ;;
esac
ru_ext=$(printf '%s' "$ru_name" | sed 's/.*\.//' | tr 'A-Z' 'a-z')
case "$ru_ext" in
jpg|jpeg|png|webp|svg|ico) ;;
*)
rm -f "$ru_tmp"
echo '{ "result": 1, "error": "Unsupported image type" }'; return 1 ;;
esac
;;
*)
rm -f "$ru_tmp"
echo '{ "result": 1, "error": "Unknown upload kind" }'; return 1 ;;
esac
return 0
}
# Fail fast when the shipped key list is missing or empty: with zero keys the
# expected-count check degrades to 0==0, so preset/import validation would
# vacuously succeed while writing no color keys at all. Called by every method
# that consumes COLOR_TOKEN_KEYS before it validates anything.
require_color_token_keys() {
[ "$PRESET_EXPECTED_COUNT" -gt 0 ]
}
# Populated by load_preset_snapshot() with newline-separated "key=value" pairs
# for the requested preset; reused for both validation and emission so the
# template is parsed a single time.
PRESET_SNAPSHOT=""
# The layout and typography half of a preset. A built-in preset is a whole
# look, not a palette: navigation shape, spacing, corner radius, content width
# and both font stacks travel with the colours, exactly the fields
# validate_and_apply_hub_payload accepts from a shared configuration. Keeping
# the two lists identical is what makes "apply this theme" mean the same thing
# whether the theme came off the disk or off the hub.
readonly PRESET_STRUCT_KEYS="nav_type toolbar_enabled struct_spacing struct_radius_base struct_content_width_centered struct_font_sans struct_font_mono"
normalize_preset_name() {
local name="${1:-default}"
case "$name" in
classic) echo "default" ;;
*) echo "$name" ;;
esac
}
# 当前配置「长什么样」的指纹。取的键与两条 apply 路径写入的集合完全一致,
# 因此刚应用完必然相等,用户一改就不等 —— 这就是商店判断「钩子该落在哪张卡
# 上」的全部依据。不需要任何脏标志位,也就与 studio.js 完全解耦:工作室、
# import_config、手动 uci set、保留配置升级,任何一条路径改了外观都会被发现。
# 排序是必须的:uci show 不保证输出顺序,两份内容相同、顺序不同的配置若哈希
# 不同,每次 apply 之后立刻就会读回「已修改」。
# 资产选项(logo_svg/favicon_*/pwa_icon_*/struct_login_bg)刻意不取:它们存的是
# 文件名而不是字节,换一张图片文件名往往原封不动,哈希它只会给出虚假的确定性。
# 簿记项(active_*/hub_applied/icon_cache_version/hub_key_saved)也不取:它们每次
# apply 都变,取了就没有任何配置能等于自己。
theme_fingerprint() {
uci -q show aurora.theme 2>/dev/null \
| sed -n 's/^aurora\.theme\.//p' \
| grep -E '^(light_|dark_|nav_type=|struct_font_sans=|struct_font_mono=|struct_spacing=|struct_radius_base=|struct_content_width_centered=|toolbar_enabled=|struct_main_bg_alpha=|struct_main_bg_blur=|struct_main_bg_scrim=|struct_login_bg_alpha=|struct_login_bg_blur=|struct_login_bg_scrim=)' \
| sort \
| md5sum \
| cut -d' ' -f1
}
# 「当前外观是我自己的吗」。返回 0 = 是。
# 没有记录指纹时也判为「是」:那要么是本特性上线前就带着 hub_applied 的老配置
# (见 ensure_active_source),要么是有人手改过 uci —— 两种情况下这份配置都不对应
# 商店里的任何一张卡,把它当成用户自己的东西是唯一安全的答案:它会被备份,不会
# 被无声盖掉。
theme_modified() {
local recorded
recorded=$(uci -q get aurora.theme.active_fingerprint)
[ -n "$recorded" ] || return 0
[ "$recorded" != "$(theme_fingerprint)" ]
}
# 把老配置迁到单一真相 active_source 上。幂等:有值即返回。
# 三种取值:builtin:<preset-id> / hub:<hub-id> / mine。
# hub_applied 只存名字不存 id,反查不到商店条目,所以带 hub_applied 的老配置一律
# 降级成 mine 并清空指纹 —— 它会显示为「我的配置」并持有钩子。这是正确的降级
# 而不是丢失:那份配置确实不对应任何一张卡,而它自己完好无损。
ensure_active_source() {
local preset
[ -z "$(uci -q get aurora.theme.active_source)" ] || return 0
if [ -n "$(uci -q get aurora.theme.hub_applied)" ]; then
uci -q set "aurora.theme.active_source=mine"
uci -q set "aurora.theme.active_fingerprint="
else
preset=$(normalize_preset_name "$(uci -q get aurora.theme.active_preset)")
uci -q set "aurora.theme.active_source=builtin:$preset"
uci -q set "aurora.theme.active_fingerprint=$(theme_fingerprint)"
fi
uci -q commit aurora
}
# 只在当前配置属于「我的」时才留底。
# 用一个商店主题盖掉另一个商店主题不需要快照:被盖掉的那个还是列表里的一张卡,
# 点一下就回来了。这正是「我的配置」那个单槽位能一直握着你自己那份工作的原因
# —— 你可以在商店里连点五个主题试看,它一次都不会被冲掉。
# THEME_BACKUP_TAKEN 是给 apply_hub_asset 看的。它在覆盖每张图片前会把原图备份
# 到 pre-hub-images/,可这次 apply 若没有拍配置快照,那些原图属于**上一个主题**
# 而不属于备份里那份配置 —— 让它写下去,回滚就会交还一份穿着别人 logo 的配置。
backup_if_mine() {
THEME_BACKUP_TAKEN=0
theme_modified || return 0
mkdir -p "$DEVICE_DIR"
cp "$CONFIG_FILE" "$DEVICE_DIR/pre-hub-backup.conf" 2>/dev/null || return 0
chmod 644 "$DEVICE_DIR/pre-hub-backup.conf" 2>/dev/null
# 单槽位:丢掉更早那次 apply 备份的图片,这次 apply 才好备份自己的。
rm -rf "$DEVICE_DIR/pre-hub-images"
THEME_BACKUP_TAKEN=1
}
# 记录「这套外观是从哪张卡来的」,外加它此刻的指纹。之后任何一次改动都会让指纹
# 对不上,商店就知道钩子该从那张卡挪到「我的配置」上。
# 必须在全部外观写入(含资产那次独立 commit)之后调用:指纹抢在前面算,刚应用完的
# 主题立刻就会读回「已修改」。
record_active_source() { # $1=builtin:<id> | hub:<id>
uci -q set "aurora.theme.active_source=$1"
uci -q set "aurora.theme.active_fingerprint=$(theme_fingerprint)"
uci -q commit aurora
}
resolve_preset_path() {
case "$(normalize_preset_name "$1")" in
default) echo "$DEFAULT_TEMPLATE" ;;
sage-green) echo "$SAGE_GREEN_TEMPLATE" ;;
amber-sand) echo "$AMBER_SAND_TEMPLATE" ;;
monochrome) echo "$MONOCHROME_TEMPLATE" ;;
sky-blue) echo "$SKY_BLUE_TEMPLATE" ;;
*) return 1 ;;
esac
}
is_safe_color_value() {
case "$1" in
*[\;{}\<\>]*) return 1 ;;
esac
printf '%s' "$1" | LC_ALL=C grep -q '[[:cntrl:]]' && return 1
return 0
}
is_color_token_key() {
local candidate="$1" token
for token in $COLOR_TOKEN_KEYS; do
[ "$candidate" = "$token" ] && return 0
done
return 1
}
# Pull the PRESET_STRUCT_KEYS options out of a template as "key=value" lines,
# then validate each one against the very bounds
# validate_and_apply_hub_payload enforces on an inbound hub configuration --
# a preset that could write a spacing the hub would reject would mean this
# package held two different definitions of a valid look.
# Every key is required. A template missing one is a broken install rather than
# an older preset (all five ship from this same repo), and half a look applied
# over the previous one is worse than a visible failure.
load_preset_struct_snapshot() {
local template="$1"
local snapshot key value
local nav_type="" toolbar_enabled="" spacing="" radius="" width=""
local font_sans="" font_mono=""
snapshot=$(awk -v keys="$PRESET_STRUCT_KEYS" '
BEGIN {
n = split(keys, a, /[ \t\n]+/)
for (i = 1; i <= n; i++) if (a[i] != "") want[a[i]] = 1
}
/^[ \t]*option[ \t]+/ {
key = $2
if (!(key in want) || (key in seen)) next
seen[key] = 1
val = substr($0, index($0, key) + length(key))
sub(/^[ \t]+/, "", val)
sub(/[ \t]+$/, "", val)
if (val ~ /^'\''.*'\''$/ || val ~ /^".*"$/)
val = substr(val, 2, length(val) - 2)
print key "=" val
}
' "$template") || return 1
while IFS='=' read -r key value; do
case "$key" in
nav_type) nav_type="$value" ;;
toolbar_enabled) toolbar_enabled="$value" ;;
struct_spacing) spacing="$value" ;;
struct_radius_base) radius="$value" ;;
struct_content_width_centered) width="$value" ;;
struct_font_sans) font_sans="$value" ;;
struct_font_mono) font_mono="$value" ;;
esac
done <<-EOF
$snapshot
EOF
case "$nav_type" in mega-menu|dropdown|sidebar) ;; *) return 1 ;; esac
case "$toolbar_enabled" in 0|1) ;; *) return 1 ;; esac
printf '%s' "$spacing" | grep -Eq '^[0-9]+(\.[0-9]+)?rem$' || return 1
awk -v v="${spacing%rem}" 'BEGIN{ if (v+0<0.05 || v+0>1) exit 1 }' || return 1
printf '%s' "$radius" | grep -Eq '^[0-9]+(\.[0-9]+)?rem$' || return 1
awk -v v="${radius%rem}" 'BEGIN{ if (v+0<0 || v+0>2) exit 1 }' || return 1
printf '%s' "$width" | grep -Eq '^[0-9]+(\.[0-9]+)?rem$' || return 1
awk -v v="${width%rem}" 'BEGIN{ if (v+0<40 || v+0>160) exit 1 }' || return 1
# The stacks are written into a single-quoted `uci batch` line, so a
# literal quote in one would break out of it; is_valid_font_stack rejects
# that along with control characters, over-long values and any character
# outside a font stack's alphabet.
is_valid_font_stack "$font_sans" || return 1
is_valid_font_stack "$font_mono" || return 1
printf '%s\n' "$snapshot"
return 0
}
# Parse a preset template into PRESET_SNAPSHOT ("key=value" lines) with a single
# awk pass. awk validates every option in one shot: the token must be known, the
# value must be injection-safe, and the count must match PRESET_EXPECTED_COUNT.
# This replaces the previous approach of spawning one `uci get` per token (plus a
# grep per value), which was ~120 subprocesses on the page-load path.
# The layout/typography options are appended by load_preset_struct_snapshot, so
# the caller writes one flat list of "key=value" pairs and cannot apply a
# preset's colours without the look they were designed against.
load_preset_snapshot() {
local template="$1"
local struct
PRESET_SNAPSHOT=""
[ -f "$template" ] || return 1
grep -Eq "^config[[:space:]]+aurora" "$template" 2>/dev/null || return 1
PRESET_SNAPSHOT=$(awk -v tokens="$COLOR_TOKEN_KEYS" -v expected="$PRESET_EXPECTED_COUNT" '
BEGIN {
n = split(tokens, a, /[ \t\n]+/)
for (i = 1; i <= n; i++) if (a[i] != "") valid[a[i]] = 1
}
/^[ \t]*option[ \t]+(light|dark)_/ {
key = $2
val = substr($0, index($0, key) + length(key))
sub(/^[ \t]+/, "", val)
sub(/[ \t]+$/, "", val)
if (val ~ /^'\''.*'\''$/ || val ~ /^".*"$/)
val = substr(val, 2, length(val) - 2)
tok = key
sub(/^(light|dark)_/, "", tok)
if (!(tok in valid)) next
if (val ~ /[;{}<>]/ || val ~ /[[:cntrl:]]/) { bad = 1; exit 1 }
print key "=" val
count++
}
END { if (bad || count != expected + 0) exit 1 }
' "$template") || { PRESET_SNAPSHOT=""; return 1; }
struct=$(load_preset_struct_snapshot "$template") || { PRESET_SNAPSHOT=""; return 1; }
PRESET_SNAPSHOT="$PRESET_SNAPSHOT
$struct"
return 0
}
load_preset_toolbar() { # $1=template
local template="$1"
local title url icon enabled tlen
local tab
tab=$(printf '\t')
PRESET_TOOLBAR=""
[ -f "$template" ] || return 1
PRESET_TOOLBAR=$(awk '
function flush() {
if (!started) return
if (title == "" || url == "" || enabled == "") { bad = 1; exit 1 }
print title "\t" url "\t" enabled "\t" icon
count++
title = ""; url = ""; icon = ""; enabled = ""
}
function value(key, val) {
val = substr($0, index($0, key) + length(key))
sub(/^[ \t]+/, "", val)
sub(/[ \t]+$/, "", val)
if (val ~ /^'\''.*'\''$/ || val ~ /^".*"$/)
val = substr(val, 2, length(val) - 2)
return val
}
/^[ \t]*config[ \t]+toolbar_item[ \t]*$/ { flush(); started = 1; next }
!started { next }
/^[ \t]*config[ \t]+/ { flush(); started = 0; next }
/^[ \t]*option[ \t]+title[ \t]/ { title = value("title"); next }
/^[ \t]*option[ \t]+url[ \t]/ { url = value("url"); next }
/^[ \t]*option[ \t]+icon[ \t]/ { icon = value("icon"); next }
/^[ \t]*option[ \t]+enabled[ \t]/ { enabled = value("enabled"); next }
END {
flush()
if (bad || count > 12) exit 1
}
' "$template") || { PRESET_TOOLBAR=""; return 1; }
[ -n "$PRESET_TOOLBAR" ] || return 0
while IFS="$tab" read -r title url enabled icon; do
[ -n "$title" ] || continue
has_batch_unsafe_char "$title" && { PRESET_TOOLBAR=""; return 1; }
tlen=${#title}
[ "$tlen" -ge 1 ] && [ "$tlen" -le 30 ] || { PRESET_TOOLBAR=""; return 1; }
has_batch_unsafe_char "$url" && { PRESET_TOOLBAR=""; return 1; }
[ "${#url}" -le 200 ] || { PRESET_TOOLBAR=""; return 1; }
case "$url" in
//*) PRESET_TOOLBAR=""; return 1 ;;
http://*|https://*|/*) ;;
*) PRESET_TOOLBAR=""; return 1 ;;
esac
if [ -n "$icon" ]; then
[ "${#icon}" -le 64 ] || { PRESET_TOOLBAR=""; return 1; }
printf '%s' "$icon" | grep -Eq '^[A-Za-z0-9._-]+$' \
|| { PRESET_TOOLBAR=""; return 1; }
fi
case "$enabled" in 0|1) ;; *) PRESET_TOOLBAR=""; return 1 ;; esac
done <<-EOF
$PRESET_TOOLBAR
EOF
return 0
}
validate_import_color_options() {
local import_dir option_name mode token value
import_dir=$(mktemp -d /tmp/aurora-import.XXXXXX) || return 1
cp "$IMPORT_CONFIG_TMP" "$import_dir/aurora" || {
rm -rf "$import_dir"
return 1
}
while IFS= read -r option_name; do
[ -n "$option_name" ] || continue
case "$option_name" in
light_*) mode="light"; token="${option_name#light_}" ;;
dark_*) mode="dark"; token="${option_name#dark_}" ;;
*) continue ;;
esac
is_color_token_key "$token" || {
rm -rf "$import_dir"
return 1
}
value=$(uci -c "$import_dir" -q get "aurora.theme.${mode}_${token}") || {
rm -rf "$import_dir"
return 1
}
is_safe_color_value "$value" || {
rm -rf "$import_dir"
return 1
}
done <<-EOF
$(uci -c "$import_dir" -q show aurora.theme \
| sed -n 's/^aurora\.theme\.\([^=]*\)=.*/\1/p')
EOF
rm -rf "$import_dir"
return 0
}
list_font_entries() {
local slot_filter="$1"
[ -r "$FONT_PRESETS_FILE" ] || return 1
while IFS='|' read -r kind slot name label source family stack; do
[ "$kind" = "font" ] || continue
[ -n "$name" ] || continue
[ -n "$slot_filter" ] && [ "$slot" != "$slot_filter" ] && continue
printf '%s|%s|%s|%s|%s|%s\n' \
"$slot" "$name" "$label" "$source" "$family" "$stack"
done < "$FONT_PRESETS_FILE"
}
# Emit "weight|sha256|url_primary|url_fallback" lines for one preset.
list_font_files() {
local slot="$1" name="$2" kind fslot fname weight sha256 url1 url2
[ -r "$FONT_PRESETS_FILE" ] || return 1
while IFS='|' read -r kind fslot fname weight sha256 url1 url2; do
[ "$kind" = "file" ] || continue
[ "$fslot" = "$slot" ] && [ "$fname" = "$name" ] || continue
printf '%s|%s|%s|%s\n' "$weight" "$sha256" "$url1" "$url2"
done < "$FONT_PRESETS_FILE"
}
# Memoised in $_pkg_manager: get_init_data asks twice (feed status and installed
# versions) and each `$(...)` is its own fork. Callers read the variable after
# calling; nothing is echoed.
_pkg_manager=""
detect_package_manager() {
[ -n "$_pkg_manager" ] && return
# Decide from the on-disk database, exactly as install.sh does. Probing for
# binaries can answer differently on the same machine (both can be present
# during migration), and the two paths must never disagree about what this
# device is.
if [ -f /lib/apk/db/installed ]; then _pkg_manager="apk"
elif [ -f /usr/lib/opkg/status ]; then _pkg_manager="opkg"
else _pkg_manager="unknown"; fi
}
get_system_lang() {
local lang=""
if [ -f /etc/config/luci ]; then
lang=$(uci -q get luci.main.lang)
fi
[ -z "$lang" ] && lang="zh-cn"
[ "$lang" = "auto" ] && lang="zh-cn"
echo "$lang"
}
update_icon_cache_timestamp() {
local timestamp=$(date +%s)
uci -q set aurora.theme.icon_cache_version="$timestamp"
uci -q commit aurora
}
cleanup_custom_fonts() {
local slot="$1"
mkdir -p "$FONT_PATH"
if [ -n "$slot" ]; then
rm -f "$FONT_PATH"/aurora-font-${slot}-*.woff2 \
"$FONT_PATH"/aurora-font-${slot}-*.tmp \
"$FONT_PATH"/aurora-font-${slot}.remote.* \
"$FONT_PATH"/aurora-font-${slot}-*.face \
"$FONT_PATH/aurora-font-${slot}.css" \
"$FONT_PATH/aurora-font-${slot}.face" \
"$FONT_PATH/aurora-font-${slot}.face.tmp" \
"$FONT_PATH/aurora-font-${slot}.preset" 2>/dev/null
else
rm -f "$FONT_PATH"/aurora-font-sans-*.woff2 \
"$FONT_PATH"/aurora-font-mono-*.woff2 \
"$FONT_PATH"/aurora-font-sans-*.tmp \
"$FONT_PATH"/aurora-font-mono-*.tmp \
"$FONT_PATH"/aurora-font-sans.remote.* \
"$FONT_PATH"/aurora-font-mono.remote.* \
"$FONT_PATH"/aurora-font-sans.css \
"$FONT_PATH"/aurora-font-mono.css \
"$FONT_PATH"/aurora-font-sans.face \
"$FONT_PATH"/aurora-font-mono.face \
"$FONT_PATH"/aurora-font-sans.face.tmp \
"$FONT_PATH"/aurora-font-mono.face.tmp \
"$FONT_PATH"/aurora-font-sans.preset \
"$FONT_PATH"/aurora-font-mono.preset 2>/dev/null
rm -f "$FONT_PATH/preload.txt" 2>/dev/null
fi
}
fetch_url() {
local output="$1"
shift
local tries_arg=""
# A caller that needs to tell a 404 apart from a dead network sets
# FETCH_URL_ERR to a file to collect stderr, which also drops -q --
# -q silences the very line that names the status. Everyone else stays
# quiet and throws stderr away, which is what the asset-download loop
# wants: it fetches many files and its progress meters have nowhere to go
# but rpcd's stderr.
local quiet="-q" err="/dev/null"
[ -n "$FETCH_URL_ERR" ] && { quiet=""; err="$FETCH_URL_ERR"; }
if wget --help 2>&1 | grep -q -- '--tries'; then
tries_arg="--tries=1"
fi
if command -v timeout >/dev/null 2>&1; then
timeout 12 wget $quiet -O "$output" --timeout=8 $tries_arg "$@" 2>"$err"
else
wget $quiet -O "$output" --timeout=8 $tries_arg "$@" 2>"$err"
fi
}
# Ensures the device's hub identity exists: device.key (0600) is the secret
# device_token used to authenticate share/update/delete calls; device.hash
# (0644) is an anonymous device_hash used only for download dedup and carries
# no authority. Both are 64 hex chars. Sets DEVICE_TOKEN/DEVICE_HASH for the
# caller.
ensure_device_identity() {
mkdir -p "$DEVICE_DIR"
if [ ! -s "$DEVICE_DIR/device.key" ]; then
head -c32 /dev/urandom | hexdump -v -e '/1 "%02x"' > "$DEVICE_DIR/device.key"
chmod 600 "$DEVICE_DIR/device.key"
fi
if [ ! -s "$DEVICE_DIR/device.hash" ]; then
head -c32 /dev/urandom | hexdump -v -e '/1 "%02x"' > "$DEVICE_DIR/device.hash"
chmod 644 "$DEVICE_DIR/device.hash"
fi
DEVICE_TOKEN=$(cat "$DEVICE_DIR/device.key")
DEVICE_HASH=$(cat "$DEVICE_DIR/device.hash")
}
# "This one is gone" and "the store is down" are different things to tell a
# user, and uclient-fetch hands its caller neither: exit 8 covers every error
# status the hub can send, and -q silences the one line that names which. So
# the three helpers below keep stderr and read the status off it. Measured on
# the device against the live hub: a 404 exits 8 and prints "HTTP error 404";
# a host it cannot reach exits 4 and prints "Failed to send request". The
# wording is uclient-fetch's own fixed format, not the hub's -- a device
# carrying busybox wget instead prints something else, misses the match, and
# degrades to the network answer, which is what it reported before anyway.
hub_http_gone() { # $1=stderr_file
grep -q '^HTTP error 404$' "$1" 2>/dev/null
}
# Returns 2, not 1, when the store has no such config any more, so the apply
# path can say "this theme is gone" instead of blaming the network. fetch_url
# stays the transport: it carries the --tries shim the asset downloads rely on.
hub_http_get() { # $1=path
local out err rc; out=$(mktemp); err=$(mktemp)
FETCH_URL_ERR="$err"
fetch_url "$out" "$HUB_BASE$1"
rc=$?
FETCH_URL_ERR=""
if [ "$rc" = 0 ]; then
cat "$out"
elif hub_http_gone "$err"; then
rc=2
else
rc=1
fi
rm -f "$out" "$err"
return "$rc"
}
# Same 0/2/1 contract as hub_http_put/hub_http_delete, plus 3: the hub was
# reached and refused the request outright (quota, validation, ownership).
# The -q that used to be here swallowed the one line uclient-fetch prints
# naming the status, so "your daily quota is used up" and "this router has no
# route to the internet" arrived in LuCI as the same sentence -- which is most
# of why a publish that could never have worked looked like a store outage.
# HUB_HTTP_STATUS carries the code for callers that want a finer branch; it is
# empty when no status could be parsed.
hub_http_post() { # $1=path $2=body_file
local out err rc; out=$(mktemp); err=$(mktemp)
HUB_HTTP_STATUS=""
# uclient-fetch/wget on OpenWrt: --post-file + --header both supported
timeout 15 wget -O "$out" --timeout=12 \
--header='Content-Type: application/json' \
--post-file="$2" "$HUB_BASE$1" 2>"$err"
rc=$?
if [ "$rc" = 0 ]; then
cat "$out"
elif hub_http_gone "$err"; then
rc=2
else
HUB_HTTP_STATUS=$(sed -n 's/^HTTP error \([0-9][0-9]*\)$/\1/p' "$err" | head -n1)
[ -n "$HUB_HTTP_STATUS" ] && rc=3 || rc=1
fi
rm -f "$out" "$err"
return "$rc"
}
# Mirrors hub_http_post for the update path, with one difference that is easy
# to get wrong: --post-file *selects* the POST method, so pairing it with
# --method is a contradiction that uclient-fetch rejects outright -- it prints
# its usage and exits non-zero, which hub_http_put can only report as
# hub_unreachable. The body flag that goes with --method is --body-file.
# Verified on the device: with --body-file the hub answers
# {"id":"...","removed":true}; with the POST-selecting flag instead, wget only
# prints its usage. Update and delete never once worked before this.
# Returns 2, not 1, when the hub has no such share any more (see hub_http_gone).
hub_http_put() { # $1=path $2=body_file
local out err rc; out=$(mktemp); err=$(mktemp)
timeout 15 wget -O "$out" --timeout=12 \
--header='Content-Type: application/json' \
--method=PUT --body-file="$2" "$HUB_BASE$1" 2>"$err"
rc=$?
if [ "$rc" = 0 ]; then
cat "$out"
elif hub_http_gone "$err"; then
rc=2
else
rc=1
fi
rm -f "$out" "$err"
return "$rc"
}
# Same --method/--body-file pairing and the same 0/2/1 contract as
# hub_http_put above; the hub expects the device_token in a JSON body even on
# DELETE.
hub_http_delete() { # $1=path $2=body_file
local out err rc; out=$(mktemp); err=$(mktemp)
timeout 15 wget -O "$out" --timeout=12 \
--header='Content-Type: application/json' \
--method=DELETE --body-file="$2" "$HUB_BASE$1" 2>"$err"
rc=$?
if [ "$rc" = 0 ]; then
cat "$out"
elif hub_http_gone "$err"; then
rc=2
else
rc=1
fi
rm -f "$out" "$err"
return "$rc"
}
# Minimal JSON string escaper for values that have already been rejected
# outright on any control character (has_control_char must run first): this
# only escapes the two characters JSON forbids unescaped in a string --
# backslash and double-quote -- so free-text fields (name/author/description)
# can be spliced into a hand-assembled request body alongside pre-built JSON
# fragments (payload/assets) that jshn has no way to re-embed as raw values
# once they are already serialized text.
json_escape_string() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
# Mirrors write_font_job_status: write-tmp-then-mv so a concurrent
# get_hub_status read never observes a half-written status file. Also prunes
# status files older than an hour on every write -- /tmp/aurora_hub_jobs is
# never otherwise cleaned up, and marketplace.js triggers a hub_apply (and thus a
# new job_id/status file) on every apply attempt.
write_hub_job_status() {
local job_id="$1" state="$2" step="$3" error="$4"
local status_file="$HUB_JOB_PATH/$job_id.status"
mkdir -p "$HUB_JOB_PATH"
{
printf "state='%s'\n" "$state"
printf "step='%s'\n" "$step"
printf "error='%s'\n" "$error"
} > "$status_file.tmp"
mv "$status_file.tmp" "$status_file"
find "$HUB_JOB_PATH" -name '*.status' -mmin +60 -delete 2>/dev/null
}
# Prints the extension implied by a local file's leading bytes, or returns
# non-zero. Two jobs in one read:
# 1. Local re-verification. The hub sniffed these bytes on the way in, but
# they arrived here over the network -- same posture apply_hub_font_asset
# takes with `head -c 4 == wOF2`. Not checking would outsource the check
# to the server.
# 2. The on-disk extension. uhttpd derives Content-Type from it, and an SVG
# landed as .png simply does not render. The url the byte came from
# ("/assets/{id}/{kind}") carries no extension at all, so it cannot
# answer this -- the old login_bg branch parsed one out of that url and
# therefore always fell through to its "jpg" default, mislabelling
# everything that was not JPEG.
# hexdump is already relied on for device.key generation, so it is present.
sniff_image_ext() { # $1=file
local head8 offset
# 12 bytes, not 8: WebP is a RIFF container, so the four bytes that say
# "WEBP" sit at offset 8, past the container's own size field.
head8=$(head -c 12 "$1" 2>/dev/null | hexdump -v -e '/1 "%02x"' 2>/dev/null)
case "$head8" in
89504e470d0a1a0a*) printf 'png'; return 0 ;;
ffd8ff*) printf 'jpg'; return 0 ;;
00000100*) printf 'ico'; return 0 ;;
52494646????????57454250*) printf 'webp'; return 0 ;;
esac
# SVG is text and has no fixed magic; mirror the hub's own isSvg -- drop a
# leading UTF-8 BOM and any whitespace, then require "<svg" or "<?xml".
# The BOM goes by byte offset, not by feeding tr an octal escape: `tr -d
# '\357...'` is a GNU extension that BSD tr silently does not honour, so
# that spelling would have the router and the test host disagree about a
# BOM'd file -- green tests, blank logo.
case "$head8" in efbbbf*) offset=4 ;; *) offset=1 ;; esac
case "$(tail -c "+$offset" "$1" 2>/dev/null | head -c 512 | tr -d ' \t\r\n')" in
'<svg'*|'<?xml'*) printf 'svg'; return 0 ;;
esac
return 1
}
# True (0) when a sniffed extension is one this kind is allowed to be. Kinds
# the hub pins to a single format say so; the backgrounds and the toolbar
# icons are genuinely multi-format, because the hub stores whichever format
# the sharer uploaded rather than converting it.
hub_asset_ext_ok() { # $1=kind $2=ext
case "$1" in
logo_svg) [ "$2" = "svg" ] ;;
favicon_png|pwa_icon_192|pwa_icon_512) [ "$2" = "png" ] ;;
favicon_ico) [ "$2" = "ico" ] ;;
login_bg|main_bg) [ "$2" = "png" ] || [ "$2" = "jpg" ] || [ "$2" = "webp" ] ;;
toolbar_icon_*) [ "$2" = "png" ] || [ "$2" = "svg" ] ;;
*) return 1 ;;
esac
}
# Maps a hub asset "kind" (plus its sha256 and sniffed extension) to the local
# filename it is written to under ICON_PATH. Unknown kinds return failure so
# the caller skips them instead of aborting the whole apply -- one
# bad/unexpected asset must never undo config data that has already been
# validated and committed.
# Every name is sha-derived and "hub-" prefixed. It used to be the factory
# filename (logo.svg, app-icon-192x192.png, ...), which cost two things:
# - Applying a store theme OVERWROTE the shipped default. Apply theme A then
# theme B and B takes no snapshot (backup_if_mine: the config being
# replaced is not yours), so the factory logo A had clobbered was gone for
# good.
# - A config applied from the store could not be shared onward with its
# images: build_share_payload skips factory filenames, and four of the
# five landed under exactly those names. logo, favicon.ico and both PWA
# icons vanished silently on the second hop.
# The prefix is what makes the leftovers collectable -- see sweep_hub_images.
hub_asset_target_name() { # $1=kind $2=sha256 $3=ext
case "$1" in
logo_svg) printf 'hub-logo-%.12s.%s' "$2" "$3" ;;
favicon_png|favicon_ico) printf 'hub-favicon-%.12s.%s' "$2" "$3" ;;
pwa_icon_192) printf 'hub-app192-%.12s.%s' "$2" "$3" ;;
pwa_icon_512) printf 'hub-app512-%.12s.%s' "$2" "$3" ;;
login_bg) printf 'hub-login-bg-%.12s.%s' "$2" "$3" ;;
main_bg) printf 'hub-main-bg-%.12s.%s' "$2" "$3" ;;
toolbar_icon_*) printf 'hub-tb-%.12s.%s' "$2" "$3" ;;
*) return 1 ;;
esac
}
# Maps a hub asset "kind" to the aurora.theme uci option that must point at
# the applied filename for the asset to actually take effect -- theme.js
# reads these options to render the configured image, it does not infer
# anything from ICON_PATH's contents. login_bg is handled separately by the
# caller (its value is a url('...') wrapper, not a bare filename).
hub_asset_uci_option() { # $1=kind
case "$1" in
logo_svg) printf '%s' "logo_svg" ;;
favicon_png) printf '%s' "favicon_png" ;;
favicon_ico) printf '%s' "favicon_ico" ;;
pwa_icon_192) printf '%s' "pwa_icon_192" ;;
pwa_icon_512) printf '%s' "pwa_icon_512" ;;
*) return 1 ;;
esac
}
# Fetches+verifies one hub image asset into ICON_PATH and points the uci slot
# that renders it at the landed filename. On success the filename is left in
# APPLIED_ASSET_NAME for apply_hub_toolbar_icon to pick up -- a global rather
# than stdout so that no future `wget` chattiness inside fetch_url can end up
# being read back as a filename. On any failure nothing under ICON_PATH is
# touched and the asset is simply skipped, matching the existing "one bad
# asset never undoes validated config" posture.
# fetch_verified rm's *its own dest* on a checksum mismatch, so it is pointed
# at a temp name and never at a live file. The name is only computed after the
# bytes are on disk, because sniff_image_ext is what decides the extension.
# The backup below is now the narrow case rather than the common one: names
# are sha-derived, so a landing normally introduces a file rather than
# replacing one. Introduced files get a ".new" marker instead, exactly like
# apply_hub_font_asset -- without it a rollback would leave someone else's
# images sitting in ICON_PATH forever.
apply_hub_asset() { # $1=kind $2=sha256 $3=url
local kind="$1" sha256="$2" url="$3"
local backup_dir="$DEVICE_DIR/pre-hub-images" opt ext name target_path
local tmp_path="$ICON_PATH/.hubtmp-$$"
APPLIED_ASSET_NAME=""
rm -f "$tmp_path"
fetch_verified "$tmp_path" "$sha256" "$url" "" || {
rm -f "$tmp_path"
return 1
}
ext=$(sniff_image_ext "$tmp_path") && hub_asset_ext_ok "$kind" "$ext" || {
rm -f "$tmp_path"
return 1
}
name=$(hub_asset_target_name "$kind" "$sha256" "$ext") || {
rm -f "$tmp_path"
return 1
}
target_path="$ICON_PATH/$name"
# 只在这次 apply 真的拍了配置快照时才记回滚信息 —— 见 backup_if_mine。没拍
# 快照就说明被覆盖的是另一个商店主题,那些原图不属于备份里那份配置,写下去
# 会让回滚交还一份穿着别人 logo 的配置。
if [ "$THEME_BACKUP_TAKEN" = "1" ]; then
mkdir -p "$backup_dir"
if [ -f "$target_path" ]; then
cp -p "$target_path" "$backup_dir/$name" 2>/dev/null
else
: > "$backup_dir/$name.new"
fi
fi
mv "$tmp_path" "$target_path" 2>/dev/null || {
rm -f "$tmp_path"
return 1
}
chmod 644 "$target_path" 2>/dev/null
# 换图之后旧 LQIP 指着换掉前的那张,留着会先闪一帧错图 —— 写回 url() 的
# 同时删掉;studio 下次打开会按新图自动补生成。
if [ "$kind" = "login_bg" ]; then
uci -q set aurora.theme.struct_login_bg="url('/luci-static/aurora/images/$name')"
uci -q delete aurora.theme.struct_login_bg_lqip
uci -q commit aurora
elif [ "$kind" = "main_bg" ]; then
uci -q set aurora.theme.struct_main_bg="url('/luci-static/aurora/images/$name')"
uci -q delete aurora.theme.struct_main_bg_lqip
uci -q commit aurora
elif opt=$(hub_asset_uci_option "$kind"); then
uci -q set "aurora.theme.$opt=$name"
uci -q commit aurora
fi
APPLIED_ASSET_NAME="$name"
return 0
}
# Lands one toolbar shortcut icon and repoints every shortcut that used it.
# The asset itself goes through apply_hub_asset unchanged (hub_asset_uci_option
# has no entry for these kinds, so it writes no uci of its own). What is
# specific here is the second half: the config batch already wrote the AUTHOR's
# filename into every toolbar_item.icon, and that name means nothing on this
# device -- it has to be swapped for the name the bytes just landed under.
# Which shortcuts? The ones naming the k-th distinct custom icon, k being the
# number in the kind. Both ends derive that ordering from the same name-only
# rule over the same toolbar list; see nth_custom_toolbar_icon.
# On failure the shortcut keeps the author's filename and renders as an empty
# box -- exactly what it did before any of this existed, and strictly better
# than dropping the shortcut itself.
apply_hub_toolbar_icon() { # $1=kind $2=sha256 $3=url
local kind="$1" sha256="$2" url="$3"
local index="${1#toolbar_icon_}" author_name landed sid changed=0
case "$index" in ''|*[!0-9]*) return 1 ;; esac
author_name=$(nth_custom_toolbar_icon "$index") || return 1
apply_hub_asset "$kind" "$sha256" "$url" || return 1
landed="$APPLIED_ASSET_NAME"
for sid in $(uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'); do
[ "$(uci -q get "aurora.$sid.icon")" = "$author_name" ] || continue
uci -q set "aurora.$sid.icon=$landed"
changed=1
done
# 一个都没改到就说明这份字节没有归属 —— 把它留在 ICON_PATH 里只会等着
# sweep_hub_images 收走,而现在就删掉更直接。
if [ "$changed" = 0 ]; then
rm -f "$ICON_PATH/$landed" "$DEVICE_DIR/pre-hub-images/$landed.new"
return 1
fi
uci -q commit aurora
return 0
}
# Lands a downloaded custom font as the exact same three files upload_font
# writes (.woff2 + .meta + .face under FONT_CUSTOM_PATH). That shape is the
# whole point: every downstream consumer already knows it, so none of them
# needs a line changed -- write_combined_font_css picks the face up when it
# scans custom/*.face, write_font_preload matches the stack recorded in .meta,
# and migrate_font_cache sees the stack still has an owner and leaves
# struct_font_$slot alone instead of resetting it to default.
# The family name comes from the struct_font_$slot already committed by
# validate_and_apply_hub_payload: the payload carries no separate family
# field, but that stack is exactly what upload_font composed FROM the family,
# so taking the first entry back out returns the same string.
# No uci write here, deliberately: struct_font_$slot was set by the config
# batch, and the asset step must never be able to point typography at a font
# whose bytes then failed to land.
apply_hub_font_asset() { # $1=slot $2=sha256 $3=url
local slot="$1" sha256="$2" url="$3"
local stack family slug dest tmp_path backup_dir f
stack=$(uci -q get "aurora.theme.struct_font_$slot")
[ -n "$stack" ] || return 1
# First family in the stack, unquoted -- same rule as marketplace.js's
# firstFontFamily, so the store's "Sans Font" row and this name cannot
# disagree.
family=$(printf '%s' "$stack" | awk -F',' '{print $1}' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/^"//;s/"$//')
# Same charset gate upload_font applies. The stack itself was already
# validated inbound, but this substring gets interpolated into the
# @font-face `font-family:` below -- a quote or newline surviving to
# there would rewrite the whole stylesheet.
case "$family" in
""|*[!A-Za-z0-9\ \'-]*) return 1 ;;
esac
[ "${#family}" -le 64 ] || return 1
slug=$(printf '%s' "$family" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]\{1,\}/-/g;s/^-//;s/-$//')
[ -n "$slug" ] || return 1
mkdir -p "$FONT_CUSTOM_PATH"
dest="$FONT_CUSTOM_PATH/${slot}-${slug}.woff2"
tmp_path="${dest}.hubtmp"
rm -f "$tmp_path"
fetch_verified "$tmp_path" "$sha256" "$url" "" || {
rm -f "$tmp_path"
return 1
}
# The hub checks woff2 magic on the way in, but these bytes arrived over
# the network -- not re-checking locally would be outsourcing validation
# to the server. Same gate receive_upload applies to a local upload.
[ "$(head -c 4 "$tmp_path")" = "wOF2" ] || {
rm -f "$tmp_path"
return 1
}
# Same posture as apply_hub_asset: only back up when this apply actually
# snapshotted the config, so a rollback cannot hand back a font that
# belonged to some other store theme.
# Two cases, and the second one matters on small-flash devices: a font
# this router already had gets its three files copied aside, while one
# that is entirely new leaves a `.new` marker instead. Without that
# marker a rollback would restore the old config but leave several MB of
# somebody else's typeface sitting in the overlay forever.
if [ "$THEME_BACKUP_TAKEN" = "1" ]; then
backup_dir="$DEVICE_DIR/pre-hub-fonts"
mkdir -p "$backup_dir"
if [ -f "$dest" ]; then
for f in woff2 meta face; do
[ -f "$FONT_CUSTOM_PATH/${slot}-${slug}.$f" ] && \
cp -p "$FONT_CUSTOM_PATH/${slot}-${slug}.$f" \
"$backup_dir/${slot}-${slug}.$f" 2>/dev/null
done
else
: > "$backup_dir/${slot}-${slug}.new"
fi
fi
mv "$tmp_path" "$dest" 2>/dev/null || {
rm -f "$tmp_path"
return 1
}
chmod 644 "$dest"
printf '%s|%s\n' "$family" "$stack" > "$FONT_CUSTOM_PATH/${slot}-${slug}.meta"
chmod 644 "$FONT_CUSTOM_PATH/${slot}-${slug}.meta"
cat > "$FONT_CUSTOM_PATH/${slot}-${slug}.face" <<FACE
@font-face {
font-display: swap;
font-family: "$family";
font-style: normal;
font-weight: 400;
src: url("/luci-static/aurora/fonts/custom/${slot}-${slug}.woff2?v=$(date +%s)") format("woff2");
}
FACE
chmod 644 "$FONT_CUSTOM_PATH/${slot}-${slug}.face"
return 0
}
# Font half of restore_pre_hub_images (see apply_hub_font_asset's backup
# step). Two moves, in this order: drop the three files of every font the
# apply introduced from scratch (`.new` marker), then copy back the ones it
# overwrote. Dropping first matters -- a font can be both, if an apply
# replaced one that an even-earlier apply had introduced.
# The caller re-runs sync_and_cache_fonts_from_uci afterwards, which rebuilds
# aurora-font.css from whatever is left here, so nothing rewrites CSS itself.
restore_pre_hub_fonts() {
local backup_dir="$DEVICE_DIR/pre-hub-fonts" f base
[ -d "$backup_dir" ] || return 0
for f in "$backup_dir"/*.new; do
[ -f "$f" ] || continue
base="${f##*/}"; base="${base%.new}"
rm -f "$FONT_CUSTOM_PATH/${base}.woff2" \
"$FONT_CUSTOM_PATH/${base}.meta" \
"$FONT_CUSTOM_PATH/${base}.face" 2>/dev/null
done
for f in "$backup_dir"/*.woff2 "$backup_dir"/*.meta "$backup_dir"/*.face; do
[ -f "$f" ] || continue
cp -p "$f" "$FONT_CUSTOM_PATH/${f##*/}" 2>/dev/null
chmod 644 "$FONT_CUSTOM_PATH/${f##*/}" 2>/dev/null
done
rm -rf "$backup_dir"
}
# Undoes everything hub_apply_worker did to ICON_PATH (see apply_hub_asset),
# then clears the backup directory -- a later restore must not re-apply images
# from an even-earlier apply. Companion to hub_restore_backup's
# /etc/config/aurora restore, which on its own left applied images in place.
# Two moves, in this order, same shape as restore_pre_hub_fonts: drop the
# files the apply introduced from scratch (".new" marker), then copy back the
# ones it overwrote. Dropping first matters -- a file can be both, if an apply
# replaced one that an even-earlier apply had introduced. Without the first
# move a rollback would leave someone else's images occupying the overlay for
# good, which is exactly what sha-derived names would otherwise cost.
restore_pre_hub_images() {
local backup_dir="$DEVICE_DIR/pre-hub-images" f base
[ -d "$backup_dir" ] || return 0
for f in "$backup_dir"/*.new; do
[ -f "$f" ] || continue
base="${f##*/}"
rm -f "$ICON_PATH/${base%.new}" 2>/dev/null
done
for f in "$backup_dir"/*; do
[ -f "$f" ] || continue
case "${f##*/}" in *.new) continue ;; esac
cp -p "$f" "$ICON_PATH/${f##*/}" 2>/dev/null
chmod 644 "$ICON_PATH/${f##*/}" 2>/dev/null
done
rm -rf "$backup_dir"
}
is_hub_asset_name() { # $1=filename
case "$1" in
hub-logo-*|hub-favicon-*|hub-app192-*|hub-app512-*|hub-login-bg-*|hub-main-bg-*|hub-tb-*) ;;
*) return 1 ;;
esac
case "${1##*-}" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].???) return 0 ;;
esac
return 1
}
clear_inherited_assets() { # $1=kinds written by this apply
local written=" ${1:-} " kind fname value changed=0
for kind in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512; do
case "$written" in *" $kind "*) continue ;; esac
fname=$(uci -q get "aurora.theme.$kind")
[ -n "$fname" ] || continue
is_hub_asset_name "$fname" || continue
uci -q delete "aurora.theme.$kind"
changed=1
done
for kind in login_bg main_bg; do
case "$written" in *" $kind "*) continue ;; esac
value=$(uci -q get "aurora.theme.struct_$kind")
fname=$(printf '%s' "$value" | sed "s|url('/luci-static/aurora/images/||;s|')||")
[ -n "$fname" ] || continue
is_hub_asset_name "$fname" || continue
uci -q delete "aurora.theme.struct_$kind"
uci -q delete "aurora.theme.struct_${kind}_lqip"
uci -q delete "aurora.theme.struct_${kind}_alpha"
uci -q delete "aurora.theme.struct_${kind}_blur"
uci -q delete "aurora.theme.struct_${kind}_scrim"
changed=1
done
[ "$changed" = 1 ] && uci -q commit aurora
return 0
}
drop_missing_toolbar_icons() {
local sid icon changed=0
[ -d "$ICON_PATH" ] || return 0
for sid in $(uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'); do
icon=$(uci -q get "aurora.$sid.icon")
[ -n "$icon" ] || continue
case "$icon" in */*|*..*) ;; *)
[ -f "$ICON_PATH/$icon" ] && continue
;;
esac
uci -q delete "aurora.$sid.icon"
changed=1
done
[ "$changed" = 1 ] && uci -q commit aurora
return 0
}
# Deletes the hub-* images left over from earlier applies. Names are
# sha-derived, so nothing overwrites anything and applying five themes in a
# row would otherwise leave five sets of images on the overlay -- a leak
# hub-login-bg-* has had since it became the first sha-named kind.
# Two things keep a file: a live uci slot still pointing at it (theme asset
# options, struct_login_bg's url() form, or any toolbar_item icon), or the
# rollback backup still referencing it. Everything else under the prefix is
# unreachable by definition -- only this function's own naming scheme
# produces it, and nothing else on the device writes there.
sweep_hub_images() {
local backup_dir="$DEVICE_DIR/pre-hub-images" f name opt live
live=$(
for opt in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512 pwa_apple_touch; do
uci -q get "aurora.theme.$opt"
done
uci -q get aurora.theme.struct_login_bg | sed "s|url('/luci-static/aurora/images/||;s|')||"
uci -q get aurora.theme.struct_main_bg | sed "s|url('/luci-static/aurora/images/||;s|')||"
uci show aurora 2>/dev/null | sed -n "s/^aurora\.[^.]*\.icon='\(.*\)'\$/\1/p"
)
for f in "$ICON_PATH"/hub-logo-* "$ICON_PATH"/hub-favicon-* \
"$ICON_PATH"/hub-app192-* "$ICON_PATH"/hub-app512-* \
"$ICON_PATH"/hub-login-bg-* "$ICON_PATH"/hub-main-bg-* "$ICON_PATH"/hub-tb-*; do
[ -f "$f" ] || continue
name="${f##*/}"
is_hub_asset_name "$name" || continue
printf '%s\n' "$live" | grep -qxF "$name" && continue
[ -f "$backup_dir/$name" ] && continue
[ -f "$backup_dir/$name.new" ] && continue
rm -f "$f"
done
}
# Reverse-maps a locally-applied font stack back to the hub's preset id: the
# hub payload's typography.font_sans/font_mono fields are informational
# preset ids (not stacks), so sharing has to recover "geist-sans" from
# whatever CSS stack struct_font_$slot currently holds. No match (custom
# uploaded font, or a stack that predates the roster) collapses to "default"
# -- v1 never shares the custom woff2 itself, only the id it resolves to.
resolve_font_preset_id() {
local slot="$1" stack preset
stack=$(uci -q get "aurora.theme.struct_font_$slot")
if [ -n "$stack" ] && [ -r "$FONT_PRESETS_FILE" ]; then
preset=$(awk -F'|' -v s="$slot" -v k="$stack" '$1=="font"&&$2==s&&$7==k{print $3;exit}' "$FONT_PRESETS_FILE" 2>/dev/null)
fi
printf '%s' "${preset:-default}"
}
# Looks up the canonical (roster-authored) font stack for a given slot+preset
# id -- field 7 of its "font" row in font-presets.conf. Used as the fallback
# value when a locally-stored struct_font_$slot fails the hub's own
# typography regex (e.g. a hand-edited uci config), since every roster stack
# is authored to already satisfy that regex.
font_preset_stack() {
local slot="$1" preset="$2"
[ -r "$FONT_PRESETS_FILE" ] || return 1
awk -F'|' -v s="$slot" -v n="$preset" '$1=="font"&&$2==s&&$3==n{print $7;exit}' "$FONT_PRESETS_FILE" 2>/dev/null
}
# True (0) when $1 is a non-empty string that satisfies the hub's
# struct_font_sans/struct_font_mono contract: <=200 chars, no control
# characters, and matching ^[A-Za-z0-9 ,"'-]+$ -- the same rule
# validate_and_apply_hub_payload enforces on the inbound side. A locally
# stored stack that fails this (most likely a hand-edited uci config, since
# every value this package itself ever writes -- preset stacks and
# `"$family", $default_stack` for custom uploads -- already conforms) must
# never be shared verbatim: the hub would otherwise reject the entire share.
# Also rejects a literal single quote even though the hub's own regex above
# permits one: validate_and_apply_hub_payload additionally rejects it on the
# apply side (see has_batch_unsafe_char), so sharing a stack containing one
# verbatim would only ever produce a share that fails to re-apply -- falling
# back to the canonical preset stack here (same as any other regex failure)
# keeps both sides consistent.
is_valid_font_stack() {
local v="$1"
[ -n "$v" ] || return 1
[ "${#v}" -le 200 ] || return 1
has_control_char "$v" && return 1
case "$v" in *\'*) return 1 ;; esac
printf '%s' "$v" | grep -Eq '^[A-Za-z0-9 ,"'\''-]+$'
}
# True (0) when a main-background tunable is inside the hub schema's bounds.
# Shared by build_share_payload (an out-of-range local value is silently left
# out, same posture as the font-stack fallback) and
# validate_and_apply_hub_payload (an out-of-range inbound value rejects the
# payload, same as every other layout key). Bounds mirror the hub's
# MAIN_BG_BOUNDS: alpha 50-100%, blur 0-40px, scrim 0-70%.
is_valid_main_bg_value() { # $1=key $2=value
local n
case "$1" in
struct_main_bg_alpha|struct_login_bg_alpha)
printf '%s' "$2" | grep -Eq '^[0-9]{1,3}%$' || return 1
n="${2%\%}"; [ "$n" -ge 50 ] && [ "$n" -le 100 ] ;;
struct_main_bg_blur|struct_login_bg_blur)
printf '%s' "$2" | grep -Eq '^[0-9]{1,3}px$' || return 1
n="${2%px}"; [ "$n" -ge 0 ] && [ "$n" -le 40 ] ;;
struct_main_bg_scrim|struct_login_bg_scrim)
printf '%s' "$2" | grep -Eq '^[0-9]{1,3}%$' || return 1
n="${2%\%}"; [ "$n" -ge 0 ] && [ "$n" -le 70 ] ;;
*) return 1 ;;
esac
}
# The basename (e.g. "sans-oppo-sans-regular") of the uploaded custom font
# that struct_font_$slot currently points at, or non-zero when this slot is
# not backed by one. A roster preset always answers non-zero: its bytes do
# not travel with the configuration, the receiving end downloads them itself.
# Both the manifest shown before publishing (hub_local_state) and the actual
# packing (build_share_payload) ask this one function, so "which font goes
# out" cannot end up being two different answers -- one on screen and one on
# the wire.
shared_custom_font_base() { # $1=slot
local slot="$1" stack meta_file cfamily cstack base
stack=$(uci -q get "aurora.theme.struct_font_$slot")
[ -n "$stack" ] || return 1
get_font_preset_by_stack "$slot" "$stack" >/dev/null 2>&1 && return 1
# Matched on the full stack recorded in .meta, same as write_font_preload
# -- never by guessing the filename.
for meta_file in "$FONT_CUSTOM_PATH/${slot}-"*.meta; do
[ -f "$meta_file" ] || continue
IFS='|' read -r cfamily cstack < "$meta_file"
[ "$cstack" = "$stack" ] || continue
base="${meta_file##*/}"; base="${base%.meta}"
[ -f "$FONT_CUSTOM_PATH/${base}.woff2" ] || return 1
printf '%s' "$base"
return 0
done
return 1
}
# True (0) when $1 is a filename the theme package itself ships into
# ICON_PATH. Those bytes exist on every device that has the theme installed,
# so sharing them would upload a file the receiver already has -- and, worse,
# the receiver would get the AUTHOR's copy landed under a hub-* name while
# their own shipped file sat unused.
# The first five are the asset slots' factory defaults; the last four are the
# icons header.ut's default floating toolbar points at. Both groups are
# "present on every install", which is the only property this predicate is
# about. Duplicated in marketplace.js as THEME_SHIPPED_NAMES --
# tests/marketplace-view.test.mjs asserts the two stay identical.
is_theme_shipped_image() { # $1=filename
case "$1" in
logo.svg|favicon.ico|app-icon-192x192.png|app-icon-512x512.png|apple-touch-icon.png) return 0 ;;
network.svg|overview.svg|software.svg|system.svg) return 0 ;;
esac
return 1
}
# True (0) when an image of $1 bytes is small enough to ship. The hub caps
# every non-font asset at 2 MiB while receive_upload accepts 8 MiB, so a
# perfectly usable local background can be too big for the store -- and the
# hub rejects the WHOLE share, not just that asset. Answering this here means
# the oversized slot is simply left out, the same posture the toolbar and
# font loops already take for their own out-of-range items.
# Both the manifest shown before publishing (hub_local_state) and the actual
# packing (build_share_payload) ask this one function.
image_fits_share() { # $1=size in bytes
[ "$1" -gt 0 ] && [ "$1" -le "$MAX_SHARED_IMAGE" ]
}
# The ICON_PATH filename an image asset kind would ship, or non-zero when the
# slot ships nothing (unset, still the factory file, or the file is gone).
# Deliberately does NOT apply image_fits_share: the manifest needs to tell an
# oversized slot apart from an empty one so it can say why nothing goes out.
# The name comes straight out of uci (or a login_bg url() capture) and is
# about to be joined onto $ICON_PATH and read, sha256'd and uploaded to the
# public hub -- a value containing "/" or ".." (e.g.
# "../../etc/aurora/device.key") could otherwise escape ICON_PATH and
# exfiltrate an arbitrary file. Rejected outright, same posture as
# remove_icon's `dirname "$filename" | grep -q "\.\."` guard.
local_image_name() { # $1=kind
local kind="$1" value fname
case "$kind" in
login_bg|main_bg)
value=$(uci -q get "aurora.theme.struct_$kind")
fname=$(printf '%s' "$value" | sed "s|url('/luci-static/aurora/images/||;s|')||")
;;
*)
fname=$(uci -q get "aurora.theme.$kind")
;;
esac
[ -n "$fname" ] || return 1
case "$fname" in */*|*..*) return 1 ;; esac
is_theme_shipped_image "$fname" && return 1
[ -f "$ICON_PATH/$fname" ] || return 1
printf '%s' "$fname"
}
# 一个工具栏图标能不能随配置发出去。图标存的是 ICON_PATH 里的文件名,而
# payload 里从来只有这个名字 —— 接收方的机器上根本没有这个文件,渲染出来就是
# 一个空方块(header.ut 把它拼成 background-image 的 url)。所以字节必须跟着走。
# 两道闸,**只看名字**:路径必须安全(同 local_image_name),主题自带的那几个
# 不算(对方本来就有那份字节)。刻意不碰文件本身 —— 这个判断决定的是槽位编号,
# 而接收端算同一个编号时,那些文件在它机器上根本还不存在。
toolbar_icon_custom() { # $1=icon filename
local icon="$1"
[ -n "$icon" ] || return 1
case "$icon" in */*|*..*) return 1 ;; esac
is_theme_shipped_image "$icon" && return 1
return 0
}
# 这一份图标的字节能不能真的发出去。只有分享端问它 —— 它看的是本机文件。
# 格式只收 PNG 和 SVG:审核台只有这两条清洗路径(canvas 重编码和 sanitizeSvg),
# 一张永远批不下来的图标不如从一开始就不发。
# 尺寸上限单独一档(MAX_SHARED_TOOLBAR_ICON):图标渲染出来是 20px,而 12 个
# 槽位都按图片的 2 MiB 放行的话,光图标就能把审核 body 顶到 24 MiB —— 那是
# 「能分享但永远批不了」。
# 不通过只是这一个槽位不上传,**编号照占**:漏掉的那个 kind 在 assets 里缺席,
# 后面的编号一个都不移位。
toolbar_icon_uploadable() { # $1=icon filename
local file="$ICON_PATH/$1" size
[ -f "$file" ] || return 1
size=$(wc -c < "$file")
[ "$size" -gt 0 ] && [ "$size" -le "$MAX_SHARED_TOOLBAR_ICON" ] || return 1
case "$(sniff_image_ext "$file")" in png|svg) ;; *) return 1 ;; esac
return 0
}
# 一条工具栏快捷方式够不够格随配置发出去。本地编辑器只要求每个字段非空,而
# hub 的 schema 严得多(title 1-30、url <=200 且带 scheme 或以 / 开头、icon 只认
# [A-Za-z0-9._-] 且 <=64)。越界的那一条整条不发,而不是让 hub 拒掉整次分享。
# 独立成函数是因为它有两个消费者:真正打包的 build_share_payload,和数图标序号
# 的 nth_custom_toolbar_icon。两处各判一次的话,一条被打包跳过的快捷方式仍然会
# 在序号里占掉一格,接收端从那里往后的图标全部错位。
toolbar_item_shareable() { # $1=uci section id
local sid="$1" title url icon enabled trimmed_title tlen
title=$(uci -q get "aurora.$sid.title")
trimmed_title=$(strip_control_chars "$title")
tlen=${#trimmed_title}
[ "$tlen" -ge 1 ] && [ "$tlen" -le 30 ] || return 1
# validate_and_apply_hub_payload rejects a literal quote in a title
# outright (see has_batch_unsafe_char); skip such an item here too, same
# posture as any other out-of-range item, instead of sharing a config that
# would fail to re-apply on the receiving end.
case "$trimmed_title" in *\'*) return 1 ;; esac
url=$(uci -q get "aurora.$sid.url")
has_control_char "$url" && return 1
case "$url" in
//*) return 1 ;;
http://*|https://*|/*) ;;
*) return 1 ;;
esac
[ "${#url}" -le 200 ] || return 1
icon=$(uci -q get "aurora.$sid.icon")
if [ -n "$icon" ]; then
has_control_char "$icon" && return 1
[ "${#icon}" -le 64 ] || return 1
printf '%s' "$icon" | grep -Eq '^[A-Za-z0-9._-]+$' || return 1
fi
enabled=$(uci -q get "aurora.$sid.enabled")
case "$enabled" in 0|1) ;; *) return 1 ;; esac
return 0
}
# The k-th distinct custom toolbar icon filename in uci order, or non-zero when
# there is none. This is the whole mapping between `toolbar_icon_<k>` and the
# shortcuts that use it: nothing about it travels on the wire, so the toolbar
# item shape in the payload stays exactly what it has always been.
# Both ends compute the same number because the receiving end's toolbar_item
# sections ARE payload.toolbar, in payload order
# (validate_and_apply_hub_payload deletes them all and re-adds them one by
# one), and because every rule feeding into the count -- toolbar_item_shareable
# for which shortcuts count, toolbar_icon_custom for which of their icons do,
# and the 12-item cap -- is decided from names and uci fields alone. Whether
# the bytes can actually be uploaded (toolbar_icon_uploadable) deliberately
# does NOT feed in: those files exist only on the sender, so letting them
# affect the numbering would put the two ends out of step.
nth_custom_toolbar_icon() { # $1=0-based index
local want="$1" sid icon i=0 tcount=0 seen=" "
for sid in $(uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'); do
[ "$tcount" -ge 12 ] && break
toolbar_item_shareable "$sid" || continue
tcount=$((tcount + 1))
icon=$(uci -q get "aurora.$sid.icon")
toolbar_icon_custom "$icon" || continue
case "$seen" in *" $icon "*) continue ;; esac
seen="$seen$icon "
[ "$i" = "$want" ] && { printf '%s' "$icon"; return 0; }
i=$((i + 1))
[ "$i" -ge "$MAX_SHARED_TOOLBAR_ICONS" ] && break
done
return 1
}
# Assembles the current /etc/config/aurora into the hub's strict payload
# schema and writes it to $1 (colors/layout/typography/toolbar/assets, no
# device_token/name -- those are the caller's job). The payload's "assets"
# entries carry only {kind,sha256,size} per the hub schema; the bytes
# themselves never pass through here. Which local file backs each kind is
# written to "$1.files", one "kind<TAB>path" line each (path relative to
# /luci-static/aurora/), for hub_share_begin to turn into a URL the browser
# fetches and uploads itself. Uploaded custom fonts ship as font_sans/font_mono
# assets; roster presets do not -- typography.font_sans/font_mono stay
# resolve_font_preset_id ids, and the receiving end downloads those itself.
build_share_payload() {
local out_file="$1" files_file="${1}.files"
local token prefix key value
local sid url icon enabled tcount trimmed_title
local kind fname file sha256 size
local font_sans_id font_mono_id struct_font_sans struct_font_mono
local slot base
local tb_icons=" " tb_count=0 tb_i=0
require_color_token_keys || return 1
: > "$files_file"
json_init
json_add_int "schema" 1
json_add_string "theme" "aurora"
json_add_object "colors"
for token in $COLOR_TOKEN_KEYS; do
for prefix in light dark; do
key="${prefix}_${token}"
value=$(uci -q get "aurora.theme.$key")
json_add_string "$key" "$value"
done
done
json_close_object
json_add_object "layout"
json_add_string "nav_type" "$(uci -q get aurora.theme.nav_type)"
json_add_string "struct_spacing" "$(uci -q get aurora.theme.struct_spacing)"
json_add_string "struct_radius_base" "$(uci -q get aurora.theme.struct_radius_base)"
json_add_string "struct_content_width_centered" "$(uci -q get aurora.theme.struct_content_width_centered)"
json_add_string "toolbar_enabled" "$(uci -q get aurora.theme.toolbar_enabled)"
# 主背景的三个可选键:没设就不发(接收端走 CSS fallback 默认),越界也不发
# —— 同字体栈的回退姿势,绝不让一个手改出界的值拒掉整次分享。
for key in struct_main_bg_alpha struct_main_bg_blur struct_main_bg_scrim struct_login_bg_alpha struct_login_bg_blur struct_login_bg_scrim; do
value=$(uci -q get "aurora.theme.$key")
[ -n "$value" ] && is_valid_main_bg_value "$key" "$value" \
&& json_add_string "$key" "$value"
done
json_close_object
font_sans_id=$(resolve_font_preset_id sans)
font_mono_id=$(resolve_font_preset_id mono)
struct_font_sans=$(uci -q get aurora.theme.struct_font_sans)
struct_font_mono=$(uci -q get aurora.theme.struct_font_mono)
# A struct_font_* stack that fails the hub's own regex (only possible via
# a hand-edited uci config; nothing this package writes can produce one)
# would otherwise get the whole share rejected by the hub -- fall back to
# the resolved preset's canonical stack, then to the slot's default stack,
# so typography always validates.
if ! is_valid_font_stack "$struct_font_sans"; then
struct_font_sans=$(font_preset_stack sans "$font_sans_id")
is_valid_font_stack "$struct_font_sans" || struct_font_sans=$(font_preset_stack sans default)
fi
if ! is_valid_font_stack "$struct_font_mono"; then
struct_font_mono=$(font_preset_stack mono "$font_mono_id")
is_valid_font_stack "$struct_font_mono" || struct_font_mono=$(font_preset_stack mono default)
fi
json_add_object "typography"
json_add_string "font_sans" "$font_sans_id"
json_add_string "font_mono" "$font_mono_id"
json_add_string "struct_font_sans" "$struct_font_sans"
json_add_string "struct_font_mono" "$struct_font_mono"
json_close_object
# Anonymous toolbar_item sections have no stable index, so their uci ids
# are recovered from `uci show` output rather than iterated by position.
# toolbar_item_shareable holds the per-field rules (and why an
# out-of-range item is skipped rather than failing the whole share).
# Capped at 12 to match the hub schema.
json_add_array "toolbar"
tcount=0
for sid in $(uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'); do
[ "$tcount" -ge 12 ] && break
toolbar_item_shareable "$sid" || continue
url=$(uci -q get "aurora.$sid.url")
icon=$(uci -q get "aurora.$sid.icon")
enabled=$(uci -q get "aurora.$sid.enabled")
trimmed_title=$(strip_control_chars "$(uci -q get "aurora.$sid.title")")
json_add_object ""
json_add_string "title" "$trimmed_title"
json_add_string "url" "$url"
[ -n "$icon" ] && json_add_string "icon" "$icon"
json_add_string "enabled" "$enabled"
json_close_object
tcount=$((tcount + 1))
# 图标的字节要跟着走,所以这里顺手把「哪些图标要编号」按首次出现顺序
# 去重记下来 —— 必须在**接受**一条之后才记,而不是遍历 uci 时就记:被
# 跳过的那条也有图标,让它占掉一个槽位号,接收端算出来的序号就整体错位
# 了。用的是 toolbar_icon_custom(只看名字),与接收端同一条规则;能不能
# 真的上传是下面 assets 那一段的事。见 nth_custom_toolbar_icon。
if toolbar_icon_custom "$icon" \
&& [ "$tb_count" -lt "$MAX_SHARED_TOOLBAR_ICONS" ]; then
case "$tb_icons" in *" $icon "*) ;; *)
tb_icons="$tb_icons$icon "
tb_count=$((tb_count + 1))
;;
esac
fi
done
json_close_array
# Only non-default, existing image assets are shared -- an unmodified
# factory filename means the user never customized that slot, so there is
# nothing worth uploading (and nothing copyright-risky to check).
json_add_array "assets"
for kind in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512 login_bg main_bg; do
fname=$(local_image_name "$kind") || continue
file="$ICON_PATH/$fname"
sha256=$(sha256sum "$file" 2>/dev/null | cut -d' ' -f1)
[ -n "$sha256" ] || continue
size=$(wc -c < "$file")
# 超限的图片整张跳过,而不是让 hub 拒掉整次分享 —— 见 image_fits_share。
image_fits_share "$size" || continue
json_add_object ""
json_add_string "kind" "$kind"
json_add_string "sha256" "$sha256"
json_add_int "size" "$size"
json_close_object
# 只记路径,相对 /luci-static/aurora/。字节由浏览器从本机直接读、直接
# PUT 到 hub —— uclient-fetch 走 TLS 传不动这个量级(实测 512KB 三次
# 里挂一次、1MB 必断,而同机 curl 传 1.6MB 只要 3.4 秒),而浏览器 fetch
# 没有这个限制。图片在 images/,字体在 fonts/custom/,所以这一列必须带
# 上目录 —— hub_share_begin 曾经把 images/ 前缀写死在拼 URL 那一步。
printf '%s\timages/%s\n' "$kind" "$fname" >> "$files_file"
done
# 工具栏图标的字节。payload.toolbar 里的 icon 一直只是个文件名,而那个文件
# 只存在于作者的路由器上 —— 接收方应用完,快捷方式的图就是一个空框
# (header.ut 把它拼成 background-image 的 url)。编号规则见
# nth_custom_toolbar_icon:两端各算各的,线格式一个字段都不用加。
# tb_icons 里的名字已经过了 toolbar_icon_custom;这里再问一次能不能上传。
# 不能就只是**这一个 kind 缺席**,编号不移位 —— 接收端按编号找名字,不按
# assets 的下标。
tb_i=0
for icon in $tb_icons; do
if toolbar_icon_uploadable "$icon"; then
file="$ICON_PATH/$icon"
sha256=$(sha256sum "$file" 2>/dev/null | cut -d' ' -f1)
if [ -n "$sha256" ]; then
json_add_object ""
json_add_string "kind" "toolbar_icon_$tb_i"
json_add_string "sha256" "$sha256"
json_add_int "size" "$(wc -c < "$file")"
json_close_object
printf 'toolbar_icon_%s\timages/%s\n' "$tb_i" "$icon" >> "$files_file"
fi
fi
tb_i=$((tb_i + 1))
done
# 自定义字体跟着配置走。struct_font_* 里那个族名只存在于上传它的那台路由器
# 上,只发字体栈不发字节,收到的人就会静默回退到栈尾的 Lato —— 配色布局全
# 对、字体没变,而商店详情页还明明白白写着那个字体名。
# 名册预设(geist-sans 等)刻意不上传:接收端的 sync_and_cache_fonts_from_uci
# 会自己从固定源下载并校验,再传一份既浪费带宽也绕开了那份校验。
for slot in sans mono; do
base=$(shared_custom_font_base "$slot") || continue
file="$FONT_CUSTOM_PATH/${base}.woff2"
sha256=$(sha256sum "$file" 2>/dev/null | cut -d' ' -f1)
[ -n "$sha256" ] || continue
size=$(wc -c < "$file")
# 超限的字体整份跳过,而不是让 hub 拒掉整次分享 —— 与上面图片和
# toolbar 那两处「越界的那一项不发,其余照发」同一个姿态。上限与
# hub 的 FONT_ASSET_LIMIT 同为 8 MiB。
[ "$size" -gt 0 ] && [ "$size" -le "$MAX_UPLOAD" ] || continue
json_add_object ""
json_add_string "kind" "font_$slot"
json_add_string "sha256" "$sha256"
json_add_int "size" "$size"
json_close_object
printf '%s\tfonts/custom/%s.woff2\n' "font_$slot" "$base" >> "$files_file"
done
json_close_array
json_dump > "$out_file"
json_cleanup
return 0
}
# Returns success (0) when $1 contains any control character, INCLUDING a
# newline. This must run before every regex/case check below that uses
# `grep '^...$'` or a case glob: `^`/`$` anchor to line boundaries, not the
# whole string, so a value like "#fff\nset dropbear.@dropbear[0].enable=0"
# would otherwise pass the color regex (first line matches) and then, once
# written as `set aurora.theme.key='<value>'` into the uci-batch script,
# the embedded newline starts a second, attacker-controlled uci command --
# a cross-package injection. Reject, never strip: stripping would silently
# change accepted content instead of failing closed.
has_control_char() {
case "$1" in *[[:cntrl:]]*) return 0 ;; *) return 1 ;; esac
}
# $1 with every control character removed.
# NOT `tr -d '[:cntrl:]'`, which is what this used to be everywhere. BusyBox's
# tr does not implement POSIX character classes: it reads that argument as the
# literal set { [ : c n t r l ] } and deletes those letters from the text. On a
# real device "Overview" came back "Oveview" and "Again" came back "Agai" ---
# every shared shortcut title quietly lost its c/n/t/r/l, and the apply side
# wrote the mangled version into uci. GNU and BSD tr both handle the class, so
# nothing on a developer machine ever showed it.
# The shell's own glob classes DO work under ash (has_control_char above relies
# on the same thing), so the strip is done with parameter expansion instead.
# Each pass eats the run of clean characters up to the next control character,
# so the loop runs once per control character, not once per character.
strip_control_chars() { # $1=text
local rest="$1" out="" clean
while [ -n "$rest" ]; do
clean=${rest%%[[:cntrl:]]*}
if [ "$clean" = "$rest" ]; then
out="$out$rest"
break
fi
out="$out$clean"
rest=${rest#"$clean"?}
done
printf '%s' "$out"
}
# True (0) when $1 is unsafe to single-quote verbatim into a `uci batch`
# script as `set foo.bar='<value>'`: either a control character (see
# has_control_char) or a literal single quote. `uci batch` parses each line
# as a shell-like command, so a value containing "'" closes the quoted
# argument early -- e.g. a struct_font_sans of `x' network.lan.proto='none`
# turns `set aurora.theme.struct_font_sans='x' network.lan.proto='none'`
# into a 3-arg `set` line that uci silently *skips* (not a cross-package
# injection -- the token can never be the line's sole/first argument -- but
# it defeats the "every field validated or the whole apply fails" contract
# by dropping that field without failing the apply). Both the hub's
# struct_font_*/toolbar-title schema rules otherwise *permit* a literal
# quote, so this must be checked in addition to, not instead of, the format
# regex. Reject outright, matching has_control_char's fail-closed posture.
has_batch_unsafe_char() {
has_control_char "$1" && return 0
case "$1" in *\'*) return 0 ;; esac
return 1
}
# Local, non-trusting validation of a hub theme payload, plus its atomic UCI
# apply. The server is untrusted input: every field is parsed with jshn and
# checked against the same rules the frontend enforces, using only
# case/grep -E (no eval). Nothing is written to UCI until every field in the
# payload has passed; on any violation the function returns non-zero having
# touched no UCI state at all. On success it builds one `uci batch` (colors
# deleted then reset, layout/typography set, toolbar_item sections replaced,
# hub_applied/icon_cache_version bookkeeping), commits, and reverts on
# commit failure.
# $1 is the FULL flat hub config body (as returned by hub_http_get -- there
# is no "data" wrapper; that envelope belongs to the frontend and is added
# by hubFetch in htdocs/.../utils/hub-api.js, which never touches this
# path, see hub_apply_worker). The config
# name for hub_applied bookkeeping lives at the body's root ("name"), not
# inside "payload" -- it is read here, before "payload" is ever selected,
# so the caller no longer has to extract and pass it separately.
validate_and_apply_hub_payload() {
local body_json="$1" hub_name
local batch_file schema theme
local key token prefix value
local nav_type toolbar_enabled
local struct_spacing struct_radius_base struct_content_width_centered
local font_sans font_mono struct_font_sans struct_font_mono
local color_keys color_key_count=0 k
local i title url icon enabled tcount trimmed_title tlen
local toolbar_section_count
json_load "$body_json" 2>/dev/null || return 1
# hub_applied is server-supplied free text too, and is written the same
# single-quoted uci-batch way as every other field below -- guard it the
# same way (control chars AND a literal quote, see has_batch_unsafe_char)
# before it ever reaches the batch. Read at the body's root, before
# "payload" is selected.
json_get_var hub_name "name"
has_batch_unsafe_char "$hub_name" && { json_cleanup; return 1; }
json_select payload 2>/dev/null || { json_cleanup; return 1; }
json_get_var schema "schema"
[ "$schema" = "1" ] || { json_cleanup; return 1; }
json_get_var theme "theme"
[ "$theme" = "aurora" ] || { json_cleanup; return 1; }
require_color_token_keys || { json_cleanup; return 1; }
batch_file=$(mktemp) || return 1
# --- colors: exactly the 62 light_/dark_ keys, each a valid hex color ---
json_select colors 2>/dev/null || { rm -f "$batch_file"; return 1; }
json_get_keys color_keys
for k in $color_keys; do color_key_count=$((color_key_count + 1)); done
if [ "$color_key_count" -ne "$PRESET_EXPECTED_COUNT" ]; then
rm -f "$batch_file"; return 1
fi
for token in $COLOR_TOKEN_KEYS; do
for prefix in light dark; do
key="${prefix}_${token}"
json_get_var value "$key" 2>/dev/null
[ -n "$value" ] || { rm -f "$batch_file"; return 1; }
has_control_char "$value" && { rm -f "$batch_file"; return 1; }
printf '%s' "$value" | grep -Eqi '^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$' || {
rm -f "$batch_file"; return 1; }
printf "set aurora.theme.%s='%s'\n" "$key" "$value" >> "$batch_file"
done
done
json_select ..
# --- layout: enum nav_type/toolbar_enabled, bounded rem lengths ---
json_select layout 2>/dev/null || { rm -f "$batch_file"; return 1; }
json_get_var nav_type "nav_type"
case "$nav_type" in mega-menu|dropdown|sidebar) ;; *) rm -f "$batch_file"; return 1 ;; esac
json_get_var toolbar_enabled "toolbar_enabled"
case "$toolbar_enabled" in 0|1) ;; *) rm -f "$batch_file"; return 1 ;; esac
json_get_var struct_spacing "struct_spacing"
has_control_char "$struct_spacing" && { rm -f "$batch_file"; return 1; }
printf '%s' "$struct_spacing" | grep -Eq '^[0-9]+(\.[0-9]+)?rem$' \
|| { rm -f "$batch_file"; return 1; }
awk -v v="${struct_spacing%rem}" 'BEGIN{ if (v+0<0.05 || v+0>1) exit 1 }' \
|| { rm -f "$batch_file"; return 1; }
json_get_var struct_radius_base "struct_radius_base"
has_control_char "$struct_radius_base" && { rm -f "$batch_file"; return 1; }
printf '%s' "$struct_radius_base" | grep -Eq '^[0-9]+(\.[0-9]+)?rem$' \
|| { rm -f "$batch_file"; return 1; }
awk -v v="${struct_radius_base%rem}" 'BEGIN{ if (v+0<0 || v+0>2) exit 1 }' \
|| { rm -f "$batch_file"; return 1; }
json_get_var struct_content_width_centered "struct_content_width_centered"
has_control_char "$struct_content_width_centered" && { rm -f "$batch_file"; return 1; }
printf '%s' "$struct_content_width_centered" | grep -Eq '^[0-9]+(\.[0-9]+)?rem$' \
|| { rm -f "$batch_file"; return 1; }
awk -v v="${struct_content_width_centered%rem}" 'BEGIN{ if (v+0<40 || v+0>160) exit 1 }' \
|| { rm -f "$batch_file"; return 1; }
printf "set aurora.theme.nav_type='%s'\n" "$nav_type" >> "$batch_file"
printf "set aurora.theme.toolbar_enabled='%s'\n" "$toolbar_enabled" >> "$batch_file"
printf "set aurora.theme.struct_spacing='%s'\n" "$struct_spacing" >> "$batch_file"
printf "set aurora.theme.struct_radius_base='%s'\n" "$struct_radius_base" >> "$batch_file"
printf "set aurora.theme.struct_content_width_centered='%s'\n" \
"$struct_content_width_centered" >> "$batch_file"
HUB_BG_TUNABLES=""
for key in struct_main_bg_alpha struct_main_bg_blur struct_main_bg_scrim struct_login_bg_alpha struct_login_bg_blur struct_login_bg_scrim; do
value=""
json_get_var value "$key" 2>/dev/null
if [ -n "$value" ]; then
is_valid_main_bg_value "$key" "$value" || { rm -f "$batch_file"; return 1; }
fi
HUB_BG_TUNABLES="$HUB_BG_TUNABLES$key=$value
"
done
json_select ..
# --- typography: enum font stacks (informational) + char-limited stacks ---
json_select typography 2>/dev/null || { rm -f "$batch_file"; return 1; }
json_get_var font_sans "font_sans"
case "$font_sans" in default|system|geist-sans|nunito|space-grotesk) ;; \
*) rm -f "$batch_file"; return 1 ;; esac
json_get_var font_mono "font_mono"
case "$font_mono" in default|jetbrains-mono|maple-mono|fira-code|cascadia-code) ;; \
*) rm -f "$batch_file"; return 1 ;; esac
json_get_var struct_font_sans "struct_font_sans"
[ -n "$struct_font_sans" ] && [ "${#struct_font_sans}" -le 200 ] \
|| { rm -f "$batch_file"; return 1; }
has_control_char "$struct_font_sans" && { rm -f "$batch_file"; return 1; }
printf '%s' "$struct_font_sans" | grep -Eq '^[A-Za-z0-9 ,"'\''-]+$' \
|| { rm -f "$batch_file"; return 1; }
# The hub's own regex above permits a literal single quote; reject it
# anyway before it is single-quoted into the uci batch below (see
# has_batch_unsafe_char).
case "$struct_font_sans" in *\'*) rm -f "$batch_file"; return 1 ;; esac
json_get_var struct_font_mono "struct_font_mono"
[ -n "$struct_font_mono" ] && [ "${#struct_font_mono}" -le 200 ] \
|| { rm -f "$batch_file"; return 1; }
has_control_char "$struct_font_mono" && { rm -f "$batch_file"; return 1; }
printf '%s' "$struct_font_mono" | grep -Eq '^[A-Za-z0-9 ,"'\''-]+$' \
|| { rm -f "$batch_file"; return 1; }
case "$struct_font_mono" in *\'*) rm -f "$batch_file"; return 1 ;; esac
# font_sans/font_mono are informational preset ids only -- not applied to
# UCI. Only the resolved struct_font_* stacks are.
printf "set aurora.theme.struct_font_sans='%s'\n" "$struct_font_sans" >> "$batch_file"
printf "set aurora.theme.struct_font_mono='%s'\n" "$struct_font_mono" >> "$batch_file"
json_select ..
# --- toolbar: <=12 items, each field validated ---
json_select toolbar 2>/dev/null || { rm -f "$batch_file"; return 1; }
i=1; tcount=0
while json_select "$i" 2>/dev/null; do
tcount=$((tcount + 1))
if [ "$tcount" -gt 12 ]; then
json_select ..; rm -f "$batch_file"; return 1
fi
json_get_var title "title"
json_get_var url "url"
json_get_var icon "icon"
json_get_var enabled "enabled"
# 长度先粗筛再剥:剥离按控制字符个数循环,而这里的 title 来自不可信的 hub
# 响应。一份塞了几十万个控制字符的标题会把它拖成一次拒绝服务,而任何合法
# 标题剥完都要 <=30,原串再长也长不到哪去。
[ "${#title}" -le 200 ] || { json_select ..; rm -f "$batch_file"; return 1; }
trimmed_title=$(strip_control_chars "$title")
tlen=${#trimmed_title}
if [ "$tlen" -lt 1 ] || [ "$tlen" -gt 30 ]; then
json_select ..; rm -f "$batch_file"; return 1
fi
# The hub's title schema has no charset restriction beyond length,
# so a literal single quote is otherwise legal content; reject it
# here before it is single-quoted into the uci batch below.
case "$trimmed_title" in
*\'*) json_select ..; rm -f "$batch_file"; return 1 ;;
esac
has_control_char "$url" && { json_select ..; rm -f "$batch_file"; return 1; }
case "$url" in
//*) json_select ..; rm -f "$batch_file"; return 1 ;;
http://*|https://*|/*) ;;
*) json_select ..; rm -f "$batch_file"; return 1 ;;
esac
[ "${#url}" -le 200 ] || { json_select ..; rm -f "$batch_file"; return 1; }
if [ -n "$icon" ]; then
[ "${#icon}" -le 64 ] || { json_select ..; rm -f "$batch_file"; return 1; }
has_control_char "$icon" && { json_select ..; rm -f "$batch_file"; return 1; }
printf '%s' "$icon" | grep -Eq '^[A-Za-z0-9._-]+$' \
|| { json_select ..; rm -f "$batch_file"; return 1; }
fi
case "$enabled" in 0|1) ;; *) json_select ..; rm -f "$batch_file"; return 1 ;; esac
printf 'add aurora toolbar_item\n' >> "$batch_file"
printf "set aurora.@toolbar_item[-1].title='%s'\n" "$trimmed_title" >> "$batch_file"
printf "set aurora.@toolbar_item[-1].url='%s'\n" "$url" >> "$batch_file"
[ -n "$icon" ] && printf "set aurora.@toolbar_item[-1].icon='%s'\n" "$icon" >> "$batch_file"
printf "set aurora.@toolbar_item[-1].enabled='%s'\n" "$enabled" >> "$batch_file"
json_select ..
i=$((i + 1))
done
json_select ..
json_cleanup
# Every field validated: build the delete-then-apply batch. Deletes come
# first (colors and toolbar_item sections) so nothing from a previous
# config survives, then the validated data is written; values are
# single-quoted (critical -- a bare `#` hex is otherwise parsed by `uci
# batch` as a comment and truncates the value, see apply_theme_preset).
# The pipeline's exit status (== `uci batch`'s own exit status, since it is
# the last command in the pipe) MUST be checked here: previously it was
# discarded and only the later `uci commit` was checked, so a mid-batch
# failure (e.g. a malformed line) could still be committed with whatever
# partial state `uci batch` had staged before it failed.
if ! {
uci -q show aurora.theme \
| sed -n 's/^aurora\.theme\.\(\(light\|dark\)_[^=]*\)=.*/delete aurora.theme.\1/p'
toolbar_section_count=$(uci -q show aurora 2>/dev/null | grep -c '=toolbar_item$')
i=0
while [ "$i" -lt "$toolbar_section_count" ]; do
printf 'delete aurora.@toolbar_item[0]\n'
i=$((i + 1))
done
cat "$batch_file"
printf "set aurora.theme.hub_applied='%s'\n" "$hub_name"
printf "set aurora.theme.icon_cache_version='%s'\n" "$(date +%s)"
} | uci batch >/dev/null; then
rm -f "$batch_file"
uci -q revert aurora
return 1
fi
rm -f "$batch_file"
if ! uci -q commit aurora; then
uci -q revert aurora
return 1
fi
chmod 644 "$CONFIG_FILE" 2>/dev/null
return 0
}
apply_hub_bg_tunables() { # $1=kinds written by this apply
local written=" ${1:-} " key value changed=0
[ -n "$HUB_BG_TUNABLES" ] || return 0
while IFS='=' read -r key value; do
[ -n "$key" ] || continue
case "$key" in
struct_main_bg_*)
case "$written" in *" main_bg "*) ;; *) continue ;; esac ;;
struct_login_bg_*)
case "$written" in *" login_bg "*) ;; *) continue ;; esac ;;
esac
if [ -n "$value" ]; then
uci -q set "aurora.theme.$key=$value"
else
uci -q delete "aurora.theme.$key"
fi
changed=1
done <<-EOF
$HUB_BG_TUNABLES
EOF
[ "$changed" = 1 ] && uci -q commit aurora
return 0
}
# Async apply worker (forked by the hub_apply handler): ①ensure_device_identity
# ②snapshot the current config, but only when it is the user's own -- see
# backup_if_mine ③fetch the hub payload ④validate+apply locally (no server trust)
# ⑤fetch+verify assets, best-effort ⑥resync derived font CSS ⑦fire-and-forget
# download-count ping. Every step updates the job status file so the
# frontend can poll get_hub_status.
hub_apply_worker() {
local job_id="$1" id="$2"
local body tmpfile fetch_rc
local asset_i=1 a_kind a_sha256 a_url
local landed=""
HUB_BG_TUNABLES=""
ensure_device_identity
write_hub_job_status "$job_id" "running" "backup" ""
ensure_active_source
backup_if_mine
write_hub_job_status "$job_id" "running" "fetch" ""
body=$(hub_http_get "/api/v1/themes/aurora/configs/$id")
fetch_rc=$?
if [ "$fetch_rc" != 0 ]; then
# 2 is the store answering "no such config": the theme was taken down
# between its card being drawn and Apply being tapped. Telling the
# user to check their connection over that sends them hunting for a
# fault that is not theirs.
if [ "$fetch_rc" = 2 ]; then
write_hub_job_status "$job_id" "error" "fetch" "invalid_id"
else
write_hub_job_status "$job_id" "error" "fetch" "hub_unreachable"
fi
return 1
fi
# $body is the hub's response to a direct hub_http_get call: a FLAT
# object (id/name/author/.../payload/assets at the root), never wrapped
# in "data" -- that envelope is the frontend's, added by hubFetch in
# utils/hub-api.js on the browser-direct read path, which this code
# never goes through. validate_and_apply_hub_payload takes this flat
# body directly and does its own json_load/json_select.
write_hub_job_status "$job_id" "running" "validate" ""
if ! validate_and_apply_hub_payload "$body"; then
write_hub_job_status "$job_id" "error" "validate" "bad_payload"
return 1
fi
write_hub_job_status "$job_id" "running" "assets" ""
json_load "$body" 2>/dev/null
if json_select assets 2>/dev/null; then
mkdir -p "$ICON_PATH"
asset_i=1
while json_select "$asset_i" 2>/dev/null; do
json_get_var a_kind "kind"
json_get_var a_sha256 "sha256"
json_get_var a_url "url"
json_select ..
# sha256 feeds hub_asset_target_name's login_bg filename (as the
# first 12 chars); an unvalidated value there could smuggle a
# path-traversal segment into $ICON_PATH/$target, so it must be
# rejected outright on any control character -- `grep '^...$'`
# anchors per line, not to the whole string, so "64 hex chars,
# newline, garbage" would otherwise still satisfy the regex on
# its first line (the same bypass class has_control_char guards
# against everywhere else in this file) -- before being matched
# against the strict 64-hex-char shape and used for a filename
# or handed to fetch_verified.
if ! has_control_char "$a_sha256" \
&& printf '%s' "$a_sha256" | grep -Eq '^[0-9a-fA-F]{64}$' \
&& [ -n "$a_url" ]; then
# Fonts land as a three-file set under FONT_CUSTOM_PATH and
# set no uci option of their own, so they take their own
# path rather than being forced through the image shape.
case "$a_kind" in
font_sans|font_mono)
apply_hub_font_asset "${a_kind#font_}" "$a_sha256" \
"$HUB_BASE$a_url"
;;
# 图标落地之外还要把快捷方式指过来,所以走自己那条路。
toolbar_icon_*)
apply_hub_toolbar_icon "$a_kind" "$a_sha256" \
"$HUB_BASE$a_url"
;;
*)
apply_hub_asset "$a_kind" "$a_sha256" \
"$HUB_BASE$a_url" \
&& landed="$landed$a_kind "
;;
esac
fi
asset_i=$((asset_i + 1))
done
json_select ..
fi
json_cleanup
write_hub_job_status "$job_id" "running" "finalize" ""
apply_hub_bg_tunables "$landed"
clear_inherited_assets "$landed"
drop_missing_toolbar_icons
# 必须是 _and_cache_ 那个:分享带来的字体多半是这台路由器从没下载过的
# 预设,而 sync_font_css_from_uci 只按已有缓存重写 CSS —— 没有 .face 就
# 不写 @font-face,字体栈又总以内置族收尾,于是静默回退成 Lato,看起来
# 就是"配色应用了、字体没应用"。apply_theme_preset 一直用的就是这个。
sync_and_cache_fonts_from_uci
# 必须排在所有资产落地和 uci 指向之后:它按「当前还有没有槽位指着」判断
# 去留,抢在前面跑就会把刚下载的图当成垃圾删掉。
sweep_hub_images
update_icon_cache_timestamp
# 排在资产之后:资产是在色值那批 uci batch 之后单独 commit 的,指纹抢在前面
# 算,刚应用完的主题立刻就会读回「已修改」。
record_active_source "hub:$id"
write_hub_job_status "$job_id" "done" "finalize" ""
tmpfile=$(mktemp)
printf '{ "device_hash": "%s" }' "$DEVICE_HASH" > "$tmpfile"
hub_http_post "/api/v1/themes/aurora/configs/$id/download" "$tmpfile" >/dev/null 2>&1
rm -f "$tmpfile"
return 0
}
write_font_job_status() {
local job_id="$1" state="$2" sans_status="$3" mono_status="$4" sans_error="$5" mono_error="$6"
local status_file="$FONT_JOB_PATH/$job_id.status"
mkdir -p "$FONT_JOB_PATH"
{
printf "state='%s'\n" "$state"
printf "sans_status='%s'\n" "$sans_status"
printf "mono_status='%s'\n" "$mono_status"
printf "sans_error='%s'\n" "$sans_error"
printf "mono_error='%s'\n" "$mono_error"
} > "$status_file.tmp"
mv "$status_file.tmp" "$status_file"
}
write_default_font_face() {
cat <<'EOF'
@font-face {
font-display: swap;
font-family: "Lato";
font-style: normal;
font-weight: 400;
src: url("/luci-static/aurora/fonts/lato-v24-latin-regular.woff2") format("woff2");
}
EOF
}
get_font_preset_by_stack() {
local slot="$1" stack="$2" font_name meta meta_stack
[ -z "$stack" ] && {
echo "default"
return 0
}
for font_name in $(list_font_names "$slot"); do
meta=$(get_font_meta "$slot" "$font_name") || continue
meta_stack=$(echo "$meta" | cut -d'|' -f4)
if [ "$stack" = "$meta_stack" ]; then
echo "$font_name"
return 0
fi
done
return 1
}
get_font_preset_from_uci() {
local slot="$1" stack
stack=$(uci -q get "aurora.theme.struct_font_$slot")
if [ -n "$stack" ] && get_font_preset_by_stack "$slot" "$stack"; then
return 0
fi
echo "default"
}
write_combined_font_css() {
local sans_preset="${1:-default}" mono_preset="${2:-default}" sans_stack="$3"
local tmp_file="$FONT_CSS_FILE.tmp"
local slot preset preset_file face_file f
get_font_meta "sans" "$sans_preset" >/dev/null 2>&1 || return 1
get_font_meta "mono" "$mono_preset" >/dev/null 2>&1 || return 1
mkdir -p "$FONT_PATH"
{
write_default_font_face
for slot in sans mono; do
case "$slot" in
sans) preset="$sans_preset" ;;
mono) preset="$mono_preset" ;;
esac
[ "${preset:-default}" = "default" ] && continue
preset_file="$FONT_PATH/aurora-font-${slot}.preset"
face_file="$FONT_PATH/aurora-font-${slot}.face"
if [ -s "$face_file" ] && [ "$(cat "$preset_file" 2>/dev/null)" = "$preset" ]; then
printf '\n'
cat "$face_file"
printf '\n'
fi
done
# Custom uploaded fonts are always declared; browsers only fetch a
# face when its family is actually referenced by the active stack.
for f in "$FONT_CUSTOM_PATH"/*.face; do
[ -s "$f" ] || continue
printf '\n'
cat "$f"
printf '\n'
done
} > "$tmp_file" || return 1
chmod 644 "$tmp_file"
mv "$tmp_file" "$FONT_CSS_FILE"
write_font_preload "$sans_preset" "$sans_stack"
return 0
}
# Single-line marker consumed by the theme's header.ut: the woff2 to
# <link rel="preload">. Points at the active sans 400 weight, whether that
# weight comes from a cached preset or a user-uploaded custom font; falls
# back to the built-in Lato face when no cached webfont is active.
write_font_preload() {
local sans_preset="${1:-default}" sans_stack_override="$2"
local target="" candidate
local sans_family
local uci_stack meta_file base cfamily cstack face_url
sans_family=$(get_font_meta "sans" "$sans_preset" 2>/dev/null | cut -d'|' -f3)
# Reject unsafe overrides (only ever compared for equality against .meta
# stacks, but keep the same injection posture as other RPC inputs): a
# newline or shell/markup metacharacter means "not a real stack", so fall
# back to the current UCI value instead of trusting it.
case "$sans_stack_override" in
*[\;\{\}\<\>]*) sans_stack_override="" ;;
esac
if printf '%s' "$sans_stack_override" | LC_ALL=C grep -q '[[:cntrl:]]'; then
sans_stack_override=""
fi
if [ "$sans_preset" != "default" ]; then
candidate="$FONT_PATH/aurora-font-sans-${sans_preset}-400.woff2"
[ -f "$candidate" ] && [ -s "$FONT_PATH/aurora-font-sans.face" ] && \
target="/luci-static/aurora/fonts/aurora-font-sans-${sans_preset}-400.woff2"
fi
if [ -z "$target" ]; then
# Custom sans fonts don't resolve to a named preset (they always
# collapse to "default" downstream), so match the active stack
# against the custom fonts' .meta stacks the same way remove_font
# detects "in use", then reuse the exact URL baked into its .face
# (including the cache-busting query) instead of reconstructing it.
uci_stack="${sans_stack_override:-$(uci -q get aurora.theme.struct_font_sans)}"
if [ -n "$uci_stack" ]; then
for meta_file in "$FONT_CUSTOM_PATH"/sans-*.meta; do
[ -f "$meta_file" ] || continue
IFS='|' read -r cfamily cstack < "$meta_file"
[ "$cstack" = "$uci_stack" ] || continue
base="${meta_file##*/}"; base="${base%.meta}"
[ -s "$FONT_CUSTOM_PATH/${base}.face" ] || continue
face_url=$(sed -n 's/.*url("\([^"]*\)").*/\1/p' "$FONT_CUSTOM_PATH/${base}.face" | head -1)
[ -n "$face_url" ] && target="$face_url"
break
done
fi
fi
if [ -z "$target" ] && [ "$sans_preset" != "default" ] && [ -z "$sans_family" ]; then
# Pure-stack preset (e.g. "system"): nothing to preload.
printf '%s\n' "" > "$FONT_PATH/preload.txt.tmp"
chmod 644 "$FONT_PATH/preload.txt.tmp"
mv "$FONT_PATH/preload.txt.tmp" "$FONT_PATH/preload.txt"
return 0
fi
[ -z "$target" ] && target="/luci-static/aurora/fonts/lato-v24-latin-regular.woff2"
printf '%s\n' "$target" > "$FONT_PATH/preload.txt.tmp"
chmod 644 "$FONT_PATH/preload.txt.tmp"
mv "$FONT_PATH/preload.txt.tmp" "$FONT_PATH/preload.txt"
}
prepare_font_slot() {
local slot="$1" preset="$2"
local preset_file="$FONT_PATH/aurora-font-${slot}.preset"
local current_preset
if [ "${preset:-default}" = "default" ]; then
cleanup_custom_fonts "$slot"
return 0
fi
get_font_meta "$slot" "$preset" >/dev/null 2>&1 || return 1
current_preset=$(cat "$preset_file" 2>/dev/null)
if [ "$current_preset" != "$preset" ] || [ ! -s "$FONT_PATH/aurora-font-${slot}.face" ]; then
cleanup_custom_fonts "$slot"
fi
mkdir -p "$FONT_PATH"
printf '%s' "$preset" > "$preset_file"
chmod 644 "$preset_file"
return 0
}
# Download one woff2 with checksum verification: primary source first, then
# fallback; a hash mismatch counts as failure and triggers the fallback too.
fetch_verified() {
local dest="$1" sha256="$2" url1="$3" url2="$4" url actual
for url in "$url1" "$url2"; do
[ -n "$url" ] || continue
if fetch_url "$dest" "$url" 2>/dev/null; then
actual=$(sha256sum "$dest" | cut -d' ' -f1)
[ "$actual" = "$sha256" ] && return 0
fi
rm -f "$dest"
done
return 1
}
cache_font_slot() {
local slot="$1" preset="$2"
local preset_file="$FONT_PATH/aurora-font-${slot}.preset"
local face_file="$FONT_PATH/aurora-font-${slot}.face"
local face_tmp="$FONT_PATH/aurora-font-${slot}.face.tmp"
local files meta family weight sha256 url1 url2 local_file ok=1
[ "${preset:-default}" = "default" ] && return 0
if [ -s "$face_file" ] && [ "$(cat "$preset_file" 2>/dev/null)" = "$preset" ]; then
return 0
fi
meta=$(get_font_meta "$slot" "$preset") || return 1
family=$(printf '%s' "$meta" | cut -d'|' -f3)
files=$(list_font_files "$slot" "$preset")
# Pure-stack preset (e.g. "system"): no webfont files to download,
# so there is nothing to cache and the slot is already in effect.
[ -z "$files" ] && [ -z "$family" ] && return 0
[ -n "$files" ] || return 1
[ -n "$family" ] || return 1
mkdir -p "$FONT_PATH"
: > "$face_tmp"
while IFS='|' read -r weight sha256 url1 url2; do
[ -n "$weight" ] || continue
local_file="aurora-font-${slot}-${preset}-${weight}.woff2"
if ! fetch_verified "$FONT_PATH/$local_file.tmp" "$sha256" "$url1" "$url2"; then
ok=0
break
fi
mv "$FONT_PATH/$local_file.tmp" "$FONT_PATH/$local_file"
chmod 644 "$FONT_PATH/$local_file"
cat >> "$face_tmp" <<FACE
@font-face {
font-display: swap;
font-family: "$family";
font-style: normal;
font-weight: $weight;
src: url("/luci-static/aurora/fonts/$local_file") format("woff2");
}
FACE
done <<EOF
$files
EOF
if [ "$ok" != "1" ] || [ ! -s "$face_tmp" ]; then
rm -f "$face_tmp"
rm -f "$FONT_PATH/aurora-font-${slot}-${preset}"-*.woff2
return 1
fi
# Bail out if the user switched presets while we were downloading.
[ "$(cat "$preset_file" 2>/dev/null)" = "$preset" ] || {
rm -f "$face_tmp"
rm -f "$FONT_PATH/aurora-font-${slot}-${preset}"-*.woff2
return 1
}
chmod 644 "$face_tmp"
mv "$face_tmp" "$face_file"
return 0
}
font_job_worker() {
local job_id="$1" sans_preset="$2" mono_preset="$3" sans_stack="$4"
local sans_status="ready" mono_status="ready" sans_error="" mono_error=""
write_font_job_status "$job_id" "running" "$sans_status" "$mono_status" "$sans_error" "$mono_error"
if [ "${sans_preset:-default}" != "default" ]; then
if cache_font_slot "sans" "$sans_preset"; then
sans_status="cached"
else
sans_status="fallback"
sans_error="local_cache_failed"
fi
write_combined_font_css "$sans_preset" "$mono_preset" "$sans_stack"
write_font_job_status "$job_id" "running" "$sans_status" "$mono_status" "$sans_error" "$mono_error"
fi
if [ "${mono_preset:-default}" != "default" ]; then
if cache_font_slot "mono" "$mono_preset"; then
mono_status="cached"
else
mono_status="fallback"
mono_error="local_cache_failed"
fi
write_combined_font_css "$sans_preset" "$mono_preset" "$sans_stack"
fi
update_icon_cache_timestamp
write_font_job_status "$job_id" "done" "$sans_status" "$mono_status" "$sans_error" "$mono_error"
}
start_font_job() {
local sans_preset="$1" mono_preset="$2" sans_stack="$3"
local job_id
[ -z "$sans_preset" ] && sans_preset=$(get_font_preset_from_uci "sans")
[ -z "$mono_preset" ] && mono_preset=$(get_font_preset_from_uci "mono")
[ -z "$sans_preset" ] && sans_preset="default"
[ -z "$mono_preset" ] && mono_preset="default"
if ! prepare_font_slot "sans" "$sans_preset"; then
json_init
json_add_int "result" 1
json_add_string "error" "Invalid sans font preset"
json_dump; json_cleanup
return 0
fi
if ! prepare_font_slot "mono" "$mono_preset"; then
json_init
json_add_int "result" 1
json_add_string "error" "Invalid mono font preset"
json_dump; json_cleanup
return 0
fi
if ! write_combined_font_css "$sans_preset" "$mono_preset" "$sans_stack"; then
json_init
json_add_int "result" 1
json_add_string "error" "Failed to write font CSS"
json_dump; json_cleanup
return 0
fi
update_icon_cache_timestamp
job_id="$(date +%s)-$$"
write_font_job_status "$job_id" "queued" "ready" "ready" "" ""
(font_job_worker "$job_id" "$sans_preset" "$mono_preset" "$sans_stack") </dev/null >/dev/null 2>&1 &
json_init
json_add_int "result" 0
json_add_string "job_id" "$job_id"
json_dump; json_cleanup
}
sync_font_css_from_uci() {
local sans_preset mono_preset
sans_preset=$(get_font_preset_from_uci "sans")
mono_preset=$(get_font_preset_from_uci "mono")
prepare_font_slot "sans" "$sans_preset" || return 1
prepare_font_slot "mono" "$mono_preset" || return 1
write_combined_font_css "$sans_preset" "$mono_preset"
}
# sync_font_css_from_uci only rewrites the CSS from whatever woff2 files are
# already cached. A preset names a typeface this router may never have
# downloaded -- and every font stack ends in a fallback family, so a missing
# webfont does not fail loudly, it silently renders as Lato and the preset
# looks like it half-applied. Fetch the files too.
# The CSS is written synchronously (so the page that reloads right after this
# already points at whatever is cached) and the download runs detached: it
# talks to the network, and the rpc call must not wait on it. Both slots are
# no-ops when the preset resolves to a built-in stack, so a preset that names
# no webfont costs nothing.
sync_and_cache_fonts_from_uci() {
local sans_preset mono_preset
sans_preset=$(get_font_preset_from_uci "sans")
mono_preset=$(get_font_preset_from_uci "mono")
prepare_font_slot "sans" "$sans_preset" || return 1
prepare_font_slot "mono" "$mono_preset" || return 1
write_combined_font_css "$sans_preset" "$mono_preset" || return 1
(
cache_font_slot "sans" "$sans_preset"
cache_font_slot "mono" "$mono_preset"
write_combined_font_css "$sans_preset" "$mono_preset"
update_icon_cache_timestamp
) </dev/null >/dev/null 2>&1 &
return 0
}
# One-shot repair after a package upgrade (invoked from uci-defaults, not
# exposed over ubus). The font cache under /www is runtime state the package
# never touches, so a v1 install would otherwise keep serving its old
# aurora-font.css — possibly carrying the remote @import fallback that v2
# removed — until the user re-saves. Idempotent: safe to run on every boot.
migrate_font_cache() {
local slot stack base preset f meta_file cfamily cstack matched changed=""
mkdir -p "$FONT_PATH"
# v1 pipeline leftovers: per-slot css and remote @import fallback files.
rm -f "$FONT_PATH"/aurora-font-sans.remote.* \
"$FONT_PATH"/aurora-font-mono.remote.* \
"$FONT_PATH/aurora-font-sans.css" \
"$FONT_PATH/aurora-font-mono.css" \
"$FONT_PATH"/aurora-font-*.tmp 2>/dev/null
for slot in sans mono; do
# Cached woff2 sets whose preset no longer exists in the roster.
for f in "$FONT_PATH/aurora-font-${slot}-"*.woff2; do
[ -f "$f" ] || continue
base="${f##*/aurora-font-${slot}-}"
preset="${base%.woff2}"
preset="${preset%-*}"
get_font_meta "$slot" "$preset" >/dev/null 2>&1 || rm -f "$f"
done
# Face/preset markers pointing at a removed preset.
preset=$(cat "$FONT_PATH/aurora-font-${slot}.preset" 2>/dev/null)
if [ -n "$preset" ] && ! get_font_meta "$slot" "$preset" >/dev/null 2>&1; then
rm -f "$FONT_PATH/aurora-font-${slot}.preset" \
"$FONT_PATH/aurora-font-${slot}.face"
fi
# A stored stack that resolves to neither a v2 preset nor an
# uploaded custom font would silently degrade to Lato while the
# settings UI keeps showing a font that no longer exists; reset
# it so UCI, UI and rendering agree on the default.
stack=$(uci -q get "aurora.theme.struct_font_${slot}")
[ -n "$stack" ] || continue
get_font_preset_by_stack "$slot" "$stack" >/dev/null 2>&1 && continue
matched=""
for meta_file in "$FONT_CUSTOM_PATH/${slot}-"*.meta; do
[ -f "$meta_file" ] || continue
IFS='|' read -r cfamily cstack < "$meta_file"
[ "$cstack" = "$stack" ] && { matched=1; break; }
done
[ -n "$matched" ] && continue
uci -q delete "aurora.theme.struct_font_${slot}"
changed=1
logger -t aurora "font migration: struct_font_${slot} pointed at a removed preset, reset to default"
done
[ -n "$changed" ] && uci -q commit aurora
sync_font_css_from_uci
}
json_add_font() {
local name="$1" label="$2" source="$3" family="$4" stack="$5"
json_add_object ""
json_add_string "name" "$name"
json_add_string "label" "$label"
json_add_string "source" "$source"
json_add_string "family" "$family"
json_add_string "stack" "$stack"
json_close_object
}
get_font_meta() {
local slot="$1" name="$2" entry
while IFS= read -r entry; do
[ "$(echo "$entry" | cut -d'|' -f2)" = "$name" ] || continue
echo "$entry" | cut -d'|' -f3-
return 0
done <<EOF
$(list_font_entries "$slot")
EOF
return 1
}
list_font_names() {
list_font_entries "$1" | cut -d'|' -f2
}
parse_installed_packages() {
local pkg_manager="$1" status_file installed
theme_inst=""
config_inst=""
config_i18n=""
case "$pkg_manager" in
opkg)
status_file="/usr/lib/opkg/status"
if [ -r "$status_file" ]; then
# Single pass over the status file: classify each package block, then
# capture its first Version line. Emits theme/config/i18n on 3 lines.
installed=$(awk '
/^Package: luci-theme-aurora$/ { cap = "t"; next }
/^Package: luci-app-aurora-config$/ { cap = "c"; next }
/^Package: luci-i18n-aurora-config-/ { cap = "i"; pkg = $2; next }
/^Version:/ {
if (cap == "t") theme = $2
else if (cap == "c") config = $2
else if (cap == "i") i18n = i18n (i18n ? "," : "") pkg ":" $2
cap = ""
}
END { print theme; print config; print i18n }
' "$status_file")
{ read -r theme_inst; read -r config_inst; read -r config_i18n; } <<-EOF
$installed
EOF
elif command -v opkg >/dev/null 2>&1; then
installed=$(opkg list-installed 2>/dev/null)
theme_inst=$(printf '%s\n' "$installed" | grep "^luci-theme-aurora " | awk '{print $3}')
config_inst=$(printf '%s\n' "$installed" | grep "^luci-app-aurora-config " | awk '{print $3}')
config_i18n=$(printf '%s\n' "$installed" | grep "^luci-i18n-aurora-config-" | awk '{print $1 ":" $3}' | tr '\n' ',' | sed 's/,$//')
fi
;;
apk)
# Read the database directly instead of running `apk list -I`, which
# spawns apk to parse every installed package on each page load -- the
# most expensive thing this call used to do on a weak router. The
# format (verified on a snapshot device) is one blank-line-separated
# record per package, with "P:<name>" followed by "V:<version>".
installed=$(awk '
/^P:/ { pkg = substr($0, 3); next }
/^V:/ {
if (pkg == "luci-theme-aurora") theme = substr($0, 3)
else if (pkg == "luci-app-aurora-config") config = substr($0, 3)
else if (pkg ~ /^luci-i18n-aurora-config-/)
i18n = i18n (i18n ? "," : "") pkg ":" substr($0, 3)
pkg = ""
}
END { print theme; print config; print i18n }
' /lib/apk/db/installed 2>/dev/null)
{ read -r theme_inst; read -r config_inst; read -r config_i18n; } <<-EOF
$installed
EOF
;;
esac
}
# Remove our own feed lines before appending a fresh one, so re-running is a
# no-op and switching channels leaves no stale entry. Matched by fixed string,
# so other feeds' lines are never touched.
aurora_drop_feed_lines() {
local file="$1" pattern="$2"
[ -f "$file" ] || return 0
grep -vF "$pattern" "$file" > "$file.tmp" 2>/dev/null || true
mv "$file.tmp" "$file"
}
aurora_feed_error() {
json_init
json_add_int "result" 1
json_add_string "error" "$1"
json_add_boolean "index_refreshed" "0"
json_dump; json_cleanup
}
# Steps 2 and 3 of install.sh: import the signing key, add the feed, refresh
# the index. The key ships inside this package rather than being downloaded --
# the trust is already anchored in the installed package, and the router needs
# no outbound access to get it.
aurora_add_feed() {
local pm refreshed="0"
detect_package_manager; pm="$_pkg_manager"
case "$pm" in
apk)
mkdir -p /etc/apk/keys /etc/apk/repositories.d 2>/dev/null || {
aurora_feed_error "cannot create the apk directories"; return
}
cp "$AURORA_FEED_KEYS/eamonxg.pem" /etc/apk/keys/eamonxg.pem || {
aurora_feed_error "cannot install the signing key"; return
}
aurora_drop_feed_lines /etc/apk/repositories.d/customfeeds.list \
"https://$AURORA_FEED_HOST/"
echo "https://$AURORA_FEED_HOST/$AURORA_FEED_CHANNEL/apk/packages.adb" \
>> /etc/apk/repositories.d/customfeeds.list
apk update >/dev/null 2>&1 && refreshed="1"
;;
opkg)
mkdir -p /etc/opkg/keys 2>/dev/null || {
aurora_feed_error "cannot create /etc/opkg/keys"; return
}
cp "$AURORA_FEED_KEYS/eamonxg.pub" "/etc/opkg/keys/$AURORA_FEED_FPR" || {
aurora_feed_error "cannot install the signing key"; return
}
aurora_drop_feed_lines /etc/opkg/customfeeds.conf "src/gz eamonxg "
echo "src/gz eamonxg https://$AURORA_FEED_HOST/$AURORA_FEED_CHANNEL/opkg" \
>> /etc/opkg/customfeeds.conf
opkg update >/dev/null 2>&1 && refreshed="1"
;;
*)
aurora_feed_error "no supported package manager on this device"
return
;;
esac
# Writing the source and refreshing the index fail independently, and the
# page tells the user different things about each.
json_init
json_add_int "result" 0
json_add_string "error" ""
json_add_boolean "index_refreshed" "$refreshed"
json_dump; json_cleanup
}
json_add_feed_status() {
local pm configured="0" channel="" line=""
detect_package_manager; pm="$_pkg_manager"
case "$pm" in
apk)
line=$(grep -F "https://$AURORA_FEED_HOST/" \
/etc/apk/repositories.d/customfeeds.list 2>/dev/null | head -1)
;;
opkg)
line=$(grep -F "src/gz eamonxg " \
/etc/opkg/customfeeds.conf 2>/dev/null | head -1)
;;
esac
if [ -n "$line" ]; then
configured="1"
case "$line" in
*/releases/*) channel="releases" ;;
*/snapshots/*) channel="snapshots" ;;
esac
fi
json_add_object "feed"
json_add_string "pm" "$pm"
json_add_boolean "configured" "$configured"
json_add_string "channel" "$channel"
json_close_object
}
json_add_installed_versions() {
local pkg_manager
detect_package_manager; pkg_manager="$_pkg_manager"
parse_installed_packages "$pkg_manager"
json_add_object "theme"; json_add_string "installed_version" "$theme_inst"; json_close_object
json_add_object "config"
json_add_string "installed_version" "$config_inst"
json_add_string "i18n_packages" "$config_i18n"
json_close_object
}
json_add_font_presets() {
local slot_name presets
local meta_file base cslot cname cfamily cstack
presets=$(cat "$FONT_PRESETS_FILE" 2>/dev/null)
json_add_object "fonts"
for slot_name in sans mono; do
json_add_array "$slot_name"
while IFS='|' read -r _kind _slot _name _label _source _family _stack; do
[ "$_kind" = "font" ] || continue
[ "$_slot" = "$slot_name" ] || continue
json_add_font "$_name" "$_label" "$_source" "$_family" "$_stack"
done <<-EOF
$presets
EOF
json_close_array
done
json_add_array "custom"
for meta_file in "$FONT_CUSTOM_PATH"/*.meta; do
[ -f "$meta_file" ] || continue
base="${meta_file##*/}"; base="${base%.meta}"
cslot="${base%%-*}"; cname="${base#*-}"
IFS='|' read -r cfamily cstack < "$meta_file"
json_add_object ""
json_add_string "slot" "$cslot"
json_add_string "name" "$cname"
json_add_string "family" "$cfamily"
json_add_string "stack" "$cstack"
csize=0
[ -f "$FONT_CUSTOM_PATH/${base}.woff2" ] && csize=$(wc -c < "$FONT_CUSTOM_PATH/${base}.woff2")
json_add_int "size" "$csize"
json_close_object
done
json_close_array
json_close_object
}
json_add_icons() {
local file size path sizes
json_add_array "icons"
for file in "$ICON_PATH"/*; do
[ -f "$file" ] && json_add_string "" "${file##*/}"
done
json_close_array
# One wc for the whole directory instead of one per file: on the routers
# this runs on, the fork costs more than the read it performs. Output is
# "<size> <path>" per file, plus a "total" line when there is more than
# one -- the -f guard drops that line without matching its wording.
# Fed through a heredoc rather than a pipe: a pipe runs the loop in a
# subshell, and every json_add_int would be written to a shell that exits.
sizes=$(wc -c "$ICON_PATH"/* 2>/dev/null)
json_add_object "icon_sizes"
while read -r size path; do
[ -f "$path" ] && json_add_int "${path##*/}" "$size"
done <<-EOF
$sizes
EOF
json_close_object
}
# 把一份存下来的 /etc/config/aurora 投影成与 presets.json 行相同的 preview 形状。
# 前端于是用 paletteOf/navOf/tileEntriesFor 读它 —— 和读内置预设、读 hub 列表行
# 用的是同一套访问器。三种卡,一套访问器,同一个外观不可能被画成两个样子。
json_add_config_preview() { # $1=一份存下来的 aurora 配置文件
local conf="$1" dir line key value v
[ -f "$conf" ] || return 1
dir=$(mktemp -d /tmp/aurora_preview.XXXXXX) || return 1
cp "$conf" "$dir/aurora" 2>/dev/null || { rm -rf "$dir"; return 1; }
json_add_object "colors"
# 色值是十六进制字面量,不含空白,所以在这里用不带引号的词分割是安全的 ——
# 而这样循环体才留在**本** shell 里。写成 `... | while read` 的话循环体会在
# 子 shell 中执行,每一次 json_add_string 都会随它退出而丢光。
# 两条 -e 而不是一条 \(light\|dark\):BRE 里的交替是 GNU 扩展,busybox sed
# 认,可这个函数是切片出来在开发机上跑测试的,BSD sed 不认。
for line in $(uci -c "$dir" -q show aurora.theme 2>/dev/null \
| sed -n -e "s/^aurora\.theme\.\(light_[^=]*\)='\(.*\)'\$/\1=\2/p" \
-e "s/^aurora\.theme\.\(dark_[^=]*\)='\(.*\)'\$/\1=\2/p"); do
key=${line%%=*}
value=${line#*=}
json_add_string "$key" "$value"
done
json_close_object
json_add_object "layout"
for key in nav_type struct_spacing struct_radius_base \
struct_content_width_centered toolbar_enabled; do
v=$(uci -c "$dir" -q get "aurora.theme.$key")
json_add_string "$key" "$v"
done
json_close_object
# 字体栈里有空格,必须一个一个 get,不能走上面那种词分割。
json_add_object "typography"
v=$(uci -c "$dir" -q get aurora.theme.struct_font_sans)
json_add_string "struct_font_sans" "$v"
v=$(uci -c "$dir" -q get aurora.theme.struct_font_mono)
json_add_string "struct_font_mono" "$v"
json_close_object
rm -rf "$dir"
return 0
}
json_add_theme_preset_snapshot() {
local name="$1" template key value
require_color_token_keys || {
json_add_int "result" 1
json_add_string "error" "Missing or empty $COLOR_TOKENS_FILE"
return
}
name=$(normalize_preset_name "$name")
template=$(resolve_preset_path "$name") || {
json_add_int "result" 1
json_add_string "error" "Unknown preset"
return
}
if ! load_preset_snapshot "$template"; then
json_add_int "result" 1
json_add_string "error" "Invalid preset content"
return
fi
json_add_int "result" 0
json_add_string "name" "$name"
# The snapshot is one flat list, but it is published grouped the way a hub
# payload groups the same fields (see build_share_payload). An object
# called "colors" that also held nav_type would be a trap for the next
# reader -- and the settings UI looks colours up by key, so a stray
# struct_* in there would sit undetected until something iterated it.
json_add_object "colors"
while IFS='=' read -r key value; do
case "$key" in
light_*|dark_*) json_add_string "$key" "$value" ;;
esac
done <<-EOF
$PRESET_SNAPSHOT
EOF
json_close_object
json_add_object "layout"
while IFS='=' read -r key value; do
case "$key" in
nav_type|toolbar_enabled|struct_spacing|struct_radius_base|struct_content_width_centered)
json_add_string "$key" "$value" ;;
esac
done <<-EOF
$PRESET_SNAPSHOT
EOF
json_close_object
json_add_object "typography"
while IFS='=' read -r key value; do
case "$key" in
struct_font_sans|struct_font_mono) json_add_string "$key" "$value" ;;
esac
done <<-EOF
$PRESET_SNAPSHOT
EOF
json_close_object
}
case "$1" in
# Maintenance verb for uci-defaults; rpcd itself only ever passes list/call.
"migrate-fonts")
migrate_font_cache
exit 0
;;
"list")
json_init
json_add_object "list_icons"; json_close_object
json_add_object "upload_icon"
json_add_string "filename" "filename"
json_close_object
json_add_object "remove_icon"
json_add_string "filename" "filename"
json_close_object
json_add_object "reset_defaults"; json_close_object
json_add_object "add_feed"; json_close_object
json_add_object "get_init_data"; json_close_object
json_add_object "get_theme_preset"
json_add_string "name" "name"
json_close_object
json_add_object "get_font_presets"; json_close_object
json_add_object "get_font_status"
json_add_string "job_id" "job_id"
json_close_object
json_add_object "prepare_font"
json_add_string "sans" "sans"
json_add_string "mono" "mono"
json_add_string "sans_stack" "sans_stack"
json_close_object
json_add_object "upload_font"
json_add_string "slot" "slot"
json_add_string "family" "family"
json_close_object
json_add_object "remove_font"
json_add_string "slot" "slot"
json_add_string "name" "name"
json_close_object
json_add_object "apply_theme_preset"
json_add_string "name" "name"
json_close_object
json_add_object "export_config"; json_close_object
json_add_object "import_config"; json_close_object
json_add_object "get_system_info"; json_close_object
json_add_object "remove_login_bg"; json_close_object
json_add_object "write_pwa_manifest"; json_close_object
json_add_object "hub_apply"
json_add_string "id" "id"
json_close_object
json_add_object "get_hub_status"
json_add_string "job_id" "job_id"
json_close_object
json_add_object "hub_restore_backup"; json_close_object
json_add_object "hub_local_state"; json_close_object
json_add_object "hub_share_begin"
json_add_string "name" "name"
json_add_string "description" "description"
json_add_string "target_id" "target_id"
json_close_object
json_add_object "hub_share_commit"
json_add_string "draft_id" "draft_id"
json_close_object
json_add_object "hub_set_nickname"
json_add_string "nickname" "nickname"
json_close_object
json_add_object "hub_me"; json_close_object
json_add_object "hub_export_key"; json_close_object
json_add_object "hub_import_key"
json_add_string "key" "key"
json_close_object
json_add_object "hub_delete"
json_add_string "id" "id"
json_close_object
json_dump; json_cleanup
;;
"call")
case "$2" in
"get_init_data")
active_preset=$(uci -q get aurora.theme.active_preset)
active_preset=$(normalize_preset_name "$active_preset")
json_init
json_add_string "active_preset" "$active_preset"
json_add_object "versions"
json_add_installed_versions
json_close_object
# Rides along here on purpose: the settings page must not gain a round
# trip just to learn whether this router has the feed configured.
json_add_feed_status
json_add_font_presets
json_add_icons
json_add_object "theme_preset"
json_add_theme_preset_snapshot "$active_preset"
json_close_object
json_dump; json_cleanup
;;
"add_feed")
aurora_add_feed
;;
"reset_defaults")
if [ -f "$DEFAULT_TEMPLATE" ]; then
cp "$DEFAULT_TEMPLATE" "$CONFIG_FILE"
chmod 644 "$CONFIG_FILE"
cleanup_custom_fonts
# Uploaded custom fonts survive a settings reset, matching the
# promise the confirm dialog makes for uploaded images; only
# the selection is cleared. remove_font deletes the files.
sync_font_css_from_uci
update_icon_cache_timestamp
echo '{ "result": 0 }'
else
echo '{ "result": 1, "error": "Template file not found" }'
fi
;;
"get_theme_preset")
read -r input; json_load "$input" 2>/dev/null
json_get_var name "name"
json_cleanup
json_init
json_add_theme_preset_snapshot "$name"
json_dump; json_cleanup
;;
"get_font_presets")
json_init
json_add_font_presets
json_dump; json_cleanup
;;
"get_font_status")
read -r input; json_load "$input" 2>/dev/null
json_get_var job_id "job_id"
json_cleanup
case "$job_id" in
""|*[!A-Za-z0-9_.-]*)
json_init
json_add_string "state" "missing"
json_add_string "error" "job_not_found"
json_dump; json_cleanup
exit 0
;;
esac
status_file="$FONT_JOB_PATH/$job_id.status"
if [ -n "$job_id" ] && [ -f "$status_file" ]; then
. "$status_file"
json_init
json_add_string "state" "${state:-unknown}"
json_add_string "sans_status" "${sans_status:-unknown}"
json_add_string "mono_status" "${mono_status:-unknown}"
json_add_string "sans_error" "${sans_error:-}"
json_add_string "mono_error" "${mono_error:-}"
json_dump; json_cleanup
else
json_init
json_add_string "state" "missing"
json_add_string "error" "job_not_found"
json_dump; json_cleanup
fi
;;
"prepare_font")
read -r input; json_load "$input" 2>/dev/null
json_get_var sans_preset "sans"
json_get_var mono_preset "mono"
json_get_var sans_stack "sans_stack"
json_cleanup
start_font_job "$sans_preset" "$mono_preset" "$sans_stack"
;;
"upload_font")
read -r input; json_load "$input" 2>/dev/null
json_get_var slot "slot"
json_get_var family "family"
json_cleanup
case "$slot" in sans|mono) ;; *)
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "Invalid slot" }'; exit 0 ;;
esac
case "$family" in
""|*[!A-Za-z0-9\ \'-]*)
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "Invalid family name" }'; exit 0 ;;
esac
[ ${#family} -le 64 ] || {
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "Family name too long" }'; exit 0; }
receive_upload "$FONT_TMP_UPLOAD" font || exit 0
slug=$(printf '%s' "$family" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]\{1,\}/-/g;s/^-//;s/-$//')
[ -n "$slug" ] || {
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "Invalid family name" }'; exit 0; }
meta_path="$FONT_CUSTOM_PATH/${slot}-${slug}.meta"
if [ -f "$meta_path" ]; then
existing_family=$(cut -d'|' -f1 "$meta_path")
[ "$existing_family" = "$family" ] || {
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "A custom font with a conflicting name already exists" }'; exit 0; }
fi
# Compose the stack from the roster's default entry so the tail
# has a single source of truth (the generated font-presets.conf).
# write_font_preload/remove_font match stacks by string equality,
# so a drifting duplicate here would silently break them.
default_stack=$(get_font_meta "$slot" "default" | cut -d'|' -f4)
[ -n "$default_stack" ] || {
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "Font preset roster unavailable" }'; exit 0; }
stack="\"$family\", $default_stack"
mkdir -p "$FONT_CUSTOM_PATH"
if ! mv "$FONT_TMP_UPLOAD" "$FONT_CUSTOM_PATH/${slot}-${slug}.woff2" 2>"/dev/null"; then
rm -f "$FONT_TMP_UPLOAD"
echo '{ "result": 1, "error": "Failed to store file" }'; exit 0
fi
chmod 644 "$FONT_CUSTOM_PATH/${slot}-${slug}.woff2"
printf '%s|%s\n' "$family" "$stack" > "$FONT_CUSTOM_PATH/${slot}-${slug}.meta"
chmod 644 "$FONT_CUSTOM_PATH/${slot}-${slug}.meta"
cat > "$FONT_CUSTOM_PATH/${slot}-${slug}.face" <<FACE
@font-face {
font-display: swap;
font-family: "$family";
font-style: normal;
font-weight: 400;
src: url("/luci-static/aurora/fonts/custom/${slot}-${slug}.woff2?v=$(date +%s)") format("woff2");
}
FACE
chmod 644 "$FONT_CUSTOM_PATH/${slot}-${slug}.face"
sync_font_css_from_uci
update_icon_cache_timestamp
json_init
json_add_int "result" 0
json_add_string "name" "$slug"
json_add_string "stack" "$stack"
json_dump; json_cleanup
;;
"remove_font")
read -r input; json_load "$input" 2>/dev/null
json_get_var slot "slot"
json_get_var name "name"
json_cleanup
case "$slot" in sans|mono) ;; *)
echo '{ "result": 1, "error": "Invalid slot" }'; exit 0 ;;
esac
case "$name" in
""|*[!a-z0-9-]*)
echo '{ "result": 1, "error": "Invalid font name" }'; exit 0 ;;
esac
meta_file="$FONT_CUSTOM_PATH/${slot}-${name}.meta"
if [ -f "$meta_file" ]; then
IFS='|' read -r _cfamily cstack < "$meta_file"
current=$(uci -q get "aurora.theme.struct_font_$slot")
if [ -n "$current" ] && [ "$current" = "$cstack" ]; then
uci -q delete "aurora.theme.struct_font_$slot"
uci -q commit aurora
fi
fi
rm -f "$FONT_CUSTOM_PATH/${slot}-${name}.woff2" \
"$FONT_CUSTOM_PATH/${slot}-${name}.meta" \
"$FONT_CUSTOM_PATH/${slot}-${name}.face"
sync_font_css_from_uci
update_icon_cache_timestamp
echo '{ "result": 0 }'
;;
"apply_theme_preset")
read -r input; json_load "$input"; json_get_var name "name"; json_cleanup
name=$(normalize_preset_name "$name")
require_color_token_keys || {
echo '{ "result": 1, "error": "Missing or empty /usr/share/aurora/color-tokens.conf" }'
exit 0
}
template=$(resolve_preset_path "$name") || {
echo '{ "result": 1, "error": "Unknown preset" }'
exit 0
}
if ! load_preset_snapshot "$template"; then
echo '{ "result": 1, "error": "Invalid preset content" }'
exit 0
fi
if ! load_preset_toolbar "$template"; then
echo '{ "result": 1, "error": "Invalid preset content" }'
exit 0
fi
# 内置预设从前完全不做备份。现在它和 hub 走同一条闸门:你自己调出来
# 的配置,不管被哪一类主题盖掉,都留得下来 —— 它是「我的配置」那张卡
# 的内容,回滚就是把它点回来。
ensure_active_source
backup_if_mine
# Stage the whole preset in one `uci batch`: drop the existing color
# options, write the snapshot, then update the preset bookkeeping --
# replacing what used to be ~180 individual uci invocations.
# The snapshot carries the preset's layout and typography too, so the
# same loop writes nav_type, spacing, radius, content width and both
# font stacks. Those are `set` over whatever is there, never deleted
# first: unlike the colour keys they are a fixed, always-present set,
# and every one of them is in the snapshot.
# Values MUST be single-quoted: `uci batch` parses each line as a
# shell-like command and treats a bare `#` as a comment, so an
# unquoted hex color (e.g. set ...=#fcfcfc) is silently truncated to
# an empty value. Snapshot values are already validated injection-safe
# `uci batch`'s stdout MUST go to /dev/null: every `add` line makes it
# print the new anonymous section name (cfg0686e6...), and this handler's
# stdout *is* the ubus reply body. Those names ahead of `{ "result": 0 }`
# make rpcd fail to parse the JSON and return INVALID_ARGUMENT, so the
# store says "apply failed" about a preset it already committed.
{
uci -q show aurora.theme \
| sed -n 's/^aurora\.theme\.\(\(light\|dark\)_[^=]*\)=.*/delete aurora.theme.\1/p'
toolbar_section_count=$(uci -q show aurora 2>/dev/null | grep -c '=toolbar_item$')
i=0
while [ "$i" -lt "$toolbar_section_count" ]; do
printf 'delete aurora.@toolbar_item[0]\n'
i=$((i + 1))
done
while IFS='=' read -r key value; do
[ -n "$key" ] && printf "set aurora.theme.%s='%s'\n" "$key" "$value"
done <<-EOF
$PRESET_SNAPSHOT
EOF
while IFS="$(printf '\t')" read -r t_title t_url t_enabled t_icon; do
[ -n "$t_title" ] || continue
printf 'add aurora toolbar_item\n'
printf "set aurora.@toolbar_item[-1].title='%s'\n" "$t_title"
printf "set aurora.@toolbar_item[-1].url='%s'\n" "$t_url"
[ -n "$t_icon" ] && printf "set aurora.@toolbar_item[-1].icon='%s'\n" "$t_icon"
printf "set aurora.@toolbar_item[-1].enabled='%s'\n" "$t_enabled"
done <<-EOF
$PRESET_TOOLBAR
EOF
printf "set aurora.theme.active_preset='%s'\n" "$name"
printf "set aurora.theme.icon_cache_version='%s'\n" "$(date +%s)"
} | uci batch >/dev/null
if ! uci -q commit aurora; then
uci -q revert aurora
echo '{ "result": 1, "error": "Failed to apply preset" }'
exit 0
fi
chmod 644 "$CONFIG_FILE" 2>/dev/null
clear_inherited_assets ""
sweep_hub_images
# The preset just chose a typeface. Rewrite the font CSS now and fetch
# whatever woff2 files it names in the background, or the page that
# reloads in a moment renders the fallback family instead.
sync_and_cache_fonts_from_uci
# $name 已经过 normalize_preset_name(见本分支开头),与商店里那张卡的
# id 一一对应。
record_active_source "builtin:$name"
echo '{ "result": 0 }'
;;
"export_config")
if [ -f "$CONFIG_FILE" ]; then
if cp "$CONFIG_FILE" "$EXPORT_CONFIG_TMP" 2>/dev/null; then
chmod 0644 "$EXPORT_CONFIG_TMP"
json_init
json_add_int "result" 0
json_add_string "filename" "aurora"
json_add_string "path" "$EXPORT_CONFIG_TMP"
json_dump; json_cleanup
else
echo '{ "result": 1, "error": "Failed to prepare export file" }'
fi
else
echo '{ "result": 1, "error": "Config file not found" }'
fi
;;
"import_config")
if ! require_color_token_keys; then
echo '{ "result": 1, "error": "Missing or empty /usr/share/aurora/color-tokens.conf" }'
elif [ ! -f "$IMPORT_CONFIG_TMP" ]; then
echo '{ "result": 1, "error": "Upload file not found" }'
else
if grep -Eq "^config[[:space:]]+aurora" "$IMPORT_CONFIG_TMP" 2>/dev/null \
&& validate_import_color_options; then
cp "$IMPORT_CONFIG_TMP" "$CONFIG_FILE"
chmod 644 "$CONFIG_FILE"
# 同 hub_apply_worker:导入的配置来自别的设备,它选的字体这台
# 机器未必下载过,只重写 CSS 会静默回退。
sync_and_cache_fonts_from_uci
update_icon_cache_timestamp
rm -f "$IMPORT_CONFIG_TMP"
echo '{ "result": 0 }'
else
rm -f "$IMPORT_CONFIG_TMP"
echo '{ "result": 1, "error": "Invalid config file" }'
fi
fi
;;
"list_icons")
json_init
json_add_icons
json_dump; json_cleanup
;;
"upload_icon")
read -r input; json_load "$input"; json_get_var filename "filename"; json_cleanup
receive_upload "$TMP_UPLOAD_PATH" image "$filename" || exit 0
mkdir -p "$ICON_PATH"
if mv "$TMP_UPLOAD_PATH" "$ICON_PATH/$filename" 2>"/dev/null"; then
chmod 0644 "$ICON_PATH/$filename"
update_icon_cache_timestamp
echo '{ "result": 0 }'
else
rm -f "$TMP_UPLOAD_PATH"
echo '{ "result": 1, "error": "Failed to store file" }'
fi
;;
"remove_icon")
read -r input; json_load "$input"; json_get_var filename "filename"; json_cleanup
if dirname "$filename" | grep -q "\.\."; then echo '{ "result": 255 }'; exit 255; fi
rm -f "$ICON_PATH/$filename"
# Every slot that named the deleted file has to be cleared, not just
# the login background: an icon can be assigned as the logo, a favicon,
# a PWA icon, the iOS home-screen icon and a toolbar shortcut at the
# same time. Leaving those set pointed uci at a file that no longer
# exists -- the theme then served a missing logo, iOS kept a 404
# home-screen icon, and build_share_payload's `[ -f "$file" ]` check
# dropped the asset while the share manifest still promised it. Same
# slots the asset loop in build_share_payload walks, plus
# pwa_apple_touch (settable in theme.js, defaulted in
# root/usr/share/aurora/default.template) and struct_login_bg.
icon_slot_cleared=0
for icon_slot in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512 pwa_apple_touch; do
icon_slot_file=$(uci -q get "aurora.theme.$icon_slot")
[ -n "$icon_slot_file" ] && [ "$icon_slot_file" = "$filename" ] || continue
uci -q delete "aurora.theme.$icon_slot"
icon_slot_cleared=1
done
# Toolbar shortcuts pick their image from the same uploaded icons, and
# a dangling name here travels further than the others:
# build_share_payload ships `icon` as a plain string with no existence
# check, so the broken shortcut image gets published to the hub and
# every downstream applier inherits it. toolbar_item sections are
# anonymous, so their uci ids are recovered from `uci show` exactly
# the way build_share_payload does it, never by index.
for toolbar_sid in $(uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'); do
icon_slot_file=$(uci -q get "aurora.$toolbar_sid.icon")
[ -n "$icon_slot_file" ] && [ "$icon_slot_file" = "$filename" ] || continue
uci -q delete "aurora.$toolbar_sid.icon"
icon_slot_cleared=1
done
current_login_bg=$(uci -q get aurora.theme.struct_login_bg)
current_file=$(printf '%s' "$current_login_bg" | sed "s|url('/luci-static/aurora/images/||;s|')||")
if [ -n "$current_file" ] && [ "$current_file" = "$filename" ]; then
uci -q delete aurora.theme.struct_login_bg
uci -q delete aurora.theme.struct_login_bg_lqip
icon_slot_cleared=1
fi
current_main_bg=$(uci -q get aurora.theme.struct_main_bg)
current_file=$(printf '%s' "$current_main_bg" | sed "s|url('/luci-static/aurora/images/||;s|')||")
if [ -n "$current_file" ] && [ "$current_file" = "$filename" ]; then
uci -q delete aurora.theme.struct_main_bg
uci -q delete aurora.theme.struct_main_bg_lqip
icon_slot_cleared=1
fi
# One commit for the whole removal, and only when a slot actually
# changed -- committing unconditionally would flush unrelated pending
# aurora changes made by another handler.
[ "$icon_slot_cleared" = 1 ] && uci -q commit aurora
update_icon_cache_timestamp
echo '{ "result": 0 }'
;;
"write_pwa_manifest")
icon_192=$(uci -q get aurora.theme.pwa_icon_192)
icon_512=$(uci -q get aurora.theme.pwa_icon_512)
[ -z "$icon_192" ] && icon_192="app-icon-192x192.png"
[ -z "$icon_512" ] && icon_512="app-icon-512x512.png"
manifest_file="$ICON_PATH/app.webmanifest"
tmp_file="${manifest_file}.tmp"
cat > "$tmp_file" <<MANIFEST
{
"name": "LuCI Aurora",
"short_name": "Aurora",
"start_url": "/cgi-bin/luci",
"scope": "/",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#f8fafc",
"icons": [
{
"src": "/luci-static/aurora/images/${icon_192}",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/luci-static/aurora/images/${icon_512}",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
MANIFEST
if mv "$tmp_file" "$manifest_file" 2>/dev/null; then
chmod 0644 "$manifest_file"
echo '{ "result": 0 }'
else
rm -f "$tmp_file"
echo '{ "result": 1, "error": "Failed to write manifest" }'
fi
;;
"get_system_info")
json_init
detect_package_manager
json_add_string "package_manager" "$_pkg_manager"
json_add_string "language" "$(get_system_lang)"
json_dump; json_cleanup
;;
"hub_apply")
read -r input; json_load "$input" 2>/dev/null
json_get_var id "id"; json_cleanup
case "$id" in ''|*[!A-Za-z0-9]*)
echo '{ "result": 1, "error": "invalid_id" }'; exit 0 ;;
esac
ensure_device_identity
job_id="$(date +%s)-$$"
write_hub_job_status "$job_id" "queued" "start" ""
(hub_apply_worker "$job_id" "$id") </dev/null >/dev/null 2>&1 &
json_init
json_add_int "result" 0
json_add_string "job_id" "$job_id"
json_dump; json_cleanup
;;
"get_hub_status")
read -r input; json_load "$input" 2>/dev/null
json_get_var job_id "job_id"
json_cleanup
case "$job_id" in
""|*[!A-Za-z0-9_.-]*)
json_init
json_add_string "state" "missing"
json_add_string "error" "job_not_found"
json_dump; json_cleanup
exit 0
;;
esac
status_file="$HUB_JOB_PATH/$job_id.status"
if [ -n "$job_id" ] && [ -f "$status_file" ]; then
. "$status_file"
json_init
json_add_string "state" "${state:-unknown}"
json_add_string "step" "${step:-}"
json_add_string "error" "${error:-}"
json_dump; json_cleanup
else
json_init
json_add_string "state" "missing"
json_add_string "error" "job_not_found"
json_dump; json_cleanup
fi
;;
"hub_local_state")
# 纯本地:不碰网络。商店页把它放进 load() 的 Promise.all,ubus + fork
# 整份脚本实测约 0.09s(见 docs/specs/2026-08-05-store-first-paint.md),
# 不会挡住首屏。
# 三个答案对应界面上的三件事:钩子该落在哪张卡(active_source)、当前外观
# 还等不等于那张卡(modified)、以及被盖掉的那份自己长什么样(backup)。
ensure_active_source
json_init
json_add_int "result" 0
json_add_string "active_source" "$(uci -q get aurora.theme.active_source)"
if theme_modified; then
json_add_boolean "modified" 1
else
json_add_boolean "modified" 0
fi
# 会随这份配置一起发出去的自定义字体。发布面板拿它显示体积和版权
# 声明 —— 问的是 build_share_payload 打包时问的同一个函数,所以屏幕
# 上说要发什么,线路上发的就是什么。名册预设不在其中:它们的字节不走
# 这条路。
json_add_array "shared_fonts"
for slot in sans mono; do
base=$(shared_custom_font_base "$slot") || continue
json_add_object ""
json_add_string "slot" "$slot"
json_add_string "family" "$(cut -d'|' -f1 "$FONT_CUSTOM_PATH/${base}.meta")"
json_add_int "size" "$(wc -c < "$FONT_CUSTOM_PATH/${base}.woff2")"
json_close_object
done
json_close_array
# 会随这份配置一起发出去的图片,带体积。发布面板此前只写一句
# "Included" —— 而体积正是「要不要把这份主题装到我的路由器上」的
# 决定性信息,尤其登录背景动辄一两 MB。
# 超限的那张也报上来(oversized=1),否则它就是从面板上凭空消失:
# 用户设了登录背景、发布面板里一个字都没有,而 hub 那边只回一句
# 「商店拒绝了这份配置」。
json_add_array "shared_images"
for kind in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512 login_bg main_bg; do
fname=$(local_image_name "$kind") || continue
size=$(wc -c < "$ICON_PATH/$fname")
json_add_object ""
json_add_string "kind" "$kind"
json_add_int "size" "$size"
if image_fits_share "$size"; then
json_add_boolean "oversized" 0
else
json_add_boolean "oversized" 1
fi
json_close_object
done
json_close_array
# 工具栏图标同理。这里刻意按 uci 顺序去重后逐个列出,而不是给一个总数:
# 一个装不下的图标(格式不对、或超过 MAX_SHARED_TOOLBAR_ICON)必须能在
# 面板上单独指出来。
json_add_array "shared_toolbar_icons"
tb_i=0
while [ "$tb_i" -lt "$MAX_SHARED_TOOLBAR_ICONS" ]; do
fname=$(nth_custom_toolbar_icon "$tb_i") || break
tb_i=$((tb_i + 1))
[ -f "$ICON_PATH/$fname" ] || continue
json_add_object ""
json_add_string "name" "$fname"
json_add_int "size" "$(wc -c < "$ICON_PATH/$fname")"
if toolbar_icon_uploadable "$fname"; then
json_add_boolean "shipped" 1
else
json_add_boolean "shipped" 0
fi
json_close_object
done
json_close_array
# 备份文件不存在时整个字段不发出,前端读到 undefined —— 与「没有我的
# 配置可回去」是同一件事。
if [ -f "$DEVICE_DIR/pre-hub-backup.conf" ]; then
json_add_object "backup"
json_add_config_preview "$DEVICE_DIR/pre-hub-backup.conf"
json_close_object
fi
json_dump; json_cleanup
;;
"hub_restore_backup")
if [ -f "$DEVICE_DIR/pre-hub-backup.conf" ]; then
cp "$DEVICE_DIR/pre-hub-backup.conf" "$CONFIG_FILE"
chmod 644 "$CONFIG_FILE"
restore_pre_hub_images
restore_pre_hub_fonts
# 回滚目标的字体缓存可能已经被它之后那次 apply 清掉了
# (prepare_font_slot 换预设时会 cleanup_custom_fonts),所以这里
# 同样要重新取,不能只按现存缓存重写 CSS。
sync_and_cache_fonts_from_uci
# restore_pre_hub_images 只认得**这一次**备份记下的东西。中间那些
# 没拍快照的 apply(拿商店主题盖商店主题)留下的图,只有这里按
# 「还有没有槽位指着」才收得干净。
sweep_hub_images
update_icon_cache_timestamp
echo '{ "result": 0 }'
else
echo '{ "result": 1, "error": "no_backup" }'
fi
;;
# 三段式发布的第一段。发出去的只有 payload + 元信息(实测约 2.3KB),远在
# uclient-fetch 稳得住的量级里 —— 64KB 到 384KB 实测都是 3/3 通过,而把
# 一张 1.2MB 的登录背景 base64 塞进去的老做法是 1.6MB,必断。
# 回复里刻意不含 device_token:浏览器拿到的是 hub 签的一张票据,权限窄到
# 只剩"把这一份字节传到这一个位置"。device_token 是创作者身份本身,谁拿到
# 谁能以你的名义发布和删除你所有作品,它留在 0600 的 device.key 里。
"hub_share_begin")
read -r input; json_load "$input" 2>/dev/null
json_get_var name "name"
json_get_var description "description"
json_get_var target_id "target_id"
json_cleanup
has_control_char "$name" && {
echo '{ "result": 1, "error": "invalid_name" }'; exit 0; }
name_len=${#name}
[ "$name_len" -ge 1 ] && [ "$name_len" -le 60 ] || {
echo '{ "result": 1, "error": "invalid_name" }'; exit 0; }
has_control_char "$description" && {
echo '{ "result": 1, "error": "invalid_description" }'; exit 0; }
[ "${#description}" -le 500 ] || {
echo '{ "result": 1, "error": "invalid_description" }'; exit 0; }
# 空串 = 新发布;非空必须是纯字母数字。不能写成 ''|*[A-Za-z0-9]*)
# —— 那个通配只要求"含有"一个字母数字,"ab/../x" 也会过。
case "$target_id" in
'') ;;
*[!A-Za-z0-9]*) echo '{ "result": 1, "error": "invalid_id" }'; exit 0 ;;
esac
require_color_token_keys || {
echo '{ "result": 1, "error": "config_unavailable" }'; exit 0; }
ensure_device_identity
draft_payload_tmp=$(mktemp)
if ! build_share_payload "$draft_payload_tmp"; then
rm -f "$draft_payload_tmp" "$draft_payload_tmp.files"
echo '{ "result": 1, "error": "payload_build_failed" }'
exit 0
fi
draft_payload_json=$(cat "$draft_payload_tmp")
name_esc=$(json_escape_string "$name")
description_esc=$(json_escape_string "$description")
draft_body_tmp=$(mktemp)
if [ -n "$target_id" ]; then
printf '{"device_token":"%s","name":"%s","description":"%s","target_id":"%s","payload":%s}' \
"$DEVICE_TOKEN" "$name_esc" "$description_esc" "$target_id" \
"$draft_payload_json" > "$draft_body_tmp"
else
printf '{"device_token":"%s","name":"%s","description":"%s","payload":%s}' \
"$DEVICE_TOKEN" "$name_esc" "$description_esc" \
"$draft_payload_json" > "$draft_body_tmp"
fi
if draft_body=$(hub_http_post "/api/v1/themes/aurora/configs/draft" "$draft_body_tmp"); then
rm -f "$draft_body_tmp"
# jshn 的 json_init 和 json_load 共用同一份全局状态:一边解析响应
# 一边拼回复会把两者互相冲掉(拼到一半的输出被 json_load 清空)。
# 所以先把响应摊成一个制表符分隔的临时文件,cleanup 之后再拼输出。
tickets_tmp=$(mktemp)
draft_id=""; dup_id=""
json_load "$draft_body" 2>/dev/null
json_get_var draft_id "draft_id"
json_get_var dup_id "id"
if json_select "assets" >/dev/null 2>&1; then
idx=1
while json_select "$idx" >/dev/null 2>&1; do
json_get_var a_kind "kind"
json_get_var a_url "url"
json_get_var a_ticket "ticket"
printf '%s\t%s\t%s\n' "$a_kind" "$a_url" "$a_ticket" >> "$tickets_tmp"
json_select ..
idx=$((idx + 1))
done
json_select ..
fi
json_cleanup
# hub 判重:内容一模一样的东西已经在商店里了,一个字节都不用传。
if [ -z "$draft_id" ] && [ -n "$dup_id" ]; then
rm -f "$draft_payload_tmp" "$draft_payload_tmp.files" "$tickets_tmp"
printf '{ "result": 0, "duplicate": 1, "id": "%s" }\n' "$dup_id"
exit 0
fi
[ -n "$draft_id" ] || {
rm -f "$draft_payload_tmp" "$draft_payload_tmp.files" "$tickets_tmp"
echo '{ "result": 1, "error": "hub_bad_response" }'; exit 0; }
# 把 hub 发回的票据与本机文件名按 kind 对起来,交给浏览器。
TAB=$(printf '\t')
json_init
json_add_int "result" 0
json_add_string "draft_id" "$draft_id"
json_add_array "assets"
while IFS="$TAB" read -r a_kind a_url a_ticket; do
[ -n "$a_kind" ] || continue
a_fname=$(awk -F'\t' -v k="$a_kind" '$1==k{print $2; exit}' \
"$draft_payload_tmp.files")
[ -n "$a_fname" ] || continue
json_add_object ""
json_add_string "kind" "$a_kind"
json_add_string "src" "/luci-static/aurora/$a_fname"
json_add_string "url" "$a_url"
json_add_string "ticket" "$a_ticket"
json_close_object
done < "$tickets_tmp"
json_close_array
json_dump; json_cleanup
rm -f "$draft_payload_tmp" "$draft_payload_tmp.files" "$tickets_tmp"
else
rc=$?
rm -f "$draft_body_tmp" "$draft_payload_tmp" "$draft_payload_tmp.files"
case "$rc" in
3) case "$HUB_HTTP_STATUS" in
429) echo '{ "result": 1, "error": "quota_exceeded" }' ;;
403) echo '{ "result": 1, "error": "not_owner" }' ;;
# 清单里就有一张图超了限,一个字节都还没传。说清楚是图的
# 问题,别让它和「商店拒了这份配置」混成一句话。
413) echo '{ "result": 1, "error": "asset_too_large" }' ;;
*) echo '{ "result": 1, "error": "hub_rejected" }' ;;
esac ;;
2) echo '{ "result": 1, "error": "invalid_id" }' ;;
*) echo '{ "result": 1, "error": "hub_unreachable" }' ;;
esac
fi
;;
# 三段式的最后一段。字节此刻已经在 hub 的 draft/ 前缀下,这里只是拿
# device_token 认领它 —— 浏览器全程没有能力让一份配置真正上架。
"hub_share_commit")
read -r input; json_load "$input" 2>/dev/null
json_get_var draft_id "draft_id"
json_cleanup
case "$draft_id" in ''|*[!A-Za-z0-9]*)
echo '{ "result": 1, "error": "invalid_id" }'; exit 0 ;;
esac
ensure_device_identity
commit_body_tmp=$(mktemp)
printf '{"device_token":"%s"}' "$DEVICE_TOKEN" > "$commit_body_tmp"
if commit_body=$(hub_http_post "/api/v1/drafts/$draft_id/commit" "$commit_body_tmp"); then
rm -f "$commit_body_tmp"
json_load "$commit_body" 2>/dev/null
json_get_var new_id "id"
json_cleanup
if [ -n "$new_id" ]; then
json_init
json_add_int "result" 0
json_add_string "id" "$new_id"
json_dump; json_cleanup
else
echo '{ "result": 1, "error": "hub_bad_response" }'
fi
else
rc=$?
rm -f "$commit_body_tmp"
case "$rc" in
3) echo '{ "result": 1, "error": "hub_rejected" }' ;;
2) echo '{ "result": 1, "error": "invalid_id" }' ;;
*) echo '{ "result": 1, "error": "hub_unreachable" }' ;;
esac
fi
;;
"hub_me")
# The creator profile plus everything this key has published. The hub
# is the authority here -- no local id list is consulted, which is
# what makes a reflash survivable: restore device.key and the listing
# comes back on its own.
# This also replaces a per-id serial fetch (one hub round trip per
# published config, on every store page load) with a single request.
ensure_device_identity
me_body_tmp=$(mktemp)
printf '{"device_token":"%s"}' "$DEVICE_TOKEN" > "$me_body_tmp"
if me_body=$(hub_http_post "/api/v1/me" "$me_body_tmp"); then
printf '{ "result": 0, "data": %s }\n' "$me_body"
else
echo '{ "result": 1, "error": "hub_unreachable" }'
fi
rm -f "$me_body_tmp"
;;
"hub_set_nickname")
read -r input; json_load "$input" 2>/dev/null
json_get_var nickname "nickname"
json_cleanup
has_control_char "$nickname" && {
echo '{ "result": 1, "error": "invalid_nickname" }'; exit 0; }
nickname_len=${#nickname}
[ "$nickname_len" -ge 1 ] && [ "$nickname_len" -le 40 ] || {
echo '{ "result": 1, "error": "invalid_nickname" }'; exit 0; }
ensure_device_identity
nickname_esc=$(json_escape_string "$nickname")
nick_body_tmp=$(mktemp)
printf '{"device_token":"%s","nickname":"%s"}' "$DEVICE_TOKEN" "$nickname_esc" \
> "$nick_body_tmp"
# The hub answers 200 even for "that name is taken": wget collapses
# every 4xx into the same failure as an unreachable host, so a 409
# would reach the page as "couldn't reach the store". Pass the body
# straight through -- marketplace.js turns data.error into the sentence.
if nick_body=$(hub_http_post "/api/v1/me" "$nick_body_tmp"); then
printf '{ "result": 0, "data": %s }\n' "$nick_body"
else
echo '{ "result": 1, "error": "hub_unreachable" }'
fi
rm -f "$nick_body_tmp"
;;
"hub_export_key")
# device.key IS the account password: whoever holds it can publish as
# this creator and delete their work. That is why this sits behind the
# write ACL rather than read.
ensure_device_identity
# Handing the key out once is enough to stop nagging -- it has been
# rendered into a browser by now, downloaded or copied. The flag only
# collapses the reminder; it is not a claim that the user actually
# stored it safely, and nothing depends on it being true.
uci -q set aurora.theme.hub_key_saved='1'
uci -q commit aurora
json_init
json_add_int "result" 0
json_add_string "key" "$DEVICE_TOKEN"
json_dump; json_cleanup
;;
"hub_import_key")
read -r input; json_load "$input" 2>/dev/null
json_get_var key "key"
json_cleanup
# Same rule as the hub's TOKEN_PATTERN. Length first, then a
# character-class sweep, so nothing but 64 hex ever reaches the
# credential file.
[ "${#key}" -eq 64 ] || {
echo '{ "result": 1, "error": "invalid_key" }'; exit 0; }
case "$key" in *[!a-f0-9]*)
echo '{ "result": 1, "error": "invalid_key" }'; exit 0 ;;
esac
mkdir -p "$DEVICE_DIR"
# write-tmp-then-mv: a concurrent hub_share must never read a
# half-written key and authenticate as nobody.
printf '%s' "$key" > "$DEVICE_DIR/device.key.tmp"
chmod 600 "$DEVICE_DIR/device.key.tmp"
mv "$DEVICE_DIR/device.key.tmp" "$DEVICE_DIR/device.key"
# An imported key is by definition stored somewhere else already, so
# the backup reminder has nothing left to ask for.
uci -q set aurora.theme.hub_key_saved='1'
uci -q commit aurora
echo '{ "result": 0 }'
;;
"hub_delete")
read -r input; json_load "$input" 2>/dev/null
json_get_var id "id"
json_cleanup
case "$id" in ''|*[!A-Za-z0-9]*)
echo '{ "result": 1, "error": "invalid_id" }'; exit 0 ;;
esac
ensure_device_identity
delete_body_tmp=$(mktemp)
printf '{"device_token":"%s"}' "$DEVICE_TOKEN" > "$delete_body_tmp"
hub_http_delete "/api/v1/themes/aurora/configs/$id" "$delete_body_tmp" >/dev/null
delete_rc=$?
rm -f "$delete_body_tmp"
case "$delete_rc" in
# No local bookkeeping to undo -- hub_me re-reads the truth.
0) echo '{ "result": 0 }' ;;
# The hub has no such share anymore. Reporting that as
# hub_unreachable sent the user off checking their internet over a
# share that was simply already gone.
2) echo '{ "result": 1, "error": "invalid_id" }' ;;
*) echo '{ "result": 1, "error": "hub_unreachable" }' ;;
esac
;;
esac
;;
esac