Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 bf770fed83 fix(cinny-web): no-cache for the app shell on SPA routes; keep old hashed assets 14 days (#23)
Lint / Shell (shellcheck) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 15s
Lint / No secrets in webhook configs (push) Successful in 7s
Lint / Landing page is rendered (matrix (push) Successful in 42s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Lint / Shell (shellcheck) (pull_request) Successful in 9s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / No secrets in webhook configs (pull_request) Successful in 5s
Lint / Landing page is rendered (matrix (pull_request) Successful in 4s
Lint / Python (ruff) (pull_request) Successful in 6s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m59s
Lint / Secret scan (gitleaks) (pull_request) Successful in 15s
SPA URLs (/, /home/…, /login/…) are rewritten to /index.html inside
location /, which set no cache headers, so browsers heuristically reused
a stale shell after a deploy, and rsync --delete had just removed the
assets it referenced: a blank app until the shell expired. Send
no-cache for the rewritten shell (map on $uri; location / now includes
the security headers, since add_header drops the inherited ones), and
stop deleting previous builds' hashed assets: prune after 14 days.

Fixes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:26:02 -04:00
4 changed files with 24 additions and 74 deletions
+7 -1
View File
@@ -160,7 +160,13 @@ echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $WEBROOT..."
# /var/www/html/config.json by lxc106-cinny.sh. The build ships a DEV default
# (allowCustomHomeservers:true); rsyncing it would clobber the production config
# on every deploy. Keep the app bundle and the runtime config separate.
rsync -a --delete --exclude config.json dist/ "$WEBROOT/"
# [matrix #23] Keep the previous builds' hashed assets for a while: a browser
# still holding an older index.html (or service-worker precache) asks for its
# assets/index-<old hash>.js, and deleting them on every deploy broke the app
# for those users until their cached shell expired. Prune after 14 days.
rsync -a --delete --exclude config.json --exclude assets/ dist/ "$WEBROOT/"
rsync -a dist/assets/ "$WEBROOT/assets/"
find "$WEBROOT/assets" -type f -mtime +14 -delete
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
+12
View File
@@ -1,3 +1,12 @@
# [matrix #23] SPA routes (/, /home/…, /login/…) are rewritten to /index.html
# inside `location /`, where no cache headers were set: browsers then reused a
# heuristically fresh shell after a deploy and requested deleted assets. Only
# the rewritten shell gets no-cache; other files there keep their defaults.
map $uri $lotus_shell_cache_control {
/index.html "no-cache, no-store, must-revalidate";
default "";
}
server {
listen 80;
listen [::]:80;
@@ -99,6 +108,9 @@ server {
}
location / {
# add_header here drops the server-level headers, so include them again.
include snippets/cinny-security-headers.conf;
add_header Cache-Control $lotus_shell_cache_control always;
rewrite ^/config\.json$ /config.json break;
rewrite ^/manifest\.json$ /manifest.json break;
rewrite ^/sw\.js$ /sw.js break;
-28
View File
@@ -389,31 +389,3 @@ class NormalizePermissionTest(unittest.TestCase):
guard.livekit_update_participant = lambda *a: None
participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}}
self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"}))
class SlotReservationTests(unittest.TestCase):
"""[matrix #25] Simultaneous joins can't both pass a full room."""
def test_second_simultaneous_join_is_blocked(self):
r = guard.SlotReservations(ttl=30)
present = set()
counted = present | r.reserved("room", present, now=0)
self.assertFalse(guard.should_block(1, counted, "@a:x"))
r.reserve("room", "@a:x", now=0)
# @b asks a moment later, before @a has connected to LiveKit.
counted = present | r.reserved("room", present, now=1)
self.assertTrue(guard.should_block(1, counted, "@b:x"))
# @a asking again (second device) is still allowed.
self.assertFalse(guard.should_block(1, counted, "@a:x"))
def test_reservation_expires_and_clears_once_present(self):
r = guard.SlotReservations(ttl=30)
r.reserve("room", "@a:x", now=0)
self.assertEqual(r.reserved("room", set(), now=31), set())
r.reserve("room", "@a:x", now=40)
self.assertEqual(r.reserved("room", {"@a:x"}, now=41), set())
def test_room_lock_is_per_room(self):
r = guard.SlotReservations()
self.assertIs(r.room_lock("a"), r.room_lock("a"))
self.assertIsNot(r.room_lock("a"), r.room_lock("b"))
+5 -45
View File
@@ -145,41 +145,6 @@ def should_block(limit: int, present_users: set, requester: str) -> bool:
return len(present_users) >= limit
# [matrix #25] Tokens issued but not yet connected weren't counted, so two
# users pressing Join together could both pass a 1-person limit. Each admitted
# user holds a short reservation (counted like a present user) until LiveKit
# lists them or it expires; checks for a room are serialised with a lock.
RESERVATION_TTL = 30.0
class SlotReservations:
"""Per-room reservations for admitted-but-not-yet-connected users."""
def __init__(self, ttl: float = RESERVATION_TTL):
self.ttl = ttl
self._by_room = {} # alias -> {user: expiry}
self._guard = threading.Lock()
self._room_locks = {}
def room_lock(self, alias: str) -> threading.Lock:
with self._guard:
return self._room_locks.setdefault(alias, threading.Lock())
def reserved(self, alias: str, present: set, now: float) -> set:
"""Unexpired reservations; drops expired ones and users now present."""
room = self._by_room.get(alias, {})
for user in [u for u, exp in room.items() if exp <= now or u in present]:
del room[user]
return set(room)
def reserve(self, alias: str, user: str, now: float) -> None:
if user:
self._by_room.setdefault(alias, {})[user] = now + self.ttl
RESERVATIONS = SlotReservations()
def allowed_sources(policy: dict):
"""Pure decision: the narrowed `canPublishSources` list, or None.
@@ -653,16 +618,11 @@ class Handler(BaseHTTPRequestHandler):
# LiveKit-admin outage here cannot skip the publish-source policy below.
if limit > 0 and alias:
try:
with RESERVATIONS.room_lock(alias):
present = livekit_present_users(alias)
now = time.monotonic()
counted = present | RESERVATIONS.reserved(alias, present, now)
if should_block(limit, counted, requester):
log(f"blocked {requester or '?'} from {room_id}: {len(counted)}/{limit}")
self._send_blocked()
return
if requester not in present:
RESERVATIONS.reserve(alias, requester, now)
present = livekit_present_users(alias)
if should_block(limit, present, requester):
log(f"blocked {requester or '?'} from {room_id}: {len(present)}/{limit}")
self._send_blocked()
return
except Exception as exc:
# Fail open on the limit only; still apply the policy below.
log(f"limit check error for {room_id}: {exc}")