Skip to content

fix: 路徑穿越消毒(H2)+ 接上 LOBBY_WINDOW_S 自動清理(H1)+ 純邏輯回歸測試#1

Open
cutedogspark wants to merge 7 commits into
mainfrom
fix/round1-hardening
Open

fix: 路徑穿越消毒(H2)+ 接上 LOBBY_WINDOW_S 自動清理(H1)+ 純邏輯回歸測試#1
cutedogspark wants to merge 7 commits into
mainfrom
fix/round1-hardening

Conversation

@cutedogspark

Copy link
Copy Markdown
Owner

analysis.md 的建議下一步,修掉兩個高優先項目,並補上回歸測試。安全為最高原則(本服務為對外公開 demo,voice-demo.intemotech.com)。

H2 — meeting_id / track 路徑穿越消毒

meeting_id / track 直接來自 client query 參數,並被當成磁碟目錄名(root/<id>/<track>/audio.pcm)。原本只有 DELETE 端有 resolve() 防護,建立路徑(WS / get_meeting / get_track)完全沒驗證

  • 新增 meeting.valid_id(s):白名單 re.fullmatch(r"[A-Za-z0-9_-]{1,64}"),非字串安全回 False,不丟例外。
  • WS 握手:在 accept() 之前驗證 idtrack(viewer 可省略 track),不合 → close(4000)
  • get_meeting() / Meeting.get_track():不合 → raise ValueError(縱深防禦)。
  • DELETE /meetings/{mid}:加上 valid_id 早退(原 resolve() 保留)。
  • 前端 genId() 本就只產生 [A-Za-z0-9_-],正常流程不受影響。

H1 — 接上 LOBBY_WINDOW_S 自動清理 + 單場容量防爆

LOBBY_WINDOW_S(預設 86400s)早已在 .env.example / start-meeting.sh 匯出,但後端從未引用

  • 新增純判定 meeting.should_evict(idle_s, is_live, ttl_s)Meeting.is_live() / last_activity();/meetings 端點也改用 is_live,消除重複的 live 判定。
  • diarization_worker 每輪呼叫 _evict_idle_meetings(),三重安全閘才清:
    1. 非 live(is_live 為真絕不清);
    2. 目前無任何連線(保護正在檢視的人);
    3. 閒置超過 TTL
  • ⚠️ 只清記憶體,絕不刪磁碟:audio.pcm / meta.json 原封保留(重啟由 _resume_meetings 載回)。符合 .env.example「frees memory … on-disk recordings are kept」,滿足「絕不可誤刪 live 中或仍可檢視的會議」。唯一刪磁碟的仍是手動「清理 / DELETE」。
  • 時間基準:eviction 用 time.time()(牆鐘,與 t.updated 一致),非 loop.time()
  • 單場防爆:session.over_capacity() + append_pcm 在達 MEETING_MAX_TRACK_S(預設 6h,設 0 停用)後丟棄後續音框,擋住失控 / 被遺棄分頁 / 惡意無限串流灌爆磁碟。

測試(純邏輯,無 GPU/FunASR)

FunASR import 為 lazy,故只需 numpy + fastapi 即可跑(另建 .venv-test,已 gitignore)。

  • tests/test_unit.py(18):valid_idshould_evictis_live/last_activity、容量 cap,以及 session._stitch 回歸網(講者 id 跨輪穩定 / 命名黏著 / 重啟重錨)。
  • tests/test_app_glue.py(9):app.py 接線 — eviction 三重安全閘、保留磁碟、清理字典簿記、id 拒絕。
27 passed, 2 warnings in 0.39s

(2 warning 為既有 FastAPI on_event deprecation,與本次改動無關。)

TDD 證據:先 RED(ImportError: valid_id 收集失敗)→ 最小實作 GREEN → 變異測試移除 is_live 安全閘使 test_should_evict_never_touches_live_meeting 如預期失敗,還原後全綠,證明安全測試有牙齒。

取捨(詳見 .agent-out/voice-meeting/fix-round1.md)

  • 自動清理刻意不刪磁碟(安全優先);真正回收磁碟需另做歸檔策略並經同意。
  • MEETING_MAX_TRACK_S=6h 為寬鬆預設,與「無時間上限」賣點的張力可再議(設 0 停用)。
  • 本輪僅 H1/H2;H3 認證、文件同步、長逐字稿摘要等留待後續。

🤖 Generated with Claude Code

cutedogspark and others added 7 commits June 3, 2026 23:09
…y removal)

Root cause: the lobby (/meetings) listed only the in-memory _meetings dict,
which is populated lazily on WS-connect; startup never rescanned MEETING_DATA,
so a backend restart left the lobby empty (meetings "disappeared") even though
each track's audio + meta were on disk. Segments and the meeting title/created
were also not persisted.

Fix:
- session.py: persist self.segments in meta.json -> transcript is viewable after
  a restart with no re-diarization.
- meeting.py: persist title/created in meeting.json -> lobby keeps the real name
  + order after restart.
- app.py: _resume_meetings() on startup scans MEETING_DATA and reloads every
  meeting into _meetings (marking resumed tracks "ended" — viewable, not live).
  /meetings now returns an `ended` flag. A meeting is removed ONLY by the manual
  清理 (DELETE /meetings/{id}); no auto-expiry exists.
- frontend: lobby shows a 「已結束 · 可檢視」 tag for ended meetings. Cache-bust v=22.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /meetings now returns a reliable started_at per meeting: meeting.json
  created for new meetings, else the earliest audio.pcm mtime (real recording
  onset) — via min(created, earliest_audio_mtime), so legacy meetings whose
  created was overwritten by the persistence restart still show their true
  recording time. No-audio standby meetings keep created.
- Backend sorts the full list by started_at descending (newest first).
- Frontend lobby: show each meeting's date+time, sort the whole list then
  paginate 20/page with 上一頁/下一頁 + 第 X/Y 頁 · 共 N 場 (controls only when
  >20); 已結束·可檢視 tag and 加入/清理 buttons unchanged. Cache v=22→v=23.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem 1 — speaker/participant count grew with every "joined but never
started" person:
- Track dir + meta.json used to be created on WS join (get_track + set_device
  -> _save_meta), and participants()/counts included every track regardless of
  whether it recorded. A fresh trackId per join meant repeated in/out piled up
  empty tracks.
- Fix: lazy track persistence — Track no longer mkdir/saves on construction;
  the dir + meta are written only on the FIRST real audio frame (append_pcm).
  _save_meta is a no-op while there is no audio.pcm, so standby joins leave zero
  on-disk footprint. participants() and the lobby count now include only tracks
  with has_audio(), so existing accumulated empty tracks also stop counting
  (audio is never touched/deleted). Lobby "ended" ignores audio-less active
  tracks so a standby viewer can't flip a meeting back to 進行中.

Problem 2 — in-meeting delete:
- New 刪除本會議 button in the meeting view (two-step confirm) calls the existing
  DELETE /meetings/{id} (path-guarded) and returns to the lobby.
- It tears down the WS (wantOpen=false) BEFORE issuing DELETE, so an in-flight
  recording socket can't auto-reconnect and resurrect the just-deleted meeting.

Cache v=23 -> v=25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /meetings now returns a per-meeting `live` boolean:
  live = any track that is active AND has recorded audio AND received an audio
  frame within LIVE_WINDOW_S (default 10s, MEETING_LIVE_WINDOW_S env).
Recording appends a PCM frame every ~250ms (updates t.updated), so a paused or
left track drops out of the window within seconds, and a standby WS that merely
connected (no audio) never counts. session._load_meta now restores `updated`
from meta so a resumed (ended) meeting after a restart is never falsely live.

Frontend lobby: a live meeting shows a red pulsing "🔴 進行中" pill (.livetag),
which takes priority over the 已結束·可檢視 tag; non-live stays low-key (no
badge). The existing 5s auto-refresh updates the state. Cache v=25 -> v=26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a dedicated section at the top of the lobby listing only live (currently
recording) meetings, each with the 🔴 進行中 tag + 加入/清理 (reuses the shared
row style via a new makeMeetingRow helper). Hidden entirely when no meeting is
live. The existing "全部會議" list below is unchanged (newest-first, 20/page);
the live section heading there was renamed from 進行中的會議 to 全部會議 to avoid
confusion. No dedup — a live meeting appears in both the top section and the
full list (simplest; the top section is a quick-access highlight). The existing
5s auto-refresh re-renders the section, so it appears on record-start and
auto-clears ~10s after stop. Backend unchanged (reuses the `live` flag). v=26->v=27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 進行中的會議 section used to hide entirely when no meeting was live, so a
user who refreshed saw nothing and assumed it vanished. Now the section (title)
is always shown; with no live meetings it displays a low-key muted line
"目前沒有進行中的會議"; when meetings are live it lists them with 🔴 進行中 as
before. The 5s auto-refresh swaps between the two states. Frontend only;
backend unchanged. v=27->v=28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
H2 path traversal: meeting_id/track come straight from client query params and
become on-disk dir names. Add a valid_id() whitelist ([A-Za-z0-9_-]{1,64}) and
reject bad ids in the WS handshake (close 4000, before accept), in get_meeting,
Meeting.get_track, and DELETE — mirroring the resolve() guard already in delete.

H1 auto-cleanup: LOBBY_WINDOW_S was exposed in .env/start-meeting.sh but never
used. Wire it into the diarization worker via a should_evict() predicate +
Meeting.is_live/last_activity. A meeting is evicted from memory only when it is
idle past the TTL, NOT live, and has nobody connected. SAFETY (public demo):
eviction is memory-only — on-disk recordings are KEPT, so no live or still-
viewable meeting is ever destroyed; manual 清理/DELETE stays the only disk delete.
Also add a per-track recording cap (MEETING_MAX_TRACK_S, default 6h) to bound
runaway/abandoned streams. Refactor /meetings to share Meeting.is_live.

Tests (pure logic, no GPU): valid_id, should_evict, is_live/last_activity,
capacity cap, and a regression net around session._stitch; plus runtime app-glue
tests for eviction safety + id rejection. 27 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant