Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl a704f12945 feat(qzone): delete_qzone_msg — delete a 说说 by tid via web API
deleteQzoneMsg POSTs a form-urlencoded tid to taotao.qzone.qq.com's
emotion_cgi_delete_v6 (proxied through h5.qzone), on the bot's own space.
Success has no positive payload beyond code 0, so the contract is
throw-on-non-zero-code (foreign/unknown tid or auth failure); the server
only deletes feeds the hostUin owns. Rejects empty tid pre-flight.

- core: QzoneApi.delete; onebot: delete_qzone_msg action; regen catalog.
- tests: tid form-body/url/headers lock, empty-tid preflight, non-zero
  code throw, and bridge self-space threading.
2026-06-20 01:47:50 +08:00
DrSmoothl 9e21c3a7ef fix(qzone): read real t1_tid/t1_time publish fields — was false-throwing every success
Adversarial review (verdict NEEDS-FIXES, CRITICAL): emotion_cgi_publish_v6's
success envelope names the new feed id `t1_tid` and post time `t1_time`
(a STRING), NOT `tid`/`now`. So every successful publish hit the
'缺少 tid' guard and was reported as a failure (risking caller-retry
duplicate 说说). The tests passed only because they mocked the wrong field
names — they codified the bug.

- Read t1_tid (primary) with tid as a defensive fallback; t1_time||now
  via Number() (t1_time is a string on the wire). Verified against the
  Go reference lib starim00/AnimeAPI EmotionPublishVo.
- Added who=1 to the form body (both working references send it).
- Tests now encode the real contract (t1_tid/t1_time string), plus a
  tid/now fallback case and a no-feed-id throw case.
2026-06-20 01:44:50 +08:00
DrSmoothl 9f2eb37232 feat(qzone): send_qzone_msg — publish a text 说说 via web API
First Qzone WRITE path. publishQzoneMsg POSTs a form-urlencoded body to
taotao.qzone.qq.com's emotion_cgi_publish_v6 (proxied through h5.qzone),
g_tk in the query, on the bot's own space. Returns the server-assigned
tid (the handle for later delete/comment/like) + post time.

- Throws on transport failure, non-zero Qzone code (content rejected /
  rate-limited), or a success body with no tid — never reports a publish
  as succeeded without the feed id. Rejects empty content pre-flight.
- 带图 (image) publishing is a later slice (needs the upload CGI first).
- Action carries a风控/限流 caveat: publishing is an active action and
  high frequency is rate-limited by Qzone, same as sending messages.

- core: QzoneApi.publish; onebot: send_qzone_msg action; regen catalog.
- tests: form-body/url/headers lock, empty-content preflight, non-zero
  code throw, missing-tid throw, and bridge self-space threading.
2026-06-20 01:40:06 +08:00
DrSmoothl b00f5ab759 test(qzone): align feeds JSONP fixture to requested _preloadCallback name
Cosmetic fidelity nit from re-review: the fixture used _Callback(...) while
the request pins callback=_preloadCallback. Parser is name-agnostic so
behavior is unchanged; this just makes the fixture mirror slice-1.
2026-06-20 01:37:55 +08:00
DrSmoothl 5164ba9e39 fix(qzone): route feeds3_html_more through proxy + pin JSONP format
Adversarial review of the get_qzone_feeds slice (verdict NEEDS-FIXES):
- Host: hit ic2.qzone.qq.com directly, bypassing the proxy gateway that
  slice-1/group-album use. The qzone.qq.com cookie jar only authenticates
  against the proxy origin, so the direct call would fail referer/origin.
  Now routed through h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/...
- Format: requested format=json but the test fed a JSONP body, so the
  requested format was never pinned. Aligned to slice-1's
  format=jsonp + callback=_preloadCallback; the test now asserts format.
- Pagination: documented honestly that page_num is reliable only for the
  first page (deep paging needs a time cursor, deferred) — in the
  docstring and the action summary/param desc.
- Clarified the key/feedskey fallback is the per-feed handle, not a cursor.
2026-06-20 01:36:04 +08:00
DrSmoothl 094dc12f0c feat(qzone): get_qzone_feeds — read friend feeds (好友动态) via web API
Second read-only Qzone slice. getQzoneFeeds over ic2.qzone.qq.com's
feeds3_html_more CGI, same cookie/g_tk plumbing + JSONP-tolerant parse +
throw-on-auth-failure contract as get_qzone_msg_list. Surfaces the stable
structured fields (uin/nickname/time/appid/key) and passes the
pre-rendered html blob through verbatim — deep HTML→segment parsing is
left to callers. Paginated via page_num (1-based) + count.

- protocol/web/qzone.ts: getQzoneFeeds + mapFeeds.
- core: QzoneApi.getFeeds (always the bot's own identity).
- onebot: get_qzone_feeds action; regenerated MCP catalog.
- tests: feeds mapping, HTTP url/params, empty-feed→[], auth-throw,
  and bridge pageNum/count threading.

Exact CGI field names / pagination cursor to be re-confirmed against a
live capture (same posture as the msglist helper).
2026-06-20 01:31:36 +08:00
DrSmoothl 0083a8fb07 test(qzone): cover empty-space happy path + bridge target_uin defaulting
Adversarial review of the get_qzone_msg_list slice flagged two real
coverage gaps (verdict was SHIP, no correctness defects):
- protocol: empty space ({code:0,msglist:[]}) must return [] not throw —
  the exact distinction the throw-on-auth-failure contract promises.
- core: QzoneApi.getMsgList target_uin defaulting + the >0 guard, the
  only place the string(identity.uin)/number(param) boundary is crossed.
2026-06-20 01:28:57 +08:00
DrSmoothl 61b4bd04a6 feat(qzone): get_qzone_msg_list — read personal Qzone 说说 list via web API
First slice of personal QQ-Zone (个人空间) support, an exclusive feature
vs NapCat/SL (both only do group albums). Read-only to prove out the
auth+parse chain before write ops (publish/like/comment).

- protocol/web/qzone.ts: getQzoneMsgList over taotao.qzone.qq.com's
  emotion_cgi_msglist_v6 (h5.qzone proxy), reusing the same cookie/g_tk
  plumbing as group-album. JSONP-tolerant parse; throws on auth failure
  rather than swallowing to [] (mirrors group-signin contract).
- core: QzoneApi bridge class wired into ApiHub.
- onebot: get_qzone_msg_list action (target_uin/pos/num), regenerated
  MCP catalog + new 空间 doc category.
- HTTP-layer tests lock URL/params, JSONP stripping, field mapping, and
  the throw-on-auth-failure paths (9 cases, all green).

Exact CGI params/response fields to be re-confirmed against a live
capture when extending (same maintenance posture as group-album).
2026-06-20 01:11:07 +08:00
Qiao d38a0a4c1b feat(protocol): modify/move custom face via OIDB 0x902e + 0x902f (#110)
modify(改备注)走 OIDB 0x902e_1 opType=3 单包;move(移动排序)走
0x902f_1 移动指令 + 0x902e_1 opType=2 上传新顺序两步流程。两者均带
envelope f12=1(uinForm),实测持久化(跨 QQ 重启保持)。

- proto-defs: CustomFaceModifyBody/Resp、CustomFaceOrderBody、CustomFaceMoveBody
- protocol: ModifyCustomFace、OrderCustomFace、MoveCustomFace 三个 namespace + wire byte-oracle 测试
- core: ProfileApi 加 modifyCustomFace / moveCustomFace(move 内部 fetch + 两步发包)
- onebot: modify_custom_face、move_custom_face action
2026-06-19 15:47:43 +08:00
DrSmoothl 258b7e1e72 docs: 添加免责声明和免责声明翻译 2026-06-19 02:36:43 +08:00
DrSmoothl d0498de966 fix(webui): make the consent agree-checkbox clearly visible
The 16px faint-bordered box + muted label read as nearly invisible. Make it a
full-width bordered row: a larger (20px) border-2 checkbox with a white unchecked
fill, a checked primary tint on the whole row, and the primary label in
foreground weight (English subtitle stays muted).
2026-06-19 02:32:10 +08:00
DrSmoothl 67c99e1e32 feat(webui): EULA/PRIVACY consent gate — frontend
ConsentPage shown after login, before the forced password change: tabbed
EULA/PRIVACY rendered by an in-house, dependency-free markdown renderer
(React elements only, XSS-safe; single-pass bounded-class inline parser to
avoid ReDoS on large docs). App gate order is login -> consent -> password ->
app; api client gains agreements.get / recordConsent with a 409 stale-version
re-prompt that refetches the new text.
2026-06-19 02:27:04 +08:00
DrSmoothl 8c7efc34a9 feat(webui): EULA/PRIVACY consent gate — backend
Server-side consent gate: GET /api/agreements (texts + content-hash version +
consentRequired) and POST /api/agreements/record-consent, persisted to
config/consent.json. Enforced in the auth middleware (CONSENT_ALLOWLIST,
ordered before the must-change-password gate) so the gate is real, not just a
frontend convention. The version is content-derived, so consent is stable
across app upgrades and re-prompts only when the agreement text changes. The
EULA/PRIVACY docs are bundled into dist/ (build fails if they are missing).
2026-06-19 02:26:48 +08:00
DrSmoothl f71ed2e4db chore(license): source-available non-commercial license
Switch from open-source to a NapCat-style source-available, NON-commercial
license so the free software cannot be resold / paid-deployed — which an OSI
license (AGPL included) cannot prevent. Source stays public for study and
non-commercial self-hosting; commercial use and public release of modifications
require written permission; the native addon (snowluma-*.node) is carved out as
proprietary. Explicitly NOT OSI open source.

- LICENSE: SnowLuma Source-Available Non-Commercial License v1.0 (bilingual)
- all 14 package.json: "license": "SEE LICENSE IN LICENSE"
- README license section; CONTRIBUTING contribution-licensing note + a grant
  letting the project relicense contributions under commercial terms
  (contributors keep copyright)
2026-06-19 02:26:33 +08:00
Qiao 552238fd04 feat(protocol): favorite emoji fetch/delete/add via Faceroam.OpReq + highway (#107)
* feat(protocol): fetch/delete favorite emoji via Faceroam.OpReq

First non-OIDB services in oidb-services/ — Faceroam.OpReq is a trpc
service, so these bypass invokeOidb and call sendRawPacket directly.

- proto-defs: reuse FaceroamOpReq/Inner/Resp, add field5 + FaceroamOpBody
- protocol: oidb-services/emoji/{shared,fetch-fav-list,delete-fav-emoji}
- core: EmojiApi facade + ApiHub registration
- onebot: fetch_fav_emoji_list + delete_fav_emoji actions

Request wire matches 9.9.26-44343 captures byte-for-byte; fetch response
decode + delete verified end-to-end against a live account.

* feat(protocol): add favorite emoji via BDHExpressionRoam + highway HTTP upload

Flow: BDHExpressionRoam (apply upload, get token) -> highway frame
(PicUp.DataUp commandId=9, serviceTicket = BDHExpressionRoam token) ->
HTTP POST httpconn?htcmd=0x6FF0087 -> fetch refresh.

- proto-defs: BDHExpressionRoamReq/Resp + FavEmojiHighwayHead types
- protocol: oidb-services/emoji/add-fav-emoji (fetchHighwaySession +
  BDHExpressionRoam + packHighwayFrame + HTTP POST)
- core: EmojiApi.addFavEmoji facade (reuses loadBinarySource)
- onebot: add_fav_emoji action
- mcp: regenerate catalog

End-to-end verified: new emoji_id appears in fetch list after upload.

* refactor(emoji): reuse fetchCustomFace for fetch_fav_emoji_list

Address review: Faceroam.OpReq had two call sites (profile.fetchCustomFace
+ the new FetchFavList service). Drop the duplicate service —
fetch_fav_emoji_list now reuses ProfileApi.fetchCustomFace and extracts
emoji_id from the returned image URL (qq_expression/<uin>/<id>/0).

delete/add unchanged (independent protocols).

* refactor: align custom-face naming with NapCat

Address review: 'fav_emoji' vs existing 'custom_face' named the same
thing two ways. Rename across the stack to match NapCat's custom_face:
- services: oidb-services/emoji/* -> oidb-services/custom-face/*
  (DeleteFavEmoji -> DeleteCustomFace, AddFavEmoji -> AddCustomFace)
- facade: EmojiApi dropped; delete/add moved to ProfileApi
  (deleteCustomFace / addCustomFace), next to fetchCustomFace
- actions: fetch_fav_emoji_list -> fetch_custom_face_ids,
  delete_fav_emoji -> delete_custom_face, add_fav_emoji -> add_custom_face
- package.json subpaths + catalog regenerated

* refactor(onebot): merge fetch_custom_face_ids into fetch_custom_face

Per review: merge fetch_custom_face_ids back into fetch_custom_face
with a return_type param ('url' default / 'id'). One action, one service,
no separate id-only variant.
2026-06-19 02:15:54 +08:00
DrSmoothl e3a04858b4 add EULA 2026-06-19 02:15:54 +08:00
DrSmoothl 3428d37764 feat(onebot): get_qun_album_list (napcat-parity over existing web API)
NapCat's get_qun_album_list calls the kernel AlbumService (trpc, not
statically recoverable). SnowLuma already exposes the same qun_list_album_v2
web API as get_group_album_list (raw array); this adds the NapCat-named
action reshaping it into NapCat's {album_list, attach_info, has_more}
envelope with {album_id, album_name, create_time, pic_num} items. Web
endpoint pulls up to 1000 albums in one shot → attach_info/has_more are
''/false (no cursor); cover_url not returned by this endpoint. Reuses the
existing groupAlbum.list bridge call — no new wire.
2026-06-18 23:02:29 +08:00
DrSmoothl 7ea1ffb497 feat(onebot): TierB ③ followup — doubt-buddy reject path (delDoubtBuddyReq)
set_doubt_friends_add_request with approve:false now rejects/declines via
delDoubtBuddyReq instead of erroring. RE'd from QQNT wrapper.linux.node
(DoubtBuddyDelWorker ctor sub_3F3F320 → OIDB 0xd69_0; EncodeRequest
sub_3F3E860 body {1: varint const 3 (op discriminator; get uses 1),
3:{1: string uid}}, uid from kernel attr 21001). uin-form (reserved=1).
Adversarially IDA-verified; byte-oracle locks the tags. Split out as its
own commit because the approve-only 0xd69_0 work was already pushed.
2026-06-18 22:18:08 +08:00
DrSmoothl 43cd4a0b99 feat(onebot): TierB ③ — RE'd OIDB actions (ark share / doubt friends / robot-add)
All recovered from QQNT wrapper.linux.node via IDA — NapCat ships these as
kernel-only with no portable packet wire, so each cmd + protobuf was traced
from the binary (worker ctor cmd/subcmd via BaseOidbWorker::DoSendOidbReq;
field tags from the per-feature EncodeRequest). All uin-form OIDB (envelope
reserved=1, matching the NT worker flag=1). One commit for easy rollback.

New OIDB services + byte-oracle tests (fixed-wire, mutation-resistant):
- 0x9130_0 getBuddyRecommendContactArkJson → share_peer/send_ark_share (user)
- 0x8b7_5  getGroupRecommendContactArkJson → share_peer/send_ark_share (group),
           share_group_ex/send_group_ark_share (NapCat's getArkJsonGroupShare
           was NOT RE'd; routed to this confident equivalent — documented)
- 0xd69_0  getDoubtBuddyReq → get_doubt_friends_add_request
- 0xd69_0  approvalDoubtBuddyReq → set_doubt_friends_add_request (approve only)
- 0xf00_3  modifyGroupExtInfoV2 → set_group_robot_add_option (EXTInfo tags
           30/31; GroupExtFilter is client-side gating, not on the wire)

NOT shipped (kernel-.so-side, no static cmd recoverable from this binary —
honest, no guessed packets): get_robot_uin_range (trpc method-routed),
create_collection/get_collection_list (Weiyun multi-step), trans_group_file.

Doubt-list string field NAMES (nick/source/msg) are medium-confidence
(generic serializer); uid+reqTime are high. READ path, so a mislabel is
cosmetic, never a wire/ban risk. Wired through bridge friend/contacts/
groupAdmin apis; proto-defs + per-file subpath exports registered.
2026-06-18 21:12:29 +08:00
DrSmoothl b9a015a4aa feat(onebot): TierB ② — get_recent_contact honest stub
QQ's recent-contact list is a kernel-local snapshot
(getRecentContactListSnapShot) with rich peer metadata
(peerName/remark/lastestMsg). SnowLuma has no SSO/packet wire for it, and
its own message store only covers observed sessions and lacks those
fields — so a faithful version isn't achievable app-layer. Rather than
ship a divergent approximation under a name implying QQ's native list,
register a documented stub returning [] (accepts count for compat),
matching how NapCat handles its own unobtainable get_online_clients.
The other ② stubs (online_clients/model_show/mark_all_as_read) were
finalized in P1; collection stays in the deferred deep-RE tier.
2026-06-18 19:32:43 +08:00
DrSmoothl c572cd498f docs(core): fix stale getSignedList comment (propagates, not fail-soft []) 2026-06-18 19:30:32 +08:00
DrSmoothl 9b787dfdc1 fix(onebot): TierB ① — propagate signed-list errors instead of fail-soft []
Adversarial review: swallowing transport/auth errors to [] made an expired
cookie indistinguishable from a group with zero sign-ins, and left the
onebot action's try/catch as dead code. Now getDaySignedListWebAPI
propagates transport errors and throws on a page-less response (matches
NapCat, which throws on missing page) — so auth failure becomes a proper
'failed' OneBot response; a genuinely empty group still maps to [].

Also: cover the previously-untested HTTP layer (exact URL + g_tk + json
body + error propagation), harden the rank de-skew with Number(), and
document the deliberate local-date (vs NapCat UTC) choice for dayYmd.
2026-06-18 19:28:52 +08:00
DrSmoothl 36d557e5ea feat(onebot): TierB ① — get_group_signed_list via qun.qq.com web API
Group sign-in (打卡) list is NapCat's one kernel-free item here: it's a
plain HTTP POST to qun.qq.com/v2/signin/trpc/GetDaySignedList with g_tk
from p_skey, not OIDB. SnowLuma already has the cookie/bkn infra (same as
group notice/essence/honor), so this is a straight port — no RE.

- protocol/web/group-signin.ts: getDaySignedListWebAPI + pure
  mapDaySignedList (de-skews signInRank via (r-1)/2+1, [] when no page).
- core WebApi.getSignedList(groupId) wires the qun.qq.com cookie jar.
- onebot get_group_signed_list groupAction (readOnly), [] on no sign-ins.
- Tests pin the rank/field transform and the action wiring.
2026-06-18 19:24:42 +08:00
DrSmoothl dfb6aa54c7 chore(release): v1.9.13 2026-06-18 17:16:15 +08:00
DrSmoothl a9f84a2aa0 fix(onebot): TierB P1 — honest compat-divergence comments + pin defaults
Adversarial review: code/test comments claimed napcat-surface parity while
get_online_clients ({clients:[]}) and _get_model_show (echoes model vs
NapCat's hardcoded 'napcat') deliberately diverge. Correct the comments to
state these are intentional OneBot-v11/spec choices, and strengthen the
default-model test to pin 'snowluma' literally + cover explicit empty
string. No behavior change.
2026-06-18 16:49:02 +08:00
DrSmoothl fac51cd94d feat(onebot): TierB P1 — _get_model_show napcat-shaped compat return
NapCat ships model_show/online_clients/mark_all_as_read as kernel-only
mock/no-op with no packet wire, so SnowLuma can't RE a wire that doesn't
exist. Align _get_model_show to NapCat's response shape
([{variants:{model_show,need_pay}}]) and echo the requested model
(default 'snowluma'). online_clients keeps the OneBot-standard
{clients:[]} envelope; _set_model_show / _mark_all_as_read stay no-ops.
Pin all four shapes with tests.
2026-06-18 16:45:46 +08:00
DrSmoothl aee543c7de fix(protocol): record inviter identity for uid-bearing group_invite
A group_invite carrying a fromUid is routed to the async dispatchGroupInvite
enrich path, which only resolved the requester profile when needsProfile
(uin missing/polluted) — so when the uin was already known it bypassed the
synchronous rememberRequestIdentity that friend_request uses, and the inviter's
uid↔uin was never stored. Record it up-front (mirrors the sync path), and
re-record after a profile resolve so a previously-unknown uin self-heals.
Fixes the long-standing bridge-member-identity test failure (core now fully
green: 387 passed).
2026-06-18 16:16:26 +08:00
渡辺鳩P 06c8954881 feat(notifications): default body template → Server酱 JSON (title/desp) (#108)
- Change DEFAULT_BODY_TEMPLATE from plaintext to Server酱-style JSON
- Add JSON-safe escaping in renderTemplate for backslashes and quotes
- Update frontend DEFAULT_TEMPLATE to match backend
- Add tests for JSON escaping behavior

Fixes #106 (targets dev branch)
2026-06-18 16:10:09 +08:00
DrSmoothl c637329884 fix(webui): a11y for debug page controls (match project standard)
Adversarial pass (MINOR): the new Segmented/IconBtn regressed a11y vs the
project's existing Segmented. Add role=radiogroup/radio + aria-checked to the
segmented control, type="button" to all buttons (segmented, icon, execute,
row toggle), aria-label to icon buttons, aria-expanded to the row toggle.
2026-06-18 15:48:21 +08:00
DrSmoothl f153781208 feat(webui): promote debug to a standalone page, Apple-style redesign
Move the debug console out of the Settings 调试 subtab into a dedicated
top-level /debug page (sidebar nav + route + AppPath), and redesign it in an
Apple-HIG flavour: translucent sticky header with title block + connection
pill, soft layered cards (rounded-2xl, hairline borders, blurred backdrop),
an iOS-style segmented control with a sliding active pill (params form/JSON,
stream all/event/action filter), refined timeline rows + empty state, calm
type hierarchy with tabular-nums. Logic (tester + live SSE) carried over
verbatim. Remove the old settings/debug-panel + the Settings 'debug' subtab.
2026-06-18 15:44:05 +08:00
DrSmoothl 3655f10a43 test(webui): extract + test debug SSE backpressure framing
Adversarial Wave A3 flagged the hand-rolled SSE drop/marker logic as the
untested weak point. Extract createFramePusher (drop under desiredSize<=0,
coalesced {kind:'dropped'} marker before the next delivered frame, null = no
backpressure) and unit-test it; the stream handler now uses it. Non-blocking
hardening — no behavior change.
2026-06-18 15:31:50 +08:00
DrSmoothl fae2f75525 feat(webui): debug panel — action tester + live stream (Wave A3)
New Settings → 调试 tab (DebugPanel): an action tester (account picker +
schema-driven form from /api/debug/actions, with a raw-JSON escape hatch for
legacy actions, real-side-effect warning) and a live merged SSE view of OneBot
events + action calls (pause / clear / kind filter, expandable JSON, drop
marker). Typed debug API client domain (actions/invoke/stream over EventSource
with ?token=). View layer — verified by tsc + vite build.
2026-06-18 15:25:34 +08:00
DrSmoothl 39864f6e48 feat(webui): debug API — action schemas, invoke, live SSE (Wave A3)
GET /api/debug/actions (collectActionDocs for the tester form), POST
/api/debug/invoke {uin,action,params} (routes to that account's ApiHandler —
real side effects, /api/* auth, uin validated), GET /api/debug/stream (merged
SSE of OneBot events + action calls across accounts, tagged by uin+kind). Taps
attach only while a client is connected (on-demand); a slow client is dropped
with a periodic {kind:'dropped'} marker rather than buffering unbounded.
/api/debug/stream added to the ?token= allowlist for EventSource.
2026-06-18 15:20:06 +08:00
DrSmoothl cde5a22ec8 feat(onebot): debug subscribe seams for NetworkManager + ApiHandler
Add NetworkManager.subscribeDebug (notified for every emitEvent, before the
adapter check, so the debug stream sees events even with no adapter) and
ApiHandler.setObserver (fires after every handled action with
{action,params,response,ms}, success or failure). Both Set-based, return an
unsubscribe, and swallow subscriber throws so they never break dispatch.
OneBotInstance exposes subscribeDebugEvents / observeActions / invokeAction.
Foundation for the Wave A3 debug stream + action tester.
2026-06-18 15:17:28 +08:00
DrSmoothl d2e8222886 fix(webui): A2 backup — per-uin onebot config, no-store, atomic restore
Adversarial Wave A2 pass, 3 MAJORs:
- #3/#3b: the allowlist missed per-account config/onebot_<uin>.json (the file
  that actually carries each account's network config + OneBot access token).
  Add it via an /^onebot_\d+\.json$/ pattern; classify ALL onebot config
  (global + per-uin) as credential since it holds the access token — a
  no-credentials backup no longer leaks tokens. Export scans config/ for them.
- #6: GET export could carry the private key/hash; add Cache-Control: no-store
  + Pragma so the response is never cached.
- #4: refactor applyBackup → pure planRestore + a two-phase atomic restore in
  server.ts (snapshot → stage all .tmp → rename all), so a staging failure
  leaves the live config untouched; clean up stray tmp on failure.
2026-06-18 15:08:51 +08:00
DrSmoothl 0c20d58a8c feat(webui): backup/restore UI in system settings (Wave A2)
A '配置备份 / 恢复' card in the 服务 tab: export downloads the JSON bundle
(with an 包含登录凭据 toggle + red warning), restore picks a .json file and
calls importBackup with a 一并恢复登录凭据 toggle (default off). Shows
restored/skipped counts + restart-to-apply. Typed systemSettings.exportBackup/
importBackup client methods. View layer — verified by tsc + vite build.
2026-06-18 15:01:17 +08:00
DrSmoothl c0e3bd0cff feat(webui): backup export/import API (Wave A2)
GET /api/system/backup/export?credentials=1 streams the JSON bundle (download
header); POST /api/system/backup/import validates then applies, snapshotting
current config to config/.restore-backup-<ts>/ first (recoverable), writing
atomically (tmp+rename), key.pem at 0600. Both behind /api/* auth; import body
capped at 32MB. Restore is restart-to-apply.
2026-06-18 14:57:46 +08:00
DrSmoothl b71aedaa60 feat(webui): config backup/restore core (Wave A2)
Pure, tested backup module: buildBackup (allowlist → JSON bundle, credential
files gated), validateBackup (version/app/known-filename/no-traversal/entry
shape — rejects wholesale), applyBackup (snapshot-then-write, skips
credentials unless opted in). Credentials = webui.json + key.pem; cert.pem is
public and always travels. fs glue lands next in server.ts.
2026-06-18 14:56:12 +08:00
DrSmoothl a38b33c8aa feat(webui): Select/Textarea UI components + hide installed TLS cert
Add reusable Select (styled native select + chevron) and Textarea components
matching Input's design language, and replace every native <select>/<textarea>
across the app (system-panel, node-edit-dialog, notification-channel-dialog,
settings customCss, widget note) with them.

TLS cert UX: once a cert is installed, the sensitive PEM is no longer rendered
on revisit — show an '已安装(未改变)' summary with 更改/删除 buttons; 更改
reveals the inputs again (with a 取消). Private key is never sent back to the
client. Verified by tsc + vite build.
2026-06-18 14:53:43 +08:00
DrSmoothl 675261691f fix(webui): make key.pem chmod best-effort (match auth.ts)
Wrap the post-write chmodSync in try/catch like auth.ts: the key is already
written with mode 0600, so a rare chmod failure shouldn't misreport a
successful write as a 500.
2026-06-18 14:45:09 +08:00
DrSmoothl a4b226f1d0 fix(webui): write key.pem with 0600 (was world-readable)
Adversarial Wave A1 pass (MAJOR): the TLS private key was written with the
default umask mode (typically 0644 — world-readable). Write config/key.pem
with mode 0600 + an explicit chmodSync (writeFileSync's mode is ignored for an
existing file), mirroring auth.ts's webui.json handling. cert.pem stays public.
2026-06-18 14:43:27 +08:00
DrSmoothl 82d5f782a3 feat(webui): system settings panel (port/host/TLS/trust-proxy)
New Settings → 服务 tab (SystemPanel) over the /api/system/settings + TLS cert
API: edit port / bind host / trust-proxy, paste cert+key PEM to enable HTTPS,
delete cert. Flags env-overridden fields ('被环境变量覆盖') and shows a
restart-to-apply banner (no self-restart). Typed systemSettings API client
domain. View layer — verified by tsc + vite build (webui has no component-test
infra).
2026-06-18 14:39:35 +08:00
DrSmoothl 7faef1b7c0 feat(webui): system settings + TLS cert API (Wave A1)
GET/POST /api/system/settings (port/host/tlsEnabled/trustProxy) persisted via
updateRuntimeConfig — listener-level, restart-to-apply; response carries
restartRequiredToApply + the env-overridden field names so the panel can flag
edits that won't take effect. Enabling TLS is blocked unless a usable cert is
present. POST/DELETE /api/system/tls/cert validates a pasted PEM pair
(validateTlsPair) before writing config/cert.pem + key.pem. Adds read-only
readRuntimeConfig() and a tested pure coerceSettingsPatch validator. All routes
sit behind the existing /api/* auth middleware.
2026-06-18 14:32:24 +08:00
DrSmoothl 9a455b30c6 feat(common): updateRuntimeConfig(patch) for the WebUI settings panel
Persist a partial RuntimeConfig update, merging onto the on-disk config (not
the env-merged view) so an env override is never baked into runtime.json.
Tested: patch merge + the env-not-persisted invariant (chdir to a temp cwd).
2026-06-18 14:28:31 +08:00
DrSmoothl ad21e19dc8 feat(webui): source trustProxy from runtime config (env still overrides)
Move the trust-proxy/client-ip resolver from a module-level direct read of
SNOWLUMA_WEBUI_TRUST_PROXY into initWebUI, fed by runtime.json's trustProxy
(loadRuntimeConfig already merges the env override on top). Default '' parses
identically to the old undefined (trust socket peer only), so behavior is
unchanged when unset.
2026-06-18 14:26:32 +08:00
DrSmoothl 64c10e2856 feat(webui): HTTPS serving + configurable bind host
initWebUI now takes { host, tlsEnabled }. When TLS is enabled, load+validate
config/cert.pem + key.pem (tls.ts: validateTlsPair / resolveTlsContext, both
unit-tested) and serve via @hono/node-server's https createServer; a missing
or invalid cert falls back to HTTP with a loud warning (never bricks the WebUI).
Bind host is now configurable (default 0.0.0.0). Wired from runtime.json
(webuiHost / webuiTls.enabled) in core/index.ts.
2026-06-18 14:24:51 +08:00
DrSmoothl 77b16faebb feat(common): extend RuntimeConfig for WebUI listener settings
Add webuiHost / webuiTls.enabled / trustProxy to RuntimeConfig as the shared
system-settings model for WebUI Wave A1 (HTTPS + self-config panel). Refactor
load into pure, tested normalizeRuntimeConfig + resolveRuntimeEnvOverrides
(env wins, in-memory only; SNOWLUMA_WEBUI_HOST added alongside the existing
PORT/TRUST_PROXY overrides). loadRuntimeConfig backfills older runtime.json
with the new defaults. Adds vitest to @snowluma/common (was typecheck-only).
2026-06-18 14:19:45 +08:00
DrSmoothl 90d3f7c0d6 test(protocol): real byte-oracle for ocr_image request wire
Adversarial Wave 3 pass: the prior 'round-trip locks pb tags' test was a
fake lock — symmetric encode/decode via the same proto would not catch a tag
swap. Replace with a fixed hex byte-oracle (hand-decoded in the comment) that
goes red on any tag change, plus an empty-ocr-body deserialize test. Note in
the action that a passed-in URL is used verbatim (not rkey re-signed).
2026-06-18 00:40:26 +08:00
DrSmoothl e07090376a feat(onebot): implement ocr_image via OIDB 0xE07_0
Server-side image OCR ported from Lagrange.Core ([Service]
"OidbSvcTrpcTcp.0xe07_0") cross-checked with NapCat's Oidb.0xE07 proto
(NapCat's transformer cmd 0xEB7 is a typo; the proto name + Lagrange + the
wire name all say 0xE07_0). New ImageOcr service + proto types, misc.ocrImage
api, and the ocr_image/.ocr_image action. The cmd takes an image URL, so the
action accepts an http(s) url or a cached image file_id (resolved via
getImageInfo); base64/local input is unsupported (NapCat's path is a
Windows-only kernel OCR, not portable). Wire round-trip + response-parse
tests lock the pb tags.
2026-06-18 00:36:07 +08:00
DrSmoothl f936515a59 fix(onebot): get_rkey_server fails on empty rkey set; document rename return
Adversarial Wave 2 pass (no blockers): get_rkey_server now returns
ACTION_FAILED instead of an 'expired_time=now, no keys' shell when no
download rkey is available, so a caching caller can't misread 'unavailable'
as 'valid-but-expired'. Added an empty-list test. Annotated rename_group_file's
{ok:true} body as a deliberate NapCat-parity deviation from sibling file ops.
2026-06-18 00:26:34 +08:00