feat: עיצוב מחדש של מסך הגדרות הניהול + captain-definition - #23
Open
27180781 wants to merge 114 commits into
Open
feat: עיצוב מחדש של מסך הגדרות הניהול + captain-definition#2327180781 wants to merge 114 commits into
27180781 wants to merge 114 commits into
Conversation
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
…hannel-request form, which no longer exists
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
regex-replace(שדות pattern/replace נפרדים), הדבקת JSON של Service Account שמפצל אוטומטית לכל שדותfcm_json_*, ומקטע "הגדרות מתקדמות" מתקפל בתחתית שמאפשר עדיין הוספת key/value חופשי (גם מציג הגדרות לא מוכרות שכבר קיימות).[{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
1/לא קיים).regex-replaceולוודא שהם נשמרים בפורמטpattern#replaceופועלים בפרסום הודעות.fcm_json_*מתמלאים.🤖 Generated with Claude Code