Skip to content

feat: עיצוב מחדש של מסך הגדרות הניהול + captain-definition - #23

Open
27180781 wants to merge 114 commits into
NetFree-Community:masterfrom
27180781:master
Open

feat: עיצוב מחדש של מסך הגדרות הניהול + captain-definition#23
27180781 wants to merge 114 commits into
NetFree-Community:masterfrom
27180781:master

Conversation

@27180781

@27180781 27180781 commented May 7, 2026

Copy link
Copy Markdown

Summary

  • שכתוב מסך הגדרות הניהול: במקום טבלת key/value גנרית, ההגדרות מאורגנות בקטגוריות עם תוויות והסברים בעברית, עם רכיבים מתאימים לכל סוג שדה (toggle לבוליאני, מספר, סיסמה, textarea וכו').
  • קטגוריות: כללי, הזדהות ואבטחה, מונה צפיות, פרסומות, וובהוק, התראות דחיפה (FCM), חשבון שירות FCM, החלפות טקסט אוטומטיות.
  • תוספות נוחות: רשימה דינמית לכללי regex-replace (שדות pattern/replace נפרדים), הדבקת JSON של Service Account שמפצל אוטומטית לכל שדות fcm_json_*, ומקטע "הגדרות מתקדמות" מתקפל בתחתית שמאפשר עדיין הוספת key/value חופשי (גם מציג הגדרות לא מוכרות שכבר קיימות).
  • תאימות לאחור: אין שינוי ב-API. הקומפוננטה ממירה את כל הערכים בחזרה למבנה [{key, value}] בעת שמירה (בוליאנים נשמרים כ-'1', regex כ-pattern#replace).
  • הוספת קובץ captain-definition להגדרת פריסה ב-CapRover.

Files changed

  • frontend/src/app/components/admin/settings/settings.schema.ts (חדש) — schema עם קטגוריות, סוגי שדות, ותיאורים בעברית.
  • frontend/src/app/components/admin/settings/settings.component.ts — נכתב מחדש לטעון/לשמור לפי ה-schema.
  • frontend/src/app/components/admin/settings/settings.component.html — UI חדש לחלוטין.
  • frontend/src/app/components/admin/settings/settings.component.scss — סגנונות חדשים.
  • captain-definition — קובץ פריסה.

Test plan

  • להיכנס לממשק הניהול ולוודא שכל ההגדרות הקיימות נטענות נכון לשדות החדשים.
  • להפעיל/לכבות toggles ולוודא שהערך נשמר נכון בבקאנד (1/לא קיים).
  • להזין כללי regex-replace ולוודא שהם נשמרים בפורמט pattern#replace ופועלים בפרסום הודעות.
  • להדביק JSON של Service Account ולוודא שכל שדות fcm_json_* מתמלאים.
  • להוסיף הגדרה דרך "הגדרות מתקדמות" ולוודא שהיא נשמרת ונטענת בחזרה לאזור המתקדם.
  • לוודא שהגדרות לא מוכרות שכבר קיימות במערכת מופיעות באזור "הגדרות מתקדמות".

🤖 Generated with Claude Code

27180781 and others added 30 commits May 7, 2026 04:56
Replace the generic key/value table with a structured form: settings are
grouped by category (general, auth, ads, webhook, notifications, FCM),
each with Hebrew labels and descriptions. Booleans are toggles, regex
rules have separate pattern/replace fields, and a paste-JSON helper
auto-fills FCM service account fields. A collapsible "advanced" section
preserves manual key/value entry for unknown settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add a dedicated admin tab "שילוב פרסומות ממגנט" where admins paste an
HTML/JS embed snippet from the Magnet ad platform. Ads render between
chat messages according to configurable rules: either every N messages
(with optional minimum time gap) or every N seconds (with optional
minimum new-messages gap).

Each ad slot is lazy-loaded with IntersectionObserver — the external
embed only fires when the user scrolls near it, and re-fires on every
re-mount so each viewer (and each scroll) gets a live ad. If the embed
produces no DOM content within 5 seconds, the slot collapses silently.

Backend exposes a new public GET /api/ads/magnet endpoint and persists
the seven magnet_* keys in the existing settings:list store.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add a publisher API key field and a stats viewer in the Magnet admin tab.
A new admin-protected endpoint GET /api/admin/magnet/stats proxies the
request to Magnet's publisher-stats function so the API key never reaches
the browser. The stats panel shows clicks and earnings (today / week /
month) in two columns with currency formatting, displays the site domain,
and has a refresh button with a last-updated timestamp. Errors from
Magnet (400 invalid key, 404 unapproved site) are surfaced in Hebrew.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add defensive fallbacks so a regression in the Magnet ad-injection logic
can never prevent regular chat messages from rendering:

- rebuildItems() now wraps buildItems() in try/catch and falls back to
  plain message items if it throws or returns an empty list while there
  are messages.
- Template renders directly from messages[] when items[] is empty,
  bypassing the ad-injection layer entirely as a safety net.
- ngOnInit catches a rejected loadSettings() promise so it cannot block
  the subsequent rebuildItems call.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The previous approach replaced the @for over messages with a @for over a
new ChatItem[] union type. That introduced a subtle regression where
messages stopped rendering for some users. Restore the original
@for (message of messages; track message) loop and inject the magnet ad
slot as a sibling <nb-list-item> right after each message that should
have an ad — driven by an adSlotsAfter Set<number> of message IDs.

Service no longer builds an items array; it returns the Set directly via
computeAdSlots(). The chat component owns adSlotsAfter and recomputes it
on every message-list mutation. If computation throws, the Set stays
empty and messages render unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…dering

Despite restoring the @for messages template loop, deployed builds were
still rendering an empty chat list with no messages, no loading spinner,
and no "no messages" placeholder — a state inconsistent with what the
template should produce. To unblock users, fully revert chat.component.ts
and chat.component.html to the pre-magnet snapshot. The magnet ads
service, slot component, admin tab, and backend endpoints remain in
place; ad injection into the chat list will be re-introduced separately
once the regression is understood.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Step 1 of incremental re-introduction: add the MagnetAdsService
injection, the adSlotsAfter Set, the rebuildItems() helper, and the
ngOnInit loadSettings() call. The template is left unchanged from the
known-working revert. If the chat still renders, we know the regression
came from the template @if injection rather than the service wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Step 2 of incremental re-introduction: add the @if (adSlotsAfter.has(...))
template block, but render a plain <div> placeholder instead of the real
<app-magnet-ad-slot> component. If messages still render with this in
place, the regression was tied to the magnet-ad-slot component (DI,
ngAfterViewInit, IntersectionObserver, etc.) rather than to the @if
template structure itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Step 3 of incremental re-introduction: instead of adding a sibling
<nb-list-item> after the message item, place the ad placeholder inside
the same <nb-list-item> as the message. This avoids creating a second
projected list-item child of <nb-list> via @if, which appears to be
what blocks rendering — Nebular's <nb-list> may rely on a content-child
query that does not survive a conditional second item.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Now that the regression is understood (creating a sibling <nb-list-item>
via @if inside @for breaks Nebular's <nb-list> rendering), wire the real
<app-magnet-ad-slot> component back in — but place it inside the
existing <nb-list-item> rather than as a sibling. This keeps every
iteration of @for producing exactly one <nb-list-item>, which is what
<nb-list> expects.

Also re-add MagnetAdSlotComponent to ChatComponent's imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wrap the magnet ad slot in the same visual structure as a regular admin
message: channel logo on the side, channel name + 'פרסומת' label
above, and the embed itself rendered inside the same .message-card
bubble used for chat messages. Inject ChatService into the slot
component to read channelInfo for the logo and name.

The collapsed state now toggles a CSS class on the wrapper (display:
none) instead of rebuilding the DOM, which keeps the #host ViewChild
stable across renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add a new "אנליטיקס וטראקינג" category in the admin settings UI with a
textarea field for pasting any HTML/JS analytics snippet (Google
Analytics, GTM, Meta Pixel, etc.). The backend stores it under the
analytics_head settings key and serveSpaFile injects it into the
served index.html immediately before the closing </head> tag, so the
snippet runs on every page load.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Update SET.md with the changes introduced over this work session:
- The new visual settings UI with categorized cards.
- The analytics_head setting and its head injection.
- The full Magnet ad integration (frequency rules, lazy loading, ad
  bubble rendering, stats panel) and the architectural note about why
  ads are nested inside the existing <nb-list-item> rather than as
  siblings.
- The new /api/ads/magnet and /api/admin/magnet/stats endpoints.
- A complete refreshed settings table including all magnet_* keys and
  analytics_head.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Rewrite privileges.go with new GlobalRole/ChannelRole system (SuperAdmin, Owner, Moderator, Writer)
- Add channels.go with ChannelData, ChannelFeatures, and channel CRUD handlers
- Add channelMiddleware to inject channel into request context
- Channel owners can manage their users; super admin controls everything

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- Replace old Privileges map with GlobalRole and ChannelRoles fields
- Update getUser to populate GlobalRole and ChannelRoles from privilegesUsers
- Update registeringEmail to accept slug for per-channel email tracking
- Remove checkPrivilege (replaced by isSuperAdmin/hasChannelRole)

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- db.go: all DB functions now channel-scoped (slug parameter), added channel CRUD
  (dbCreateChannel, dbGetChannel, dbListChannels, dbDeleteChannel, dbSetChannelFeatures)
  Settings split: per-channel (channel:{slug}:settings) + global (global:settings)
- messages.go: slug from context, SSE subscribes to events:{slug}
- settings.go: per-channel getSettings/setSettings + getGlobalSettings/setGlobalSettings
- statistics.go: per-channel SSE counters via sync.Map
- reactions.go: per-channel emojis loaded from DB per request
- report.go, scheduled.go: channel-scoped DB calls
- notifications.go: FCM stays global, subscriptions are per-channel
- ads.go, webhook.go: load per-channel config via getChannelConfig()
- api.go: API key validated from per-channel settings
- files.go: file URL includes channel slug, max size from per-channel config
- channelInfo.go: features sourced from channel context
- main.go: full routing rewrite - /api/channel/{slug}/... and /api/super-admin/...

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- ChannelFeatures: add MagnetLockedByAdmin bool (set by super admin only)
- db.go: add GlobalMagnetConfig struct + dbGetGlobalMagnetConfig/dbSetGlobalMagnetConfig
  stored at global:magnet:config
- ads.go: getMagnetAdsSettings now checks if channel is locked (LockAll, LockedChannels
  list, or MagnetLockedByAdmin flag) and returns global config instead of channel config
- ads.go: add getGlobalMagnetConfig/setGlobalMagnetConfig handlers for super admin
- ads.go: syncMagnetLockFlags goroutine syncs MagnetLockedByAdmin on all channels
  when super admin saves global config
- getMagnetStats now reads API key from global magnet config
- main.go: add GET/POST /api/super-admin/magnet/config routes

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
…s Magnet)

- ChannelFeatures: add AdsLockedByAdmin bool
- db.go: add GlobalAdsConfig struct + dbGetGlobalAdsConfig/dbSetGlobalAdsConfig
  stored at global:ads:config (src, width, lockAll, lockedChannels)
- ads.go: getAdsSettings checks if channel is locked and returns global src/width
- ads.go: add getGlobalAdsConfig/setGlobalAdsConfig handlers + syncAdsLockFlags goroutine
- main.go: add GET/POST /api/super-admin/ads/config routes

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- user.model.ts: add globalRole, channelRoles, publicName fields
- super-admin.service.ts: all super admin API calls
- super-admin.guard.ts: route guard (globalRole === super_admin only)
- super-admin/channels: channels list, channel features, channel users components
- super-admin/global-ads: global ads config component (partial)

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- super-admin-panel.component: main shell with sidebar menu (ערוצים, פרסומות iframe,
  פרסומות מגנט, משתמשים, הגדרות גלובליות, סטטיסטיקות)
- channels-list: table with create/edit-features/manage-users/delete actions
- channel-features: all 12 feature toggles with Hebrew labels (incl. lock indicators)
- channel-users: add/remove/change-role per channel, save button
- global-ads: src, width, lockAll toggle, locked channels tag input
- global-magnet: full magnet config + frequency settings + lock rules
- global-users: read-only overview of all users with role chips
- global-settings: key-value FCM/VAPID settings editor
- statistics: reset peak connections + magnet stats display
- app.routes.ts: add /super-admin route with SuperAdminGuard
- channel-header: add "פאנל מנהל-על" link for super admins
- channel.component: show input footer for super_admin + channel role holders

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- storage.go: R2 client init (aws-sdk-go-v2/s3), upload, exists-check, download helpers
  Reads env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,
  R2_BUCKET_NAME, R2_PUBLIC_URL (optional)
- files.go: file metadata moved to Redis (key: file:{id}), YAML fallback for legacy files
  If R2 configured: uploads to R2, serves via redirect (public URL) or proxy
  If R2 not configured: local disk at /app/files/ (backward compatible)
  Deduplication by SHA-256 hash preserved for both R2 and local
- main.go: call initR2() on startup
- sample.env: document R2 env vars
- go.mod/go.sum: add aws-sdk-go-v2 dependencies

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
…ression

- Backend: storage quota tracking with per-channel and global defaults
- Backend: auto-cleanup oldest files when quota is near full
- Backend: TinyPNG API integration — compresses PNG/JPEG/WebP before upload
- Frontend (admin): channel storage panel with usage bar, warnings, auto-cleanup toggle
- Frontend (admin): TinyPNG API key field in settings (storage category)
- Frontend (super-admin): global storage quota config panel
- Frontend (super-admin): per-channel storage view with quota override and usage bar
- Frontend (super-admin): "אחסון" button in channels list to open per-channel storage
- Super admin service: added getGlobalStorageConfig/setGlobalStorageConfig/getChannelStorage/setChannelStorage

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
- Public landing page at '/' with hero, how-it-works, features, and request form
- Route '/' is now the landing page; '/channel' is the auth-guarded channel app
- Visitors fill out a form (name, email, desired slug, description)
- Backend stores requests in Redis, exposed via GET /api/super-admin/channel-requests
- Super admin can approve (auto-creates channel + assigns owner) or reject requests
- Approval shows slug, owner email, and the channel link to copy and send manually
- Login redirects super_admin → /super-admin, others → /channel
- Logout navigates back to the landing page
- Super admin panel: new 'בקשות לערוצים' menu item with inline approve/reject table

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Security (data isolation):
- serveFile: verify meta.ChannelSlug matches URL slug — blocks cross-channel file access
- addNewPost (/import): validate slug against slugRegex to prevent key injection

Performance (50+ concurrent channels):
- Redis pool: PoolSize=100, MinIdleConns=10, retries, timeouts (was default 10)
- dbDeleteChannel: replace O(keyspace) SCAN with targeted pipeline using known
  key names + message IDs from sorted set — O(messages) instead of O(total keys)
- Scheduled messages: maintain sorted set "scheduled:due_channels" (score=next due time)
  so the goroutine only wakes channels with pending messages, not all channels every minute
- Lua getMessageRange: add max_scan_rounds=20 guard to prevent unbounded loops on sparse data

Rate limiting:
- File uploads: 30/min per channel (burst=10) via golang.org/x/time/rate
  returns HTTP 429 when exceeded

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Redis Streams (horizontal scaling):
- Replace pub/sub with Redis Streams for SSE events
  * publishEvent() uses XADD (MaxLen~1000, auto-trim)
  * getEvents() uses XREAD BLOCK with 5s timeout loop
  * Supports Last-Event-ID header: clients reconnect without missing events
  * Multiple backend instances can all serve SSE from the same stream
- Stream key: channel:{slug}:events (consistent naming convention)
- Stream keys added to dbDeleteChannel cleanup list

Redis Universal Client (Cluster/Sentinel support):
- rdb changed from *redis.Client to redis.UniversalClient
- NewUniversalClient: single addr = normal, multiple REDIS_ADDRS = cluster,
  REDIS_MASTER set = Sentinel — zero code changes needed to scale up

Pre-signed R2 URLs (backend off the file-serving path):
- r2PresignURL() generates 1-hour signed URLs via s3.NewPresignClient
- serveFile: public bucket → CDN redirect, private bucket → pre-signed URL,
  only falls back to backend proxy if presigning fails

HTTP ETags for message caching:
- touchLastModified() stores nano-timestamp on every message write/edit/delete
- getMessages returns ETag header; returns 304 Not Modified when client is current
- Eliminates redundant Lua script executions for unchanged channels

Per-user upload rate limiting:
- Rate limiter key is now userEmail:channelSlug instead of just slug
- Prevents one user from exhausting the channel's upload budget
- 30 uploads/min per user per channel (burst 10)

https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
27180781 and others added 30 commits August 17, 2026 14:33
Two separate paths rendered nothing at all for a visitor without a
session, in both cases leaving the router with no active route and the
console with no error:

- AuthGuard ended in `return false`, which cancels the navigation
  outright rather than redirecting. It now returns a UrlTree to /login.
  The 401 branch is also widened: loadUserInfo() resolving with an empty
  body means "anonymous" just as much as a 401 does, and a non-401
  failure says nothing about the session so the route is allowed to
  render and surface its own error.
- ChannelComponent awaited loadUserInfo() unguarded on the slug-less
  route. The throw aborted ngOnInit with slugReady=false and
  noChannel=false, a combination the template renders as an empty page.

The attribution strip sat at the top of the scrolling column, above the
feed, and the feed opens scrolled to the newest message — so on a
channel with any history it was thousands of pixels off screen. It now
sticks to the bottom of the column, inside the gap the composer lift
already reserves. Measured visible in all four viewport/composer
combinations; previously off screen in all four.

Feed images now carry loading="lazy" and decoding="async", and media
elements preload metadata only. On a 25-image channel this takes the
first paint from 25 image requests to 6, with all 25 still arriving
across a full scroll. Both places the feed can emit an <img> are
covered: the custom embed extension and the plain markdown renderer.
No width/height pair is emitted for uploaded images because the backend
does not record their dimensions.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Feed images load lazily, so without a known aspect ratio the page grows
as each picture lands and shoves the reader's position around. Nothing
downstream could fix that because the size was never recorded: a file
record held only URL, filename, MIME type and byte size.

uploadFile now reads the pixel dimensions out of the image header and
stores them on FileMetadata, returning them in FileResponse. PNG, JPEG
and GIF go through image.DecodeConfig; WebP is parsed by hand across its
three encodings (VP8, VP8L, VP8X) rather than adding golang.org/x/image
for three integers. Only the header is read, never the pixel data.

The size is measured after TinyPNG compression, since that returns a
re-encoded file and the recorded size must be the one the browser
actually receives.

On the client the size rides in the embed token as
`[image-embedded#800x600](url)` and is rendered as width/height
attributes. Bootstrap's .img-fluid is `max-width: 100%; height: auto`,
so the browser uses the pair purely as an aspect ratio: the picture
still fits the 300px box, but the box reserves the right height before
the bytes arrive.

Every fallback is the previous behaviour rather than a guess. The size
group in the token is optional, so messages written before this change
tokenize exactly as they did and render with the old flat width="300".
An unreadable header, a non-image, a truncated upload or an implausible
canvas all yield no dimensions at all — a guessed ratio would letterbox
or distort the picture, which is worse than the reflow. The size is
carried in the token rather than the URL so the src stays byte-identical
to the path the server handed out.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
index.html was served with no cache headers at all, so browsers and any
upstream proxy applied heuristic caching to the one file that must never
be stale. Every deploy renames the bundles by content hash, and this
document is the only thing that says which names are current. Held in a
cache past a deploy, it points at an /assets/main-<hash>.js that no
longer exists: the script 404s and app-root is left empty. That is a
white page with nothing in the console from the application, because the
application never started — and it appears for a visitor with a cold
cache while anyone holding a warm, self-consistent copy of the old
document and its old bundles keeps working.

index.html is now no-cache, must-revalidate. It is also rewritten per
deployment with the operator's title and analytics, which a shared cache
had no business storing either.

The content-hashed assets get a year and immutable, which is what makes
revalidating the small document on every visit cheap. Files under
/assets without a hash in the name are replaced in place rather than
renamed, so they revalidate after five minutes instead of being pinned.

Verified against a real production build: index.html and SPA routes
no-cache, main-<hash>.js and styles-<hash>.css immutable, favicon
max-age=300, and a request for a hashed bundle that does not exist
returns 404 — the failure this prevents.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
The strip moves back above the feed, where it was originally asked to
be, and stays there: sitting in the normal flow at the top of the column
it was invisible either way, because the feed opens scrolled to the
newest message and the `.columns` lift drags the top of the column under
the fixed header.

Sticky offsets resolve against the scrollport, which begins at the top of
the viewport behind the fixed header, so `top` has to clear the header
and nothing else. It deliberately does not compensate for the
`.columns { bottom: var(--input-height) }` lift: measurement shows that
lift does not move the pinned element, and adding it parked the strip a
composer's height below the header instead of flush against it.

The header height comes from a value the component measures and
publishes as --chrome-height, not from nb-theme(header-height). That
token only resolves inside nb-install-component(); used outside it the
whole declaration is dropped, which is how the first attempt ended up
with top: auto and a strip that was not pinned at all. Measuring also
survives a theme changing the token, and is re-read on resize and after
view checks because the header grows taller when the channel name wraps.

The host gets an opaque background: pinned means the feed now scrolls
underneath, and without one the messages read through the padding around
the pill.

Measured with a long feed scrolled to the end, in four configurations —
desktop and mobile, with and without a composer. The strip's top edge
lands at 76px in all four, exactly the header's bottom edge; previously
it was off screen in all four.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Two places to write in — the public landing page and each channel's admin
panel — one inbox for the super admin, and a thread so the answer lands
back where the question was asked.

Anonymous visitors can open a ticket. Someone deciding whether to open a
channel, or unable to sign in, is exactly the person who most needs to
reach the operator, so creation and the thread view sit outside
checkLogin. That leaves the problem of how an anonymous sender reads the
reply, and there is no mail infrastructure in this project to send it to
them: each ticket therefore mints an unguessable access token, returned
exactly once at creation and kept by the browser that sent it. Every
response is serialised through publicView, which clears the token, so it
can never travel back out through a listing or the operator's inbox.

A signed-in sender's name and email come from the session and are never
taken from the request body, so a ticket cannot be attributed to someone
else. Their threads are found by a per-email index rather than a scan of
every ticket, and that index is lowercased because Google does not return
a stable capitalisation.

Rate limiting keys by session email when there is one and by IP
otherwise, so a shared address cannot exhaust a signed-in user's quota.
It sits immediately before the write, so a message rejected for a missing
subject or a malformed address costs the sender nothing.

Bounds throughout, because each ticket is one Redis value read back in
full on every view: subject, body, name and email are truncated on write,
a thread stops at 100 messages, the inbox read is capped, and records
carry a TTL that any reply refreshes so an active conversation is never
cut off. The index drops entries whose bodies have expired instead of
surfacing them on every listing.

Message bodies render through interpolation, never markdown, so a ticket
cannot inject markup into the operator's panel. A wrong or missing token
answers 404 rather than 403, so it does not confirm that a ticket exists.

Verified end to end against a live server: correct token 200, wrong token
404, no token 404, unauthenticated inbox 401, and no accessToken in any
response body. Rate limiting refuses the fourth submission with an
accurate Retry-After while validation failures spend no quota. In the
browser, an anonymous visitor submits from the landing page, the ticket
appears under "my tickets", and after the operator answers the reply is
visible on return with the thread reopened for a response.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Findings from a full-system audit. Each was confirmed by reading the
code, and the two load defects are pinned by tests that were checked
against the old behaviour before the fix landed.

SSE starved the whole platform. Every viewer sits in a blocking XREAD,
which holds its pooled connection for the duration and is re-issued
immediately, so one viewer occupies one connection for as long as it is
connected — out of a shared PoolSize of 100. Roughly a hundred idle
readers consumed every connection in the process and logins, uploads and
message reads queued behind them until they timed out. Measured with 140
concurrent blocking reads: an ordinary request took 2.01s on the shared
pool and 345us once SSE was given its own, with a connection cap so the
viewer past the limit gets an immediate 503 instead of blocking on a
connection that frees only when somebody else leaves. One fan-out reader
per stream, rather than per viewer, remains the fix for viewer scale;
this is the isolation that makes the current design safe to run.

Deleting a message was reachable by CSRF. The route was registered on
GET as well as DELETE, and the session cookie is SameSite=Lax, which
deliberately still rides along on a top-level GET navigation — so
following a link was enough to delete a writer's message from any site
on the web. Lax withholds the cookie from cross-site DELETE, so the verb
was the entire defence. The only client has always used DELETE.

The storage quota was a check-then-act. enforceStorageQuota read
used_bytes and compared, but the counter only moved after the blob was
written, a window spanning an R2 PutObject with a 60-second budget.
Every upload starting inside it read the same value and passed a check
only one of them should have. Measured: 40 concurrent uploads against a
10-file quota stored 4x the quota. Bytes are now claimed with an atomic
INCRBY whose own post-increment total is judged, and released on every
failure path; the same 40 uploads now admit exactly 10.

Also: the API-import route is mounted outside channelMiddleware, so a
disabled channel kept ingesting posts and firing webhooks through the
owner's API key while every browser-facing route returned 403 — it now
checks the kill switch itself. A channel owner could strip a co-owner's
role, irreversibly since only a super admin can grant owner back; the
guard blocked promotion to owner but not removal of one. getFavicon
served a channel's logo blob outside every channel guard, ignoring the
disabled and require-auth flags. The support limiter was the one
limiter map absent from the idle sweep, and the only one an anonymous
caller can add unbounded keys to. Request bodies are now capped by
router middleware rather than at each of ~25 decode sites, so a handler
added later cannot forget it; multipart uploads keep their own limit.

Separately, the channel admin dialog could not be scrolled to its save
button. size="giant" pins the card to a fixed 44.25rem, which is taller
than the viewport on a phone and on a laptop once browser chrome is
subtracted; because the dialog sits in a fixed overlay, the page behind
it cannot scroll to reach the overflow, so the sticky save bar was
simply off screen. The card is now capped to the viewport and the body
scrolls instead. This one is reasoned from the compiled cascade rather
than measured: reproducing it needs an authenticated owner session,
which the harness could not stand up.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
The feed renders message text with [disableSanitizer]="true", so Angular
never gets a second look at the markup, and every renderer built HTML by
concatenating token values straight into attributes. Message text was
therefore executable. Verified by parsing the rendered output in a real
browser DOM and asking it what elements and attributes actually exist:
before this change 13 of 13 payloads produced live handlers — img[onerror],
audio[onerror], blockquote[onmouseover], a[href=javascript:],
a[href=data:], and a real <script> element through the quote branch —
and after it, 0 of 13. Any writer could have run script in the browser
of every viewer of their channel, including an owner's or a super
admin's, and the payload needs no attribute break at all in the quote
case.

Values are now HTML-escaped at every interpolation, and URLs bound for a
src or href go through a scheme check that accepts only http, https and
relative URLs — control characters are stripped first, since java\tscript:
is the same URL to a browser but not to a naive prefix test. Overriding
marked's link and image renderers had also discarded its own escaping and
its cleanUrl check, which is why plain markdown links were affected too.

Quoting an image was broken by the dimension work: a private copy of the
embed pattern in the message component still required "#]" immediately,
so [image-embedded#800x600] stopped being recognised and quoting an image
embedded raw markdown into the reply instead of "תמונה". The pattern is
now exported once and shared, so the two cannot drift again.

Support tickets lost replies. A ticket is one JSON blob holding its whole
thread, and all three mutating handlers did an unguarded read-modify-Set:
an operator answering while the requester replied meant both loaded the
same thread, both appended, and the second write silently discarded the
other's message — with a 200 for both. Measured with 12 concurrent
writers: 2 of 13 messages survived. They now run through a WATCH-guarded
transaction with jittered backoff, and all 13 survive; conditions that
must be judged against fresh state (a ticket closed since the handler
read it) are re-checked inside it.

Removing a file from a channel's index pulled the entire index over the
wire to locate one entry, because a member encodes "id:size". The caller
already holds the size, so it is now a direct ZREM with the scan kept
only as a fallback for a size mismatch — auto-cleanup calls this up to
200 times inside a single upload, which on a large channel was millions
of member transfers per request.

Auto-cleanup also counted a no-op delete as freed space: deleteFileByID
silently returns when a record is already deleted or when it loses its
claim to a concurrent caller, but the loop subtracted the file's size
regardless. Two near-quota uploads reading the same oldest-files list
could both be admitted against bytes only one of them freed. It now
returns the bytes it actually released.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
The webhook URL is written by a channel owner, who on a self-service
platform is an untrusted party, and it was fetched with no validation
and the default redirect policy. An owner could point it at the cloud
metadata service or the platform's own loopback interface and have the
server issue requests from inside its trust boundary, reading the logged
response code as an internal port scanner. Connections are now refused
at the socket level via Dialer.Control, which runs after DNS resolution
on the address actually being dialled — so it covers redirects and DNS
rebinding, not just the literal URL — plus a scheme check before the
request is built. Loopback, RFC1918, link-local, CGNAT, multicast, IPv6
ULA and IPv4-mapped forms of all of those are refused; 21 cases pinned,
including that ordinary public destinations still deliver.
WEBHOOK_ALLOW_PRIVATE=1 restores the old behaviour for an operator whose
whole platform sits inside a trusted network.

The /messages Lua script bounded its scan rounds but not its work. Each
round does one HGETALL per entry, and a soft-deleted entry is not
appended for a non-admin viewer, so the batch size never shrank: a page
of 100 over a run of tombstones was 20 x 100 HGETALLs inside one atomic
script, and Redis is single-threaded, so every other tenant queued behind
it — roughly 2000 Redis operations per unauthenticated request. The cap
is now on entries examined. A 900-entry tombstone run returns in 1.7ms.
The trade-off is recorded in the script: a run longer than the budget
ends a page early rather than reaching the live messages beneath it, as
the old round cap also did at its own limit; removing tombstones from
that index is the change that would retire the cap entirely.

resetStatistics issued about 133 sequential deletes per channel after an
N+1 channel listing, all with their errors discarded — a few thousand
channels ran past the handler's own deadline and left statistics
half-reset while reporting success. The deletes are pipelined per
channel, the deadline is checked, and running out of time now says so.

The SSE statistics series grew a member on every connect and every
disconnect, for every channel, with no cap and no TTL — nothing but a
channel delete or a statistics reset ever removed one, so churning
mobile clients leaked Redis memory indefinitely. It is now trimmed to
the 1000 points the read path actually returns, and expires.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Blobs are deduplicated by hash across every tenant, so the reference
count is the only thing standing between one channel deleting its copy
and another channel's file vanishing. It was claimed after the existence
check, leaving a window in which a concurrent delete could take the count
to zero and remove the very blob this upload had just decided it did not
need to write — leaving a file record, charged against the channel's
quota and present in every index, that 404s forever. The claim now comes
first, so any concurrent delete sees it, and is released if the upload
then fails.

That also exposes the legacy case: a blob written before reference
counting has no counter, so an upload deduplicating against it created
the counter at 1 while two records referenced it, and the new tenant
deleting its own copy destroyed the original tenant's file. When an
upload's own claim comes back as 1 but the blob was already present, it
now counts the untracked reference. Erring this way can at worst strand
a blob nothing points at; the other direction loses somebody's data.

The per-account channel cap was a check-then-act — the count is read from
the roles list, but the role that would make a new channel count is only
assigned after creation, and the creation limiter's burst of 3 lets three
requests through together, so an account capped at five could reach
seven. Creates are now serialised per owner with a short owner-scoped
lock; the atomic slug claim never helped here because the slugs differ.

The scheduler read every due channel with no limit and declared a 30
second budget it then never consulted, since every operation inside the
loop built its own context. A post-outage catch-up, where every channel
is due at once, ran far past the budget while the next tick started
anyway and piled up on top of it. The read is bounded and the deadline
is checked; whatever does not fit is still due next tick.

Recording views issued one round trip per message on every page of
/messages — at a page size of 100, the most-read endpoint became 100
sequential writes per read, competing for the pool the rest of the
application shares. It is one pipeline now.

Finally, measuring the header height in ngAfterViewChecked forced a
synchronous layout on every change-detection pass, and change detection
runs on every scroll frame because the feed listens for scroll. A
ResizeObserver now does the measuring, and the hook only does a cheap
identity check to notice the header appearing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Each connected viewer ran its own blocking XREAD, which holds a pooled
connection for the whole block and is re-issued immediately — so viewer
count and Redis connection count were the same number, and the pool was
the hard ceiling on concurrent viewers. Isolating SSE onto its own pool
stopped that from taking the rest of the platform down with it, but the
ceiling remained.

There is now one reader per channel stream, fanning out in process to
every viewer of that channel. A channel with ten thousand readers costs
one connection, and what the pool bounds is how many distinct channels
can have live viewers at once. The per-instance viewer cap is no longer
tied to the pool at all — a viewer costs a goroutine, a socket and a
256-event buffer — so it moves from 480 to 20000.

Measured against the running server: with 300 concurrent viewers Redis
held 108 connections; with 600 it held 108, a delta of zero, and all 600
received the published event. Connection count is now independent of
viewer count. 200 viewers of one channel produce exactly one hub.

The hub reads from the tip of the stream, so a reconnecting viewer's
history cannot come from it. Such a viewer subscribes first and then
replays from the stream itself with a bounded XRANGE, so an event
published during the replay is buffered rather than lost, and delivery
is deduplicated on stream id. Verified over real HTTP: a client
reconnecting with Last-Event-ID receives exactly the events after that
id and not the one it already had.

A viewer that stops draining is dropped rather than allowed to block the
hub and stall delivery for everyone else on the channel; its browser
reconnects and resumes from Last-Event-ID. A hub is retired when its
last viewer leaves, so connections do not accumulate per channel ever
visited. The concurrency tests pass under -race.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
Verifying the settings dialog end to end turned up a flaw in its own
fix: the rule giving the content column min-height:0 and its own
scrollbar was written against .panel-content, but the column only ever
carried "flex-grow-1 overflow-auto". The selector matched nothing, so
that half of the fix was dead and the height cap was doing all the work.
The class is now on the element, and the menu's companion selector is
scoped through :host rather than the card, which is what it meant.

Verified by driving the real dialog in a browser across four viewports,
with the settings form actually rendered rather than stubbed empty. The
fix now measurably applies — computed min-height is 0px, and 2692px of
settings content scrolls inside a 518px column on a 360x600 phone — and
the save button is on screen and hit-testable in all four.

The same harness run against the pre-fix stylesheet reproduces the
original report and explains why it was not universally broken: the card
is pinned to a fixed 708px, so it overflows only viewports shorter than
that. On a 1366x640 laptop and a 360x600 phone the save button was
unreachable even after scrollIntoView, while on a 1280x900 desktop and a
390x844 phone it was fine.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx
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.

2 participants