diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..07d9f70b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Build artifacts and dependencies — these are regenerated inside the image. +# Without this file the whole 600MB+ node_modules tree is sent to the Docker +# daemon as build context and copied into the builder layer, which exhausts +# disk space on the build host. +frontend/node_modules/ +frontend/dist/ +frontend/.angular/ + +# Go build output and debug binaries +backend/the-channel +backend/channel +backend/__debug* + +# Local runtime state — must never leak into an image +caddy_data/ +caddy_config/ +redis_data/ +channel_data/ +data/ +.env +# A developer's redis-server writes its snapshot/AOF into the cwd it was +# started from (backend/ during tests); `COPY ./backend .` would ship it. +**/dump.rdb +**/appendonly.aof +**/appendonlydir/ + +# VCS and editor noise +.git/ +.gitignore +.vscode/ +.idea/ +**/.DS_Store +**/.ds_store + +# Docs and local-only compose config +*.md +docker-compose.yml +kvrocks.conf +Caddyfile diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..fc6f4118 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: build + +on: + pull_request: + push: + branches: [master] + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run build + + backend: + runs-on: ubuntu-latest + services: + redis: + image: redis:7 + ports: ["6379:6379"] + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + - run: go vet ./... + - run: go test ./... + env: + REDIS_ADDR: 127.0.0.1:6379 diff --git a/.gitignore b/.gitignore index c462de35..ca0d9e22 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,18 @@ frontend/node_modules/ frontend/dist/* package-lock.json backend/__debug* +backend/channel caddy_data/ redis_data/ channel_data/* caddy_config/ data/kvrocks.sock .env -.ds_store \ No newline at end of file +.ds_store +# Redis writes its snapshot into the cwd of whoever started it (tests) +dump.rdb +**/dump.rdb +appendonly.aof +**/appendonly.aof +appendonlydir/ +**/appendonlydir/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d2ee15c2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,330 @@ +# TheChannel — Multi-Tenant System — Change Log + +## Overview + +This document describes all changes made to transform TheChannel from a single-channel application into a **multi-tenant platform** where each user can own a private channel, with a central super-admin panel for full control. + +--- + +## Architecture Changes + +### Multi-Tenant Backend (Go) + +All data is now stored with per-channel Redis key prefixes: + +| Old key pattern | New key pattern | +|---|---| +| `messages:{id}` | `channel:{slug}:messages:{id}` | +| `m_times` | `channel:{slug}:m_times` | +| `settings:list` | `channel:{slug}:settings` | +| (none) | `global:settings` (FCM/VAPID) | + +A single Go backend instance and a single Redis/Kvrocks instance serve **all channels**. No new CapRover projects needed per channel. + +--- + +## New Files + +### Backend + +| File | Description | +|---|---| +| `backend/channels.go` | Channel CRUD, features management, middleware | +| `backend/storage.go` | Cloudflare R2 client (`initR2`, `r2Upload`, `r2Download`, `r2Delete`, `r2Exists`) | +| `backend/storage_handlers.go` | Storage quota HTTP handlers for channel admin and super admin | + +### Frontend + +| File | Description | +|---|---| +| `frontend/src/app/guards/super-admin.guard.ts` | Route guard for `/super-admin` — only allows `globalRole === 'super_admin'` | +| `frontend/src/app/services/super-admin.service.ts` | All super admin API calls | +| `frontend/src/app/components/super-admin/super-admin-panel.component.*` | Main super admin shell | +| `frontend/src/app/components/super-admin/channels/channels-list.component.*` | Channel list with CRUD actions | +| `frontend/src/app/components/super-admin/channels/channel-features.component.*` | Per-channel feature toggles (13 features) | +| `frontend/src/app/components/super-admin/channels/channel-users.component.*` | Per-channel user/role management | +| `frontend/src/app/components/super-admin/global-ads/global-ads.component.*` | Global iframe-ads config + lock | +| `frontend/src/app/components/super-admin/global-magnet/global-magnet.component.*` | Global Magnet ads config + frequency + lock | +| `frontend/src/app/components/super-admin/global-users/global-users.component.*` | Read-only global user list with roles | +| `frontend/src/app/components/super-admin/global-settings/global-settings.component.*` | Global key-value settings editor (FCM/VAPID) | +| `frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.*` | Magnet stats + statistics reset | +| `frontend/src/app/components/super-admin/storage/super-admin-storage.component.ts` | Per-channel storage quota + usage bar (super admin view) | +| `frontend/src/app/components/super-admin/global-storage/global-storage.component.ts` | Global default storage quota editor | +| `frontend/src/app/components/admin/storage/storage.component.ts` | Channel admin storage view — usage bar, warnings, auto-cleanup toggle | + +--- + +## Modified Files + +### Backend + +#### `backend/privileges.go` — Complete rewrite +- New role system: `GlobalRole` (`super_admin`) and `ChannelRole` (`owner`, `moderator`, `writer`) +- `User` struct now holds `GlobalRole` and `ChannelRoles map[string]ChannelRole` +- Middleware: `requireSuperAdmin`, `protectedWithChannelRole` + +#### `backend/auth.go` +- `Session` struct updated with `GlobalRole` and `ChannelRoles` +- `registeringEmail(slug, email string)` — `slug=""` registers globally, slug set registers into a channel + +#### `backend/db.go` — Complete rewrite +- All message/settings functions are now channel-scoped (slug prefix) +- Channel CRUD: `dbCreateChannel`, `dbGetChannel`, `dbListChannels`, `dbDeleteChannel`, `dbSetChannelFeatures`, `dbAssignChannelRole` +- Storage quota functions: + - `dbGetGlobalStorageQuota` / `dbSetGlobalStorageQuota` + - `dbGetChannelStorageQuota` / `dbSetChannelStorageQuota` (0 = use global) + - `dbGetEffectiveStorageQuota` (returns channel-specific or global default) + - `dbGetChannelStorageUsed` / `dbIncrChannelStorageUsed` / `dbDecrChannelStorageUsed` + - `dbAddChannelFile` / `dbRemoveChannelFile` (sorted set by upload timestamp) + - `dbGetOldestChannelFiles` (for auto-cleanup) + - `dbGetChannelAutoCleanup` / `dbSetChannelAutoCleanup` + - `dbIncrFileHashRefs` / `dbDecrFileHashRefs` (deduplication reference counter) +- Global ads/magnet config storage: `dbGetGlobalMagnetConfig`, `dbSetGlobalMagnetConfig`, `dbGetGlobalAdsConfig`, `dbSetGlobalAdsConfig` + +#### `backend/files.go` +- `FileMetadata` struct: added `Size int64` and `ChannelSlug string` +- `dbSaveFileMetadata` / `dbGetFileMetadata` — now uses Redis JSON; YAML fallback for legacy local files +- `uploadFile` — reads bytes to memory, checks quota, deduplicates by SHA-256 hash, increments ref counter, tracks in channel sorted set +- `reserveStorageQuota` — checks quota before upload; runs auto-cleanup (target: 80% usage) if enabled +- `deleteFileByID` — marks deleted, decrements storage counter, removes from R2/disk only when refs reach 0 +- **TinyPNG integration**: `compressWithTinyPng(ctx, apiKey, data, mimeType)` — compresses PNG/JPEG/WebP via TinyPNG API before upload if `tinypng_api_key` is set in channel settings + +#### `backend/settings.go` +- `SettingConfig` struct: added `TinyPngApiKey string` +- `ToConfig()`: added `"tinypng_api_key"` case + +#### `backend/ads.go` +- `isChannelMagnetLocked` / `isChannelAdsLocked` — checks if super admin locked ads for this channel +- `getMagnetAdsSettings` / `getAdsSettings` — returns global config if locked, channel config otherwise +- `getGlobalMagnetConfig` / `setGlobalMagnetConfig` — super admin global Magnet config +- `getGlobalAdsConfig` / `setGlobalAdsConfig` — super admin global iframe-ads config +- `syncMagnetLockFlags` / `syncAdsLockFlags` — goroutines that update `MagnetLockedByAdmin`/`AdsLockedByAdmin` on all channels when global config changes + +#### `backend/main.go` — Full routing rewrite + +``` +/auth/google +/auth/login +/auth/logout +/favicon.ico, /assets/favicon.ico +/firebase-messaging-sw.js + +(login required, global) + GET /api/user-info + POST /api/channels/create self-service channel creation + GET /api/channels/slug-available + +/api/support/* (no login; an anonymous thread is guarded by its token) + POST /tickets + GET /tickets/{id} + POST /tickets/{id}/reply + GET /my-tickets (login required) + +/api/super-admin/* (login + requireSuperAdmin) + GET /channels + POST /channels/create + GET /channels/{slug} + DELETE /channels/{slug} + PUT /channels/{slug}/features + GET /channels/{slug}/users + POST /channels/{slug}/users + GET /channels/{slug}/storage + PUT /channels/{slug}/storage + GET /users/list + POST /users/set + GET /global-settings/get + POST /global-settings/set + GET /ads/config + POST /ads/config + GET /magnet/config + POST /magnet/config + GET /magnet/stats + GET /storage/config + POST /storage/config + POST /statistics/reset + GET /support/tickets + POST /support/tickets/{id}/reply + POST /support/tickets/{id}/status + GET /channel-requests + POST /channel-requests/{id}/approve + POST /channel-requests/{id}/reject + +/api/channel/{slug}/import/post (API key auth) + +/api/channel/{slug}/* (channelMiddleware + channelIfRequireAuth) + GET /info + GET /messages + GET /events + GET /files/{fileid} + GET /emojis/list + GET /notifications-config + GET /ads/settings + GET /ads/magnet + (login required): + POST /notifications-subscribe + POST /reactions/set-reactions + POST /messages/report + GET /user-info + + /admin/* (channel role: writer < moderator < owner; a higher tier includes the lower) + writer: + POST /new + POST /edit-message + DELETE /delete-message/{id} + POST /upload + GET /scheduled-messages/get + POST /scheduled-messages/update + moderator: + POST /edit-channel-info + GET /statistics + POST /set-emojis + GET /reports/get + POST /reports/set + owner: + GET /settings/get + POST /settings/set + GET /users/get + POST /users/set + GET /storage + POST /storage/auto-cleanup +``` + +`backend/main.go` is the authoritative route table; the block above mirrors it. + +### Frontend + +#### `frontend/src/app/models/channel.model.ts` +- Added `slug?: string` field + +#### `frontend/src/app/models/user.model.ts` +- Added `globalRole: string` and `channelRoles: Record` + +#### `frontend/src/app/app.routes.ts` +- Added `/super-admin` route guarded by `AuthGuard` + `SuperAdminGuard` + +#### `frontend/src/app/components/admin/admin-panel.component.ts` +- Added `StorageComponent` import +- Added `"אחסון"` menu item with `hard-drive-outline` icon +- Added `readonly storage = "storage"` constant +- Added `channelSlug` getter via `ChatService` +- Added `case 'hard-drive-outline'` in menu switch + +#### `frontend/src/app/components/admin/admin-panel.component.html` +- Added `@case (storage)` rendering `` + +#### `frontend/src/app/components/admin/settings/settings.schema.ts` +- Added new category **"אחסון ומדיה"** with `tinypng_api_key` field (password type) + +--- + +## Features Added + +### 1. Multi-Tenant Channels +- Each user gets their own private channel with a unique slug +- Single Go + Redis instance serves all channels +- No CapRover project needed per channel + +### 2. Role System +- **GlobalRole**: `super_admin` +- **ChannelRole**: `owner` > `moderator` > `writer` +- All API routes protected with appropriate role middleware + +### 3. Super Admin Panel (`/super-admin`) +- Accessible only to users with `globalRole: "super_admin"` +- **Channels**: List all channels, create, delete, edit features, manage users, manage storage +- **Iframe Ads**: Global iframe-ad src/width with option to lock all channels or specific channels +- **Magnet Ads**: Global Magnet config (snippet, mode, frequency settings) with lock option +- **Global Storage**: Set the default storage quota (GB) for all channels +- **Users**: Read-only list of all users with their global and channel roles +- **Global Settings**: FCM/VAPID key-value editor +- **Statistics**: View Magnet stats, reset all statistics + +### 4. Storage Quota System +- Super admin sets a **global default quota** (default: 5 GB per channel) +- Super admin can **override per-channel** from the channels list → "אחסון" +- Channel owners see **usage bar** with color-coded status: + - Green (`ok`): below 80% used + - Orange (`warning`): 80% up to, not including, 90% + - Red (`critical`): 90% and above +- **Auto-cleanup toggle**: When enabled, an upload that would exceed the quota first deletes the channel's oldest files (at most the 200 oldest, never the current logo), aiming for 80% usage; if that does not free enough space the upload is still refused with `storage quota exceeded` +- File deduplication: identical files (same SHA-256 hash) share one physical copy; storage only freed when last reference is removed + +### 5. Cloudflare R2 Storage +- All media uploads go to R2 (S3-compatible) +- Local disk used as fallback when R2 is not configured +- Configure via environment variables: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_PUBLIC_URL` +- Files stored at path: `files/{hash[0:2]}/{hash[2:4]}/{hash}` + +### 6. TinyPNG Image Compression +- Channel owners add their TinyPNG API key in **Admin → הגדרות → אחסון ומדיה** +- When set, PNG/JPEG/WebP images are automatically compressed via TinyPNG API **before** upload +- Compressed file is what gets stored and counted toward quota — saves significant space +- Falls back silently to original file if compression fails or API is unavailable +- Non-image files (video, documents, etc.) are never sent to TinyPNG + +### 7. Global Ads Override +- Super admin can push **global iframe-ads** settings to all channels or specific channels +- Super admin can push **global Magnet ads** settings (including frequency controls) to all channels or specific channels +- Locked channels display the global config and cannot be overridden by channel owners + +--- + +## Environment Variables + +Add to your `.env` / CapRover environment: + +``` +# Cloudflare R2 +# Leave all four unset to keep files on local disk; a value starting with +# "your_" is treated as unset. No inline comments: env-file loaders keep them +# as part of the value. +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME= +# optional, for direct CDN links +R2_PUBLIC_URL= +``` + +--- + +## Dependencies Added + +### Go (`backend/go.mod`) +- `github.com/aws/aws-sdk-go-v2` — AWS SDK v2 (used for R2 via S3-compatible API) +- `github.com/aws/aws-sdk-go-v2/credentials` +- `github.com/aws/aws-sdk-go-v2/service/s3` + +--- + +## UX overhaul (October 2026) + +A visual and structural rework of the whole frontend, plus two backend additions. Behaviour and endpoints are unchanged unless listed here. + +### Backend +- `GET /api/my-channels` — every channel the signed-in user holds a role on (name, logo, role, disabled flag, participant count), sorted owners first. Backs the "my channels" page and the channel switcher. +- gzip compression (chi `middleware.Compress`) for HTML, CSS, JavaScript, JSON and SVG. The SSE stream is routed around the middleware entirely (`backend/compress.go`): chi v1's compressing writer has no `SetWriteDeadline`, so wrapped, the 30 s write deadline `getEvents` arms could never be set and a dead viewer kept its connection slot until TCP timed out. Media is excluded by content type. +- Link previews: `serveSpaFile` injects `` and Open Graph tags (`backend/seo.go`). `/channel/<slug>` carries the channel's name, description and logo; every other page the platform card (`/assets/og.jpg`). Private and disabled channels get the generic card and leak nothing. `.webmanifest` is registered as `application/manifest+json`. +- Support tickets: the access token opens anonymous tickets only. A signed-in ticket is reachable by its session alone — tokens issued to signed-in senders before this change (still in the browser's localStorage after logout) no longer open them. Test. + +### Frontend +- Brand theme registered through Nebular tokens (`frontend/src/themes.scss`); every screen reads colours, radii and shadows from it. The prebuilt Nebular `default.css` is no longer loaded (it duplicated the compiled theme), Bootstrap is compiled from a subset (`frontend/src/bootstrap-subset.scss`), the Bootstrap Icons CDN stylesheet and the 24 MB NotoColorEmoji font are gone (system emoji font). +- Lazy routes: landing, login, super-admin and the new manage page load on demand; the channel page stays in the initial bundle. Firebase is imported only on channels with push enabled. +- The channel admin panel is now a page: `/channel/:slug/manage/:tab` (ChannelManagerGuard), with a channel switcher. The header's user menu lists the user's other channels for one-tap switching. +- "My channels" hub at `/channel` with names, logos, roles, share buttons and the 5-channel cap explained; one-moment channel creation with a live URL preview and a share-ready success screen. +- Shared primitives: `ShareService` (copy / WhatsApp / OS share), `ConfirmService` + `ConfirmDialogComponent` (replaces `window.confirm` / `alert`), `MyChannelsService`. +- Message times use `Intl` instead of moment. +- Only the brand theme is emitted to CSS (`$nb-enabled-themes: (custom)`): the unused `default` parent block was 211 KB of custom properties in every visitor's stylesheet. Type-scale and motion tokens (`--text-*`, `--motion-*`, `--ease-out`) in `styles.scss`; buttons transition and press. +- Toasts stay 5 s, close on click and do not stack duplicates (`app.config.ts`). +- Brand assets in `frontend/public/`: SVG favicon, apple-touch-icon, 512px icon, web app manifest, Open Graph card. +- Eva icons: only the names in use are registered (`src/app/eva-icons.subset.ts`, generated by `node scripts/gen-eva-subset.mjs`; `--check` fails when stale) instead of `NbEvaIconsModule`'s whole pack. A new icon name needs a re-run of the script, otherwise it renders empty. `scripts/check-bootstrap-classes.mjs <dist/browser>` verifies every Bootstrap-looking template class exists in the compiled CSS. +- Dependencies dropped: `@ng-bootstrap/ng-bootstrap`, `@popperjs/core` (the reaction popover is Nebular's now) and `moment` (replaced by `Intl`). +- Channel open: the route guard fetches `/info` through `ChatService` in parallel with the session check, and `initChannel` keeps that answer (`clearCache(slug)`), so the header no longer requests `/info` a second time and the first `/messages` starts one round trip earlier. +- Landing CTA goes straight to Google (`loginWithGoogle()`); `/login` remains the OAuth callback and the fallback when the hand-off fails. +- Phones: header height 4.25rem (from 4.75rem) via a `--header-height` override in `styles.scss`; the composer toolbar wraps instead of scrolling sideways; the super-admin chip navigation scrolls inside its box instead of widening the page. +- Document titles everywhere, brand name "הערוץ" throughout. +- Nebular's tooltip, popover and context-menu directives each provide the same `NbDynamicOverlayHandler` on their host element, so two of them on one element share a single handler and only the directive built last keeps its trigger. A tooltip beside the header's user menu, share menu and the message ⋮ menu had left those menus dead (only the tooltip showed). The tooltips are gone from those elements; never pair `nbTooltip` with `nbPopover`/`nbContextMenu` on one element. +- Adversarial review fixes: the message ⋮ menu is keyboard-operable (focus moves in, arrows/Home/End/Escape, focus returns); quoting a message while an edit is open prepends to the composer instead of rewriting the entry being edited (a scheduled entry is found on the server by its text, so the save used to fail); editing a scheduled message no longer crashes the time picker on the wire-format timestamp; sending from markdown preview returns to the editor; Back from Google's account picker (bfcache restore) resets the login and landing buttons; the manage page mounts only the sections the role may open; a dead channel link keeps the guard's not-found verdict instead of flashing the chat shell; slug verdict chips, composer strip buttons and the feed banner meet contrast and tap-target sizes; copy fixes (support box named "פנייה לתמיכה" everywhere, channel-limit text, magnet lock text, role hints, SET.md labels). +- Browser sweep fixes (28 findings reproduced with Playwright against a local build, each fixed): switching channels in-app (user menu, Back) now reloads the feed, the SSE stream and the tab title instead of keeping the previous channel's; the header share menu, user menu, about card and the formatting-help popover are keyboard-operable (focus moves in, arrows/Home/End, Enter activates, Escape and Tab close and return focus, aria-expanded on the triggers, menu roles); the header shows the public name; the create form keeps the field, the preview and the verdict in step (illegal characters never show, a cleared address falls back to the name, Enter waits for the live address check), and ?new=1 follows what is shown; a not-found/disabled card never mounts the chat shell or fires requests for the missing channel on "home"; the super-admin guard sends a signed-out visitor to the login page; a message you just sent scrolls into view and the unread marker is placed only after a real last-read message; the feed's top sits below the fixed header and strip; quote previews keep link titles; same-document #message links scroll; the support box clears its alerts and validates the email format in place; the global-ad width field loads 300 instead of 0; storage figures read left-to-right; the channels table keeps its action icons on one line and shows filter-specific empty states; toasts fit a phone width; login and about tap targets meet 40px; the copy-link fallback shows the link to copy by hand. +- Re-verification of those 28 in the browser found four residuals, fixed: the create form judged "address typed" on the raw input, so a field that only ever held Hebrew or punctuation blocked the name from deriving the address; a signed-in visitor navigating in-app to a missing/disabled channel still mounted the chat shell and fired eight requests (the route guard now settles the /info probe for signed-in visitors too) while the card itself lost its signed-in buttons (the session is loaded on the card as well); the feed's top padding sat inside a Sass `@if meta.variable-exists(--input-height)` that never emitted (a CSS custom property is not a Sass variable), so the first day separator and message still sat under the strip; and a session that ends mid-edit in the super-admin panel or the manage page now says so and goes to sign in (new `sessionExpiredInterceptor`), instead of a generic "save failed" — with the cached user dropped first, or the login page bounced the operator straight back in a loop. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1e4ce53a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# מערכת הערוצים (fork) + +> קובץ קצר בכוונה, כי המאגר הזה ציבורי. ההקשר של הפרויקט נמצא אצל הבעלים, במאגר פרטי. + +- **מיזוג ל-`master` הוא פריסה.** CapRover בונה ופורס לבד כל push ל-`master`, בלי שלב נוסף. לכן כל שינוי נכנס ב-PR, + והמיזוג רק אחרי אישור מפורש של בעל המאגר. אחרי מיזוג מוודאים מול האתר החי שהגרסה החדשה עלתה, ולא מניחים. +- **תשובה של מנהל מוצגת תמיד "ניהול"**, אף פעם לא שם או חשבון התחברות. כלל קבוע של הבעלים. +- טקסטים למשתמש בעברית ו-RTL. + +## בנייה ובדיקות + +```bash +# Backend (Go): needs a local Redis +redis-server --port 6379 --daemonize yes +cd backend && GOFLAGS=-mod=mod GOTOOLCHAIN=auto REDIS_ADDR=127.0.0.1:6379 go test ./... + +# Frontend (Angular) +cd frontend && npx ng build --configuration production +``` + +ה-workflow `build` ב-GitHub Actions מריץ את שתיהן על כל PR ועל master. סביבה שאין בה גישה ל-npm סומכת עליו; אין למזג PR שהבנייה בו אדומה. diff --git a/Caddyfile b/Caddyfile index bfe48373..0715a8bf 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,3 +1,10 @@ * { - reverse_proxy backend:3000 -} \ No newline at end of file + reverse_proxy backend:3000 { + # The backend's RealIP middleware takes the client address from + # X-Real-IP (and would also honour True-Client-IP). Both must come from + # this proxy and never from the client, or anyone could pick the + # address every per-IP rate limit and log line sees. + header_up X-Real-IP {remote_host} + header_up -True-Client-IP + } +} diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 00000000..4f0d6a33 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,248 @@ +# Deployment + +Operator notes for running TheChannel. The reference deployment is CapRover with +the database as its own app; `docker-compose.yml` in this repo describes a +different, single-host layout and is **not** what CapRover uses — see +[The `/app/files` trap](#the-appfiles-trap) for why that difference matters. + +CapRover builds from `captain-definition`, which points at the `Dockerfile`. +Changing an environment variable restarts the container with the **existing** +image; it does not rebuild. New code requires a Deploy, not a restart. + +--- + +## 1. The database app + +The backend reaches the database purely through environment variables, so the +database living in its own app is a configuration fact and needs no code change. + +| Variable | Value on CapRover | +|---|---| +| `REDIS_ADDR` | `srv-captain--<dbapp>:6666` — exactly one address, see below | +| `REDIS_PROTOCOL` | `tcp` | +| `REDIS_PASSWORD` | only if the database requires one | +| `REDIS_MASTER` | leave unset — Redis Sentinel is not supported, see below | + +`srv-captain--<appname>` is how CapRover apps reach each other on the internal +network. `6666` is Apache Kvrocks' default port — `kvrocks.conf` in this repo +sets no `port` line, so the default applies. If you changed it, use your value. + +**`REDIS_PROTOCOL` must be `tcp`.** Note that `sample.env` shows + +``` +REDIS_ADDR=/app/data/kvrocks.sock +REDIS_PROTOCOL=unix +``` + +That is correct only for the docker-compose layout, where both containers share +a volume. A unix socket cannot cross container boundaries, so copying those two +lines into a CapRover setup with a separate database app cannot work. The code +defaults `REDIS_PROTOCOL` to `tcp` when unset, but an explicitly wrong value is +fatal at boot. The log line to look for is +`Session store init failed (REDIS_PROTOCOL="unix", REDIS_ADDR="srv-captain--<dbapp>:6666"): dial unix srv-captain--<dbapp>:6666: connect: no such file or directory` +(the session store is the only client that honours `REDIS_PROTOCOL`; the +go-redis clients pick the network from the address themselves). + +**One address only — no Sentinel, no cluster.** `REDIS_ADDR` (singular) is a +single `host:port` or socket path. The session store (`redistore`, in +`backend/main.go`) dials that string verbatim: it cannot follow a Sentinel +master and cannot take a comma-separated address list, even though the go-redis +clients could. Rather than let the session store silently talk to a different +server than the rest of the backend, the backend refuses to boot when +`REDIS_MASTER` is set or `REDIS_ADDR` contains a comma, and logs a line saying +which of the two it objected to. Run one Kvrocks/Redis instance and point +`REDIS_ADDR` at it. + +### Verify the database has a persistent volume + +The database app must have a persistent directory mapped to its data directory +(`/var/lib/kvrocks` for Kvrocks). Without it, every channel, message, role and +setting is destroyed on the next redeploy of the **database** app. + +In CapRover: open the database app → App Configs → Persistent Directories, and +confirm one is mapped to the data path. If the list is empty, add it before you +put anything real in the system. + +--- + +## 2. Cloudflare R2 (file storage) + +Set all four of these, or R2 stays off entirely and files are written to local +disk: + +| Variable | Where it comes from | +|---|---| +| `R2_ACCOUNT_ID` | Cloudflare dashboard → R2 → Account ID (right-hand panel) | +| `R2_ACCESS_KEY_ID` | R2 → Manage API Tokens → Create API Token | +| `R2_SECRET_ACCESS_KEY` | shown once, alongside the Access Key ID | +| `R2_BUCKET_NAME` | the bucket you created | + +The token must have **Object Read & Write** permission and its scope must +include the bucket. A read-only or wrongly scoped token authenticates fine and +then fails every upload with `AccessDenied` — see [Diagnosing an upload +failure](#diagnosing-an-upload-failure). + +**Take the Access Key ID and Secret Access Key, not the token value.** The +create-token page shows a `cfat_…` token first and the S3 credential pair below +it. The `cfat_` token is a Cloudflare REST API bearer token and is not used by +this application, which speaks the S3 protocol. + +### `R2_PUBLIC_URL` — optional, and usually leave it unset + +| Unset (recommended) | Set | +|---|---| +| The backend issues a pre-signed URL valid for one hour and redirects the browser to it. The bucket stays private. | The backend redirects to the public bucket URL. | + +A public bucket URL is readable by anyone who has the link, **regardless of the +channel's "require authentication to view files" setting**. Setting this +silently overrides that privacy choice for every channel on the platform. Leave +it unset unless you specifically want a public CDN. + +No Cloudflare Worker is required in either mode. + +### Migrating files that already exist + +Blobs written while R2 was off are on local disk only. A one-shot migration +copies them into the bucket on the first boot after R2 is configured. Its log +lines look like: + +``` +migrations: running 2026-08-local-files-to-r2 +migrations: local-files-to-R2: 12 uploaded, 0 already in R2, 0 missing locally, 0 malformed, 0 failed, out of 12 file records across 3 channels +migrations: 2026-08-local-files-to-r2 completed +``` + +`0 failed` and `completed` together mean it is done. Anything else leaves the +migration **unmarked**, so it continues on the next boot — a failed or partial +run costs nothing and loses nothing. + +Each migration gets its own budget of **2 minutes per boot** (`migrationBudget` +in `backend/migrations.go`), so a slow one cannot eat the time of the ones after +it. A run that hits its budget stops where it is and is **not** marked applied. +The R2 copy records every blob it has already uploaded in the Redis set +`migrations:r2-copied`, so the next boot only processes what is left rather +than starting over, and a large corpus finishes across a few restarts. Expect +`migrations: running 2026-08-local-files-to-r2` on each of those boots until the +`completed` line appears. + +Local copies are never deleted, and a file the migration has not reached yet is +still served from disk, so switching R2 on cannot break historical images. + +--- + +## 3. Environment variables + +### Required + +| Variable | Notes | +|---|---| +| `SERVER_PORT` | The port the backend listens on. There is no default — leaving it unset binds an arbitrary port and nothing can reach the app. | +| `SECRET_KEY` | Signs session cookies. Required: an empty value is a fatal boot error. Use a random string of at least 32 bytes (`openssl rand -hex 32`); a shorter key logs `WARNING: SECRET_KEY is only N bytes` on every start. Changing it invalidates every active session and logs everyone out. | +| `REDIS_ADDR` | See [The database app](#1-the-database-app). | +| `GOOGLE_CLIENT_ID` | Google OAuth. Sign-in is the only way in, so without these nobody can log in. | +| `GOOGLE_CLIENT_SECRET` | | +| `ADMIN_USERS` | Comma-separated emails granted super-admin. Without at least one, no one can reach the super-admin panel. | + +### Optional + +| Variable | Default | Notes | +|---|---|---| +| `REDIS_PROTOCOL` | `tcp` | Must be `tcp` for a separate database app. | +| `REDIS_PASSWORD` | empty | | +| `REDIS_MASTER` | unset | **Must stay unset.** Redis Sentinel is not supported, and neither is a cluster given as a comma-separated `REDIS_ADDR`: the session store dials `REDIS_ADDR` as one plain address. With either set, the backend refuses to start and logs why. See [The database app](#1-the-database-app). | +| `ROOT_STATIC_FOLDER` | `/usr/share/ng` | Where the built frontend is served from. The Dockerfile already puts it there. | +| `COOKIE_INSECURE` | unset | Set to `1` **only** for local plain-HTTP development. Unset, the session cookie is marked `Secure` and will not survive a non-HTTPS origin. | +| `R2_*` | unset | See [Cloudflare R2](#2-cloudflare-r2-file-storage). | +| `SKIP_MIGRATIONS` | unset | `1` skips all data migrations at boot. An escape hatch, not a normal setting. | +| `MIGRATION_APPLY_AUTH_FLAGS` | unset | `1` makes the **first** features backfill honour leftover `require_auth`, `require_auth_for_view_files` and `count_views` settings values. Only that migration reads it: once it is recorded as applied (every deployment that has booted this code once), setting the flag does nothing and nothing says so. The v2 backfill ignores it. Off by default because those keys are no longer in the owner UI, so applying them would restrict channels in a way their owners cannot see or undo. | +| `ALLOW_LEGACY_UNSCOPED_FILES` | unset | `true` lets file records with no owning channel be served from any channel. This reopens a cross-tenant read path and exists only as a temporary escape hatch during a backfill. | +| `PPROF_ADDR` | unset | e.g. `localhost:6060` to enable the Go profiler. Never expose it publicly. | +| `MAGNET_MAIL_URL` | unset | In production `https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/channels-mail`. Magnet's endpoint for the system's emails (the channel welcome mail and a support answer). Magnet builds and sends the mail under its own sender name; replies to it land in Magnet's inbox. Unset (or `MAGNET_MAIL_SECRET` unset) turns the feature off: nothing is sent, and the boot log shows `magnet mail: MAGNET_MAIL_URL or MAGNET_MAIL_SECRET is not set` once, at the first mail that would have gone out. | +| `MAGNET_MAIL_SECRET` | unset | Sent as the `x-channels-secret` header on every mail request; must equal the secret configured on Magnet's side. Never logged. | +| `PUBLIC_BASE_URL` | `https://arootz.magnetads.co.il` | Origin for the links inside those emails, used only when the `project_domain` setting is not an `https://` URL. Magnet accepts links on `arootz.magnetads.co.il` only. | +| `WEBHOOK_ALLOW_PRIVATE` | unset | Unset, channel webhooks may only reach public addresses; a private, loopback, link-local or carrier-grade-NAT target is refused and logged as `webhook: refusing to connect to non-public address`. `1` lifts that check. Because `webhook_url` is set by channel owners, `1` reopens SSRF from any owner against your internal network (loopback, `169.254.169.254`, internal services) — use it only on a platform whose channel owners are all trusted, never on a self-service one. | + +--- + +## 4. The reverse proxy must pass the client address + +Rate limits, the failed-login budget and the per-client cap on live event +streams (100 concurrent `/events` connections per client) are all keyed by +the session e-mail or, for anonymous viewers, by the address the backend +sees. The backend trusts `X-Real-IP` for that, so the proxy in front of it +must set it from the connection (the shipped `Caddyfile` does: +`header_up X-Real-IP {remote_host}` plus `header_up -True-Client-IP`; +CapRover's nginx sets it by default). Behind a proxy that does not, every +anonymous viewer shares the proxy's address: the 101st viewer platform-wide +is refused with 429 and one abusive visitor's failed logins lock out everyone. + +## 5. The `/app/files` trap + +With R2 off, uploads are written to `/app/files/` inside the container. + +`docker-compose.yml` maps `./channel_data:/app/files`, which makes that path +durable — but **CapRover does not use docker-compose**. It builds from +`captain-definition` and the `Dockerfile`, and mounts no volume there unless you +add one explicitly. So on a default CapRover deployment every uploaded file +lands in the container's writable layer and is destroyed on the next deploy, and +the upload fails outright if that layer is full or read-only. + +Either configure R2, or add a persistent directory mapped to `/app/files` in the +backend app's App Configs. R2 is the better answer: it also survives running +more than one backend replica, which a local directory does not. + +--- + +## 6. Diagnosing an upload failure + +An upload returning 500 logs the reason. Find the line beginning `uploadFile:`: + +``` +uploadFile: raam: R2 upload of file 8759a37 failed (bucket "arootz", +key files/0d/fb/0dfb51…, 31126 bytes, content-type image/jpeg): +… api error AccessDenied: Access Denied +``` + +The S3 error code at the end is the diagnosis: + +| Code | Cause | +|---|---| +| `AccessDenied` | The token authenticated but cannot write — created read-only, or scoped to a different bucket. | +| `NoSuchBucket` | `R2_BUCKET_NAME` or `R2_ACCOUNT_ID` is wrong. | +| `InvalidAccessKeyId` | `R2_ACCESS_KEY_ID` is wrong. | +| `SignatureDoesNotMatch` | `R2_SECRET_ACCESS_KEY` is wrong. | + +Do not change variables before reading this line — all four failures look +identical from the browser, which only ever sees a 500. + +### Telling where a file was served from + +A request to `/api/channel/<slug>/files/<id>` answers: + +- **302** with a tiny body — redirected to R2 (pre-signed, or the public URL). +- **200** with the file's real size and the log line + `serveFile: … not in R2 …, serving local copy` — served from local disk. +- **200** with the log line `serveFile: presign failed for … : <error>` — the + file **is** in R2, but the backend could not sign a URL and is proxying the + object through itself. Fix the R2 credentials or the clock; do not re-upload. + +A browser showing the image proves neither; the status code does. + +--- + +## 7. Boot log checklist + +A healthy start prints, in order: + +``` +Connection to DB successful! +R2 storage enabled (bucket: <name>) # absent when R2 is off, which is a valid state +migrations: running <id> # only for migrations not yet applied +migrations: <id> completed +``` + +`migrations: channel features backfill touched N of M channels` (first boot of +this code) or `migrations: channel features backfill v2 touched N of M channels` +(an instance upgraded after the first backfill already ran) should report an +`M` matching your real channel count. If it does not, stop and investigate +before continuing — the migrations are one-shot. diff --git a/Dockerfile b/Dockerfile index 9e0a4bbf..63cbc4b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,33 @@ -FROM node:20 as builder1 +FROM node:20 AS builder1 WORKDIR /app + +# Copy manifests first so the dependency layer is cached when only source changes. +COPY ./frontend/package.json ./frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund + COPY ./frontend . -RUN npm install \ - && npm run build +RUN npm run build -FROM golang:1.24 AS builder2 +FROM golang:1.25 AS builder2 WORKDIR /app +# Copy dependency manifests first so this layer is cached when only source changes +COPY ./backend/go.mod ./backend/go.sum ./ +RUN go mod download + COPY ./backend . COPY --from=builder1 /app/dist/channel/browser/favicon.ico assets -RUN go mod tidy RUN go build -o the-channel . FROM debian:latest WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates && update-ca-certificates -COPY --from=builder2 /app/the-channel . +COPY --from=builder2 /app/the-channel . +# The platform favicon is served from ./assets by the backend; without this +# line only the build stage had it and every tab showed no icon. +COPY --from=builder2 /app/assets ./assets COPY --from=builder1 /app/dist/channel/browser /usr/share/ng RUN chmod +x the-channel CMD ["./the-channel"] diff --git a/README.md b/README.md index 58c81127..b83a32aa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # הערוץ -![Go](https://img.shields.io/badge/Go-1.22-blue?style=flat-square&logo=go) +![Go](https://img.shields.io/badge/Go-1.25-blue?style=flat-square&logo=go) ![Angular](https://img.shields.io/badge/Angular-DD0031?style=flat&logo=angular&logoColor=white) ![Caddy](https://img.shields.io/badge/Caddy-00BFB3?style=flat&logo=caddy&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat&logo=docker&logoColor=white) @@ -24,16 +24,20 @@ Caddy לניהול דומיין ויצירת תעודה עבור האתר. ```caddy example.com { - reverse_proxy backend:3000 + reverse_proxy backend:3000 { + header_up X-Real-IP {remote_host} + header_up -True-Client-IP + } } ``` +שתי שורות ה-`header_up` הן חובה, כמו בקובץ `Caddyfile` שמגיע עם הפרויקט: השרת סומך על הכותרת `X-Real-IP` כדי לזהות את כתובת הלקוח (הגבלת קצב לפי IP ורישום ללוג), ולכן היא חייבת להיקבע על ידי ה-proxy ולא להגיע מהלקוח. מלבד הטיפול בבקשות והפניה ל Container המתאים, **Caddy** מטפל גם בהוספת תעודה לדומיין. כך שנותר רק להריץ `docker-compose up --build -d`. ## הוראות שימוש -לאחר ההרצה הראשונה, יש להכנס למערכת עם חשבון שמוגדר כמנהל. -הגדרת מנהלים המזוהים באמצעות כתובת המייל, תחת משתנה הסביבה: +לאחר ההרצה הראשונה, יש להכנס למערכת עם חשבון שמוגדר כמנהל-על. +הגדרת מנהלי-על המזוהים באמצעות כתובת המייל, תחת משתנה הסביבה: ``` ADMIN_USERS=example@gmail.com,example1@gmail.com ``` @@ -42,7 +46,9 @@ https://example.com/login ולהזדהות באמצעות חשבון גוגל. הערכים הדרושים נמצאים בקובץ env. יצירת חשבון והגדרת חשבון עבור הערוץ בגוגל, ניתן לראות דוגמא במדריך [זה](https://dev.to/idrisakintobi/a-step-by-step-guide-to-google-oauth2-authentication-with-javascript-and-bun-4he7). -יש להגדיר את שם הערוץ תיאור הערוץ ולהעלות לוגו, שמירה וניתן להתחיל לפרסם הודעות... +מנהל-על מועבר אחרי הכניסה לפאנל מנהל-העל (`/super-admin`). שם, תחת **ערוצים** ← **ערוץ חדש**, יוצרים את הערוץ הראשון ובוחרים לו מזהה (slug). +הערוץ נפתח בכתובת `https://example.com/channel/<slug>`. בתוך הערוץ: תפריט המשתמש ← **ניהול הערוץ** ← **פרטי הערוץ** — מגדירים שם, תיאור ולוגו, שומרים, וניתן להתחיל לפרסם הודעות. +משתמש רגיל לוחץ בדף הבית על "פתיחת ערוץ" — הלחיצה מעבירה ישירות לכניסה עם גוגל — ומגיע לעמוד **הערוצים שלי** (`/channel`): משם פותחים ערוץ משלו תוך רגע (עד 5 ערוצים לחשבון), עוברים בין הערוצים, משתפים את הקישור ומגיעים למסך הניהול של כל ערוץ (`/channel/<slug>/manage`). פירוט ב-[SET.md](SET.md). ## הגדרות נוספות [שאר ההגדרות, הוראות מפורטות ועוד נמצאים כאן](SET.md). diff --git a/SET.md b/SET.md index 63d9d02d..ed99b6fb 100644 --- a/SET.md +++ b/SET.md @@ -7,29 +7,42 @@ ROOT_STATIC_FOLDER=/path/files ~~~ +## שתי רמות ניהול +המערכת היא רב-ערוצית. יש שני מקומות שבהם מגדירים דברים, וחשוב לא לבלבל ביניהם: +* **פאנל מנהל-על** (`/super-admin`, למשתמשים ב-`ADMIN_USERS`): הגדרות שחלות על כל האתר — כותרת ואנליטיקס, תשתית התראות (FCM), פרסומות גלובליות, אחסון, ניהול הערוצים ותכונותיהם, פניות למערכת. +* **מסך ניהול הערוץ** (`/channel/<slug>/manage/<מסך>`, מתוך הערוץ: תפריט המשתמש ← "ניהול הערוץ"): הגדרות שחלות על ערוץ אחד — פרטי הערוץ, ההגדרות שבטבלה למטה, צוות והרשאות, אימוג'ים, דיווחים, מגנט ואחסון. זה עמוד לכל דבר (אפשר לשלוח קישור ישיר למסך מסוים, למשל `/channel/news/manage/users`), ולא חלונית מעל הערוץ. + +### כמה ערוצים לחשבון אחד +כל התפקידים של המשתמש (בעלים, מנהל, כותב) בכל הערוצים מרוכזים בעמוד **הערוצים שלי** (`/channel`): משם נכנסים לערוץ, משתפים את הקישור ופותחים את מסך הניהול. בתוך ערוץ, תפריט המשתמש בכותרת מציג את שאר הערוצים של המשתמש למעבר מיידי ביניהם, ומסך הניהול מאפשר לעבור לניהול ערוץ אחר בלי לחזור לעמוד הראשי. הרשימה מגיעה מ-`GET /api/my-channels` (ראו בטבלת ה-API למטה). + +### פתיחת ערוץ +ערוץ נפתח באחת משתי דרכים: +* **שירות עצמי** — כל משתמש מחובר (חשבון גוגל) פותח ערוץ מהעמוד `/channel` ("פתיחת ערוץ נוסף"), בלי מעורבות מנהל-על. המגבלות: עד **5 ערוצים לחשבון** (הקבוע `maxChannelsPerOwner` ב-`backend/channel_create.go`), ו-3 פתיחות ברצף לחשבון, אחר כך אחת לכל 20 דקות. +* **מנהל-על** — פאנל מנהל-על ← ערוצים ← "ערוץ חדש", עם slug, שם ואימייל הבעלים. ללא מגבלת כמות. + +הערוץ זמין בכתובת `/channel/<slug>`. + ## כותרת מותאמת אישית לשיפור וקידום האתר בתוצאות חיפוש -יש להגדיר את הטקסט הרצוי בממשק הניהול תחת ההגדרה: -`custom-title` +מוגדרת בפאנל מנהל-על ← **הגדרות מערכת** ← "כותרת האתר" (המפתח `custom_title`). +הכותרת משותפת לכל הערוצים ומוזרקת לתגית `<title>` של העמוד לפני שהאפליקציה עולה; בתוך ערוץ, שם הערוץ בא לפניה ("שם הערוץ · הכותרת"). היא גם "שם האתר" בכרטיסי התצוגה המקדימה של קישורים (ראו "תצוגה מקדימה של קישור" למטה). -## חיוב הזדהות לגישה לערוץ: -בברירת מחדל, אין חיוב הזדהות במערכת. -בכדי לחייב הזדהות, יש להגדיר בממשק הניהול את הערך הבא: -`require_auth` -עם הערך 1 +## חיוב הזדהות לגישה לערוץ +בברירת מחדל, אין חיוב הזדהות. +ההגדרה היא לכל ערוץ בנפרד, בפאנל מנהל-על ← **ערוצים** ← "הגדרות הערוץ" ← המתג **כניסה חובה לצפייה**. ## חיוב הזדהות לצפיה בקבצי תמונות וסרטונים בערוץ -ברירת מחדל, במידה ולא מוגדר חיוב הזדהות בערוץ הקבצים נגישים לכל. -במידה ומעוניינים לאפשר צפיה בקבצים רק לרשומים, יש להגדיר בהגדרות הניהול: -`require_auth_for_view_files` עם הערך 1. +בברירת מחדל, כשלא נדרשת הזדהות בערוץ, הקבצים נגישים לכל. +כדי לאפשר צפיה בקבצים רק למזדהים: פאנל מנהל-על ← **ערוצים** ← "הגדרות הערוץ" ← המתג **כניסה חובה לקבצים**. ## הפעלת כפתור צור קשר -במידה וההגדרה `contact_us` מוגדרת בממשק הניהול עם קישור להפניה, יוצג למשתמשים כפתור צור קשר המפנה לקישור. +בפאנל ניהול הערוץ ← **פרטי ערוץ** ← השדה "קישור צור קשר". כשמוגדר קישור `https://` או `mailto:`, מוצג בכותרת הערוץ כפתור צור קשר שמוביל אליו; שדה ריק מסתיר את הכפתור. ## יבוא הודעות ניתן לייבא הודעות באמצעות API כדי להוסיף תכנים מפלטפורמות חיצוניות, כולל אפשרות להגדיר תאריך יצירה מדויק (timestamp) עבור כל הודעה. ### כתובת: -POST https://example.com/api/import/post +POST https://example.com/api/channel/{slug}/import/post +(כאשר `{slug}` הוא מזהה הערוץ שאליו מייבאים.) ### כותרות (Headers): | שם הכותרת | ערך | @@ -37,6 +50,8 @@ POST https://example.com/api/import/post | Content-Type | application/json | | X-API-Key | *המפתח שהוגדר בהגדרות הערוץ תחת הערך: `api_secret_key`* | +המפתח צריך להיות מחרוזת ארוכה ואקראית (למשל הפלט של `openssl rand -hex 32`), לא ערך קצר או צפוי — מי שמחזיק בו יכול לפרסם הודעות בערוץ. + ### גוף הבקשה (Request Body): יש לשלוח אובייקט JSON במבנה הבא: @@ -44,16 +59,55 @@ POST https://example.com/api/import/post { "text": "Hello from another platform!", "author": "John Doe", - "timestamp": "2025-04-06T12:34:56Z" + "timestamp": "2025-04-06T12:34:56Z", + "is_ads": false } ``` +| שדה | חובה | הסבר | +|-|-|-| +| `text` | כן | תוכן ההודעה ב-Markdown, עד 100,000 בייטים. | +| `author` | לא | שם הכותב שיוצג ליד ההודעה. | +| `timestamp` | לא | RFC 3339. ריק = "עכשיו". חותמת של **יותר משעה קדימה** נדחית (400). חותמת עבר מתקבלת כפי שהיא (יבוא ארכיון). הוובהוק נשלח תמיד; התראת דחיפה נשלחת רק כשהחותמת בטווח של שעה מעכשיו. | +| `is_ads` | לא | `true` מסמן את ההודעה כפרסומת (כמו לחצן המנעול בתיבת הכתיבה). | + +שדה `type` בבקשה מתעלמים ממנו — ההודעה נשמרת תמיד עם `"type": "md"`. גוף הבקשה מוגבל ל-1 MB. + +### תשובות: +| קוד | משמעות | +|-|-| +| `200` | ההודעה נשמרה. הגוף הוא אובייקט ההודעה שנשמר, כולל `id` (להצלבה עם הוובהוק). | +| `400` | JSON לא תקין, `text too long`, או `timestamp out of range`. | +| `401` | מפתח שגוי, או שלא הוגדר `api_secret_key` לערוץ — כל עוד השדה ריק הנתיב סגור. | +| `403` | `channel_disabled` — הערוץ הושבת על ידי מנהל-על. | +| `404` | ערוץ לא קיים. | +| `429` | יותר מדי ניסיונות עם מפתח שגוי מאותה כתובת לאותו ערוץ — יש להמתין. | + +דוגמת תשובת `200`: +```json +{ + "id": 124, + "type": "md", + "text": "Hello from another platform!", + "author": "John Doe", + "authorId": "", + "timestamp": "2025-04-06T12:34:56Z", + "last_edit": "0001-01-01T00:00:00Z", + "file": { "url": "", "filename": "", "filetype": "" }, + "deleted": false, + "views": 0, + "reactions": null, + "is_ads": false +} +``` + ## הגבלת גודל קבצים להעלאה ברירת מחדל מוגדר כי ניתן להעלות קבצים עד 100MB, ניתן לשנות זאת על ידי הגדרת הערך הרצוי בהגדרות הניהול: -`max_file_size` עם הערך הרצוי בMB. לדוגמא `50` בכדי להגביל ל50 MB +`max_file_size` עם הערך הרצוי בMB. לדוגמא `50` בכדי להגביל ל50 MB +הערך המרבי הוא **512**. ערך מחוץ לטווח 1–512 (או 0, או לא מספרי) שמזינים בטופס **נדחה** — השרת מחזיר 400 והטופס מציג "גודל הקובץ המרבי חייב להיות בין 1 ל-512 MB", בלי לשמור. רק ערך ישן שכבר היה שמור מחוץ לטווח לפני שהאימות נוסף ממשיך להישמר בשקט ומתעלמים ממנו — המגבלה בפועל נשארת אז 100 MB (האימות רץ רק על ערכים שהשתנו, כדי שטופס שמציג ערך ישן כזה יישאר ניתן לשמירה). ## וובהוק (Webhook) -המערכת תומכת בשליחת וובהוק בעת יצירה, עדכון או מחיקה של הודעות. הוובהוק יישלח רק אם הוגדר URL לוובהוק במשתני הסביבה. +המערכת תומכת בשליחת וובהוק בעת יצירה, עדכון או מחיקה של הודעות. הוובהוק יישלח רק אם הוגדר `webhook_url` בהגדרות הערוץ (פאנל ניהול הערוץ ← הגדרות), וכשהמתג **שליחת הודעות לכתובת חיצונית** דלוק לערוץ בפאנל מנהל-על ← ערוצים ← הגדרות הערוץ. אין לכך משתנה סביבה. ### הגדרת וובהוק כדי להפעיל את הוובהוק, יש להגדיר את ההגדרות הבאות בממשק הניהול: @@ -69,7 +123,7 @@ POST https://example.com/api/import/post "action": "create", // "create", "update", או "delete" "message": { "id": 123, - "type": "text", + "type": "md", "text": "message content", "author": "username", "timestamp": "2025-04-10T18:30:00Z", @@ -82,6 +136,9 @@ POST https://example.com/api/import/post } ``` +בפעולת `delete` נשלחים רק `id` ו-`deleted: true`; שאר שדות ההודעה ריקים (מחרוזות ריקות ותאריך אפס), כי ההודעה כבר לא נטענת מהמסד. אובייקט ההודעה ב-`create` וב-`update` כולל גם `authorId`, `file`, `reactions` ו-`is_ads`. `type` הוא כיום תמיד `md`. השדה `file` נשלח תמיד ריק (`{"url":"","filename":"","filetype":""}`) — הוא אינו נשמר במסד; קבצים מצורפים מוטמעים בתוך `text` כקישורי Markdown (`[image-embedded#WxH](url)`, `[video-embedded#](url)`, `[audio-embedded#](url)`, `[שם-הקובץ](url)`), ומי שצריך אותם מפרסר את `text`. +הוובהוק נשלח גם על הודעות שנוצרו דרך ה-API ליבוא הודעות ודרך התזמון. כתובת יעד ברשת פנימית (RFC 1918, loopback) נחסמת כברירת מחדל; ראו `WEBHOOK_ALLOW_PRIVATE` ב-DEPLOY.md. + ### אבטחה אם הגדרתם `webhook_verify_token`, תוכלו להשתמש בו כדי לוודא שהבקשות מגיעות אכן מהמערכת שלכם. בדקו שהערך ב-`verifyToken` תואם לערך שהגדרתם. @@ -100,8 +157,10 @@ POST https://example.com/api/import/post ## החלפה אוטומטית של טקסטים בעת פרסום הודעות חדשות ניתן להגדיר תחת הערך `regex-replace` סט רגקס#החלפה עבור טקסטים. ניתן להגדיר גם כמה הגדרות. +הכללים רצים רק בעת פרסום הודעה חדשה, לא בעריכה — כך שעריכה של הודעה לא מחילה את הכלל פעמיים על אותו טקסט. +סולמית (`#`) בתוך תבנית הרגקס עצמה (למשל כלל להדגשת האשטגים) נכתבת בטופס כרגיל; היא נשמרת כ-`\#` כדי שלא תתבלבל עם המפריד. כלל שהתבנית שלו ריקה מתעלמים ממנו. לדוגמא, הדגשה אוטומטית של כותרת הודעה: -הטקסט: `הודעה חשובה! ערוך חשוב!` +הטקסט: `הודעה חשובה! ערוץ חשוב!` ההגדרה: `(.*?\!)(.*)#**$1**$2` תוצאה: `**הודעה חשובה!** ערוץ חשוב!` וכתוצאה מכך, זה יראה בערוץ כך: @@ -109,14 +168,17 @@ POST https://example.com/api/import/post ## ספירת צפיות ורשומים לערוץ בפרטי הערוץ מוצגים כמות הרשומים שהזדהו עם חשבון גוגל בערוץ. -כמו כן, במידה ומגדירים בהגדרות ניהול את הערך: -`count_views` עם הערך 1, נרשם במערכת גם כמות הצפיות בכל הודעה והנתון מוצג בערוץ ליד כל הודעה. +כמו כן, כשהמתג **ספירת צפיות** דלוק לערוץ (פאנל מנהל-על ← ערוצים ← הגדרות הערוץ), נרשמת כמות הצפיות בכל הודעה והנתון מוצג בערוץ ליד כל הודעה. ## הפעלת קבלת התראות מהערוץ בכל עת השתמשנו בשירות FCM של גוגל. פרטים על יצירת חשבון והגדרתו ניתן למצוא במדריכים רבים במרחבי המרשתת, לדוגמא: [כאן](https://dev.to/this-is-angular/push-notifications-in-angular-19-with-firebase-cloud-messaging-3o3a) ו [כאן](https://youtu.be/iz5arafmatc). -בכדי להפעיל את השירות יש להגדיר בהגדרות הניהול: -את `on_notification` עם הערך `1`. +ההפעלה היא בשלושה מקומות, וכולם נדרשים: +1. **תשתית** — פאנל מנהל-על ← הגדרות מערכת ← "התראות לטלפון": המתג "הפעלת ההתראות במערכת" (`on_notification` הגלובלי) ופרטי Firebase שבטבלאות למטה. מוגדר פעם אחת לכל האתר. +2. **אישור מנהל-על לערוץ** — פאנל מנהל-על ← ערוצים ← הגדרות הערוץ ← המתג **התראות לטלפון**. +3. **בחירת בעל הערוץ** — פאנל ניהול הערוץ ← הגדרות ← "התראות לנייד ולדפדפן" ← המתג "לשלוח התראה על כל הודעה חדשה" (`on_notification` של הערוץ). + +פרטי Firebase (הגדרות מערכת): |setting|מקור/הסבר| |-|-| |`vapid`|cloudmessaging > Web Push certificates > Key pair| @@ -132,7 +194,7 @@ POST https://example.com/api/import/post בכדי ששליחת ההודעות תעבוד, יש להוריד קובץ JSON עם מפתח פרטי. הקובץ זמין להורדה מ[כאן](https://console.firebase.google.com/), לאחר בחירת הפרויקט, תחת הלשונית: `serviceaccounts > Generate new private key` -יש להעתיק את הערכים ולהגדיר אותם בממשק הניהול, רשימת ההגדרות המלאה להלן: +בהגדרות המערכת אפשר להדביק את כל קובץ ה-JSON בתיבה "הדבקת קובץ ה-JSON" והשדות יתמלאו לבד; רשימת ההגדרות המלאה להלן: |setting| |-------| |`fcm_json_type`| @@ -148,19 +210,115 @@ POST https://example.com/api/import/post |`fcm_json_universe_domain`| -## ריכוז הגדרות בממשק ניהול +## ממשק ניהול ההגדרות (UI חזותי) +ממשק ההגדרות עוצב מחדש לתצוגה חזותית מסודרת במקום טבלת key/value. ההגדרות מחולקות לקטגוריות בכרטיסים נפרדים, כל הגדרה מוצגת עם תווית והסבר בעברית, ולכל סוג שדה רכיב מתאים: +* בוליאנים מוצגים כ-toggle (כן/לא). +* שדות מספריים, טקסט, סיסמה ו-URL מקבלים רכיבים מתאימים. +* שדות ארוכים (כגון snippets ו-private keys) מוצגים כ-textarea. +* שדות התלויים בהפעלת תכונה ראשית (כגון FCM) מסתתרים אוטומטית כשהתכונה מכובה. + +הקטגוריות בהגדרות הערוץ (פאנל ניהול הערוץ ← הגדרות): +* קבצים ותמונות (גודל מרבי לקובץ, מפתח TinyPNG) +* התראות לנייד ולדפדפן (המתג "לשלוח התראה על כל הודעה חדשה" — של הערוץ בלבד; פרטי FCM עצמם נמצאים בהגדרות המערכת של מנהל-העל) +* פרסומת במסגרת (כתובת עמוד הפרסומת ורוחב המסגרת — iframe סטטי בצד הערוץ) +* חיבור למערכות חיצוניות (מפתח לפרסום הודעות מבחוץ, כתובת ה-Webhook ומחרוזת האימות שלה) +* החלפות אוטומטיות בטקסט (רשימה דינמית של כללי regex עם שדות תבנית והחלפה נפרדים) +* הגדרות מתקדמות (אזור מתקפל בתחתית עם הטבלה הישנה של key/value חופשי, לטובת הגדרות שאינן בסכמת ה-UI) + +תחת המכסה, השמירה ממירה את הכל בחזרה למבנה ה-API הקיים `[{key, value}]` כך שאין שינוי במבנה השמור ב-Redis (`channel:{slug}:settings`). + +## תג אנליטיקס בראש האתר +ניתן להזין כל קוד HTML/JS של שירות אנליטיקס (לדוגמא Google Analytics gtag.js, Google Tag Manager, Meta Pixel וכו') בפאנל מנהל-על ← **הגדרות מערכת** ← "קוד אנליטיקס" (המפתח `analytics_head`). הקוד משותף לכל הערוצים. + +הקוד יוזרק על ידי השרת לתוך תגית `<head>` של ה-`index.html` בכל בקשת עמוד SPA. ההזרקה מתבצעת בצד השרת לפני שהדפדפן מקבל את ה-HTML, כך שהקוד פועל מהרגע הראשון. + +## שילוב פרסומות ממגנט (Magnet) +Magnet היא פלטפורמת פרסומות חיצונית. ניתן לשלב פרסומות שלה כהודעות בתוך הערוץ. הניהול דרך לשונית ייעודית בממשק הניהול: **שילוב פרסומות ממגנט**. + +### זרימה ועקרונות +* הפרסומת מוטמעת כקוד embed (HTML/JS) שמספקת מגנט. +* כל פרסומת מתרנדרת רק כשהגולש גולל אליה (`IntersectionObserver` עם `rootMargin: 200px`), כך שהקריאה לשרת מגנט מתבצעת בזמן אמת לכל גולש בנפרד. +* כל סלוט שנחשף מבצע קריאה משלו, כך שגולש יכול לראות פרסומת אחרת בכל סלוט. סלוט שכבר הוצג אינו נטען מחדש בגלילה; את הפרסומת בתוכו מחליף הקוד של מגנט עצמו, כל דקה כשהוא נראה. +* כל המסגרות של אותו ערוץ מקבלות מאותו דפדפן את אותו מזהה גולש (`magnet_vid:<slug>` ב-localStorage של האתר, מוזרק למסגרת כ-`nc_vid`), כדי שמגנט ידע מה הגולש כבר ראה ויעדיף פרסומת אחרת. המזהה אקראי, נפרד לכל ערוץ ומתחדש כל 24 שעות (כמשך הזמן שמגנט זוכר מה הגולש ראה). הוא גלוי לקוד המוטמע של הערוץ. +* הקוד המוטמע רץ בתוך `iframe` מבודד (`sandbox` ללא `allow-same-origin`, מסמך `srcdoc`), ולא ישירות בדף הערוץ. כך הוא לא יכול לגשת לעוגיית ההתחברות של הגולש או ל-API של המערכת. כתובות יחסיות בקוד נפתרות מול כתובת האתר, וקישורים נפתחים בלשונית חדשה. גובה המסגרת מתעדכן אוטומטית לפי התוכן (עד 1200px). +* כשמגנט מודיע שאין פרסומת (האירוע `magnet:empty`) ושום דבר אחר במסגרת אינו גלוי, הסלוט מתקפל מיד. קוד אחר שלא דיווח גובה עד 5 שניות אחרי טעינת המסגרת, או עד 20 שניות מההזרקה אם המסגרת לא נטענה עד אז, מתקפל גם הוא. הסלוט מתקפל לגובה 0 ולא נעלם מהפריסה, כך שפרסומת שמגיעה מאוחר יותר פותחת אותו מחדש. +* הסלוט מוצג ככרטיס עם תווית "פרסומת" ושורת הסבר, והקוד המוטמע בתוכו. בעל ערוץ יכול להגדיר בקוד של מגנט תוכן חלופי (`<template data-ad-fallback>`) שיוצג כשאין פרסומת; אז הסלוט לא מתקפל. + +### תדירות הצגה +שני מצבים בלעדיים, נבחרים דרך ה-UI: + +**מצב א' — לפי כמות הודעות (`magnet_mode=by_messages`)** +* `magnet_per_messages` — הצגת פרסומת כל X הודעות (לדוגמא 5 = פרסומת אחרי כל 5 הודעות). +* `magnet_min_time_seconds` — החרגה: גם אם עברו X הודעות, לא תוצג פרסומת אחרי הודעה שפורסמה פחות מהזמן הזה אחרי ההודעה של הפרסומת הקודמת (לפי שעות הפרסום, לא זמן הקריאה). 0 לביטול. + +**מצב ב' — לפי זמן (`magnet_mode=by_time`)** +* `magnet_per_seconds` — הצגת פרסומת כל X שניות (לפי הפרשי הזמנים בין ההודעות). +* `magnet_min_messages_since` — החרגה: גם אם עבר הזמן, אם לא נוספו לפחות X הודעות חדשות מאז הפרסומת הקודמת - לא תוצג שוב. 0 לביטול. + +### נתוני הקלקות ותגמולים +בלשונית מגנט קיים כרטיס ייעודי לצפייה בנתוני הקלקות ותגמולים בזמן אמת ממגנט. הכרטיס מוצג למנהל-על בלבד, כי מפתח ה-API של מגנט הוא גלובלי. +* את מפתח ה-API (publisher key שמתקבל ממגנט) מזינים בפאנל מנהל-על ← **פרסומות מגנט** ← "מפתח API" ושומרים. +* כפתור **הצגת הנתונים** טוען את הנתונים. הקריאה למגנט מתבצעת מצד השרת בלבד דרך endpoint מוגן `GET /api/super-admin/magnet/stats` כך שה-API key לא נחשף לדפדפן. +* כפתור **רענון** מבצע קריאה חדשה (מגנט שומר תוצאות במטמון לכ-30 שניות). +* התצוגה כוללת: דומיין האתר, כמות הקלקות (היום / שבוע / חודש), ותגמולים (היום / שבוע / חודש) בפורמט מטבע. +* "היום" מתחיל מ-00:00 שעון ישראל. "שבוע"/"חודש" = 7/30 ימים אחורה. +* רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ. + +ה-endpoint שמגנט חושף: +`GET https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/publisher-stats?k=YOUR_API_KEY` + +### הערה ארכיטקטורית +ה-injection של פרסומות לתוך רשימת הצ'אט נעשה ע"י תוספת בתוך ה-`<nb-list-item>` של ההודעה (לא יצירת `<nb-list-item>` שני בכל iteration), כדי לא לשבור את ה-content children של `<nb-list>` של Nebular. ה-component `chat.component` שומר על `Set<number>` של ה-IDs של ההודעות שאחריהן יש להציג פרסומת, ומחשב מחדש את ה-Set בכל שינוי ב-`messages` (טעינה ראשונית, scroll, SSE). + +## ריכוז הגדרות הערוץ (פאנל ניהול הערוץ ← הגדרות) |setting |value | הסבר | |---------------|------|------| -|`require_auth` | `1` |חיוב הזדהות בכניסה לערוץ | -|`require_auth_for_view_files`|`1`|חיוב הזדהות לצפיה בקבצי תמונות וסרטונים בערוץ| -|`api_secret_key`|`1`|מפתח עבור יבוא הודעות באמצעות API| +|`api_secret_key`|`a-long-random-secret`|מפתח עבור יבוא הודעות באמצעות API. חייב להיות מחרוזת ארוכה ואקראית (למשל הפלט של `openssl rand -hex 32`) — מי שמחזיק בו יכול לפרסם הודעות בערוץ| |`webhook_url`|`https://example.com/webhook`|כתובת לשליחת וובהוק| |`webhook_verify_token`|`your-secret-token`|טוקן לשליחה יחד עם וובהוק| -|`ad-iframe-src`| |קישור HTML להטמעת פרסומת| -|`ad-iframe-width`|`300`|רוחב פרסום| -|`count_views`|`1`|הפעלת מונה צפיות פר הודעה| -|`regex-replace`|`(.*?\!)(.*)#**$1**$2`|ערך של רגקס והחלפה בכדי ליצור החלפות אוטומטיות לטקסטים| -|`on_notification`|`1`|הפעלת התראות דחיפה| +|`ad-iframe-src`| |קישור HTML להטמעת פרסומת בעמודה הצידית| +|`ad-iframe-width`|`300`|רוחב פרסום בעמודה הצידית| +|`regex-replace`|`(.*?\!)(.*)#**$1**$2`|ערך של רגקס והחלפה בכדי ליצור החלפות אוטומטיות לטקסטים (רץ פעם אחת, בפרסום ההודעה; לא בעריכה)| +|`on_notification`|`1`|הפעלת התראות דחיפה בערוץ (בנוסף לתשתית הגלובלית ולמתג "התראות לטלפון" בהגדרות הערוץ שבפאנל מנהל-העל)| |`max_file_size`|`50`|הגבלת משקל קבצים| -|`custom_title`||title מותאם אישית| -|`contact_us`|url|הפעלת כפתור צור קשר| \ No newline at end of file +|`tinypng_api_key`||מפתח TinyPNG לכיווץ תמונות לפני העלאה| +|`magnet_enabled`|`1`|הפעלת שילוב פרסומות ממגנט בערוץ| +|`magnet_snippet`|`<script>...</script>`|קוד הטמעה (HTML/JS) שהתקבל ממגנט| +|`magnet_mode`|`by_messages` / `by_time`|שיטת תזמון הצגת פרסומות| +|`magnet_per_messages`|`5`|בתזמון לפי הודעות: כל כמה הודעות להציג פרסומת| +|`magnet_min_time_seconds`|`0`|בתזמון לפי הודעות: זמן מינימום בשניות בין פרסומות (0 = ללא החרגה)| +|`magnet_per_seconds`|`60`|בתזמון לפי זמן: כל כמה שניות להציג פרסומת| +|`magnet_min_messages_since`|`0`|בתזמון לפי זמן: מינימום הודעות חדשות מאז הפרסומת הקודמת (0 = ללא החרגה)| + +הגדרות שהיו פעם בטבלה הזו ועברו למקום אחר: +|הגדרה ישנה|איפה היא היום| +|-|-| +|`require_auth`|פאנל מנהל-על ← ערוצים ← הגדרות הערוץ ← "כניסה חובה לצפייה"| +|`require_auth_for_view_files`|פאנל מנהל-על ← ערוצים ← הגדרות הערוץ ← "כניסה חובה לקבצים"| +|`count_views`|פאנל מנהל-על ← ערוצים ← הגדרות הערוץ ← "ספירת צפיות"| +|`contact_us`|פאנל ניהול הערוץ ← פרטי ערוץ ← "קישור צור קשר"| +|`enter_sends_message`|המתג "Enter שולח" ליד תיבת הכתיבה — העדפה אישית שנשמרת בדפדפן של כל משתמש, לא הגדרת ערוץ (השרת מעולם לא קרא אותה)| +|`custom_title`, `analytics_head`|פאנל מנהל-על ← הגדרות מערכת ← האתר| +|`vapid`, `fcm_*`, `fcm_json_*`, `project_domain`|פאנל מנהל-על ← הגדרות מערכת ← התראות לטלפון| +|`magnet_api_key`|פאנל מנהל-על ← פרסומות מגנט ← "מפתח API". המפתח גלובלי לכל האתר ומשמש רק את `GET /api/super-admin/magnet/stats`; ערך שנשאר בהגדרות הערוץ מגרסה ישנה לא נקרא על ידי השרת כלל, ונמחק בשמירה הבאה של הגדרות הערוץ| + +## API endpoints נוספים שנוספו +| Method | Path | הסבר | +|--------|------|------| +|GET|`/api/channel/{slug}/ads/magnet`|מחזיר את הגדרות מגנט הציבוריות (snippet, mode, thresholds) לקליינט. ה-snippet מוחזר רק כש-`magnet_enabled=1`. מפתח ה-API לעולם לא מוחזר.| +|GET|`/api/my-channels`|למשתמש המחובר: כל הערוצים שיש לו בהם תפקיד — `slug`, `name`, `description`, `logoUrl`, `role` (`owner`/`moderator`/`writer`), `createdAt`, `disabled`, `participants`. ממוין: בעלים קודם, אחר כך לפי תפקיד, ואז מהחדש לישן. תפקיד שהערוץ שלו נמחק אינו מופיע. משמש את עמוד "הערוצים שלי" ואת מעבר הערוצים בכותרת.| + +|GET|`/api/super-admin/magnet/stats`|מנהל-על בלבד. עושה proxy ל-publisher-stats של מגנט עם ה-API key הגלובלי, כך שה-key לא נחשף לדפדפן.| + +## דחיסה (gzip) +השרת (Go) דוחס בעצמו את התשובות הטקסטואליות — קובצי ה-JS וה-CSS תחת `/assets`, `index.html` וכל תשובת JSON — כשהדפדפן שולח `Accept-Encoding: gzip`. אין צורך ב-`encode` ב-Caddy. זרם העדכונים החיים (`/api/channel/{slug}/events`, `text/event-stream`) וקובצי מדיה נשארים לא דחוסים בכוונה. + +## תצוגה מקדימה של קישור (Open Graph) ואייקונים +כשמעבירים קישור לערוץ בוואטסאפ, בטלגרם או ברשת חברתית, הכרטיס שמוצג נבנה מתגיות שהשרת מזריק ל-`<head>` לפני שהאפליקציה עולה (`backend/seo.go`), כי הסורקים של השירותים האלה אינם מריצים את האפליקציה: + +- בעמוד ערוץ (`/channel/<slug>`): `<title>` = שם הערוץ · שם האתר, `description`/`og:description` מהתיאור של הערוץ (עד 160 תווים), `og:image` = הלוגו של הערוץ, `og:url` הכתובת המלאה. +- בכל עמוד אחר, ובערוץ **ללא** לוגו: כרטיס המותג `/assets/og.jpg` (1200×630) ושם האתר. +- ערוץ שדורש כניסה לצפייה, ערוץ מושבת או ערוץ שלוגו שלו מאחורי "כניסה חובה לקבצים" — אינם חושפים שם, תיאור או לוגו; הכרטיס הכללי מוצג במקומם. +- "שם האתר" הוא `custom_title` כשהוגדר, ואחרת "הערוץ". הכתובת המלאה נלקחת מ-`project_domain` כשהוגדר, ואחרת מהבקשה (`X-Forwarded-Proto` + `Host`). + +האייקונים (`/assets/icon.svg`, `apple-touch-icon.png`, `icon-512.png`) והמניפסט (`/assets/manifest.webmanifest`) נמצאים ב-`frontend/public/`. מקור כרטיס המותג וסקריפט הרינדור שלו אינם במאגר; כדי להחליף את הכרטיס מספיק להחליף את `og.jpg` (JPEG, 1200×630). diff --git a/backend/ads.go b/backend/ads.go index b868cb42..c031c46b 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -1,21 +1,575 @@ package main import ( + "context" "encoding/json" + "io" + "log" "net/http" + "regexp" + "slices" + "strings" + "sync" + "time" ) type AdsSettings struct { Src string `json:"src"` Width int64 `json:"width"` + // Locked tells the owner's settings screen that the super admin's + // configuration is being served instead of the channel's own, which used + // to be invisible: saves succeeded and changed nothing. + Locked bool `json:"locked"` +} + +// isChannelAdsLocked returns true if the super admin has locked ads settings for this channel. +func isChannelAdsLocked(globalAds *GlobalAdsConfig, ch *ChannelData) bool { + if globalAds.LockAll { + return true + } + // Guard the deref that follows: the LockedChannels lookup reads ch.Slug + // unconditionally, so a nil ch (a caller outside channelMiddleware) would + // panic. LockAll is already handled above; with no channel, nothing else can + // be locked. + if ch == nil { + return false + } + if ch.Features.AdsLockedByAdmin { + return true + } + return slices.Contains(globalAds.LockedChannels, ch.Slug) } func getAdsSettings(w http.ResponseWriter, r *http.Request) { - settings := AdsSettings{ - Src: settingConfig.AdSrc, - Width: settingConfig.AdWidth, + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + ch := channelFromCtx(r) + + globalAds, err := dbGetGlobalAdsConfig(ctx) + if err != nil { + globalAds = &GlobalAdsConfig{} + } + + var settings AdsSettings + switch { + case ch != nil && !ch.Features.Ads: + // Operator-level kill switch. Answer with empty settings rather than an + // error so the client simply renders no ad frame. + settings = AdsSettings{} + case isChannelAdsLocked(globalAds, ch): + settings = AdsSettings{Src: globalAds.Src, Width: globalAds.Width, Locked: true} + default: + cfg := getChannelConfig(ctx, slug) + settings = AdsSettings{Src: cfg.AdSrc, Width: cfg.AdWidth} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } + +// getGlobalAdsConfig – super admin: read global ads config +func getGlobalAdsConfig(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cfg, err := dbGetGlobalAdsConfig(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cfg) +} + +// setGlobalAdsConfig – super admin: save global ads config + lock rules +func setGlobalAdsConfig(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var cfg GlobalAdsConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // The same rule the per-channel ad-iframe-src gets: this source is bound + // to an <iframe src> on every locked channel. + cfg.Src = strings.TrimSpace(cfg.Src) + if cfg.Src != "" && !isFramableURL(cfg.Src) { + http.Error(w, "src must be an absolute http(s) URL", http.StatusBadRequest) + return + } + + if err := dbSetGlobalAdsConfig(ctx, &cfg); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + go syncAdsLockFlags(&cfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +// syncLockFlagsMu serialises the lock syncs. Two saves in quick succession +// (lock, then unlock) used to run as two interleaving goroutines with +// different snapshots, so the stale "locked" could land after the "unlocked" +// and the channel stayed locked with nothing in the global list naming it. +var syncLockFlagsMu sync.Mutex + +// syncLockFlags propagates a global lock config (LockAll + LockedChannels) into +// the per-channel feature flag selected by get. Shared by the ads and magnet +// syncs so a robustness fix applied to one can never leave the other drifting. +// +// Each channel's flag is updated atomically against the stored features +// (dbUpdateChannelFeatures) rather than written back from the listing +// snapshot, so a features save made while the sync runs — disabling the +// channel, say — is not reverted by it. Failures are logged: a sync that died +// half-way used to leave the remaining channels locked, silently, with the +// admin shown a success. +func syncLockFlags(lockAll bool, lockedChannels []string, get func(*ChannelFeatures) *bool) { + syncLockFlagsMu.Lock() + defer syncLockFlagsMu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + log.Printf("syncLockFlags: listing channels failed, lock flags not synced: %v\n", err) + return + } + + failed := 0 + for _, ch := range channels { + shouldLock := lockAll || slices.Contains(lockedChannels, ch.Slug) + if *get(&ch.Features) == shouldLock { + continue + } + err := dbUpdateChannelFeatures(ctx, ch.Slug, func(f *ChannelFeatures) { + *get(f) = shouldLock + }) + if err != nil { + failed++ + log.Printf("syncLockFlags: %s: setting lock flag to %v failed: %v\n", ch.Slug, shouldLock, err) + } + } + if failed > 0 { + log.Printf("syncLockFlags: %d of %d channels not synced; saving the global config again retries\n", failed, len(channels)) + } +} + +// syncAdsLockFlags updates AdsLockedByAdmin on all channels based on the new global config. +func syncAdsLockFlags(globalAds *GlobalAdsConfig) { + syncLockFlags(globalAds.LockAll, globalAds.LockedChannels, + func(f *ChannelFeatures) *bool { return &f.AdsLockedByAdmin }) +} + +type MagnetAdsSettings struct { + Enabled bool `json:"enabled"` + Snippet string `json:"snippet"` + Mode string `json:"mode"` + PerMessages int64 `json:"perMessages"` + MinTimeSeconds int64 `json:"minTimeSeconds"` + PerSeconds int64 `json:"perSeconds"` + MinMessagesSinceLast int64 `json:"minMessagesSinceLast"` + // Locked: see AdsSettings.Locked. + Locked bool `json:"locked"` +} + +// isChannelMagnetLocked returns true if the super admin has locked magnet settings for this channel. +func isChannelMagnetLocked(globalMagnet *GlobalMagnetConfig, ch *ChannelData) bool { + if globalMagnet.LockAll { + return true + } + // Same nil guard as isChannelAdsLocked: the LockedChannels lookup derefs + // ch.Slug, so a nil channel must be handled before it. + if ch == nil { + return false + } + if ch.Features.MagnetLockedByAdmin { + return true + } + return slices.Contains(globalMagnet.LockedChannels, ch.Slug) +} + +func getMagnetAdsSettings(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + ch := channelFromCtx(r) + + globalMagnet, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + globalMagnet = &GlobalMagnetConfig{} + } + + var settings MagnetAdsSettings + + if isChannelMagnetLocked(globalMagnet, ch) && !globalMagnet.Enabled { + // This endpoint is public and is also how a locked channel's managers + // learn of the lock. The platform's placement rules are needed by a + // viewer only while its ads run, so with them off nothing of the + // global config goes out beyond the lock itself. + settings = MagnetAdsSettings{Locked: true} + } else if isChannelMagnetLocked(globalMagnet, ch) { + // Super admin override: use global magnet config. The snippet and the + // placement rules are what every viewer's browser needs to show the + // ad, so they are public by nature; nothing else (embed API key, + // stats key, lock list) is part of this view. + settings = MagnetAdsSettings{ + Enabled: globalMagnet.Enabled, + Mode: globalMagnet.Mode, + PerMessages: globalMagnet.PerMessages, + MinTimeSeconds: globalMagnet.MinTimeSeconds, + PerSeconds: globalMagnet.PerSeconds, + MinMessagesSinceLast: globalMagnet.MinMessagesSinceLast, + Locked: true, + } + if settings.Enabled { + settings.Snippet = globalMagnet.Snippet + } + } else { + // Use channel's own magnet settings + cfg := getChannelConfig(ctx, slug) + settings = MagnetAdsSettings{ + Enabled: cfg.MagnetEnabled, + Mode: cfg.MagnetMode, + PerMessages: cfg.MagnetPerMessages, + MinTimeSeconds: cfg.MagnetMinTimeSeconds, + PerSeconds: cfg.MagnetPerSeconds, + MinMessagesSinceLast: cfg.MagnetMinMessagesSince, + } + if settings.Enabled { + settings.Snippet = cfg.MagnetSnippet + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(settings) +} + +// statsKeyRe is the shape of a Magnet stats key (rotate_site_stats_key mints +// "mgs_" + 24 random bytes as lowercase hex). Checking it on save turns a +// mis-pasted key into an immediate Hebrew error instead of a 401 later on the +// statistics screen. +var statsKeyRe = regexp.MustCompile(`^mgs_[0-9a-f]{48}$`) + +// globalMagnetConfigView is what the super-admin screen receives. It lists +// its fields explicitly, with no StatsKey at all, rather than embedding +// GlobalMagnetConfig and shadowing the field: a secret must not depend on +// encoding/json's tie-breaking rules to stay out of a response. +type globalMagnetConfigView struct { + Enabled bool `json:"enabled"` + Snippet string `json:"snippet"` + Mode string `json:"mode"` + PerMessages int64 `json:"perMessages"` + MinTimeSeconds int64 `json:"minTimeSeconds"` + PerSeconds int64 `json:"perSeconds"` + MinMessagesSinceLast int64 `json:"minMessagesSinceLast"` + ApiKey string `json:"apiKey"` + LockAll bool `json:"lockAll"` + LockedChannels []string `json:"lockedChannels"` + StatsKeySet bool `json:"statsKeySet"` +} + +func magnetConfigView(cfg *GlobalMagnetConfig) globalMagnetConfigView { + return globalMagnetConfigView{ + Enabled: cfg.Enabled, + Snippet: cfg.Snippet, + Mode: cfg.Mode, + PerMessages: cfg.PerMessages, + MinTimeSeconds: cfg.MinTimeSeconds, + PerSeconds: cfg.PerSeconds, + MinMessagesSinceLast: cfg.MinMessagesSinceLast, + ApiKey: cfg.ApiKey, + LockAll: cfg.LockAll, + LockedChannels: cfg.LockedChannels, + StatsKeySet: cfg.StatsKey != "", + } +} + +// globalMagnetConfigInput is the POST body. The browser never holds the +// stored stats key, so an empty statsKey means "keep what is stored" and +// removing it needs its own explicit flag. +type globalMagnetConfigInput struct { + GlobalMagnetConfig + ClearStatsKey bool `json:"clearStatsKey"` +} + +// writeJSONError answers with {"error":code,"message":message}, the shape the +// frontend maps to Hebrew text. +func writeJSONError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": code, "message": message}) +} + +// getGlobalMagnetConfig – super admin: read global magnet config +func getGlobalMagnetConfig(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cfg, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_read_failed", "הגדרות מגנט לא נטענו. נסו שוב בעוד רגע.") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(magnetConfigView(cfg)) +} + +// setGlobalMagnetConfig – super admin: save global magnet config + lock rules +func setGlobalMagnetConfig(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var in globalMagnetConfigInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // The stored key is needed to keep it when the form leaves the field + // empty; saving over a config we could not read would silently erase it. + current, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_read_failed", "הגדרות מגנט לא נטענו. נסו שוב בעוד רגע.") + return + } + + cfg := in.GlobalMagnetConfig + k := strings.TrimSpace(in.StatsKey) + switch { + case in.ClearStatsKey: + cfg.StatsKey = "" + case k == "": + cfg.StatsKey = current.StatsKey + case statsKeyRe.MatchString(k): + cfg.StatsKey = k + default: + writeJSONError(w, http.StatusBadRequest, "invalid_stats_key", + "מפתח הסטטיסטיקה אינו תקין — הוא מתחיל ב-mgs_ ואחריו 48 תווים. העתיקו אותו שוב ממגנט: קוד הטמעה ← מפתח סטטיסטיקה לשרת.") + return + } + + if err := dbSetGlobalMagnetConfig(ctx, &cfg); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + // Sync MagnetLockedByAdmin flag on each affected channel's features + go syncMagnetLockFlags(&cfg) + + // The redacted view, so the screen learns statsKeySet without the key. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(magnetConfigView(&cfg)) +} + +// syncMagnetLockFlags updates MagnetLockedByAdmin on all channels based on the new global config. +// Called in a goroutine after saving global magnet config. +func syncMagnetLockFlags(globalMagnet *GlobalMagnetConfig) { + syncLockFlags(globalMagnet.LockAll, globalMagnet.LockedChannels, + func(f *ChannelFeatures) *bool { return &f.MagnetLockedByAdmin }) +} + +// magnetStatsURL is Magnet's server-to-server stats endpoint (F37). It +// replaced the old stats function, which took the public embed key in the query +// string — a key printed on every page that shows the widget — and so let +// anyone read the site's earnings. A var so tests can point it at httptest. +var magnetStatsURL = "https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/site-stats" + +var magnetStatsClient = &http.Client{Timeout: 15 * time.Second} + +func getMagnetStats(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + globalMagnet, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_read_failed", "הגדרות מגנט לא נטענו. נסו שוב בעוד רגע.") + return + } + if globalMagnet.StatsKey == "" { + writeJSONError(w, http.StatusBadRequest, "missing_stats_key", "מפתח הסטטיסטיקה של מגנט לא הוגדר") + return + } + proxyMagnetStats(w, r, globalMagnet.StatsKey) +} + +// proxyMagnetStats asks Magnet's site-stats for the site that key belongs to +// and passes the answer through. Shared by the platform's own numbers (the +// global key) and an unlocked channel's (that channel's key): the two screens +// read one response shape and one set of error codes. +func proxyMagnetStats(w http.ResponseWriter, r *http.Request, key string) { + // The key travels in a header and never in the URL, so it stays out of + // access logs on both sides. Errors below never include it. + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, magnetStatsURL, nil) + if err != nil { + http.Error(w, `{"error":"request_build_failed"}`, http.StatusInternalServerError) + return + } + req.Header.Set("x-magnet-stats-key", key) + req.Header.Set("Accept", "application/json") + + resp, err := magnetStatsClient.Do(req) + if err != nil { + http.Error(w, `{"error":"upstream_unreachable"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + http.Error(w, `{"error":"upstream_read_failed"}`, http.StatusBadGateway) + return + } + + // Magnet's 401 (invalid_key/missing_key) is about the stored stats key, + // not the operator: forwarded as 401 it would hit the frontend's + // session-expired interceptor, which signs the super admin out on any 401 + // under /api/super-admin/. It goes out as 502 with Magnet's body intact, + // and the screen tells the cases apart by the body's error code. + status := resp.StatusCode + if status == http.StatusUnauthorized { + status = http.StatusBadGateway + // A 401 without an error code did not come from site-stats itself but + // from the platform's JWT gate in front of it ({"code":401,...}), which + // means site-stats was deployed with verify_jwt on. Passed through, the + // screen could only call it an outage and the operator would keep + // retrying a permanent setup fault, so it gets a code of its own. + var upstream struct { + Error any `json:"error"` + } + if json.Unmarshal(body, &upstream) != nil || upstream.Error == nil || upstream.Error == "" { + w.Header().Set("Cache-Control", "no-store") + writeJSONError(w, status, "upstream_unauthorized", + "מגנט דחה את הבקשה — בדקו את מפתח הסטטיסטיקה ואת פריסת site-stats במגנט.") + return + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + w.Write(body) +} + +// ─── A channel's own Magnet numbers ──────────────────────────────────────── +// +// The owner's rule: a channel that manages its own Magnet embed (not locked) +// earns from it, so its managers may see its numbers, read with a stats key +// from their own Magnet account. A locked channel serves the platform's embed +// and the earnings are the platform's: its managers get nothing at all — no +// key field, no numbers — and these routes refuse before touching Magnet, +// even when a key was saved before the lock. + +const channelMagnetLockedMessage = "הנהלת המערכת קבעה הגדרות פרסומת אחידות לערוץ הזה, ולכן אין כאן נתוני מגנט." + +// channelMagnetUnlocked answers 403 (or 500) and returns false unless the +// channel in the request manages its own Magnet embed. A global config that +// cannot be read counts as locked: guessing "unlocked" would hand the +// platform's channels a route to Magnet. +func channelMagnetUnlocked(ctx context.Context, w http.ResponseWriter, r *http.Request) bool { + globalMagnet, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_read_failed", "הגדרות מגנט לא נטענו. נסו שוב בעוד רגע.") + return false + } + ch := channelFromCtx(r) + if ch == nil || isChannelMagnetLocked(globalMagnet, ch) { + writeJSONError(w, http.StatusForbidden, "magnet_locked", channelMagnetLockedMessage) + return false + } + return true +} + +// channelMagnetStatsKeyView is all a browser learns about the channel's key. +type channelMagnetStatsKeyView struct { + StatsKeySet bool `json:"statsKeySet"` +} + +// getChannelMagnetStatsKey – owner: is a stats key stored for this channel? +func getChannelMagnetStatsKey(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if !channelMagnetUnlocked(ctx, w, r) { + return + } + key, err := dbGetChannelMagnetStatsKey(ctx, channelSlugFromCtx(r)) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_read_failed", "הגדרות מגנט לא נטענו. נסו שוב בעוד רגע.") + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(channelMagnetStatsKeyView{StatsKeySet: key != ""}) +} + +// setChannelMagnetStatsKey – owner: store, replace or remove the channel's +// key. Unlike the global form there is nothing else in the body, so an empty +// key is refused rather than read as "keep". +func setChannelMagnetStatsKey(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var in struct { + StatsKey string `json:"statsKey"` + ClearStatsKey bool `json:"clearStatsKey"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if !channelMagnetUnlocked(ctx, w, r) { + return + } + + k := strings.TrimSpace(in.StatsKey) + switch { + case in.ClearStatsKey: + k = "" + case !statsKeyRe.MatchString(k): + writeJSONError(w, http.StatusBadRequest, "invalid_stats_key", + "מפתח הסטטיסטיקה אינו תקין — הוא מתחיל ב-mgs_ ואחריו 48 תווים. העתיקו אותו שוב מחשבון המגנט שלכם: קוד הטמעה ← מפתח סטטיסטיקה.") + return + } + + if err := dbSetChannelMagnetStatsKey(ctx, channelSlugFromCtx(r), k); err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_write_failed", "שמירת המפתח נכשלה. נסו שוב בעוד רגע.") + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(channelMagnetStatsKeyView{StatsKeySet: k != ""}) +} + +// getChannelMagnetStats – owner: the channel's own numbers from Magnet. +func getChannelMagnetStats(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if !channelMagnetUnlocked(ctx, w, r) { + return + } + key, err := dbGetChannelMagnetStatsKey(ctx, channelSlugFromCtx(r)) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "config_read_failed", "הגדרות מגנט לא נטענו. נסו שוב בעוד רגע.") + return + } + if key == "" { + writeJSONError(w, http.StatusBadRequest, "missing_stats_key", "מפתח הסטטיסטיקה של הערוץ לא הוגדר") + return + } + proxyMagnetStats(w, r, key) +} diff --git a/backend/api.go b/backend/api.go index a02c2910..96320007 100644 --- a/backend/api.go +++ b/backend/api.go @@ -2,23 +2,72 @@ package main import ( "context" + "crypto/subtle" "encoding/json" "log" "net/http" "time" + + "github.com/go-chi/chi" ) func addNewPost(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if !slugRegex.MatchString(slug) { + http.Error(w, "invalid slug", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Wrong keys are rationed per client and channel; a client that has used + // up its failures is refused before the key is even looked at, so the + // key cannot be guessed at network speed. Correct keys cost nothing. + authLimiter := importAuthLimiter(clientKey(r) + ":" + slug) + if authLimiter.Tokens() < 1 { + http.Error(w, "too many failed attempts — please wait", http.StatusTooManyRequests) + return + } + + // This route is mounted at the router root, outside the /api/channel/{slug} + // group, so channelMiddleware never runs for it — and channelMiddleware is + // where the super admin's kill switch lives. Without this check, disabling a + // channel stopped every browser-facing route while the owner's API key kept + // ingesting posts, publishing to SSE and firing the channel's webhook. The + // check is repeated here rather than by remounting the route because the + // API key, not a session, is what authenticates it. + // + // It runs before the key check: a mistyped slug has no configured key, so + // it used to answer 401 and burn the failure budget of an integrator whose + // key was fine. A channel's existence is public (/info), not a secret. + channel, err := dbGetChannel(ctx, slug) + if err != nil { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + if channel.Features.Disabled { + http.Error(w, "channel_disabled", http.StatusForbidden) + return + } + + cfg := getChannelConfig(ctx, slug) + // Unauthenticated route: an unset key must fail closed, and the comparison + // against the configured secret must not vary with how much of it matched. key := r.Header.Get("X-API-Key") - if key != settingConfig.ApiSecretKey { - http.Error(w, "error", http.StatusBadRequest) + if cfg.ApiSecretKey == "" || subtle.ConstantTimeCompare([]byte(key), []byte(cfg.ApiSecretKey)) != 1 { + authLimiter.Allow() + http.Error(w, "error", http.StatusUnauthorized) return } var message Message - var err error defer r.Body.Close() + // The decoded text is stored verbatim and broadcast to every SSE client, so + // an unbounded body must not reach it. + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + body := Message{} if err = json.NewDecoder(r.Body).Decode(&body); err != nil { log.Printf("Failed to decode message: %v\n", err) @@ -26,23 +75,49 @@ func addNewPost(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + if len(body.Text) > maxMessageTextLen { + http.Error(w, "text too long", http.StatusBadRequest) + return + } - message.ID = getMessageNextId(ctx) + if message.ID, err = getMessageNextId(ctx, slug); err != nil { + log.Printf("Failed to allocate message id: %v\n", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } message.Type = "md" //body.Type message.Author = body.Author - message.Timestamp = body.Timestamp + // The timestamp is the sort score. A missing one decodes to the zero time + // and buries the post at the bottom of every listing; a far-future one pins + // it to the top forever. Backdated imports stay legitimate. + if body.Timestamp.IsZero() { + message.Timestamp = time.Now() + } else if body.Timestamp.After(time.Now().Add(time.Hour)) { + http.Error(w, "timestamp out of range", http.StatusBadRequest) + return + } else { + message.Timestamp = body.Timestamp + } message.Text = body.Text message.Views = 0 message.IsAds = body.IsAds - if err = setMessage(ctx, &message, false); err != nil { + if err = setMessage(ctx, slug, &message, false); err != nil { log.Printf("Failed to set new message: %v\n", err) http.Error(w, "error", http.StatusInternalServerError) return } + // Same fan-out as a post from the composer or the scheduler: the docs + // promise the webhook on every creation, and readers who subscribed to + // push expect one for content that appears in the feed. A backdated import + // is an archive being copied in, not news, so it fires the webhook but + // not a push. + go SendWebhook(context.Background(), slug, "create", &message) + if time.Since(message.Timestamp) < time.Hour { + go pushFcmMessage(slug, &message) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(message) } diff --git a/backend/audit_helpers_test.go b/backend/audit_helpers_test.go new file mode 100644 index 00000000..252de295 --- /dev/null +++ b/backend/audit_helpers_test.go @@ -0,0 +1,271 @@ +package main + +import ( + "regexp" + "strings" + "testing" + "unicode/utf8" +) + +func TestPushPreview(t *testing.T) { + cases := []struct{ in, want string }{ + {"שלום **עולם**", "שלום עולם"}, + {"[image-embedded#640x480](/api/channel/x/files/abc)\nכותרת", "כותרת"}, + {"ראו [כאן](https://example.com/a?b=c) עכשיו", "ראו כאן עכשיו"}, + {"[quote-embedded#]ציטוט\n\n\nשורה", "ציטוט שורה"}, + {" ", ""}, + } + for _, c := range cases { + if got := pushPreview(c.in); got != c.want { + t.Errorf("pushPreview(%q) = %q, want %q", c.in, got, c.want) + } + } + + long := strings.Repeat("א", 2*maxPushBodyRunes) + got := pushPreview(long) + if !utf8.ValidString(got) { + t.Fatalf("preview is not valid UTF-8") + } + if n := utf8.RuneCountInString(got); n != maxPushBodyRunes+1 { + t.Errorf("preview length = %d runes, want %d + ellipsis", n, maxPushBodyRunes) + } + if !strings.HasSuffix(got, "…") { + t.Errorf("truncated preview should end with an ellipsis") + } +} + +func TestTruncateRunesKeepsShortInput(t *testing.T) { + if got := truncateRunes("abc", 3); got != "abc" { + t.Errorf("got %q", got) + } +} + +func TestContentDispositionAttachment(t *testing.T) { + got := contentDispositionAttachment("דוח שנתי 2024.pdf") + want := `attachment; filename="___ ____ 2024.pdf"; filename*=UTF-8''%D7%93%D7%95%D7%97%20%D7%A9%D7%A0%D7%AA%D7%99%202024.pdf` + if got != want { + t.Errorf("\n got %s\nwant %s", got, want) + } + // A space must never become "+": browsers do not decode it in filename*. + if strings.Contains(rfc5987Encode("a b"), "+") { + t.Errorf("space encoded as +") + } + if got := contentDispositionAttachment(`x"y\z.txt`); !strings.Contains(got, `filename="x_y_z.txt"`) { + t.Errorf("quote/backslash not neutralised in fallback: %s", got) + } + if got := contentDispositionAttachment("שלום"); !strings.Contains(got, `filename="file"`) { + t.Errorf("all-non-ascii name should fall back to a placeholder: %s", got) + } +} + +func TestTrimToCountsCharacters(t *testing.T) { + // 5 Hebrew letters = 10 bytes; the limit is in characters, like the + // form's maxlength, so all five fit under a cap of 5. + if got := trimTo("אבגדה", 5); got != "אבגדה" { + t.Errorf("got %q, want the whole string", got) + } + got := trimTo("אבגדה", 3) + if !utf8.ValidString(got) { + t.Fatalf("trimTo produced invalid UTF-8: %q", got) + } + if got != "אבג" { + t.Errorf("got %q, want %q", got, "אבג") + } + if got := trimTo(" abc ", 10); got != "abc" { + t.Errorf("got %q", got) + } + if got := trimTo("abcdef", 3); got != "abc" { + t.Errorf("got %q", got) + } +} + +func TestRemoteHost(t *testing.T) { + cases := map[string]string{ + "1.2.3.4:5678": "1.2.3.4", + "1.2.3.4": "1.2.3.4", + "[2001:db8::1]:443": "2001:db8::1", + "2001:db8::1": "2001:db8::1", + "2001:db8::1:2:3:4:5": "2001:db8::1:2:3:4:5", + "[2001:db8::1]": "2001:db8::1", + } + for in, want := range cases { + if got := remoteHost(in); got != want { + t.Errorf("remoteHost(%q) = %q, want %q", in, got, want) + } + } +} + +func TestIsHTTPURL(t *testing.T) { + ok := []string{"https://ads.example.com/banner.html", "http://a.b/c?d=1", "HTTPS://X.Y/"} + for _, u := range ok { + if !isHTTPURL(u) { + t.Errorf("isHTTPURL(%q) = false, want true", u) + } + } + bad := []string{"", "javascript:alert(1)", "javascript://x/%0aalert(1)", "data:text/html,hi", + "//evil.example/x", "https://", "ftp://a.b/c", "https://a.b/c d", "vbscript:x"} + for _, u := range bad { + if isHTTPURL(u) { + t.Errorf("isHTTPURL(%q) = true, want false", u) + } + } +} + +func TestIsSafeContactURL(t *testing.T) { + ok := []string{"", "https://example.com/contact", "http://wa.me/972500000000", "mailto:owner@example.com", "MAILTO:A@b.c"} + bad := []string{"example.com", "javascript:alert(1)", "mailto:", "https://x y", "data:text/html,hi", "/relative"} + for _, u := range ok { + if !isSafeContactURL(u) { + t.Errorf("%q should be accepted", u) + } + } + for _, u := range bad { + if isSafeContactURL(u) { + t.Errorf("%q should be refused", u) + } + } +} + +func TestSplitRegexRule(t *testing.T) { + cases := []struct { + rule, pat, rep string + ok bool + }{ + {`(.*?\!)(.*)#**$1**$2`, `(.*?\!)(.*)`, `**$1**$2`, true}, + // A '#' inside the pattern is escaped by the form; the replacement + // half keeps its own '#' characters as they are. + {`\#(\S+)#**#$1**`, `\#(\S+)`, `**#$1**`, true}, + // An escaped backslash before the separator is not an escape of it. + {`a\\#b`, `a\\`, `b`, true}, + {`no-separator`, ``, ``, false}, + {`#rep`, ``, `rep`, true}, + } + for _, c := range cases { + pat, rep, ok := splitRegexRule(c.rule) + if pat != c.pat || rep != c.rep || ok != c.ok { + t.Errorf("splitRegexRule(%q) = (%q, %q, %v), want (%q, %q, %v)", c.rule, pat, rep, ok, c.pat, c.rep, c.ok) + } + } + // The escaped form must compile to a literal '#'. + re, err := regexp.Compile(`\#(\S+)`) + if err != nil { + t.Fatalf("escaped '#' does not compile: %v", err) + } + if got := re.ReplaceAllString("see #tag now", "**#$1**"); got != "see **#tag** now" { + t.Errorf("hashtag rule: got %q", got) + } +} + +func TestNormalizeUsersMergesCaseDuplicates(t *testing.T) { + users := normalizeUsers([]User{ + {Email: "Owner@Example.com", ChannelRoles: map[string]ChannelRole{"a": RoleWriter}}, + {Email: " owner@example.com ", PublicName: "Owner", ChannelRoles: map[string]ChannelRole{"a": RoleOwner, "b": RoleModerator}}, + {Email: "", GlobalRole: RoleSuperAdmin}, + {Email: "Admin@Example.com", GlobalRole: RoleSuperAdmin}, + }) + if len(users) != 2 { + t.Fatalf("got %d users, want 2: %+v", len(users), users) + } + o := users[0] + if o.Email != "owner@example.com" || o.PublicName != "Owner" { + t.Errorf("merged owner: %+v", o) + } + if o.ChannelRoles["a"] != RoleOwner || o.ChannelRoles["b"] != RoleModerator { + t.Errorf("merged roles keep the strongest: %+v", o.ChannelRoles) + } + if users[1].Email != "admin@example.com" || users[1].GlobalRole != RoleSuperAdmin { + t.Errorf("admin: %+v", users[1]) + } +} + +func TestIsUploadRoute(t *testing.T) { + if !isUploadRoute("/api/channel/news/admin/upload") { + t.Error("upload route not recognised") + } + for _, p := range []string{"/api/channel/news/admin/new", "/api/support/tickets", "/admin/upload", "/api/channel/x/admin/upload/extra"} { + if isUploadRoute(p) { + t.Errorf("%s wrongly exempt", p) + } + } +} + +func TestIsPlausibleSlug(t *testing.T) { + for _, ok := range []string{"news", "Legacy_Channel", "a-b-c"} { + if !isPlausibleSlug(ok) { + t.Errorf("%q should be looked up", ok) + } + } + for _, bad := range []string{"", "x:messages:5", "a/b", "a b", strings.Repeat("a", 65)} { + if isPlausibleSlug(bad) { + t.Errorf("%q should be refused", bad) + } + } +} + +// A protocol-relative iframe source is what some channels saved before the +// URL check existed; it must keep working, while webhooks stay strict. +func TestIsFramableURLAcceptsProtocolRelative(t *testing.T) { + for _, ok := range []string{"https://ads.example.com/x", "//ads.example.com/banner.html"} { + if !isFramableURL(ok) { + t.Errorf("isFramableURL(%q) = false, want true", ok) + } + } + for _, bad := range []string{"//", "javascript:alert(1)", "/relative", "ftp://x/y", "//host with space/x"} { + if isFramableURL(bad) { + t.Errorf("isFramableURL(%q) = true, want false", bad) + } + } + if isHTTPURL("//ads.example.com/banner.html") { + t.Error("isHTTPURL must stay strict for webhook targets") + } +} + +// Only entries that changed are validated, so a legacy value the form cannot +// even display does not make the whole list unsaveable. +func TestChangedSettingsIgnoresUntouchedLegacyValues(t *testing.T) { + stored := Settings{ + {Key: "regex-replace", Value: "#(\\S+)#**#$1**"}, + {Key: "max_file_size", Value: float64(0)}, + {Key: "webhook_url", Value: "https://hooks.example.com/a"}, + } + next := Settings{ + {Key: "regex-replace", Value: "#(\\S+)#**#$1**"}, + {Key: "max_file_size", Value: float64(0)}, + {Key: "webhook_url", Value: "not a url"}, + {Key: "magnet_enabled", Value: true}, + } + changed := changedSettings(stored, next) + if len(changed) != 2 || changed[0].Key != "webhook_url" || changed[1].Key != "magnet_enabled" { + t.Fatalf("changed = %+v, want webhook_url and magnet_enabled", changed) + } + if err := validateSettings(&changed); err == nil || !strings.Contains(err.Error(), "webhook_url") { + t.Fatalf("expected the new webhook_url to be rejected, got %v", err) + } + untouched := changedSettings(stored, stored) + if len(untouched) != 0 { + t.Fatalf("re-saving the stored list must validate nothing, got %+v", untouched) + } +} + +// The import key guards an unauthenticated route, and a one-character key +// falls to guessing in minutes despite the failed-attempt limiter. Only a +// value being set is checked, so a short legacy key keeps working until the +// owner replaces it; the error text is what the frontend maps to Hebrew, so +// it is matched exactly. +func TestValidateSettingsRequiresLongAPISecretKey(t *testing.T) { + short := Settings{{Key: "api_secret_key", Value: "1"}} + err := validateSettings(&short) + if err == nil || err.Error() != "api_secret_key must be at least 16 characters" { + t.Fatalf("short key: got %v", err) + } + for _, v := range []string{"", " ", "0123456789abcdef", strings.Repeat("א", 16)} { + ok := Settings{{Key: "api_secret_key", Value: v}} + if err := validateSettings(&ok); err != nil { + t.Errorf("%q should be accepted: %v", v, err) + } + } + stored := Settings{{Key: "api_secret_key", Value: "1"}} + if changed := changedSettings(stored, stored); len(changed) != 0 { + t.Fatalf("an untouched legacy key must not be re-validated, got %+v", changed) + } +} diff --git a/backend/audit_leftovers_test.go b/backend/audit_leftovers_test.go new file mode 100644 index 00000000..f7b97554 --- /dev/null +++ b/backend/audit_leftovers_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// A support token that still arrives as ?token= must be moved into the header +// and out of the URL before the request logger (which prints RequestURI) sees +// it; a token already in the header wins; other routes are left untouched. +func TestHideTicketTokenQuery(t *testing.T) { + var seen *http.Request + h := hideTicketTokenQuery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r })) + + r := httptest.NewRequest("GET", "/api/support/tickets/abc?token=s3cr3t&x=1", nil) + h.ServeHTTP(httptest.NewRecorder(), r) + if got := seen.Header.Get(ticketTokenHeader); got != "s3cr3t" { + t.Errorf("header = %q, want the query token", got) + } + if strings.Contains(seen.URL.RawQuery, "s3cr3t") || strings.Contains(seen.RequestURI, "s3cr3t") { + t.Errorf("token still in URL: RawQuery=%q RequestURI=%q", seen.URL.RawQuery, seen.RequestURI) + } + if seen.URL.Query().Get("x") != "1" || seen.RequestURI != "/api/support/tickets/abc?x=1" { + t.Errorf("other query parameters lost: RawQuery=%q RequestURI=%q", seen.URL.RawQuery, seen.RequestURI) + } + if ticketToken(seen) != "s3cr3t" { + t.Errorf("ticketToken did not see the lifted token") + } + + // Header first: a query token never overrides one the client sent properly. + r = httptest.NewRequest("GET", "/api/support/tickets/abc?token=stale", nil) + r.Header.Set(ticketTokenHeader, "fresh") + h.ServeHTTP(httptest.NewRecorder(), r) + if got := seen.Header.Get(ticketTokenHeader); got != "fresh" { + t.Errorf("header = %q, want the header token to win", got) + } + if strings.Contains(seen.RequestURI, "stale") { + t.Errorf("stale query token still logged: %q", seen.RequestURI) + } + + // Any other route keeps its query string as sent. + r = httptest.NewRequest("GET", "/api/channel/x/messages?token=keep", nil) + h.ServeHTTP(httptest.NewRecorder(), r) + if seen.URL.RawQuery != "token=keep" || seen.Header.Get(ticketTokenHeader) != "" { + t.Errorf("non-support route was rewritten: RawQuery=%q header=%q", seen.URL.RawQuery, seen.Header.Get(ticketTokenHeader)) + } +} + +// Only the FCM token alphabet is accepted; anything else could never be +// delivered to and would only fill the subscription set. +func TestFcmTokenShape(t *testing.T) { + ok := []string{"abc:DEF-ghi_123", strings.Repeat("a", 50) + ":" + strings.Repeat("Z", 100)} + for _, tok := range ok { + if !fcmTokenRe.MatchString(tok) { + t.Errorf("%q should be accepted", tok) + } + } + bad := []string{"", "abc def", "abc/def", "abc+def", "abc=def", "אבג", "abc\n"} + for _, tok := range bad { + if fcmTokenRe.MatchString(tok) { + t.Errorf("%q should be refused", tok) + } + } +} + +// The SSE stream re-reads the role per event from the live map, so a change to +// the map must change the answer with no reconnect. +func TestEmailHasChannelRoleFollowsLiveMap(t *testing.T) { + const email = "sse-role-test@example.com" + t.Cleanup(func() { privilegesUsers.Delete(email) }) + + if emailHasChannelRole(email, "ch", RoleWriter) { + t.Fatal("unknown e-mail should hold no role") + } + if emailHasChannelRole("", "ch", RoleWriter) { + t.Fatal("empty e-mail should hold no role") + } + privilegesUsers.Store(email, User{Email: email, ChannelRoles: map[string]ChannelRole{"ch": RoleModerator}}) + if !emailHasChannelRole(email, "ch", RoleWriter) { + t.Error("moderator should satisfy writer") + } + if emailHasChannelRole(email, "ch", RoleOwner) { + t.Error("moderator should not satisfy owner") + } + if emailHasChannelRole(email, "other", RoleWriter) { + t.Error("role on one channel must not leak to another") + } + // Demotion takes effect on the next call. + privilegesUsers.Store(email, User{Email: email}) + if emailHasChannelRole(email, "ch", RoleWriter) { + t.Error("demoted user still seen as writer") + } + privilegesUsers.Store(email, User{Email: email, GlobalRole: RoleSuperAdmin}) + if !emailHasChannelRole(email, "ch", RoleOwner) { + t.Error("super admin should satisfy every channel role") + } +} diff --git a/backend/auth.go b/backend/auth.go index 0fa5069c..acc9449a 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -10,7 +10,6 @@ import ( "github.com/boj/redistore" "github.com/icza/dyno" - "github.com/redis/go-redis/v9" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/idtoken" @@ -37,12 +36,13 @@ type GoogleAuthValues struct { } type Session struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - PublicName string `json:"publicName"` - Picture string `json:"picture,omitempty"` - Privileges Privileges `json:"privileges,omitempty"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PublicName string `json:"publicName"` + Picture string `json:"picture,omitempty"` + GlobalRole GlobalRole `json:"globalRole,omitempty"` + ChannelRoles map[string]ChannelRole `json:"channelRoles,omitempty"` } type Response struct { @@ -66,6 +66,13 @@ func login(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var auth Auth + // Rationed first: the round trip to Google is the expensive part, but the + // failure paths below each spawn an audit-log write too, so an unlimited + // stream of malformed requests was an unlimited stream of goroutines. + if !allowOrRetryAfter(w, loginLimiter(clientKey(r)), "too many login attempts — please wait") { + return + } + if err := json.NewDecoder(r.Body).Decode(&auth); err != nil { go saveLoginFailedLog("Decode", err) http.Error(w, "error", http.StatusBadRequest) @@ -94,21 +101,36 @@ func login(w http.ResponseWriter, r *http.Request) { } if !token.Valid() { - go saveLoginFailedLog("Invalid token", nil) + go saveLoginFailedLog("Invalid token", errors.New("oauth2 token not valid")) http.Error(w, "Invalid token", http.StatusUnauthorized) return } tokenStr, _ := dyno.GetString(token.Extra("id_token")) tokenValidator, err := idtoken.NewValidator(ctx) + if err != nil { + go saveLoginFailedLog("NewValidator", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } payload, err := tokenValidator.Validate(ctx, tokenStr, googleOAuthClientId) if err != nil { http.Error(w, "Invalid token", http.StatusUnauthorized) return } + // The email address is the sole authorization principal (ADMIN_USERS, + // ownerEmail, channel roles), so an unproven one must not be accepted. + // Google always emits email_verified alongside email for the scopes we + // request, so an absent claim is treated as unverified. + if verified, verr := dyno.GetBoolean(payload.Claims["email_verified"]); verr != nil || !verified { + go saveLoginFailedLog("unverified email", errors.New("email_verified claim is false or absent")) + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + email, _ := dyno.GetString(payload.Claims["email"]) - go registeringEmail(email) + go registeringEmail("", normEmail(email)) u, err := getUser(ctx, payload.Claims) if err != nil { @@ -118,15 +140,25 @@ func login(w http.ResponseWriter, r *http.Request) { } picture, _ := dyno.GetString(payload.Claims["picture"]) userSession := Session{ - ID: u.ID, - Username: u.Username, - PublicName: u.PublicName, - Picture: picture, - Privileges: u.Privileges, - Email: u.Email, + ID: u.ID, + Username: u.Username, + PublicName: u.PublicName, + Picture: picture, + GlobalRole: u.GlobalRole, + ChannelRoles: u.ChannelRoles, + Email: u.Email, } session, _ := store.Get(r, cookieName) + // Rotate the session identifier before writing the authenticated principal, + // otherwise an attacker-planted cookie would be upgraded to the victim's + // session (fixation). redistore mints a fresh ID only when ID is empty. + if session.ID != "" { + session.Options.MaxAge = -1 + _ = session.Save(r, w) // drops session_<oldID> from Redis + w.Header().Del("Set-Cookie") // discard the expiry cookie; the real one is set below + session.ID = "" + } session.Values["user"] = userSession session.Options.MaxAge = 60 * 60 * 24 * 30 // 30 days if err := session.Save(r, w); err != nil { @@ -171,17 +203,6 @@ func checkLogin(next http.Handler) http.Handler { }) } -func checkPrivilege(r *http.Request, privilege Privilege) bool { - session, _ := store.Get(r, cookieName) - - s, ok := session.Values["user"].(Session) - if !ok { - return false - } - - return s.Privileges[privilege] -} - func getUserInfo(w http.ResponseWriter, r *http.Request) { session, _ := store.Get(r, cookieName) userInfo, ok := session.Values["user"].(Session) @@ -190,6 +211,23 @@ func getUserInfo(w http.ResponseWriter, r *http.Request) { return } + // Register the viewer against the channel they entered. This is the only + // per-channel writer of registered_emails, which backs the channel user + // count. Returns "" on the global mount, which stays unaffected. + if slug := channelSlugFromCtx(r); slug != "" { + go registeringEmail(slug, userInfo.Email) + } + + // Authorization resolves roles live (see sessionUser); the copy stored in + // the cookie at login time can be stale, so refresh it before responding + // or the UI would keep offering actions the backend now rejects. + userInfo.GlobalRole = "" + userInfo.ChannelRoles = nil + if u, ok := sessionUser(r); ok { + userInfo.GlobalRole = u.GlobalRole + userInfo.ChannelRoles = u.ChannelRoles + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(userInfo) } @@ -197,7 +235,8 @@ func getUserInfo(w http.ResponseWriter, r *http.Request) { func getUser(ctx context.Context, claims map[string]any) (*User, error) { var user User - email, _ := dyno.GetString(claims["email"]) + rawEmail, _ := dyno.GetString(claims["email"]) + email := normEmail(rawEmail) if email == "" { return nil, errors.New("email not found in claims") } @@ -208,7 +247,8 @@ func getUser(ctx context.Context, claims map[string]any) (*User, error) { id, _ := dyno.GetString(claims["sub"]) // Google user ID if v, ok := privilegesUsers.Load(email); ok { - user = v.(User) + stored := v.(User) + user = stored if user.ID != id && id != "" { user.ID = id } @@ -221,26 +261,59 @@ func getUser(ctx context.Context, claims map[string]any) (*User, error) { if user.PublicName == "" { user.PublicName = name } - privilegesUsers.Store(email, user) - users, err := dbGetUsersList(ctx) - if err != nil && err != redis.Nil { - return nil, err + // Only write when a profile field actually changed. Every privileged + // login used to rewrite the whole users:list blob under WATCH, so a + // burst of logins contended with each other and with role edits, and + // the loser's login answered 500 — for a write that changed nothing. + if user.ID == stored.ID && user.Username == stored.Username && + user.Email == stored.Email && user.PublicName == stored.PublicName { + return &user, nil } - for i, u := range users { - if u.Email == email { - users[i] = user + // Refresh only the profile fields, atomically. Writing the whole in-memory + // User back would race with (and undo) a concurrent role edit, and the + // roles are owned by the admin paths, not by a login. + if err := dbUpdateUsersList(ctx, func(users []User) []User { + for i := range users { + if normEmail(users[i].Email) == email { + users[i].ID = user.ID + users[i].Username = user.Username + users[i].Email = user.Email + users[i].PublicName = user.PublicName + } } - } - if err := dbSetUsersList(ctx, users); err != nil { + return users + }); err != nil { return nil, err } + // Update the live map the same way: merge profile fields onto whatever + // entry is CURRENT, never store the pre-refresh snapshot wholesale — a + // role rebuild landing between the Load above and here would otherwise be + // overwritten with pre-revocation roles (or a deleted entry resurrected). + if cur, ok := privilegesUsers.Load(email); ok { + c := cur.(User) + c.ID, c.Username, c.Email, c.PublicName = user.ID, user.Username, user.Email, user.PublicName + privilegesUsers.Store(email, c) + } } else { + // PublicName is what posts and the audit trail show; a first-time user + // used to get none, so their first message carried an empty author. user = User{ - ID: id, - Username: name, - Email: email, + ID: id, + Username: name, + Email: email, + PublicName: name, } } return &user, nil } + +func registeringEmail(slug, email string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if slug == "" { + rdb.SAdd(ctx, "registered_emails", email) + } else { + rdb.SAdd(ctx, "channel:"+slug+":registered_emails", email) + } +} diff --git a/backend/bodylimit_test.go b/backend/bodylimit_test.go new file mode 100644 index 00000000..dc54a8e5 --- /dev/null +++ b/backend/bodylimit_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Handlers decode straight into memory with json.NewDecoder(r.Body). The cap is +// applied once as router middleware rather than at each of the ~25 decode sites, +// so this pins both halves of that contract: JSON bodies are bounded, and the +// multipart upload path — which enforces its own, much larger, per-channel +// limit — is left alone. +func TestLimitRequestBody(t *testing.T) { + // Reads the body exactly as a real handler would. + drain := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 4096) + for { + _, err := r.Body.Read(buf) + if err != nil { + if strings.Contains(err.Error(), "too large") { + http.Error(w, "too large", http.StatusRequestEntityTooLarge) + return + } + break + } + } + w.WriteHeader(http.StatusOK) + }) + h := limitRequestBody(drain) + + cases := []struct { + name string + path string + contentType string + size int + wantStatus int + }{ + {"small json passes", "/api/anything", "application/json", 1024, http.StatusOK}, + {"json just under the cap passes", "/api/anything", "application/json", maxJSONRequestBody - 1024, http.StatusOK}, + {"oversized json is refused", "/api/anything", "application/json", maxJSONRequestBody + (1 << 20), http.StatusRequestEntityTooLarge}, + // uploadFile sets its own MaxBytesReader from the channel's configured + // limit, which is far above the JSON cap; capping it here would break + // every file upload. + {"multipart upload route is exempt", "/api/channel/x/admin/upload", "multipart/form-data; boundary=xyz", maxJSONRequestBody + (1 << 20), http.StatusOK}, + // The exemption is by route, not by Content-Type: a client can label + // any body multipart, and the JSON handlers must stay capped regardless. + {"multipart content type on a json route is still capped", "/api/channel/x/admin/new", "multipart/form-data; boundary=xyz", maxJSONRequestBody + (1 << 20), http.StatusRequestEntityTooLarge}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(strings.Repeat("a", c.size))) + req.Header.Set("Content-Type", c.contentType) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != c.wantStatus { + t.Errorf("%d byte %s body: got status %d, want %d", + c.size, c.contentType, rec.Code, c.wantStatus) + } + }) + } +} diff --git a/backend/channelInfo.go b/backend/channelInfo.go index 9f2ef235..3ebe0631 100644 --- a/backend/channelInfo.go +++ b/backend/channelInfo.go @@ -4,83 +4,216 @@ import ( "context" "encoding/json" "net/http" - "strconv" + "strings" "time" - - "github.com/icza/dyno" ) +// PublicFeatures is the subset of ChannelFeatures the UI needs to know about: +// exactly the toggles requireFeature gates a user-visible action on. Without +// them the client offers a button the backend then answers with 403. +type PublicFeatures struct { + Reactions bool `json:"reactions"` + FileUploads bool `json:"fileUploads"` + Reports bool `json:"reports"` + ScheduledMessages bool `json:"scheduledMessages"` +} + type Channel struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - CreatedAt time.Time `json:"created_at"` - LogoUrl string `json:"logoUrl"` - Views int64 `json:"views"` - RequireAuthForViewFiles bool `json:"require_auth_for_view_files"` - ContactUs string `json:"contact_us"` + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + LogoUrl string `json:"logoUrl"` + Views int64 `json:"views"` + RequireAuthForViewFiles bool `json:"require_auth_for_view_files"` + ContactUs string `json:"contact_us"` + Features PublicFeatures `json:"features"` + // Search settings, for the management screen. Nothing secret: the page + // itself publishes all of it (robots meta, title, description), and a + // private channel is never in search whatever its toggle says. + RequireAuth bool `json:"require_auth"` + SeoHidden bool `json:"seo_hidden"` + SeoTitle string `json:"seo_title"` + SeoDescription string `json:"seo_description"` } func getChannelInfo(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - amount, err := dbGetUsersAmount(ctx) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - - amount, _ = dyno.GetInteger(amount) + slug := channelSlugFromCtx(r) + ch := channelFromCtx(r) - c, err := getChannelDetails(ctx) + amount, err := dbGetUsersAmount(ctx, slug) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } var channel Channel - channel.Id, _ = strconv.Atoi(c["id"]) - channel.Name = c["name"] - channel.Description = c["description"] - channel.CreatedAt, _ = time.Parse(time.RFC3339, c["created_at"]) - channel.Views = amount //strconv.Atoi(c["views"]) - channel.LogoUrl = c["logoUrl"] - channel.RequireAuthForViewFiles = settingConfig.RequireAuthForViewFiles - channel.ContactUs = settingConfig.ContactUs + if ch != nil { + channel.Id = ch.Slug + channel.Name = ch.Name + channel.Description = ch.Description + channel.CreatedAt = ch.CreatedAt + channel.LogoUrl = ch.LogoUrl + channel.RequireAuthForViewFiles = ch.Features.RequireAuthFiles + channel.ContactUs = ch.ContactUs + channel.RequireAuth = ch.Features.RequireAuth + channel.SeoHidden = ch.SeoHidden + channel.SeoTitle = ch.SeoTitle + channel.SeoDescription = ch.SeoDescription + channel.Features = PublicFeatures{ + Reactions: ch.Features.Reactions, + FileUploads: ch.Features.FileUploads, + Reports: ch.Features.Reports, + ScheduledMessages: ch.Features.ScheduledMessages, + } + } + channel.Views = amount w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(channel) } +// isSafeLogoURL accepts an empty value, a relative path (what the upload flow +// produces, e.g. /api/channel/<slug>/files/<id>), or an http(s) URL. Anything +// carrying another scheme is rejected, since the value is reflected into an +// <img src> for every visitor. +// isSafeContactURL accepts an empty value (button hidden), an http(s) URL or a +// mailto: address — the only schemes the header button opens. A bare +// "example.com" is refused rather than stored: with no scheme the browser would +// open it as a path under this site and the button would lead nowhere. +func isSafeContactURL(u string) bool { + if u == "" { + return true + } + lower := strings.ToLower(u) + if strings.HasPrefix(lower, "mailto:") { + return len(u) > len("mailto:") && !strings.ContainsAny(u, " \t\r\n") + } + return (strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")) && + !strings.ContainsAny(u, " \t\r\n\"'<>") +} + +func isSafeLogoURL(u string) bool { + u = strings.TrimSpace(u) + if u == "" { + return true + } + // A scheme is "<letters>:" at the very start; a relative path has none. + if i := strings.IndexByte(u, ':'); i >= 0 { + scheme := u[:i] + // A "/" or "?" or "#" before the colon means it is a path, not a scheme + // (e.g. "/a:b" is a relative path whose first segment contains a colon). + if !strings.ContainsAny(scheme, "/?#") { + lower := strings.ToLower(scheme) + return lower == "http" || lower == "https" + } + } + return true +} + func editChannelInfo(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + + // Every field is a pointer so an ABSENT field can be told apart from a + // deliberately cleared one — only a field the client actually sent is + // written. Previously name/description/logoUrl were plain strings and always + // written, so any partial request (a client that omitted a field, or a form + // that opened before the channel had loaded) blanked the stored value. That + // is what left a channel with an empty name whose /info then came back with + // every field empty. type Request struct { - Name string `json:"name"` - Description string `json:"description"` - LogoUrl string `json:"logoUrl"` + Name *string `json:"name"` + Description *string `json:"description"` + LogoUrl *string `json:"logoUrl"` + ContactUs *string `json:"contactUs"` + channelSeoRequest } var req Request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "error", http.StatusInternalServerError) + http.Error(w, "Invalid request body", http.StatusBadRequest) return } defer r.Body.Close() - if _, err := rdb.HSet(ctx, "channel:1", "name", req.Name, "description", req.Description, "logoUrl", req.LogoUrl).Result(); err != nil { + // A channel must keep a name: refuse to blank it. Description, logo and + // contact can be cleared, and an omitted field is simply left as-is. + if req.Name != nil { + trimmed := strings.TrimSpace(*req.Name) + req.Name = &trimmed + if trimmed == "" { + http.Error(w, "channel name cannot be empty", http.StatusBadRequest) + return + } + if channelFieldTooLong(trimmed, maxChannelNameLen) { + http.Error(w, "channel name is too long", http.StatusBadRequest) + return + } + } + if req.Description != nil && channelFieldTooLong(*req.Description, maxChannelDescLen) { + http.Error(w, "description is too long", http.StatusBadRequest) + return + } + // The logo is reflected to every visitor as an <img src>. A relative path + // (what the upload flow produces) or an http(s) URL is fine; anything with + // another scheme — javascript:, data: — is refused. + if req.LogoUrl != nil && !isSafeLogoURL(*req.LogoUrl) { + http.Error(w, "invalid logo URL", http.StatusBadRequest) + return + } + if req.ContactUs != nil { + trimmed := strings.TrimSpace(*req.ContactUs) + req.ContactUs = &trimmed + if !isSafeContactURL(trimmed) { + http.Error(w, "invalid contact URL", http.StatusBadRequest) + return + } + } + + hashKey := "channel:" + slug + args := []any{} + if req.Name != nil { + args = append(args, "name", *req.Name) + } + if req.Description != nil { + args = append(args, "description", *req.Description) + } + if req.LogoUrl != nil { + args = append(args, "logoUrl", *req.LogoUrl) + } + if req.ContactUs != nil { + args = append(args, "contactUs", *req.ContactUs) + } + seoArgs, err := req.channelSeoRequest.hashArgs() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + args = append(args, seoArgs...) + if len(args) == 0 { + // Nothing to change; do not issue an empty HSet. + res := Response{Success: true} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) + return + } + if _, err := rdb.HSet(ctx, hashKey, args...).Result(); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } + // The sitemap is cached for a quarter of an hour; a channel taken out of + // search must leave it now, not when the cache happens to expire. + if len(seoArgs) > 0 { + resetSitemapCache() + } res := Response{Success: true} + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } - -func registeringEmail(email string) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - rdb.SAdd(ctx, "registered_emails", email) -} diff --git a/backend/channel_create.go b/backend/channel_create.go new file mode 100644 index 00000000..1b87aa4a --- /dev/null +++ b/backend/channel_create.go @@ -0,0 +1,306 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "strings" + "time" + "unicode/utf8" +) + +const ( + // maxChannelsPerOwner caps self-service creation per account. The endpoint is + // open to every logged-in user, so without a cap one account can mint + // channels (and their Redis keyspace) without bound. + maxChannelsPerOwner = 5 + // Field caps on the stored channel record, in characters (the forms cap + // the same fields with maxlength, which also counts characters). + maxChannelNameLen = 80 + maxChannelDescLen = 2000 +) + +// channelFieldTooLong counts characters, not bytes: the byte-length check it +// replaces cut a Hebrew name off at half the advertised limit, since every +// Hebrew letter is two bytes in UTF-8, while the form let the user type the +// full 80 and then showed an unexplained error. +func channelFieldTooLong(s string, maxRunes int) bool { + return utf8.RuneCountInString(s) > maxRunes +} + +// reservedSlugs are the top-level frontend route paths (see +// frontend/src/app/app.routes.ts) plus the prefixes the backend serves itself. +// Channels live at /channel/<slug> today, so none of these can clash yet; they +// stay reserved so a short /<slug> address can be introduced later without a +// channel shadowing a real page. +var reservedSlugs = map[string]struct{}{ + "login": {}, + "channel": {}, + "channels": {}, + "super-admin": {}, + "admin": {}, + "api": {}, + "assets": {}, + "auth": {}, + "static": {}, + // Server-rendered pages and search-engine files (seo_content.go, + // sitemap.go). + "open-channel": {}, + "community-channel": {}, + "whatsapp-alternative": {}, + "netfree-channel": {}, + "privacy": {}, + "terms": {}, + "robots": {}, + "sitemap": {}, +} + +func isReservedSlug(slug string) bool { + _, ok := reservedSlugs[slug] + return ok +} + +// slugAvailability reports why a slug cannot be used, or "" when it is free. +// Kept separate from the handlers so both the create and the live-check +// endpoint answer identically. +func slugAvailability(ctx context.Context, slug string) (string, error) { + if !slugRegex.MatchString(slug) { + return "invalid", nil + } + if isReservedSlug(slug) { + return "reserved", nil + } + exists, err := dbChannelExists(ctx, slug) + if err != nil { + return "", err + } + if exists { + return "taken", nil + } + return "", nil +} + +// countOwnedChannels counts the channels the email holds RoleOwner on. The +// users:list blob is the same source dbAssignChannelRole writes, so a channel +// created a moment ago is already counted. +func countOwnedChannels(ctx context.Context, email string) (int, error) { + users, err := dbGetUsersList(ctx) + if err != nil { + return 0, err + } + email = normEmail(email) + for _, u := range users { + if normEmail(u.Email) != email { + continue + } + var n int + for _, role := range u.ChannelRoles { + if role == RoleOwner { + n++ + } + } + return n, nil + } + return 0, nil +} + +// sessionEmail returns the authenticated email carried by the session cookie. +// Self-service creation must take the owner from here and never from the +// request body: an email accepted from the body would let anyone squat a slug +// and hand ownership to somebody else's address. +func sessionEmail(r *http.Request) (Session, bool) { + session, _ := store.Get(r, cookieName) + s, ok := session.Values["user"].(Session) + if !ok || s.Email == "" { + return Session{}, false + } + return s, true +} + +// sessionDisplayName is the human label recorded on the audit trail. +func sessionDisplayName(s Session) string { + if s.PublicName != "" { + return s.PublicName + } + if s.Username != "" { + return s.Username + } + return s.Email +} + +// POST /api/channels/create (authenticated) +// +// The only self-service channel creation path: a logged-in user gets their +// channel instantly. There is no approval queue; the super admin sees every +// channel, with its owner and creation date, on the channels screen. +func createChannelSelfService(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + s, ok := sessionEmail(r) + if !ok { + http.Error(w, "User not authenticated", http.StatusUnauthorized) + return + } + + // The creation throttle is spent further down, immediately before the channel + // is written. At three per hour, charging quota for a rejected slug meant + // three typos locked the account out for twenty minutes having created nothing. + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + defer r.Body.Close() + + var body struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + body.Name = strings.TrimSpace(body.Name) + if body.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + if channelFieldTooLong(body.Name, maxChannelNameLen) || channelFieldTooLong(body.Description, maxChannelDescLen) { + http.Error(w, "field too long", http.StatusBadRequest) + return + } + + // Sessions minted before emails were normalised may still carry the + // address in Google's casing; every grant below must use the canonical + // form or the owner would not match their own channel. + ownerEmail := normEmail(s.Email) + + reason, err := slugAvailability(ctx, body.Slug) + if err != nil { + http.Error(w, "error checking slug", http.StatusInternalServerError) + return + } + switch reason { + case "invalid": + http.Error(w, "invalid slug", http.StatusBadRequest) + return + case "reserved": + http.Error(w, "slug is reserved", http.StatusBadRequest) + return + case "taken": + http.Error(w, "slug already taken", http.StatusConflict) + return + } + + // Serialise creates by the same owner. The cap below is a check-then-act: + // countOwnedChannels reads the roles list, but the role that would make this + // channel count is only assigned after dbCreateChannel further down. Three + // concurrent requests (which the creation limiter's burst of 3 allows) all + // read the same count, all pass, and all create — an account holding five + // channels ends up with seven. dbCreateChannel's atomic slug claim does not + // help, because the slugs are different. + // + // The lock is short-lived and owner-scoped, so it costs nothing to anyone + // else and cannot outlive a crashed request. + createLock := "channel_create:lock:" + ownerEmail + gotLock, lockErr := rdb.SetNX(ctx, createLock, 1, 15*time.Second).Result() + if lockErr != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if !gotLock { + http.Error(w, "another channel is already being created for this account", http.StatusConflict) + return + } + defer rdb.Del(ctx, createLock) + + owned, err := countOwnedChannels(ctx, ownerEmail) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if owned >= maxChannelsPerOwner { + http.Error(w, "channel limit reached for this account", http.StatusForbidden) + return + } + + // Validation is done and nothing has been persisted yet, so this is the first + // point where the request actually costs a channel. + if !allowOrRetryAfter(w, channelCreateLimiter(ownerEmail), "too many requests — please try again later") { + return + } + + channel := &ChannelData{ + Slug: body.Slug, + Name: body.Name, + Description: body.Description, + OwnerEmail: ownerEmail, + CreatedAt: time.Now(), + Features: defaultChannelFeatures(), + } + + if err := dbCreateChannel(ctx, channel); err != nil { + // dbCreateChannel claims the slug atomically, so this is the branch that + // actually decides a race between two concurrent creates. + if errors.Is(err, errChannelExists) { + http.Error(w, "slug already taken", http.StatusConflict) + return + } + http.Error(w, "error creating channel", http.StatusInternalServerError) + return + } + + if err := dbAssignChannelRole(ctx, ownerEmail, channel.Slug, RoleOwner); err != nil { + log.Printf("createChannelSelfService: %s created but owner role for %s not assigned: %v\n", channel.Slug, ownerEmail, err) + } + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after createChannelSelfService(%s): %v", channel.Slug, err) + } + + // The channel is stored; the welcome mail goes out in the background and + // cannot hold up or fail this response. + created, ownerName := *channel, sessionDisplayName(s) + dispatchMagnetMail(func(context.Context) (*magnetMailEvent, bool) { + return channelCreatedMail(&created, ownerEmail, ownerName) + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "slug": channel.Slug, + "name": channel.Name, + }) +} + +// GET /api/channels/slug-available?slug=<slug> (authenticated) +// Live feedback for the create form. +func checkSlugAvailable(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + s, ok := sessionEmail(r) + if !ok { + http.Error(w, "User not authenticated", http.StatusUnauthorized) + return + } + + // Stays at the top: this endpoint's whole cost IS the lookup, so there is no + // later point to move it to. Retry-After lets the form back off precisely + // instead of leaving the live check silently stuck. + if !allowOrRetryAfter(w, slugCheckLimiter(s.Email), "too many requests — please try again later") { + return + } + + reason, err := slugAvailability(ctx, r.URL.Query().Get("slug")) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "available": reason == "", + "reason": reason, + }) +} diff --git a/backend/channel_create_test.go b/backend/channel_create_test.go new file mode 100644 index 00000000..6c75c88d --- /dev/null +++ b/backend/channel_create_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// These tests exercise the self-service creation checks against real Redis, +// same as migrations_test.go, and clean up after themselves. + +func TestSlugAvailabilityRejectsReservedAndInvalid(t *testing.T) { + ctx := testCtx(t) + + // Every top-level frontend route must be refused, or the channel would be + // shadowed by the SPA page at the same path. + for _, slug := range []string{"login", "channel", "super-admin", "admin", "api", "assets"} { + reason, err := slugAvailability(ctx, slug) + if err != nil { + t.Fatalf("slugAvailability(%q): %v", slug, err) + } + if reason != "reserved" { + t.Errorf("slugAvailability(%q) = %q, want \"reserved\"", slug, reason) + } + } + + for _, slug := range []string{"", "ab", "Upper", "-lead", "trail-", "has_underscore", "has space"} { + reason, err := slugAvailability(ctx, slug) + if err != nil { + t.Fatalf("slugAvailability(%q): %v", slug, err) + } + if reason != "invalid" { + t.Errorf("slugAvailability(%q) = %q, want \"invalid\"", slug, reason) + } + } +} + +func TestSlugAvailabilityTakenAndFree(t *testing.T) { + ctx := testCtx(t) + + if reason, err := slugAvailability(ctx, "selfsvc-free-slug"); err != nil || reason != "" { + t.Fatalf("free slug: reason=%q err=%v", reason, err) + } + + seedChannel(t, ctx, "selfsvc-taken-slug", Settings{}) + + reason, err := slugAvailability(ctx, "selfsvc-taken-slug") + if err != nil { + t.Fatalf("slugAvailability: %v", err) + } + if reason != "taken" { + t.Errorf("existing channel: reason = %q, want \"taken\"", reason) + } +} + +// The per-account cap is what stops one logged-in user minting channels +// without bound, so it must count exactly the owner grants of that email. +func TestCountOwnedChannelsFeedsTheCap(t *testing.T) { + ctx := testCtx(t) + + const email = "selfsvc-cap@example.com" + const other = "selfsvc-other@example.com" + + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dbUpdateUsersList(cctx, func(users []User) []User { + kept := make([]User, 0, len(users)) + for _, u := range users { + if u.Email != email && u.Email != other { + kept = append(kept, u) + } + } + return kept + }) + }) + + if n, err := countOwnedChannels(ctx, email); err != nil || n != 0 { + t.Fatalf("unknown email: n=%d err=%v", n, err) + } + + // Moderator/writer grants are not ownership and must not count towards it. + if err := dbAssignChannelRole(ctx, email, "selfsvc-mod", RoleModerator); err != nil { + t.Fatalf("assign moderator: %v", err) + } + if err := dbAssignChannelRole(ctx, email, "selfsvc-write", RoleWriter); err != nil { + t.Fatalf("assign writer: %v", err) + } + if n, err := countOwnedChannels(ctx, email); err != nil || n != 0 { + t.Fatalf("non-owner roles counted: n=%d err=%v", n, err) + } + + // Another account's channels are irrelevant to this account's cap. + if err := dbAssignChannelRole(ctx, other, "selfsvc-other-1", RoleOwner); err != nil { + t.Fatalf("assign other owner: %v", err) + } + + for i := 0; i < maxChannelsPerOwner; i++ { + slug := "selfsvc-owned-" + string(rune('a'+i)) + if err := dbAssignChannelRole(ctx, email, slug, RoleOwner); err != nil { + t.Fatalf("assign owner %s: %v", slug, err) + } + n, err := countOwnedChannels(ctx, email) + if err != nil { + t.Fatalf("count: %v", err) + } + want := i + 1 + if n != want { + t.Fatalf("after %d owner grants: n=%d, want %d", want, n, want) + } + // The handler refuses at >= maxChannelsPerOwner. + if capped := n >= maxChannelsPerOwner; capped != (want == maxChannelsPerOwner) { + t.Errorf("cap decision at %d owned channels = %v", n, capped) + } + } +} + +// A newly created channel must not come out disabled, and the flag must be a +// pure zero value for every features blob written before it existed. +func TestDisabledDefaultsOff(t *testing.T) { + ctx := testCtx(t) + + if defaultChannelFeatures().Disabled { + t.Error("defaultChannelFeatures must leave Disabled false") + } + + seedChannel(t, ctx, "selfsvc-disabled-default", Settings{}) + + // Overwrite with a blob shaped exactly like the ones stored before the field + // existed: dbGetChannel must unmarshal the missing key to false. + if err := rdb.Set(ctx, "channel:selfsvc-disabled-default:features", + `{"reactions":true,"fileUploads":true,"reports":true,"scheduledMessages":true}`, 0).Err(); err != nil { + t.Fatalf("write legacy features blob: %v", err) + } + if f := featuresOf(t, ctx, "selfsvc-disabled-default"); f.Disabled { + t.Error("a features blob without the field must unmarshal Disabled to false") + } +} diff --git a/backend/channel_magnet_stats_test.go b/backend/channel_magnet_stats_test.go new file mode 100644 index 00000000..b1a03e43 --- /dev/null +++ b/backend/channel_magnet_stats_test.go @@ -0,0 +1,355 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// A channel's own Magnet numbers (ads.go: getChannelMagnetStats and the +// stats-key routes). The owner's rule: an unlocked channel sees its own +// numbers through its own key; a locked one sees nothing, and Magnet is never +// even asked. Real Redis, like magnet_stats_test.go. + +const ( + cmsOwnerEmail = "cms-owner@example.com" + cmsModeratorEmail = "cms-moderator@example.com" + cmsStrangerEmail = "cms-stranger@example.com" +) + +type cmsFixture struct { + slug string + upstream *atomic.Int32 + // seenKey is the x-magnet-stats-key of the last upstream call. + seenKey *atomic.Value + base GlobalMagnetConfig +} + +// newCMSFixture seeds an unlocked channel with an owner and a moderator, a +// fake site-stats that counts its calls, and a global config whose own stats +// key (testStatsKeyA) must never be the one a channel route uses. +func newCMSFixture(t *testing.T, slug string) *cmsFixture { + t.Helper() + ctx := testCtx(t) + useTestSessionStore(t) + base := preserveGlobalMagnet(t, ctx) + + seedChannel(t, ctx, slug, Settings{{Key: "magnet_enabled", Value: "1"}}) + + privilegesUsers.Store(cmsOwnerEmail, User{Email: cmsOwnerEmail, ChannelRoles: map[string]ChannelRole{slug: RoleOwner}}) + privilegesUsers.Store(cmsModeratorEmail, User{Email: cmsModeratorEmail, ChannelRoles: map[string]ChannelRole{slug: RoleModerator}}) + privilegesUsers.Store(cmsStrangerEmail, User{Email: cmsStrangerEmail}) + t.Cleanup(func() { + privilegesUsers.Delete(cmsOwnerEmail) + privilegesUsers.Delete(cmsModeratorEmail) + privilegesUsers.Delete(cmsStrangerEmail) + }) + + f := &cmsFixture{slug: slug, upstream: &atomic.Int32{}, seenKey: &atomic.Value{}} + f.seenKey.Store("") + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.upstream.Add(1) + f.seenKey.Store(r.Header.Get("x-magnet-stats-key")) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"site":{"id":"s1","domain":"channel.example"},"currency":"ILS","clicks":{"today":1,"week":2,"month":3}}`)) + })) + t.Cleanup(up.Close) + magnetStatsURL = up.URL + "/functions/v1/site-stats" + + cfg := base + cfg.LockAll = false + cfg.LockedChannels = nil + cfg.Enabled = true + cfg.ApiKey = "platform-public-embed-key" + cfg.StatsKey = testStatsKeyA + storeMagnetConfig(t, ctx, cfg) + f.base = cfg + return f +} + +// call runs handler behind the route's real role gate, as the session user, +// with the channel as channelMiddleware would load it (live features). +func (f *cmsFixture) call(t *testing.T, email string, handler http.HandlerFunc, method string, body any) *httptest.ResponseRecorder { + t.Helper() + var rd io.Reader + if body != nil { + raw, _ := json.Marshal(body) + rd = bytes.NewReader(raw) + } + r := requestAs(t, Session{ID: email, Email: email}, f.slug, method, "/api/channel/"+f.slug+"/admin/magnet", rd, nil) + ch, err := dbGetChannel(r.Context(), f.slug) + if err != nil { + t.Fatalf("load channel: %v", err) + } + r = r.WithContext(context.WithValue(r.Context(), channelCtxKey, ch)) + rec := httptest.NewRecorder() + protectedWithChannelRole(RoleOwner, handler)(rec, r) + return rec +} + +func (f *cmsFixture) setChannelKey(t *testing.T, key string) { + t.Helper() + if err := dbSetChannelMagnetStatsKey(testCtx(t), f.slug, key); err != nil { + t.Fatalf("store channel key: %v", err) + } +} + +func (f *cmsFixture) storedChannelKey(t *testing.T) string { + t.Helper() + k, err := dbGetChannelMagnetStatsKey(testCtx(t), f.slug) + if err != nil { + t.Fatalf("read channel key: %v", err) + } + return k +} + +func errorCode(t *testing.T, rec *httptest.ResponseRecorder) (code, message string) { + t.Helper() + var e map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { + t.Fatalf("error body is not JSON: %s", rec.Body) + } + return e["error"], e["message"] +} + +func TestChannelMagnetStatsLockedRefusesWithoutCallingMagnet(t *testing.T) { + f := newCMSFixture(t, "cms-test-locked") + ctx := testCtx(t) + + locks := []struct { + name string + apply func(t *testing.T) + }{ + {"lockAll", func(t *testing.T) { + cfg := f.base + cfg.LockAll = true + storeMagnetConfig(t, ctx, cfg) + }}, + {"locked list", func(t *testing.T) { + cfg := f.base + cfg.LockedChannels = []string{"someone-else", f.slug} + storeMagnetConfig(t, ctx, cfg) + }}, + {"feature flag", func(t *testing.T) { + storeMagnetConfig(t, ctx, f.base) + if err := dbUpdateChannelFeatures(ctx, f.slug, func(cf *ChannelFeatures) { cf.MagnetLockedByAdmin = true }); err != nil { + t.Fatalf("lock flag: %v", err) + } + t.Cleanup(func() { + _ = dbUpdateChannelFeatures(context.Background(), f.slug, func(cf *ChannelFeatures) { cf.MagnetLockedByAdmin = false }) + }) + }}, + } + for _, lk := range locks { + t.Run(lk.name, func(t *testing.T) { + // A key saved while the channel was still its own. + f.setChannelKey(t, testStatsKeyB) + lk.apply(t) + before := f.upstream.Load() + + for _, c := range []struct { + name string + handler http.HandlerFunc + method string + body any + }{ + {"stats", getChannelMagnetStats, http.MethodGet, nil}, + {"key get", getChannelMagnetStatsKey, http.MethodGet, nil}, + {"key set", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"statsKey": testStatsKeyA}}, + {"key clear", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"clearStatsKey": true}}, + } { + rec := f.call(t, cmsOwnerEmail, c.handler, c.method, c.body) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s: status = %d, want 403; body %s", c.name, rec.Code, rec.Body) + } + if code, msg := errorCode(t, rec); code != "magnet_locked" || msg == "" { + t.Errorf("%s: error = %q %q, want magnet_locked with a Hebrew message", c.name, code, msg) + } + if strings.Contains(rec.Body.String(), "statsKeySet") { + t.Errorf("%s: a locked channel learns about the key: %s", c.name, rec.Body) + } + } + if n := f.upstream.Load() - before; n != 0 { + t.Errorf("Magnet called %d times for a locked channel", n) + } + if got := f.storedChannelKey(t); got != testStatsKeyB { + t.Errorf("stored key changed under the lock: %q", got) + } + }) + } +} + +func TestChannelMagnetStatsWithoutKeyDoesNotCallMagnet(t *testing.T) { + f := newCMSFixture(t, "cms-test-nokey") + f.setChannelKey(t, "") + + rec := f.call(t, cmsOwnerEmail, getChannelMagnetStats, http.MethodGet, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body %s", rec.Code, rec.Body) + } + if code, _ := errorCode(t, rec); code != "missing_stats_key" { + t.Errorf("error = %q, want missing_stats_key", code) + } + if n := f.upstream.Load(); n != 0 { + t.Errorf("Magnet called %d times without a channel key", n) + } + // The platform's key is configured, and must not stand in for the channel's. + if k := f.seenKey.Load().(string); k != "" { + t.Errorf("upstream saw a key: %q", k) + } +} + +func TestChannelMagnetStatsProxiesWithChannelKey(t *testing.T) { + f := newCMSFixture(t, "cms-test-proxy") + f.setChannelKey(t, testStatsKeyB) + + rec := f.call(t, cmsOwnerEmail, getChannelMagnetStats, http.MethodGet, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body %s", rec.Code, rec.Body) + } + if n := f.upstream.Load(); n != 1 { + t.Fatalf("Magnet called %d times, want 1", n) + } + if k := f.seenKey.Load().(string); k != testStatsKeyB { + t.Errorf("upstream saw key %q, want the channel's own (never the platform's)", k) + } + if !strings.Contains(rec.Body.String(), "channel.example") { + t.Errorf("Magnet's body not passed through: %s", rec.Body) + } + for _, k := range []string{testStatsKeyA, testStatsKeyB} { + if strings.Contains(rec.Body.String(), k) { + t.Errorf("response leaks a stats key: %s", rec.Body) + } + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", cc) + } +} + +func TestChannelMagnetStatsKeyIsNeverReturned(t *testing.T) { + f := newCMSFixture(t, "cms-test-redact") + f.setChannelKey(t, "") + + steps := []struct { + name string + handler http.HandlerFunc + method string + body any + wantStatus int + wantStored string + wantSet bool + }{ + {"get empty", getChannelMagnetStatsKey, http.MethodGet, nil, 200, "", false}, + {"set", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"statsKey": " " + testStatsKeyB + "\n"}, 200, testStatsKeyB, true}, + {"get stored", getChannelMagnetStatsKey, http.MethodGet, nil, 200, testStatsKeyB, true}, + {"empty refused", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"statsKey": ""}, 400, testStatsKeyB, false}, + {"embed key refused", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"statsKey": "public-embed-key"}, 400, testStatsKeyB, false}, + {"uppercase refused", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"statsKey": strings.ToUpper(testStatsKeyA)}, 400, testStatsKeyB, false}, + {"replace", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"statsKey": testStatsKeyA}, 200, testStatsKeyA, true}, + {"clear wins", setChannelMagnetStatsKey, http.MethodPost, map[string]any{"clearStatsKey": true, "statsKey": testStatsKeyB}, 200, "", false}, + } + for _, s := range steps { + rec := f.call(t, cmsOwnerEmail, s.handler, s.method, s.body) + if rec.Code != s.wantStatus { + t.Fatalf("%s: status = %d, want %d; body %s", s.name, rec.Code, s.wantStatus, rec.Body) + } + if got := f.storedChannelKey(t); got != s.wantStored { + t.Errorf("%s: stored = %q, want %q", s.name, got, s.wantStored) + } + if s.wantStatus != http.StatusOK { + if code, msg := errorCode(t, rec); code != "invalid_stats_key" || msg == "" { + t.Errorf("%s: error = %q %q, want invalid_stats_key with a message", s.name, code, msg) + } + continue + } + if got := assertRedacted(t, rec.Body.Bytes(), testStatsKeyA, testStatsKeyB); got != s.wantSet { + t.Errorf("%s: statsKeySet = %v, want %v", s.name, got, s.wantSet) + } + } + + // Nor through the settings blob the magnet tab loads, nor the public + // magnet endpoint every viewer reads. + f.setChannelKey(t, testStatsKeyB) + for name, h := range map[string]http.HandlerFunc{"settings/get": getSettings, "ads/magnet": getMagnetAdsSettings} { + rec := f.call(t, cmsOwnerEmail, h, http.MethodGet, nil) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status = %d", name, rec.Code) + } + if strings.Contains(rec.Body.String(), testStatsKeyB) || strings.Contains(rec.Body.String(), testStatsKeyA) { + t.Errorf("%s leaks a stats key: %s", name, rec.Body) + } + } +} + +func TestChannelMagnetStatsRequiresOwner(t *testing.T) { + f := newCMSFixture(t, "cms-test-roles") + f.setChannelKey(t, testStatsKeyB) + + for _, email := range []string{cmsModeratorEmail, cmsStrangerEmail} { + for name, h := range map[string]http.HandlerFunc{ + "stats": getChannelMagnetStats, "key get": getChannelMagnetStatsKey, "key set": setChannelMagnetStatsKey, + } { + method, body := http.MethodGet, any(nil) + if name == "key set" { + method, body = http.MethodPost, map[string]any{"clearStatsKey": true} + } + rec := f.call(t, email, h, method, body) + if rec.Code != http.StatusForbidden { + t.Errorf("%s as %s: status = %d, want 403", name, email, rec.Code) + } + } + } + if n := f.upstream.Load(); n != 0 { + t.Errorf("Magnet called %d times for a non-owner", n) + } + if got := f.storedChannelKey(t); got != testStatsKeyB { + t.Errorf("a non-owner changed the key: %q", got) + } +} + +// The public magnet endpoint is also what a locked channel's managers read to +// learn of the lock. It carries what a viewer needs to show the platform's ad +// and nothing more of the global config. +func TestLockedChannelMagnetSettingsCarryNoGlobalData(t *testing.T) { + f := newCMSFixture(t, "cms-test-public") + ctx := testCtx(t) + + cfg := f.base + cfg.LockedChannels = []string{"cms-other-locked", f.slug} + cfg.Snippet = `<script src="https://magnet.example/platform-embed.js"></script>` + cfg.Mode = "by_time" + cfg.PerSeconds = 777 + cfg.MinMessagesSinceLast = 33 + + for _, enabled := range []bool{false, true} { + cfg.Enabled = enabled + storeMagnetConfig(t, ctx, cfg) + rec := f.call(t, cmsOwnerEmail, getMagnetAdsSettings, http.MethodGet, nil) + body := rec.Body.String() + var got MagnetAdsSettings + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("enabled=%v: %v: %s", enabled, err, body) + } + if !got.Locked { + t.Errorf("enabled=%v: locked = false: %s", enabled, body) + } + for _, secret := range []string{cfg.ApiKey, testStatsKeyA, "cms-other-locked", "lockedChannels", "apiKey", "statsKey"} { + if strings.Contains(body, secret) { + t.Errorf("enabled=%v: response carries %q: %s", enabled, secret, body) + } + } + if !enabled { + if got.Snippet != "" || got.Mode != "" || got.PerSeconds != 0 || got.MinMessagesSinceLast != 0 { + t.Errorf("global ads are off, yet their settings went out: %s", body) + } + } else if got.Snippet != cfg.Snippet || got.PerSeconds != 777 { + t.Errorf("viewers of a locked channel lost the platform's ad: %s", body) + } + } +} diff --git a/backend/channel_seo.go b/backend/channel_seo.go new file mode 100644 index 00000000..27a42da8 --- /dev/null +++ b/backend/channel_seo.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/go-chi/chi" +) + +// What a search result shows before cutting off: about 60 characters of +// title and 155 of description. The management screen counts down to the +// same numbers, and the server refuses more rather than storing text Google +// would cut mid-word anyway. +const ( + maxSeoTitleLen = 60 + maxSeoDescriptionLen = 155 +) + +// channelSeoRequest is the search-engine part of a channel edit. Pointers, +// as in editChannelInfo, so a field the client did not send is left alone. +type channelSeoRequest struct { + SeoHidden *bool `json:"seoHidden"` + SeoTitle *string `json:"seoTitle"` + SeoDescription *string `json:"seoDescription"` +} + +var ( + errSeoTitleTooLong = errors.New("seo title is too long") + errSeoDescriptionTooLong = errors.New("seo description is too long") +) + +// cleanSeoText folds what was typed into one line: a search result has no +// line breaks, and control characters have no business in a <meta> tag. +func cleanSeoText(s string) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, s) + return strings.Join(strings.Fields(s), " ") +} + +// hashArgs validates the request and returns the channel-hash fields to +// write, empty when nothing search-related was sent. +func (q *channelSeoRequest) hashArgs() ([]any, error) { + args := []any{} + if q.SeoHidden != nil { + v := "0" + if *q.SeoHidden { + v = "1" + } + args = append(args, "seoHidden", v) + } + if q.SeoTitle != nil { + t := cleanSeoText(*q.SeoTitle) + if utf8.RuneCountInString(t) > maxSeoTitleLen { + return nil, errSeoTitleTooLong + } + args = append(args, "seoTitle", t) + } + if q.SeoDescription != nil { + d := cleanSeoText(*q.SeoDescription) + if utf8.RuneCountInString(d) > maxSeoDescriptionLen { + return nil, errSeoDescriptionTooLong + } + args = append(args, "seoDescription", d) + } + return args, nil +} + +// channelSearchListed is whether a channel may appear in search results: +// public, active, and not switched off by its managers. The sitemap and the +// channel page's robots directive both ask this, so they cannot disagree. +func channelSearchListed(ch *ChannelData) bool { + return ch != nil && !ch.Features.Disabled && !ch.Features.RequireAuth && !ch.SeoHidden +} + +// channelSearchTitle and channelSearchDescription are the text a search +// result shows: what the managers wrote for it, or else the defaults the +// page always had. +func channelSearchTitle(ch *ChannelData, name, siteName string) string { + if t := cleanSeoText(ch.SeoTitle); t != "" { + return t + } + return name + " · " + siteName +} + +func channelSearchDescription(ch *ChannelData, name string) string { + if d := previewText(ch.SeoDescription); d != "" { + return d + } + if d := previewText(ch.Description); d != "" { + return d + } + return previewText("ערוץ העדכונים " + name + ": הודעות, תמונות ועדכונים, ישירות בדפדפן ובלי אפליקציה.") +} + +// PUT /api/super-admin/channels/{slug}/seo +// +// The super admin's copy of the channel screen's search settings. It sits +// outside channelMiddleware, which refuses a disabled channel, so the +// settings of a channel the operator switched off stay editable. +func superAdminSetChannelSeo(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + if !requireExistingChannel(ctx, w, slug) { + return + } + var req channelSeoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + args, err := req.hashArgs() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(args) > 0 { + if err := rdb.HSet(ctx, "channel:"+slug, args...).Err(); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + resetSitemapCache() + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} diff --git a/backend/channel_seo_test.go b/backend/channel_seo_test.go new file mode 100644 index 00000000..71127e6a --- /dev/null +++ b/backend/channel_seo_test.go @@ -0,0 +1,292 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" +) + +var ldRe = regexp.MustCompile(`<script type="application/ld\+json">(.*?)</script>`) + +func sitemapLocs(t *testing.T) map[string]xmlURL { + t.Helper() + rec := seoGet(t, "/sitemap.xml") + if rec.Code != http.StatusOK { + t.Fatalf("sitemap: %d", rec.Code) + } + var set xmlURLSet + if err := xml.Unmarshal(rec.Body.Bytes(), &set); err != nil { + t.Fatalf("sitemap is not a urlset: %v", err) + } + got := map[string]xmlURL{} + for _, u := range set.URLs { + got[u.Loc] = u + } + return got +} + +// The managers' search title and description reach every tag a search +// engine or a link preview reads, escaped for HTML and for JSON-LD alike. +func TestChannelSeoTextIsUsedAndEscaped(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + withSpaFolder(t, "", "") + const slug = "seo-custom-text" + seedPreviewChannel(t, ctx, slug, ChannelData{Name: "חדשות", Description: "התיאור הרגיל", Features: defaultChannelFeatures()}) + + title := `כותרת "מיוחדת" </script><script>alert(1)</script>` + desc := "תיאור\nלגוגל & <b>עוד</b>" + body, _ := json.Marshal(map[string]string{"seoTitle": title, "seoDescription": desc}) + callEditChannelInfo(t, slug, string(body), http.StatusOK) + + got, err := dbGetChannel(ctx, slug) + if err != nil { + t.Fatal(err) + } + if got.SeoDescription != "תיאור לגוגל & <b>עוד</b>" { + t.Errorf("stored description %q: line breaks must fold to one line", got.SeoDescription) + } + + page := spaPage(t, "/channel/"+slug) + escTitle := `כותרת "מיוחדת" </script><script>alert(1)</script>` + escDesc := `תיאור לגוגל & <b>עוד</b>` + mustContain(t, page, + `<title>`+escTitle+``, + ``, + ``, + ``, + ``, + ``, + ``, + // The page itself still names the channel. + `

חדשות

`, + ) + if strings.Contains(page, "` || n["description"] != "תיאור לגוגל & עוד" { + t.Errorf("CollectionPage carries %q / %q", n["name"], n["description"]) + } + } + } + if !found { + t.Errorf("JSON-LD lacks the CollectionPage") + } + + // Cleared, the defaults come back. + callEditChannelInfo(t, slug, `{"seoTitle":"","seoDescription":" "}`, http.StatusOK) + mustContain(t, spaPage(t, "/channel/"+slug), + `חדשות · הערוץ`, + ``, + ) +} + +func TestChannelSeoLimits(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + withSpaFolder(t, "", "") + const slug = "seo-limits" + seedPreviewChannel(t, ctx, slug, ChannelData{Name: "גבולות", Features: defaultChannelFeatures()}) + + long := func(n int) string { return strings.Repeat("א", n) } + callEditChannelInfo(t, slug, `{"seoTitle":"`+long(maxSeoTitleLen)+`"}`, http.StatusOK) + callEditChannelInfo(t, slug, `{"seoTitle":"`+long(maxSeoTitleLen+1)+`"}`, http.StatusBadRequest) + callEditChannelInfo(t, slug, `{"seoDescription":"`+long(maxSeoDescriptionLen)+`"}`, http.StatusOK) + callEditChannelInfo(t, slug, `{"seoDescription":"`+long(maxSeoDescriptionLen+1)+`"}`, http.StatusBadRequest) + // A refused request writes nothing, the name included. + callEditChannelInfo(t, slug, `{"name":"שם אחר","seoTitle":"`+long(maxSeoTitleLen+1)+`"}`, http.StatusBadRequest) + if got, _ := dbGetChannel(ctx, slug); got.Name != "גבולות" || got.SeoTitle != long(maxSeoTitleLen) { + t.Errorf("after a refused edit: name %q, title %q", got.Name, got.SeoTitle) + } + // A request that sends no search field leaves the search settings alone. + callEditChannelInfo(t, slug, `{"description":"חדש"}`, http.StatusOK) + if got, _ := dbGetChannel(ctx, slug); got.SeoTitle != long(maxSeoTitleLen) { + t.Errorf("an unrelated edit cleared the search title: %q", got.SeoTitle) + } +} + +// Switched off, a public channel leaves the sitemap at once (not when the +// cache expires) and its page says noindex in the markup and in the header, +// with no structured data — while the page keeps working for people. +func TestChannelSeoHidden(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + withSpaFolder(t, "", "") + const slug = "seo-hidden-toggle" + seedPreviewChannel(t, ctx, slug, ChannelData{Name: "נסתר", Description: "תיאור", Features: defaultChannelFeatures()}) + resetSitemapCache() + t.Cleanup(resetSitemapCache) + + loc := "https://example.test/channel/" + slug + // Listed by default: the state every existing channel is in. + if _, ok := sitemapLocs(t)[loc]; !ok { + t.Fatalf("a public channel is not listed by default") + } + if rec := seoGet(t, "/channel/"+slug); rec.Header().Get("X-Robots-Tag") != "" { + t.Errorf("a listed channel sent X-Robots-Tag %q", rec.Header().Get("X-Robots-Tag")) + } + + callEditChannelInfo(t, slug, `{"seoHidden":true}`, http.StatusOK) + // The sitemap above is cached; the save must have dropped it. + if _, ok := sitemapLocs(t)[loc]; ok { + t.Errorf("a channel switched off is still in the sitemap") + } + rec := seoGet(t, "/channel/"+slug) + if rec.Code != http.StatusOK { + t.Fatalf("hidden channel page: %d", rec.Code) + } + if got := rec.Header().Get("X-Robots-Tag"); got != "noindex" { + t.Errorf("X-Robots-Tag = %q, want noindex", got) + } + page := rec.Body.String() + mustContain(t, page, + ``, + // Still a working page with a working preview. + `נסתר · הערוץ`, + ``, + `

נסתר

`, + ) + if ldRe.MatchString(page) { + t.Errorf("a hidden channel still sends structured data") + } + if strings.Contains(page, `rel="canonical"`) { + t.Errorf("a hidden channel still claims a canonical URL") + } + + callEditChannelInfo(t, slug, `{"seoHidden":false}`, http.StatusOK) + if _, ok := sitemapLocs(t)[loc]; !ok { + t.Errorf("switched back on, the channel did not return to the sitemap") + } + if !strings.Contains(spaPage(t, "/channel/"+slug), `"@type":"CollectionPage"`) { + t.Errorf("switched back on, the structured data did not return") + } +} + +// The super admin edits the same settings, a disabled channel's included +// (the channel routes refuse a disabled channel outright). +func TestSuperAdminSetChannelSeo(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + withSpaFolder(t, "", "") + const slug = "seo-sa-disabled" + f := defaultChannelFeatures() + f.Disabled = true + seedPreviewChannel(t, ctx, slug, ChannelData{Name: "מושבת", Features: f}) + + call := func(slug, body string, want int) { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/super-admin/channels/"+slug+"/seo", bytes.NewBufferString(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", slug) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + superAdminSetChannelSeo(rec, req) + if rec.Code != want { + t.Fatalf("%s %s: %d, want %d (%s)", slug, body, rec.Code, want, rec.Body.String()) + } + } + call(slug, `{"seoHidden":true,"seoTitle":"כותרת","seoDescription":"תיאור"}`, http.StatusOK) + got, err := dbGetChannel(ctx, slug) + if err != nil { + t.Fatal(err) + } + if !got.SeoHidden || got.SeoTitle != "כותרת" || got.SeoDescription != "תיאור" { + t.Errorf("stored %+v", got) + } + call(slug, `{"seoTitle":"`+strings.Repeat("א", maxSeoTitleLen+1)+`"}`, http.StatusBadRequest) + call("seo-sa-no-such-channel", `{"seoHidden":true}`, http.StatusNotFound) + if n, _ := rdb.Exists(ctx, "channel:seo-sa-no-such-channel").Result(); n != 0 { + t.Errorf("a missing channel got a hash") + } + + // Disabled stays out of search whatever the toggle says. + call(slug, `{"seoHidden":false}`, http.StatusOK) + resetSitemapCache() + t.Cleanup(resetSitemapCache) + if _, ok := sitemapLocs(t)["https://example.test/channel/"+slug]; ok { + t.Errorf("a disabled channel is in the sitemap") + } +} + +// The home page is the site's most important URL, and every public channel +// page links back to it in its first HTML. +func TestSitemapPrioritiesAndChannelLinks(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + withSpaFolder(t, "", "") + const slug = "seo-priority" + seedPreviewChannel(t, ctx, slug, ChannelData{Name: "עדיפות", Features: defaultChannelFeatures()}) + resetSitemapCache() + t.Cleanup(resetSitemapCache) + + locs := sitemapLocs(t) + if p := locs["https://example.test/"].Priority; p != priorityHome { + t.Errorf("home priority %q, want %q", p, priorityHome) + } + for _, c := range contentPages { + if p := locs["https://example.test"+c.Path].Priority; p != priorityGuide { + t.Errorf("%s priority %q, want %q", c.Path, p, priorityGuide) + } + } + if p := locs["https://example.test/channel/"+slug].Priority; p != priorityChannel { + t.Errorf("channel priority %q, want %q", p, priorityChannel) + } + for _, u := range locs { + if u.Loc != "https://example.test/" && u.Priority >= priorityHome { + t.Errorf("%s ranks with the home page (%s)", u.Loc, u.Priority) + } + } + + mustContain(t, spaPage(t, "/channel/"+slug), `פתיחת ערוץ עדכונים משלכם, בחינם`) +} + +// The NetFree guide shows a channel address on this deployment's own domain, +// and every guide links to the home page and to each other guide. +func TestGuidePagesLinkTogether(t *testing.T) { + withSpaFolder(t, "", "https://channels.example.org") + mustContain(t, seoGet(t, "/netfree-channel").Body.String(), + `

https://channels.example.org/channel/your-channel

`, + faqNetFree.Q, + ) + mustContain(t, seoGet(t, "/whatsapp-alternative").Body.String(), faqWhatsApp.Q) + for _, p := range contentPages { + body := seoGet(t, p.Path).Body.String() + mustContain(t, body, ``, `הערוץ: פתיחת ערוץ עדכונים בחינם`) + for _, o := range contentPages { + if o.Path != p.Path && !strings.Contains(body, ``) { + t.Errorf("%s does not link to %s", p.Path, o.Path) + } + } + } + home := spaPage(t, "/") + for _, p := range contentPages { + if !strings.Contains(home, ``) { + t.Errorf("the home page does not link to %s", p.Path) + } + } + mustContain(t, home, faqNetFree.Q, faqWhatsApp.Q) +} diff --git a/backend/channelinfo_test.go b/backend/channelinfo_test.go new file mode 100644 index 00000000..f5fe556f --- /dev/null +++ b/backend/channelinfo_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// editChannelInfo must only write fields the client actually sent. A partial +// request (name only) must leave description and logo untouched — the previous +// always-write behaviour blanked them, which is what left a channel with an +// empty name whose /info then came back empty. +func TestEditChannelInfoPartialRequestPreservesOtherFields(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "editinfo-partial" + ch := &ChannelData{ + Slug: slug, Name: "Original", Description: "Original desc", + LogoUrl: "/api/channel/" + slug + "/files/abc", CreatedAt: time.Now(), + } + if err := dbCreateChannel(ctx, ch); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + dbDeleteChannel(cctx, slug) + }) + + // Send ONLY a new name. + callEditChannelInfo(t, slug, `{"name":"Renamed"}`, http.StatusOK) + + got, err := dbGetChannel(ctx, slug) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "Renamed" { + t.Errorf("name = %q, want Renamed", got.Name) + } + if got.Description != "Original desc" { + t.Errorf("description was blanked to %q; a partial request must not touch it", got.Description) + } + if got.LogoUrl != "/api/channel/"+slug+"/files/abc" { + t.Errorf("logoUrl was blanked to %q; a partial request must not touch it", got.LogoUrl) + } +} + +// A blank name would leave the channel nameless (the empty-/info symptom), so +// it is refused. +func TestEditChannelInfoRejectsBlankName(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "editinfo-blank" + if err := dbCreateChannel(ctx, &ChannelData{Slug: slug, Name: "Keep", CreatedAt: time.Now()}); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + dbDeleteChannel(cctx, slug) + }) + + callEditChannelInfo(t, slug, `{"name":" "}`, http.StatusBadRequest) + + got, err := dbGetChannel(ctx, slug) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "Keep" { + t.Errorf("name = %q, want Keep — a rejected request must not have changed it", got.Name) + } +} + +// A clear (explicit empty string) on a clearable field IS honoured. +func TestEditChannelInfoClearsDescriptionWhenSent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "editinfo-clear" + if err := dbCreateChannel(ctx, &ChannelData{Slug: slug, Name: "N", Description: "to be cleared", CreatedAt: time.Now()}); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + dbDeleteChannel(cctx, slug) + }) + + callEditChannelInfo(t, slug, `{"description":""}`, http.StatusOK) + + got, _ := dbGetChannel(ctx, slug) + if got.Description != "" { + t.Errorf("description = %q, want empty (an explicit clear must be honoured)", got.Description) + } + if got.Name != "N" { + t.Errorf("name = %q, want N (untouched)", got.Name) + } +} + +func TestEditChannelInfoRejectsDangerousLogoScheme(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "editinfo-logo" + if err := dbCreateChannel(ctx, &ChannelData{Slug: slug, Name: "N", CreatedAt: time.Now()}); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + dbDeleteChannel(cctx, slug) + }) + + callEditChannelInfo(t, slug, `{"logoUrl":"javascript:alert(1)"}`, http.StatusBadRequest) + callEditChannelInfo(t, slug, `{"logoUrl":"data:text/html,x"}`, http.StatusBadRequest) + // Legit values are accepted. + callEditChannelInfo(t, slug, `{"logoUrl":"/api/channel/`+slug+`/files/xyz"}`, http.StatusOK) + callEditChannelInfo(t, slug, `{"logoUrl":"https://cdn.example.com/logo.png"}`, http.StatusOK) +} + +func TestIsSafeLogoURL(t *testing.T) { + ok := []string{"", "/api/channel/a/files/b", "https://x.com/l.png", "http://x.com/l.png", "/relative/path"} + for _, u := range ok { + if !isSafeLogoURL(u) { + t.Errorf("%q should be accepted", u) + } + } + bad := []string{"javascript:alert(1)", "data:text/html,x", "vbscript:x", "JAVASCRIPT:alert(1)"} + for _, u := range bad { + if isSafeLogoURL(u) { + t.Errorf("%q should be rejected", u) + } + } +} + +// callEditChannelInfo drives the handler with a body and asserts the status. +// The slug is injected the way channelMiddleware would, via the request context. +func callEditChannelInfo(t *testing.T, slug, body string, wantStatus int) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/channel/"+slug+"/admin/edit-channel-info", bytes.NewBufferString(body)) + req = req.WithContext(context.WithValue(req.Context(), channelCtxKey, &ChannelData{Slug: slug})) + rec := httptest.NewRecorder() + + editChannelInfo(rec, req) + + if rec.Code != wantStatus { + t.Fatalf("body %s: got status %d, want %d (%s)", body, rec.Code, wantStatus, rec.Body.String()) + } + if wantStatus == http.StatusOK { + var resp Response + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || !resp.Success { + t.Errorf("body %s: expected success response, got %s", body, rec.Body.String()) + } + } +} diff --git a/backend/channels.go b/backend/channels.go new file mode 100644 index 00000000..5f49ddde --- /dev/null +++ b/backend/channels.go @@ -0,0 +1,657 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "regexp" + "strings" + "time" + + "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" +) + +type ChannelFeatures struct { + Reactions bool `json:"reactions"` + FileUploads bool `json:"fileUploads"` + Reports bool `json:"reports"` + Ads bool `json:"ads"` + Notifications bool `json:"notifications"` + RequireAuth bool `json:"requireAuth"` + RequireAuthFiles bool `json:"requireAuthFiles"` + CountViews bool `json:"countViews"` + ScheduledMessages bool `json:"scheduledMessages"` + Webhook bool `json:"webhook"` + MagnetLockedByAdmin bool `json:"magnetLockedByAdmin"` // set by super admin, owner cannot change + AdsLockedByAdmin bool `json:"adsLockedByAdmin"` // set by super admin, owner cannot change + // Disabled is the super admin's kill switch for a whole tenant: set, every + // route under /api/channel/{slug} is refused, owner included. Stored + // features blobs written before this field existed simply unmarshal it to + // false, so no channel is disabled by the upgrade. + Disabled bool `json:"disabled"` +} + +type ChannelData struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + LogoUrl string `json:"logoUrl"` + OwnerEmail string `json:"ownerEmail"` + CreatedAt time.Time `json:"createdAt"` + Features ChannelFeatures `json:"features"` + ContactUs string `json:"contactUs"` + // Search-engine settings (channel_seo.go), kept in the channel hash. An + // absent field means the defaults: listed, with the channel's name and + // description as the search result's text. + SeoHidden bool `json:"seoHidden"` + SeoTitle string `json:"seoTitle"` + SeoDescription string `json:"seoDescription"` +} + +type ctxKey string + +const channelCtxKey ctxKey = "channel" + +var slugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9\-]{1,48}[a-z0-9]$`) + +// isPlausibleSlug is the lookup-side check (see channelMiddleware); slugRegex +// is the creation-side one. +func isPlausibleSlug(s string) bool { + return s != "" && len(s) <= 64 && !strings.ContainsAny(s, ":/\\ \t\r\n") +} + +// defaultChannelFeatures is the single source of the toggles every new channel +// starts with. Every creation path (createChannel, createChannelSelfService) must +// use it: requireFeature is only applied to toggles both paths default to true, +// and a drifted copy of this literal is exactly what backfillChannelFeatures +// had to repair. +func defaultChannelFeatures() ChannelFeatures { + return ChannelFeatures{ + Reactions: true, + FileUploads: true, + Reports: true, + ScheduledMessages: true, + // Permission to use the feature, not a request to use it: each one + // still needs the owner to configure it in channel settings. Left + // false these would be unusable and undiagnosable for a new tenant. + Ads: true, + Notifications: true, + Webhook: true, + } +} + +func channelMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + ctx := r.Context() + + // Refuse what can never be a channel before touching Redis. Keys are + // "channel::", so a slug carrying ':' (e.g. + // "x:messages:5") resolved the message hash itself as a phantom + // channel with every feature off. The check is deliberately looser + // than the creation regex so channels created before that regex + // existed stay reachable. + if !isPlausibleSlug(slug) { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + channel, err := dbGetChannel(ctx, slug) + if err != nil { + if err == redis.Nil { + http.Error(w, "Channel not found", http.StatusNotFound) + } else { + http.Error(w, "error", http.StatusInternalServerError) + } + return + } + + // A disabled channel is refused here, before anything channel-scoped + // runs, so the block covers reads, writes and the owner's own admin + // routes alike. Super-admin routes live outside this router group, so a + // disabled channel can still be re-enabled. + if channel.Features.Disabled { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"error": "channel_disabled"}) + return + } + + ctx = context.WithValue(ctx, channelCtxKey, channel) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func channelFromCtx(r *http.Request) *ChannelData { + v := r.Context().Value(channelCtxKey) + if v == nil { + return nil + } + return v.(*ChannelData) +} + +func channelSlugFromCtx(r *http.Request) string { + ch := channelFromCtx(r) + if ch == nil { + return "" + } + return ch.Slug +} + +// requireFeature rejects a request when the channel's operator-controlled +// toggle for that feature is off. Only applied to toggles both channel +// creation paths default to true, so it cannot silently disable a live tenant. +func requireFeature(pick func(*ChannelFeatures) bool, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := channelFromCtx(r) + if ch == nil || !pick(&ch.Features) { + http.Error(w, "Feature disabled for this channel", http.StatusForbidden) + return + } + next(w, r) + } +} + +// channelIfRequireAuth middleware +func channelIfRequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ch := channelFromCtx(r) + if ch != nil && ch.Features.RequireAuth { + checkLogin(next).ServeHTTP(w, r) + } else { + next.ServeHTTP(w, r) + } + }) +} + +// channelIfRequireAuthFiles middleware +func channelIfRequireAuthFiles(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ch := channelFromCtx(r) + if ch != nil && ch.Features.RequireAuthFiles { + checkLogin(next).ServeHTTP(w, r) + } else { + next.ServeHTTP(w, r) + } + }) +} + +// Super admin: list all channels +func listChannels(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(channels) +} + +// Super admin: create channel +func createChannel(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var req struct { + Slug string `json:"slug"` + Name string `json:"name"` + OwnerEmail string `json:"ownerEmail"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if !slugRegex.MatchString(req.Slug) { + http.Error(w, "Invalid slug: use lowercase letters, numbers, hyphens (min 3 chars)", http.StatusBadRequest) + return + } + // The self-service path and the info editor both refuse a blank name; this + // path did not, and a nameless channel renders with an empty header and a + // "?" avatar. A mistyped owner email is worse: the role is granted to an + // identity nobody can sign in as, and the channel has no reachable owner. + req.Name = strings.TrimSpace(req.Name) + req.OwnerEmail = normEmail(req.OwnerEmail) + if req.Name == "" { + http.Error(w, "Channel name is required", http.StatusBadRequest) + return + } + if channelFieldTooLong(req.Name, maxChannelNameLen) { + http.Error(w, "Channel name is too long", http.StatusBadRequest) + return + } + if req.OwnerEmail != "" && !looksLikeEmail(req.OwnerEmail) { + http.Error(w, "Invalid owner email", http.StatusBadRequest) + return + } + // The reserved check lived only in the self-service path; the admin path + // must refuse the same slugs (see reservedSlugs for why they are kept). + if isReservedSlug(req.Slug) { + http.Error(w, "Slug is reserved", http.StatusBadRequest) + return + } + + exists, err := dbChannelExists(ctx, req.Slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if exists { + http.Error(w, "Channel already exists", http.StatusConflict) + return + } + + channel := &ChannelData{ + Slug: req.Slug, + Name: req.Name, + OwnerEmail: req.OwnerEmail, + CreatedAt: time.Now(), + Features: defaultChannelFeatures(), + } + + if err := dbCreateChannel(ctx, channel); err != nil { + if errors.Is(err, errChannelExists) { + http.Error(w, "Channel already exists", http.StatusConflict) + return + } + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if req.OwnerEmail != "" { + if err := dbAssignChannelRole(ctx, req.OwnerEmail, req.Slug, RoleOwner); err != nil { + log.Printf("createChannel: %s created but owner role for %s not assigned: %v\n", req.Slug, req.OwnerEmail, err) + } + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after createChannel: %v", err) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(channel) +} + +// Super admin: delete channel +func deleteChannel(w http.ResponseWriter, r *http.Request) { + // Deletion also releases every uploaded blob (R2/disk), so it needs more + // headroom than the usual 5s handler budget. + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + + // Without these checks a typo deletes nothing and still reports success, so + // the operator records a channel as gone while it is still live. The + // plausibility check, not the creation regex: a channel created before + // the regex existed (uppercase, underscore, two characters) could be + // viewed and edited but never deleted. + if !isPlausibleSlug(slug) { + http.Error(w, "Invalid slug", http.StatusBadRequest) + return + } + exists, err := dbChannelExists(ctx, slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + // Hold the scheduled-dispatch claim for the whole delete (see + // claimScheduled); the TTL outlives this handler's budget. + // The dispatcher refreshes its claim per posted message and can hold it + // for most of a minute on a channel with many due posts; two seconds of + // waiting turned that into a 503 the operator had to retry by hand. + token, ok := claimScheduled(ctx, slug, 90*time.Second, 15*time.Second) + if !ok { + w.Header().Set("Retry-After", "5") + http.Error(w, "channel is busy, try again", http.StatusServiceUnavailable) + return + } + defer releaseScheduledLock(slug, token) + + // Viewers with an open event stream never learned the channel was gone: + // the stream key vanished underneath the hub, which kept polling it, and + // the heartbeat kept the page looking live until the next reload. Tell + // them first, and give the hub a moment to fan the event out before the + // stream is deleted. + publishEvent(ctx, slug, []byte(sseChannelGoneEvent)) + time.Sleep(500 * time.Millisecond) + // Then retire this instance's hub outright: a client that ignores the + // event, or a stale connection, would otherwise keep its stream — and + // the writer resolution it was opened with — for as long as it stayed up. + streamKey := "channel:" + slug + ":events" + sseStopHub(streamKey) + + if err := dbDeleteChannel(ctx, slug); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + // A reconnect that slipped in between the stop above and the fence + // inside dbDeleteChannel built a fresh hub on a key that no longer + // exists, where it would sit and pick up the events of whatever tenant + // re-creates the slug. Now that the channel is gone, reconnects meet 404. + sseStopHub(streamKey) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +// Super admin: update channel features +func updateChannelFeatures(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + if !requireExistingChannel(ctx, w, slug) { + return + } + + var features ChannelFeatures + if err := json.NewDecoder(r.Body).Decode(&features); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // The two *LockedByAdmin mirrors are owned by the global ads/magnet lock + // lists and their sync, never by this form: it loads them and passes them + // back untouched, so a save made after the global lock changed used to + // replay the stale pair. They are kept from the stored record, and the + // write is atomic against the sync so neither side reverts the other. + var wasDisabled bool + err := dbUpdateChannelFeatures(ctx, slug, func(cur *ChannelFeatures) { + wasDisabled = cur.Disabled + magnetLocked, adsLocked := cur.MagnetLockedByAdmin, cur.AdsLockedByAdmin + *cur = features + cur.MagnetLockedByAdmin, cur.AdsLockedByAdmin = magnetLocked, adsLocked + }) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + // Disabling a channel or making it private takes it out of the sitemap; + // the cached copy would otherwise list it for up to a quarter of an hour. + resetSitemapCache() + + // The kill switch used to stop new requests only: every open event + // stream — a since-demoted writer's included — kept streaming, unmasked, + // until its connection dropped. The control event tells the clients and + // every hub, this instance's included, which retire themselves after + // fanning it out. Reconnects meet channelMiddleware's 403. + if features.Disabled && !wasDisabled { + announceChannelDisabled(ctx, slug) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +// announceChannelDisabled publishes the channel-disabled control event and +// retires this instance's hub once the event had a chance to fan out. +// +// The hub used to be stopped right after the publish. sseStopHub closes every +// subscriber at once, and the hub's blocking read returned with the event only +// afterwards, straight into its "already cancelled" exit — so on the instance +// that served the request (every viewer, in a single-replica deployment) the +// viewers lost the very event that tells them the channel is disabled and kept +// a live-looking page until the heartbeat watchdog reconnected them into a +// 403. The hub retires itself when it reads the control event (see run); the +// delayed stop only covers a hub that never read it, and does not hold the +// request. A hub built for the slug within the delay (a re-enable in that +// window) is stopped too, which costs its viewers one reconnect. +func announceChannelDisabled(ctx context.Context, slug string) { + publishEvent(ctx, slug, []byte(sseChannelDisabledEvent)) + streamKey := "channel:" + slug + ":events" + time.AfterFunc(500*time.Millisecond, func() { sseStopHub(streamKey) }) +} + +// requireExistingChannel answers 404 and returns false when slug does not name +// a channel. The super-admin write handlers take the slug straight from the +// URL, outside channelMiddleware, and used to write a features blob or a role +// grant for a slug that did not exist — silently, with a success response. +func requireExistingChannel(ctx context.Context, w http.ResponseWriter, slug string) bool { + if !isPlausibleSlug(slug) { + http.Error(w, "Channel not found", http.StatusNotFound) + return false + } + exists, err := dbChannelExists(ctx, slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return false + } + if !exists { + http.Error(w, "Channel not found", http.StatusNotFound) + return false + } + return true +} + +// Super admin: get channel (including features) +func getSuperAdminChannel(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + channel, err := dbGetChannel(ctx, slug) + if err != nil { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(channel) +} + +// channelUserChange is one requested role change on a channel's user list. +type channelUserChange struct { + Email string `json:"email"` + Role ChannelRole `json:"role"` +} + +// applyChannelRoleChanges returns the dbUpdateUsersList callback shared by the +// super-admin and owner user-management handlers. allowOwner is the only +// difference between them: a channel owner may not promote others to owner. +func applyChannelRoleChanges(slug string, changes []channelUserChange, allowOwner bool) func([]User) []User { + return func(users []User) []User { + users = normalizeUsers(users) + userMap := make(map[string]int) + for i, u := range users { + userMap[u.Email] = i + } + + for _, ru := range changes { + ru.Email = normEmail(ru.Email) + if ru.Email == "" { + continue + } + if !allowOwner && ru.Role == RoleOwner { + continue // owner cannot promote others to owner + } + if i, exists := userMap[ru.Email]; exists { + // Guarding only the promotion above left the reverse open: a + // change to "" or to any lesser role is not RoleOwner, so an + // owner could strip a co-owner's role and take sole control of + // the tenant — irreversibly, since only a super admin can grant + // owner back. Who currently holds owner is what matters here, + // not what the change asks for. + if !allowOwner && users[i].ChannelRoles[slug] == RoleOwner { + continue + } + if users[i].ChannelRoles == nil { + users[i].ChannelRoles = make(map[string]ChannelRole) + } + if ru.Role == "" { + delete(users[i].ChannelRoles, slug) + } else { + users[i].ChannelRoles[slug] = ru.Role + } + } else if ru.Role != "" { + userMap[ru.Email] = len(users) + users = append(users, User{ + Email: ru.Email, + ChannelRoles: map[string]ChannelRole{slug: ru.Role}, + }) + } + } + return users + } +} + +// setChannelUsersForSlug decodes the request and applies the role changes under +// the guarded users:list transaction. Shared by both set-users handlers. +func setChannelUsersForSlug(w http.ResponseWriter, r *http.Request, slug string, allowOwner bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var req struct { + Users []channelUserChange `json:"users"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // A role is one of three words; anything else was stored as typed, + // echoed back by the users list as if granted, and granted nothing. + for _, u := range req.Users { + if u.Role != "" && !validChannelRole(u.Role) { + http.Error(w, "invalid role", http.StatusBadRequest) + return + } + // A grant to something that is not an address ("yossi.gmail.com") + // was saved with a success toast and could never match a login: the + // intended person stayed locked out and a phantom user record was + // created. Revocations are left alone so a bad entry can be removed. + if u.Role != "" && !looksLikeEmail(normEmail(u.Email)) { + http.Error(w, "invalid email: "+strings.TrimSpace(u.Email), http.StatusBadRequest) + return + } + } + + // Read-modify-write under a WATCH so a concurrent role edit elsewhere is not + // silently overwritten by this one. The mutator may run more than once; + // the last run is the one that committed, so its output is what is kept. + var updated []User + mutate := applyChannelRoleChanges(slug, req.Users, allowOwner) + if err := dbUpdateUsersList(ctx, func(users []User) []User { + updated = mutate(users) + return updated + }); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + // The channel hash's ownerEmail (what the channels list shows as owner) + // was written at creation only, so moving owner through this editor left + // the list naming the old one. Owner can only change when allowOwner. + if allowOwner { + owner := "" + for _, u := range updated { + if u.ChannelRoles[slug] == RoleOwner { + owner = u.Email + break + } + } + // Written only if the hash still exists: a bare HSET would otherwise + // mint a phantom channel for a slug deleted a moment ago. + if err := rdb.Eval(ctx, `if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('HSET', KEYS[1], 'ownerEmail', ARGV[1]) end return 0`, + []string{"channel:" + slug}, owner).Err(); err != nil { + log.Printf("setChannelUsers(%s): ownerEmail not updated: %v", slug, err) + } + } + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after setChannelUsers(%s): %v", slug, err) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +// listChannelUsers writes the channel's user/role list. Shared by the +// super-admin and owner get-users handlers. +// +// hideOperators drops an operator's owner row. An operator holds owner on every +// channel they created themselves, and a co-owner reading that list must not +// learn the operator's email. Only owner rows are hidden: an owner can never +// grant owner, so such a row was never their own doing, whereas a writer or +// moderator row on an operator's email is one an owner typed in and must stay +// visible — hiding it too would make the row vanish after saving, which both +// looks like a failed save and confirms that the address is the operator's. +// Hiding changes nothing about access: a super admin reaches every channel +// through hasChannelRole anyway, and an owner's save only touches the rows it +// sends. +func listChannelUsers(w http.ResponseWriter, slug string, hideOperators bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + users, err := dbGetUsersList(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + type ChannelUser struct { + Email string `json:"email"` + Role ChannelRole `json:"role"` + } + + // Encoded straight to JSON: a nil slice would serialise as null, which the + // user management screens do not accept. + channelUsers := make([]ChannelUser, 0) + for _, u := range users { + if hideOperators && u.GlobalRole == RoleSuperAdmin && u.ChannelRoles[slug] == RoleOwner { + continue + } + if u.ChannelRoles != nil { + if role, exists := u.ChannelRoles[slug]; exists { + channelUsers = append(channelUsers, ChannelUser{Email: u.Email, Role: role}) + } + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(channelUsers) +} + +// Super admin: set channel users +func superAdminSetChannelUsers(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + slug := chi.URLParam(r, "slug") + if !requireExistingChannel(ctx, w, slug) { + return + } + setChannelUsersForSlug(w, r, slug, true) +} + +// Super admin: get channel users +func superAdminGetChannelUsers(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + slug := chi.URLParam(r, "slug") + if !requireExistingChannel(ctx, w, slug) { + return + } + listChannelUsers(w, slug, false) +} + +// Channel owner: get channel users (for this channel only) +func getChannelUsers(w http.ResponseWriter, r *http.Request) { + listChannelUsers(w, channelSlugFromCtx(r), !isSuperAdmin(r)) +} + +// Channel owner: set channel users (cannot assign owner role) +func setChannelUsers(w http.ResponseWriter, r *http.Request) { + setChannelUsersForSlug(w, r, channelSlugFromCtx(r), false) +} diff --git a/backend/compress.go b/backend/compress.go new file mode 100644 index 00000000..bb906dea --- /dev/null +++ b/backend/compress.go @@ -0,0 +1,47 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/middleware" +) + +// compressedTypes are the responses worth gzipping: the bundles under /assets, +// index.html and every JSON reply. Uploaded media is already compressed and +// the SSE stream is handled below, so neither is listed. +var compressedTypes = []string{ + "text/html", "text/css", "text/plain", "text/javascript", + "application/javascript", "application/x-javascript", "application/json", + "application/manifest+json", "image/svg+xml", +} + +// isEventStreamRoute matches /api/channel/{slug}/events, the one long-lived +// response the server sends. +func isEventStreamRoute(p string) bool { + return strings.HasPrefix(p, "/api/channel/") && strings.HasSuffix(p, "/events") +} + +// compressResponses is chi's gzip middleware for everything except the SSE +// stream. Caddy in the shipped docker-compose does not `encode`, so without +// this a first visit downloaded the JavaScript and CSS (over 3 MB before the +// change) uncompressed. +// +// The stream is routed around the middleware rather than merely left out of +// the type list: chi v1's compressing writer wraps the connection for every +// request and, even when it passes text/event-stream through untouched, it +// implements neither SetWriteDeadline nor Unwrap. getEvents arms a write +// deadline through http.ResponseController so a phone that stopped reading is +// dropped in 30 s; through the wrapper that call silently failed and a dead +// viewer held its goroutine, its event buffer and one of the connection slots +// until TCP gave up, about a quarter of an hour later. +func compressResponses(next http.Handler) http.Handler { + compressed := middleware.Compress(5, compressedTypes...)(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isEventStreamRoute(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + compressed.ServeHTTP(w, r) + }) +} diff --git a/backend/compress_test.go b/backend/compress_test.go new file mode 100644 index 00000000..d84e90bb --- /dev/null +++ b/backend/compress_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi" +) + +// The SSE handler must reach the real connection through the middleware +// chain: a write deadline that cannot be armed is exactly the bug the +// stream-aware compression wrapper exists to prevent. +func TestCompressResponsesLeavesEventStreamUnwrapped(t *testing.T) { + r := chi.NewRouter() + r.Use(compressResponses) + r.Get("/api/channel/{slug}/events", func(w http.ResponseWriter, r *http.Request) { + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil { + t.Errorf("SetWriteDeadline through the middleware chain: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "event: ping\ndata: {}\n\n") + }) + r.Get("/api/channel/{slug}/info", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, `{"slug":"`+chi.URLParam(r, "slug")+`","name":"`+string(make([]byte, 2000))+`"}`) + }) + srv := httptest.NewServer(r) + defer srv.Close() + + get := func(path string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil) + req.Header.Set("Accept-Encoding", "gzip") + // The default transport would transparently decode gzip; a raw one + // shows what actually travelled. + resp, err := (&http.Transport{DisableCompression: true}).RoundTrip(req) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + return resp + } + + ev := get("/api/channel/x/events") + defer ev.Body.Close() + if enc := ev.Header.Get("Content-Encoding"); enc != "" { + t.Fatalf("event stream must not be compressed, got Content-Encoding %q", enc) + } + body, _ := io.ReadAll(ev.Body) + if string(body) != "event: ping\ndata: {}\n\n" { + t.Fatalf("event stream body altered: %q", body) + } + + info := get("/api/channel/x/info") + defer info.Body.Close() + if enc := info.Header.Get("Content-Encoding"); enc != "gzip" { + t.Fatalf("JSON must be gzipped, got Content-Encoding %q", enc) + } + zr, err := gzip.NewReader(info.Body) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + plain, _ := io.ReadAll(zr) + if len(plain) < 2000 { + t.Fatalf("decoded JSON too short: %d bytes", len(plain)) + } +} diff --git a/backend/db.go b/backend/db.go index ee23fda1..a1a50dd6 100644 --- a/backend/db.go +++ b/backend/db.go @@ -1,10 +1,13 @@ package main import ( + "cmp" "context" "encoding/json" + "errors" "fmt" "log" + "math/rand" "os" "strconv" "strings" @@ -14,10 +17,15 @@ import ( "github.com/redis/go-redis/v9" ) -var redisType = os.Getenv("REDIS_PROTOCOL") +// redisType is the network the session store dials on. An empty value makes +// redistore.NewRediStore fail with "dial: unknown network", so it defaults. +var redisType = cmp.Or(os.Getenv("REDIS_PROTOCOL"), "tcp") var redisAddr = os.Getenv("REDIS_ADDR") var redisPass = os.Getenv("REDIS_PASSWORD") -var rdb *redis.Client +var rdb redis.UniversalClient + +// rdbEvents is the SSE-only client; see init() for why it is separate. +var rdbEvents redis.UniversalClient type Message struct { ID int `json:"id" redis:"id"` @@ -35,11 +43,12 @@ type Message struct { } type User struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - PublicName string `json:"publicName"` - Privileges Privileges `json:"privileges"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PublicName string `json:"publicName"` + GlobalRole GlobalRole `json:"globalRole,omitempty"` + ChannelRoles map[string]ChannelRole `json:"channelRoles,omitempty"` } type PushMessage struct { @@ -50,11 +59,51 @@ type PushMessage struct { func init() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - rdb = redis.NewClient(&redis.Options{ - Network: redisType, - Addr: redisAddr, - Password: redisPass, - DB: 0, + // The data client could take a comma-separated REDIS_ADDR list (cluster) + // or REDIS_MASTER (Sentinel), but the session store in main.go cannot and + // refuses to boot with either, so in practice this is one address. + addrs := strings.Split(redisAddr, ",") + rdb = redis.NewUniversalClient(&redis.UniversalOptions{ + Addrs: addrs, + Password: redisPass, + DB: 0, + MasterName: os.Getenv("REDIS_MASTER"), + PoolSize: 100, + MinIdleConns: 10, + MaxRetries: 3, + DialTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 5 * time.Second, + }) + + // A second client, used only by the SSE event loop. + // + // Every connected viewer sits in a blocking XREAD, and a blocking command + // holds its pooled connection for the whole block — the loop re-issues + // immediately, so one viewer occupies one connection essentially all the + // time. Sharing the main pool meant roughly PoolSize concurrent viewers + // could consume every connection in the process, at which point logins, + // uploads and message reads all queued behind them and timed out: a hundred + // idle readers took the whole platform down. + // + // Giving SSE its own pool turns that platform-wide outage into a bounded + // limit on concurrent viewers, which sseMaxConnections then refuses + // cleanly. The real fix for viewer scale is one fan-out reader per stream + // rather than one per viewer; this is the isolation that makes the current + // design safe to run in the meantime. + rdbEvents = redis.NewUniversalClient(&redis.UniversalOptions{ + Addrs: addrs, + Password: redisPass, + DB: 0, + MasterName: os.Getenv("REDIS_MASTER"), + PoolSize: sseRedisPoolSize, + MinIdleConns: 5, + MaxRetries: 3, + DialTimeout: 5 * time.Second, + // Must exceed the XREAD block duration, or every block would surface as + // a read timeout instead of an empty result. + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Second, }) _, err := rdb.Ping(ctx).Result() @@ -65,37 +114,96 @@ func init() { log.Println("Connection to DB successful!") } -func getMessageNextId(ctx context.Context) int { - id, err := rdb.Incr(ctx, "message:next_id").Result() - if err != nil { - log.Fatalf("Failed to get id: %v\n", err) +// publishEvent appends an event to the channel's Redis Stream (replaces pub/sub). +// Streams allow multiple backend instances to serve SSE without missing events. +// The stream is capped at ~1000 entries to avoid unbounded growth. +func publishEvent(ctx context.Context, slug string, data []byte) { + if err := rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: fmt.Sprintf("channel:%s:events", slug), + MaxLen: 1000, + Approx: true, + Values: map[string]interface{}{"data": string(data)}, + }).Err(); err != nil { + log.Printf("publishEvent: XAdd to channel:%s:events failed: %v\n", slug, err) } +} - return int(id) +// touchLastModified bumps a per-channel nano-timestamp used for HTTP ETags. +func touchLastModified(ctx context.Context, slug string) { + rdb.Set(ctx, fmt.Sprintf("channel:%s:last_modified", slug), time.Now().UnixNano(), 0) } -func setMessage(ctx context.Context, m *Message, isUpdate bool) error { - messageKey := fmt.Sprintf("messages:%d", m.ID) +func getLastModified(ctx context.Context, slug string) string { + v, _ := rdb.Get(ctx, fmt.Sprintf("channel:%s:last_modified", slug)).Result() + return v +} - for _, regex := range settingConfig.RegexReplace { - if !strings.HasPrefix(m.Text, "[quote-embedded#]") { - t := regex.Pattern.ReplaceAllString(m.Text, regex.Replace) - m.Text = t - } +func getMessageNextId(ctx context.Context, slug string) (int, error) { + id, err := rdb.Incr(ctx, fmt.Sprintf("channel:%s:message:next_id", slug)).Result() + if err != nil { + return 0, fmt.Errorf("failed to get next message id for %s: %w", slug, err) } - // Set message in hash - if err := rdb.HSet(ctx, messageKey, m).Err(); err != nil { - return err - } + return int(id), nil +} + +func setMessage(ctx context.Context, slug string, m *Message, isUpdate bool) error { + messageKey := fmt.Sprintf("channel:%s:messages:%d", slug, m.ID) - // Add message timestamp to sorted set + // The channel's regex replacements run once, when a message is first + // published. They must NOT run again on edit: the composer opens with the + // stored text — which already carries the replacement — so re-applying the + // same rule wrapped the markup a second time on every save (the documented + // "**$1**" headline rule turned "**title!**" into "****title!****"). if !isUpdate { - if err := rdb.ZAdd(ctx, "m_times:1", redis.Z{Score: float64(m.Timestamp.Unix()), Member: messageKey}).Err(); err != nil { - return err + settings, err := dbGetSettings(ctx, slug) + if err == nil && !strings.HasPrefix(m.Text, "[quote-embedded#]") { + cfg := settings.ToConfig() + for _, regex := range cfg.RegexReplace { + m.Text = regex.Pattern.ReplaceAllString(m.Text, regex.Replace) + } } } + // The hash and its index entry go in one transaction: written separately, + // a failure between the two left an orphan hash that no listing showed, + // no cleanup reached, and that still counted as existing for reactions + // and reports. + if _, err := rdb.TxPipelined(ctx, func(p redis.Pipeliner) error { + if isUpdate { + // Only the fields an edit can change. Writing the whole struct + // back replayed the views and reactions the handler had read + // milliseconds earlier over every HINCRBY (addViewsToMessages) + // and reaction toggle that landed in between, so both counters + // silently lost increments on each save. The caller still fills + // them in on m, but only for the SSE payload published below. + fields := map[string]any{ + "type": m.Type, + "text": m.Text, + "last_edit": m.LastEdit, + "deleted": m.Deleted, + "is_ads": m.IsAds, + } + // The author travels with the edit only when the caller carries + // it: updateMessage copies it from the stored record, re-labelled + // when the record still names an operator (see currentOperators), + // so a legacy operator post is stored anonymised from its first + // edit. An update without an author leaves the stored one alone. + if m.Author != "" { + fields["author"], fields["authorId"] = m.Author, m.AuthorId + } + p.HSet(ctx, messageKey, fields) + } else { + p.HSet(ctx, messageKey, m) + } + if !isUpdate { + p.ZAdd(ctx, fmt.Sprintf("channel:%s:m_times", slug), redis.Z{Score: float64(m.Timestamp.Unix()), Member: messageKey}) + } + return nil + }); err != nil { + return err + } + pushType := "new-message" if isUpdate { pushType = "edit-message" @@ -107,43 +215,39 @@ func setMessage(ctx context.Context, m *Message, isUpdate bool) error { } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, "events", pushMessageData) + publishEvent(ctx, slug, pushMessageData) + touchLastModified(ctx, slug) return nil } -func setReaction(ctx context.Context, messageId int, emoji string, userId string) error { - kay := fmt.Sprintf("message:%d:reactions", messageId) - userId = fmt.Sprintf("%v", userId) - - react := map[string]string{ - userId: emoji, +func setReaction(ctx context.Context, slug string, messageId int, emoji string, userId string) error { + // The message must exist and be live before anything is written (the + // script checks both): an HSET on an invented id would mint an orphan + // reactions key with no TTL, and a soft-deleted message must not keep + // collecting votes. + res, err := toggleReaction.Run(ctx, rdb, []string{ + fmt.Sprintf("channel:%s:message:%d:reactions", slug, messageId), + fmt.Sprintf("channel:%s:messages:%d", slug, messageId), + }, userId, emoji).Result() + if err != nil { + return fmt.Errorf("failed to set reaction: %v", err) } - - prevReact, err := rdb.HGet(ctx, kay, userId).Result() - if err != nil && err != redis.Nil { - return fmt.Errorf("failed to get previous reaction: %v", err) + encoded, _ := dyno.GetString(res) + switch encoded { + case "missing": + return fmt.Errorf("message %d does not exist", messageId) + case "deleted": + return fmt.Errorf("message %d is deleted", messageId) } - if prevReact == emoji { - react = map[string]string{ - userId: "", + var r Reactions + if encoded != "" && encoded != "{}" { + if err := json.Unmarshal([]byte(encoded), &r); err != nil { + return fmt.Errorf("failed to unmarshal reactions: %v", err) } } - if err := rdb.HSet(ctx, kay, react).Err(); err != nil { - return err - } - - r, err := funcGetSumReactions(ctx, messageId) - if err != nil { - return err - } - - if err := updateMessageReactions(ctx, messageId, r); err != nil { - return err - } - pushMessage := PushMessage{ Type: "reaction", M: Message{ @@ -153,7 +257,12 @@ func setReaction(ctx context.Context, messageId int, emoji string, userId string } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, "events", pushMessageData) + publishEvent(ctx, slug, pushMessageData) + // The reaction counts are part of every /messages response, whose ETag is + // derived from last_modified. Without this bump a reload after reacting + // revalidated to 304 and the browser showed its cached, pre-reaction + // counts until something else touched the channel. + touchLastModified(ctx, slug) return nil } @@ -166,25 +275,57 @@ var getMessageRange = redis.NewScript(` local isAdmin = ARGV[2] == 'true' local countViews = ARGV[3] == 'true' local direction = ARGV[4] or 'desc' + local showDeleted = ARGV[5] == 'true' - local start_index + -- ZRANK returns false when the offset key is absent (initial load), which + -- must start at rank 0; a real rank of 0 must still advance past itself. + local rank if direction == 'asc' then - start_index = redis.call('ZRANK', time_set_key, offset_key) or 0 + rank = redis.call('ZRANK', time_set_key, offset_key) else - start_index = redis.call('ZREVRANK', time_set_key, offset_key) or 0 + rank = redis.call('ZREVRANK', time_set_key, offset_key) end - if start_index > 0 then - start_index = start_index + 1 + local start_index = 0 + if rank then + start_index = rank + 1 end local messages = {} + local max_scan_rounds = 20 + -- Bounding rounds alone is not a bound on work: each round does one HGETALL + -- per entry, and a soft-deleted entry is never appended, so #messages stops growing and batch_size stays at the full page + -- size for every round. 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 waited behind it. Cap the entries examined, not the rounds. + -- + -- The trade-off, which the old round cap had too at 2000: a run of more + -- than max_scanned consecutive tombstones exhausts the budget and returns + -- an empty page, so a client paging through one stops early rather than + -- reaching the live messages beneath it. Bounding the work is still the + -- right side to err on — an unbounded scan stalls every tenant, not one + -- feed. Removing deleted entries from this index would make the scan never + -- pay for tombstones at all and retire the cap. + local max_scanned = 500 + local scanned = 0 + local scan_rounds = 0 repeat + scan_rounds = scan_rounds + 1 + if scan_rounds > max_scan_rounds then break end local batch_size = required_length - #messages - local stop_index = start_index + batch_size + -- Never look at more than the remaining budget allows. + if batch_size > max_scanned - scanned then + batch_size = max_scanned - scanned + end + if batch_size <= 0 then break end + -- ZRANGE/ZREVRANGE are inclusive on both ends, so the window is + -- batch_size wide only when stop is one short of start + batch_size. + -- Line 'start_index = start_index + batch_size' below then advances to + -- the next window without re-reading the boundary entry. + local stop_index = start_index + batch_size - 1 local message_ids - + if direction == 'asc' then message_ids = redis.call('ZRANGE', time_set_key, start_index, stop_index) else @@ -198,18 +339,18 @@ var getMessageRange = redis.NewScript(` for i, message_key in ipairs(message_ids) do local message_data = redis.call('HGETALL', message_key) local message = {} - + for j = 1, #message_data, 2 do local key = message_data[j] local value = message_data[j+1] - + if key == 'id' then message[key] = tonumber(value) elseif key == 'views' then if countViews then message[key] = tonumber(value) else - message[key] = 0 + message[key] = 0 end elseif key == 'deleted' then message[key] = value == '1' @@ -238,12 +379,17 @@ var getMessageRange = redis.NewScript(` message[key] = value end end - - if not message['deleted'] or isAdmin then + + -- A deleted message is hidden from every viewer by default, writers + -- and admins included: showing it to them greyed out read as if the + -- delete had not worked. The hash stays in Redis, and staff can ask + -- for it back with showDeleted (the feed's "show deleted" toggle). + if not message['deleted'] or showDeleted then table.insert(messages, message) end end + scanned = scanned + #message_ids start_index = start_index + batch_size until #messages >= required_length @@ -251,9 +397,14 @@ var getMessageRange = redis.NewScript(` return cjson.encode(messages) `) -func funcGetMessageRange(ctx context.Context, start, stop int64, isAdmin, countViews bool, direction string) ([]Message, error) { - offsetKeyName := fmt.Sprintf("messages:%d", start) - res, err := getMessageRange.Run(ctx, rdb, []string{"m_times:1", offsetKeyName}, []string{strconv.FormatInt(stop, 10), strconv.FormatBool(isAdmin), strconv.FormatBool(countViews), direction}).Result() +// showDeleted returns soft-deleted messages too; callers pass it only for +// staff (see getMessages). +func funcGetMessageRange(ctx context.Context, slug string, start, stop int64, isAdmin, countViews, showDeleted bool, direction string) ([]Message, error) { + offsetKeyName := fmt.Sprintf("channel:%s:messages:%d", slug, start) + res, err := getMessageRange.Run(ctx, rdb, []string{ + fmt.Sprintf("channel:%s:m_times", slug), + offsetKeyName, + }, []string{strconv.FormatInt(stop, 10), strconv.FormatBool(isAdmin), strconv.FormatBool(countViews), direction, strconv.FormatBool(showDeleted)}).Result() if err != nil { return []Message{}, err } @@ -283,12 +434,49 @@ var sumMessageReactions = redis.NewScript(` result[reaction] = 1 end end - end + end return cjson.encode(result) `) -func funcGetSumReactions(ctx context.Context, messageId int) (Reactions, error) { - res, err := sumMessageReactions.Run(ctx, rdb, []string{fmt.Sprintf("message:%d:reactions", messageId)}).Result() +// toggleReaction is the whole reaction write in one script: existence and +// deleted checks, the per-user toggle, the recount and the message-field +// write. Done as separate round trips, two viewers reacting within +// milliseconds interleaved so the message stored (and every viewer was +// pushed) a stale total, and one user's double-click read the same "no +// previous reaction" twice and never un-reacted. +// KEYS[1] reactions hash, KEYS[2] message hash; ARGV[1] user id, ARGV[2] emoji. +// Returns the encoded totals, "missing" or "deleted". +var toggleReaction = redis.NewScript(` + if redis.call('EXISTS', KEYS[2]) == 0 then + return 'missing' + end + if redis.call('HGET', KEYS[2], 'deleted') == '1' then + return 'deleted' + end + local prev = redis.call('HGET', KEYS[1], ARGV[1]) + if prev == ARGV[2] then + redis.call('HSET', KEYS[1], ARGV[1], '') + else + redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) + end + local result = {} + for _, reaction in ipairs(redis.call('HVALS', KEYS[1])) do + if reaction ~= '' then + result[reaction] = (result[reaction] or 0) + 1 + end + end + local encoded = cjson.encode(result) + if encoded == '[]' then + encoded = '{}' + end + redis.call('HSET', KEYS[2], 'reactions', encoded) + return encoded +`) + +func funcGetSumReactions(ctx context.Context, slug string, messageId int) (Reactions, error) { + res, err := sumMessageReactions.Run(ctx, rdb, []string{ + fmt.Sprintf("channel:%s:message:%d:reactions", slug, messageId), + }).Result() if err != nil || res == nil || res == "{}" { return nil, err } @@ -302,33 +490,30 @@ func funcGetSumReactions(ctx context.Context, messageId int) (Reactions, error) return reactions, nil } -func updateMessageReactions(ctx context.Context, messageId int, reactions Reactions) error { - messageKey := fmt.Sprintf("messages:%d", messageId) - - exists, err := rdb.Exists(ctx, messageKey).Result() +// dbGetMessageFields returns the raw Redis hash of a message. +// It reports redis.Nil when the message does not exist. +func dbGetMessageFields(ctx context.Context, slug string, id string) (map[string]string, error) { + fields, err := rdb.HGetAll(ctx, fmt.Sprintf("channel:%s:messages:%s", slug, id)).Result() if err != nil { - return err - } - if exists == 0 { - return fmt.Errorf("message %d does not exist", messageId) + return nil, err } - - reactionsJSON, err := json.Marshal(reactions) - if err != nil { - return fmt.Errorf("failed to marshal reactions: %v", err) + if len(fields) == 0 { + return nil, redis.Nil } + return fields, nil +} - if err := rdb.HSet(ctx, messageKey, "reactions", reactionsJSON).Err(); err != nil { +func funcDeleteMessage(ctx context.Context, slug string, id string, deletedBy string) error { + msgKey := fmt.Sprintf("channel:%s:messages:%s", slug, id) + // The write's error used to be discarded: on a Redis failure the delete + // event was still broadcast and the handler answered success, and the + // message came back on the next reload. deletedBy records who took the + // post down, so a writer cannot undo a moderator's takedown (see + // updateMessage). + if err := rdb.HSet(ctx, msgKey, "deleted", true, "deletedBy", deletedBy).Err(); err != nil { return err } - return nil -} - -func funcDeleteMessage(ctx context.Context, id string) error { - msgKey := fmt.Sprintf("messages:%s", id) - rdb.HSet(ctx, msgKey, "deleted", true) - var m Message idInt, _ := strconv.Atoi(id) m.ID = idInt @@ -342,26 +527,39 @@ func funcDeleteMessage(ctx context.Context, id string) error { M: m, } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, "events", pushMessageData) + publishEvent(ctx, slug, pushMessageData) + touchLastModified(ctx, slug) return nil } -func addViewsToMessages(ctx context.Context, messages []Message) { - if !settingConfig.CountViews { +func addViewsToMessages(ctx context.Context, slug string, countViews bool, messages []Message) { + if !countViews { return } + // One pipeline, not one round trip per message. This runs on every page of + // /messages, so at a page size of 100 it turned the platform's most-read + // endpoint into 100 sequential writes per read — competing for the same + // connection pool the rest of the application shares. + pipe := rdb.Pipeline() for _, m := range messages { - rdb.HIncrBy(ctx, fmt.Sprintf("messages:%d", m.ID), "views", 1) + pipe.HIncrBy(ctx, fmt.Sprintf("channel:%s:messages:%d", slug, m.ID), "views", 1) + } + if _, err := pipe.Exec(ctx); err != nil { + log.Printf("addViewsToMessages: %s: recording %d views failed: %v\n", slug, len(messages), err) } } // https://redis.io/docs/latest/operate/oss_and_stack/management/security/#string-escaping-and-nosql-injection -func addSubscription(token string) error { +func addSubscription(slug, token string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := rdb.SAdd(ctx, "subscriptions", token).Result() + key := "subscriptions" + if slug != "" { + key = fmt.Sprintf("channel:%s:subscriptions", slug) + } + _, err := rdb.SAdd(ctx, key, token).Result() if err != nil { return err } @@ -369,45 +567,90 @@ func addSubscription(token string) error { return nil } -func getSubcriptionsList() ([]string, error) { +// countSubscriptions is the size of the channel's token set, read before a +// subscribe so the set can be capped (see maxSubscriptionsPerChannel). +func countSubscriptions(slug string) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - subscriptionsSet, err := rdb.SMembers(ctx, "subscriptions").Result() - if err != nil { + key := "subscriptions" + if slug != "" { + key = fmt.Sprintf("channel:%s:subscriptions", slug) + } + return rdb.SCard(ctx, key).Result() +} + +// removeSubscriptions drops tokens FCM has told us are dead (unregistered or +// malformed). Without this the subscription set only ever grows: every expired +// or rotated device token stays forever and every push wastes an FCM slot on +// it. Best effort — a failed prune just means we retry the dead tokens next push. +func removeSubscriptions(slug string, tokens []string) { + if len(tokens) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + key := "subscriptions" + if slug != "" { + key = fmt.Sprintf("channel:%s:subscriptions", slug) + } + members := make([]any, len(tokens)) + for i, t := range tokens { + members[i] = t + } + if err := rdb.SRem(ctx, key, members...).Err(); err != nil { + log.Printf("removeSubscriptions: pruning %d dead tokens from %s failed: %v\n", len(tokens), key, err) + } +} + +func getSubcriptionsList(slug string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + key := "subscriptions" + if slug != "" { + key = fmt.Sprintf("channel:%s:subscriptions", slug) + } + // SSCAN in pages rather than one SMEMBERS: the set holds every device that + // ever subscribed to the channel, and a single reply carrying all of it + // blocked Redis for the duration on every post to a large channel. + var tokens []string + iter := rdb.SScan(ctx, key, 0, "", 1000).Iterator() + for iter.Next(ctx) { + tokens = append(tokens, iter.Val()) + } + if err := iter.Err(); err != nil { log.Printf("Failed to get subscriptions: %v\n", err) return []string{}, err } - return subscriptionsSet, nil + return tokens, nil } -func getChannelDetails(ctx context.Context) (map[string]string, error) { - return rdb.HGetAll(ctx, "channel:1").Result() +func getChannelDetails(ctx context.Context, slug string) (map[string]string, error) { + return rdb.HGetAll(ctx, fmt.Sprintf("channel:%s", slug)).Result() } -func dbSetEmojisList(ctx context.Context, emojis []string) error { - // if len(emojis) == 0 { - // return fmt.Errorf("emojis list cannot be empty") - // } - +func dbSetEmojisList(ctx context.Context, slug string, emojis []string) error { emojisJSON, err := json.Marshal(emojis) if err != nil { return fmt.Errorf("failed to marshal emojis: %v", err) } - if err := rdb.Set(ctx, "emojis:list", emojisJSON, 0).Err(); err != nil { + key := fmt.Sprintf("channel:%s:emojis:list", slug) + if err := rdb.Set(ctx, key, emojisJSON, 0).Err(); err != nil { return fmt.Errorf("failed to set emojis in db: %v", err) } return nil - } -func dbGetEmojisList(ctx context.Context) ([]string, error) { - emojisJSON, err := rdb.Get(ctx, "emojis:list").Result() +func dbGetEmojisList(ctx context.Context, slug string) ([]string, error) { + key := fmt.Sprintf("channel:%s:emojis:list", slug) + emojisJSON, err := rdb.Get(ctx, key).Result() if err != nil { if err == redis.Nil { - return emojis, nil + return []string{}, nil } return nil, fmt.Errorf("failed to get emojis from db: %v", err) } @@ -420,18 +663,6 @@ func dbGetEmojisList(ctx context.Context) ([]string, error) { return emojisList, nil } -func dbSetUsersList(ctx context.Context, usersList []User) error { - jsonUsersList, err := json.Marshal(usersList) - if err != nil { - return err - } - if err := rdb.Set(ctx, "users:list", jsonUsersList, 0).Err(); err != nil { - return err - } - - return nil -} - func dbGetUsersList(ctx context.Context) ([]User, error) { u, err := rdb.Get(ctx, "users:list").Result() if err != nil { @@ -449,21 +680,72 @@ func dbGetUsersList(ctx context.Context) ([]User, error) { return usersList, nil } -func dbSetSettings(ctx context.Context, settings *Settings) error { +// dbUpdateUsersList applies mutate to the whole users:list blob under a +// WATCH/MULTI, retrying if another writer got there first. Every role change is +// a read-modify-write of one JSON string, so doing it unguarded means two +// concurrent administrative edits silently discard one another. +func dbUpdateUsersList(ctx context.Context, mutate func([]User) []User) error { + // Same budget and backoff as dbUpdateSupportTicket: five back-to-back + // retries with no pause were exhausted by a handful of simultaneous + // privileged logins re-colliding in lockstep, and each loser's login + // answered 500. The jitter spreads the contenders apart. + const maxRetries = 20 + + txf := func(tx *redis.Tx) error { + var users []User + u, err := tx.Get(ctx, "users:list").Result() + if err != nil && err != redis.Nil { + return err + } + if err == nil { + if err := json.Unmarshal([]byte(u), &users); err != nil { + return err + } + } + + data, err := json.Marshal(mutate(users)) + if err != nil { + return err + } + + _, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error { + p.Set(ctx, "users:list", data, 0) + return nil + }) + return err + } + + for i := 0; i < maxRetries; i++ { + err := rdb.Watch(ctx, txf, "users:list") + if err != redis.TxFailedErr { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(rand.Int63n(int64(2*time.Millisecond))) + time.Millisecond): + } + } + return errors.New("users:list: too much contention") +} + +func dbSetSettings(ctx context.Context, slug string, settings *Settings) error { jsonSettings, err := json.Marshal(settings) if err != nil { return fmt.Errorf("failed to marshal settings: %v", err) } - if err := rdb.Set(ctx, "settings:list", jsonSettings, 0).Err(); err != nil { + key := fmt.Sprintf("channel:%s:settings", slug) + if err := rdb.Set(ctx, key, jsonSettings, 0).Err(); err != nil { return fmt.Errorf("failed to set settings in db: %v", err) } return nil } -func dbGetSettings(ctx context.Context) (Settings, error) { - settingsJSON, err := rdb.Get(ctx, "settings:list").Result() +func dbGetSettings(ctx context.Context, slug string) (Settings, error) { + key := fmt.Sprintf("channel:%s:settings", slug) + settingsJSON, err := rdb.Get(ctx, key).Result() if err != nil { if err == redis.Nil { return Settings{}, nil @@ -479,35 +761,79 @@ func dbGetSettings(ctx context.Context) (Settings, error) { return settings, nil } -func dbGetUsersAmount(ctx context.Context) (int64, error) { - amount, err := rdb.SCard(ctx, "registered_emails").Result() +// Global settings (FCM/VAPID) stored under global:settings +func dbSetGlobalSettings(ctx context.Context, settings *Settings) error { + jsonSettings, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("failed to marshal global settings: %v", err) + } + + if err := rdb.Set(ctx, "global:settings", jsonSettings, 0).Err(); err != nil { + return fmt.Errorf("failed to set global settings in db: %v", err) + } + + return nil +} + +func dbGetGlobalSettings(ctx context.Context) (Settings, error) { + settingsJSON, err := rdb.Get(ctx, "global:settings").Result() + if err != nil { + if err == redis.Nil { + // Fallback to legacy settings:list key + settingsJSON2, err2 := rdb.Get(ctx, "settings:list").Result() + if err2 != nil { + if err2 == redis.Nil { + return Settings{}, nil + } + return nil, fmt.Errorf("failed to get global settings from db: %v", err2) + } + settingsJSON = settingsJSON2 + } else { + return nil, fmt.Errorf("failed to get global settings from db: %v", err) + } + } + + var settings Settings + if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { + return nil, fmt.Errorf("failed to unmarshal global settings: %v", err) + } + + return settings, nil +} + +func dbGetUsersAmount(ctx context.Context, slug string) (int64, error) { + key := fmt.Sprintf("channel:%s:registered_emails", slug) + amount, err := rdb.SCard(ctx, key).Result() if err != nil { return 0, fmt.Errorf("failed to get users amount: %v", err) } return amount, nil } -func getReportNextID(ctx context.Context) (int64, error) { - return rdb.Incr(ctx, "report:next_id").Result() +func getReportNextID(ctx context.Context, slug string) (int64, error) { + return rdb.Incr(ctx, fmt.Sprintf("channel:%s:report:next_id", slug)).Result() } -func dbReportMessage(ctx context.Context, report *Report) error { - id, err := getReportNextID(ctx) +func dbReportMessage(ctx context.Context, slug string, report *Report) error { + id, err := getReportNextID(ctx, slug) if err != nil { return err } - reportKey := fmt.Sprintf("report:%d", id) + reportKey := fmt.Sprintf("channel:%s:report:%d", slug, id) report.Id = id if err := rdb.HSet(ctx, reportKey, report).Err(); err != nil { return err } - if err := rdb.ZAdd(ctx, "reports:list", redis.Z{Score: float64(report.CreatedAt.Unix()), Member: reportKey}).Err(); err != nil { + reportsListKey := fmt.Sprintf("channel:%s:reports:list", slug) + reportsOpenKey := fmt.Sprintf("channel:%s:reports:open", slug) + + if err := rdb.ZAdd(ctx, reportsListKey, redis.Z{Score: float64(report.CreatedAt.Unix()), Member: reportKey}).Err(); err != nil { return err } - if err := rdb.ZAdd(ctx, "reports:open", redis.Z{Score: float64(report.CreatedAt.Unix()), Member: reportKey}).Err(); err != nil { + if err := rdb.ZAdd(ctx, reportsOpenKey, redis.Z{Score: float64(report.CreatedAt.Unix()), Member: reportKey}).Err(); err != nil { return err } @@ -555,44 +881,68 @@ var getReportsScript = redis.NewScript(` return cjson.encode(result) `) -func dbGetReports(ctx context.Context, status ReportStatus) (Reports, error) { - jsonReports, err := getReportsScript.Run(ctx, rdb, []string{"reports:list", "reports:open", "reports:closed"}, []string{string(status), "100"}).Result() +func dbGetReports(ctx context.Context, slug string, status ReportStatus) (Reports, error) { + listKey := fmt.Sprintf("channel:%s:reports:list", slug) + openKey := fmt.Sprintf("channel:%s:reports:open", slug) + closedKey := fmt.Sprintf("channel:%s:reports:closed", slug) + + jsonReports, err := getReportsScript.Run(ctx, rdb, []string{listKey, openKey, closedKey}, []string{string(status), "100"}).Result() if err != nil { return nil, err } - var reports Reports + // The handler encodes this value straight to the client, and a nil slice + // serialises as null, which the reports screen does not accept. + reports := make(Reports, 0) if jsonReports == nil || jsonReports == "{}" { return reports, nil } resStr, _ := dyno.GetString(jsonReports) if resStr == "" { - return nil, nil + return reports, nil } if err := json.Unmarshal([]byte(resStr), &reports); err != nil { return nil, err } + if reports == nil { + reports = make(Reports, 0) + } return reports, nil } -func dbSetReports(ctx context.Context, report *Report) error { - reportKey := fmt.Sprintf("report:%d", report.Id) +func dbSetReports(ctx context.Context, slug string, report *Report) error { + reportKey := fmt.Sprintf("channel:%s:report:%d", slug, report.Id) + openKey := fmt.Sprintf("channel:%s:reports:open", slug) + closedKey := fmt.Sprintf("channel:%s:reports:closed", slug) + + // The report must already exist: HSET below would otherwise fabricate a + // phantom hash and index it under open/closed while reports:list (which only + // dbReportMessage writes) never learns about it — a permanent desync. + // messageId is always written by dbReportMessage's full-struct HSet. + exists, err := rdb.HExists(ctx, reportKey, "messageId").Result() + if err != nil { + return err + } + if !exists { + return redis.Nil + } + switch report.Closed { case true: - if err := rdb.ZRem(ctx, "reports:open", reportKey).Err(); err != nil { + if err := rdb.ZRem(ctx, openKey, reportKey).Err(); err != nil { return err } - if err := rdb.ZAdd(ctx, "reports:closed", redis.Z{Score: float64(report.UpdatedAt.Unix()), Member: reportKey}).Err(); err != nil { + if err := rdb.ZAdd(ctx, closedKey, redis.Z{Score: float64(report.UpdatedAt.Unix()), Member: reportKey}).Err(); err != nil { return err } case false: - if err := rdb.ZRem(ctx, "reports:closed", reportKey).Err(); err != nil { + if err := rdb.ZRem(ctx, closedKey, reportKey).Err(); err != nil { return err } - if err := rdb.ZAdd(ctx, "reports:open", redis.Z{Score: float64(report.UpdatedAt.Unix()), Member: reportKey}).Err(); err != nil { + if err := rdb.ZAdd(ctx, openKey, redis.Z{Score: float64(report.UpdatedAt.Unix()), Member: reportKey}).Err(); err != nil { return err } } @@ -604,40 +954,100 @@ func dbSetReports(ctx context.Context, report *Report) error { return nil } -func dbSavePeakSSEConnections(peak *PeakSSEConnections) { +// peakSSEMaxWrite persists a peak only when it exceeds the stored one, so a +// replica with a small local count can never overwrite a larger recorded peak, +// and clearing the key (resetStatistics) is a complete cross-replica reset. +var peakSSEMaxWrite = redis.NewScript(` + local cur = tonumber(redis.call('HGET', KEYS[1], 'value') or '0') + if tonumber(ARGV[1]) > cur then + redis.call('HSET', KEYS[1], 'value', ARGV[1], 'timestamp', ARGV[2]) + return 1 + end + return 0 +`) + +func dbSavePeakSSEConnections(slug string, peak *PeakSSEConnections) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - rdb.HSet(ctx, "peak_sse_connections", "value", peak.Value, "timestamp", peak.Timestamp.Unix()) + key := fmt.Sprintf("channel:%s:peak_sse_connections", slug) + peakSSEMaxWrite.Run(ctx, rdb, []string{key}, + strconv.FormatInt(peak.Value, 10), strconv.FormatInt(peak.Timestamp.Unix(), 10)) } -func dbGetPeakSSEConnections(ctx context.Context) (*PeakSSEConnections, error) { - p, err := rdb.HGetAll(ctx, "peak_sse_connections").Result() +func dbGetPeakSSEConnections(ctx context.Context, slug string) (*PeakSSEConnections, error) { + key := fmt.Sprintf("channel:%s:peak_sse_connections", slug) + p, err := rdb.HGetAll(ctx, key).Result() if err != nil { return nil, err } + // No record (a new channel, or right after resetStatistics) is a zero + // value with a zero time. time.Unix(0, 0) here was a real 1970 timestamp, + // which the statistics page rendered as a peak of 0 users on 01/01/1970. var peak PeakSSEConnections + if len(p) == 0 { + return &peak, nil + } vel, _ := dyno.GetInteger(p["value"]) timestamp, _ := dyno.GetInteger(p["timestamp"]) peak.Value = vel - peak.Timestamp = time.Unix(timestamp, 0) + if timestamp > 0 { + peak.Timestamp = time.Unix(timestamp, 0) + } return &peak, nil } -func dbSaveSSEStatistics(amount int64) { +// sseStatisticsMaxPoints bounds one channel-month series. dbGetSSEStatistics +// reads at most 1000 points back, so keeping more only costs memory. +const sseStatisticsMaxPoints = 1000 + +// sseStatisticsTTL retires a channel-month series well after the month it +// covers, so an abandoned channel cannot leave keys behind for ever. +const sseStatisticsTTL = 400 * 24 * time.Hour + +// sseStatisticsKeysFor returns every monthly SSE-statistics key a channel could +// hold, over the retention window. These keys have no index, so both channel +// deletion and the statistics reset enumerate the month/year space rather than +// SCAN the keyspace. It is centralised here so the enumeration cannot drift from +// the write-side format in dbSaveSSEStatistics. +func sseStatisticsKeysFor(slug string) []string { + currentYear := time.Now().Year() + keys := make([]string, 0, 12*11) + for year := currentYear - 10; year <= currentYear; year++ { + for month := time.January; month <= time.December; month++ { + keys = append(keys, fmt.Sprintf("channel:%s:sse_statistics:%d:%d", slug, month, year)) + } + } + return keys +} + +func dbSaveSSEStatistics(slug string, amount int64) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - key := fmt.Sprintf("sse_statistics:%d:%d", time.Now().Month(), time.Now().Year()) + key := fmt.Sprintf("channel:%s:sse_statistics:%d:%d", slug, time.Now().Month(), time.Now().Year()) member := fmt.Sprintf("%d&%s", amount, time.Now().Format("02-01-2006 15:04")) - rdb.ZAdd(ctx, key, redis.Z{Score: float64(time.Now().Unix()), Member: member}) + // A member is added on every connect and every disconnect, and distinct + // counts within the same minute are distinct members — so on a busy channel + // with churning mobile clients this series grew without bound, for every + // channel, with nothing but a channel delete or a statistics reset ever + // removing it. Trim to the most recent entries and expire the key, so the + // series stays a rolling window rather than a permanent leak. Only the last + // 1000 are ever read back (dbGetSSEStatistics). + pipe := rdb.Pipeline() + pipe.ZAdd(ctx, key, redis.Z{Score: float64(time.Now().Unix()), Member: member}) + pipe.ZRemRangeByRank(ctx, key, 0, -(sseStatisticsMaxPoints + 1)) + pipe.Expire(ctx, key, sseStatisticsTTL) + if _, err := pipe.Exec(ctx); err != nil { + log.Printf("dbSaveSSEStatistics: %s: recording connection count failed: %v\n", slug, err) + } } -func dbGetSSEStatistics(ctx context.Context, length int64) (*Statistics, error) { - key := fmt.Sprintf("sse_statistics:%d:%d", time.Now().Month(), time.Now().Year()) +func dbGetSSEStatistics(ctx context.Context, slug string, length int64) (*Statistics, error) { + key := fmt.Sprintf("channel:%s:sse_statistics:%d:%d", slug, time.Now().Month(), time.Now().Year()) result := &Statistics{ Data: []int64{}, Labels: []string{}, @@ -666,21 +1076,49 @@ func dbGetSSEStatistics(ctx context.Context, length int64) (*Statistics, error) return result, nil } -func dbSaveScheduledMessages(ctx context.Context, messages *[]Message) error { +func dbSaveScheduledMessages(ctx context.Context, slug string, messages *[]Message) error { jsonMessages, err := json.Marshal(messages) if err != nil { return fmt.Errorf("failed to marshal scheduled messages: %v", err) } - if err := rdb.Set(ctx, "scheduled_messages:list", jsonMessages, 0).Err(); err != nil { + key := fmt.Sprintf("channel:%s:scheduled_messages:list", slug) + if err := rdb.Set(ctx, key, jsonMessages, 0).Err(); err != nil { return fmt.Errorf("failed to set scheduled messages in db: %v", err) } + // Maintain a global sorted set of "next due time" per channel so the + // scheduler only wakes channels that actually have pending messages. + // A zero time.Time scores far below any real timestamp, so including it + // would win the minimum, fail the earliest > 0 test and drop the channel + // from due_channels entirely — silently stalling its other pending posts. + var earliest float64 + for _, m := range *messages { + ts := float64(m.Timestamp.Unix()) + if ts <= 0 { + continue + } + if earliest == 0 || ts < earliest { + earliest = ts + } + } + // The index write must be reported: the list is already saved, so a lost + // ZADD strands a due message that the scheduler never wakes for, while the + // writer was told the save succeeded. + if earliest > 0 { + if err := rdb.ZAdd(ctx, "scheduled:due_channels", redis.Z{Score: earliest, Member: slug}).Err(); err != nil { + return fmt.Errorf("index scheduled messages of %s: %w", slug, err) + } + } else if err := rdb.ZRem(ctx, "scheduled:due_channels", slug).Err(); err != nil { + return fmt.Errorf("unindex scheduled messages of %s: %w", slug, err) + } + return nil } -func dbGetScheduledMessages(ctx context.Context) (*[]Message, error) { - messagesJSON, err := rdb.Get(ctx, "scheduled_messages:list").Result() +func dbGetScheduledMessages(ctx context.Context, slug string) (*[]Message, error) { + key := fmt.Sprintf("channel:%s:scheduled_messages:list", slug) + messagesJSON, err := rdb.Get(ctx, key).Result() if err != nil { if err == redis.Nil { return &[]Message{}, nil @@ -695,3 +1133,765 @@ func dbGetScheduledMessages(ctx context.Context) (*[]Message, error) { return &messages, nil } + +// Channel CRUD functions + +func dbChannelExists(ctx context.Context, slug string) (bool, error) { + n, err := rdb.Exists(ctx, fmt.Sprintf("channel:%s", slug)).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +// errChannelExists reports that the slug was already claimed. Callers must map +// it to 409 rather than 500. +var errChannelExists = errors.New("channel already exists") + +func dbCreateChannel(ctx context.Context, channel *ChannelData) error { + hashKey := fmt.Sprintf("channel:%s", channel.Slug) + + // Claim the slug atomically. A plain HSet lets two concurrent creates for the + // same slug both succeed, overwriting one channel's metadata and granting two + // owners; the caller's prior existence check cannot prevent that on its own. + claimed, err := rdb.HSetNX(ctx, hashKey, "slug", channel.Slug).Result() + if err != nil { + return err + } + if !claimed { + return errChannelExists + } + + // The claim above is what makes the slug unavailable, so anything that fails + // after it has to release the claim or the slug stays wedged forever. + releaseClaim := func() { + rdb.Del(context.WithoutCancel(ctx), hashKey) + } + + if err := rdb.HSet(ctx, hashKey, + "name", channel.Name, + "description", channel.Description, + "logoUrl", channel.LogoUrl, + "ownerEmail", channel.OwnerEmail, + "createdAt", channel.CreatedAt.Format(time.RFC3339), + "contactUs", channel.ContactUs, + ).Err(); err != nil { + releaseClaim() + return err + } + + featuresJSON, err := json.Marshal(channel.Features) + if err != nil { + releaseClaim() + return fmt.Errorf("failed to marshal features: %v", err) + } + + featuresKey := fmt.Sprintf("channel:%s:features", channel.Slug) + if err := rdb.Set(ctx, featuresKey, featuresJSON, 0).Err(); err != nil { + releaseClaim() + return err + } + + if err := rdb.ZAdd(ctx, "channels:list", redis.Z{ + Score: float64(channel.CreatedAt.Unix()), + Member: channel.Slug, + }).Err(); err != nil { + releaseClaim() + return err + } + + return nil +} + +func dbGetChannel(ctx context.Context, slug string) (*ChannelData, error) { + hashKey := fmt.Sprintf("channel:%s", slug) + h, err := rdb.HGetAll(ctx, hashKey).Result() + if err != nil { + return nil, err + } + + featuresKey := fmt.Sprintf("channel:%s:features", slug) + featuresJSON, err := rdb.Get(ctx, featuresKey).Result() + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("load features for %s: %w", slug, err) + } + return channelFromRecords(slug, h, featuresJSON, err == nil) +} + +// channelFromRecords assembles a ChannelData from its two Redis records, so +// the single-channel read and the pipelined list read cannot drift apart. +// hasFeatures is false when the features key does not exist. +func channelFromRecords(slug string, h map[string]string, featuresJSON string, hasFeatures bool) (*ChannelData, error) { + if len(h) == 0 { + return nil, redis.Nil + } + + channel := &ChannelData{ + Slug: h["slug"], + Name: h["name"], + Description: h["description"], + LogoUrl: h["logoUrl"], + OwnerEmail: h["ownerEmail"], + ContactUs: h["contactUs"], + + SeoHidden: h["seoHidden"] == "1", + SeoTitle: h["seoTitle"], + SeoDescription: h["seoDescription"], + } + if h["slug"] == "" { + channel.Slug = slug + } + + if t, err := time.Parse(time.RFC3339, h["createdAt"]); err == nil { + channel.CreatedAt = t + } + + // A failed features read must surface rather than silently serving a + // zero-valued ChannelFeatures: RequireAuth/RequireAuthFiles defaulting to + // false on a transient error would fail open on an auth toggle. Only a + // genuinely absent key keeps the zero value. + if hasFeatures { + var features ChannelFeatures + if err := json.Unmarshal([]byte(featuresJSON), &features); err != nil { + return nil, fmt.Errorf("unmarshal features for %s: %w", slug, err) + } + channel.Features = features + } + + return channel, nil +} + +// dbListChannelsBatch bounds one pipeline so a platform with tens of +// thousands of channels never builds a single multi-megabyte reply. +const dbListChannelsBatch = 500 + +func dbListChannels(ctx context.Context) ([]*ChannelData, error) { + slugs, err := rdb.ZRange(ctx, "channels:list", 0, -1).Result() + if err != nil { + return nil, err + } + + // Never nil: an empty platform must list as [] and not as JSON null, which + // the super-admin screen does not treat as a list. + channels := make([]*ChannelData, 0, len(slugs)) + + // Pipelined: the two records per channel used to be read one channel at a + // time, so the super-admin list, the statistics reset and the ads/magnet + // lock sync all cost two round trips per channel and ran out of their + // handler budgets somewhere in the low thousands of channels. + for start := 0; start < len(slugs); start += dbListChannelsBatch { + end := min(start+dbListChannelsBatch, len(slugs)) + batch := slugs[start:end] + + pipe := rdb.Pipeline() + hashes := make([]*redis.MapStringStringCmd, len(batch)) + features := make([]*redis.StringCmd, len(batch)) + for i, slug := range batch { + hashes[i] = pipe.HGetAll(ctx, fmt.Sprintf("channel:%s", slug)) + features[i] = pipe.Get(ctx, fmt.Sprintf("channel:%s:features", slug)) + } + // Exec reports the first failed command, and a missing features key + // is a redis.Nil "failure" here; only a real error aborts the list. + if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil { + return nil, err + } + + for i, slug := range batch { + h, err := hashes[i].Result() + if err != nil { + log.Printf("Failed to get channel %s: %v\n", slug, err) + continue + } + featuresJSON, err := features[i].Result() + if err != nil && err != redis.Nil { + log.Printf("Failed to get channel %s: load features: %v\n", slug, err) + continue + } + ch, err := channelFromRecords(slug, h, featuresJSON, err == nil) + if err != nil { + log.Printf("Failed to get channel %s: %v\n", slug, err) + continue + } + channels = append(channels, ch) + } + } + + return channels, nil +} + +func dbDeleteChannel(ctx context.Context, slug string) error { + p := slug + + // Step 0: revoke every per-channel role for this slug FIRST. This used to + // be the last step, after the keys were gone: if it failed (or the 60s + // budget ran out on a channel with many uploads), the channel was already + // deleted but the grants survived, and recreating the same slug handed the + // old grantees their access back. Revoking first means a failure further + // down leaves a channel that still exists and can simply be deleted again. + if err := dbUpdateUsersList(ctx, func(users []User) []User { + for i := range users { + delete(users[i].ChannelRoles, slug) // delete on a nil map is a no-op + } + return users + }); err != nil { + return fmt.Errorf("clear roles for %s: %w", slug, err) + } + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after dbDeleteChannel: %v\n", err) + } + + // Step 0b: fence the channel. Revoking roles does not stop every writer: + // the API-key import path authenticates with the channel's secret, not a + // role, and a super admin passes hasChannelRole outright — so until the + // hash went in the final pipeline both could still post during the + // (long) file sweep below and leave an unindexed message hash behind. + // Disabled is the one flag channelMiddleware and addNewPost both refuse + // on. A delete that fails past this point leaves a disabled channel, + // which is the safer state: the operator deletes it again (deleteChannel + // checks existence only) or re-enables it from the features form. + if err := dbUpdateChannelFeatures(ctx, slug, func(f *ChannelFeatures) { f.Disabled = true }); err != nil { + return fmt.Errorf("fence %s: %w", slug, err) + } + // Tell the hubs once more, now that the fence is up. deleteChannel + // published the control event before calling here, but until the fence + // channelMiddleware still admitted /events: a viewer whose stream had + // just been closed reconnected and built a fresh hub — on another replica + // one that no stop reaches — which then never saw a control event, sat on + // the deleted key, and fanned the events of whatever tenant re-created + // the slug to viewers who never passed that channel's middleware. Any hub + // built in the window reads this entry and retires; a second control + // event is a no-op for a client and a hub that already got the first. + // The entry goes with the stream in the final pipeline. + publishEvent(ctx, slug, []byte(sseChannelGoneEvent)) + + // Step 1: release every uploaded file through the normal delete path so + // hash refcounts are decremented and the R2/disk blob is removed once the + // last reference is gone. The tracking set is NOT deleted up front: it is + // the only record of which blobs still need cleanup, so destroying it before + // the loop finishes would orphan every remaining blob permanently if the + // context expires or the process dies mid-delete. The per-file index removal + // is skipped instead (removeFromIndex=false) to keep the loop O(N); the set + // itself is dropped with the fixed keys below once the loop completes. + // + // Two passes over the set: the fence above refuses new uploads, but one + // admitted just before it can still index its file while this sweep runs + // (an R2 write has a minute's budget), and a single snapshot left that + // blob and its reference count orphaned when the set was dropped. The + // second pass re-reads the set and releases only what the first missed. + fileKeys := make([]string, 0) + swept := make(map[string]bool) + for pass := 0; pass < 2; pass++ { + fileMembers, err := rdb.ZRange(ctx, channelFilesKey(p), 0, -1).Result() + if err != nil { + return fmt.Errorf("list files of %s: %w", slug, err) + } + unreleased := 0 + for _, m := range fileMembers { + if err := ctx.Err(); err != nil { + return err + } + fileID, _, ok := decodeFileMember(m) + if !ok || swept[fileID] { + continue + } + swept[fileID] = true + if len(fileID) >= 4 { + // A file that could not be released (a failed read or claim) + // keeps its record and its index: deleting them regardless + // orphaned the blob and its reference count for good, behind a + // success response. The channel survives and is deleted again. + if _, released := deleteFileByID(ctx, p, fileID, false); !released { + unreleased++ + continue + } + } + fileKeys = append(fileKeys, "file:"+fileID) + } + if unreleased > 0 { + return fmt.Errorf("%d of %d files of %s could not be released; delete the channel again", unreleased, len(fileMembers), slug) + } + } + + // Step 1b: collect all message keys and reaction keys from the sorted set + // (avoids SCAN). Every index read below must succeed before anything is + // deleted: an ignored error left the slice empty, Step 2 then dropped the + // index itself, and every message, reaction, report hash and — worst — + // every file blob and hash refcount it pointed at was orphaned for good, + // while the handler still reported success. + // + // Taken after the file sweep, not before it: a post admitted before the + // fence can land while the sweep runs, and a snapshot from before it left + // that message hash behind when the index was deleted — to resurface on a + // re-created slug. + messageKeys, err := rdb.ZRange(ctx, fmt.Sprintf("channel:%s:m_times", p), 0, -1).Result() + if err != nil { + return fmt.Errorf("list messages of %s: %w", slug, err) + } + reactionKeys := make([]string, 0, len(messageKeys)) + reporterKeys := make([]string, 0, len(messageKeys)) + for _, mk := range messageKeys { + // mk is already the full key e.g. "channel:{slug}:messages:{id}" + // derive reaction key from it by replacing "messages:" prefix with "message:" and appending ":reactions" + id := strings.TrimPrefix(mk, fmt.Sprintf("channel:%s:messages:", p)) + reactionKeys = append(reactionKeys, fmt.Sprintf("channel:%s:message:%s:reactions", p, id)) + // The per-message report-dedup set lives under the same message id. + reporterKeys = append(reporterKeys, fmt.Sprintf("channel:%s:message:%s:reporters", p, id)) + } + + // Step 1c: collect the individual report hashes referenced by the reports index + reportKeys, err := rdb.ZRange(ctx, fmt.Sprintf("channel:%s:reports:list", p), 0, -1).Result() + if err != nil { + return fmt.Errorf("list reports of %s: %w", slug, err) + } + + // Step 1d: monthly SSE statistics keys have no index; enumerate the possible + // month/year combinations instead of running a SCAN across the keyspace. + statsKeys := sseStatisticsKeysFor(p) + + // Step 2: delete all known fixed keys in one pipeline + fixedKeys := []string{ + fmt.Sprintf("channel:%s", p), + fmt.Sprintf("channel:%s:settings", p), + fmt.Sprintf("channel:%s:features", p), + fmt.Sprintf("channel:%s:m_times", p), + fmt.Sprintf("channel:%s:message:next_id", p), + fmt.Sprintf("channel:%s:subscriptions", p), + fmt.Sprintf("channel:%s:registered_emails", p), + fmt.Sprintf("channel:%s:emojis:list", p), + fmt.Sprintf("channel:%s:reports:list", p), + fmt.Sprintf("channel:%s:reports:open", p), + fmt.Sprintf("channel:%s:reports:closed", p), + fmt.Sprintf("channel:%s:report:next_id", p), + fmt.Sprintf("channel:%s:scheduled_messages:list", p), + fmt.Sprintf("channel:%s:scheduled:dispatched", p), + fmt.Sprintf("channel:%s:peak_sse_connections", p), + fmt.Sprintf("channel:%s:storage:used_bytes", p), + fmt.Sprintf("channel:%s:storage:quota_bytes", p), + fmt.Sprintf("channel:%s:storage:auto_cleanup", p), + fmt.Sprintf("channel:%s:files", p), + fmt.Sprintf("channel:%s:sse_connections", p), + fmt.Sprintf("channel:%s:events", p), + fmt.Sprintf("channel:%s:last_modified", p), + channelMagnetStatsKeyKey(p), + } + allKeys := append(fixedKeys, messageKeys...) + allKeys = append(allKeys, reactionKeys...) + allKeys = append(allKeys, reporterKeys...) + allKeys = append(allKeys, fileKeys...) + allKeys = append(allKeys, reportKeys...) + allKeys = append(allKeys, statsKeys...) + + pipe := rdb.Pipeline() + // Delete in batches of 200 to avoid huge single commands + for i := 0; i < len(allKeys); i += 200 { + end := i + 200 + if end > len(allKeys) { + end = len(allKeys) + } + pipe.Del(ctx, allKeys[i:end]...) + } + pipe.ZRem(ctx, "channels:list", slug) + pipe.ZRem(ctx, "scheduled:due_channels", slug) + if _, err := pipe.Exec(ctx); err != nil { + return err + } + + return nil +} + +func dbSetChannelFeatures(ctx context.Context, slug string, features *ChannelFeatures) error { + featuresJSON, err := json.Marshal(features) + if err != nil { + return fmt.Errorf("failed to marshal features: %v", err) + } + + featuresKey := fmt.Sprintf("channel:%s:features", slug) + if err := rdb.Set(ctx, featuresKey, featuresJSON, 0).Err(); err != nil { + return err + } + + return nil +} + +// dbUpdateChannelFeatures applies mutate to the channel's stored features +// under WATCH/MULTI, retrying when another writer got in between. The +// features blob is one JSON string with several independent writers (the +// super-admin features form, the ads and magnet lock syncs), and a blind +// read-modify-SET from any of them replayed a stale snapshot over the others': +// a channel disabled while a lock sync was running came back enabled. +func dbUpdateChannelFeatures(ctx context.Context, slug string, mutate func(*ChannelFeatures)) error { + featuresKey := fmt.Sprintf("channel:%s:features", slug) + const maxRetries = 10 + for i := 0; i < maxRetries; i++ { + err := rdb.Watch(ctx, func(tx *redis.Tx) error { + var features ChannelFeatures + raw, err := tx.Get(ctx, featuresKey).Result() + if err != nil && err != redis.Nil { + return err + } + if err == nil { + if err := json.Unmarshal([]byte(raw), &features); err != nil { + return fmt.Errorf("unmarshal features for %s: %w", slug, err) + } + } + mutate(&features) + out, err := json.Marshal(&features) + if err != nil { + return fmt.Errorf("failed to marshal features: %v", err) + } + _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.Set(ctx, featuresKey, out, 0) + return nil + }) + return err + }, featuresKey) + if err == redis.TxFailedErr { + continue + } + return err + } + return fmt.Errorf("update features for %s: too many concurrent writers", slug) +} + +func dbAssignChannelRole(ctx context.Context, email, slug string, role ChannelRole) error { + email = normEmail(email) + return dbUpdateUsersList(ctx, func(users []User) []User { + for i, u := range users { + if normEmail(u.Email) == email { + if users[i].ChannelRoles == nil { + users[i].ChannelRoles = make(map[string]ChannelRole) + } + users[i].ChannelRoles[slug] = role + return users + } + } + return append(users, User{ + Email: email, + ChannelRoles: map[string]ChannelRole{slug: role}, + }) + }) +} + +// GlobalMagnetConfig stored at global:magnet:config +type GlobalMagnetConfig struct { + Enabled bool `json:"enabled"` + Snippet string `json:"snippet"` + Mode string `json:"mode"` + PerMessages int64 `json:"perMessages"` + MinTimeSeconds int64 `json:"minTimeSeconds"` + PerSeconds int64 `json:"perSeconds"` + MinMessagesSinceLast int64 `json:"minMessagesSinceLast"` + ApiKey string `json:"apiKey"` + LockAll bool `json:"lockAll"` // override ALL channels + LockedChannels []string `json:"lockedChannels"` // override specific channels + // StatsKey is the Magnet server-to-server stats secret (mgs_…). It is + // stored here and sent only to Magnet; no handler may serialize it — + // GET/POST /magnet/config answer with magnetConfigView instead. + StatsKey string `json:"statsKey"` +} + +func dbGetGlobalMagnetConfig(ctx context.Context) (*GlobalMagnetConfig, error) { + data, err := rdb.Get(ctx, "global:magnet:config").Result() + if err != nil { + if err == redis.Nil { + return &GlobalMagnetConfig{}, nil + } + return nil, err + } + var cfg GlobalMagnetConfig + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func dbSetGlobalMagnetConfig(ctx context.Context, cfg *GlobalMagnetConfig) error { + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return rdb.Set(ctx, "global:magnet:config", data, 0).Err() +} + +// channelMagnetStatsKeyKey holds an unlocked channel's own Magnet stats key. +// It lives outside channel::settings on purpose: settings/get hands that +// whole blob to the owner's browser, and this secret must never reach one. +func channelMagnetStatsKeyKey(slug string) string { + return fmt.Sprintf("channel:%s:magnet:stats_key", slug) +} + +// dbGetChannelMagnetStatsKey returns "" when the channel has none. +func dbGetChannelMagnetStatsKey(ctx context.Context, slug string) (string, error) { + v, err := rdb.Get(ctx, channelMagnetStatsKeyKey(slug)).Result() + if err == redis.Nil { + return "", nil + } + return v, err +} + +// dbSetChannelMagnetStatsKey stores key, or removes it when key is "". +func dbSetChannelMagnetStatsKey(ctx context.Context, slug, key string) error { + if key == "" { + return rdb.Del(ctx, channelMagnetStatsKeyKey(slug)).Err() + } + return rdb.Set(ctx, channelMagnetStatsKeyKey(slug), key, 0).Err() +} + +// GlobalAdsConfig stored at global:ads:config +type GlobalAdsConfig struct { + Src string `json:"src"` + Width int64 `json:"width"` + LockAll bool `json:"lockAll"` // override ALL channels + LockedChannels []string `json:"lockedChannels"` // override specific channels +} + +func dbGetGlobalAdsConfig(ctx context.Context) (*GlobalAdsConfig, error) { + data, err := rdb.Get(ctx, "global:ads:config").Result() + if err != nil { + if err == redis.Nil { + return &GlobalAdsConfig{}, nil + } + return nil, err + } + var cfg GlobalAdsConfig + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func dbSetGlobalAdsConfig(ctx context.Context, cfg *GlobalAdsConfig) error { + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return rdb.Set(ctx, "global:ads:config", data, 0).Err() +} + +// ─── Storage Quota & Usage ──────────────────────────────────────────────────── + +const defaultStorageQuotaBytes = int64(5 * 1024 * 1024 * 1024) // 5 GB + +func dbGetGlobalStorageQuota(ctx context.Context) (int64, error) { + v, err := rdb.Get(ctx, "global:storage:quota_bytes").Int64() + if err != nil { + if err == redis.Nil { + return defaultStorageQuotaBytes, nil + } + return 0, err + } + return v, nil +} + +func dbSetGlobalStorageQuota(ctx context.Context, bytes int64) error { + return rdb.Set(ctx, "global:storage:quota_bytes", bytes, 0).Err() +} + +// dbGetChannelStorageQuota returns the quota for a channel. +// 0 means "use global default". +func dbGetChannelStorageQuota(ctx context.Context, slug string) (int64, error) { + v, err := rdb.Get(ctx, "channel:"+slug+":storage:quota_bytes").Int64() + if err != nil { + if err == redis.Nil { + return 0, nil // 0 = use global + } + return 0, err + } + return v, nil +} + +func dbSetChannelStorageQuota(ctx context.Context, slug string, bytes int64) error { + if bytes == 0 { + return rdb.Del(ctx, "channel:"+slug+":storage:quota_bytes").Err() + } + return rdb.Set(ctx, "channel:"+slug+":storage:quota_bytes", bytes, 0).Err() +} + +// dbGetEffectiveStorageQuota returns the effective quota for a channel (channel override or global default). +func dbGetEffectiveStorageQuota(ctx context.Context, slug string) (int64, error) { + v, err := dbGetChannelStorageQuota(ctx, slug) + if err != nil { + return 0, err + } + if v > 0 { + return v, nil + } + return dbGetGlobalStorageQuota(ctx) +} + +func dbGetChannelStorageUsed(ctx context.Context, slug string) (int64, error) { + v, err := rdb.Get(ctx, "channel:"+slug+":storage:used_bytes").Int64() + if err != nil { + if err == redis.Nil { + return 0, nil + } + return 0, err + } + return v, nil +} + +func dbIncrChannelStorageUsed(ctx context.Context, slug string, bytes int64) error { + return rdb.IncrBy(ctx, "channel:"+slug+":storage:used_bytes", bytes).Err() +} + +// dbIncrChannelStorageUsedResult increments and returns the caller's own +// post-increment total. That return value is what makes a quota check safe +// under concurrency: every concurrent uploader gets a different number, so only +// one of them can be the request that crosses the limit. +func dbIncrChannelStorageUsedResult(ctx context.Context, slug string, bytes int64) (int64, error) { + return rdb.IncrBy(ctx, "channel:"+slug+":storage:used_bytes", bytes).Result() +} + +func dbDecrChannelStorageUsed(ctx context.Context, slug string, bytes int64) error { + return rdb.DecrBy(ctx, "channel:"+slug+":storage:used_bytes", bytes).Err() +} + +// channelFilesKey is the sorted set tracking a channel's uploaded files. +func channelFilesKey(slug string) string { return "channel:" + slug + ":files" } + +// encodeFileMember encodes a tracking-set member as "fileID:size". +func encodeFileMember(id string, size int64) string { return fmt.Sprintf("%s:%d", id, size) } + +// decodeFileMember is the single inverse of encodeFileMember; every reader of +// the tracking set must go through it rather than re-implementing the parse. +func decodeFileMember(m string) (id string, size int64, ok bool) { + i := strings.LastIndex(m, ":") + if i <= 0 { + return "", 0, false + } + size, err := strconv.ParseInt(m[i+1:], 10, 64) + if err != nil { + return "", 0, false + } + return m[:i], size, true +} + +// dbAddChannelFile registers a file in the channel's file tracking sorted set. +func dbAddChannelFile(ctx context.Context, slug, fileID string, uploadedAt int64, size int64) error { + return rdb.ZAdd(ctx, channelFilesKey(slug), redis.Z{ + Score: float64(uploadedAt), + Member: encodeFileMember(fileID, size), + }).Err() +} + +// dbRemoveChannelFile removes a file from the channel's tracking set. +// size is the file's recorded size, which the caller already holds. Because a +// member is exactly "id:size", knowing the size makes removal a direct ZREM +// instead of a scan: this used to pull the channel's entire file index over the +// wire to locate one entry, and auto-cleanup calls it up to 200 times inside a +// single upload — on a channel with 100k files that was millions of member +// transfers per request, with Redis blocked on each one. +// +// The scan survives only as a fallback for an index entry whose size disagrees +// with the metadata (a record written before the size was tracked), so a +// mismatch still removes the entry rather than silently leaking it. +func dbRemoveChannelFile(ctx context.Context, slug, fileID string, size int64) error { + removed, err := rdb.ZRem(ctx, channelFilesKey(slug), encodeFileMember(fileID, size)).Result() + if err != nil { + return err + } + if removed > 0 { + return nil + } + + members, err := rdb.ZRange(ctx, channelFilesKey(slug), 0, -1).Result() + if err != nil { + return err + } + for _, m := range members { + if id, _, ok := decodeFileMember(m); ok && id == fileID { + return rdb.ZRem(ctx, channelFilesKey(slug), m).Err() + } + } + return nil +} + +// dbGetOldestChannelFiles returns the oldest file IDs and their sizes (oldest first). +func dbGetOldestChannelFiles(ctx context.Context, slug string, limit int64) ([]struct { + ID string + Size int64 +}, error) { + members, err := rdb.ZRange(ctx, channelFilesKey(slug), 0, limit-1).Result() + if err != nil { + return nil, err + } + result := make([]struct { + ID string + Size int64 + }, 0, len(members)) + for _, m := range members { + if fileID, size, ok := decodeFileMember(m); ok { + result = append(result, struct { + ID string + Size int64 + }{fileID, size}) + } + } + return result, nil +} + +func dbGetChannelAutoCleanup(ctx context.Context, slug string) (bool, error) { + v, err := rdb.Get(ctx, "channel:"+slug+":storage:auto_cleanup").Result() + // "unset" (redis.Nil) means disabled; a real failure must not be reported as + // a successful "disabled", or the caller silently acts on a guess. + if err == redis.Nil { + return false, nil + } + if err != nil { + return false, err + } + return v == "true" || v == "1", nil +} + +func dbSetChannelAutoCleanup(ctx context.Context, slug string, enabled bool) error { + val := "false" + if enabled { + val = "true" + } + return rdb.Set(ctx, "channel:"+slug+":storage:auto_cleanup", val, 0).Err() +} + +// dbIncrFileHashRefs increments the reference count for a file hash (dedup tracking). +func dbIncrFileHashRefs(ctx context.Context, hash string) error { + return rdb.Incr(ctx, "file:hash:"+hash+":refs").Err() +} + +// dbIncrFileHashRefsResult claims a reference and returns the resulting count. +// The upload path claims BEFORE deciding whether the blob needs writing, and +// the returned count is what tells it whether it is the first reference. +func dbIncrFileHashRefsResult(ctx context.Context, hash string) (int64, error) { + return rdb.Incr(ctx, "file:hash:"+hash+":refs").Result() +} + +// delFileHashRefsIfUnreferenced removes the counter only while nothing holds a +// reference; otherwise it answers the count that keeps it alive. GET on an +// absent key is false in Lua, hence the "or 0". +var delFileHashRefsIfUnreferenced = redis.NewScript(` + local n = tonumber(redis.call('GET', KEYS[1]) or 0) + if n <= 0 then + redis.call('DEL', KEYS[1]) + return 0 + end + return n +`) + +// dbDelFileHashRefs removes the ref counter once it reached zero, and returns +// 0 when it did (or when the counter was already gone). A plain DEL was not +// safe: the last-reference delete reads the counter before removing the blob, +// and an upload of the same bytes could claim (INCR) the hash between that +// read and the DEL — it then waits on the per-hash lock and writes the blob +// anew, but its reference was wiped. The check and the removal are one +// atomic step here; a positive return is the count that kept the counter. +func dbDelFileHashRefs(ctx context.Context, hash string) (int64, error) { + return delFileHashRefsIfUnreferenced.Run(ctx, rdb, []string{"file:hash:" + hash + ":refs"}).Int64() +} + +// dbGetFileHashRefs reads the current count; an absent counter is redis.Nil. +func dbGetFileHashRefs(ctx context.Context, hash string) (int64, error) { + return rdb.Get(ctx, "file:hash:"+hash+":refs").Int64() +} + +// dbDecrFileHashRefs decrements ref count; returns new count. +func dbDecrFileHashRefs(ctx context.Context, hash string) (int64, error) { + return rdb.Decr(ctx, "file:hash:"+hash+":refs").Result() +} diff --git a/backend/filerefs_test.go b/backend/filerefs_test.go new file mode 100644 index 00000000..61121826 --- /dev/null +++ b/backend/filerefs_test.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func refsKey(hash string) string { return "file:hash:" + hash + ":refs" } + +func readRefs(t *testing.T, ctx context.Context, hash string) int64 { + t.Helper() + v, err := rdb.Get(ctx, refsKey(hash)).Int64() + if err != nil { + return 0 // absent counts as zero + } + return v +} + +// 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 disappearing. These pin the two ways that count used to be +// wrong. +func TestFileHashRefsClaimedBeforeExistenceCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const hash = "refs-test-claim-first" + rdb.Del(ctx, refsKey(hash)) + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + rdb.Del(cctx, refsKey(hash)) + }) + + // First uploader claims: it is the only reference. + first, err := dbIncrFileHashRefsResult(ctx, hash) + if err != nil { + t.Fatalf("first claim: %v", err) + } + if first != 1 { + t.Fatalf("first claim returned %d, want 1", first) + } + + // A second uploader of identical bytes claims before deciding to skip the + // write. Its own post-increment value must show it is not the only holder. + second, err := dbIncrFileHashRefsResult(ctx, hash) + if err != nil { + t.Fatalf("second claim: %v", err) + } + if second != 2 { + t.Errorf("second claim returned %d, want 2", second) + } + + // The first tenant now deletes its file. One reference remains, so the blob + // must survive. + remaining, err := dbDecrFileHashRefs(ctx, hash) + if err != nil { + t.Fatalf("decr: %v", err) + } + if remaining != 1 { + t.Errorf("after one delete the count is %d, want 1", remaining) + } + if remaining <= 0 { + t.Error("the blob would have been deleted while another channel still references it") + } +} + +// A blob written before reference counting existed has no counter. An upload +// that dedupes against it would create the counter at 1 — recording one +// reference where two exist — so the new tenant deleting its own copy would +// destroy the original tenant's file. uploadFile detects that case (its own +// claim came back as 1 even though the blob was already there) and counts the +// pre-existing reference. +func TestFileHashRefsAdoptsPreexistingBlob(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const hash = "refs-test-legacy" + rdb.Del(ctx, refsKey(hash)) + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + rdb.Del(cctx, refsKey(hash)) + }) + + // The legacy state: a blob on disk/R2, no counter at all. + if got := readRefs(t, ctx, hash); got != 0 { + t.Fatalf("precondition: counter should be absent, got %d", got) + } + + // A new tenant uploads identical bytes. It claims first... + claimed, err := dbIncrFileHashRefsResult(ctx, hash) + if err != nil { + t.Fatalf("claim: %v", err) + } + // ...and, seeing the blob already present with a claim of 1, adopts the + // untracked reference — this is the branch uploadFile takes when + // !isNewHash && refs == 1. + blobAlreadyExisted := true + if blobAlreadyExisted && claimed == 1 { + if err := dbIncrFileHashRefs(ctx, hash); err != nil { + t.Fatalf("adopt: %v", err) + } + } + + if got := readRefs(t, ctx, hash); got != 2 { + t.Fatalf("count is %d, want 2 (the legacy record plus the new one)", got) + } + + // The new tenant deletes its file. The legacy record still points at the + // blob, so the count must not reach zero. + remaining, err := dbDecrFileHashRefs(ctx, hash) + if err != nil { + t.Fatalf("decr: %v", err) + } + if remaining <= 0 { + t.Errorf("count fell to %d: deleting the new copy would destroy the "+ + "pre-existing channel's blob", remaining) + } +} + +// A failed upload must give its claim back, or every failure permanently +// inflates the count and strands the blob forever. +func TestFileHashRefsReleasedOnFailedUpload(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const hash = "refs-test-release" + rdb.Del(ctx, refsKey(hash)) + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + rdb.Del(cctx, refsKey(hash)) + }) + + if _, err := dbIncrFileHashRefsResult(ctx, hash); err != nil { + t.Fatalf("claim: %v", err) + } + // The upload then fails; uploadFile's deferred release runs. + if _, err := dbDecrFileHashRefs(ctx, hash); err != nil { + t.Fatalf("release: %v", err) + } + if got := readRefs(t, ctx, hash); got != 0 { + t.Errorf("count is %d after a failed upload, want 0", got) + } +} + +// An upload of bytes whose last reference is being deleted waits for that +// delete to finish before looking for the blob, instead of adopting an object +// that is about to vanish. The wait is bounded, so a lock leaked by a crash +// cannot hang uploads for its whole TTL. +func TestFileHashLockMakesSameHashUploadWait(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const hash = "refs-test-lock-wait" + lock := fileHashLockKey(hash) + rdb.Del(ctx, lock) + t.Cleanup(func() { rdb.Del(context.Background(), lock) }) + + // No delete in flight: no wait. + start := time.Now() + waitFileHashUnlocked(ctx, hash) + if d := time.Since(start); d > 500*time.Millisecond { + t.Fatalf("waited %v with no lock held", d) + } + + // A delete holds the lock for 400 ms; the upload proceeds once it clears. + if err := rdb.Set(ctx, lock, 1, 400*time.Millisecond).Err(); err != nil { + t.Fatalf("lock: %v", err) + } + start = time.Now() + waitFileHashUnlocked(ctx, hash) + d := time.Since(start) + if d < 300*time.Millisecond { + t.Errorf("upload proceeded after %v while the delete still held the lock", d) + } + if d > 3*time.Second { + t.Errorf("waited %v, want a bounded wait that ends with the lock", d) + } +} + +// A last-reference delete removes the blob and its counter as before, and +// leaves no lock behind for the next same-hash upload to wait on. +func TestDeleteFileByIDLastReferenceReleasesLock(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + prevRoot := rootUploadPath + rootUploadPath = t.TempDir() + t.Cleanup(func() { rootUploadPath = prevRoot }) + + const hash = "abcdrefs-test-last-ref" + const id = "refs-test-last-ref-file" + const slug = "refs-lock-chan" + blob, ok := localBlobPath(hash) + if !ok { + t.Fatal("blob path") + } + if err := os.MkdirAll(filepath.Dir(blob), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(blob, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + keys := []string{"file:" + id, "file:" + id + ":deleting", refsKey(hash), fileHashLockKey(hash), "channel:" + slug + ":storage:used_bytes"} + rdb.Del(ctx, keys...) + t.Cleanup(func() { rdb.Del(context.Background(), keys...) }) + if err := rdb.Set(ctx, refsKey(hash), 1, 0).Err(); err != nil { + t.Fatal(err) + } + meta := &FileMetadata{ID: id, Filename: "x.txt", Hash: hash, Type: "text", Size: 1, ChannelSlug: slug} + if err := dbSaveFileMetadata(ctx, meta); err != nil { + t.Fatal(err) + } + + freed, released := deleteFileByID(ctx, slug, id, false) + if !released || freed != 1 { + t.Fatalf("deleteFileByID = (%d, %v), want (1, true)", freed, released) + } + if _, err := os.Stat(blob); !os.IsNotExist(err) { + t.Errorf("blob still on disk after its last reference went (%v)", err) + } + if got := readRefs(t, ctx, hash); got != 0 { + t.Errorf("counter is %d after the last reference went, want it gone", got) + } + if n, _ := rdb.Exists(ctx, fileHashLockKey(hash)).Result(); n != 0 { + t.Errorf("the per-hash lock was left behind") + } +} + +// The last-reference delete re-reads the counter, removes the blob and then +// drops the counter. An upload of the same bytes that claimed the hash between +// the re-read and the drop is waiting on the lock and will write the blob anew, +// so its reference must survive the drop — an unconditional DEL wiped it, and +// the next same-hash delete removed a blob that record still served. +func TestFileHashRefsCounterKeptWhenClaimedBeforeRemoval(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const hash = "refs-test-conditional-del" + rdb.Del(ctx, refsKey(hash)) + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + rdb.Del(cctx, refsKey(hash)) + }) + + // Nobody claimed the hash: the counter goes, as before. + for _, v := range []int{0, -1} { + if err := rdb.Set(ctx, refsKey(hash), v, 0).Err(); err != nil { + t.Fatal(err) + } + kept, err := dbDelFileHashRefs(ctx, hash) + if err != nil || kept != 0 { + t.Fatalf("counter at %d: dbDelFileHashRefs = (%d, %v), want (0, nil)", v, kept, err) + } + if n, _ := rdb.Exists(ctx, refsKey(hash)).Result(); n != 0 { + t.Errorf("counter at %d survived the removal", v) + } + } + // A counter that is already gone is not an error either. + if kept, err := dbDelFileHashRefs(ctx, hash); err != nil || kept != 0 { + t.Fatalf("absent counter: dbDelFileHashRefs = (%d, %v), want (0, nil)", kept, err) + } + + // The delete's re-read saw zero; an upload then claimed the hash and is + // waiting on the lock. The removal must leave that reference alone. + if err := rdb.Set(ctx, refsKey(hash), 0, 0).Err(); err != nil { + t.Fatal(err) + } + if _, err := dbIncrFileHashRefsResult(ctx, hash); err != nil { + t.Fatalf("upload's claim: %v", err) + } + kept, err := dbDelFileHashRefs(ctx, hash) + if err != nil { + t.Fatalf("dbDelFileHashRefs: %v", err) + } + if kept != 1 { + t.Errorf("kept = %d, want 1 (the waiting upload's reference)", kept) + } + if got := readRefs(t, ctx, hash); got != 1 { + t.Errorf("counter is %d after the removal, want 1: the waiting upload's reference was wiped", got) + } +} diff --git a/backend/files.go b/backend/files.go index 4a9506fa..aab0e297 100644 --- a/backend/files.go +++ b/backend/files.go @@ -1,229 +1,1081 @@ package main import ( + "bufio" + "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" + "fmt" "io" + "log" "net/http" - "net/url" "os" "path" "path/filepath" + "strconv" + "strings" "time" "github.com/go-chi/chi" "github.com/h2non/filetype" "github.com/icza/dyno" + "github.com/redis/go-redis/v9" "github.com/subosito/gozaru" "gopkg.in/yaml.v3" ) +// errFileNotFound means no metadata exists for an id, in Redis or on disk. +// It is distinct from a failed read: a caller that prunes an index entry +// for a missing record must never do so on a transient Redis error. +var errFileNotFound = errors.New("file not found") + +// compressWithTinyPng compresses an image using the TinyPNG API. +// Returns the compressed bytes, or the original bytes if compression fails or is not applicable. +func compressWithTinyPng(ctx context.Context, apiKey string, data []byte, mimeType string) []byte { + // Only compress supported image types + switch mimeType { + case "image/png", "image/jpeg", "image/webp": + default: + return data + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.tinify.com/shrink", bytes.NewReader(data)) + if err != nil { + return data + } + req.SetBasicAuth("api", apiKey) + req.Header.Set("Content-Type", mimeType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return data + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return data + } + + var result struct { + Output struct { + URL string `json:"url"` + } `json:"output"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.Output.URL == "" { + return data + } + + dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, result.Output.URL, nil) + if err != nil { + return data + } + dlReq.SetBasicAuth("api", apiKey) + + dlResp, err := http.DefaultClient.Do(dlReq) + if err != nil { + return data + } + defer dlResp.Body.Close() + if dlResp.StatusCode != http.StatusOK { + return data + } + + compressed, err := io.ReadAll(dlResp.Body) + if err != nil || len(compressed) == 0 { + return data + } + return compressed +} + var rootUploadPath = "/app/files/" type FileResponse struct { URL string `json:"url"` Filename string `json:"filename"` FileType string `json:"filetype"` + // Pixel dimensions, present only for images whose header could be read. + // The client turns them into an aspect ratio so a lazily-loaded picture + // reserves its space before it arrives; omitted, it simply falls back to + // the old reflow-on-load behaviour. + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` } -var maxBytesReader *http.MaxBytesError +type FileMetadata struct { + ID string `json:"id"` + Filename string `json:"filename"` + Hash string `json:"hash"` + Type string `json:"type"` + Delete bool `json:"delete"` + Size int64 `json:"size"` // bytes + ChannelSlug string `json:"channelSlug"` // which channel owns this file + // Zero for non-images and for images whose header could not be read. + // Recorded at upload time; historical records simply have neither. + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} -func serveFile(w http.ResponseWriter, r *http.Request) { - fileId := chi.URLParam(r, "fileid") +// allowLegacyUnscopedFiles re-opens the pre-migration corpus (records with no +// ChannelSlug) to every tenant. Off by default: unscoped records are the normal +// shape of every YAML-era file, so exempting them from isolation would leave a +// permanent cross-tenant read path. +var allowLegacyUnscopedFiles = os.Getenv("ALLOW_LEGACY_UNSCOPED_FILES") == "true" - metadataFilePath := filepath.Join(rootUploadPath, fileId[:2], fileId[2:4], fileId+".yaml") - metadataFile, err := os.ReadFile(metadataFilePath) +// fileVisibleToChannel reports whether a file record may be served under slug. +func fileVisibleToChannel(meta *FileMetadata, slug string) bool { + if meta.ChannelSlug == slug { + return true + } + if meta.ChannelSlug == "" && allowLegacyUnscopedFiles { + log.Printf("legacy unscoped file %s served to channel %s\n", meta.ID, slug) + return true + } + return false +} + +// dbSaveFileMetadata stores file metadata in Redis. +func dbSaveFileMetadata(ctx context.Context, meta *FileMetadata) error { + data, err := json.Marshal(meta) if err != nil { + return err + } + return rdb.Set(ctx, "file:"+meta.ID, data, 0).Err() +} + +// dbGetFileMetadata retrieves file metadata from Redis, falling back to YAML for old files. +func dbGetFileMetadata(ctx context.Context, id string) (*FileMetadata, error) { + data, err := rdb.Get(ctx, "file:"+id).Result() + if err == nil { + var meta FileMetadata + if err := json.Unmarshal([]byte(data), &meta); err != nil { + // Unreadable is as gone as missing: nothing can be released from + // it, and a caller that waits for it would wait for ever. + return nil, fmt.Errorf("%w: corrupt metadata for %s: %v", errFileNotFound, id, err) + } + return &meta, nil + } + if err != redis.Nil { + return nil, err + } + + // Fallback: read from YAML (legacy local files). The slicing below panics on + // short ids, and callers feed this ids parsed out of Redis index members, so + // malformed data must be an error rather than a crash. + if len(id) < 4 { + return nil, errFileNotFound + } + metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") + yamlData, err := os.ReadFile(metadataFilePath) + if err != nil { + return nil, errFileNotFound + } + var raw map[string]any + if err := yaml.Unmarshal(yamlData, &raw); err != nil { + return nil, err + } + deleted, _ := raw["delete"].(bool) + hash, _ := dyno.GetString(raw["hash"]) + filename, _ := dyno.GetString(raw["filename"]) + fileType, _ := dyno.GetString(raw["type"]) + return &FileMetadata{ + ID: id, + Filename: filename, + Hash: hash, + Type: fileType, + Delete: deleted, + }, nil +} + +// localBlobPath returns the on-disk path of a blob and whether the hash is +// well-formed enough to build one. Every caller slices hash[:2]/hash[2:4], so a +// short or empty hash (malformed metadata, a YAML-era record with no hash at +// all) would panic the handler; they go through here instead. +func localBlobPath(hash string) (string, bool) { + if len(hash) < 4 { + return "", false + } + return filepath.Join(rootUploadPath, hash[:2], hash[2:4], hash), true +} + +// localBlobExists reports whether the local copy of a blob is present. +func localBlobExists(hash string) (string, bool) { + p, ok := localBlobPath(hash) + if !ok { + return "", false + } + if _, err := os.Stat(p); err != nil { + return "", false + } + return p, true +} + +// contentDispositionAttachment builds the download header for a stored name. +// +// filename* is percent-encoded as RFC 5987 defines it. url.QueryEscape was used +// before, and it writes a space as "+", which browsers do not decode in this +// header: "דוח שנתי.pdf" was saved as "דוח+שנתי.pdf". A plain ASCII filename= +// is kept alongside for the few clients that ignore filename*. +func contentDispositionAttachment(name string) string { + return `attachment; filename="` + asciiFilename(name) + `"; filename*=UTF-8''` + rfc5987Encode(name) +} + +// rfc5987Encode percent-encodes everything outside the attr-char set of +// RFC 5987 §3.2.1, byte by byte, so multi-byte UTF-8 survives intact. +func rfc5987Encode(s string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', + c == '!', c == '#', c == '$', c == '&', c == '+', c == '-', c == '.', + c == '^', c == '_', c == '`', c == '|', c == '~': + b.WriteByte(c) + default: + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0x0f]) + } + } + return b.String() +} + +// asciiFilename is the lossy fallback name: anything a quoted-string cannot +// carry, or that is not printable ASCII, becomes "_". A name with nothing +// printable left (a purely Hebrew one) gets a placeholder instead of "____". +func asciiFilename(s string) string { + var b strings.Builder + kept := false + for _, r := range s { + if r >= 0x20 && r < 0x7f && r != '"' && r != '\\' { + b.WriteRune(r) + kept = kept || r != ' ' + } else { + b.WriteByte('_') + } + } + if !kept { + return "file" + } + return b.String() +} + +func serveFile(w http.ResponseWriter, r *http.Request) { + fileId := chi.URLParam(r, "fileid") + if len(fileId) < 4 { http.Error(w, "File not found", http.StatusNotFound) return } - var metaData map[string]any - if err := yaml.Unmarshal(metadataFile, &metaData); err != nil { - http.Error(w, "error", http.StatusInternalServerError) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + + meta, err := dbGetFileMetadata(ctx, fileId) + if err != nil || meta.Delete { + http.Error(w, "File not found", http.StatusNotFound) return } - - if delete := metaData["delete"].(bool); delete { + // Enforce channel isolation: reject if the file belongs to another channel. + if !fileVisibleToChannel(meta, slug) { http.Error(w, "File not found", http.StatusNotFound) return } - fileHash, _ := dyno.GetString(metaData["hash"]) - filePath := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4], fileHash) - originalFileName, _ := dyno.GetString(metaData["filename"]) + w.Header().Set("Content-Disposition", contentDispositionAttachment(meta.Filename)) + w.Header().Set("X-Content-Type-Options", "nosniff") + + if r2Enabled && len(meta.Hash) >= 4 { + key := r2ObjectKey(meta.Hash) + + // Flipping R2 on does not move the historical corpus with it: the + // local-to-R2 migration runs on the next boot and may not have reached + // this blob (or may have failed for it). Redirecting to a key that is + // not there yet would 404 every legacy image, so a miss falls back to + // the local copy, which the migration never deletes. + if !r2ExistsCached(ctx, key) { + if filePath, ok := localBlobExists(meta.Hash); ok { + log.Printf("serveFile: %s/%s not in R2 (key %s), serving local copy\n", slug, fileId, key) + http.ServeFile(w, r, filePath) + return + } + log.Printf("serveFile: %s/%s missing in R2 (key %s) and on local disk\n", slug, fileId, key) + http.Error(w, "File not found", http.StatusNotFound) + return + } - w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(originalFileName)) + // The redirect itself is cacheable (private: a channel may require a + // login), so the browser skips this round trip on the next view. + if r2PublicURL != "" { + // Public bucket: redirect to CDN URL (fastest, no auth needed) + w.Header().Set("Cache-Control", "private, max-age=3600") + http.Redirect(w, r, r2PublicURL+"/"+key, http.StatusFound) + return + } + // Private bucket: a pre-signed URL, reused for a while so the browser + // can serve the file from its cache (see r2_cache.go). + // The client fetches directly from R2 — backend is not in the data path. + presignedURL, left, err := r2PresignCached(ctx, key, contentDispositionAttachment(meta.Filename)) + if err == nil { + w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(left.Seconds()))) + http.Redirect(w, r, presignedURL, http.StatusFound) + return + } + log.Printf("serveFile: presign failed for %s/%s (key %s): %v\n", slug, fileId, key, err) + // Fallback: proxy through backend if presigning fails + body, contentType, err := r2Download(ctx, key) + if err != nil { + log.Printf("serveFile: R2 download failed for %s/%s (key %s): %v\n", slug, fileId, key, err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer body.Close() + if contentType != nil { + w.Header().Set("Content-Type", *contentType) + } + io.Copy(w, body) + return + } + + // Local storage fallback + filePath, ok := localBlobPath(meta.Hash) + if !ok { + log.Printf("serveFile: %s/%s has malformed hash %q\n", slug, fileId, meta.Hash) + http.Error(w, "File not found", http.StatusNotFound) + return + } + w.Header().Set("Cache-Control", "private, max-age=3600") http.ServeFile(w, r, filePath) } func uploadFile(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, int64(settingConfig.MaxFileSize)<<20) + // Derived from the request so a client disconnect stops the work, and wide + // enough to cover reading the body off the wire. The hostile sub-operations + // (TinyPNG, the R2 write) get their own budgets below. + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + + slug := channelSlugFromCtx(r) + cfg := getChannelConfig(ctx, slug) + + // Defensive clamp: getChannelConfig is not the only way a config can reach + // here, and a non-positive or huge value would either reject every upload + // or let a single request buffer unbounded bytes in memory. + maxMB := cfg.MaxFileSize + if maxMB <= 0 || maxMB > maxAllowedFileSizeMB { + maxMB = defaultMaxFileSizeMB + } + r.Body = http.MaxBytesReader(w, r.Body, maxMB<<20) + + // Before the body is read: a channel that is already full (and cannot + // free space) used to ingest the whole file into memory, and spend one of + // the owner's TinyPNG compressions, only to answer 507 afterwards. + if storageAlreadyFull(ctx, slug) { + http.Error(w, "storage quota exceeded", http.StatusInsufficientStorage) + return + } file, handler, err := r.FormFile("file") if err != nil { - if errors.As(err, &maxBytesReader) { + // The errors.As target must be a local: a package-level one is written + // concurrently by every upload. + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + log.Printf("uploadFile: %s: rejected upload over the %d MB limit: %v\n", slug, maxMB, err) http.Error(w, "File too large", http.StatusRequestEntityTooLarge) return } + log.Printf("uploadFile: %s: reading multipart form failed: %v\n", slug, err) http.Error(w, "error", http.StatusBadRequest) return } defer file.Close() - if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { - http.Error(w, "error", http.StatusInternalServerError) + // Each upload is buffered whole (up to the channel's limit, at most 512 MB) + // and the per-user limiter allows a burst of ten, so ten concurrent uploads + // from one writer could take the whole backend past its memory. A small + // process-wide cap turns that into a 503 the client retries. It is taken + // here, after the body has been read off the wire, and not before + // r.FormFile: held across the network read it bounded socket time rather + // than memory, so four slow phone uploads (there is no server read timeout) + // stalled every upload on every channel for as long as they trickled. + select { + case uploadSlots <- struct{}{}: + defer func() { <-uploadSlots }() + default: + w.Header().Set("Retry-After", "5") + http.Error(w, "too many uploads in progress — please retry", http.StatusServiceUnavailable) return } - head := make([]byte, 512) - file.Read(head) - - t, _ := filetype.Match(head) - - file.Seek(0, io.SeekStart) - fileHash, err := generatedFileHash(file) + fileBytes, err := io.ReadAll(file) if err != nil { + log.Printf("uploadFile: %s: reading uploaded body %q failed: %v\n", slug, handler.Filename, err) http.Error(w, "error", http.StatusInternalServerError) return } - hashSubDir := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4]) - if err := os.MkdirAll(hashSubDir, os.ModePerm); err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } + // Not just the first 512 bytes: Office documents are ZIP containers whose + // identifying entry sits past that, so a .docx/.xlsx was typed as a plain + // archive and served as application/zip. + t, _ := filetype.Match(fileBytes[:min(64<<10, len(fileBytes))]) - var isDuplicateFile bool - testISDuplicateFilePath := filepath.Join(hashSubDir, fileHash) - _, err = os.Stat(testISDuplicateFilePath) - if err == nil { //|| !os.IsNotExist(err) - isDuplicateFile = true + // Compress images with TinyPNG if the channel has an API key configured + if cfg.TinyPngApiKey != "" { + tinyCtx, tinyCancel := context.WithTimeout(ctx, 20*time.Second) + fileBytes = compressWithTinyPng(tinyCtx, cfg.TinyPngApiKey, fileBytes, t.MIME.Value) + tinyCancel() } - if !isDuplicateFile { - destPath := filepath.Join(hashSubDir, fileHash) + // After compression, not before: TinyPNG returns a re-encoded file, so the + // dimensions recorded must be the ones the browser will actually receive. + var imgWidth, imgHeight int + if t.MIME.Type == "image" { + imgWidth, imgHeight = imageDimensions(fileBytes, t.MIME.Value) + } - file.Seek(0, io.SeekStart) - destFile, err := os.Create(destPath) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - defer destFile.Close() + fileSize := int64(len(fileBytes)) + hashBytes := sha256.Sum256(fileBytes) + fileHash := hex.EncodeToString(hashBytes[:]) - if _, err := io.Copy(destFile, file); err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } + // Quota check + auto-cleanup. This RESERVES the bytes: the counter moves + // here, not after the blob is written, because everything between the two + // points — an R2 PutObject with a 60-second budget — is time during which + // every other concurrent upload would otherwise read a counter that does + // not yet include this file and pass a quota check it should have failed. + // + // committed stays false until the file record exists; every early return + // below therefore gives the reservation back rather than leaking quota that + // no stored file accounts for. + committed := false + if err := reserveStorageQuota(ctx, slug, fileSize); err != nil { + log.Printf("uploadFile: %s: quota check rejected %d bytes: %v\n", slug, fileSize, err) + http.Error(w, err.Error(), http.StatusInsufficientStorage) + return + } + // The releases below run on a context that survives the request's: the + // usual reason an upload is abandoned is the client going away (or the + // deadline passing) during the R2 write, and go-redis refuses a cancelled + // context outright — so the quota bytes and the hash reference were leaked + // exactly when they most needed giving back. + releaseCtx := func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) } + defer func() { + if !committed { + rctx, rcancel := releaseCtx() + defer rcancel() + releaseStorageReservation(rctx, slug, fileSize) + } + }() id := generatedRandomID(20) if id == "" { + log.Printf("uploadFile: %s: crypto/rand failed to generate a file id\n", slug) http.Error(w, "error", http.StatusInternalServerError) return } - yamlFileDir := filepath.Join(rootUploadPath, id[:2], id[2:4]) - if err := os.MkdirAll(yamlFileDir, os.ModePerm); err != nil { + safeFilename := gozaru.Sanitize(handler.Filename) + contentType := t.MIME.Value + if contentType == "" { + contentType = "application/octet-stream" + } + + // Claim a reference to the blob BEFORE looking at whether it exists. + // + // Incrementing afterwards left a window in which a concurrent delete could + // take the count to zero and physically 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. + // Claiming first means any concurrent delete sees this reference and leaves + // the blob alone. + refs, refErr := dbIncrFileHashRefsResult(ctx, fileHash) + if refErr != nil { + log.Printf("uploadFile: %s: claiming a reference to hash %s failed: %v\n", slug, fileHash, refErr) http.Error(w, "error", http.StatusInternalServerError) return } + defer func() { + if !committed { + rctx, rcancel := releaseCtx() + defer rcancel() + if _, err := dbDecrFileHashRefs(rctx, fileHash); err != nil { + log.Printf("uploadFile: %s: releasing the reference to hash %s failed: %v\n", slug, fileHash, err) + } + } + }() - safeFilename := gozaru.Sanitize(handler.Filename) + // A last-reference delete of these very bytes may be mid-flight: it has + // taken the count to zero and is about to remove the object. Looking for + // the blob now would find it, adopt it, and leave this record pointing at + // nothing for good — so wait, briefly and bounded, for that delete to + // finish before deciding whether the blob needs writing. + waitFileHashUnlocked(ctx, fileHash) - fileMetadata := map[string]any{ - "id": id, - "filename": safeFilename, - "hash": fileHash, - "type": t.MIME.Type, - "delete": false, + isNewHash := true + if r2Enabled { + key := r2ObjectKey(fileHash) + if r2Exists(ctx, key) { + isNewHash = false + } else { + upCtx, upCancel := context.WithTimeout(ctx, 60*time.Second) + err := r2Upload(upCtx, key, bytes.NewReader(fileBytes), contentType) + upCancel() + if err != nil { + // The SDK error is printed verbatim, not summarised: its code is + // the only thing that separates AccessDenied (a read-only or + // wrong-bucket API token), NoSuchBucket, InvalidAccessKeyId and + // SignatureDoesNotMatch — all of which surface here as the same + // opaque 500 for the operator. + log.Printf("uploadFile: %s: R2 upload of file %s failed (bucket %q, key %s, %d bytes, content-type %s): %v\n", + slug, id, r2Bucket, key, fileSize, contentType, err) + http.Error(w, "error uploading file", http.StatusInternalServerError) + return + } + } + } else { + if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { + log.Printf("uploadFile: %s: mkdir root upload path %s failed: %v\n", slug, rootUploadPath, err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + hashSubDir := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4]) + if err := os.MkdirAll(hashSubDir, os.ModePerm); err != nil { + log.Printf("uploadFile: %s: mkdir %s for file %s failed: %v\n", slug, hashSubDir, id, err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + destPath := filepath.Join(hashSubDir, fileHash) + if _, statErr := os.Stat(destPath); os.IsNotExist(statErr) { + // Written beside its final name and renamed into place: the path is + // content-addressed, and every later upload of the same bytes + // trusts whatever sits there. A process killed mid-write used to + // leave a truncated blob at the exact hash path, deduplicated + // against — and served — from then on. + tmpPath := destPath + ".tmp-" + id + if err := os.WriteFile(tmpPath, fileBytes, 0644); err != nil { + log.Printf("uploadFile: %s: writing %s for file %s (%d bytes) failed: %v\n", slug, tmpPath, id, fileSize, err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + if err := os.Rename(tmpPath, destPath); err != nil { + os.Remove(tmpPath) + log.Printf("uploadFile: %s: moving %s into place for file %s failed: %v\n", slug, tmpPath, id, err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + } else { + isNewHash = false + } } - metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") - metadataFile, err := os.Create(metadataFilePath) - if err != nil { + // A count of 1 means this upload created the counter, so as far as the + // counter knows it is the only reference. If the blob was nonetheless + // already there, some record predating reference counting points at it too. + // Count that unknown reference, or this channel deleting its own copy would + // destroy the other channel's file. Erring this way can at worst strand a + // blob nothing references; the other direction loses somebody's data. + if !isNewHash && refs == 1 { + log.Printf("uploadFile: %s: hash %s had no reference count but its blob already existed; counting the pre-existing reference\n", slug, fileHash) + if err := dbIncrFileHashRefs(ctx, fileHash); err != nil { + log.Printf("uploadFile: %s: counting the pre-existing reference to %s failed: %v\n", slug, fileHash, err) + } + } + + meta := &FileMetadata{ + ID: id, + Filename: safeFilename, + Hash: fileHash, + Type: t.MIME.Type, + Delete: false, + Size: fileSize, + ChannelSlug: slug, + Width: imgWidth, + Height: imgHeight, + } + if err := dbSaveFileMetadata(ctx, meta); err != nil { + log.Printf("uploadFile: %s: saving metadata for file %s failed: %v\n", slug, id, err) http.Error(w, "error", http.StatusInternalServerError) return } - defer metadataFile.Close() - yamlData, err := yaml.Marshal(fileMetadata) - if err != nil { + // The reservation above already accounted for these bytes; incrementing + // again here would double-count every upload. + committed = true + + // The index entry is what auto-cleanup and channel deletion walk; a file + // whose ZADD failed (its error used to be dropped) stayed charged to the + // quota, held its hash reference and kept its blob, with nothing able to + // reclaim any of it. A fresh context: the upload's own may already be + // cancelled by a client that disconnected right after the blob landed, + // and that cancellation must not orphan the file either. + ictx, icancel := context.WithTimeout(context.Background(), 10*time.Second) + defer icancel() + if err := dbAddChannelFile(ictx, slug, id, time.Now().Unix(), fileSize); err != nil { + log.Printf("uploadFile: %s: indexing file %s failed, releasing it: %v\n", slug, id, err) + // Releases the quota bytes, the hash reference and (on the last + // reference) the blob, exactly as a delete would. + deleteFileByID(ictx, slug, id, false) http.Error(w, "error", http.StatusInternalServerError) return } - metadataFile.Write(yamlData) - fileUrl := "/api/files/" + id + fileUrl := "/api/channel/" + slug + "/files/" + id w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(FileResponse{ URL: fileUrl, Filename: handler.Filename, FileType: t.MIME.Type, + Width: imgWidth, + Height: imgHeight, }) } +// releaseStorageReservation gives back bytes reserved by reserveStorageQuota +// for an upload that then failed. Best effort: a lost release overstates usage +// for the channel until a file is deleted, which is the safe direction to err. +func releaseStorageReservation(ctx context.Context, slug string, size int64) { + if size <= 0 { + return + } + if err := dbDecrChannelStorageUsed(ctx, slug, size); err != nil { + log.Printf("releaseStorageReservation: %s: returning %d reserved bytes failed: %v\n", slug, size, err) + } +} + +// reserveStorageQuota atomically claims newFileSize bytes of the channel's +// quota, or reports why it cannot. +// +// The claim is made with a single INCRBY and only then compared against the +// quota, which is what makes it safe under concurrency: a plain read-compare- +// write lets N simultaneous uploads all read the same "used" value, all decide +// they fit, and all proceed — overshooting the quota by up to N times the file +// size. Because the increment is the same atomic operation for every caller, +// exactly one of them can be the request that crosses the limit, and that one +// hands its bytes straight back. +// +// The caller owns the reserved bytes until it either records a file (making the +// reservation the real accounting) or calls releaseStorageReservation. +func reserveStorageQuota(ctx context.Context, slug string, newFileSize int64) error { + // All three config reads fail closed: dbGetEffectiveStorageQuota and + // dbGetChannelStorageUsed already map redis.Nil to defaults, so only real + // failures surface here — and a real failure silently disabling quota + // enforcement would let usage grow past quota unnoticed. + quota, err := dbGetEffectiveStorageQuota(ctx, slug) + if err != nil { + // The client only ever sees "storage configuration unavailable", which + // is indistinguishable from a genuine quota breach; the real cause (a + // failed Redis read) only exists here. + log.Printf("reserveStorageQuota: %s: reading effective quota failed: %v\n", slug, err) + return fmt.Errorf("storage configuration unavailable") + } + if quota == 0 { + // Unlimited: still count the bytes, since used_bytes is what the storage + // screens report and what a later quota would be measured against. + if err := dbIncrChannelStorageUsed(ctx, slug, newFileSize); err != nil { + log.Printf("reserveStorageQuota: %s: counting %d bytes failed: %v\n", slug, newFileSize, err) + return fmt.Errorf("storage configuration unavailable") + } + return nil + } + + // Claim first, judge second. INCRBY returns this caller's own post-increment + // total, so concurrent uploads get distinct values and cannot all conclude + // they fit. + used, err := dbIncrChannelStorageUsedResult(ctx, slug, newFileSize) + if err != nil { + log.Printf("reserveStorageQuota: %s: reserving %d bytes failed: %v\n", slug, newFileSize, err) + return fmt.Errorf("storage configuration unavailable") + } + if used <= quota { + return nil // within quota, bytes already accounted for + } + + autoCleanup, err := dbGetChannelAutoCleanup(ctx, slug) + if err != nil { + log.Printf("reserveStorageQuota: %s: reading auto-cleanup flag failed: %v\n", slug, err) + releaseStorageReservation(ctx, slug, newFileSize) + return fmt.Errorf("storage configuration unavailable") + } + if !autoCleanup { + releaseStorageReservation(ctx, slug, newFileSize) + return fmt.Errorf("storage quota exceeded (%d/%d bytes)", used-newFileSize, quota) + } + + // Auto-cleanup: delete oldest files until we have enough space (target: 80% + // of quota). used already includes this file's reservation, so freeing down + // to the target makes room for it too. + target := int64(float64(quota) * 0.80) + needToFree := used - target + + files, err := dbGetOldestChannelFiles(ctx, slug, 200) + if err != nil { + log.Printf("reserveStorageQuota: %s: listing oldest files for auto-cleanup failed: %v\n", slug, err) + releaseStorageReservation(ctx, slug, newFileSize) + return fmt.Errorf("storage quota exceeded") + } + + // The channel's logo goes through the same upload path and is usually the + // oldest file a channel has, so it was the first thing evicted: the header + // showed a broken image and nothing told the owner. It stays. + logoID := "" + if details, derr := getChannelDetails(ctx, slug); derr == nil { + if u := details["logoUrl"]; u != "" { + logoID = path.Base(u) + } + } + + for _, f := range files { + if needToFree <= 0 { + break + } + if f.ID == logoID { + continue + } + // Count what the delete actually released. Subtracting f.Size + // unconditionally meant a no-op still counted as freed space: two + // concurrent near-quota uploads read the same oldest-files list, and the + // one that lost every deletion claim still reached needToFree <= 0 and + // was admitted against bytes the other upload had freed for itself. + freed, released := deleteFileByID(ctx, slug, f.ID, true) + needToFree -= freed + if released && freed == 0 { + // Gone or tombstoned before this pass: index debris. Nothing else + // ever removed such a member, so each one stayed among the oldest + // entries for ever and shrank the 200-entry window this loop + // scans; once 200 accumulated every pass freed nothing and every + // upload on the channel was refused with thousands of newer, + // deletable files behind them. + if err := dbRemoveChannelFile(ctx, slug, f.ID, f.Size); err != nil { + log.Printf("reserveStorageQuota: %s: pruning stale index entry %s failed: %v\n", slug, f.ID, err) + } + } + } + + // Cleanup is best-effort: it walks at most the 200 oldest files, so it can + // finish without having made room. Reporting success here would let the + // channel grow past its quota indefinitely. + if needToFree > 0 { + releaseStorageReservation(ctx, slug, newFileSize) + return fmt.Errorf("storage quota exceeded (%d/%d bytes); auto-cleanup could not free enough space", used-newFileSize, quota) + } + + return nil +} + +// fileHashLockKey marks a last-reference removal of a blob in progress. Blobs +// are shared by hash across tenants, and an upload of the same bytes claims +// its reference before looking for the blob (see uploadFile): one that claimed +// between the delete's DECR and its physical removal found the object, adopted +// it, and was left with a record that answered 404 for good while its bytes +// stayed charged to the channel. The delete holds this key while the blob +// goes and the upload waits for it to clear before deciding whether the blob +// needs writing. The TTL only bounds a lock leaked by a crash. +func fileHashLockKey(hash string) string { return "file:hash:" + hash + ":lock" } + +const fileHashLockTTL = 30 * time.Second + +// waitFileHashUnlocked pauses while a last-reference delete of hash is in +// progress, for a few seconds at most: the wait is the rare case, and an +// upload must not hang on a leaked lock for its whole TTL. +func waitFileHashUnlocked(ctx context.Context, hash string) { + for i := 0; i < 50; i++ { + n, err := rdb.Exists(ctx, fileHashLockKey(hash)).Result() + if err != nil || n == 0 { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } + } +} + +// deleteFileByID marks a file as deleted, decrements storage counter, and removes +// from R2/disk if no more refs. removeFromIndex controls whether the channel's +// file tracking set is updated; dbDeleteChannel passes false because it drops +// the whole set itself (and a per-file O(N) scan would make deletion O(N^2)). +// It returns the number of bytes it actually released, which is 0 whenever it +// no-ops — an unreadable record, one already deleted, or a claim lost to a +// concurrent caller. Callers that are freeing space to make room must count +// that return value rather than the size they hoped to free. +// deleteFileByID releases one file: tombstones its metadata, gives its bytes +// back to the channel quota, drops its hash reference and removes the blob +// once the last reference is gone. It returns the bytes freed by this call +// and whether the file is released at all — now, or earlier (already +// tombstoned, or no metadata anywhere). released is false on a failed read, +// a failed claim or a claim held by a concurrent deleter: callers that go on +// to delete the file's own records must not do so in that case, or the blob +// and its reference count are orphaned with nothing left that points at them. +func deleteFileByID(ctx context.Context, slug, fileID string, removeFromIndex bool) (freed int64, released bool) { + meta, err := dbGetFileMetadata(ctx, fileID) + if err != nil { + return 0, errors.Is(err, errFileNotFound) + } + if meta.Delete { + return 0, true + } + + // Claim the deletion atomically: two concurrent callers (e.g. parallel + // near-quota uploads running auto-cleanup) would otherwise both pass the + // meta.Delete check and double-decrement used_bytes and the hash refcount, + // deleting a blob another channel still references. + claimed, err := rdb.SetNX(ctx, "file:"+fileID+":deleting", 1, time.Minute).Result() + if err != nil || !claimed { + return 0, false + } + + meta.Delete = true + dbSaveFileMetadata(ctx, meta) + dbDecrChannelStorageUsed(ctx, slug, meta.Size) + if removeFromIndex { + dbRemoveChannelFile(ctx, slug, fileID, meta.Size) + } + + // Decrement hash refs and delete from storage if no more references + refs, err := dbDecrFileHashRefs(ctx, meta.Hash) + if err == nil && refs <= 0 { + // Hold the per-hash lock while the blob goes (see fileHashLockKey), + // then re-read the counter: an upload that claimed the hash between + // the DECR above and the lock is not waiting on it and would find the + // object and adopt it — so if anyone holds a reference now, the blob + // and its counter are theirs and stay. The lock itself is best + // effort: a failed SETNX only reopens the old window. + lockKey := fileHashLockKey(meta.Hash) + locked, _ := rdb.SetNX(ctx, lockKey, 1, fileHashLockTTL).Result() + if n, gerr := dbGetFileHashRefs(ctx, meta.Hash); gerr == nil && n > 0 { + log.Printf("deleteFileByID: %s: hash %s was claimed by an upload while %s was being deleted; keeping the blob\n", slug, meta.Hash, fileID) + } else { + if r2Enabled && len(meta.Hash) >= 4 { + if err := r2Delete(ctx, r2ObjectKey(meta.Hash)); err != nil { + log.Printf("deleteFileByID: %s: R2 delete of %s (key %s) failed: %v\n", slug, fileID, r2ObjectKey(meta.Hash), err) + } + } + // The local copy is removed either way: with R2 on it is the migration's + // fallback copy of the same blob, and the last reference is gone. + if p, ok := localBlobPath(meta.Hash); ok { + os.Remove(p) + } + // Drop the counter itself, so a fresh upload of the same hash starts + // at 1 again — unless an upload claimed the hash after the re-read + // above. It is waiting on the lock and will write the blob anew, + // and an unconditional DEL here wiped its reference: the next + // same-hash delete then removed a blob that record still served. + // The removal is conditional on the count, atomically. + if kept, derr := dbDelFileHashRefs(ctx, meta.Hash); derr == nil && kept > 0 { + log.Printf("deleteFileByID: %s: hash %s was claimed by %d upload(s) while the blob of %s was being removed; keeping the counter\n", slug, meta.Hash, kept, fileID) + } + } + if locked { + // On a fresh context: the caller's may have expired mid-delete, and + // a lingering lock stalls every same-hash upload for its TTL. + uctx, ucancel := context.WithTimeout(context.Background(), 5*time.Second) + rdb.Del(uctx, lockKey) + ucancel() + } + } + return meta.Size, true +} + func generatedFileHash(file io.Reader) (string, error) { hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "", err } - return hex.EncodeToString(hash.Sum(nil)), nil } -func generatedRandomID(len int) string { - b := make([]byte, len) - _, err := rand.Read(b) - if err != nil { +func generatedRandomID(length int) string { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { return "" } - return hex.EncodeToString(b) } -// TODO: Image size limitation +// serveDefaultFavicon answers with the platform icon. The runtime image did +// not ship assets/favicon.ico (only the build stage had it), so every tab +// showed no icon and the log filled with 404s; the frontend bundle in the +// static folder always carries one, so it is the fallback. +func serveDefaultFavicon(w http.ResponseWriter, r *http.Request) { + if _, err := os.Stat("assets/favicon.ico"); err == nil { + http.ServeFile(w, r, "assets/favicon.ico") + return + } + if cfg := getGlobalConfig(); cfg != nil && cfg.RootStaticFolder != "" { + http.ServeFile(w, r, filepath.Join(cfg.RootStaticFolder, "favicon.ico")) + return + } + http.NotFound(w, r) +} + func getFavicon(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, err := getChannelDetails(ctx) + slug := r.URL.Query().Get("slug") + if slug == "" { + serveDefaultFavicon(w, r) + return + } + // This handler sits outside channelMiddleware, so it is the one place a raw + // ?slug= still reached Redis unchecked; a hash-typed key such as + // "foo:messages:5" would resolve as a phantom channel. Same guard as the + // middleware, same answer as "not found" (no oracle). + if !isPlausibleSlug(slug) { + serveDefaultFavicon(w, r) + return + } + + c, err := getChannelDetails(ctx, slug) if err != nil { - http.Error(w, "error", http.StatusInternalServerError) + serveDefaultFavicon(w, r) + return + } + + // This route is mounted at the router root, so none of channelMiddleware's + // disabled check, channelIfRequireAuth or channelIfRequireAuthFiles runs — + // yet it serves a blob out of the channel's own file store. A favicon + // request cannot carry a meaningful session (browsers fetch it out of band), + // so instead of trying to authenticate it, a channel that has asked not to + // be readable anonymously simply gets the platform default. That leaks + // neither the logo nor the fact that the channel exists. + if ch, chErr := dbGetChannel(ctx, slug); chErr != nil || + ch.Features.Disabled || ch.Features.RequireAuth || ch.Features.RequireAuthFiles { + serveDefaultFavicon(w, r) return } logoUrl := c["logoUrl"] if logoUrl == "" { - logoUrl = "assets/favicon.ico" + serveDefaultFavicon(w, r) + return } + fileId := path.Base(logoUrl) + if len(fileId) < 4 { + serveDefaultFavicon(w, r) + return + } - metadataFilePath := filepath.Join(rootUploadPath, fileId[:2], fileId[2:4], fileId+".yaml") - metadataFile, err := os.ReadFile(metadataFilePath) - if err != nil { - http.ServeFile(w, r, "assets/favicon.ico") + meta, err := dbGetFileMetadata(ctx, fileId) + // The logo URL is arbitrary moderator-supplied input, so the same channel + // isolation serveFile applies must hold here too. + if err != nil || meta.Delete || !fileVisibleToChannel(meta, slug) { + serveDefaultFavicon(w, r) return } - var metaData map[string]any - if err := yaml.Unmarshal(metadataFile, &metaData); err != nil { - http.ServeFile(w, r, "assets/favicon.ico") + // Only an image is streamed from here. This route is anonymous, mounted at + // the root, and — unlike serveFile — sends the blob inline: uploads accept + // any file type, and the logo URL is whatever a moderator saved, so it + // could name an uploaded HTML file, which the browser would then render on + // the app's own origin with the viewer's session — stored XSS. The R2 + // redirect below is exempt: R2 serves it from another origin. + if r2Enabled && len(meta.Hash) >= 4 { + key := r2ObjectKey(meta.Hash) + // Same partial-migration fallback as serveFile: a logo whose blob has + // not been copied to R2 yet is still on local disk. + if !r2ExistsCached(ctx, key) { + if filePath, ok := localBlobExists(meta.Hash); ok { + log.Printf("getFavicon: %s: logo %s not in R2 (key %s), serving local copy\n", slug, fileId, key) + serveLocalImageOrFavicon(w, r, meta, filePath) + return + } + log.Printf("getFavicon: %s: logo %s missing in R2 (key %s) and on local disk\n", slug, fileId, key) + serveDefaultFavicon(w, r) + return + } + if r2PublicURL != "" { + http.Redirect(w, r, r2PublicURL+"/"+key, http.StatusFound) + return + } + body, contentType, err := r2Download(ctx, key) + if err != nil { + log.Printf("getFavicon: %s: R2 download of logo %s (key %s) failed: %v\n", slug, fileId, key, err) + serveDefaultFavicon(w, r) + return + } + defer body.Close() + br := bufio.NewReader(body) + head, _ := br.Peek(512) + if !isImageBlob(meta, head) { + serveDefaultFavicon(w, r) + return + } + ct := http.DetectContentType(head) + if contentType != nil && strings.HasPrefix(*contentType, "image/") { + ct = *contentType + } + w.Header().Set("Content-Type", ct) + w.Header().Set("X-Content-Type-Options", "nosniff") + io.Copy(w, br) return } - if delete := metaData["delete"].(bool); delete { - http.ServeFile(w, r, "assets/favicon.ico") + filePath, ok := localBlobPath(meta.Hash) + if !ok { + log.Printf("getFavicon: %s: logo %s has malformed hash %q\n", slug, fileId, meta.Hash) + serveDefaultFavicon(w, r) return } + serveLocalImageOrFavicon(w, r, meta, filePath) +} - fileHash, _ := dyno.GetString(metaData["hash"]) - filePath := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4], fileHash) +// isImageBlob reports whether a blob may be sent inline as an image: typed +// "image" at upload (by magic bytes), or — for a record written before the +// type was stored — sniffed as one from its first bytes. SVG never passes: +// neither the upload typer nor DetectContentType classify it as an image, +// and it can carry script. +func isImageBlob(meta *FileMetadata, head []byte) bool { + if meta.Type == "image" { + return true + } + return meta.Type == "" && strings.HasPrefix(http.DetectContentType(head), "image/") +} +// serveLocalImageOrFavicon serves a local blob inline if it is an image and +// the default favicon otherwise (see getFavicon). +func serveLocalImageOrFavicon(w http.ResponseWriter, r *http.Request, meta *FileMetadata, filePath string) { + f, err := os.Open(filePath) + if err != nil { + serveDefaultFavicon(w, r) + return + } + head := make([]byte, 512) + n, _ := io.ReadFull(f, head) + f.Close() + if !isImageBlob(meta, head[:n]) { + serveDefaultFavicon(w, r) + return + } + w.Header().Set("Content-Type", http.DetectContentType(head[:n])) + w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeFile(w, r, filePath) } + +// uploadSlots bounds how many uploads are buffered at once (see uploadFile). +var uploadSlots = make(chan struct{}, 4) + +// storageAlreadyFull reports whether the channel is at or over its quota with +// no way to make room, so an upload can be refused before its body is read. +// Errors read as "not full": the authoritative check is reserveStorageQuota, +// which fails closed later. +func storageAlreadyFull(ctx context.Context, slug string) bool { + quota, err := dbGetEffectiveStorageQuota(ctx, slug) + if err != nil || quota == 0 { + return false + } + used, err := dbGetChannelStorageUsed(ctx, slug) + if err != nil || used < quota { + return false + } + autoCleanup, err := dbGetChannelAutoCleanup(ctx, slug) + return err == nil && !autoCleanup +} diff --git a/backend/gbtobytes_test.go b/backend/gbtobytes_test.go new file mode 100644 index 00000000..f63cafef --- /dev/null +++ b/backend/gbtobytes_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "math" + "testing" +) + +// A GB quota that overflows int64 when converted to bytes wraps negative and +// bypassed the plain ">= 0" guard, so every upload silently started failing +// while the dashboard showed "ok". gbToBytes rejects both negatives and +// overflow. +func TestGBToBytes(t *testing.T) { + cases := []struct { + gb float64 + want int64 + wantOk bool + }{ + {0, 0, true}, + {1, 1 << 30, true}, + {100, 100 << 30, true}, + {-1, 0, false}, // negative + {-0.0001, 0, false}, // small negative + {1e12, 0, false}, // overflows int64 when *2^30 + {math.MaxFloat64, 0, false}, + } + for _, c := range cases { + got, ok := gbToBytes(c.gb) + if ok != c.wantOk { + t.Errorf("gbToBytes(%v) ok=%v, want %v", c.gb, ok, c.wantOk) + } + if ok && got != c.want { + t.Errorf("gbToBytes(%v) = %d, want %d", c.gb, got, c.want) + } + // On rejection the result must never be a wrapped negative. + if !ok && got < 0 { + t.Errorf("gbToBytes(%v) returned negative %d on rejection", c.gb, got) + } + } + + // The exact boundary the old code broke on: a value whose byte product just + // exceeds MaxInt64 must be refused, not wrapped to a huge negative. + overflowGB := (float64(math.MaxInt64) / (1 << 30)) * 2 + if _, ok := gbToBytes(overflowGB); ok { + t.Errorf("gbToBytes(%v) was accepted; it overflows int64", overflowGB) + } +} diff --git a/backend/go.mod b/backend/go.mod index a58c2703..ef7f4de6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,8 +1,17 @@ module channel -go 1.24 +go 1.25.0 -require github.com/redis/go-redis/v9 v9.7.0 +require ( + firebase.google.com/go/v4 v4.16.1 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 + github.com/redis/go-redis/v9 v9.7.0 + golang.org/x/time v0.15.0 + google.golang.org/api v0.233.0 +) require ( cel.dev/expr v0.24.0 // indirect @@ -15,11 +24,24 @@ require ( cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.54.0 // indirect - firebase.google.com/go/v4 v4.16.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -54,8 +76,6 @@ require ( golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect - golang.org/x/time v0.11.0 // indirect - google.golang.org/api v0.233.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7bfddbc3..db683221 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -34,6 +34,42 @@ github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/appleboy/go-fcm v1.2.6 h1:TU5/+2QnmTNjWkHLe9hUB9EPJ5Bv+CegzTJI0qK0QgA= github.com/appleboy/go-fcm v1.2.6/go.mod h1:nvi8DgoMax8o6nwQYgO8pIXSX6iaQY7yDYvtwIGa6aI= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/boj/redistore v1.4.0 h1:PFn5Hcbmj22WGsMKGLaEn33In4+C0lX+txbAOpA3mPA= github.com/boj/redistore v1.4.0/go.mod h1:JeLqX+qEEBrjytalj9s+3a7o34lNmYncdPj8HeZRvYk= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -175,6 +211,8 @@ golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/backend/imagedims.go b/backend/imagedims.go new file mode 100644 index 00000000..69c8e349 --- /dev/null +++ b/backend/imagedims.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "encoding/binary" + "image" + // Registered for their DecodeConfig side effect only — the pixel data is + // never decoded here. + _ "image/gif" + _ "image/jpeg" + _ "image/png" +) + +// maxReasonableImageDimension guards against a corrupt or hostile header +// claiming an enormous canvas. The value only ever reaches the browser as an +// aspect-ratio hint, but a wild number there would reserve a screenful of +// blank space, so an implausible size is treated as "unknown" instead. +const maxReasonableImageDimension = 100000 + +// imageDimensions returns the pixel width and height of an encoded image, or +// (0, 0) when they cannot be determined — an unsupported format, a truncated +// upload, or a file that is not an image at all. +// +// The frontend needs these to reserve the right box before the picture itself +// arrives. Feed images load lazily, so without a known aspect ratio the page +// grows as each one lands and shoves the reader's position around. +// +// Only the header is parsed, never the pixel data, so this stays cheap on a +// large upload and cannot be made to allocate a full-size framebuffer. +func imageDimensions(data []byte, mime string) (int, int) { + var w, h int + if mime == "image/webp" { + // The standard library has no WebP decoder, and golang.org/x/image is a + // large dependency to add for three integers. + w, h = webpDimensions(data) + } else { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, 0 + } + w, h = cfg.Width, cfg.Height + } + + if w <= 0 || h <= 0 || w > maxReasonableImageDimension || h > maxReasonableImageDimension { + return 0, 0 + } + return w, h +} + +// webpDimensions reads the canvas size out of a WebP file's RIFF header. +// +// The container is "RIFF" + a 4-byte length + "WEBP", followed by chunks of a +// 4-byte FourCC, a 4-byte little-endian payload length, and the payload. The +// first chunk always carries the dimensions, in one of three encodings. +func webpDimensions(data []byte) (int, int) { + if len(data) < 20 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" { + return 0, 0 + } + // Payload of the first chunk: 12 bytes of RIFF header, then this chunk's + // own 4-byte FourCC and 4-byte length. + body := data[20:] + + switch string(data[12:16]) { + case "VP8X": + // Extended (also what animated files use): a 4-byte flags field, then + // canvas width and height as 24-bit little-endian values, each stored + // minus one. + if len(body) < 10 { + return 0, 0 + } + w := int(body[4]) | int(body[5])<<8 | int(body[6])<<16 + h := int(body[7]) | int(body[8])<<8 | int(body[9])<<16 + return w + 1, h + 1 + + case "VP8 ": + // Lossy: a 3-byte frame tag, the 3-byte start code 9d 01 2a, then + // width and height as 16-bit little-endian values whose top two bits + // are a scaling hint rather than part of the size. + if len(body) < 10 || body[3] != 0x9d || body[4] != 0x01 || body[5] != 0x2a { + return 0, 0 + } + w := int(binary.LittleEndian.Uint16(body[6:8]) & 0x3fff) + h := int(binary.LittleEndian.Uint16(body[8:10]) & 0x3fff) + return w, h + + case "VP8L": + // Lossless: a 0x2f signature byte, then 14 bits of width-1 followed by + // 14 bits of height-1, packed little-endian into the next 4 bytes. + if len(body) < 5 || body[0] != 0x2f { + return 0, 0 + } + bits := binary.LittleEndian.Uint32(body[1:5]) + return int(bits&0x3fff) + 1, int((bits>>14)&0x3fff) + 1 + } + return 0, 0 +} diff --git a/backend/imagedims_test.go b/backend/imagedims_test.go new file mode 100644 index 00000000..e4be904a --- /dev/null +++ b/backend/imagedims_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "testing" +) + +func encodedImage(t *testing.T, format string, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + + var buf bytes.Buffer + var err error + switch format { + case "png": + err = png.Encode(&buf, img) + case "jpeg": + err = jpeg.Encode(&buf, img, nil) + case "gif": + err = gif.Encode(&buf, img, nil) + default: + t.Fatalf("unknown format %q", format) + } + if err != nil { + t.Fatalf("encode %s: %v", format, err) + } + return buf.Bytes() +} + +// riffWrap builds a WebP container around a single chunk, which is all the +// dimension reader ever looks at. +func riffWrap(fourCC string, payload []byte) []byte { + var b bytes.Buffer + b.WriteString("RIFF") + _ = binary.Write(&b, binary.LittleEndian, uint32(12+len(payload))) + b.WriteString("WEBP") + b.WriteString(fourCC) + _ = binary.Write(&b, binary.LittleEndian, uint32(len(payload))) + b.Write(payload) + return b.Bytes() +} + +func webpVP8(w, h int) []byte { + payload := make([]byte, 10) + payload[3], payload[4], payload[5] = 0x9d, 0x01, 0x2a + binary.LittleEndian.PutUint16(payload[6:8], uint16(w)) + binary.LittleEndian.PutUint16(payload[8:10], uint16(h)) + return riffWrap("VP8 ", payload) +} + +func webpVP8L(w, h int) []byte { + payload := make([]byte, 5) + payload[0] = 0x2f + binary.LittleEndian.PutUint32(payload[1:5], uint32(w-1)|uint32(h-1)<<14) + return riffWrap("VP8L", payload) +} + +func webpVP8X(w, h int) []byte { + payload := make([]byte, 10) + cw, ch := uint32(w-1), uint32(h-1) + payload[4], payload[5], payload[6] = byte(cw), byte(cw>>8), byte(cw>>16) + payload[7], payload[8], payload[9] = byte(ch), byte(ch>>8), byte(ch>>16) + return riffWrap("VP8X", payload) +} + +func TestImageDimensions(t *testing.T) { + cases := []struct { + name string + data []byte + mime string + wantW int + wantH int + }{ + {"png", encodedImage(t, "png", 640, 480), "image/png", 640, 480}, + {"jpeg", encodedImage(t, "jpeg", 300, 200), "image/jpeg", 300, 200}, + {"gif", encodedImage(t, "gif", 120, 90), "image/gif", 120, 90}, + + // The three WebP encodings. VP8L and VP8X both store size minus one, so + // an off-by-one in either would show up here. + {"webp lossy", webpVP8(800, 600), "image/webp", 800, 600}, + {"webp lossless", webpVP8L(1024, 768), "image/webp", 1024, 768}, + {"webp extended", webpVP8X(1920, 1080), "image/webp", 1920, 1080}, + + // A 14-bit field is the widest a lossy/lossless WebP can express. + {"webp max 14-bit", webpVP8L(16384, 16384), "image/webp", 16384, 16384}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w, h := imageDimensions(c.data, c.mime) + if w != c.wantW || h != c.wantH { + t.Errorf("got %dx%d, want %dx%d", w, h, c.wantW, c.wantH) + } + }) + } +} + +// Anything unreadable must degrade to (0, 0) — the client then renders exactly +// as it did before dimensions existed — and must never panic, because these +// bytes come straight off an upload. +func TestImageDimensionsUnknownIsZeroNotPanic(t *testing.T) { + png640 := encodedImage(t, "png", 640, 480) + + cases := []struct { + name string + data []byte + mime string + }{ + {"empty", nil, "image/png"}, + {"not an image", []byte("this is a text file, not a picture"), "image/png"}, + {"truncated png", png640[:20], "image/png"}, + {"pdf mislabelled as image", []byte("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"), "image/png"}, + + // Every WebP branch, cut short at a different point. + {"webp empty", nil, "image/webp"}, + {"webp riff only", []byte("RIFF\x00\x00\x00\x00WEBP"), "image/webp"}, + {"webp truncated vp8", webpVP8(800, 600)[:24], "image/webp"}, + {"webp truncated vp8l", webpVP8L(800, 600)[:22], "image/webp"}, + {"webp truncated vp8x", webpVP8X(800, 600)[:25], "image/webp"}, + {"webp bad start code", riffWrap("VP8 ", make([]byte, 10)), "image/webp"}, + {"webp bad signature", riffWrap("VP8L", make([]byte, 5)), "image/webp"}, + {"webp unknown chunk", riffWrap("XXXX", make([]byte, 32)), "image/webp"}, + {"webp not riff", append([]byte("XXXX"), webpVP8(8, 8)[4:]...), "image/webp"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w, h := imageDimensions(c.data, c.mime) + if w != 0 || h != 0 { + t.Errorf("got %dx%d, want 0x0", w, h) + } + }) + } +} + +// A VP8X header can claim up to 2^24 pixels a side. Reserving space for that +// would blank the viewport, so an implausible size is reported as unknown. +func TestImageDimensionsRejectsImplausibleSize(t *testing.T) { + if w, h := imageDimensions(webpVP8X(16777216, 16777216), "image/webp"); w != 0 || h != 0 { + t.Errorf("got %dx%d, want 0x0 for an absurd canvas", w, h) + } +} diff --git a/backend/import_api_test.go b/backend/import_api_test.go new file mode 100644 index 00000000..01a5907a --- /dev/null +++ b/backend/import_api_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "encoding/gob" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/boj/redistore" + "github.com/go-chi/chi" +) + +// ensureSessionStore stands up the session store main() would have built: +// the import handler keys its rate limiter by session e-mail when there is +// one, which reads the store. +func ensureSessionStore(t *testing.T) { + t.Helper() + gob.Register(Session{}) + if store == nil || store.Pool == nil { + s, err := redistore.NewRediStore(10, redisType, redisAddr, "", redisPass, []byte("test-secret-key-for-import-api-tests")) + if err != nil { + t.Fatalf("session store: %v", err) + } + store = s + } +} + +func callImport(t *testing.T, slug, key, body string) *httptest.ResponseRecorder { + t.Helper() + ensureSessionStore(t) + r := chi.NewRouter() + r.Post("/api/channel/{slug}/import/post", addNewPost) + req := httptest.NewRequest(http.MethodPost, "/api/channel/"+slug+"/import/post", strings.NewReader(body)) + req.RemoteAddr = "203.0.113.7:4242" + if key != "" { + req.Header.Set("X-API-Key", key) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +// A mistyped slug used to answer 401 (no configured key for a channel that +// does not exist) and burn the caller's failed-auth budget, so an integrator +// with a perfectly valid key debugged an authentication problem. Existence +// and the kill switch are checked first; the key only matters for a channel +// that can be posted to. +func TestImportUnknownAndDisabledChannelsAnswerBeforeKeyCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if rec := callImport(t, "import-no-such-chan", "", `{"text":"x"}`); rec.Code != http.StatusNotFound { + t.Fatalf("unknown slug: got %d (%s), want 404", rec.Code, rec.Body.String()) + } + + const slug = "import-disabled" + ch := &ChannelData{Slug: slug, Name: slug, CreatedAt: time.Now()} + ch.Features.Disabled = true + if err := dbCreateChannel(ctx, ch); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + dbDeleteChannel(cctx, slug) + }) + if rec := callImport(t, slug, "whatever", `{"text":"x"}`); rec.Code != http.StatusForbidden { + t.Fatalf("disabled channel: got %d (%s), want 403", rec.Code, rec.Body.String()) + } + + // A live channel with no key configured stays closed. + const live = "import-live-nokey" + if err := dbCreateChannel(ctx, &ChannelData{Slug: live, Name: live, CreatedAt: time.Now()}); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + dbDeleteChannel(cctx, live) + }) + if rec := callImport(t, live, "guess", `{"text":"x"}`); rec.Code != http.StatusUnauthorized { + t.Fatalf("live channel, wrong key: got %d (%s), want 401", rec.Code, rec.Body.String()) + } +} diff --git a/backend/legal.go b/backend/legal.go new file mode 100644 index 00000000..7ea25a48 --- /dev/null +++ b/backend/legal.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "html/template" + "log" + "net/http" +) + +// The privacy policy and the terms of service. Google's OAuth consent screen +// links to both, so they must answer without a session and without the app: +// each is a complete document rendered here, like the content pages. +// +// Like the content pages, every sentence must be true of the code as it +// stands: what auth.go asks Google for, what the session cookie holds, what +// notifications.go and the ad slot store. When one of those changes, the text +// changes with it, and the page's Revised date is bumped. + +type legalPage struct { + Path string + Title string + Description string + Revised string + Body *template.Template +} + +var legalPages = []legalPage{ + { + Path: "/privacy", + Title: "מדיניות פרטיות", + Description: "איזה מידע נאסף בשירות, למה, עם מי הוא משותף ואיך מבקשים למחוק אותו.", + Revised: "15.10.2026", + Body: template.Must(template.New("privacy").Parse(` +

{{.SiteName}} ({{.Origin}}) הוא שירות לפתיחת ערוצי עדכונים, המופעל על ידי מגנט (magnetads.co.il). בעמוד הזה מוסבר איזה מידע נאסף כשמשתמשים בשירות, למה, ומה אפשר לעשות איתו.

+ +

קריאה בערוץ בלי להתחבר

+

אפשר לקרוא ערוץ ציבורי בלי חשבון. במקרה כזה לא נאסף מידע מזהה. הדפדפן שומר אצלכם בלבד כמה העדפות טכניות, למשל איזו הודעה קראתם אחרונה בכל ערוץ.

+ +

התחברות עם חשבון גוגל

+

ההתחברות נעשית רק דרך חשבון גוגל. אנחנו מבקשים מגוגל את השם, כתובת האימייל ותמונת הפרופיל שלכם, ולא שום הרשאה אחרת. אין לנו גישה לסיסמה, לתיבת הדואר, לאנשי הקשר או לקבצים שלכם בגוגל, ואנחנו לא מפרסמים בשמכם.

+

המידע הזה משמש כדי:

+
    +
  • לרשום את הערוצים שפתחתם על שמכם ולזהות אתכם בכניסה הבאה;
  • +
  • לתת לכם את ההרשאות שקיבלתם בערוץ (כותב, מנהל או בעלים);
  • +
  • לשלוח לכם הודעות שקשורות לשירות, כמו אישור על פתיחת ערוץ או תשובה לפנייה שלכם.
  • +
+

אחרי ההתחברות נשמרת בדפדפן עוגייה (cookie) מאובטחת שמחזיקה את החיבור שלכם עד 30 יום, או עד שתתנתקו.

+ +

מה נשמר כשאתם משתמשים בשירות

+
    +
  • הודעות, תמונות, סרטונים וקבצים שמנהלי ערוץ מפרסמים בו.
  • +
  • תגובות אימוג׳י שמשתמשים מחוברים משאירים על הודעות.
  • +
  • דיווחים ופניות לתמיכה, יחד עם כתובת האימייל של הפונה.
  • +
  • אם ביקשתם לקבל התראות, מזהה טכני של הדפדפן או המכשיר שנדרש כדי לשלוח אליו התראה. אפשר לבטל את ההתראות בכל עת.
  • +
+ +

פרסומות

+

בחלק מהערוצים מוצגות פרסומות ממערכת הפרסום של מגנט. כדי לא להציג לכם את אותה פרסומת שוב ושוב, נשמר בדפדפן מזהה צפייה אקראי שמתחלף כל 24 שעות. המזהה הזה לא קשור לשם, לאימייל או לחשבון שלכם.

+ +

עם מי המידע משותף

+

אנחנו לא מוכרים מידע אישי ולא מעבירים אותו לצד שלישי לצורכי שיווק. המידע עובר רק לספקים שמפעילים את השירות בשבילנו:

+
    +
  • Google: ההתחברות, ושליחת התראות דרך Firebase Cloud Messaging;
  • +
  • Cloudflare: אחסון הקבצים שמועלים לערוצים;
  • +
  • מגנט: שליחת הודעות האימייל של השירות והצגת הפרסומות.
  • +
+

מנהלי ערוץ רואים את כתובות האימייל של מי שקיבל הרשאה בערוץ שלהם. נמסור מידע לרשויות רק אם נחויב לכך על פי דין.

+ +

השימוש במידע מגוגל

+

השימוש במידע שמתקבל מ-Google APIs והעברתו לכל אפליקציה אחרת יעמדו בGoogle API Services User Data Policy, כולל דרישות ה-Limited Use.

+ +

אבטחה ושמירת מידע

+

החיבור לאתר מוצפן (HTTPS), והגישה לכל ערוץ מוגבלת לפי ההרשאות שבו. המידע נשמר כל עוד החשבון או הערוץ פעילים, או עד שתבקשו למחוק אותו.

+ +

הזכויות שלכם

+

אתם יכולים לבקש לעיין במידע שנשמר עליכם, לתקן אותו או למחוק אותו, כולל מחיקת החשבון והערוצים שפתחתם. אפשר גם לבטל בכל עת את הגישה של השירות לחשבון הגוגל שלכם, בדף ההרשאות של חשבון Google.

+ +

יצירת קשר

+

לכל שאלה או בקשה בנושא פרטיות, פנו אלינו דרך טופס יצירת הקשר באתר.

+ +

שינויים במדיניות

+

אם המדיניות תשתנה, הגרסה המעודכנת תפורסם בעמוד הזה עם תאריך העדכון.

+`)), + }, + { + Path: "/terms", + Title: "תנאי שימוש", + Description: "התנאים לשימוש בשירות: מה מותר לפרסם, האחריות על התוכן, הסרת תוכן והגבלת אחריות.", + Revised: "15.10.2026", + Body: template.Must(template.New("terms").Parse(` +

תנאים אלה חלים על השימוש ב{{.SiteName}} ({{.Origin}}), שירות לפתיחת ערוצי עדכונים המופעל על ידי מגנט (magnetads.co.il). השימוש בשירות, בקריאה או בפתיחת ערוץ, מהווה הסכמה לתנאים. אם אינכם מסכימים להם, אל תשתמשו בשירות.

+ +

השירות

+

השירות מאפשר לפתוח ערוץ, לפרסם בו הודעות, תמונות, סרטונים וקבצים, ולשתף אותו בקישור. הקריאה בערוץ ציבורי פתוחה לכל אחד. פתיחת ערוץ, ניהולו ותגובות דורשים התחברות עם חשבון גוגל. השימוש הבסיסי בשירות ניתן בחינם.

+ +

החשבון שלכם

+ + +

תוכן שאתם מפרסמים

+

התוכן שאתם מפרסמים שייך לכם, והאחריות עליו היא שלכם בלבד. כדי להפעיל את השירות אתם מעניקים לנו רשות לאחסן את התוכן, להציג אותו לקוראי הערוץ ולהפיץ אותו כפי שהגדרתם (למשל בהתראות או בתוצאות חיפוש של ערוץ ציבורי).

+

אסור לפרסם בשירות תוכן ש:

+ + +

שימוש אסור בשירות

+

אין לנסות לעקוף את מנגנוני האבטחה וההרשאות, להעמיס על השירות, לאסוף מידע ממנו באופן אוטומטי בניגוד להגבלות שבו, או להשתמש בו בדרך שפוגעת במשתמשים אחרים או בשירות עצמו.

+ +

הסרת תוכן והשעיה

+

אנחנו רשאים להסיר תוכן, לחסום ערוץ או להשעות חשבון שמפרים את התנאים או את הדין, או בעקבות דיווח, גם בלי הודעה מוקדמת. מצאתם תוכן שמפר את התנאים? אפשר לדווח עליו מתוך הערוץ או דרך טופס יצירת הקשר.

+ +

פרסומות

+

בחלק מהערוצים יכולות להופיע פרסומות ממערכת הפרסום של מגנט. התוכן של פרסומת הוא באחריות המפרסם.

+ +

פרטיות

+

האופן שבו אנחנו אוספים ומשתמשים במידע מוסבר במדיניות הפרטיות, שהיא חלק מהתנאים האלה.

+ +

הגבלת אחריות

+

השירות ניתן כמות שהוא (AS IS). אנחנו עושים מאמץ שהוא יפעל ברציפות ושהמידע בו יישמר, אבל לא מתחייבים שהשירות יהיה זמין תמיד או נקי מתקלות, ולא נישא באחריות לנזק שנגרם מהשימוש בו, מהפסקה בפעילותו, מאובדן תוכן או מתוכן שפרסמו משתמשים אחרים, ככל שהדין מתיר. מומלץ לשמור עותק של תוכן חשוב.

+ +

הפסקת שימוש

+

אתם יכולים להפסיק להשתמש בשירות בכל עת ולבקש למחוק את החשבון והערוצים שלכם. אנחנו רשאים לשנות את השירות או להפסיק אותו, כולו או חלקו.

+ +

שינויים בתנאים

+

אם התנאים ישתנו, הגרסה המעודכנת תפורסם בעמוד הזה עם תאריך העדכון. המשך השימוש בשירות אחרי העדכון מהווה הסכמה לתנאים המעודכנים.

+ +

דין ושיפוט

+

על התנאים האלה חל הדין הישראלי, וסמכות השיפוט הבלעדית נתונה לבתי המשפט המוסמכים בישראל.

+ +

יצירת קשר

+

לכל שאלה על התנאים, פנו אלינו דרך טופס יצירת הקשר באתר.

+`)), + }, +} + +var legalShellTmpl = template.Must(template.New("legal").Parse(` + + + + +{{.Page.Title}} · {{.SiteName}} + + + + + + + + +
{{.SiteName}}
+
+

{{.Page.Title}}

+

עודכן לאחרונה: {{.Page.Revised}}

+{{.Body}}
+ + + +`)) + +func serveLegalPage(w http.ResponseWriter, r *http.Request, p *legalPage) { + if r.URL.Path != p.Path { + // One URL per page: "/privacy/" would otherwise be a duplicate. + http.Redirect(w, r, p.Path, http.StatusMovedPermanently) + return + } + cfg := getGlobalConfig() + data := struct { + SiteName string + Origin string + }{siteNameOf(cfg), requestOrigin(r, cfg)} + + var body, out bytes.Buffer + err := p.Body.Execute(&body, data) + if err == nil { + err = legalShellTmpl.Execute(&out, struct { + SiteName string + Origin string + Page *legalPage + Body template.HTML + }{data.SiteName, data.Origin, p, template.HTML(body.String())}) + } + if err != nil { + log.Printf("legal page %s: %v\n", p.Path, err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "public, max-age=300") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(out.Bytes()) +} diff --git a/backend/legal_test.go b/backend/legal_test.go new file mode 100644 index 00000000..7068450e --- /dev/null +++ b/backend/legal_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestLegalPagesRender(t *testing.T) { + for i := range legalPages { + p := &legalPages[i] + rec := httptest.NewRecorder() + serveLegalPage(rec, httptest.NewRequest(http.MethodGet, p.Path, nil), p) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", p.Path, rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"

" + p.Title + "

", "

", `href="/privacy"`, `href="/terms"`} { + if !strings.Contains(body, want) { + t.Errorf("%s: missing %q", p.Path, want) + } + } + if strings.Contains(body, "<h2>") { + t.Errorf("%s: body was escaped", p.Path) + } + + rec = httptest.NewRecorder() + serveLegalPage(rec, httptest.NewRequest(http.MethodGet, p.Path+"/", nil), p) + if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != p.Path { + t.Errorf("%s/: got %d to %q, want a redirect to the canonical path", p.Path, rec.Code, rec.Header().Get("Location")) + } + } +} + +func TestLegalPathsAreReservedSlugs(t *testing.T) { + for _, p := range legalPages { + if !isReservedSlug(strings.TrimPrefix(p.Path, "/")) { + t.Errorf("%s is not a reserved slug", p.Path) + } + } +} diff --git a/backend/logs.go b/backend/logs.go index f1d2fdc1..1fa13da3 100644 --- a/backend/logs.go +++ b/backend/logs.go @@ -29,10 +29,26 @@ func saveLoginFailedLog(title string, err error) { log.Println("Failed to open log file:", fileErr) return } + + // This is written from unauthenticated request paths, so it must not grow + // without bound: rotate past 10MB, keeping a single previous generation + // (total usage capped at ~20MB on the volume shared with uploads). + if st, serr := f.Stat(); serr == nil && st.Size() > 10<<20 { + f.Close() + os.Rename(logFile, logFile+".1") + f, fileErr = os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if fileErr != nil { + log.Println("Failed to reopen log file:", fileErr) + return + } + } defer f.Close() t := time.Now().Format(time.DateTime) - errStr := err.Error() + errStr := "" + if err != nil { + errStr = err.Error() + } logEntry := map[string]any{ "title": title, "error": errStr, diff --git a/backend/magnet_mail.go b/backend/magnet_mail.go new file mode 100644 index 00000000..8275110b --- /dev/null +++ b/backend/magnet_mail.go @@ -0,0 +1,364 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// Transactional email for channel owners and support requesters is built and +// sent by Magnet (its own sender name, its own templates, replies land in its +// inbox). This side only tells Magnet that something happened, over one +// server-to-server endpoint shared with Magnet's implementation: +// +// POST $MAGNET_MAIL_URL +// x-channels-secret: $MAGNET_MAIL_SECRET +// {"event": "channel_created" | "support_reply", "idempotency_key": ..., ...} +// +// Both variables unset (or either one) means the feature is off: nothing is +// sent and that is logged once. A mail is never on the request path — it is +// handed to a goroutine with its own deadline after the action it reports has +// been stored, so a slow or failing Magnet can neither delay nor fail opening a +// channel or answering a ticket. + +const ( + magnetMailEventChannelCreated = "channel_created" + magnetMailEventSupportReply = "support_reply" + + // defaultPublicBaseURL is where every link in the mail points when the + // operator has not configured a domain. Magnet refuses links to any other + // host, so this is also the only value that works with it today. + defaultPublicBaseURL = "https://arootz.magnetads.co.il" + + magnetMailAttempts = 3 + maxIdempotencyKeyLen = 200 + magnetMailOverallTime = 2 * time.Minute +) + +var ( + magnetMailURL = strings.TrimSpace(os.Getenv("MAGNET_MAIL_URL")) + magnetMailSecret = strings.TrimSpace(os.Getenv("MAGNET_MAIL_SECRET")) + publicBaseURLEnv = strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")) + + // Per attempt. Magnet renders and hands the mail to its provider before it + // answers, which is seconds, not milliseconds. + magnetMailClient = &http.Client{Timeout: 15 * time.Second} + + // magnetMailBackoff is the pause before the 2nd and 3rd attempt. A var so + // tests can shorten it. + magnetMailBackoff = []time.Duration{3 * time.Second, 15 * time.Second} + + // magnetMailInFlight lets tests wait for background deliveries to settle. + magnetMailInFlight sync.WaitGroup + + magnetMailOffLogged sync.Once +) + +type magnetMailChannel struct { + Slug string `json:"slug"` + Name string `json:"name"` + URL string `json:"url"` + ManageURL string `json:"manage_url"` +} + +type magnetMailTicket struct { + ID string `json:"id"` + Subject string `json:"subject"` + ReplyText string `json:"reply_text"` + URL string `json:"url"` +} + +type magnetMailEvent struct { + Event string `json:"event"` + IdempotencyKey string `json:"idempotency_key"` + To string `json:"to"` + ToName *string `json:"to_name"` + Channel *magnetMailChannel `json:"channel"` + ContactURL string `json:"contact_url"` + Ticket *magnetMailTicket `json:"ticket,omitempty"` +} + +// magnetMailEnabled reports whether both halves of the configuration are +// present, logging the "off" state once per process rather than per event. +func magnetMailEnabled() bool { + if magnetMailURL != "" && magnetMailSecret != "" { + return true + } + magnetMailOffLogged.Do(func() { + log.Println("magnet mail: MAGNET_MAIL_URL or MAGNET_MAIL_SECRET is not set — channel emails are off") + }) + return false +} + +// publicBaseURL is the absolute origin every emailed link is built on. A mail +// has no request to derive a host from, so it takes the operator's configured +// project_domain (the value push notifications already use), then +// PUBLIC_BASE_URL, then the production address. Only https values count: a +// link in an email that lands on plain http, or on a bare host, is refused by +// Magnet and useless to the reader anyway. +func publicBaseURL() string { + if cfg := getGlobalConfig(); cfg != nil { + if d := strings.TrimRight(strings.TrimSpace(cfg.ProjectDomain), "/"); strings.HasPrefix(d, "https://") { + return d + } + } + if d := strings.TrimRight(publicBaseURLEnv, "/"); strings.HasPrefix(d, "https://") { + return d + } + return defaultPublicBaseURL +} + +// supportContactURL opens the support box on the landing page (?support=1, +// handled by LandingPageComponent), tagged with the channel when there is one +// so the operator sees which tenant is asking. +func supportContactURL(base, slug string) string { + q := url.Values{"support": {"1"}} + if slug != "" { + q.Set("channel", slug) + } + return base + "/?" + q.Encode() +} + +// supportTicketURL opens the support box with this thread expanded. It never +// carries the ticket's access token: the mail goes to an address the +// requester typed and nobody verified, and the token is the only credential on +// an anonymous thread — putting it in the link would hand the thread (and the +// right to answer as the requester) to whoever owns that address. A signed-in +// requester finds the thread after signing in; an anonymous one finds it in +// the browser that opened it. Everyone else has the reply text in the mail and +// can answer by replying to it. +func supportTicketURL(base, ticketID string) string { + q := url.Values{"support": {"1"}, "ticket": {ticketID}} + return base + "/?" + q.Encode() +} + +func mailChannel(base string, ch *ChannelData) *magnetMailChannel { + name := oneLine(ch.Name) + if name == "" { + name = ch.Slug + } + p := "/channel/" + url.PathEscape(ch.Slug) + return &magnetMailChannel{ + Slug: ch.Slug, + Name: name, + URL: base + p, + ManageURL: base + p + "/manage", + } +} + +// mailToName is the greeting name, or null when all we have is the address +// itself (an anonymous ticket without a name stores the email as its name). +func mailToName(name, email string) *string { + name = oneLine(name) + if name == "" || strings.EqualFold(name, email) { + return nil + } + return &name +} + +// oneLine makes a free-text field safe for Magnet, which refuses control +// characters outside reply_text with a 400 (a lost mail, since a 400 is not +// retried) and caps names at 200 characters. +func oneLine(s string) string { + s = strings.Join(strings.FieldsFunc(s, func(r rune) bool { + return r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) + }), " ") + return trimTo(s, 200) +} + +func capKey(k string) string { + if len(k) > maxIdempotencyKeyLen { + return k[:maxIdempotencyKeyLen] + } + return k +} + +// channelCreatedMail is the welcome mail to a channel's new owner. The slug +// is unique for the channel's whole life, so it alone keys the mail. +func channelCreatedMail(ch *ChannelData, ownerEmail, ownerName string) (*magnetMailEvent, bool) { + if ch == nil || !looksLikeEmail(ownerEmail) { + return nil, false + } + base := publicBaseURL() + return &magnetMailEvent{ + Event: magnetMailEventChannelCreated, + IdempotencyKey: capKey("channel_created:" + ch.Slug), + To: ownerEmail, + ToName: mailToName(ownerName, ownerEmail), + Channel: mailChannel(base, ch), + ContactURL: supportContactURL(base, ch.Slug), + }, true +} + +// supportReplyMail tells the requester the management answered. replyIndex is +// the reply's position in the thread: messages are append-only and the thread +// is capped, so (ticket, index) names this one reply for good. ch is the +// channel the ticket was opened from, already filtered by ticketMailChannel, +// or nil. +func supportReplyMail(t *SupportTicket, replyIndex int, ch *ChannelData) (*magnetMailEvent, bool) { + if t == nil || replyIndex < 0 || replyIndex >= len(t.Messages) { + return nil, false + } + reply := t.Messages[replyIndex] + if reply.Author != "admin" || !looksLikeEmail(t.Email) { + return nil, false + } + base := publicBaseURL() + ev := &magnetMailEvent{ + Event: magnetMailEventSupportReply, + IdempotencyKey: capKey(fmt.Sprintf("support_reply:%s:%d", t.ID, replyIndex)), + To: t.Email, + ToName: mailToName(t.Name, t.Email), + ContactURL: supportContactURL(base, ""), + Ticket: &magnetMailTicket{ + ID: t.ID, + Subject: oneLine(t.Subject), + // Exactly what the requester sees in the thread. The signature is + // Magnet's template ("ניהול"); nothing here names who answered. + ReplyText: reply.Body, + URL: supportTicketURL(base, t.ID), + }, + } + if ch != nil { + ev.Channel = mailChannel(base, ch) + ev.ContactURL = supportContactURL(base, ch.Slug) + } + return ev, true +} + +// ticketMailChannel resolves the channel a ticket names, if its name and link +// may go into the requester's mail. ChannelSlug is free text from the request +// body, so anyone could name a private channel there; the mail must not tell +// them what it is called. A public channel is fine (its page says as much to +// anyone); a restricted one only for a signed-in requester who holds a role on +// it. +func ticketMailChannel(ctx context.Context, t *SupportTicket) *ChannelData { + if t == nil || !isPlausibleSlug(t.ChannelSlug) { + return nil + } + ch, err := dbGetChannel(ctx, t.ChannelSlug) + if err != nil || ch == nil { + return nil + } + if !ch.Features.Disabled && !ch.Features.RequireAuth { + return ch + } + if !t.Authenticated { + return nil + } + if v, ok := privilegesUsers.Load(normEmail(t.Email)); ok { + if u, ok := v.(User); ok { + if _, has := u.ChannelRoles[ch.Slug]; has { + return ch + } + } + } + return nil +} + +// dispatchMagnetMail builds and delivers one mail in the background. build +// runs inside the goroutine (it may read Redis) and returns false to send +// nothing. The caller has already stored the action; nothing here can affect +// its response. +func dispatchMagnetMail(build func(ctx context.Context) (*magnetMailEvent, bool)) { + if !magnetMailEnabled() { + return + } + magnetMailInFlight.Add(1) + go func() { + defer magnetMailInFlight.Done() + defer func() { + // A mail is never worth the process. + if p := recover(); p != nil { + log.Printf("magnet mail: panic: %v", p) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), magnetMailOverallTime) + defer cancel() + ev, ok := build(ctx) + if !ok || ev == nil { + return + } + if err := deliverMagnetMail(ctx, ev); err != nil { + log.Printf("magnet mail: %s %s not delivered: %v", ev.Event, ev.IdempotencyKey, err) + } + }() +} + +// errMagnetMailRejected marks a 4xx: Magnet read the request and refused it, +// so sending the same body again cannot succeed. +var errMagnetMailRejected = errors.New("rejected by magnet") + +// deliverMagnetMail posts ev, retrying network errors and 5xx answers with a +// backoff. The idempotency key makes a retry after a lost 200 harmless. +// Neither the secret nor the recipient is ever written to the log. +func deliverMagnetMail(ctx context.Context, ev *magnetMailEvent) error { + payload, err := json.Marshal(ev) + if err != nil { + return err + } + var lastErr error + for attempt := 0; attempt < magnetMailAttempts; attempt++ { + if attempt > 0 { + wait := magnetMailBackoff[min(attempt-1, len(magnetMailBackoff)-1)] + select { + case <-ctx.Done(): + return fmt.Errorf("gave up after %d attempts: %w (last: %v)", attempt, ctx.Err(), lastErr) + case <-time.After(wait): + } + } + status, err := postMagnetMail(ctx, payload) + switch { + case err != nil: + lastErr = err + case status >= 200 && status < 300: + return nil + case status == http.StatusConflict: + // Magnet's "in_progress": the same key is mid-send in another + // request. Not a refusal of the body, so it is worth another try. + lastErr = fmt.Errorf("status %d", status) + case status >= 400 && status < 500: + return fmt.Errorf("%w: status %d", errMagnetMailRejected, status) + default: + lastErr = fmt.Errorf("status %d", status) + } + } + return fmt.Errorf("gave up after %d attempts: %v", magnetMailAttempts, lastErr) +} + +func postMagnetMail(ctx context.Context, payload []byte) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, magnetMailURL, bytes.NewReader(payload)) + if err != nil { + // The URL is operator configuration, not a secret, but the error text + // is kept generic so a malformed value never echoes a pasted secret. + return 0, errors.New("request build failed — check MAGNET_MAIL_URL") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-channels-secret", magnetMailSecret) + + resp, err := magnetMailClient.Do(req) + if err != nil { + // *url.Error names the URL; the cause alone is enough to act on. + var uerr *url.Error + if errors.As(err, &uerr) { + return 0, uerr.Err + } + return 0, err + } + defer resp.Body.Close() + // Drained (bounded) so the connection can be reused; the body is not + // logged, since an error page might quote request headers back. + io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + return resp.StatusCode, nil +} diff --git a/backend/magnet_mail_test.go b/backend/magnet_mail_test.go new file mode 100644 index 00000000..b9fa2a5a --- /dev/null +++ b/backend/magnet_mail_test.go @@ -0,0 +1,514 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// The mail endpoint is Magnet's; here it is an httptest server that records +// what it was sent. Handlers run against real Redis like the other handler +// tests, with a real session store. + +const testMailSecret = "test-channels-secret-0123456789" + +type mailHit struct { + header http.Header + body map[string]any +} + +type fakeMagnet struct { + mu sync.Mutex + hits []mailHit + // respond decides each answer; nil answers 200. + respond func(n int, w http.ResponseWriter, r *http.Request) + srv *httptest.Server +} + +func (f *fakeMagnet) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.hits) +} + +func (f *fakeMagnet) last(t *testing.T) mailHit { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.hits) == 0 { + t.Fatal("magnet received no mail request") + } + return f.hits[len(f.hits)-1] +} + +// useFakeMagnet points the mailer at a fresh fake, pins the base URL to the +// default and shortens the retry pauses. Every in-flight delivery is waited +// for before the variables are restored, so no goroutine outlives its test. +func useFakeMagnet(t *testing.T, respond func(n int, w http.ResponseWriter, r *http.Request)) *fakeMagnet { + t.Helper() + f := &fakeMagnet{respond: respond} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + f.mu.Lock() + f.hits = append(f.hits, mailHit{header: r.Header.Clone(), body: body}) + n := len(f.hits) + f.mu.Unlock() + if f.respond != nil { + f.respond(n, w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + + prevURL, prevSecret, prevEnv := magnetMailURL, magnetMailSecret, publicBaseURLEnv + prevBackoff, prevClient, prevCfg := magnetMailBackoff, magnetMailClient, getGlobalConfig() + magnetMailURL, magnetMailSecret, publicBaseURLEnv = f.srv.URL+"/functions/v1/channels-mail", testMailSecret, "" + magnetMailBackoff = []time.Duration{5 * time.Millisecond, 5 * time.Millisecond} + magnetMailClient = &http.Client{Timeout: 300 * time.Millisecond} + setGlobalConfigCache(&SettingConfig{}) + t.Cleanup(func() { + magnetMailInFlight.Wait() + f.srv.CloseClientConnections() + f.srv.Close() + magnetMailURL, magnetMailSecret, publicBaseURLEnv = prevURL, prevSecret, prevEnv + magnetMailBackoff, magnetMailClient = prevBackoff, prevClient + setGlobalConfigCache(prevCfg) + }) + return f +} + +func str(t *testing.T, m map[string]any, key string) string { + t.Helper() + v, ok := m[key].(string) + if !ok { + t.Fatalf("field %q = %#v, want a string", key, m[key]) + } + return v +} + +func obj(t *testing.T, m map[string]any, key string) map[string]any { + t.Helper() + v, ok := m[key].(map[string]any) + if !ok { + t.Fatalf("field %q = %#v, want an object", key, m[key]) + } + return v +} + +// cleanupOwnedChannel removes a channel a handler created, and the owner +// grant and audit record that came with it. +func cleanupOwnedChannel(t *testing.T, slug, email string) { + t.Helper() + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dbDeleteChannel(cctx, slug) + _ = dbUpdateUsersList(cctx, func(users []User) []User { + kept := make([]User, 0, len(users)) + for _, u := range users { + if u.Email != email { + kept = append(kept, u) + } + } + return kept + }) + rdb.Del(cctx, "channel_create:lock:"+email) + }) +} + +func createChannelAs(t *testing.T, email, slug string) *httptest.ResponseRecorder { + t.Helper() + cleanupOwnedChannel(t, slug, email) + sess := Session{ID: "mail-" + slug, Email: email, Username: email, PublicName: "בעל הערוץ"} + r := requestAs(t, sess, "", http.MethodPost, "/api/channels/create", + strings.NewReader(`{"slug":"`+slug+`","name":"ערוץ הבדיקה","description":"תיאור"}`), nil) + w := httptest.NewRecorder() + createChannelSelfService(w, r) + return w +} + +func TestMagnetMailChannelCreatedSelfService(t *testing.T) { + useTestSessionStore(t) + f := useFakeMagnet(t, nil) + + const email, slug = "mail-owner-a@example.com", "mail-created-a" + w := createChannelAs(t, email, slug) + if w.Code != http.StatusCreated { + t.Fatalf("create: status %d, body %q", w.Code, w.Body.String()) + } + magnetMailInFlight.Wait() + + if n := f.count(); n != 1 { + t.Fatalf("magnet got %d requests, want 1", n) + } + hit := f.last(t) + if got := hit.header.Get("x-channels-secret"); got != testMailSecret { + t.Errorf("x-channels-secret = %q", got) + } + if got := hit.header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q", got) + } + + b := hit.body + base := defaultPublicBaseURL + if str(t, b, "event") != "channel_created" { + t.Errorf("event = %v", b["event"]) + } + if str(t, b, "idempotency_key") != "channel_created:"+slug { + t.Errorf("idempotency_key = %v", b["idempotency_key"]) + } + if str(t, b, "to") != email { + t.Errorf("to = %v", b["to"]) + } + if str(t, b, "to_name") != "בעל הערוץ" { + t.Errorf("to_name = %v", b["to_name"]) + } + ch := obj(t, b, "channel") + if str(t, ch, "slug") != slug || str(t, ch, "name") != "ערוץ הבדיקה" || + str(t, ch, "url") != base+"/channel/"+slug || str(t, ch, "manage_url") != base+"/channel/"+slug+"/manage" { + t.Errorf("channel = %v", ch) + } + if str(t, b, "contact_url") != base+"/?channel="+slug+"&support=1" { + t.Errorf("contact_url = %v", b["contact_url"]) + } + if _, has := b["ticket"]; has { + t.Errorf("channel_created must not carry a ticket: %v", b["ticket"]) + } +} + +// Unconfigured means off: the channel opens and nothing leaves the server. +func TestMagnetMailOffWhenUnconfigured(t *testing.T) { + useTestSessionStore(t) + f := useFakeMagnet(t, nil) + + for _, tc := range []struct{ name, url, secret, slug string }{ + {"no url", "", testMailSecret, "mail-off-nourl"}, + {"no secret", f.srv.URL, "", "mail-off-nosecret"}, + } { + magnetMailURL, magnetMailSecret = tc.url, tc.secret + w := createChannelAs(t, "mail-off-"+tc.slug+"@example.com", tc.slug) + if w.Code != http.StatusCreated { + t.Fatalf("%s: create status %d", tc.name, w.Code) + } + } + magnetMailInFlight.Wait() + if n := f.count(); n != 0 { + t.Errorf("magnet got %d requests with the feature off, want 0", n) + } +} + +// A failing Magnet is retried a bounded number of times in the background, +// and the channel is created regardless. +func TestMagnetMailServerErrorIsRetriedAndCreationSucceeds(t *testing.T) { + useTestSessionStore(t) + f := useFakeMagnet(t, func(_ int, w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError) + }) + + w := createChannelAs(t, "mail-500@example.com", "mail-upstream-500") + if w.Code != http.StatusCreated { + t.Fatalf("create: status %d", w.Code) + } + magnetMailInFlight.Wait() + if n := f.count(); n != magnetMailAttempts { + t.Errorf("magnet got %d attempts, want %d", n, magnetMailAttempts) + } + keys := map[string]bool{} + for _, h := range f.hits { + keys[str(t, h.body, "idempotency_key")] = true + } + if len(keys) != 1 { + t.Errorf("retries must reuse one idempotency key, got %v", keys) + } +} + +// A hanging Magnet must not hold the user's response: the handler answers +// before the mailer has even given up. +func TestMagnetMailHangingServerDoesNotBlockCreation(t *testing.T) { + useTestSessionStore(t) + release := make(chan struct{}) + f := useFakeMagnet(t, func(_ int, w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + }) + t.Cleanup(func() { close(release) }) + // Long enough that a handler waiting on even one attempt would show. + // useFakeMagnet restores the client afterwards. + magnetMailClient = &http.Client{Timeout: time.Second} + + start := time.Now() + w := createChannelAs(t, "mail-hang@example.com", "mail-upstream-hang") + elapsed := time.Since(start) + if w.Code != http.StatusCreated { + t.Fatalf("create: status %d", w.Code) + } + if elapsed > 900*time.Millisecond { + t.Errorf("create took %v with Magnet hanging; it must not wait for the mail", elapsed) + } + magnetMailInFlight.Wait() + if n := f.count(); n != magnetMailAttempts { + t.Errorf("timed-out attempts = %d, want %d", n, magnetMailAttempts) + } +} + +func TestMagnetMailClientErrorIsNotRetried(t *testing.T) { + useTestSessionStore(t) + f := useFakeMagnet(t, func(_ int, w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"invalid_url"}`, http.StatusBadRequest) + }) + + if w := createChannelAs(t, "mail-400@example.com", "mail-upstream-400"); w.Code != http.StatusCreated { + t.Fatalf("create: status %d", w.Code) + } + magnetMailInFlight.Wait() + if n := f.count(); n != 1 { + t.Errorf("magnet got %d attempts after a 400, want exactly 1", n) + } +} + +func adminReply(t *testing.T, sess *Session, id, body string) { + t.Helper() + r := requestAs(t, *sess, "", http.MethodPost, "/api/super-admin/support/tickets/"+id+"/reply", + strings.NewReader(`{"body":"`+body+`"}`), map[string]string{"id": id}) + w := httptest.NewRecorder() + adminReplySupportTicket(w, r) + if w.Code != http.StatusOK { + t.Fatalf("admin reply: status %d, body %q", w.Code, w.Body.String()) + } +} + +var testOperator = Session{ID: "op-1", Email: "operator-mail@example.com", PublicName: "Real Operator Name"} + +func TestMagnetMailSupportReply(t *testing.T) { + ctx := supportCtx(t) + useTestSessionStore(t) + f := useFakeMagnet(t, nil) + + ticket := newTestTicket(t, ctx, "mail-sup-1", "asker@example.com") + adminReply(t, &testOperator, ticket.ID, "התשובה שלנו") + magnetMailInFlight.Wait() + + hit := f.last(t) + if hit.header.Get("x-channels-secret") != testMailSecret { + t.Error("missing secret header") + } + b := hit.body + base := defaultPublicBaseURL + if str(t, b, "event") != "support_reply" || str(t, b, "to") != "asker@example.com" || + str(t, b, "to_name") != "בודק" { + t.Errorf("support mail = %v", b) + } + if str(t, b, "idempotency_key") != "support_reply:mail-sup-1:1" { + t.Errorf("idempotency_key = %v", b["idempotency_key"]) + } + if v, present := b["channel"]; !present || v != nil { + t.Errorf("channel = %#v, want an explicit null for a general ticket", v) + } + if str(t, b, "contact_url") != base+"/?support=1" { + t.Errorf("contact_url = %v", b["contact_url"]) + } + tk := obj(t, b, "ticket") + if str(t, tk, "id") != "mail-sup-1" || str(t, tk, "subject") != "נושא" || + str(t, tk, "reply_text") != "התשובה שלנו" || + str(t, tk, "url") != base+"/?support=1&ticket=mail-sup-1" { + t.Errorf("ticket = %v", tk) + } + + // Nothing of the operator, and never the anonymous access token. + raw, _ := json.Marshal(b) + for _, leak := range []string{testOperator.Email, testOperator.PublicName, ticket.AccessToken} { + if strings.Contains(string(raw), leak) { + t.Errorf("mail payload leaks %q: %s", leak, raw) + } + } + + // A second answer is a second mail with its own stable key. + adminReply(t, &testOperator, ticket.ID, "עוד תשובה") + magnetMailInFlight.Wait() + if got := str(t, f.last(t).body, "idempotency_key"); got != "support_reply:mail-sup-1:2" { + t.Errorf("second reply key = %q", got) + } +} + +// Only the management's answer is mailed: the requester adding to their own +// thread, an operator answering a thread they opened, and a ticket with no +// address all send nothing. +func TestMagnetMailSupportReplyNotSent(t *testing.T) { + ctx := supportCtx(t) + useTestSessionStore(t) + f := useFakeMagnet(t, nil) + + // The requester replies (anonymous ticket, token header). + anon := newTestTicket(t, ctx, "mail-sup-self", "self@example.com") + anon.Authenticated = false + if err := dbSaveSupportTicket(ctx, anon); err != nil { + t.Fatal(err) + } + r := requestAs(t, Session{}, "", http.MethodPost, "/api/support/tickets/"+anon.ID+"/reply", + strings.NewReader(`{"body":"עוד פרט"}`), map[string]string{"id": anon.ID}) + r.Header.Set(ticketTokenHeader, anon.AccessToken) + // Its own address, so the shared per-IP submit limiter cannot refuse it. + r.RemoteAddr = "203.0.113.71:4000" + w := httptest.NewRecorder() + replySupportTicket(w, r) + if w.Code != http.StatusOK { + t.Fatalf("requester reply: status %d %q", w.Code, w.Body.String()) + } + + // The operator answers their own ticket. + own := newTestTicket(t, ctx, "mail-sup-own", testOperator.Email) + adminReply(t, &testOperator, own.ID, "בדיקה") + + // No address on the ticket. + noMail := newTestTicket(t, ctx, "mail-sup-nomail", "") + adminReply(t, &testOperator, noMail.ID, "תשובה") + + magnetMailInFlight.Wait() + if n := f.count(); n != 0 { + t.Errorf("magnet got %d requests, want 0: %v", n, f.hits) + } +} + +// ChannelSlug is free text from the requester, so a restricted channel's name +// is only mailed to someone entitled to it. +func TestMagnetMailSupportReplyChannel(t *testing.T) { + ctx := supportCtx(t) + useTestSessionStore(t) + f := useFakeMagnet(t, nil) + + seedChannel(t, ctx, "mail-public-ch", Settings{}) + private := &ChannelData{Slug: "mail-private-ch", Name: "סודי", CreatedAt: time.Now(), + Features: ChannelFeatures{RequireAuth: true}} + if err := dbCreateChannel(ctx, private); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = dbDeleteChannel(context.Background(), private.Slug) }) + + pub := newTestTicket(t, ctx, "mail-sup-pub", "pub@example.com") + pub.ChannelSlug = "mail-public-ch" + priv := newTestTicket(t, ctx, "mail-sup-priv", "stranger@example.com") + priv.ChannelSlug = private.Slug + priv.Authenticated = false + for _, tk := range []*SupportTicket{pub, priv} { + if err := dbSaveSupportTicket(ctx, tk); err != nil { + t.Fatal(err) + } + } + + adminReply(t, &testOperator, pub.ID, "א") + magnetMailInFlight.Wait() + b := f.last(t).body + ch := obj(t, b, "channel") + if str(t, ch, "slug") != "mail-public-ch" { + t.Errorf("public channel = %v", ch) + } + if str(t, b, "contact_url") != defaultPublicBaseURL+"/?channel=mail-public-ch&support=1" { + t.Errorf("contact_url = %v", b["contact_url"]) + } + + adminReply(t, &testOperator, priv.ID, "ב") + magnetMailInFlight.Wait() + b = f.last(t).body + if v := b["channel"]; v != nil { + t.Errorf("a restricted channel named by a stranger must not be mailed: %v", v) + } + if strings.Contains(str(t, b, "contact_url"), "mail-private-ch") { + t.Errorf("contact_url names the private channel: %v", b["contact_url"]) + } +} + +func TestMagnetMailIdempotencyKeysAreStable(t *testing.T) { + ch := &ChannelData{Slug: "stable-slug", Name: "x"} + a, _ := channelCreatedMail(ch, "o@example.com", "") + b, _ := channelCreatedMail(ch, "o@example.com", "שם אחר") + if a.IdempotencyKey != b.IdempotencyKey || a.IdempotencyKey != "channel_created:stable-slug" { + t.Errorf("channel keys %q / %q", a.IdempotencyKey, b.IdempotencyKey) + } + + tk := &SupportTicket{ID: "tid", Email: "r@example.com", Messages: []SupportMessage{ + {Author: "user", Body: "q"}, {Author: "admin", Body: "a"}, + }} + x, ok1 := supportReplyMail(tk, 1, nil) + y, ok2 := supportReplyMail(tk, 1, nil) + if !ok1 || !ok2 || x.IdempotencyKey != y.IdempotencyKey || x.IdempotencyKey != "support_reply:tid:1" { + t.Errorf("support keys %v / %v", x, y) + } + // The requester's own message is never mailed. + if _, ok := supportReplyMail(tk, 0, nil); ok { + t.Error("a user message must not build a support_reply mail") + } + if _, ok := channelCreatedMail(ch, "", ""); ok { + t.Error("no address, no mail") + } +} + +// Magnet answers 409 while the same key is mid-send elsewhere; that is a +// "try again", unlike the other 4xx answers. +func TestMagnetMailInProgressIsRetried(t *testing.T) { + f := useFakeMagnet(t, func(n int, w http.ResponseWriter, _ *http.Request) { + if n == 1 { + http.Error(w, `{"error":"in_progress"}`, http.StatusConflict) + return + } + w.Write([]byte(`{"ok":true}`)) + }) + ev, _ := channelCreatedMail(&ChannelData{Slug: "inprogress-slug", Name: "x"}, "o@example.com", "") + if err := deliverMagnetMail(context.Background(), ev); err != nil { + t.Fatalf("deliver: %v", err) + } + if n := f.count(); n != 2 { + t.Errorf("attempts = %d, want 2", n) + } +} + +// Magnet refuses control characters outside reply_text with a 400, which is +// never retried — so they are flattened here instead of losing the mail. +func TestMagnetMailFlattensControlCharacters(t *testing.T) { + ch := &ChannelData{Slug: "ctl-slug", Name: "שם\nעם\tשורות"} + ev, _ := channelCreatedMail(ch, "o@example.com", "בעלים\r\nשני") + if ev.Channel.Name != "שם עם שורות" || ev.ToName == nil || *ev.ToName != "בעלים שני" { + t.Errorf("name = %q, to_name = %v", ev.Channel.Name, ev.ToName) + } + tk := &SupportTicket{ID: "ctl", Subject: "נושא\nשבור", Email: "r@example.com", + Messages: []SupportMessage{{Author: "admin", Body: "שורה\nשנייה"}}} + sr, _ := supportReplyMail(tk, 0, nil) + if sr.Ticket.Subject != "נושא שבור" || sr.Ticket.ReplyText != "שורה\nשנייה" { + t.Errorf("subject = %q, reply = %q (reply_text keeps its newlines)", sr.Ticket.Subject, sr.Ticket.ReplyText) + } +} + +func TestPublicBaseURLPrecedence(t *testing.T) { + prevCfg, prevEnv := getGlobalConfig(), publicBaseURLEnv + t.Cleanup(func() { setGlobalConfigCache(prevCfg); publicBaseURLEnv = prevEnv }) + + setGlobalConfigCache(&SettingConfig{}) + publicBaseURLEnv = "" + if got := publicBaseURL(); got != defaultPublicBaseURL { + t.Errorf("default = %q", got) + } + publicBaseURLEnv = "https://env.example.com/" + if got := publicBaseURL(); got != "https://env.example.com" { + t.Errorf("env = %q", got) + } + setGlobalConfigCache(&SettingConfig{ProjectDomain: "https://arootz.magnetads.co.il/"}) + if got := publicBaseURL(); got != "https://arootz.magnetads.co.il" { + t.Errorf("project_domain = %q", got) + } + // A non-https setting is skipped rather than mailed. + setGlobalConfigCache(&SettingConfig{ProjectDomain: "http://insecure.example.com"}) + publicBaseURLEnv = "" + if got := publicBaseURL(); got != defaultPublicBaseURL { + t.Errorf("http project_domain = %q", got) + } +} diff --git a/backend/magnet_stats_test.go b/backend/magnet_stats_test.go new file mode 100644 index 00000000..e83d216c --- /dev/null +++ b/backend/magnet_stats_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// Exercised against real Redis like my_channels_test.go. The global magnet +// config is a single shared key, so each test saves the stored value and puts +// it back afterwards. + +const ( + testStatsKeyA = "mgs_0123456789abcdef0123456789abcdef0123456789abcdef" + testStatsKeyB = "mgs_fedcba9876543210fedcba9876543210fedcba9876543210" +) + +// preserveGlobalMagnet restores global:magnet:config and magnetStatsURL when +// the test ends, and returns the original config so POST bodies can reuse its +// lock rules (the sync goroutine then has nothing to change on channels). +func preserveGlobalMagnet(t *testing.T, ctx context.Context) GlobalMagnetConfig { + t.Helper() + orig, origErr := rdb.Get(ctx, "global:magnet:config").Result() + origURL := magnetStatsURL + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + magnetStatsURL = origURL + if origErr != nil { + _ = rdb.Del(cctx, "global:magnet:config").Err() + return + } + _ = rdb.Set(cctx, "global:magnet:config", orig, 0).Err() + }) + cfg, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + t.Fatalf("read global magnet config: %v", err) + } + return *cfg +} + +func storeMagnetConfig(t *testing.T, ctx context.Context, cfg GlobalMagnetConfig) { + t.Helper() + if err := dbSetGlobalMagnetConfig(ctx, &cfg); err != nil { + t.Fatalf("store global magnet config: %v", err) + } +} + +func storedStatsKey(t *testing.T, ctx context.Context) string { + t.Helper() + cfg, err := dbGetGlobalMagnetConfig(ctx) + if err != nil { + t.Fatalf("read global magnet config: %v", err) + } + return cfg.StatsKey +} + +// assertRedacted fails when a response carries the key or a statsKey field, +// and returns the decoded statsKeySet. +func assertRedacted(t *testing.T, body []byte, keys ...string) bool { + t.Helper() + for _, k := range keys { + if bytes.Contains(body, []byte(k)) { + t.Fatalf("response leaks the stats key: %s", body) + } + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("response is not JSON: %v: %s", err, body) + } + if _, ok := m["statsKey"]; ok { + t.Fatalf("response has a statsKey field: %s", body) + } + set, ok := m["statsKeySet"].(bool) + if !ok { + t.Fatalf("response has no boolean statsKeySet: %s", body) + } + return set +} + +func TestGlobalMagnetConfigGetRedactsStatsKey(t *testing.T) { + ctx := testCtx(t) + base := preserveGlobalMagnet(t, ctx) + + for _, tc := range []struct { + name string + key string + wantSet bool + }{ + {"stored", testStatsKeyA, true}, + {"empty", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := base + cfg.ApiKey = "public-embed-key" + cfg.StatsKey = tc.key + storeMagnetConfig(t, ctx, cfg) + + rec := httptest.NewRecorder() + getGlobalMagnetConfig(rec, httptest.NewRequest(http.MethodGet, "/api/super-admin/magnet/config", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body %s", rec.Code, rec.Body) + } + if got := assertRedacted(t, rec.Body.Bytes(), testStatsKeyA); got != tc.wantSet { + t.Errorf("statsKeySet = %v, want %v", got, tc.wantSet) + } + // The rest of the config still reaches the screen. + if !strings.Contains(rec.Body.String(), `"apiKey":"public-embed-key"`) { + t.Errorf("apiKey missing from view: %s", rec.Body) + } + }) + } +} + +func TestGlobalMagnetConfigPostKeepReplaceClear(t *testing.T) { + ctx := testCtx(t) + base := preserveGlobalMagnet(t, ctx) + + post := func(t *testing.T, extra map[string]any) *httptest.ResponseRecorder { + t.Helper() + body := map[string]any{ + "enabled": base.Enabled, + "snippet": base.Snippet, + "mode": base.Mode, + "apiKey": base.ApiKey, + "lockAll": base.LockAll, + "lockedChannels": base.LockedChannels, + } + for k, v := range extra { + body[k] = v + } + raw, _ := json.Marshal(body) + rec := httptest.NewRecorder() + setGlobalMagnetConfig(rec, httptest.NewRequest(http.MethodPost, "/api/super-admin/magnet/config", bytes.NewReader(raw))) + return rec + } + + cases := []struct { + name string + extra map[string]any + wantStatus int + wantStored string + wantSet bool + }{ + {"empty keeps", map[string]any{"statsKey": ""}, 200, testStatsKeyA, true}, + {"absent keeps", map[string]any{}, 200, testStatsKeyA, true}, + {"whitespace keeps", map[string]any{"statsKey": " "}, 200, testStatsKeyA, true}, + {"valid replaces", map[string]any{"statsKey": testStatsKeyB}, 200, testStatsKeyB, true}, + {"valid with spaces is trimmed", map[string]any{"statsKey": " " + testStatsKeyB + "\n"}, 200, testStatsKeyB, true}, + {"clear", map[string]any{"clearStatsKey": true}, 200, "", false}, + {"clear wins over a supplied key", map[string]any{"clearStatsKey": true, "statsKey": testStatsKeyB}, 200, "", false}, + {"uppercase rejected", map[string]any{"statsKey": strings.ToUpper(testStatsKeyB)}, 400, testStatsKeyA, false}, + {"embed key rejected", map[string]any{"statsKey": "public-embed-key"}, 400, testStatsKeyA, false}, + {"too short rejected", map[string]any{"statsKey": testStatsKeyB[:len(testStatsKeyB)-1]}, 400, testStatsKeyA, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + start := base + start.StatsKey = testStatsKeyA + storeMagnetConfig(t, ctx, start) + + rec := post(t, tc.extra) + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d; body %s", rec.Code, tc.wantStatus, rec.Body) + } + if got := storedStatsKey(t, ctx); got != tc.wantStored { + t.Errorf("stored key = %q, want %q", got, tc.wantStored) + } + if tc.wantStatus == http.StatusOK { + if got := assertRedacted(t, rec.Body.Bytes(), testStatsKeyA, testStatsKeyB); got != tc.wantSet { + t.Errorf("statsKeySet = %v, want %v", got, tc.wantSet) + } + return + } + var e map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { + t.Fatalf("error body is not JSON: %s", rec.Body) + } + if e["error"] != "invalid_stats_key" || e["message"] == "" { + t.Errorf("error body = %v, want invalid_stats_key with a message", e) + } + }) + } +} + +func TestMagnetStatsWithoutKeyDoesNotCallMagnet(t *testing.T) { + ctx := testCtx(t) + base := preserveGlobalMagnet(t, ctx) + + var hits atomic.Int32 + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + })) + defer up.Close() + magnetStatsURL = up.URL + "/functions/v1/site-stats" + + cfg := base + // The public embed key alone must no longer reach Magnet at all. + cfg.ApiKey = "public-embed-key" + cfg.StatsKey = "" + storeMagnetConfig(t, ctx, cfg) + + rec := httptest.NewRecorder() + getMagnetStats(rec, httptest.NewRequest(http.MethodGet, "/api/super-admin/magnet/stats", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body %s", rec.Code, rec.Body) + } + var e map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &e) + if e["error"] != "missing_stats_key" { + t.Errorf("error = %q, want missing_stats_key", e["error"]) + } + if n := hits.Load(); n != 0 { + t.Errorf("upstream called %d times without a stats key", n) + } +} + +func TestMagnetStatsProxiesWithHeader(t *testing.T) { + ctx := testCtx(t) + base := preserveGlobalMagnet(t, ctx) + + type seen struct { + method, path, rawQuery, key string + } + var got seen + status := http.StatusOK + respBody := `{"site":{"id":"s1","domain":"example.com"},"currency":"ILS","clicks":{"today":1,"week":2,"month":3},"earnings":{"today":0.5,"week":1,"month":1.5},"generated_at":"2026-10-13T09:30:00.000Z"}` + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = seen{r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("x-magnet-stats-key")} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(respBody)) + })) + defer up.Close() + magnetStatsURL = up.URL + "/functions/v1/site-stats" + + cfg := base + cfg.ApiKey = "public-embed-key" + cfg.StatsKey = testStatsKeyA + storeMagnetConfig(t, ctx, cfg) + + rec := httptest.NewRecorder() + getMagnetStats(rec, httptest.NewRequest(http.MethodGet, "/api/super-admin/magnet/stats", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body %s", rec.Code, rec.Body) + } + if rec.Body.String() != respBody { + t.Errorf("body not passed through:\n got %s\nwant %s", rec.Body, respBody) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", cc) + } + want := seen{http.MethodGet, "/functions/v1/site-stats", "", testStatsKeyA} + if got != want { + t.Errorf("upstream saw %+v, want %+v (key in header, nothing in the query string)", got, want) + } + + // Other upstream statuses pass through unchanged. + status = http.StatusNotFound + respBody = `{"error":"site_not_active","message":"הפלטפורמה אינה מאושרת במגנט או שהחשבון מושעה"}` + rec = httptest.NewRecorder() + getMagnetStats(rec, httptest.NewRequest(http.MethodGet, "/api/super-admin/magnet/stats", nil)) + if rec.Code != http.StatusNotFound || rec.Body.String() != respBody { + t.Errorf("got %d %s, want 404 passed through", rec.Code, rec.Body) + } + + // A revoked or wrong key: Magnet's body reaches the screen, but never as + // a 401, which the frontend reads as the operator's session ending. + status = http.StatusUnauthorized + respBody = `{"error":"invalid_key","message":"מפתח הסטטיסטיקה שגוי או שבוטל"}` + rec = httptest.NewRecorder() + getMagnetStats(rec, httptest.NewRequest(http.MethodGet, "/api/super-admin/magnet/stats", nil)) + if rec.Code != http.StatusBadGateway || rec.Body.String() != respBody { + t.Errorf("got %d %s, want 502 with Magnet's invalid_key body", rec.Code, rec.Body) + } + + // The platform's JWT gate answers 401 with no error code (site-stats + // deployed with verify_jwt on). It must not read as an outage: it gets + // upstream_unauthorized, still as 502 so the operator stays signed in. + for _, gateBody := range []string{`{"code":401,"message":"Missing authorization header"}`, `Unauthorized`, ``} { + status = http.StatusUnauthorized + respBody = gateBody + rec = httptest.NewRecorder() + getMagnetStats(rec, httptest.NewRequest(http.MethodGet, "/api/super-admin/magnet/stats", nil)) + var e map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &e) + if rec.Code != http.StatusBadGateway || e["error"] != "upstream_unauthorized" || e["message"] == "" { + t.Errorf("upstream 401 %q: got %d %s, want 502 upstream_unauthorized with a message", gateBody, rec.Code, rec.Body) + } + if strings.Contains(rec.Body.String(), testStatsKeyA) { + t.Errorf("upstream 401 %q: response leaks the stats key: %s", gateBody, rec.Body) + } + } +} diff --git a/backend/main.go b/backend/main.go index a33d03c9..8230b5b6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,10 +4,13 @@ import ( "bytes" "encoding/gob" "log" + "mime" "net/http" _ "net/http/pprof" "os" "path/filepath" + "regexp" + "strings" "github.com/boj/redistore" "github.com/go-chi/chi" @@ -16,125 +19,399 @@ import ( var rootStaticFolder = os.Getenv("ROOT_STATIC_FOLDER") -func protectedWithPrivilege(Privilege Privilege, handler http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !checkPrivilege(r, Privilege) { - http.Error(w, "User not authorized or not privilege", http.StatusUnauthorized) - return - } - handler(w, r) - } -} - -func ifRequireAuth(next http.Handler) http.Handler { +// hideTicketTokenQuery moves a support-thread access token that still arrives +// as ?token= (a SPA tab loaded before the X-Ticket-Token header existed, or a +// hand-built link) into the header and strips it from the URL. It runs ahead +// of the request logger, whose line is built from r.RequestURI, so the only +// credential of an anonymous thread never reaches stdout. Scoped to the +// support routes so no other endpoint's query string is rewritten. +func hideTicketTokenQuery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check every time - if settingConfig.RequireAuth { - checkLogin(next).ServeHTTP(w, r) - } else { - next.ServeHTTP(w, r) + if strings.HasPrefix(r.URL.Path, "/api/support/tickets/") && r.URL.RawQuery != "" { + q := r.URL.Query() + if t := q.Get("token"); t != "" { + if r.Header.Get(ticketTokenHeader) == "" { + r.Header.Set(ticketTokenHeader, t) + } + q.Del("token") + r.URL.RawQuery = q.Encode() + r.RequestURI = r.URL.RequestURI() + } } + next.ServeHTTP(w, r) }) } func main() { + // Go's built-in table has no entry for the web app manifest, so the static + // handler served it as application/octet-stream and browsers ignored it. + _ = mime.AddExtensionType(".webmanifest", "application/manifest+json") + // Sessions are signed with SECRET_KEY; with it unset, redistore signs with + // an empty key and every cookie is forgeable. Refuse to start rather than + // run with no session security at all. + if secretKey == "" { + log.Fatal("SECRET_KEY is not set; refusing to start with unsigned sessions") + } + if len(secretKey) < 32 { + log.Printf("WARNING: SECRET_KEY is only %d bytes; use at least 32 random bytes\n", len(secretKey)) + } + gob.Register(Session{}) - initializePrivilegeUsers() - go statLogger() + initR2() + if err := initializePrivilegeUsers(); err != nil { + panic(err) + } + startPrivilegesRefresh() + startGlobalSettingsRefresh() + // The session store is built before the migrations run: a misconfiguration + // here is fatal, and failing after a one-shot migration has been marked + // applied would consume it without ever serving a request. + // redistore dials REDIS_ADDR as one plain address. With Sentinel + // (REDIS_MASTER) the data client resolved the master while sessions were + // written to the sentinel itself, so the boot looked healthy and every + // login answered 500; a comma-separated list failed to dial outright. + // Neither is supported, so refuse loudly instead of half-working. + if master := os.Getenv("REDIS_MASTER"); master != "" || strings.Contains(redisAddr, ",") { + log.Fatalf("Session store cannot use Redis Sentinel or several addresses (REDIS_MASTER=%q, REDIS_ADDR=%q); point REDIS_ADDR at a single Redis/Kvrocks instance", master, redisAddr) + } var err error store, err = redistore.NewRediStore(10, redisType, redisAddr, "", redisPass, []byte(secretKey)) if err != nil { - panic(err) + log.Fatalf("Session store init failed (REDIS_PROTOCOL=%q, REDIS_ADDR=%q): %v", redisType, redisAddr, err) } store.SetMaxAge(60 * 60 * 24 * 30) + // redistore refuses to save a session larger than 4 KiB by default. The + // session carries the user's whole channel-role map, so a moderator of a + // few dozen channels could not log in at all: session.Save failed and the + // login answered 500. + store.SetMaxLength(64 << 10) store.Options.HttpOnly = true + // Secure by default; set COOKIE_INSECURE=1 only for local plain-HTTP dev. + store.Options.Secure = os.Getenv("COOKIE_INSECURE") != "1" + // Lax (not Strict) so the cookie still rides along on the Google OAuth + // redirect back to the site and on inbound links to authenticated views. + store.Options.SameSite = http.SameSiteLaxMode defer store.Close() + // Data migrations run to completion before the listener starts, so a request + // never observes a half-migrated state. A failure is logged and retried on + // the next boot rather than taking the server down. + migCtx, migCancel := migrationContext() + runMigrations(migCtx) + migCancel() + r := chi.NewRouter() + // Before Logger, which prints RequestURI: the anonymous support token must + // be lifted out of the query string before anything can write it down. + r.Use(hideTicketTokenQuery) r.Use(middleware.Logger) + // The shipped deployment fronts the backend with Caddy (docker-compose + // exposes only the proxy), so RemoteAddr is the proxy's container IP for + // every request. RealIP restores the client address from X-Real-IP, which + // the Caddyfile sets from the connection itself (and it strips the other + // headers RealIP would otherwise honour), so per-IP rate limiting works. + // The backend port must therefore never be exposed directly, since RealIP + // trusts whatever header it is given. + r.Use(middleware.RealIP) + // gzip for everything except the SSE stream; see compress.go. + r.Use(compressResponses) + r.Use(limitRequestBody) + r.Use(noindexAPI) - // Protected with api key - r.Post("/api/import/post", addNewPost) - + // Auth routes r.Get("/auth/google", getGoogleAuthValues) r.Post("/auth/login", login) r.Post("/auth/logout", logout) + + // Global assets r.Get("/assets/favicon.ico", getFavicon) r.Get("/favicon.ico", getFavicon) + r.Get("/firebase-messaging-sw.js", getFirebaseMessagingSW) + // Search engines: see seo.go, seo_content.go and sitemap.go. + r.Get("/robots.txt", getRobotsTxt) + for p := range googleVerificationFiles { + r.Get(p, serveGoogleVerificationFile) + } + r.Get("/sitemap.xml", getSitemap) + r.Get("/sitemap-pages.xml", getSitemapPages) + r.Get("/sitemap-channels-{n:[0-9]+}.xml", getSitemapChannels) + for _, p := range contentPages { + page := p + h := func(w http.ResponseWriter, r *http.Request) { serveContentPage(w, r, &page, getGlobalConfig()) } + r.Get(page.Path, h) + r.Get(page.Path+"/", h) + } + for _, p := range legalPages { + page := p + h := func(w http.ResponseWriter, r *http.Request) { serveLegalPage(w, r, &page) } + r.Get(page.Path, h) + r.Get(page.Path+"/", h) + } + + // User info (global, requires login) r.Group(func(r chi.Router) { r.Use(checkLogin) - r.Post("/api/reactions/set-reactions", setReactions) - r.Post("/api/messages/report", reportMessage) + r.Get("/api/user-info", getUserInfo) + // Self-service channel creation: the owner is taken from the session, + // never from the body, so nobody can claim a slug for someone else. + r.Post("/api/channels/create", createChannelSelfService) + r.Get("/api/channels/slug-available", checkSlugAvailable) + // Every channel the user holds a role on, with names and logos: what + // the "my channels" page and the channel switcher list. + r.Get("/api/my-channels", listMyChannels) }) - r.Group(func(r chi.Router) { - r.Use(ifRequireAuth) - r.Get("/firebase-messaging-sw.js", getFirebaseMessagingSW) - r.Route("/api", func(api chi.Router) { - api.Get("/ads/settings", getAdsSettings) - api.Get("/emojis/list", getEmojisList) - api.Get("/channel/notifications-config", getNotificationsConfig) - api.Post("/channel/notifications-subscribe", subscribeNotifications) - - api.Get("/channel/info", getChannelInfo) - api.Get("/messages", getMessages) - api.Get("/events", getEvents) - api.Get("/files/{fileid}", serveFile) - api.Get("/user-info", getUserInfo) - - api.Route("/admin", func(protected chi.Router) { - // ⚠️ WARNING: Route not check privilege use protectedWithPrivilege to check privilege. - - protected.Post("/new", protectedWithPrivilege(Writer, addMessage)) - protected.Post("/edit-message", protectedWithPrivilege(Writer, updateMessage)) - protected.Get("/delete-message/{id}", protectedWithPrivilege(Writer, deleteMessage)) - protected.Post("/upload", protectedWithPrivilege(Writer, uploadFile)) - protected.Get("/scheduled-messages/get", protectedWithPrivilege(Writer, getScheduledMessages)) - protected.Post("/scheduled-messages/update", protectedWithPrivilege(Writer, updateScheduledMessages)) - - protected.Post("/edit-channel-info", protectedWithPrivilege(Moderator, editChannelInfo)) - protected.Get("/statistics", protectedWithPrivilege(Moderator, getStatistics)) - protected.Post("/set-emojis", protectedWithPrivilege(Moderator, setEmojis)) - - protected.Post("/statistics/reset", protectedWithPrivilege(Admin, resetStatistics)) - protected.Get("/privilegs-users/get-list", protectedWithPrivilege(Admin, getPrivilegeUsersList)) - protected.Post("/privilegs-users/set", protectedWithPrivilege(Admin, setPrivilegeUsers)) - protected.Get("/settings/get", protectedWithPrivilege(Admin, getSettings)) - protected.Post("/settings/set", protectedWithPrivilege(Admin, setSettings)) - protected.Get("/reports/get", protectedWithPrivilege(Admin, getReports)) - protected.Post("/reports/set", protectedWithPrivilege(Admin, setReports)) + // Super admin routes + r.Route("/api/super-admin", func(r chi.Router) { + r.Use(checkLogin) + r.Use(requireSuperAdmin) + + r.Get("/channels", listChannels) + r.Post("/channels/create", createChannel) + r.Get("/channels/{slug}", getSuperAdminChannel) + r.Delete("/channels/{slug}", deleteChannel) + r.Put("/channels/{slug}/features", updateChannelFeatures) + r.Put("/channels/{slug}/seo", superAdminSetChannelSeo) + r.Get("/channels/{slug}/users", superAdminGetChannelUsers) + r.Post("/channels/{slug}/users", superAdminSetChannelUsers) + r.Get("/users/list", getPrivilegeUsersList) + r.Post("/users/set", setPrivilegeUsers) + r.Get("/global-settings/get", getGlobalSettings) + r.Post("/global-settings/set", setGlobalSettings) + r.Get("/ads/config", getGlobalAdsConfig) + r.Post("/ads/config", setGlobalAdsConfig) + r.Get("/magnet/config", getGlobalMagnetConfig) + r.Post("/magnet/config", setGlobalMagnetConfig) + r.Get("/magnet/stats", getMagnetStats) + r.Get("/storage/config", getSuperAdminStorageConfig) + r.Post("/storage/config", setSuperAdminStorageConfig) + r.Get("/channels/{slug}/storage", getSuperAdminChannelStorage) + r.Put("/channels/{slug}/storage", setSuperAdminChannelStorage) + r.Post("/statistics/reset", resetStatistics) + r.Get("/support/tickets", adminListSupportTickets) + r.Post("/support/tickets/{id}/reply", adminReplySupportTicket) + r.Post("/support/tickets/{id}/status", adminSetSupportTicketStatus) + }) + + // Support tickets. Creation and the thread view are deliberately outside + // checkLogin: a visitor who cannot sign in (or has not yet) is exactly the + // person who most needs to reach the operator. An anonymous thread is + // guarded by the unguessable token minted at creation. + r.Post("/api/support/tickets", createSupportTicket) + r.Get("/api/support/tickets/{id}", getSupportTicket) + r.Post("/api/support/tickets/{id}/reply", replySupportTicket) + r.With(checkLogin).Get("/api/support/my-tickets", listMySupportTickets) + + // Mail to the channels mailbox, handed over by Magnet. Server to server, + // guarded by the shared secret alone; see support_inbound.go. + r.Post("/api/integrations/magnet/inbound-mail", magnetInboundMail) + + // Per-channel API import (with API key, no channel middleware needed - slug from URL) + r.Post("/api/channel/{slug}/import/post", addNewPost) + + // Per-channel routes + r.Route("/api/channel/{slug}", func(r chi.Router) { + r.Use(channelMiddleware) + r.Use(channelIfRequireAuth) + + r.Get("/info", getChannelInfo) + r.Get("/messages", getMessages) + r.Get("/events", getEvents) + r.With(channelIfRequireAuthFiles).Get("/files/{fileid}", serveFile) + r.Get("/emojis/list", getEmojisList) + r.Get("/notifications-config", getNotificationsConfig) + r.Get("/ads/settings", getAdsSettings) + r.Get("/ads/magnet", getMagnetAdsSettings) + + r.Group(func(r chi.Router) { + r.Use(checkLogin) + // Gated like the other feature routes: a channel whose push the + // operator switched off must not keep collecting tokens. + r.Post("/notifications-subscribe", requireFeature(func(f *ChannelFeatures) bool { return f.Notifications }, subscribeNotifications)) + r.Post("/reactions/set-reactions", requireFeature(func(f *ChannelFeatures) bool { return f.Reactions }, setReactions)) + r.Post("/messages/report", requireFeature(func(f *ChannelFeatures) bool { return f.Reports }, reportMessage)) + r.Get("/user-info", getUserInfo) + + r.Route("/admin", func(r chi.Router) { + // Writer level + r.Post("/new", protectedWithChannelRole(RoleWriter, addMessage)) + r.Post("/edit-message", protectedWithChannelRole(RoleWriter, updateMessage)) + // DELETE only. The GET alias that used to sit here was a CSRF + // hole: the session cookie is SameSite=Lax, which deliberately + // still rides along on a top-level GET navigation, so merely + // visiting a link like + // /api/channel//admin/delete-message/42 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 is + // the whole defence. The only client (admin.service.ts) has + // always used DELETE. + r.Delete("/delete-message/{id}", protectedWithChannelRole(RoleWriter, deleteMessage)) + r.Post("/upload", protectedWithChannelRole(RoleWriter, requireFeature(func(f *ChannelFeatures) bool { return f.FileUploads }, uploadRateLimit(uploadFile)))) + r.Get("/scheduled-messages/get", protectedWithChannelRole(RoleWriter, requireFeature(func(f *ChannelFeatures) bool { return f.ScheduledMessages }, getScheduledMessages))) + r.Post("/scheduled-messages/update", protectedWithChannelRole(RoleWriter, requireFeature(func(f *ChannelFeatures) bool { return f.ScheduledMessages }, updateScheduledMessages))) + + // Moderator level + r.Post("/edit-channel-info", protectedWithChannelRole(RoleModerator, editChannelInfo)) + r.Get("/statistics", protectedWithChannelRole(RoleModerator, getStatistics)) + r.Post("/set-emojis", protectedWithChannelRole(RoleModerator, setEmojis)) + r.Get("/reports/get", protectedWithChannelRole(RoleModerator, getReports)) + r.Post("/reports/set", protectedWithChannelRole(RoleModerator, setReports)) + + // Owner level + r.Get("/settings/get", protectedWithChannelRole(RoleOwner, getSettings)) + r.Post("/settings/set", protectedWithChannelRole(RoleOwner, setSettings)) + r.Get("/users/get", protectedWithChannelRole(RoleOwner, getChannelUsers)) + r.Post("/users/set", protectedWithChannelRole(RoleOwner, setChannelUsers)) + r.Get("/storage", protectedWithChannelRole(RoleOwner, getChannelStorageInfo)) + r.Post("/storage/auto-cleanup", protectedWithChannelRole(RoleOwner, setChannelAutoCleanup)) + // The channel's own Magnet numbers. Owner level like the + // magnet tab itself; refused outright on a locked channel. + r.Get("/magnet/stats-key", protectedWithChannelRole(RoleOwner, getChannelMagnetStatsKey)) + r.Post("/magnet/stats-key", protectedWithChannelRole(RoleOwner, setChannelMagnetStatsKey)) + r.Get("/magnet/stats", protectedWithChannelRole(RoleOwner, getChannelMagnetStats)) }) }) }) - if settingConfig.RootStaticFolder != "" { - r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.Dir(settingConfig.RootStaticFolder)))) + if cfg := getGlobalConfig(); cfg != nil && cfg.RootStaticFolder != "" { + r.Handle("/assets/*", staticCacheHeaders(noDirectoryListing(http.StripPrefix("/assets/", http.FileServer(http.Dir(cfg.RootStaticFolder)))))) r.NotFound(serveSpaFile) } - go func() { - log.Fatal(http.ListenAndServe("localhost:6060", nil)) - }() + // The pprof listener is a debugging aid, not part of the service. Binding it + // must never be able to take the real server down, so the error is logged + // instead of fatal, and it only starts when explicitly asked for. + if pprofAddr := os.Getenv("PPROF_ADDR"); pprofAddr != "" { + go func() { + if err := http.ListenAndServe(pprofAddr, nil); err != nil { + log.Printf("pprof listener stopped: %v\n", err) + } + }() + } if err := http.ListenAndServe(":"+os.Getenv("SERVER_PORT"), r); err != nil { log.Fatal(err) } } +// maxJSONRequestBody caps any non-multipart request body. Handlers decode +// straight into memory with json.NewDecoder(r.Body), which reads as much as the +// client sends: without a cap, a single authenticated writer could post a +// multi-gigabyte body to /admin/new and have it buffered, stored verbatim and +// then fanned out to every SSE viewer of the channel. +// +// 2 MiB is far above any legitimate payload here — the largest is a scheduled +// message list, and message text is separately capped at 100k — while bounding +// what one request can allocate. +const maxJSONRequestBody = 2 << 20 + +// limitRequestBody applies that cap to every route, so a handler added later +// cannot forget it. The file upload route is exempt: it enforces its own, much +// larger, per-channel size limit in uploadFile. +func limitRequestBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil && !isUploadRoute(r.URL.Path) { + r.Body = http.MaxBytesReader(w, r.Body, maxJSONRequestBody) + } + next.ServeHTTP(w, r) + }) +} + +// isUploadRoute matches the one route that legitimately carries a large body, +// /api/channel/{slug}/admin/upload. The exemption is keyed on the route and not +// on the Content-Type, because the client chooses the Content-Type: sending +// "multipart/form-data" on any JSON route used to lift the cap there too, and +// the handler then decoded an unbounded body straight into memory. +func isUploadRoute(p string) bool { + return strings.HasPrefix(p, "/api/channel/") && strings.HasSuffix(p, "/admin/upload") +} + func serveSpaFile(w http.ResponseWriter, r *http.Request) { - htmlPath := filepath.Join(settingConfig.RootStaticFolder, "index.html") + // The SPA fallback must not answer for the API: a mistyped or removed API + // path returned index.html with 200, so an integrator (or a fetch in the + // frontend) saw success and then failed to parse HTML as JSON. + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/auth/") { + http.NotFound(w, r) + return + } + + cfg := getGlobalConfig() + if cfg == nil || cfg.RootStaticFolder == "" { + http.Error(w, "File not found", http.StatusNotFound) + return + } + htmlPath := filepath.Join(cfg.RootStaticFolder, "index.html") content, err := os.ReadFile(htmlPath) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } - if settingConfig.CustomTitle != "" { - content = bytes.ReplaceAll(content, []byte(""), []byte(settingConfig.CustomTitle)) + siteName := siteNameOf(cfg) + origin := requestOrigin(r, cfg) + meta := buildPageMeta(r.Context(), origin, siteName, r.URL.Path) + content = injectHeadTags(content, origin, siteName, r.URL.Path, meta, cfg) + + if cfg.AnalyticsHead != "" { + content = bytes.Replace(content, []byte(""), []byte(cfg.AnalyticsHead+""), 1) } + // index.html must never be cached without revalidation. Every deploy gives + // the bundles new content-hashed names, and this file is the only thing + // that says which names are current. Cached by a browser or an upstream + // proxy, it outlives the bundles it points at: the next visitor with a cold + // cache gets a stale document whose /assets/main-.js is gone, the + // script 404s, and app-root is left empty — a white page with no error the + // application could report, because the application never started. + // + // It is also rewritten per-deployment below (title, analytics), so a shared + // cache holding it would serve another operator's markup. + w.Header().Set("Cache-Control", "no-cache, must-revalidate") w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The meta tag only works for crawlers that parse the HTML; the header + // says the same to every one of them, before the body. + if meta.NoIndex { + w.Header().Set("X-Robots-Tag", "noindex") + } + // The app is served on a 404 too, so a person following a dead link + // still lands on something usable; the status is for crawlers. + w.WriteHeader(meta.Status) w.Write(content) } + +// hashedAssetRe matches the content-hashed filenames the frontend build emits +// (main-EE7HE3OC.js, styles-ZXVD3ZCM.css, media/NotoColorEmoji-HW4CGOE5.ttf). +// The hash changes whenever the contents do, so these are safe to cache +// forever; anything else under /assets (favicon.ico and friends) is not. +var hashedAssetRe = regexp.MustCompile(`-[A-Z0-9]{8}\.[a-zA-Z0-9]+$`) + +// noDirectoryListing refuses the index pages http.FileServer renders for a +// directory: /assets/media/ listed the whole bundle, which is nobody's +// business and a free inventory for anyone probing the deployment. +func noDirectoryListing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// staticCacheHeaders pairs the immutable bundles with the no-cache index.html +// above: the hashed files are what make it safe for index.html to be +// revalidated on every visit, since only the small document is ever refetched. +func staticCacheHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hashedAssetRe.MatchString(r.URL.Path) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + // Un-hashed and therefore replaceable in place: revalidate rather + // than pin an old copy for a year. + w.Header().Set("Cache-Control", "public, max-age=300") + } + next.ServeHTTP(w, r) + }) +} diff --git a/backend/messages.go b/backend/messages.go index 4f56b7e5..0ba34c42 100644 --- a/backend/messages.go +++ b/backend/messages.go @@ -6,16 +6,82 @@ import ( "fmt" "log" "net/http" + "regexp" "strconv" + "sync" + "sync/atomic" "time" "github.com/go-chi/chi" ) +// maxMessagesPerRequest caps the client-supplied page size for /messages. +const maxMessagesPerRequest = 100 + +// maxStreamReadFailures bounds how long an SSE connection keeps retrying a +// stream read that consistently fails before the handler gives up. +const maxStreamReadFailures = 5 + +// sseRedisPoolSize is the size of the SSE-only Redis pool (see db.go init). +// +// Connections are now held per *channel with at least one viewer*, not per +// viewer: sse_hub.go runs one reader per stream and fans out in process. This +// therefore bounds how many distinct channels can have live viewers at once on +// one instance, and a channel with ten thousand readers still costs one +// connection. +const sseRedisPoolSize = 500 + +// sseMaxConnections caps concurrent SSE viewers per instance. It is no longer +// tied to the Redis pool — a viewer costs a goroutine, a socket and a 256-event +// buffer, not a connection — so it exists only to bound memory and file +// descriptors, and sits far above the old ceiling. +const sseMaxConnections = 20000 + +// sseActiveConnections counts live SSE handlers on this instance. +var sseActiveConnections atomic.Int64 + +// sseMaxPerClient caps concurrent /events streams per client (session e-mail +// or address). The global cap alone let one anonymous client open streams +// until every other viewer was refused. Generous, because a large office NAT +// behind one X-Real-IP is many real viewers; one client with more open tabs +// than this to the same site is not. +const sseMaxPerClient = 100 + +var ( + sseClientMu sync.Mutex + sseClientConns = map[string]int{} +) + +// admitSSEClient reserves a per-client slot and returns how to release it, +// or false when the client already holds sseMaxPerClient streams. +func admitSSEClient(key string) (func(), bool) { + sseClientMu.Lock() + defer sseClientMu.Unlock() + if sseClientConns[key] >= sseMaxPerClient { + return nil, false + } + sseClientConns[key]++ + return func() { + sseClientMu.Lock() + defer sseClientMu.Unlock() + if sseClientConns[key] <= 1 { + delete(sseClientConns, key) + } else { + sseClientConns[key]-- + } + }, true +} + +// streamIDRegex matches a Redis stream entry ID ("" or "-"). +var streamIDRegex = regexp.MustCompile(`^\d+(-\d+)?$`) + func getMessages(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + ch := channelFromCtx(r) + offsetFromClient := r.URL.Query().Get("offset") limitFromClient := r.URL.Query().Get("limit") direction := r.URL.Query().Get("direction") @@ -25,28 +91,74 @@ func getMessages(w http.ResponseWriter, r *http.Request) { offset = 0 } + // The limit drives a blocking Lua scan (ZREVRANGE + one HGETALL per member) + // on a shared Redis, so an unbounded client value must not reach it. limit, err := strconv.Atoi(limitFromClient) - if err != nil { + if err != nil || limit <= 0 || limit > maxMessagesPerRequest { limit = 20 } - messages, err := funcGetMessageRange(ctx, int64(offset), int64(limit), checkPrivilege(r, Writer), settingConfig.CountViews, direction) + isAdmin := hasChannelRole(r, slug, RoleWriter) + countViews := ch != nil && ch.Features.CountViews + // Deleted posts are left out of the feed for everyone; staff can ask for + // them with ?deleted=1 to find and restore one. + showDeleted := isAdmin && r.URL.Query().Get("deleted") == "1" + + // ETag: skip expensive query if content hasn't changed since client's copy. + // The body varies by viewer (admins see real authors), so the validator + // has to carry those inputs and the response must never be reused across + // identities. The "op" marker retires validators + // minted before operator posts were re-labelled, whose cached bodies still + // name the operator. + w.Header().Set("Cache-Control", "private, no-cache") + w.Header().Set("Vary", "Cookie") + // No 304 while views are being counted: the short-circuit skips the query, + // and with it the per-message view increment, so repeat visitors to a + // quiet channel — the very population the counter is for — were never + // counted and kept seeing the numbers from their first visit. + if lm := getLastModified(ctx, slug); lm != "" && !countViews { + etag := `"` + lm + "-" + strconv.FormatBool(isAdmin) + "-" + strconv.FormatBool(countViews) + "-" + strconv.FormatBool(showDeleted) + `-op"` + w.Header().Set("ETag", etag) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + + messages, err := funcGetMessageRange(ctx, slug, int64(offset), int64(limit), isAdmin, countViews, showDeleted, direction) if err != nil { log.Printf("Failed to get messages: %v\n", err) http.Error(w, "error", http.StatusInternalServerError) return } + // Staff get real authors from the Lua, and posts an operator wrote before + // they were signed as operatorName still carry the operator's name and + // Google id. Everyone else already gets "Anonymous". + if isAdmin { + ops := currentOperators() + for i := range messages { + ops.anonymiseOperatorAuthor(&messages[i]) + } + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) - addViewsToMessages(ctx, messages) + addViewsToMessages(ctx, slug, countViews && !isCrawler(r), messages) } +// maxMessageTextLen is the server-side ceiling on a post's markdown, shared by +// every write path (composer, edit, import API, scheduled list) so no route can +// store a body the others would refuse. The composer itself stops far earlier; +// this only bounds what a hand-made request can push into every SSE client. +const maxMessageTextLen = 100_000 + func addMessage(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + var message Message var err error defer r.Body.Close() @@ -60,53 +172,156 @@ func addMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, "error", http.StatusBadRequest) return } + if len(body.Text) > maxMessageTextLen { + http.Error(w, "text too long", http.StatusBadRequest) + return + } + + if message.ID, err = getMessageNextId(ctx, slug); err != nil { + log.Printf("Failed to allocate message id: %v\n", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + recordAuthorID(ctx, user) - message.ID = getMessageNextId(ctx) message.Type = body.Type - message.Author = user.PublicName + message.Author = sessionDisplayName(user) message.AuthorId = user.ID + // An operator posting is the platform speaking, and the post is served to + // the channel's staff, its webhook and the event stream: none of them may + // learn who the operator is. + if isSuperAdmin(r) { + message.Author, message.AuthorId = operatorName, operatorAuthorId + } message.Timestamp = time.Now() message.Text = body.Text message.File = body.File message.Views = 0 message.IsAds = body.IsAds - if err = setMessage(ctx, &message, false); err != nil { + if err = setMessage(ctx, slug, &message, false); err != nil { log.Printf("Failed to set new message: %v\n", err) http.Error(w, "error", http.StatusInternalServerError) return } - go SendWebhook(context.Background(), "create", &message) - go pushFcmMessage(&message) + go SendWebhook(context.Background(), slug, "create", &message) + go pushFcmMessage(slug, &message) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(message) } +// sessionUserID is the Google subject of the signed-in user, or "" when there +// is no session. +func sessionUserID(r *http.Request) string { + session, _ := store.Get(r, cookieName) + user, ok := session.Values["user"].(Session) + if !ok { + return "" + } + return user.ID +} + +// canModifyMessage reports whether the current session may edit or delete a +// message written by authorId. Writers may only touch their own posts; +// moderators (and above, including super admins) may touch any message. +func canModifyMessage(r *http.Request, slug, authorId string) bool { + if hasChannelRole(r, slug, RoleModerator) { + return true + } + // System posts have no real author: the scheduler stamps "0" and the API + // import path never sets the field at all. They are channel content rather + // than anyone's personal post, so any writer may manage them — the caller is + // already behind protectedWithChannelRole(RoleWriter, ...). + if authorId == "" || authorId == "0" { + return true + } + session, _ := store.Get(r, cookieName) + user, ok := session.Values["user"].(Session) + if !ok || user.ID == "" { + return false + } + return authorId == user.ID +} + func updateMessage(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + var err error defer r.Body.Close() body := Message{} if err = json.NewDecoder(r.Body).Decode(&body); err != nil { - response := Response{Success: false} - json.NewEncoder(w).Encode(response) + log.Printf("Failed to decode message: %v\n", err) + http.Error(w, "error", http.StatusBadRequest) return } + if len(body.Text) > maxMessageTextLen { + http.Error(w, "text too long", http.StatusBadRequest) + return + } + + // The message must already exist: setMessage would otherwise happily create + // an unindexed hash from whatever ID the client sent. + stored, err := dbGetMessageFields(ctx, slug, strconv.Itoa(body.ID)) + if err != nil { + http.Error(w, "message not found", http.StatusNotFound) + return + } + + if !canModifyMessage(r, slug, stored["authorId"]) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Republishing a soft-deleted post is a moderation decision. The composer + // always sends deleted:false, so re-saving was enough for a writer to undo + // a moderator's takedown of their own message. A writer may still restore + // what they deleted themselves; deletedBy is empty on posts deleted before + // it was recorded, and those keep the old behaviour. + if stored["deleted"] == "1" && !body.Deleted && !hasChannelRole(r, slug, RoleModerator) { + if by := stored["deletedBy"]; by != "" && by != sessionUserID(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + + // Only the body of the message is editable — identity, ordering and counters + // stay as stored, so they cannot be forged through the request payload. + // Deleted is deliberately NOT restored: re-saving a deleted message is how + // the composer republishes it, which the edit form promises to the user. + body.Author = stored["author"] + body.AuthorId = stored["authorId"] + // Re-saving an operator post written before operators were anonymised + // would re-broadcast their identity; store it re-labelled instead. + currentOperators().anonymiseOperatorAuthor(&body) + body.Views, _ = strconv.Atoi(stored["views"]) + if ts, perr := time.Parse(time.RFC3339Nano, stored["timestamp"]); perr == nil { + body.Timestamp = ts + } + body.Reactions = nil + if raw := stored["reactions"]; raw != "" { + var reactions Reactions + if json.Unmarshal([]byte(raw), &reactions) == nil { + body.Reactions = reactions + } + } body.LastEdit = time.Now() - if err := setMessage(ctx, &body, true); err != nil { - response := Response{Success: false} - json.NewEncoder(w).Encode(response) + // A storage failure must not read as success: the client replaces the + // composer contents on a 2xx, so the user's edit would be lost silently. + if err := setMessage(ctx, slug, &body, true); err != nil { + log.Printf("Failed to update message: %v\n", err) + http.Error(w, "error", http.StatusInternalServerError) return } - go SendWebhook(context.Background(), "update", &body) + go SendWebhook(context.Background(), slug, "update", &body) response := Response{Success: true} json.NewEncoder(w).Encode(response) @@ -116,23 +331,41 @@ func deleteMessage(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) id := chi.URLParam(r, "id") + stored, err := dbGetMessageFields(ctx, slug, id) + if err != nil { + http.Error(w, "message not found", http.StatusNotFound) + return + } + + if !canModifyMessage(r, slug, stored["authorId"]) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + idInt, _ := strconv.Atoi(id) message := Message{ID: idInt, Deleted: true} - if err := funcDeleteMessage(ctx, id); err != nil { - response := Response{Success: false} - json.NewEncoder(w).Encode(response) + if err := funcDeleteMessage(ctx, slug, id, sessionUserID(r)); err != nil { + log.Printf("Failed to delete message: %v\n", err) + http.Error(w, "error", http.StatusInternalServerError) return } - go SendWebhook(context.Background(), "delete", &message) + go SendWebhook(context.Background(), slug, "delete", &message) response := Response{Success: true} json.NewEncoder(w).Encode(response) } +// getEvents serves a Server-Sent Events stream backed by a Redis Stream. +// +// Using Redis Streams instead of pub/sub enables: +// - Horizontal scaling: multiple backend instances can all serve SSE +// - Reconnection: clients send Last-Event-ID to resume without missing events +// - Durability: stream retains the last ~1000 events (configurable in publishEvent) func getEvents(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { @@ -140,54 +373,185 @@ func getEvents(w http.ResponseWriter, r *http.Request) { return } - clientCtx := r.Context() - heartbeat := time.NewTicker(25 * time.Second) - defer heartbeat.Stop() + // Admit before doing any work, and release on every exit path. + if n := sseActiveConnections.Add(1); n > sseMaxConnections { + sseActiveConnections.Add(-1) + w.Header().Set("Retry-After", "5") + http.Error(w, "too many live connections, please retry", http.StatusServiceUnavailable) + return + } + defer sseActiveConnections.Add(-1) + releaseClient, ok := admitSSEClient(clientKey(r)) + if !ok { + w.Header().Set("Retry-After", "5") + http.Error(w, "too many live connections from this client", http.StatusTooManyRequests) + return + } + defer releaseClient() + + slug := channelSlugFromCtx(r) + streamKey := fmt.Sprintf("channel:%s:events", slug) + + // The stream stores the full payload including real author identity; the + // /messages Lua deliberately anonymizes author/authorId for anyone below + // writer, and this stream must not leak what that endpoint withholds. The + // identity (session e-mail) is fixed per connection and resolved once; the + // role is looked up live at send time, because a moderator or writer + // demoted mid-connection kept seeing real names until they reconnected. + viewerEmail := "" + if s, ok := sessionEmail(r); ok { + viewerEmail = normEmail(s.Email) + } + isWriter := func() bool { return emailHasChannelRole(viewerEmail, slug, RoleWriter) } + + // Support SSE reconnection: browser sends Last-Event-ID with the stream entry ID + // from the last event it received. On fresh connect, start from "now". + // A malformed value would make every XREAD fail, turning the loop below into + // a busy retry against Redis, so anything that is not a stream ID is ignored. + lastID := r.Header.Get("Last-Event-ID") + if lastID == "" { + // The client's heartbeat watchdog re-creates the EventSource by hand, + // and a new EventSource carries no Last-Event-ID, so it resumed from + // the tip and lost every edit, delete and reaction of the gap. It + // passes the last id it saw as a query parameter instead. + lastID = r.URL.Query().Get("last_id") + } + if !streamIDRegex.MatchString(lastID) { + lastID = "$" + } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") - _, err := fmt.Fprintf(w, "data: {\"type\": \"heartbeat\"}\n\n") - if err != nil { + if _, err := fmt.Fprintf(w, "data: {\"type\": \"heartbeat\"}\n\n"); err != nil { return } flusher.Flush() - go increaseCounterSSE() - defer decreaseCounterSSE() + // Increment synchronously: the deferred decrement runs as soon as the loop + // notices a dead client, which can happen before a spawned goroutine is even + // scheduled, leaving the counter one below where it started. + increaseCounterSSE(slug) + defer decreaseCounterSSE(slug) - pubsub := rdb.Subscribe(r.Context(), "events") - defer pubsub.Close() + clientCtx := r.Context() + const heartbeatInterval = 25 * time.Second + + // The server runs with no WriteTimeout (an SSE response never ends), so a + // peer that stops reading — a backgrounded mobile browser behind a proxy + // that has stopped draining — left this goroutine blocked inside a write + // until TCP itself gave up, some fifteen minutes later, holding a + // connection slot the whole time. A per-write deadline turns that into a + // write error, and the browser reconnects. + const sseWriteTimeout = 30 * time.Second + rc := http.NewResponseController(w) + armWrite := func() { _ = rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)) } + + // Join the channel's shared reader BEFORE replaying history, so an event + // published during the replay is buffered rather than missed. The replay is + // then deduplicated against what the hub delivers, using the stream ids. + sub, unsubscribe := sseSubscribe(streamKey) + defer unsubscribe() + + // channelMiddleware admitted this request a moment ago, but a delete or + // disable can land in between: the control event is then already the + // stream tip, a hub built now starts past it and never reads it, and + // catch-up skips it by design. Re-read the flag once, after subscribing, + // so such a viewer is turned away instead of holding a live-looking page + // on a channel that is gone (another replica's hub is the only one that + // would otherwise outlive it). + { + rctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + ch, err := dbGetChannel(rctx, slug) + cancel() + if err != nil || ch == nil || ch.Features.Disabled { + return + } + } - if _, err := pubsub.Receive(clientCtx); err != nil { - http.Error(w, "Failed to subscribe to events", http.StatusInternalServerError) - return + send := func(ev sseEvent) bool { + data := ev.data + if !isWriter() { + data = maskEventAuthor(data) + } else { + // Staff see real authors, but never an operator's: events stored + // before operators were anonymised still name them. The snapshot is + // per event so an operator added mid-connection is covered. + data = currentOperators().anonymiseOperatorEvent(data) + } + armWrite() + if _, err := fmt.Fprintf(w, "id: %s\ndata: %s\n\n", ev.id, data); err != nil { + return false + } + flusher.Flush() + return true } - ch := pubsub.Channel() + // A reconnecting client carries Last-Event-ID; the hub reads from the tip, + // so whatever it missed while away is replayed from the stream here. The + // replay carries no channel-deleted/disabled entry (see sseCatchUp): this + // request passed channelMiddleware, so one left in the stream is stale. + replayedTo := "" + for _, ev := range sseCatchUp(clientCtx, streamKey, lastID) { + if !send(ev) { + return + } + replayedTo = ev.id + } + + heartbeat := time.NewTicker(heartbeatInterval) + defer heartbeat.Stop() + for { select { case <-clientCtx.Done(): return - case <-heartbeat.C: - _, err := fmt.Fprintf(w, "data: {\"type\": \"heartbeat\"}\n\n") - if err != nil { + case ev, ok := <-sub.ch: + if !ok { + // The hub stopped, or this connection fell too far behind and was + // dropped. Returning ends the response; the browser reconnects + // with Last-Event-ID and resumes. return } - flusher.Flush() - - case msg, ok := <-ch: - if !ok { + // Skip anything the catch-up above already delivered. + if replayedTo != "" && streamIDLessOrEqual(ev.id, replayedTo) { + continue + } + if !send(ev) { return } - _, err := fmt.Fprintf(w, "data: %s\n\n", msg.Payload) - if err != nil { + + case <-heartbeat.C: + armWrite() + if _, err := fmt.Fprintf(w, "data: {\"type\": \"heartbeat\"}\n\n"); err != nil { return } flusher.Flush() } } } + +// maskEventAuthor rewrites an event payload the way the /messages Lua does for +// sub-writer viewers: author becomes "Anonymous" and authorId is blanked. Kept +// cheap: the payload is only re-marshalled when there is something to mask, and +// anything that is not a PushMessage (heartbeats, malformed entries) passes +// through untouched. +func maskEventAuthor(data string) string { + var pm PushMessage + if err := json.Unmarshal([]byte(data), &pm); err != nil { + return data + } + if pm.M.Author == "" && pm.M.AuthorId == "" { + return data + } + pm.M.Author = "Anonymous" + pm.M.AuthorId = "" + masked, err := json.Marshal(pm) + if err != nil { + return data + } + return string(masked) +} diff --git a/backend/messages_scan_test.go b/backend/messages_scan_test.go new file mode 100644 index 00000000..54ad4452 --- /dev/null +++ b/backend/messages_scan_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/redis/go-redis/v9" +) + +// seedMessages writes n messages into a channel, marking every message whose id +// satisfies deleted(id) as soft-deleted. +func seedMessages(t *testing.T, ctx context.Context, slug string, n int, deleted func(int) bool) { + t.Helper() + timesKey := "channel:" + slug + ":m_times" + for i := 1; i <= n; i++ { + key := "channel:" + slug + ":messages:" + strconv.Itoa(i) + fields := map[string]any{ + "id": strconv.Itoa(i), + "text": "message " + strconv.Itoa(i), + "author": "someone", + "views": "0", + } + if deleted(i) { + fields["deleted"] = "1" + } + if err := rdb.HSet(ctx, key, fields).Err(); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + if err := rdb.ZAdd(ctx, timesKey, redis.Z{Score: float64(i), Member: key}).Err(); err != nil { + t.Fatalf("index message %d: %v", i, err) + } + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for i := 1; i <= n; i++ { + rdb.Del(cctx, "channel:"+slug+":messages:"+strconv.Itoa(i)) + } + rdb.Del(cctx, timesKey) + }) +} + +// An edit must not write back the counters it read: views (HINCRBY on every +// page view) and reactions (toggled by viewers) change underneath the edit +// handler, and a full-struct HSET replayed the stale copies over them. +func TestSetMessageUpdateLeavesCountersAlone(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "edit-counters" + seedMessages(t, ctx, slug, 1, func(int) bool { return false }) + key := "channel:" + slug + ":messages:1" + t.Cleanup(func() { + cctx, c := context.WithTimeout(context.Background(), 30*time.Second) + defer c() + rdb.Del(cctx, "channel:"+slug+":events", "channel:"+slug+":last_modified") + }) + + // The edit handler read the hash here (views 0, no reactions)... + m := Message{ID: 1, Type: "md", Text: "edited", Views: 0, LastEdit: time.Now()} + // ...and a viewer's page load and reaction land before it writes. + if err := rdb.HIncrBy(ctx, key, "views", 3).Err(); err != nil { + t.Fatal(err) + } + if err := rdb.HSet(ctx, key, "reactions", `{"👍":1}`).Err(); err != nil { + t.Fatal(err) + } + + if err := setMessage(ctx, slug, &m, true); err != nil { + t.Fatalf("update: %v", err) + } + stored, err := dbGetMessageFields(ctx, slug, "1") + if err != nil { + t.Fatalf("read back: %v", err) + } + if stored["views"] != "3" { + t.Errorf("views = %q after the edit, want the 3 views recorded meanwhile", stored["views"]) + } + if stored["reactions"] != `{"👍":1}` { + t.Errorf("reactions = %q after the edit, want the reaction cast meanwhile", stored["reactions"]) + } + if stored["text"] != "edited" || stored["type"] != "md" { + t.Errorf("edit not applied: text=%q type=%q", stored["text"], stored["type"]) + } + if stored["author"] != "someone" { + t.Errorf("author = %q, want untouched", stored["author"]) + } + // An edit must not re-index the message either. + if score, err := rdb.ZScore(ctx, "channel:"+slug+":m_times", key).Result(); err != nil || score != 1 { + t.Errorf("m_times score = %v (%v), want the original 1", score, err) + } +} + +// Ordinary paging must be unchanged by the scan bound: a page of live messages +// still comes back full, newest first. +func TestMessageRangeReturnsFullPageOfLiveMessages(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "scan-live" + seedMessages(t, ctx, slug, 60, func(int) bool { return false }) + + msgs, err := funcGetMessageRange(ctx, slug, 0, 20, false, false, false, "desc") + if err != nil { + t.Fatalf("range: %v", err) + } + if len(msgs) != 20 { + t.Fatalf("got %d messages, want a full page of 20", len(msgs)) + } + // desc: newest first. + if msgs[0].ID != 60 { + t.Errorf("first message id = %d, want 60 (newest first)", msgs[0].ID) + } + if msgs[19].ID != 41 { + t.Errorf("last message id = %d, want 41", msgs[19].ID) + } +} + +// Soft-deleted messages are skipped for a non-admin, and the scan still reaches +// past a short run of them to fill the page. +func TestMessageRangeSkipsDeletedAndStillFillsPage(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "scan-mixed" + // Every other message deleted: filling a page of 10 needs ~20 entries. + seedMessages(t, ctx, slug, 100, func(i int) bool { return i%2 == 0 }) + + msgs, err := funcGetMessageRange(ctx, slug, 0, 10, false, false, false, "desc") + if err != nil { + t.Fatalf("range: %v", err) + } + if len(msgs) != 10 { + t.Fatalf("got %d messages, want 10: the scan should look past tombstones", len(msgs)) + } + for _, m := range msgs { + if m.ID%2 == 0 { + t.Errorf("message %d is soft-deleted and must not be returned to a non-admin", m.ID) + } + } +} + +// The defect: a long run of tombstones meant #messages never grew, so +// batch_size stayed at the full page size for every one of 20 rounds — up to +// 2000 HGETALLs in a single atomic script, blocking every other tenant. +// +// The scan is now bounded by entries examined. This asserts the request still +// returns promptly and correctly over a large tombstone field rather than doing +// unbounded work. +func TestMessageRangeBoundsWorkOverLongTombstoneRun(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "scan-tombstones" + // 900 consecutive deleted messages, then 40 live ones underneath. + const total = 940 + seedMessages(t, ctx, slug, total, func(i int) bool { return i > 40 }) + + start := time.Now() + msgs, err := funcGetMessageRange(ctx, slug, 0, 100, false, false, false, "desc") + elapsed := time.Since(start) + if err != nil { + t.Fatalf("range: %v", err) + } + + t.Logf("scanned a %d-entry tombstone run in %v, returned %d messages", total-40, elapsed, len(msgs)) + + // Whatever it returns must be correct: never a deleted message. + for _, m := range msgs { + if m.ID > 40 { + t.Errorf("returned soft-deleted message %d", m.ID) + } + } + // The point of the bound is that one request cannot monopolise Redis. + if elapsed > 2*time.Second { + t.Errorf("request took %v over a tombstone run: the scan is not bounded", elapsed) + } +} + +// Asking for a page when nothing is stored must be empty, not an error. +func TestMessageRangeEmptyChannel(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + msgs, err := funcGetMessageRange(ctx, "scan-empty-channel", 0, 20, false, false, false, "desc") + if err != nil { + t.Fatalf("range on empty channel: %v", err) + } + if len(msgs) != 0 { + t.Errorf("got %d messages from an empty channel", len(msgs)) + } +} + +// A deleted message is hidden from writers and admins too, not only readers: +// shown greyed out, it looked as if the delete had not gone through. +func TestMessageRangeHidesDeletedFromAdmins(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "scan-admin-deleted" + seedMessages(t, ctx, slug, 20, func(i int) bool { return i%2 == 0 }) + + msgs, err := funcGetMessageRange(ctx, slug, 0, 10, true, false, false, "desc") + if err != nil { + t.Fatalf("range: %v", err) + } + if len(msgs) != 10 { + t.Fatalf("got %d messages, want 10", len(msgs)) + } + for _, m := range msgs { + if m.ID%2 == 0 || m.Deleted { + t.Errorf("message %d is soft-deleted and must not be returned to an admin", m.ID) + } + } +} + +// The staff "show deleted" toggle brings them back, flagged as deleted. +func TestMessageRangeShowDeletedReturnsTombstones(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "scan-show-deleted" + seedMessages(t, ctx, slug, 20, func(i int) bool { return i%2 == 0 }) + + msgs, err := funcGetMessageRange(ctx, slug, 0, 20, true, false, true, "desc") + if err != nil { + t.Fatalf("range: %v", err) + } + if len(msgs) != 20 { + t.Fatalf("got %d messages, want all 20", len(msgs)) + } + for _, m := range msgs { + if m.Deleted != (m.ID%2 == 0) { + t.Errorf("message %d: deleted = %v", m.ID, m.Deleted) + } + } +} diff --git a/backend/migrations.go b/backend/migrations.go new file mode 100644 index 00000000..48d6d2c8 --- /dev/null +++ b/backend/migrations.go @@ -0,0 +1,628 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "mime" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/redis/go-redis/v9" +) + +// migrationsAppliedKey holds the set of migration IDs that have already run. +const migrationsAppliedKey = "migrations:applied" + +// migrationsR2CopiedKey holds the blob hashes the R2 copy has already put in +// the bucket, so a retry is O(remaining) instead of one HEAD per blob per boot. +const migrationsR2CopiedKey = "migrations:r2-copied" + +// migrationLockTTL bounds the per-migration claim (see runMigrations): a +// replica killed mid-migration leaves its lock behind, and until it expires +// every restart skips that migration, so it is only slightly longer than the +// budget a run can actually use. +const migrationLockTTL = migrationBudget + time.Minute + +var migrationUnlock = redis.NewScript(` + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + end + return 0 +`) + +// budgetExhausted turns an expired migration context into the error that +// leaves the migration unmarked. Every per-record loop checks it: without +// this, a deadline that landed mid-run made the remaining reads fail as +// "skipped" records and the function returned nil, marking the migration +// applied with most of the corpus untouched. +func budgetExhausted(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("boot budget exhausted, will continue on next start: %w", err) + } + return nil +} + +// channelFeaturesBackfillID is the one-shot backfill that derives the +// operator-controlled ChannelFeatures toggles from what each channel was +// actually configured to do before those toggles were enforced. +const channelFeaturesBackfillID = "2026-08-channel-features-backfill" + +// fileChannelSlugBackfillID is the one-shot backfill that stamps the owning +// channel onto file records written before FileMetadata had a ChannelSlug. +const fileChannelSlugBackfillID = "2026-08-file-channel-slug-backfill" + +// fileSlugFromMessagesID is the second file-ownership pass: it derives ownership +// from message content and channel logos, catching every legacy file that is in +// no channel::files index (the index and ChannelSlug were introduced in +// the same commit, so index-only iteration visits essentially no legacy record). +const fileSlugFromMessagesID = "2026-08-file-slug-from-messages" + +// channelFeaturesBackfillV2ID converges environments where the v1 backfill +// already ran with its old conditional logic (which enabled Webhook/ +// Notifications/Ads only for channels using them at migration time, leaving +// late adopters silently dead). It unconditionally enables the same seven +// toggles the creation paths default to true. +const channelFeaturesBackfillV2ID = "2026-08-channel-features-backfill-v2" + +// localFilesToR2ID copies every blob that was written to local disk while R2 was +// off into the bucket, so switching R2 on does not orphan the whole historical +// corpus. +const localFilesToR2ID = "2026-08-local-files-to-r2" + +// supportMatchIndexID indexes the support tickets written before inbound +// mail existed by their email (support_inbound.go). +const supportMatchIndexID = "2026-10-support-match-index" + +// errMigrationSkipped tells runMigrations that a migration deliberately did no +// work and must NOT be marked applied — the loop marks every nil return as +// applied, so a plain nil would burn the one-shot on a boot where the migration +// could not run yet (R2 not configured). It is logged as a skip, not a failure. +var errMigrationSkipped = errors.New("preconditions not met, will retry on next start") + +// runMigrations applies any data migration that has not run yet. +// +// It is deliberately non-fatal: a migration that fails is simply left unmarked +// and retried on the next boot, rather than taking the server down. Callers run +// it before the HTTP listener starts, so a successful run is always complete +// before the first request is served. +func runMigrations(ctx context.Context) { + if os.Getenv("SKIP_MIGRATIONS") == "1" { + log.Println("migrations: skipped (SKIP_MIGRATIONS=1)") + return + } + + migrations := []struct { + id string + run func(context.Context) error + }{ + {channelFeaturesBackfillID, backfillChannelFeatures}, + {fileChannelSlugBackfillID, backfillFileChannelSlugs}, + {fileSlugFromMessagesID, backfillFileSlugsFromMessages}, + {channelFeaturesBackfillV2ID, backfillChannelFeaturesV2}, + {localFilesToR2ID, migrateLocalFilesToR2}, + {supportMatchIndexID, backfillSupportMatchIndex}, + } + + for _, m := range migrations { + // Each migration gets the whole budget: sharing one across the list + // meant a long message backfill on a large corpus ate the time of + // every migration after it, and the last ones failed on every boot. + ctx, cancelMigration := context.WithTimeout(context.WithoutCancel(ctx), migrationBudget) + defer cancelMigration() + + applied, err := rdb.SIsMember(ctx, migrationsAppliedKey, m.id).Result() + if err != nil { + log.Printf("migrations: cannot read applied set, skipping %s this boot: %v", m.id, err) + continue + } + if applied { + continue + } + + // Two replicas booting together both saw "not applied" and both ran + // the migration; the slower one then wrote its stale snapshot over + // changes a super admin had already made through the faster one. + // Claim the migration first, and re-check once the claim is held. + lockKey := "migrations:lock:" + m.id + token := generatedRandomID(16) + claimed, err := rdb.SetNX(ctx, lockKey, token, migrationLockTTL).Result() + if err != nil || !claimed { + log.Printf("migrations: %s is being run by another replica (or the lock could not be taken: %v), skipping this boot", m.id, err) + continue + } + if applied, err := rdb.SIsMember(ctx, migrationsAppliedKey, m.id).Result(); err != nil || applied { + migrationUnlock.Run(ctx, rdb, []string{lockKey}, token) + continue + } + + log.Printf("migrations: running %s", m.id) + err = m.run(ctx) + migrationUnlock.Run(context.WithoutCancel(ctx), rdb, []string{lockKey}, token) + if err != nil { + // A deliberate skip is not a failure, but it must leave the ID + // unmarked just the same so the migration still runs later. + if errors.Is(err, errMigrationSkipped) { + log.Printf("migrations: %s skipped this boot (not marked applied): %v", m.id, err) + continue + } + log.Printf("migrations: %s FAILED (will retry on next start): %v", m.id, err) + continue + } + if err := rdb.SAdd(ctx, migrationsAppliedKey, m.id).Err(); err != nil { + log.Printf("migrations: %s succeeded but could not be marked applied, it will run again: %v", m.id, err) + continue + } + log.Printf("migrations: %s completed", m.id) + } +} + +// settingBool reports whether key is present and truthy, using the same +// parsing the rest of the app uses (booleans are persisted as "1"). +func settingBool(settings Settings, key string) bool { + for i := range settings { + if settings[i].Key == key { + return settings[i].GetBool() + } + } + return false +} + +// backfillChannelFeatures turns on the ChannelFeatures toggles that were never +// enforced before the flags shipped. +// +// Why this is needed: createChannel and the since-removed request approval only ever set +// Reactions, FileUploads, Reports and ScheduledMessages, so Ads, Notifications +// and Webhook are false on every channel that exists today. Those three were +// never enforced, so channels have been happily using them with the flag off. +// Enforcing them without this backfill would switch the features off platform +// wide. +// +// All seven toggles are enabled unconditionally, matching the creation-path +// defaults: the flags are pure super-admin kill switches, and a channel that +// first configures a webhook/push/ad AFTER the deploy must not find the feature +// silently dead because it happened to be unconfigured at migration time. +// +// The migration only ever turns a flag ON. It runs exactly once, so a super +// admin who later turns something off is never overridden. +func backfillChannelFeatures(ctx context.Context) error { + channels, err := dbListChannels(ctx) + if err != nil { + return fmt.Errorf("list channels: %w", err) + } + + // require_auth / require_auth_for_view_files / count_views were removed from + // the owner settings form, so any surviving value is leftover configuration + // the owner can neither see nor undo. Honouring it would wall a channel off + // or start 401ing file requests that serve today, with no way back through + // the UI — unlike the ads/notifications/webhook part above, which only keeps + // live behaviour alive. So it is opt-in, for an operator who knows the + // leftovers are still what their owners want. + applyAuthFlags := os.Getenv("MIGRATION_APPLY_AUTH_FLAGS") == "1" + if applyAuthFlags { + log.Println("migrations: MIGRATION_APPLY_AUTH_FLAGS=1, applying leftover auth/view settings to features") + } + + var changed int + for _, ch := range channels { + if err := budgetExhausted(ctx); err != nil { + return err + } + settings, err := dbGetSettings(ctx, ch.Slug) + if err != nil { + // Do not mark the migration applied if we could not read a channel. + return fmt.Errorf("get settings for %s: %w", ch.Slug, err) + } + + before := ch.Features + f := ch.Features + + // Reactions, uploads, reports and scheduling were available to everyone + // before requireFeature started gating them, and nothing outside + // dbCreateChannel ever set them — so a channel whose features blob was + // written before that would silently lose all four on the first boot + // after the gates ship. Webhook, Notifications and Ads were likewise + // unenforced pre-deploy, so enabling them preserves live behaviour for + // every channel and keeps them pure super-admin kill switches. + f.Reactions = true + f.FileUploads = true + f.Reports = true + f.ScheduledMessages = true + f.Webhook = true + f.Notifications = true + f.Ads = true + + if applyAuthFlags { + // These three keys were offered in the channel settings UI but the + // backend only ever read the Features equivalents, so the owner's + // choice has silently had no effect until now. + if settingBool(settings, "count_views") { + f.CountViews = true + } + if settingBool(settings, "require_auth") { + f.RequireAuth = true + } + if settingBool(settings, "require_auth_for_view_files") { + f.RequireAuthFiles = true + } + } + + if f == before { + continue + } + + if err := dbSetChannelFeatures(ctx, ch.Slug, &f); err != nil { + return fmt.Errorf("set features for %s: %w", ch.Slug, err) + } + changed++ + log.Printf("migrations: %s features updated: %+v -> %+v", ch.Slug, before, f) + } + + log.Printf("migrations: channel features backfill touched %d of %d channels", changed, len(channels)) + return nil +} + +// backfillChannelFeaturesV2 unconditionally enables the seven default-on +// toggles. It exists because v1 originally enabled Webhook/Notifications/Ads +// only for channels using them at migration time; environments where that +// version already ran (and marked itself applied) still converge through this +// second pass. Like v1 it only ever turns flags ON and runs exactly once, so a +// super admin who turns something off afterwards is never overridden. +func backfillChannelFeaturesV2(ctx context.Context) error { + channels, err := dbListChannels(ctx) + if err != nil { + return fmt.Errorf("list channels: %w", err) + } + + var changed int + for _, ch := range channels { + if err := budgetExhausted(ctx); err != nil { + return err + } + before := ch.Features + f := ch.Features + f.Reactions = true + f.FileUploads = true + f.Reports = true + f.ScheduledMessages = true + f.Webhook = true + f.Notifications = true + f.Ads = true + + if f == before { + continue + } + if err := dbSetChannelFeatures(ctx, ch.Slug, &f); err != nil { + return fmt.Errorf("set features for %s: %w", ch.Slug, err) + } + changed++ + log.Printf("migrations: %s features updated: %+v -> %+v", ch.Slug, before, f) + } + + log.Printf("migrations: channel features backfill v2 touched %d of %d channels", changed, len(channels)) + return nil +} + +// backfillFileChannelSlugs stamps the owning channel onto file records that +// predate FileMetadata.ChannelSlug. +// +// Why this is needed: fileVisibleToChannel now denies any record whose +// ChannelSlug does not match the channel serving it, and every file uploaded +// before that field existed has an empty one. Without this backfill, historical +// attachments and channel logos 404 on the first boot after deploy, and the only +// way out is ALLOW_LEGACY_UNSCOPED_FILES, which re-opens the cross-tenant read +// the isolation check was written to close. +// +// Ownership is taken from the per-channel file index each record is already +// listed in, so nothing is guessed. Records that already carry a slug are left +// alone. As with the features backfill, a read/write failure returns the error +// and leaves the ID unmarked so the whole thing is retried on the next boot. +func backfillFileChannelSlugs(ctx context.Context) error { + channels, err := dbListChannels(ctx) + if err != nil { + return fmt.Errorf("list channels: %w", err) + } + + var stamped int + for _, ch := range channels { + members, err := rdb.ZRange(ctx, channelFilesKey(ch.Slug), 0, -1).Result() + if err != nil { + return fmt.Errorf("list files for %s: %w", ch.Slug, err) + } + + for _, m := range members { + if err := budgetExhausted(ctx); err != nil { + return err + } + fileID, _, ok := decodeFileMember(m) + if !ok { + continue + } + // dbGetFileMetadata's YAML fallback slices id[:4]; a malformed short + // member must be skipped, not crash-loop the boot (see db.go's + // identical guard in dbDeleteChannel). + if len(fileID) < 4 { + log.Printf("migrations: %s: malformed file index member %q, skipping", ch.Slug, m) + continue + } + + meta, err := dbGetFileMetadata(ctx, fileID) + if err != nil { + if berr := budgetExhausted(ctx); berr != nil { + return berr + } + // A tracked id whose metadata is gone (neither in Redis nor on + // disk) is index debris, not a reason to abandon the run. + log.Printf("migrations: %s: no metadata for tracked file %s, skipping", ch.Slug, fileID) + continue + } + if meta.ChannelSlug != "" { + continue + } + + // A YAML-era record reaches this point through the fallback path, + // which does not populate ChannelSlug either; saving it promotes it + // into Redis with the owner attached. + meta.ChannelSlug = ch.Slug + if err := dbSaveFileMetadata(ctx, meta); err != nil { + return fmt.Errorf("save metadata for %s: %w", fileID, err) + } + stamped++ + } + } + + log.Printf("migrations: stamped channel ownership onto %d legacy file records across %d channels", stamped, len(channels)) + return nil +} + +// fileRefRegexes extract file IDs from message text: the channel-scoped URL +// uploads have produced since files became per-channel, and the legacy global +// one older messages still embed. +var ( + channelFileRefRegex = regexp.MustCompile(`/api/channel/[a-z0-9\-]+/files/([0-9a-fA-F]{4,})`) + legacyFileRefRegex = regexp.MustCompile(`/api/files/([0-9a-fA-F]{4,})`) +) + +// backfillFileSlugsFromMessages is the second ownership pass. The index-based +// backfill above visits only channel::files members, but the index and +// FileMetadata.ChannelSlug were introduced by the same commit — so every +// genuinely legacy record (YAML-era files and pre-index Redis records) is in no +// index at all and would 404 forever. This pass derives ownership from what the +// channels actually reference: file URLs embedded in message text and each +// channel's logo. Records that already carry a slug are left alone; IDs +// referenced by no channel stay denied. +func backfillFileSlugsFromMessages(ctx context.Context) error { + channels, err := dbListChannels(ctx) + if err != nil { + return fmt.Errorf("list channels: %w", err) + } + + var stamped int + stampFile := func(slug, fileID string) error { + if len(fileID) < 4 { + return nil + } + meta, err := dbGetFileMetadata(ctx, fileID) + if err != nil { + // A referenced id with no surviving metadata anywhere is debris, + // not a reason to abandon the run. + return nil + } + if meta.ChannelSlug != "" { + return nil + } + // A YAML-era record reaches this point through the fallback path; + // saving it promotes it into Redis with the owner attached. + meta.ChannelSlug = slug + if err := dbSaveFileMetadata(ctx, meta); err != nil { + return fmt.Errorf("save metadata for %s: %w", fileID, err) + } + stamped++ + return nil + } + + for _, ch := range channels { + // The channel's logo is a file reference too. + if ch.LogoUrl != "" { + for _, re := range []*regexp.Regexp{channelFileRefRegex, legacyFileRefRegex} { + for _, m := range re.FindAllStringSubmatch(ch.LogoUrl, -1) { + if err := stampFile(ch.Slug, m[1]); err != nil { + return err + } + } + } + } + + // m_times members are the full message hash keys. + messageKeys, err := rdb.ZRange(ctx, fmt.Sprintf("channel:%s:m_times", ch.Slug), 0, -1).Result() + if err != nil { + return fmt.Errorf("list messages for %s: %w", ch.Slug, err) + } + for _, mk := range messageKeys { + if err := budgetExhausted(ctx); err != nil { + return err + } + text, err := rdb.HGet(ctx, mk, "text").Result() + if err != nil { + if err == redis.Nil { + continue + } + return fmt.Errorf("read message %s: %w", mk, err) + } + for _, re := range []*regexp.Regexp{channelFileRefRegex, legacyFileRefRegex} { + for _, m := range re.FindAllStringSubmatch(text, -1) { + if err := stampFile(ch.Slug, m[1]); err != nil { + return err + } + } + } + } + } + + log.Printf("migrations: stamped channel ownership onto %d legacy file records referenced by messages/logos", stamped) + return nil +} + +// migrateLocalFilesToR2 copies every blob still living under rootUploadPath into +// the R2 bucket. +// +// Why this is needed: uploads taken while R2 was off were written to local disk +// only, and serveFile with R2 on looks the blob up by its object key. Without +// this pass every historical attachment and logo depends on the local fallback +// forever — and on CapRover, where /app/files is the container's writable layer, +// that fallback disappears on the next deploy. +// +// The corpus is enumerated from Redis (channel list -> per-channel file index -> +// FileMetadata), not by walking the filesystem, so a blob nothing references is +// not resurrected and each object gets its real content type. The local copy is +// never deleted: it stays as the fallback serveFile uses until this pass, or a +// later retry, has caught up. +func migrateLocalFilesToR2(ctx context.Context) error { + if !r2Enabled { + log.Println("migrations: local-files-to-R2 skipped, R2 is not configured") + return errMigrationSkipped + } + + channels, err := dbListChannels(ctx) + if err != nil { + return fmt.Errorf("list channels: %w", err) + } + + var records, uploaded, alreadyPresent, missingLocally, malformed, failed int + // Blobs are content-addressed and deduplicated across channels, so the same + // hash can appear in many file indexes; handle each one once per run. + done := make(map[string]bool) + // Hashes copied by an earlier boot. Without this record every retry + // re-HEADed the whole corpus first and, past a few thousand blobs, spent + // its entire budget confirming old copies and never reached a new one. + copiedList, err := rdb.SMembers(ctx, migrationsR2CopiedKey).Result() + if err != nil { + return fmt.Errorf("read %s: %w", migrationsR2CopiedKey, err) + } + copied := make(map[string]bool, len(copiedList)) + for _, h := range copiedList { + copied[h] = true + } + markCopied := func(hash string) { + if err := rdb.SAdd(ctx, migrationsR2CopiedKey, hash).Err(); err != nil { + log.Printf("migrations: r2 copy: could not record %s as copied: %v", hash, err) + } + } + + for _, ch := range channels { + members, err := rdb.ZRange(ctx, channelFilesKey(ch.Slug), 0, -1).Result() + if err != nil { + return fmt.Errorf("list files for %s: %w", ch.Slug, err) + } + + for _, m := range members { + if err := budgetExhausted(ctx); err != nil { + return err + } + fileID, _, ok := decodeFileMember(m) + if !ok { + continue + } + // dbGetFileMetadata's YAML fallback slices id[:4]; a malformed short + // member must be skipped, not crash-loop the boot. + records++ + if len(fileID) < 4 { + log.Printf("migrations: r2 copy: %s: malformed file index member %q, skipping", ch.Slug, m) + malformed++ + continue + } + + meta, err := dbGetFileMetadata(ctx, fileID) + if err != nil { + if berr := budgetExhausted(ctx); berr != nil { + return berr + } + log.Printf("migrations: r2 copy: %s: no metadata for tracked file %s, skipping", ch.Slug, fileID) + continue + } + if meta.Delete { + continue + } + + // The object key and the local path both slice hash[:2]/hash[2:4]; + // an empty or short hash (YAML-era record with no hash field) would + // panic both. + if len(meta.Hash) < 4 { + log.Printf("migrations: r2 copy: %s: file %s has malformed hash %q, skipping", ch.Slug, fileID, meta.Hash) + malformed++ + continue + } + if done[meta.Hash] || copied[meta.Hash] { + continue + } + done[meta.Hash] = true + + // Local existence is checked first: with no local copy there is + // nothing to copy either way, and it saves a HEAD per record. + localPath, ok := localBlobExists(meta.Hash) + if !ok { + log.Printf("migrations: r2 copy: %s: file %s has no local blob at %s, skipping", ch.Slug, fileID, filepath.Join(rootUploadPath, meta.Hash[:2], meta.Hash[2:4], meta.Hash)) + missingLocally++ + continue + } + + key := r2ObjectKey(meta.Hash) + if r2Exists(ctx, key) { + alreadyPresent++ + markCopied(meta.Hash) + continue + } + + if err := copyBlobToR2(ctx, localPath, key, meta); err != nil { + // One unreadable or rejected blob must not stop the rest of the + // corpus; the run still ends in an error so it is retried, and + // the objects already copied are skipped next time. + log.Printf("migrations: r2 copy: %s: uploading file %s (key %s) FAILED: %v", ch.Slug, fileID, key, err) + failed++ + continue + } + uploaded++ + markCopied(meta.Hash) + } + } + + log.Printf("migrations: local-files-to-R2: %d uploaded, %d already in R2, %d missing locally, %d malformed, %d failed, out of %d file records across %d channels", + uploaded, alreadyPresent, missingLocally, malformed, failed, records, len(channels)) + + if failed > 0 { + return fmt.Errorf("%d blob(s) could not be uploaded to R2", failed) + } + return nil +} + +// copyBlobToR2 streams one local blob into the bucket. The content type matches +// what serving the local file produces: http.ServeFile derives it from the file +// name's extension, so the object gets the same one. +func copyBlobToR2(ctx context.Context, localPath, key string, meta *FileMetadata) error { + f, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("open %s: %w", localPath, err) + } + defer f.Close() + + contentType := mime.TypeByExtension(filepath.Ext(meta.Filename)) + if contentType == "" { + contentType = "application/octet-stream" + } + + upCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + return r2Upload(upCtx, key, f, contentType) +} + +// migrationBudget is the time each migration gets at boot (see runMigrations). +const migrationBudget = 2 * time.Minute + +// migrationContext is the parent context of the migration step at boot; the +// per-migration budget is applied inside runMigrations. +func migrationContext() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) +} diff --git a/backend/migrations_test.go b/backend/migrations_test.go new file mode 100644 index 00000000..2e369f6c --- /dev/null +++ b/backend/migrations_test.go @@ -0,0 +1,305 @@ +package main + +import ( + "context" + "errors" + "testing" + "time" +) + +// These tests exercise the real Redis-backed migration path. They need a live +// Redis, which the package init() already requires, and they clean up after +// themselves. Run with: +// +// REDIS_ADDR=127.0.0.1:6399 go test ./... -run Migration +func testCtx(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + return ctx +} + +// seedChannel creates a channel with the feature flags a pre-migration channel +// would have had, plus the given settings. +func seedChannel(t *testing.T, ctx context.Context, slug string, settings Settings) { + t.Helper() + + ch := &ChannelData{ + Slug: slug, + Name: slug, + CreatedAt: time.Now(), + Features: ChannelFeatures{ + // Exactly what the old creation path produced: the three toggles the + // migration is responsible for are off. + Reactions: true, + FileUploads: true, + Reports: true, + ScheduledMessages: true, + }, + } + if err := dbCreateChannel(ctx, ch); err != nil { + t.Fatalf("seed %s: %v", slug, err) + } + if err := dbSetSettings(ctx, slug, &settings); err != nil { + t.Fatalf("seed settings %s: %v", slug, err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dbDeleteChannel(cctx, slug) + }) +} + +func featuresOf(t *testing.T, ctx context.Context, slug string) ChannelFeatures { + t.Helper() + ch, err := dbGetChannel(ctx, slug) + if err != nil { + t.Fatalf("get channel %s: %v", slug, err) + } + return ch.Features +} + +// The flags are pure super-admin kill switches: the backfill enables Webhook, +// Notifications and Ads for every channel, configured or not, so a tenant that +// first adopts one of those features after the deploy is not silently dead. +func TestMigrationEnablesFeaturesUnconditionally(t *testing.T) { + ctx := testCtx(t) + + seedChannel(t, ctx, "mig-test-webhook", Settings{ + {Key: "webhook_url", Value: "https://example.com/hook"}, + }) + // Uses nothing today: must STILL come out fully enabled, so adopting a + // feature later works. + seedChannel(t, ctx, "mig-test-bare", Settings{}) + // Empty/false settings are just as irrelevant as absent ones. + seedChannel(t, ctx, "mig-test-empty", Settings{ + {Key: "webhook_url", Value: ""}, + {Key: "on_notification", Value: "0"}, + }) + seedChannel(t, ctx, "mig-test-auth", Settings{ + {Key: "require_auth", Value: "1"}, + {Key: "require_auth_for_view_files", Value: "1"}, + {Key: "count_views", Value: "1"}, + }) + + if err := backfillChannelFeatures(ctx); err != nil { + t.Fatalf("backfill: %v", err) + } + + for _, slug := range []string{"mig-test-webhook", "mig-test-bare", "mig-test-empty"} { + f := featuresOf(t, ctx, slug) + if !f.Webhook || !f.Notifications || !f.Ads { + t.Errorf("%s: expected Webhook/Notifications/Ads all enabled, got %+v", slug, f) + } + if !f.Reactions || !f.FileUploads || !f.Reports || !f.ScheduledMessages { + t.Errorf("%s: pre-existing true flags must be preserved, got %+v", slug, f) + } + } + + // require_auth / require_auth_for_view_files / count_views are no longer in + // the settings form, so a leftover value must not silently change behaviour. + if f := featuresOf(t, ctx, "mig-test-auth"); f.RequireAuth || f.RequireAuthFiles || f.CountViews { + t.Errorf("auth/view settings must not be applied by default, got %+v", f) + } +} + +// The v2 backfill converges environments where v1 already ran with its old +// conditional logic: it too enables everything unconditionally. +func TestMigrationV2EnablesFeaturesUnconditionally(t *testing.T) { + ctx := testCtx(t) + + seedChannel(t, ctx, "mig-test-v2-bare", Settings{}) + + if err := backfillChannelFeaturesV2(ctx); err != nil { + t.Fatalf("backfill v2: %v", err) + } + + f := featuresOf(t, ctx, "mig-test-v2-bare") + if !f.Webhook || !f.Notifications || !f.Ads || + !f.Reactions || !f.FileUploads || !f.Reports || !f.ScheduledMessages { + t.Errorf("v2 must enable all seven default-on toggles, got %+v", f) + } +} + +// The auth/view toggles are opt-in: an operator who knows the leftover settings +// still reflect their owners' wishes turns them on with MIGRATION_APPLY_AUTH_FLAGS. +func TestMigrationAppliesAuthFlagsWhenOptedIn(t *testing.T) { + ctx := testCtx(t) + + seedChannel(t, ctx, "mig-test-auth-optin", Settings{ + {Key: "require_auth", Value: "1"}, + {Key: "require_auth_for_view_files", Value: "1"}, + {Key: "count_views", Value: "1"}, + }) + + t.Setenv("MIGRATION_APPLY_AUTH_FLAGS", "1") + + if err := backfillChannelFeatures(ctx); err != nil { + t.Fatalf("backfill: %v", err) + } + + if f := featuresOf(t, ctx, "mig-test-auth-optin"); !f.RequireAuth || !f.RequireAuthFiles || !f.CountViews { + t.Errorf("auth/view settings should have been honoured, got %+v", f) + } +} + +// Reactions, uploads, reports and scheduling were universally available before +// requireFeature started gating them, so the backfill must guarantee them. +func TestMigrationDefaultsEverydayFeaturesOn(t *testing.T) { + ctx := testCtx(t) + + // A channel whose features blob predates dbCreateChannel setting the four. + seedChannel(t, ctx, "mig-test-nofeatures", Settings{}) + if err := dbSetChannelFeatures(ctx, "mig-test-nofeatures", &ChannelFeatures{}); err != nil { + t.Fatalf("clear features: %v", err) + } + + if err := backfillChannelFeatures(ctx); err != nil { + t.Fatalf("backfill: %v", err) + } + + f := featuresOf(t, ctx, "mig-test-nofeatures") + if !f.Reactions || !f.FileUploads || !f.Reports || !f.ScheduledMessages { + t.Errorf("everyday features should have been defaulted on, got %+v", f) + } +} + +// The migration must not undo a deliberate super-admin decision on a re-run, +// and runMigrations must not execute an already-applied migration at all. +// Both the v1 and v2 feature backfills are marked applied here, so the +// withdrawn flag proves neither of them ran again through the full registry. +func TestMigrationIsOneShotAndOnlyEverEnables(t *testing.T) { + ctx := testCtx(t) + + seedChannel(t, ctx, "mig-test-oneshot", Settings{ + {Key: "webhook_url", Value: "https://example.com/hook"}, + }) + + if err := backfillChannelFeatures(ctx); err != nil { + t.Fatalf("first backfill: %v", err) + } + if f := featuresOf(t, ctx, "mig-test-oneshot"); !f.Webhook { + t.Fatal("expected Webhook enabled after first run") + } + + // The super admin then deliberately withdraws webhooks from this tenant. + f := featuresOf(t, ctx, "mig-test-oneshot") + f.Webhook = false + if err := dbSetChannelFeatures(ctx, "mig-test-oneshot", &f); err != nil { + t.Fatalf("set features: %v", err) + } + + // runMigrations must be a no-op for both feature backfills now: their IDs + // are in the applied set. (The file-slug migrations may run; they do not + // touch features and their marks are removed again below.) + appliedIDs := []string{ + channelFeaturesBackfillID, + channelFeaturesBackfillV2ID, + } + for _, id := range appliedIDs { + rdb.SAdd(ctx, migrationsAppliedKey, id) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.SRem(cctx, migrationsAppliedKey, + channelFeaturesBackfillID, + channelFeaturesBackfillV2ID, + fileChannelSlugBackfillID, + fileSlugFromMessagesID, + ) + }) + + runMigrations(ctx) + + if f := featuresOf(t, ctx, "mig-test-oneshot"); f.Webhook { + t.Error("an applied migration must not run again and resurrect a withdrawn flag") + } +} + +// With R2 off there is nowhere to copy to, and marking the migration applied +// would consume the one-shot before it could ever run. It must report the skip +// sentinel (which runMigrations leaves unmarked) and touch nothing. +func TestMigrationLocalFilesToR2SkippedWhenR2Off(t *testing.T) { + ctx := testCtx(t) + + if r2Enabled { + t.Fatal("test environment unexpectedly has R2 enabled") + } + + seedChannel(t, ctx, "mig-test-r2-off", Settings{}) + + err := migrateLocalFilesToR2(ctx) + if !errors.Is(err, errMigrationSkipped) { + t.Fatalf("expected errMigrationSkipped with R2 off, got %v", err) + } + + // runMigrations must not mark it applied either, or it would never run once + // R2 is configured. + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.SRem(cctx, migrationsAppliedKey, + channelFeaturesBackfillID, + channelFeaturesBackfillV2ID, + fileChannelSlugBackfillID, + fileSlugFromMessagesID, + localFilesToR2ID, + ) + }) + runMigrations(ctx) + + applied, err := rdb.SIsMember(ctx, migrationsAppliedKey, localFilesToR2ID).Result() + if err != nil { + t.Fatalf("read applied set: %v", err) + } + if applied { + t.Error("local-files-to-R2 must not be marked applied while R2 is off") + } +} + +// Index members and metadata hashes are both sliced [:2]/[2:4]; legacy/YAML-era +// records can carry a short or empty hash, and those must be skipped rather than +// panicking the boot. No blob exists locally for any of them, so the pass never +// reaches an R2 call even with the flag forced on. +func TestMigrationLocalFilesToR2ShortHashDoesNotPanic(t *testing.T) { + ctx := testCtx(t) + + slug := "mig-test-r2-shorthash" + seedChannel(t, ctx, slug, Settings{}) + + records := []struct { + id string + hash string + }{ + {"ab", "0011223344556677"}, // short index member + {"aabbccddeeff00112233", ""}, // no hash at all + {"aabbccddeeff00112234", "a"}, // 1-char hash + {"aabbccddeeff00112235", "abc"}, // 3-char hash + {"aabbccddeeff00112236", "00112233445566"}, // well-formed, no local blob + } + for _, rec := range records { + meta := &FileMetadata{ID: rec.id, Filename: "x.bin", Hash: rec.hash, ChannelSlug: slug, Size: 1} + if err := dbSaveFileMetadata(ctx, meta); err != nil { + t.Fatalf("save metadata %s: %v", rec.id, err) + } + if err := dbAddChannelFile(ctx, slug, rec.id, time.Now().Unix(), 1); err != nil { + t.Fatalf("index %s: %v", rec.id, err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.Del(cctx, "file:"+rec.id) + }) + } + + // Force the flag on to get past the R2-off guard. Nothing here has a local + // blob, so no R2 client call is made. + prev := r2Enabled + r2Enabled = true + t.Cleanup(func() { r2Enabled = prev }) + + if err := migrateLocalFilesToR2(ctx); err != nil { + t.Fatalf("migration must survive malformed hashes, got %v", err) + } +} diff --git a/backend/mintsession_test.go b/backend/mintsession_test.go new file mode 100644 index 00000000..ce5cf137 --- /dev/null +++ b/backend/mintsession_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/gob" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/boj/redistore" +) + +// TestMintSessionCookie is a local UI-testing aid, not a product test. It mints +// a real signed session cookie through the application's own session store so a +// browser can drive authenticated screens (the channel admin dialog) without +// standing up Google OAuth. +// +// It only runs when MINT_SESSION_FOR is set, so a normal `go test ./...` skips +// it entirely and it can never mint anything in CI or production. +func TestMintSessionCookie(t *testing.T) { + email := os.Getenv("MINT_SESSION_FOR") + if email == "" { + t.Skip("set MINT_SESSION_FOR= to mint a session cookie") + } + slug := os.Getenv("MINT_SESSION_SLUG") + if slug == "" { + slug = "click" + } + + // store is built in main(), which never runs under test, so stand up the + // same one here with the same secret — that is what makes the cookie valid + // for a server started with this SECRET_KEY. + gob.Register(Session{}) + if store == nil || store.Pool == nil { + s, err := redistore.NewRediStore(10, redisType, redisAddr, "", redisPass, []byte(secretKey)) + if err != nil { + t.Fatalf("session store: %v", err) + } + defer s.Close() + store = s + } + + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + // store.New rather than store.Get: Get goes through gorilla's per-request + // registry, which a synthetic httptest request has not set up. + session, err := store.New(r, cookieName) + if err != nil { + t.Logf("store.New returned %v (expected for a request with no cookie)", err) + } + if session == nil { + t.Fatal("no session returned") + } + session.Values["user"] = Session{ + ID: "mint-test", + Username: email, + Email: email, + PublicName: "Test Owner", + // Owner of the given channel: enough to open the admin dialog and reach + // the owner-only settings screen. + ChannelRoles: map[string]ChannelRole{slug: RoleOwner}, + } + session.Options.MaxAge = 60 * 60 + if err := session.Save(r, w); err != nil { + t.Fatalf("session.Save: %v", err) + } + + cookie := w.Header().Get("Set-Cookie") + if cookie == "" { + t.Fatal("no Set-Cookie produced") + } + t.Logf("MINTED_COOKIE=%s", cookie) +} diff --git a/backend/my_channels.go b/backend/my_channels.go new file mode 100644 index 00000000..8c1283a9 --- /dev/null +++ b/backend/my_channels.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "sort" + "time" + + "github.com/redis/go-redis/v9" +) + +// myChannel is one row of GET /api/my-channels: what the "my channels" page +// and the channel switcher need to show a channel by name and logo rather +// than by raw slug, say what the user may do there, and warn when it is off. +type myChannel struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + LogoUrl string `json:"logoUrl"` + Role ChannelRole `json:"role"` + CreatedAt time.Time `json:"createdAt"` + Disabled bool `json:"disabled"` + Participants int64 `json:"participants"` +} + +// myChannelsFor resolves every channel the user holds a role on. Until now the +// frontend only had the slug→role map from /api/user-info, so the "my +// channels" page listed bare slugs and had no way to tell a disabled channel +// from a live one. +// +// A role whose channel no longer exists (deleted by a super admin after the +// grant) is skipped rather than failing the whole list, so one stale grant +// cannot blank the page. Owner channels come first, then by role, then +// newest first; the order is stable so the switcher does not reshuffle +// between loads. +func myChannelsFor(ctx context.Context, u User) ([]myChannel, error) { + rows := make([]myChannel, 0, len(u.ChannelRoles)) + for slug, role := range u.ChannelRoles { + ch, err := dbGetChannel(ctx, slug) + if errors.Is(err, redis.Nil) { + continue + } + if err != nil { + return nil, err + } + participants, err := dbGetUsersAmount(ctx, slug) + if err != nil { + return nil, err + } + rows = append(rows, myChannel{ + Slug: ch.Slug, + Name: ch.Name, + Description: ch.Description, + LogoUrl: ch.LogoUrl, + Role: role, + CreatedAt: ch.CreatedAt, + Disabled: ch.Features.Disabled, + Participants: participants, + }) + } + sort.Slice(rows, func(i, j int) bool { + if ri, rj := channelRoleLevels[rows[i].Role], channelRoleLevels[rows[j].Role]; ri != rj { + return ri > rj + } + if !rows[i].CreatedAt.Equal(rows[j].CreatedAt) { + return rows[i].CreatedAt.After(rows[j].CreatedAt) + } + return rows[i].Slug < rows[j].Slug + }) + return rows, nil +} + +// listMyChannels answers GET /api/my-channels for the session user. It sits +// behind checkLogin; a signed-in user who holds no role yet has no privileges +// record at all, which is an empty list and not an error. +func listMyChannels(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + u, _ := sessionUser(r) + rows, err := myChannelsFor(ctx, u) + if err != nil { + log.Printf("list my channels for %s: %v", u.Email, err) + http.Error(w, "error listing channels", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + // Roles change under the user (a grant, a channel switched off); the page + // must never show a cached answer from before that. + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(rows) +} diff --git a/backend/my_channels_test.go b/backend/my_channels_test.go new file mode 100644 index 00000000..58cc065d --- /dev/null +++ b/backend/my_channels_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" +) + +// Exercised against real Redis like channel_create_test.go, and cleaned up by +// seedChannel's t.Cleanup. + +func TestMyChannelsForListsEveryRoleAndSkipsGhosts(t *testing.T) { + ctx := testCtx(t) + + seedChannel(t, ctx, "mych-owned", Settings{}) + seedChannel(t, ctx, "mych-mod", Settings{}) + seedChannel(t, ctx, "mych-off", Settings{}) + + // The disabled flag is what the page uses to warn the owner; it must come + // through from the features record, not default to false. + if err := dbUpdateChannelFeatures(ctx, "mych-off", func(f *ChannelFeatures) { f.Disabled = true }); err != nil { + t.Fatalf("disable mych-off: %v", err) + } + + // Two registered viewers on the owned channel. + const viewers = "channel:mych-owned:registered_emails" + if err := rdb.SAdd(ctx, viewers, "one@example.com", "two@example.com").Err(); err != nil { + t.Fatalf("seed viewers: %v", err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = rdb.Del(cctx, viewers).Err() + }) + + u := User{ + Email: "mych@example.com", + ChannelRoles: map[string]ChannelRole{ + "mych-off": RoleWriter, + "mych-mod": RoleModerator, + "mych-owned": RoleOwner, + // A grant left behind by a deleted channel: must be skipped, not + // fail the list or appear as an empty row. + "mych-ghost": RoleOwner, + }, + } + + rows, err := myChannelsFor(ctx, u) + if err != nil { + t.Fatalf("myChannelsFor: %v", err) + } + + wantOrder := []string{"mych-owned", "mych-mod", "mych-off"} + if len(rows) != len(wantOrder) { + t.Fatalf("got %d rows, want %d: %+v", len(rows), len(wantOrder), rows) + } + for i, want := range wantOrder { + if rows[i].Slug != want { + t.Errorf("row %d: slug = %q, want %q (owner first, then by role)", i, rows[i].Slug, want) + } + } + + if rows[0].Role != RoleOwner || rows[0].Participants != 2 || rows[0].Disabled { + t.Errorf("owned row = %+v, want owner / 2 participants / enabled", rows[0]) + } + if rows[0].Name != "mych-owned" { + t.Errorf("owned row name = %q, want the channel's name, not its slug only", rows[0].Name) + } + if rows[2].Role != RoleWriter || !rows[2].Disabled { + t.Errorf("disabled row = %+v, want writer / disabled", rows[2]) + } +} + +// A user without any grant must get `[]`, never `null`: the frontend iterates +// the answer and a null would be a crash on the very first page a new user +// sees after signing in. +func TestMyChannelsForNoRolesEncodesAsEmptyArray(t *testing.T) { + ctx := testCtx(t) + + rows, err := myChannelsFor(ctx, User{}) + if err != nil { + t.Fatalf("myChannelsFor: %v", err) + } + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(rows); err != nil { + t.Fatalf("encode: %v", err) + } + if got := bytes.TrimSpace(buf.Bytes()); string(got) != "[]" { + t.Errorf("encoded %s, want []", got) + } +} diff --git a/backend/notifications.go b/backend/notifications.go index 045c6919..fd3f86c9 100644 --- a/backend/notifications.go +++ b/backend/notifications.go @@ -2,12 +2,17 @@ package main import ( "context" + "crypto/sha256" "encoding/json" "html/template" "log" "net/http" + "regexp" "slices" + "strings" + "sync" "time" + "unicode/utf8" "firebase.google.com/go/v4/messaging" "github.com/appleboy/go-fcm" @@ -43,17 +48,29 @@ type NotificationsConfig struct { } func getNotificationsConfig(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + channelCfg := getChannelConfig(ctx, slug) + cfg := getGlobalConfig() + + // Push needs all three: the platform configured (global), the tenant allowed + // (operator toggle) and the owner opted in (channel setting). + ch := channelFromCtx(r) + featureAllowed := ch != nil && ch.Features.Notifications + response := NotificationsConfig{ - EnableNotifications: settingConfig.OnNotification, - VAPID: settingConfig.VAPID, + EnableNotifications: cfg.OnNotification && channelCfg.OnNotification && featureAllowed, + VAPID: cfg.VAPID, FirebaseConfig: FirebaseConfig{ - ApiKey: settingConfig.FcmApiKey, - AuthDomain: settingConfig.FcmAuthDomain, - ProjectId: settingConfig.FcmProjectId, - StorageBucket: settingConfig.FcmStorageBucket, - MessagingSenderId: settingConfig.FcmMessagingSenderId, - AppId: settingConfig.FcmAppId, - MeasurementId: settingConfig.FcmMeasurementId, + ApiKey: cfg.FcmApiKey, + AuthDomain: cfg.FcmAuthDomain, + ProjectId: cfg.FcmProjectId, + StorageBucket: cfg.FcmStorageBucket, + MessagingSenderId: cfg.FcmMessagingSenderId, + AppId: cfg.FcmAppId, + MeasurementId: cfg.FcmMeasurementId, }, } @@ -105,14 +122,15 @@ func getFirebaseMessagingSW(w http.ResponseWriter, r *http.Request) { return } + cfg := getGlobalConfig() err = tmpl.Execute(w, map[string]string{ - "FcmApiKey": settingConfig.FcmApiKey, - "FcmAuthDomain": settingConfig.FcmAuthDomain, - "FcmProjectId": settingConfig.FcmProjectId, - "FcmStorageBucket": settingConfig.FcmStorageBucket, - "FcmMessagingSenderId": settingConfig.FcmMessagingSenderId, - "FcmAppId": settingConfig.FcmAppId, - "FcmMeasurementId": settingConfig.FcmMeasurementId, + "FcmApiKey": cfg.FcmApiKey, + "FcmAuthDomain": cfg.FcmAuthDomain, + "FcmProjectId": cfg.FcmProjectId, + "FcmStorageBucket": cfg.FcmStorageBucket, + "FcmMessagingSenderId": cfg.FcmMessagingSenderId, + "FcmAppId": cfg.FcmAppId, + "FcmMeasurementId": cfg.FcmMeasurementId, }) if err != nil { http.Error(w, "Failed to generate service worker", http.StatusInternalServerError) @@ -120,7 +138,19 @@ func getFirebaseMessagingSW(w http.ResponseWriter, r *http.Request) { } } +// fcmTokenRe is the shape of an FCM registration token (base64url pieces +// joined by ':'). Anything else can never be delivered to, so accepting it +// only fills the subscription set with junk that costs an FCM slot per push. +var fcmTokenRe = regexp.MustCompile(`^[A-Za-z0-9_:\-]+$`) + +// maxSubscriptionsPerChannel caps a channel's token set. Dead tokens are only +// pruned when a push fails for them, so a channel that never pushes keeps every +// token forever; the cap bounds what one tenant can cost the platform. +const maxSubscriptionsPerChannel = 50000 + func subscribeNotifications(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + var req struct { Token string `json:"token"` } @@ -130,12 +160,38 @@ func subscribeNotifications(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - if req.Token == "" || len(req.Token) < 50 || len(req.Token) > 300 { + if req.Token == "" || len(req.Token) < 50 || len(req.Token) > 300 || !fcmTokenRe.MatchString(req.Token) { http.Error(w, "Invalid token ", http.StatusBadRequest) return } - if err := addSubscription(req.Token); err != nil { + // Every accepted token is a permanent entry in the channel's subscription + // set, only pruned once FCM rejects it on a later push — so without a + // limit one signed-in user could grow the set (and slow every post on + // the channel) without bound. A device re-registers rarely. + if !allowOrRetryAfter(w, subscribeLimiter(clientKey(r)), "too many subscription requests — please slow down") { + return + } + + // The token is stored even while push is off platform-wide. A short-circuit + // that answered success without storing it told the user, on the bell, + // that notifications were enabled while nothing was kept: a tab loaded + // while push was on still shows the bell after the operator turns it off, + // and once push came back the device was not in the set and nothing made + // it retry. pushFcmMessage checks the global flag before it reads the set, + // and the per-channel cap above bounds the growth. + n, err := countSubscriptions(slug) + if err != nil { + http.Error(w, "Failed to subscribe to notifications", http.StatusInternalServerError) + return + } + if n >= maxSubscriptionsPerChannel { + w.Header().Set("Retry-After", "3600") + http.Error(w, "too many subscriptions on this channel", http.StatusTooManyRequests) + return + } + + if err := addSubscription(slug, req.Token); err != nil { http.Error(w, "Failed to subscribe to notifications", http.StatusInternalServerError) return } @@ -147,82 +203,187 @@ func subscribeNotifications(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -func pushFcmMessage(m *Message) { - if !settingConfig.OnNotification { +const ( + maxPushTitleRunes = 100 + maxPushBodyRunes = 300 +) + +var ( + // The composer's file markers, "[image-embedded#WxH](url)" and the + // "[quote-embedded#]" prefix: neither means anything in a notification. + pushEmbedMarker = regexp.MustCompile(`!?\[[a-z]+-embedded#[^\]]*\](\([^)]*\))?`) + // A markdown link or image: keep the label, drop the URL. + pushMarkdownLink = regexp.MustCompile(`!?\[([^\]]*)\]\([^)]*\)`) + pushWhitespace = regexp.MustCompile(`\s+`) + pushEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "`", "") +) + +// pushPreview reduces a post's markdown to the short plain text a notification +// can carry. Two reasons this is not just m.Text: +// - the tray shows raw markup otherwise ("**", link URLs, embed markers); +// - a data payload over 4 KiB is refused by FCM with INVALID_ARGUMENT for +// every token in the batch — the same code a dead token gets — so one +// long post not only failed to deliver but got every subscriber pruned. +func pushPreview(text string) string { + t := pushEmbedMarker.ReplaceAllString(text, "") + t = pushMarkdownLink.ReplaceAllString(t, "$1") + t = pushEmphasis.Replace(t) + t = strings.TrimSpace(pushWhitespace.ReplaceAllString(t, " ")) + return truncateRunes(t, maxPushBodyRunes) +} +// truncateRunes cuts on a character boundary, never inside a multi-byte rune. +func truncateRunes(s string, max int) string { + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return strings.TrimSpace(string(r[:max])) + "…" +} + +// The FCM client owns an OAuth token source, so rebuilding one per push throws +// away every cached token and mints fresh credentials for every message. Cache +// it, keyed on the credential bytes so a settings change still takes effect. +var ( + fcmClientMu sync.Mutex + fcmClient *fcm.Client + fcmClientHash [32]byte +) + +func getFcmClient(credentials []byte) (*fcm.Client, error) { + h := sha256.Sum256(credentials) + + fcmClientMu.Lock() + defer fcmClientMu.Unlock() + + if fcmClient != nil && h == fcmClientHash { + return fcmClient, nil + } + + // context.Background(), not the caller's: the cached client's token source + // outlives the request that happened to create it. + c, err := fcm.NewClient(context.Background(), fcm.WithCredentialsJSON(credentials)) + if err != nil { + return nil, err + } + fcmClient, fcmClientHash = c, h + return c, nil +} + +func pushFcmMessage(slug string, m *Message) { + // One snapshot for the whole function: reading the global per field would + // let a concurrent settings save splice two service-account credentials. + cfg := getGlobalConfig() + if !cfg.OnNotification { return } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - list, err := getSubcriptionsList() - if err != nil { - log.Println("Failed to get subscription list:", err) + channelCfg := getChannelConfig(ctx, slug) + if !channelCfg.OnNotification { return } - if len(list) == 0 { - log.Println("No subscriptions found, skipping FCM push") + // Operator-level kill switch, independent of the owner's on_notification. + // The channel read is reused below for the notification title, so the hash + // is fetched exactly once per push. + ch, chErr := dbGetChannel(ctx, slug) + if chErr == nil && !ch.Features.Notifications { + // The owner HAS push configured; going silent here without a trace makes + // the kill switch undiagnosable. + log.Printf("push suppressed for %s: notifications feature disabled by operator\n", slug) return } - channelName, err := getChannelDetails(ctx) + list, err := getSubcriptionsList(slug) if err != nil { - log.Println("Failed to get channel details:", err) + log.Println("Failed to get subscription list:", err) return } - fcmSet := &FcmJsonConfing{ - Type: settingConfig.FcmJson.Type, - ProjectId: settingConfig.FcmJson.ProjectId, - PrivateKeyId: settingConfig.FcmJson.PrivateKeyId, - PrivateKey: settingConfig.FcmJson.PrivateKey, - ClientEmail: settingConfig.FcmJson.ClientEmail, - ClientId: settingConfig.FcmJson.ClientId, - AuthUri: settingConfig.FcmJson.AuthUri, - TokenUri: settingConfig.FcmJson.TokenUri, - AuthProviderX509CertUrl: settingConfig.FcmJson.AuthProviderX509CertUrl, - ClientX509CertUrl: settingConfig.FcmJson.ClientX509CertUrl, - UniverseDomain: settingConfig.FcmJson.UniverseDomain, + if len(list) == 0 { + log.Println("No subscriptions found, skipping FCM push") + return } - fcmSetJson, err := json.Marshal(fcmSet) + // cfg.FcmJson is already the complete credential snapshot (ToConfig always + // allocates it); marshalling it directly means a field added to the struct + // can never be silently dropped by a stale hand-written copy. + fcmSetJson, err := json.Marshal(cfg.FcmJson) if err != nil { log.Println("Failed to marshal FCM credentials:", err) return } - client, err := fcm.NewClient( - ctx, - fcm.WithCredentialsJSON(fcmSetJson), - ) + client, err := getFcmClient(fcmSetJson) if err != nil { log.Println("Failed to create FCM client:", err) return } + // Preserve the empty-title behaviour when the channel read failed. + title := "" + if chErr == nil { + title = ch.Name + } data := map[string]string{ - "url": settingConfig.ProjectDomain, - "title": channelName["name"], - "body": m.Text, + // project_domain is global, so it must be joined with the channel slug + // or every channel's notification opens the same page. + "url": strings.TrimRight(cfg.ProjectDomain, "/") + "/channel/" + slug, + "title": truncateRunes(title, maxPushTitleRunes), + "body": pushPreview(m.Text), } + // The send loop gets its own budget: ctx above also covers the config and + // subscription lookups, and its deadline expiring mid-loop would silently + // drop every remaining chunk. + sendCtx, sendCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer sendCancel() + for chunk := range slices.Chunk(list, 500) { message := &messaging.MulticastMessage{ Tokens: chunk, Data: data, } - r, err := client.SendMulticast(ctx, message) + r, err := client.SendMulticast(sendCtx, message) if err != nil { log.Println("Failed to send push notification:", err) - return + continue } log.Printf("Push notification sent to %d tokens: \n", r.SuccessCount) log.Printf("Failed to send to %d tokens: \n", r.FailureCount) - // for _, resp := range r.Responses { - // log.Printf("Response: %s, Error: %v\n", resp.MessageID, resp.Error) - // } + + // Prune the tokens FCM reports as permanently dead, so the set does not + // grow without bound and later pushes stop paying for them. Only + // unregistered/invalid tokens are removed — a transient failure + // (rate-limited, server error) is left in place to retry. + if r.FailureCount > 0 { + var dead, invalid []string + for i, resp := range r.Responses { + if resp.Success || i >= len(chunk) { + continue + } + switch { + case messaging.IsUnregistered(resp.Error): + dead = append(dead, chunk[i]) + case messaging.IsInvalidArgument(resp.Error): + invalid = append(invalid, chunk[i]) + } + } + // INVALID_ARGUMENT is also what FCM answers for a malformed + // *message* (oversized payload, bad field) — and then every token + // in the chunk fails with it at once. That is a bad send, not a + // batch of dead tokens: pruning them would silently unsubscribe + // the whole channel because of one long post. + if len(invalid) == len(chunk) && len(chunk) > 1 { + log.Printf("push to %s: every token rejected as INVALID_ARGUMENT, keeping subscriptions (payload problem?)\n", slug) + } else { + dead = append(dead, invalid...) + } + removeSubscriptions(slug, dead) + } } } diff --git a/backend/operator_identity.go b/backend/operator_identity.go new file mode 100644 index 00000000..bb0bb599 --- /dev/null +++ b/backend/operator_identity.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "strings" +) + +// operatorName is the only name anyone else is ever shown for a platform +// operator (a super admin): on a support reply, a channel post, a report. +// +// The operator's own identity — public name, Google name and account id, email +// — must never reach another person, and that includes JSON fields and webhook +// payloads the page does not render. Records are written with this label, and +// the read paths below re-label records written before that was the rule. +const operatorName = "ניהול" + +// operatorAuthorId stands in for an operator's Google account id wherever a +// record would otherwise carry it. Deliberately not "" or "0": canModifyMessage +// treats those as system posts that any writer may edit. Operators lose nothing +// by not matching it, since hasChannelRole already lets them edit anything. +const operatorAuthorId = "operator" + +// operatorSet is a snapshot of who the operators are, for recognising their +// identity in records written before operators were anonymised. +type operatorSet struct { + ids map[string]struct{} + emails map[string]struct{} +} + +// currentOperators snapshots the super admins from privilegesUsers. The map +// holds privileged users only, so a range is cheap; callers still take one +// snapshot per request or event rather than one per record. +// +// An operator's id is their Google account id. A record learns it at login, or +// from recordAuthorID when a post is written before the record did. +func currentOperators() operatorSet { + ops := operatorSet{ids: map[string]struct{}{}, emails: map[string]struct{}{}} + privilegesUsers.Range(func(_, v any) bool { + u, ok := v.(User) + if !ok || u.GlobalRole != RoleSuperAdmin { + return true + } + if u.ID != "" { + ops.ids[u.ID] = struct{}{} + } + if u.Email != "" { + ops.emails[strings.ToLower(u.Email)] = struct{}{} + } + return true + }) + return ops +} + +func (o operatorSet) hasID(id string) bool { + if id == "" { + return false + } + _, ok := o.ids[id] + return ok +} + +func (o operatorSet) hasEmail(email string) bool { + if email == "" { + return false + } + _, ok := o.emails[strings.ToLower(email)] + return ok +} + +// anonymiseOperatorAuthor re-labels a message written by an operator and +// reports whether it changed anything. Other authors are left alone. +func (o operatorSet) anonymiseOperatorAuthor(m *Message) bool { + if !o.hasID(m.AuthorId) { + return false + } + m.Author, m.AuthorId = operatorName, operatorAuthorId + return true +} + +// anonymiseOperatorReport re-labels a report an operator filed. The email is +// cleared rather than replaced: there is no stand-in address to show. +func (o operatorSet) anonymiseOperatorReport(rep *Report) { + if !o.hasID(rep.ReporterID) && !o.hasEmail(rep.ReportedEmail) { + return + } + rep.ReporterID, rep.ReportedEmail, rep.ReporterName = operatorAuthorId, "", operatorName +} + +// anonymiseOperatorEvent re-labels an operator's message inside an SSE event +// payload. The stream keeps ~1000 entries per channel and replays them to a +// reconnecting viewer, so events published before operators were anonymised +// are still served. Anything that is not an operator's message passes through +// byte for byte, and a payload that cannot contain an operator id is not even +// parsed. +func (o operatorSet) anonymiseOperatorEvent(data string) string { + mentioned := false + for id := range o.ids { + if strings.Contains(data, id) { + mentioned = true + break + } + } + if !mentioned { + return data + } + var pm PushMessage + if err := json.Unmarshal([]byte(data), &pm); err != nil { + return data + } + if !o.anonymiseOperatorAuthor(&pm.M) { + return data + } + out, err := json.Marshal(pm) + if err != nil { + return data + } + return string(out) +} + +// recordAuthorID makes sure the author's record carries the Google account id +// their posts are signed with. +// +// A record created after the user's last login (self-service channel creation, +// an owner's invitation) has no id until the next login, up to the 30-day +// cookie lifetime away. A post written in that window carries an id no record +// knows, so if its author is later made an operator, currentOperators could not +// recognise it and the read paths would keep serving their Google id. Only +// writes when the id is missing, so it costs one map lookup per post otherwise. +func recordAuthorID(ctx context.Context, s Session) { + // The live map and the stored list are keyed by the normalised address + // (see normEmail); the session carries the address as Google reported it. + email := normEmail(s.Email) + if s.ID == "" || email == "" { + return + } + v, ok := privilegesUsers.Load(email) + if !ok { + return + } + if u, ok := v.(User); !ok || u.ID != "" { + return + } + if err := dbUpdateUsersList(ctx, func(users []User) []User { + for i := range users { + if normEmail(users[i].Email) == email && users[i].ID == "" { + users[i].ID = s.ID + } + } + return users + }); err != nil { + log.Printf("recordAuthorID: %s: %v\n", email, err) + return + } + // Merge onto whatever entry is current, as getUser does, so a concurrent + // role rebuild is not overwritten with this snapshot. + if cur, ok := privilegesUsers.Load(email); ok { + if c, ok := cur.(User); ok && c.ID == "" { + c.ID = s.ID + privilegesUsers.Store(email, c) + } + } +} diff --git a/backend/operator_identity_test.go b/backend/operator_identity_test.go new file mode 100644 index 00000000..e360fec7 --- /dev/null +++ b/backend/operator_identity_test.go @@ -0,0 +1,494 @@ +package main + +import ( + "context" + "encoding/gob" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/boj/redistore" + "github.com/go-chi/chi" +) + +// The operator, as their Google account would really identify them. None of +// these three may appear in anything served to anyone else. +const ( + testOperatorEmail = "real.operator@example.com" + testOperatorID = "109876543210987654321" + testOperatorName = "Real Operator Name" +) + +// operatorFixture makes testOperatorEmail a super admin and ownerEmail the +// owner of slug, in both the live privileges map and the stored users list, +// and returns sessions for each. The operator also holds an explicit owner role +// on slug, exactly as self-service creation leaves it. +func operatorFixture(t *testing.T, slug, ownerEmail string) (operator, owner Session) { + t.Helper() + ctx := supportCtx(t) + + opUser := User{ + ID: testOperatorID, Username: testOperatorName, Email: testOperatorEmail, + PublicName: testOperatorName, GlobalRole: RoleSuperAdmin, + ChannelRoles: map[string]ChannelRole{slug: RoleOwner}, + } + ownerUser := User{ + ID: "owner-" + slug, Username: "Channel Owner", Email: ownerEmail, + PublicName: "Channel Owner", + ChannelRoles: map[string]ChannelRole{slug: RoleOwner}, + } + privilegesUsers.Store(opUser.Email, opUser) + privilegesUsers.Store(ownerUser.Email, ownerUser) + if err := dbUpdateUsersList(ctx, func(users []User) []User { + return append(users, opUser, ownerUser) + }); err != nil { + t.Fatalf("seed users list: %v", err) + } + t.Cleanup(func() { + privilegesUsers.Delete(opUser.Email) + privilegesUsers.Delete(ownerUser.Email) + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + dbUpdateUsersList(cctx, func(users []User) []User { + kept := users[:0] + for _, u := range users { + if u.Email != opUser.Email && u.Email != ownerUser.Email { + kept = append(kept, u) + } + } + return kept + }) + for _, pattern := range []string{"channel:" + slug + ":*"} { + keys, _ := rdb.Keys(cctx, pattern).Result() + if len(keys) > 0 { + rdb.Del(cctx, keys...) + } + } + }) + + operator = Session{ID: opUser.ID, Username: opUser.Username, Email: opUser.Email, + PublicName: opUser.PublicName, GlobalRole: RoleSuperAdmin} + owner = Session{ID: ownerUser.ID, Username: ownerUser.Username, Email: ownerUser.Email, + PublicName: ownerUser.PublicName, ChannelRoles: ownerUser.ChannelRoles} + return operator, owner +} + +// useTestSessionStore swaps in a real Redis-backed session store for the test, +// as main() does at boot, so handlers can resolve a session from a cookie. +func useTestSessionStore(t *testing.T) { + t.Helper() + gob.Register(Session{}) + s, err := redistore.NewRediStore(10, redisType, redisAddr, "", redisPass, []byte("operator-identity-test")) + if err != nil { + t.Fatalf("session store: %v", err) + } + prev := store + store = s + t.Cleanup(func() { + store = prev + s.Close() + }) +} + +// requestAs builds a request carrying a signed session cookie for sess and the +// channel context channelMiddleware would have attached. +func requestAs(t *testing.T, sess Session, slug, method, target string, body io.Reader, params map[string]string) *http.Request { + t.Helper() + mr := httptest.NewRequest(http.MethodGet, "/", nil) + mw := httptest.NewRecorder() + session, _ := store.New(mr, cookieName) + session.Values["user"] = sess + if err := session.Save(mr, mw); err != nil { + t.Fatalf("session save: %v", err) + } + // redistore keeps each session for 30 days under its default prefix. + sessionKey := "session_" + session.ID + t.Cleanup(func() { rdb.Del(context.Background(), sessionKey) }) + + r := httptest.NewRequest(method, target, body) + for _, c := range mw.Result().Cookies() { + r.AddCookie(c) + } + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, channelCtxKey, &ChannelData{Slug: slug}) + return r.WithContext(ctx) +} + +func assertNoOperatorIdentity(t *testing.T, where, data string) { + t.Helper() + for _, leak := range []string{testOperatorEmail, testOperatorID, testOperatorName} { + if strings.Contains(data, leak) { + t.Errorf("%s leaks the operator's identity (%q): %s", where, leak, data) + } + } +} + +// A post the operator writes is stored, returned and published under the +// management label; an ordinary staff member's post keeps their own name. +func TestOperatorPostIsSignedAsManagement(t *testing.T) { + useTestSessionStore(t) + const slug = "op-id-post" + operator, owner := operatorFixture(t, slug, "owner.post@example.com") + ctx := supportCtx(t) + + post := func(sess Session, text string) Message { + t.Helper() + r := requestAs(t, sess, slug, http.MethodPost, "/api/channel/"+slug+"/admin/new", + strings.NewReader(`{"type":"md","text":"`+text+`"}`), nil) + w := httptest.NewRecorder() + addMessage(w, r) + if w.Code != http.StatusOK { + t.Fatalf("addMessage: status %d, body %q", w.Code, w.Body.String()) + } + assertNoOperatorIdentity(t, "addMessage response", w.Body.String()) + var m Message + if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { + t.Fatalf("decode: %v", err) + } + return m + } + + opPost := post(operator, "from the operator") + if opPost.Author != operatorName || opPost.AuthorId != operatorAuthorId { + t.Errorf("operator post signed %q/%q, want %q/%q", opPost.Author, opPost.AuthorId, operatorName, operatorAuthorId) + } + stored, err := dbGetMessageFields(ctx, slug, fmt.Sprint(opPost.ID)) + if err != nil { + t.Fatalf("stored post: %v", err) + } + if stored["author"] != operatorName || stored["authorId"] != operatorAuthorId { + t.Errorf("operator post stored as %q/%q", stored["author"], stored["authorId"]) + } + + ownerPost := post(owner, "from the owner") + if ownerPost.Author != "Channel Owner" || ownerPost.AuthorId != owner.ID { + t.Errorf("an ordinary author must keep their own name, got %q/%q", ownerPost.Author, ownerPost.AuthorId) + } + + events, err := rdb.XRange(ctx, "channel:"+slug+":events", "-", "+").Result() + if err != nil || len(events) == 0 { + t.Fatalf("expected published events, got %d (%v)", len(events), err) + } + for _, ev := range events { + data, _ := ev.Values["data"].(string) + assertNoOperatorIdentity(t, "stored event", data) + } +} + +// Posts written before operators were anonymised still carry the operator's +// name and Google id. Staff read real authors, so the read path must re-label +// them — and re-saving one must store it re-labelled. +func TestLegacyOperatorPostIsRelabelledForStaff(t *testing.T) { + useTestSessionStore(t) + const slug = "op-id-legacy" + operator, owner := operatorFixture(t, slug, "owner.legacy@example.com") + ctx := supportCtx(t) + + legacy := &Message{ID: 1, Type: "md", Text: "old post", Author: testOperatorName, + AuthorId: testOperatorID, Timestamp: time.Now().Add(-time.Hour)} + if err := setMessage(ctx, slug, legacy, false); err != nil { + t.Fatalf("seed legacy post: %v", err) + } + staff := &Message{ID: 2, Type: "md", Text: "staff post", Author: "Channel Owner", + AuthorId: owner.ID, Timestamp: time.Now()} + if err := setMessage(ctx, slug, staff, false); err != nil { + t.Fatalf("seed staff post: %v", err) + } + + r := requestAs(t, owner, slug, http.MethodGet, "/api/channel/"+slug+"/messages?offset=0&limit=20", nil, nil) + w := httptest.NewRecorder() + getMessages(w, r) + if w.Code != http.StatusOK { + t.Fatalf("getMessages: status %d", w.Code) + } + assertNoOperatorIdentity(t, "GET /messages for the owner", w.Body.String()) + var got []Message + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + byID := map[int]Message{} + for _, m := range got { + byID[m.ID] = m + } + if m := byID[1]; m.Author != operatorName || m.AuthorId != operatorAuthorId { + t.Errorf("legacy operator post shown as %q/%q", m.Author, m.AuthorId) + } + if m := byID[2]; m.Author != "Channel Owner" || m.AuthorId != owner.ID { + t.Errorf("staff post must keep its real author for staff, got %q/%q", m.Author, m.AuthorId) + } + + // The operator edits the old post: it is stored re-labelled from now on. + r = requestAs(t, operator, slug, http.MethodPost, "/api/channel/"+slug+"/admin/edit-message", + strings.NewReader(`{"id":1,"type":"md","text":"edited"}`), nil) + w = httptest.NewRecorder() + updateMessage(w, r) + if w.Code != http.StatusOK { + t.Fatalf("updateMessage: status %d, body %q", w.Code, w.Body.String()) + } + stored, _ := dbGetMessageFields(ctx, slug, "1") + if stored["author"] != operatorName || stored["authorId"] != operatorAuthorId { + t.Errorf("re-saved legacy post stored as %q/%q", stored["author"], stored["authorId"]) + } +} + +// The event stream replays up to ~1000 old entries to reconnecting staff, so +// the re-labelling is applied per event on the way out. +func TestOperatorEventIsRelabelledForStaff(t *testing.T) { + ops := operatorSet{ + ids: map[string]struct{}{testOperatorID: {}}, + emails: map[string]struct{}{testOperatorEmail: {}}, + } + + opEvent, _ := json.Marshal(PushMessage{Type: "new-message", M: Message{ + ID: 7, Text: "hi", Author: testOperatorName, AuthorId: testOperatorID}}) + out := ops.anonymiseOperatorEvent(string(opEvent)) + assertNoOperatorIdentity(t, "operator event", out) + var pm PushMessage + if err := json.Unmarshal([]byte(out), &pm); err != nil { + t.Fatalf("decode: %v", err) + } + if pm.Type != "new-message" || pm.M.ID != 7 || pm.M.Text != "hi" || + pm.M.Author != operatorName || pm.M.AuthorId != operatorAuthorId { + t.Errorf("re-labelled event = %+v", pm) + } + + // Anything else passes through byte for byte. + for _, data := range []string{ + `{"type":"heartbeat"}`, + `{"type":"delete-message","message":{"id":7,"deleted":true}}`, + string(mustJSON(t, PushMessage{Type: "new-message", M: Message{ID: 8, Author: "Someone", AuthorId: "555"}})), + "not json " + testOperatorID, + } { + if got := ops.anonymiseOperatorEvent(data); got != data { + t.Errorf("event changed:\n in: %s\nout: %s", data, got) + } + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// A report the operator files, and one filed before operators were anonymised, +// both reach the channel's moderators without the operator's email, name or id. +func TestOperatorReportIsAnonymous(t *testing.T) { + useTestSessionStore(t) + const slug = "op-id-report" + operator, owner := operatorFixture(t, slug, "owner.report@example.com") + ctx := supportCtx(t) + + for id := 1; id <= 2; id++ { + if err := setMessage(ctx, slug, &Message{ID: id, Type: "md", Text: "post", Timestamp: time.Now()}, false); err != nil { + t.Fatalf("seed post: %v", err) + } + } + + // The report limiter is process-wide; a repeated run must not trip it. + reportLimiters.Delete(testOperatorEmail) + t.Cleanup(func() { reportLimiters.Delete(testOperatorEmail) }) + + r := requestAs(t, operator, slug, http.MethodPost, "/api/channel/"+slug+"/messages/report", + strings.NewReader(`{"messageId":1,"reason":"spam"}`), nil) + w := httptest.NewRecorder() + reportMessage(w, r) + if w.Code != http.StatusOK { + t.Fatalf("reportMessage: status %d, body %q", w.Code, w.Body.String()) + } + // Checked at rest, not only through getReports: the read path re-labels + // anything it recognises, which would hide a report stored with the + // operator's details. + stored, err := rdb.HGetAll(ctx, "channel:"+slug+":report:1").Result() + if err != nil { + t.Fatalf("stored report: %v", err) + } + if stored["reporterName"] != operatorName || stored["reportedEmail"] != "" || stored["reporterId"] != operatorAuthorId { + t.Errorf("operator report stored as %q/%q/%q", stored["reporterName"], stored["reportedEmail"], stored["reporterId"]) + } + assertNoOperatorIdentity(t, "stored report", fmt.Sprint(stored)) + + legacy := &Report{MessageId: 2, Reason: "old", CreatedAt: time.Now(), + ReporterID: testOperatorID, ReportedEmail: testOperatorEmail, ReporterName: testOperatorName} + if err := dbReportMessage(ctx, slug, legacy); err != nil { + t.Fatalf("seed legacy report: %v", err) + } + + r = requestAs(t, owner, slug, http.MethodGet, "/api/channel/"+slug+"/admin/reports/get?status=all", nil, nil) + w = httptest.NewRecorder() + getReports(w, r) + if w.Code != http.StatusOK { + t.Fatalf("getReports: status %d", w.Code) + } + assertNoOperatorIdentity(t, "reports for the owner", w.Body.String()) + var reps []Report + if err := json.Unmarshal(w.Body.Bytes(), &reps); err != nil { + t.Fatalf("decode: %v", err) + } + if len(reps) != 2 { + t.Fatalf("expected 2 reports, got %d", len(reps)) + } + for _, rep := range reps { + if rep.ReporterName != operatorName || rep.ReportedEmail != "" || rep.ReporterID != operatorAuthorId { + t.Errorf("report %d shown as %q/%q/%q", rep.MessageId, rep.ReporterName, rep.ReportedEmail, rep.ReporterID) + } + } +} + +// A channel's owner screen lists who holds a role on it. The operator holds +// owner on every channel they created, and that row must not be listed to its +// co-owners; the operator's own view of the list still shows everyone. +func TestOwnerUserListHidesOperators(t *testing.T) { + useTestSessionStore(t) + const slug = "op-id-users" + operator, owner := operatorFixture(t, slug, "owner.users@example.com") + + list := func(sess Session) string { + r := requestAs(t, sess, slug, http.MethodGet, "/api/channel/"+slug+"/admin/users/get", nil, nil) + w := httptest.NewRecorder() + getChannelUsers(w, r) + if w.Code != http.StatusOK { + t.Fatalf("getChannelUsers: status %d", w.Code) + } + return w.Body.String() + } + + ownerView := list(owner) + assertNoOperatorIdentity(t, "owner's user list", ownerView) + if !strings.Contains(ownerView, owner.Email) { + t.Errorf("the owner must still see the channel's other users: %s", ownerView) + } + if opView := list(operator); !strings.Contains(opView, testOperatorEmail) { + t.Errorf("the operator's own view should list everyone: %s", opView) + } +} + +// A writer role an owner grants to the operator's email is the owner's own +// doing: it must be listed like any other, or the row would vanish after the +// save and confirm that the address they typed is the operator's. +func TestOwnerSeesOperatorEmailTheyGranted(t *testing.T) { + useTestSessionStore(t) + const slug = "op-id-granted" + const other = "someone.else.granted@example.com" + _, owner := operatorFixture(t, slug, "owner.granted@example.com") + ctx := supportCtx(t) + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + privilegesUsers.Delete(other) + dbUpdateUsersList(cctx, func(users []User) []User { + kept := users[:0] + for _, u := range users { + if u.Email != other { + kept = append(kept, u) + } + } + return kept + }) + }) + + // This time the operator holds no role on the channel. + if err := dbUpdateUsersList(ctx, func(users []User) []User { + for i := range users { + if users[i].Email == testOperatorEmail { + delete(users[i].ChannelRoles, slug) + } + } + return users + }); err != nil { + t.Fatalf("drop operator role: %v", err) + } + + r := requestAs(t, owner, slug, http.MethodPost, "/api/channel/"+slug+"/admin/users/set", + strings.NewReader(`{"users":[{"email":"`+testOperatorEmail+`","role":"writer"},{"email":"`+other+`","role":"writer"}]}`), nil) + w := httptest.NewRecorder() + setChannelUsers(w, r) + if w.Code != http.StatusOK { + t.Fatalf("setChannelUsers: status %d, body %q", w.Code, w.Body.String()) + } + + r = requestAs(t, owner, slug, http.MethodGet, "/api/channel/"+slug+"/admin/users/get", nil, nil) + w = httptest.NewRecorder() + getChannelUsers(w, r) + for _, email := range []string{testOperatorEmail, other} { + if !strings.Contains(w.Body.String(), email) { + t.Errorf("a role the owner granted to %s must be listed: %s", email, w.Body.String()) + } + } +} + +// A post written before its author's record learned their Google id must +// still be attributable, or a later promotion to operator could not re-label +// it. addMessage records the id as it posts. +func TestAuthorIDIsRecordedWhenPosting(t *testing.T) { + useTestSessionStore(t) + const slug = "op-id-record" + _, _ = operatorFixture(t, slug, "owner.record@example.com") + const lateEmail, lateID = "late.writer@example.com", "200000000000000000001" + ctx := supportCtx(t) + + // As dbAssignChannelRole or an owner's invitation leaves it: a role, no id. + late := User{Email: lateEmail, ChannelRoles: map[string]ChannelRole{slug: RoleWriter}} + privilegesUsers.Store(lateEmail, late) + if err := dbUpdateUsersList(ctx, func(users []User) []User { return append(users, late) }); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + privilegesUsers.Delete(lateEmail) + dbUpdateUsersList(cctx, func(users []User) []User { + kept := users[:0] + for _, u := range users { + if u.Email != lateEmail { + kept = append(kept, u) + } + } + return kept + }) + }) + + sess := Session{ID: lateID, Username: "Late Writer", Email: lateEmail, PublicName: "Late Writer", + ChannelRoles: late.ChannelRoles} + r := requestAs(t, sess, slug, http.MethodPost, "/api/channel/"+slug+"/admin/new", + strings.NewReader(`{"type":"md","text":"hello"}`), nil) + w := httptest.NewRecorder() + addMessage(w, r) + if w.Code != http.StatusOK { + t.Fatalf("addMessage: status %d, body %q", w.Code, w.Body.String()) + } + + if v, _ := privilegesUsers.Load(lateEmail); v.(User).ID != lateID { + t.Errorf("live record id = %q, want %q", v.(User).ID, lateID) + } + users, err := dbGetUsersList(ctx) + if err != nil { + t.Fatalf("users list: %v", err) + } + for _, u := range users { + if u.Email == lateEmail && u.ID != lateID { + t.Errorf("stored record id = %q, want %q", u.ID, lateID) + } + } + + // Promoted later: the post is now recognised as an operator's. + v, _ := privilegesUsers.Load(lateEmail) + promoted := v.(User) + promoted.GlobalRole = RoleSuperAdmin + privilegesUsers.Store(lateEmail, promoted) + if !currentOperators().hasID(lateID) { + t.Error("a promoted author's posts must be recognisable as an operator's") + } +} diff --git a/backend/privileges.go b/backend/privileges.go index 88b84654..da33875c 100644 --- a/backend/privileges.go +++ b/backend/privileges.go @@ -3,69 +3,366 @@ package main import ( "context" "encoding/json" + "fmt" "log" "net/http" "os" + "sort" "strings" "sync" "time" +) + +type GlobalRole string +type ChannelRole string + +const ( + RoleSuperAdmin GlobalRole = "super_admin" +) - "github.com/redis/go-redis/v9" +const ( + RoleOwner ChannelRole = "owner" + RoleModerator ChannelRole = "moderator" + RoleWriter ChannelRole = "writer" ) -type Privilege string //privilege type -type Privileges map[Privilege]bool // map of privileges +// validChannelRole reports whether r is one of the three channel roles. +func validChannelRole(r ChannelRole) bool { + switch r { + case RoleOwner, RoleModerator, RoleWriter: + return true + } + return false +} + +var channelRoleLevels = map[ChannelRole]int{ + RoleWriter: 1, + RoleModerator: 2, + RoleOwner: 3, +} + var privilegesUsers sync.Map -var adminUsers []string = strings.Split(os.Getenv("ADMIN_USERS"), ",") + +// privilegesMu serialises every read-then-swap of privilegesUsers, held from +// the users:list read (or transaction) through the swap. Guarding only the +// swap was not enough: rebuild A could load an older users:list, lose the +// race to rebuild B, and still swap its stale snapshot in last, pruning a +// role B had just granted until the next periodic reload. +var privilegesMu sync.Mutex const ( - Admin Privilege = "admin" // root privilege - Moderator Privilege = "moderator" // admin privilege - Writer Privilege = "writer" // can write posts + // privilegesReloadChannel is the Redis pub/sub channel a role change is + // announced on, so every replica reloads at once instead of serving the + // old roles until its next periodic refresh. + privilegesReloadChannel = "privileges:reload" + // privilegesRefreshInterval bounds how stale a replica that missed the + // announcement (or was not subscribed yet) can be. + privilegesRefreshInterval = 30 * time.Second ) -func initializePrivilegeUsers() { +// normEmail is the canonical form every principal comparison uses. Google +// reports the address in whatever case the account was created with, while +// the super admin and channel owners type it by hand: "Name@Gmail.com" in +// ADMIN_USERS or in a role grant never matched "name@gmail.com" from the +// id_token, so the grant silently did nothing. +func normEmail(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// normalizeUsers lower-cases every email and merges the duplicate records that +// case-variant grants left behind, keeping the strongest role per channel and +// the super-admin flag if either copy had it. +func normalizeUsers(users []User) []User { + out := make([]User, 0, len(users)) + idx := make(map[string]int, len(users)) + for _, u := range users { + email := normEmail(u.Email) + if email == "" { + continue + } + u.Email = email + i, dup := idx[email] + if !dup { + idx[email] = len(out) + out = append(out, u) + continue + } + m := &out[i] + if m.ID == "" { + m.ID = u.ID + } + if m.Username == "" { + m.Username = u.Username + } + if m.PublicName == "" { + m.PublicName = u.PublicName + } + if u.GlobalRole == RoleSuperAdmin { + m.GlobalRole = RoleSuperAdmin + } + for slug, role := range u.ChannelRoles { + if m.ChannelRoles == nil { + m.ChannelRoles = make(map[string]ChannelRole) + } + if channelRoleLevels[role] > channelRoleLevels[m.ChannelRoles[slug]] { + m.ChannelRoles[slug] = role + } + } + } + return out +} + +// superAdminEmails is the ADMIN_USERS list, normalised and sorted so the +// stored users list comes out the same on every rebuild. +func superAdminEmails() []string { + seen := map[string]struct{}{} + var out []string + for _, e := range strings.Split(os.Getenv("ADMIN_USERS"), ",") { + e = normEmail(e) + if e == "" { + continue + } + if _, dup := seen[e]; dup { + continue + } + seen[e] = struct{}{} + out = append(out, e) + } + sort.Strings(out) + return out +} + +// initializePrivilegeUsers rebuilds the in-memory privilegesUsers map from Redis. +// At startup, call it directly (panicking is acceptable). From request handlers +// use the returned error instead of panicking so a transient Redis error does not +// crash the entire server. +func initializePrivilegeUsers() error { + admins := superAdminEmails() + isAdmin := make(map[string]struct{}, len(admins)) + for _, e := range admins { + isAdmin[e] = struct{}{} + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - privilegesUsers.Clear() - users, err := dbGetUsersList(ctx) - if err != nil && err != redis.Nil { - panic("Failed to get users list from database: " + err.Error()) + // Held across the transaction and the swap so that the snapshot swapped in + // is always the newest one written (see privilegesMu). Nothing below takes + // the lock: dbUpdateUsersList and notifyPrivilegesChanged only talk to + // Redis, and the subscriber's reload runs on its own goroutine after this + // unlocks. The cost is that concurrent role edits are serialised, each + // bounded by the 5 s context. + privilegesMu.Lock() + defer privilegesMu.Unlock() + + // The super-admin stamping is a read-modify-write of the same blob every role + // edit touches, so it goes through the guarded update too. + var merged []User + if err := dbUpdateUsersList(ctx, func(users []User) []User { + users = normalizeUsers(users) + emailToIdx := make(map[string]int, len(users)) + for i, u := range users { + emailToIdx[u.Email] = i + // ADMIN_USERS is the source of truth in both directions. The stamp + // below only ever added the role, so an address removed from the + // variable kept super-admin for as long as the users list existed. + // One boot with the variable unset or mistyped would otherwise + // persist the demotion of every super admin; an empty list is + // far more likely a deploy mistake than a real "nobody". + if u.GlobalRole == RoleSuperAdmin && len(admins) > 0 { + if _, still := isAdmin[u.Email]; !still { + users[i].GlobalRole = "" + } + } + } + + for _, email := range admins { + if i, exists := emailToIdx[email]; exists { + users[i].GlobalRole = RoleSuperAdmin + } else { + emailToIdx[email] = len(users) + users = append(users, User{ + Email: email, + GlobalRole: RoleSuperAdmin, + }) + } + } + + merged = users + return users + }); err != nil { + return fmt.Errorf("update users list: %w", err) } - existsEmails := make(map[string]bool) + + swapPrivilegeMapLocked(merged) + notifyPrivilegesChanged() + return nil +} + +// swapPrivilegeMapLocked makes users the live authorization set. The caller +// holds privilegesMu, and has held it since it read users from Redis. +// +// Authorization resolves live from this map, so it must never be empty even +// for an instant: clearing it first would make every request that landed in +// the gap look unprivileged. Upsert everything, then prune what is gone. +func swapPrivilegeMapLocked(users []User) { + seen := make(map[string]struct{}, len(users)) for _, user := range users { - existsEmails[user.Email] = true + privilegesUsers.Store(user.Email, user) + seen[user.Email] = struct{}{} } - for _, admin := range adminUsers { - if !existsEmails[admin] { - users = append(users, User{ - Username: "", - Email: admin, - Privileges: Privileges{ - Admin: true, - Moderator: true, - Writer: true, - }, - }) + privilegesUsers.Range(func(key, _ any) bool { + email, ok := key.(string) + if !ok { + privilegesUsers.Delete(key) + return true + } + if _, kept := seen[email]; !kept { + privilegesUsers.Delete(key) } + return true + }) +} + +// reloadPrivilegeUsers refreshes the live map from Redis without writing +// anything back. This is what the other replicas run when one of them changed +// the roles: before it, a role granted or revoked on one instance took effect +// on the others only after a restart, since each instance only rebuilt its +// own map from its own handlers. +func reloadPrivilegeUsers() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // Read and swap under one hold of the lock, or a reload that read before a + // rebuild wrote could swap its older snapshot in after the rebuild's. + privilegesMu.Lock() + defer privilegesMu.Unlock() + users, err := dbGetUsersList(ctx) + if err != nil { + return err } + swapPrivilegeMapLocked(normalizeUsers(users)) + return nil +} - for _, user := range users { - privilegesUsers.Store(user.Email, user) +// notifyPrivilegesChanged tells every replica to reload. Best effort: the +// periodic refresh covers a lost announcement. +func notifyPrivilegesChanged() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := rdb.Publish(ctx, privilegesReloadChannel, "reload").Err(); err != nil { + log.Printf("privileges: announce reload: %v\n", err) + } +} + +// startPrivilegesRefresh keeps this instance's map in step with the others: +// a subscription for immediate reloads plus a periodic one as the backstop. +func startPrivilegesRefresh() { + go func() { + ticker := time.NewTicker(privilegesRefreshInterval) + defer ticker.Stop() + for range ticker.C { + if err := reloadPrivilegeUsers(); err != nil { + log.Printf("privileges: periodic reload: %v\n", err) + } + } + }() + go func() { + for { + sub := rdb.Subscribe(context.Background(), privilegesReloadChannel) + for range sub.Channel() { + if err := reloadPrivilegeUsers(); err != nil { + log.Printf("privileges: reload on announcement: %v\n", err) + } + } + // go-redis reconnects a subscription by itself; the channel only + // closes when the subscription is torn down, so start a new one. + sub.Close() + time.Sleep(5 * time.Second) + } + }() +} + +// sessionUser resolves the privileged user record for the current session. +// The session cookie is used only as an identity (email) carrier — the roles +// themselves are read live from privilegesUsers so that a revocation takes +// effect immediately instead of after the 30 day cookie lifetime. +// A logged-in user with no entry in privilegesUsers is simply unprivileged. +func sessionUser(r *http.Request) (User, bool) { + session, _ := store.Get(r, cookieName) + s, ok := session.Values["user"].(Session) + if !ok || s.Email == "" { + return User{}, false + } + v, ok := privilegesUsers.Load(normEmail(s.Email)) + if !ok { + return User{}, false + } + u, ok := v.(User) + if !ok { + return User{}, false } + return u, true +} + +func isSuperAdmin(r *http.Request) bool { + u, ok := sessionUser(r) + return ok && u.GlobalRole == RoleSuperAdmin +} - if err := dbSetUsersList(ctx, users); err != nil { - panic("Failed to set users list in database: " + err.Error()) +func hasChannelRole(r *http.Request, slug string, minRole ChannelRole) bool { + s, ok := sessionEmail(r) + if !ok { + return false } + return emailHasChannelRole(normEmail(s.Email), slug, minRole) } -func (p Privileges) MarshalBinary() ([]byte, error) { - return json.Marshal(p) +// emailHasChannelRole is hasChannelRole without the session lookup: the live +// record for email (already normalised) is a super admin or holds at least +// minRole on slug. A long-lived handler (the SSE stream) resolves the session +// once and calls this per event, so a revocation reaches it without a +// reconnect; a Load on the sync.Map is all it costs. +func emailHasChannelRole(email, slug string, minRole ChannelRole) bool { + if email == "" { + return false + } + v, ok := privilegesUsers.Load(email) + if !ok { + return false + } + u, ok := v.(User) + if !ok { + return false + } + if u.GlobalRole == RoleSuperAdmin { + return true + } + role, exists := u.ChannelRoles[slug] + if !exists { + return false + } + return channelRoleLevels[role] >= channelRoleLevels[minRole] } -func (p *Privileges) UnmarshalBinary(data []byte) error { - return json.Unmarshal(data, p) +func requireSuperAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isSuperAdmin(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func protectedWithChannelRole(minRole ChannelRole, handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + if !hasChannelRole(r, slug, minRole) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + handler(w, r) + } } func getPrivilegeUsersList(w http.ResponseWriter, r *http.Request) { @@ -96,14 +393,44 @@ func setPrivilegeUsers(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() log.Println("Setting privileges for users:", req.List) - if err := dbSetUsersList(ctx, req.List); err != nil { + // Upsert-by-email inside the guarded transaction rather than a wholesale + // replace: the submitted list is a client-side snapshot, and blindly SETting + // it would silently revert any role granted concurrently through the other + // (WATCH-guarded) writers. Users absent from the submission are left alone; + // a future deletion feature needs an explicit deleted-emails field, not + // inference from absence. The super-admin flag is owned by ADMIN_USERS + // (see initializePrivilegeUsers) and is kept as stored, whatever the + // submission says. + if err := dbUpdateUsersList(ctx, func(current []User) []User { + current = normalizeUsers(current) + byEmail := make(map[string]int, len(current)) + for i, u := range current { + byEmail[u.Email] = i + } + for _, nu := range req.List { + nu.Email = normEmail(nu.Email) + if nu.Email == "" { + continue + } + if i, ok := byEmail[nu.Email]; ok { + nu.GlobalRole = current[i].GlobalRole + current[i] = nu + } else { + nu.GlobalRole = "" + byEmail[nu.Email] = len(current) + current = append(current, nu) + } + } + return current + }); err != nil { http.Error(w, "Failed to set users list", http.StatusInternalServerError) return } - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after setPrivilegeUsers: %v", err) + } - response := Response{Success: true} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + json.NewEncoder(w).Encode(Response{Success: true}) } diff --git a/backend/r2_cache.go b/backend/r2_cache.go new file mode 100644 index 00000000..8b8e46f6 --- /dev/null +++ b/backend/r2_cache.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "sync" + "time" +) + +// Reading a file used to cost two calls to R2 before the browser got +// anywhere: a HeadObject to make sure the blob was there, then a fresh +// pre-signed URL. The fresh URL differed on every request, so the browser +// could never reuse a cached copy and downloaded every image of the feed +// again on every visit. The caches below keep one answer of each for a while. +// +// Both are per process. The backend runs as a single instance (see +// DEPLOY.md), and r2Delete evicts the key here, so a blob this process +// removes is never reported present afterwards. + +const ( + // r2PresentTTL bounds how long a successful HeadObject is trusted. Blobs + // are content-addressed and only this process deletes them, so the TTL + // only matters for a delete made by some other tool. + r2PresentTTL = 10 * time.Minute + + // r2PresignTTL is how long a pre-signed URL stays valid at R2, and + // r2PresignReuse how long the same URL is handed out again. The gap is the + // minimum validity left when a browser follows a redirect it cached. + r2PresignTTL = time.Hour + r2PresignReuse = 30 * time.Minute + + // r2CacheMaxEntries caps each map; when full it is simply emptied. + r2CacheMaxEntries = 20000 +) + +type r2PresignEntry struct { + url string + at time.Time +} + +var ( + r2CacheMu sync.Mutex + r2PresentCache = map[string]time.Time{} + r2PresignCache = map[string]r2PresignEntry{} +) + +// r2ExistsCached is r2Exists for the read paths. Only a hit is remembered: a +// miss falls back to the local copy and must be re-checked once the +// migration has copied the blob. +func r2ExistsCached(ctx context.Context, key string) bool { + now := time.Now() + r2CacheMu.Lock() + at, ok := r2PresentCache[key] + r2CacheMu.Unlock() + if ok && now.Sub(at) < r2PresentTTL { + return true + } + if !r2Exists(ctx, key) { + return false + } + r2CacheMu.Lock() + if len(r2PresentCache) >= r2CacheMaxEntries { + r2PresentCache = map[string]time.Time{} + } + r2PresentCache[key] = now + r2CacheMu.Unlock() + return true +} + +// r2PresignCached returns a pre-signed GET for key and how long a browser may +// keep following it. The same URL is reused for r2PresignReuse, so repeat +// views hit the browser's cache instead of downloading the file again. +func r2PresignCached(ctx context.Context, key, disposition string) (string, time.Duration, error) { + cacheKey := key + "\x00" + disposition + now := time.Now() + r2CacheMu.Lock() + e, ok := r2PresignCache[cacheKey] + r2CacheMu.Unlock() + if ok { + if left := r2PresignReuse - now.Sub(e.at); left > time.Minute { + return e.url, left, nil + } + } + u, err := r2PresignURL(ctx, key, r2PresignTTL, disposition) + if err != nil { + return "", 0, err + } + r2CacheMu.Lock() + if len(r2PresignCache) >= r2CacheMaxEntries { + r2PresignCache = map[string]r2PresignEntry{} + } + r2PresignCache[cacheKey] = r2PresignEntry{url: u, at: now} + r2CacheMu.Unlock() + return u, r2PresignReuse, nil +} + +// r2ForgetKey drops every cached answer about key; r2Delete calls it. +func r2ForgetKey(key string) { + r2CacheMu.Lock() + defer r2CacheMu.Unlock() + delete(r2PresentCache, key) + prefix := key + "\x00" + for k := range r2PresignCache { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + delete(r2PresignCache, k) + } + } +} diff --git a/backend/r2_cache_test.go b/backend/r2_cache_test.go new file mode 100644 index 00000000..cf71af4d --- /dev/null +++ b/backend/r2_cache_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Pre-signing is local, so a client with fake credentials is enough. +func withFakeR2(t *testing.T) { + t.Helper() + prevClient, prevBucket := r2Client, r2Bucket + r2Client = s3.New(s3.Options{ + Region: "auto", + Credentials: credentials.NewStaticCredentialsProvider("ak", "sk", ""), + BaseEndpoint: aws.String("https://acct.r2.cloudflarestorage.com"), + UsePathStyle: true, + }) + r2Bucket = "bucket" + t.Cleanup(func() { + r2Client, r2Bucket = prevClient, prevBucket + r2CacheMu.Lock() + r2PresignCache = map[string]r2PresignEntry{} + r2PresentCache = map[string]time.Time{} + r2CacheMu.Unlock() + }) +} + +func TestR2PresignCachedReusesURL(t *testing.T) { + withFakeR2(t) + ctx := context.Background() + u1, left1, err := r2PresignCached(ctx, "files/aa/bb/aabb", "attachment") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(u1, "response-cache-control=") { + t.Fatalf("signed URL carries no cache header: %s", u1) + } + if left1 != r2PresignReuse { + t.Fatalf("fresh URL: left %v, want %v", left1, r2PresignReuse) + } + u2, left2, err := r2PresignCached(ctx, "files/aa/bb/aabb", "attachment") + if err != nil || u2 != u1 { + t.Fatalf("second call signed a new URL (%v): %s vs %s", err, u2, u1) + } + if left2 > left1 || left2 <= time.Minute { + t.Fatalf("reused URL: left %v", left2) + } + if u3, _, _ := r2PresignCached(ctx, "files/aa/bb/aabb", "other"); u3 == u1 { + t.Fatal("a different disposition reused the same URL") + } + + // An entry close to the end of its reuse window is replaced. + r2CacheMu.Lock() + e := r2PresignCache["files/aa/bb/aabb\x00attachment"] + e.at = time.Now().Add(-r2PresignReuse + 30*time.Second) + e.url = "stale" + r2PresignCache["files/aa/bb/aabb\x00attachment"] = e + r2CacheMu.Unlock() + if u4, _, _ := r2PresignCached(ctx, "files/aa/bb/aabb", "attachment"); u4 == "stale" { + t.Fatal("an almost expired URL was handed out") + } +} + +func TestR2ForgetKey(t *testing.T) { + withFakeR2(t) + ctx := context.Background() + r2CacheMu.Lock() + r2PresentCache["files/aa/bb/aabb"] = time.Now() + r2PresentCache["files/aa/bb/aabbcc"] = time.Now() + r2CacheMu.Unlock() + r2PresignCached(ctx, "files/aa/bb/aabb", "x") + r2PresignCached(ctx, "files/aa/bb/aabbcc", "x") + + r2ForgetKey("files/aa/bb/aabb") + + r2CacheMu.Lock() + defer r2CacheMu.Unlock() + if _, ok := r2PresentCache["files/aa/bb/aabb"]; ok { + t.Fatal("presence survived the delete") + } + if _, ok := r2PresignCache["files/aa/bb/aabb\x00x"]; ok { + t.Fatal("signed URL survived the delete") + } + if _, ok := r2PresentCache["files/aa/bb/aabbcc"]; !ok { + t.Fatal("another key was evicted") + } + if _, ok := r2PresignCache["files/aa/bb/aabbcc\x00x"]; !ok { + t.Fatal("another key's URL was evicted") + } +} diff --git a/backend/ratelimit.go b/backend/ratelimit.go new file mode 100644 index 00000000..1b930a05 --- /dev/null +++ b/backend/ratelimit.go @@ -0,0 +1,226 @@ +package main + +import ( + "math" + "net/http" + "strconv" + "sync" + "sync/atomic" + "time" + + "golang.org/x/time/rate" +) + +// limiterEntry pairs a limiter with its last use so idle entries can be swept: +// the maps below otherwise grow monotonically for the process lifetime, one +// entry per distinct key. +type limiterEntry struct { + l *rate.Limiter + lastSeen atomic.Int64 +} + +// limiterIdleTTL must cover the slowest refill window below, or a client can +// reset a limiter by idling: the channel-creation limiter refills one token +// per 20 minutes, so with a 10 minute TTL an account could create three +// channels, wait ten minutes, and get a fresh burst of three (a recreated +// limiter starts full). One hour is the time that limiter takes to refill +// completely, so sweeping after it changes nothing a client could notice. +const ( + limiterIdleTTL = 60 * time.Minute + limiterSweepInterval = 10 * time.Minute +) + +// allowOrRetryAfter takes one token if one is free right now. When the bucket is +// empty it answers 429 with a Retry-After saying exactly how long to wait, and +// — crucially — consumes nothing, so a refused request costs the caller no quota. +// +// Callers must place this immediately before the work being rationed, not at the +// top of the handler: spending a token on a request that then fails validation +// makes a user pay full quota for a typo, which on the hourly creation limiter +// meant three mistakes locked the account out for twenty minutes. +func allowOrRetryAfter(w http.ResponseWriter, l *rate.Limiter, msg string) bool { + res := l.Reserve() + if !res.OK() { + http.Error(w, msg, http.StatusTooManyRequests) + return false + } + if d := res.Delay(); d > 0 { + res.Cancel() + w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds())))) + http.Error(w, msg, http.StatusTooManyRequests) + return false + } + return true +} + +func init() { + go func() { + ticker := time.NewTicker(limiterSweepInterval) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-limiterIdleTTL).Unix() + sweep := func(m *sync.Map) { + m.Range(func(key, value any) bool { + if e, ok := value.(*limiterEntry); ok && e.lastSeen.Load() < cutoff { + m.Delete(key) + } + return true + }) + } + sweep(&uploadLimiters) + sweep(&channelCreateLimiters) + sweep(&slugCheckLimiters) + sweep(&reportLimiters) + // Support submissions are keyed by client IP when there is no + // session, so this is the one map an anonymous caller can add + // unbounded distinct keys to. Leaving it out of the sweep made it + // the only limiter map that grew for the process lifetime. + sweep(&supportSubmitLimiters) + // Keyed by the From of each inbound mail (support_inbound.go): + // as many distinct keys as there are senders. + sweep(&inboundMailLimiters) + sweep(&loginLimiters) + sweep(&importAuthLimiters) + sweep(&subscribeLimiters) + } + }() +} + +func getLimiter(m *sync.Map, mu *sync.Mutex, key string, newLimiter func() *rate.Limiter) *rate.Limiter { + if v, ok := m.Load(key); ok { + e := v.(*limiterEntry) + e.lastSeen.Store(time.Now().Unix()) + return e.l + } + mu.Lock() + defer mu.Unlock() + if v, ok := m.Load(key); ok { + e := v.(*limiterEntry) + e.lastSeen.Store(time.Now().Unix()) + return e.l + } + e := &limiterEntry{l: newLimiter()} + e.lastSeen.Store(time.Now().Unix()) + m.Store(key, e) + return e.l +} + +// uploadLimiters stores per-user-per-channel rate limiters for file uploads. +// Key: "userEmail:channelSlug" — 30 uploads/min (one every 2s), burst of 10. +var ( + uploadLimiters sync.Map + uploadLimiterMu sync.Mutex +) + +func getUploadLimiter(key string) *rate.Limiter { + return getLimiter(&uploadLimiters, &uploadLimiterMu, key, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(2*time.Second), 10) + }) +} + +// channelCreateLimiters throttles self-service channel creation per session +// user — 3 creations/hour, burst of 3. Creation is cheap for the caller and +// permanent for us, so the window is much wider than the upload limiter's. +var ( + channelCreateLimiters sync.Map + channelCreateMu sync.Mutex +) + +func channelCreateLimiter(email string) *rate.Limiter { + return getLimiter(&channelCreateLimiters, &channelCreateMu, email, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(20*time.Minute), 3) + }) +} + +// slugCheckLimiters throttles the slug-availability probe per session user. +// It is a single EXISTS, but it is also a slug enumeration oracle, so it is +// generous rather than free — 60 checks/min (one per second), burst of 10. +var ( + slugCheckLimiters sync.Map + slugCheckMu sync.Mutex +) + +func slugCheckLimiter(email string) *rate.Limiter { + return getLimiter(&slugCheckLimiters, &slugCheckMu, email, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(time.Second), 10) + }) +} + +// reportLimiters throttles message reports per user. Reporting is behind login +// but was otherwise unlimited, and every report is a permanent Redis write +// (a hash plus two sorted-set entries), so one user could grow the store +// without bound. 10 reports/min, burst of 5. +var ( + reportLimiters sync.Map + reportMu sync.Mutex +) + +func reportLimiter(email string) *rate.Limiter { + return getLimiter(&reportLimiters, &reportMu, email, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(6*time.Second), 5) + }) +} + +// loginLimiters throttles OAuth code exchanges per client. Each POST +// /auth/login costs a round trip to Google and a Redis write, and it was the +// only unauthenticated write path with no limit at all. Twenty attempts at +// once, then one every 2 seconds, is far above what people behind one shared +// address produce by signing in (once per device per month). +var ( + loginLimiters sync.Map + loginLimiterMu sync.Mutex +) + +func loginLimiter(key string) *rate.Limiter { + return getLimiter(&loginLimiters, &loginLimiterMu, key, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(2*time.Second), 20) + }) +} + +// importAuthLimiters counts FAILED API-key checks on the import endpoint per +// client and channel. The key is owner-chosen free text, and the endpoint is +// reachable without a session, so an unlimited number of wrong guesses made a +// short key brute-forceable at network speed. Successful imports are not +// charged, so a legitimate bulk import is unaffected. +var ( + importAuthLimiters sync.Map + importAuthMu sync.Mutex +) + +func importAuthLimiter(key string) *rate.Limiter { + return getLimiter(&importAuthLimiters, &importAuthMu, key, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(6*time.Second), 10) + }) +} + +// subscribeLimiters throttles push-subscription registrations per client. +var ( + subscribeLimiters sync.Map + subscribeLimiterMu sync.Mutex +) + +func subscribeLimiter(key string) *rate.Limiter { + return getLimiter(&subscribeLimiters, &subscribeLimiterMu, key, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(10*time.Second), 5) + }) +} + +func uploadRateLimit(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + + // Key by user identity so limits are per-user, not per-channel + session, _ := store.Get(r, cookieName) + user, _ := session.Values["user"].(Session) + key := user.Email + ":" + slug + if key == ":" { + key = slug // unauthenticated fallback (shouldn't happen, upload requires login) + } + + if !getUploadLimiter(key).Allow() { + http.Error(w, "too many uploads — please slow down", http.StatusTooManyRequests) + return + } + next(w, r) + } +} diff --git a/backend/reactions.go b/backend/reactions.go index 26dcc42b..e15469ae 100644 --- a/backend/reactions.go +++ b/backend/reactions.go @@ -14,28 +14,20 @@ func (r Reactions) MarshalBinary() ([]byte, error) { return json.Marshal(r) } -var emojis []string = []string{} - -func init() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - e, err := dbGetEmojisList(ctx) +func isAllowedEmoji(ctx context.Context, slug, emoji string) bool { + list, err := dbGetEmojisList(ctx, slug) if err != nil { - panic("Failed to load emojis list: " + err.Error()) + return false } - - emojis = e -} - -func isAllowedEmoji(emoji string) bool { - return slices.Contains(emojis, emoji) + return slices.Contains(list, emoji) } func setReactions(w http.ResponseWriter, r *http.Request) { session, _ := store.Get(r, cookieName) userId := session.Values["user"].(Session).ID + slug := channelSlugFromCtx(r) + var req struct { MessageID int `json:"messageId"` Emoji string `json:"emoji"` @@ -46,15 +38,15 @@ func setReactions(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - if req.MessageID <= 0 || !isAllowedEmoji(req.Emoji) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if req.MessageID <= 0 || !isAllowedEmoji(ctx, slug, req.Emoji) { http.Error(w, "Invalid message ID or reactions", http.StatusBadRequest) return } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := setReaction(ctx, req.MessageID, req.Emoji, userId); err != nil { + if err := setReaction(ctx, slug, req.MessageID, req.Emoji, userId); err != nil { http.Error(w, "Failed to set reactions", http.StatusInternalServerError) return } @@ -66,14 +58,27 @@ func setReactions(w http.ResponseWriter, r *http.Request) { } func getEmojisList(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + + list, err := dbGetEmojisList(ctx, slug) + if err != nil { + http.Error(w, "Failed to get emojis list", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(emojis) + json.NewEncoder(w).Encode(list) } func setEmojis(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + req := struct { Emojis []string `json:"emojis"` }{} @@ -84,18 +89,11 @@ func setEmojis(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - // if len(req.Emojis) == 0 { - // http.Error(w, "No emojis provided", http.StatusBadRequest) - // return - // } - - if err := dbSetEmojisList(ctx, req.Emojis); err != nil { + if err := dbSetEmojisList(ctx, slug, req.Emojis); err != nil { http.Error(w, "Failed to set emojis", http.StatusInternalServerError) return } - emojis = req.Emojis - var res Response res.Success = true diff --git a/backend/report.go b/backend/report.go index e98f8d1e..f8c714a8 100644 --- a/backend/report.go +++ b/backend/report.go @@ -3,9 +3,12 @@ package main import ( "context" "encoding/json" + "fmt" "log" "net/http" "time" + + "github.com/redis/go-redis/v9" ) type Report struct { @@ -39,6 +42,8 @@ func reportMessage(w http.ResponseWriter, r *http.Request) { defer cancel() defer r.Body.Close() + slug := channelSlugFromCtx(r) + var report Report if err := json.NewDecoder(r.Body).Decode(&report); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) @@ -51,13 +56,81 @@ func reportMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + // Everything the reporter may not choose is set here: an unbounded reason is + // a free write into Redis, a report against a nonexistent message is noise, + // and a client-sent closed:true would land a "closed" hash in the open queue. + if len(report.Reason) > 500 { + http.Error(w, "reason too long", http.StatusBadRequest) + return + } + n, err := rdb.Exists(ctx, fmt.Sprintf("channel:%s:messages:%d", slug, report.MessageId)).Result() + if err != nil { + http.Error(w, "Error saving report", http.StatusInternalServerError) + return + } + if n == 0 { + http.Error(w, "message not found", http.StatusBadRequest) + return + } + + // A known repeat is answered before the limiter: it is idempotent and + // creates nothing, so it must not burn a token the user's next real report + // needs. Read-only; the SADD below stays the atomic guard for the race. + reportersKey := fmt.Sprintf("channel:%s:message:%d:reporters", slug, report.MessageId) + if already, err := rdb.SIsMember(ctx, reportersKey, s.ID).Result(); err == nil && already { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) + return + } + + // Rate-limit per user: reporting was unlimited, and each report is a + // permanent write, so a loop was a cheap way to grow Redis without bound. + if !allowOrRetryAfter(w, reportLimiter(s.Email), "too many reports — please slow down") { + return + } + + // One report per user per message. SADD returns 0 when the reporter is + // already in the set, so a repeat report is acknowledged without creating + // another record. The set is dropped with the channel. + added, err := rdb.SAdd(ctx, reportersKey, s.ID).Result() + if err != nil { + http.Error(w, "Error saving report", http.StatusInternalServerError) + return + } + if added == 0 { + // Already reported by this user; idempotent success, no new record. + var response Response + response.Success = true + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + report.Closed = false report.CreatedAt = time.Now() report.ReporterID = s.ID report.ReportedEmail = s.Email report.ReporterName = s.Username + // Reports are read by the channel's moderators, who must not learn who the + // operator is. The reporters set above still dedups on the real id; it is + // never returned. + if isSuperAdmin(r) { + report.ReporterID, report.ReportedEmail, report.ReporterName = operatorAuthorId, "", operatorName + } - if err := dbReportMessage(ctx, &report); err != nil { + if err := dbReportMessage(ctx, slug, &report); err != nil { log.Println("Error saving report:", err) + // The dedup entry was added first (SADD is what makes the check + // atomic); left in place after a failed save it would make every retry + // look like a repeat and answer success without ever creating the + // report. Detached from ctx: the likeliest failure is that very + // deadline expiring inside the save, and go-redis refuses a command on + // an expired context, which left the entry in place anyway. + rctx, rcancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer rcancel() + if err := rdb.SRem(rctx, reportersKey, s.ID).Err(); err != nil { + log.Printf("reportMessage: %s: undo dedup for %s after failed save: %v\n", slug, s.ID, err) + } http.Error(w, "Error saving report", http.StatusInternalServerError) return } @@ -72,18 +145,26 @@ func getReports(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + status := ReportStatus(r.URL.Query().Get("status")) if !status.IsValid() { http.Error(w, "Invalid status", http.StatusBadRequest) return } - reports, err := dbGetReports(ctx, status) + reports, err := dbGetReports(ctx, slug, status) if err != nil { log.Printf("Error retrieving reports: %v\n", err) http.Error(w, "Error retrieving reports", http.StatusInternalServerError) return } + // Reports an operator filed before they were anonymised still hold their + // email, Google name and id. + ops := currentOperators() + for _, rep := range reports { + ops.anonymiseOperatorReport(rep) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(reports) @@ -93,6 +174,9 @@ func setReports(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() defer r.Body.Close() + + slug := channelSlugFromCtx(r) + var report Report if err := json.NewDecoder(r.Body).Decode(&report); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) @@ -101,7 +185,11 @@ func setReports(w http.ResponseWriter, r *http.Request) { report.UpdatedAt = time.Now() - if err := dbSetReports(ctx, &report); err != nil { + if err := dbSetReports(ctx, slug, &report); err != nil { + if err == redis.Nil { + http.Error(w, "report not found", http.StatusNotFound) + return + } http.Error(w, "Error saving reports", http.StatusInternalServerError) return } diff --git a/backend/report_dedup_test.go b/backend/report_dedup_test.go new file mode 100644 index 00000000..ab487c90 --- /dev/null +++ b/backend/report_dedup_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/redis/go-redis/v9" +) + +// One report per (user, message): reporting was unlimited and each report is a +// permanent write, so a loop grew Redis without bound. The dedup set caps it. +func TestReportDedupSetIsIdempotentPerUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "report-dedup" + const msgID = 42 + reportersKey := "channel:" + slug + ":message:42:reporters" + rdb.Del(ctx, reportersKey) + t.Cleanup(func() { rdb.Del(context.Background(), reportersKey) }) + + // First report from user A adds them; a repeat is a no-op. + first, _ := rdb.SAdd(ctx, reportersKey, "userA").Result() + if first != 1 { + t.Fatalf("first report by A: SAdd returned %d, want 1", first) + } + repeat, _ := rdb.SAdd(ctx, reportersKey, "userA").Result() + if repeat != 0 { + t.Errorf("repeat report by A: SAdd returned %d, want 0 (dedup)", repeat) + } + // A different user is counted separately. + other, _ := rdb.SAdd(ctx, reportersKey, "userB").Result() + if other != 1 { + t.Errorf("report by B: SAdd returned %d, want 1", other) + } + if n, _ := rdb.SCard(ctx, reportersKey).Result(); n != 2 { + t.Errorf("reporters set holds %d, want 2 (A and B, no duplicates)", n) + } + _ = msgID +} + +// The per-user report limiter admits its burst and then refuses. +func TestReportLimiterThrottles(t *testing.T) { + l := reportLimiter("report-limit-test@example.com") + allowed := 0 + for i := 0; i < 20; i++ { + if l.Allow() { + allowed++ + } + } + // Burst of 5, refill every 6s, so a tight loop admits ~5 then stops. + if allowed < 1 || allowed > 6 { + t.Errorf("admitted %d of 20 rapid reports, want roughly the burst of 5", allowed) + } +} + +// The dedup set must be removed when its channel is deleted, or it leaks. +func TestReportersSetRemovedOnChannelDelete(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "report-cleanup" + ch := &ChannelData{Slug: slug, Name: "N", CreatedAt: time.Now()} + if err := dbCreateChannel(ctx, ch); err != nil { + t.Fatalf("seed channel: %v", err) + } + // A message and a reporters set under it. + msgKey := "channel:" + slug + ":messages:7" + rdb.HSet(ctx, msgKey, "id", "7", "text", "x") + rdb.ZAdd(ctx, "channel:"+slug+":m_times", redis.Z{Score: 1, Member: msgKey}) + reportersKey := "channel:" + slug + ":message:7:reporters" + rdb.SAdd(ctx, reportersKey, "userA") + + if err := dbDeleteChannel(ctx, slug); err != nil { + t.Fatalf("delete channel: %v", err) + } + + if n, _ := rdb.Exists(ctx, reportersKey).Result(); n != 0 { + t.Errorf("reporters set survived channel deletion (leak): %s", reportersKey) + } +} diff --git a/backend/scheduled.go b/backend/scheduled.go index 586db4d9..ca8e6ffd 100644 --- a/backend/scheduled.go +++ b/backend/scheduled.go @@ -2,9 +2,14 @@ package main import ( "context" + "crypto/sha256" "encoding/json" + "fmt" + "log" "net/http" "time" + + "github.com/redis/go-redis/v9" ) func init() { @@ -12,47 +17,314 @@ func init() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for range ticker.C { - ctxGet, cancelGet := context.WithTimeout(context.Background(), 5*time.Second) - list, err := dbGetScheduledMessages(ctxGet) - cancelGet() - if err != nil { + runScheduledMessages() + } + }() +} + +const scheduledLockTTL = 55 * time.Second + +func scheduledLockKey(slug string) string { return "scheduled:lock:" + slug } + +// releaseScheduledLock deletes the claim only if it still holds our token, on a +// fresh context: the caller's context may already be expired (which would leave +// the lock lingering for its full TTL), and a plain DEL could release a lock a +// second instance has since acquired after ours expired. +var scheduledLockRelease = redis.NewScript(` + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + end + return 0 +`) + +func releaseScheduledLock(slug, token string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + scheduledLockRelease.Run(ctx, rdb, []string{scheduledLockKey(slug)}, token) +} + +// claimScheduled takes the per-channel dispatch claim for ttl, retrying +// briefly, and returns the token to release it with. Channel deletion holds +// it for the whole delete: a dispatch that straddled the delete wrote the +// pruned list and the due entry back after the keys were gone, and a channel +// re-created under the same slug inherited — and posted — them. +func claimScheduled(ctx context.Context, slug string, ttl, wait time.Duration) (string, bool) { + token := generatedRandomID(16) + if token == "" { + return "", false + } + attempts := int(wait / (200 * time.Millisecond)) + if attempts < 1 { + attempts = 1 + } + for attempt := 0; attempt < attempts; attempt++ { + ok, err := rdb.SetNX(ctx, scheduledLockKey(slug), token, ttl).Result() + if err == nil && ok { + return token, true + } + if ctx.Err() != nil { + return "", false + } + time.Sleep(200 * time.Millisecond) + } + return "", false +} + +// deferScheduledChannel pushes a channel's due score forward so a channel the +// dispatcher cannot serve (disabled, feature off) rotates out of the window +// of lowest scores. Left in place, such entries accumulated at the front of +// the due set until the bounded read returned nothing but them, and every +// channel with a genuinely due post behind them was never dispatched again. +// A re-enabled channel resumes within this delay, or on its next list save. +const scheduledSkipBackoff = 10 * time.Minute + +func deferScheduledChannel(slug string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rdb.ZAdd(ctx, "scheduled:due_channels", redis.Z{ + Score: float64(time.Now().Add(scheduledSkipBackoff).Unix()), + Member: slug, + }) +} + +// forgetScheduledChannel drops the due entry and the pending list of a +// channel that no longer exists. +func forgetScheduledChannel(slug string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pipe := rdb.Pipeline() + pipe.ZRem(ctx, "scheduled:due_channels", slug) + pipe.Del(ctx, fmt.Sprintf("channel:%s:scheduled_messages:list", slug)) + pipe.Del(ctx, scheduledDispatchedKey(slug)) + pipe.Exec(ctx) +} + +// scheduledDispatchedKey is the set of fingerprints of the entries the +// dispatcher has posted, kept for scheduledDispatchedTTL so that a stale +// client snapshot cannot resurrect one (see dropDispatchedScheduled). +func scheduledDispatchedKey(slug string) string { + return fmt.Sprintf("channel:%s:scheduled:dispatched", slug) +} + +// scheduledDispatchedTTL is how long a dispatched entry is remembered. A tab +// left open over a weekend still holds its snapshot, so an hour is too short; +// the set is one short line per post and is refreshed on every dispatch. +const scheduledDispatchedTTL = 7 * 24 * time.Hour + +// scheduledFingerprint identifies an entry by the two things a client hands +// back unchanged: its scheduled time and its text. +func scheduledFingerprint(m Message) string { + return fmt.Sprintf("%d:%x", m.Timestamp.UnixNano(), sha256.Sum256([]byte(m.Text))) +} + +// rememberScheduledDispatch records that msg was posted. Best effort: a lost +// write only leaves the old behaviour (a stale save can repost the entry). +func rememberScheduledDispatch(ctx context.Context, slug string, msg Message) { + key := scheduledDispatchedKey(slug) + pipe := rdb.Pipeline() + pipe.SAdd(ctx, key, scheduledFingerprint(msg)) + pipe.Expire(ctx, key, scheduledDispatchedTTL) + if _, err := pipe.Exec(ctx); err != nil { + log.Printf("rememberScheduledDispatch: %s: %v\n", slug, err) + } +} + +// dropDispatchedScheduled removes from a submitted list every entry the +// dispatcher has already posted. A tab that missed the dispatch event (a +// backgrounded phone, an SSE gap) still holds the entry with its past +// timestamp and hands it back on its next save, and the next tick posted, +// pushed and webhooked it a second time. Not an error: the rest of the save +// is what the user meant, and the entry is simply already done. +func dropDispatchedScheduled(ctx context.Context, slug string, messages []Message) []Message { + dispatched, err := rdb.SMembers(ctx, scheduledDispatchedKey(slug)).Result() + if err != nil || len(dispatched) == 0 { + return messages + } + seen := make(map[string]struct{}, len(dispatched)) + for _, fp := range dispatched { + seen[fp] = struct{}{} + } + kept := make([]Message, 0, len(messages)) + for _, m := range messages { + if _, ok := seen[scheduledFingerprint(m)]; ok { + log.Printf("updateScheduledMessages: %s: dropping an entry the scheduler already posted (due %s)\n", slug, m.Timestamp.Format(time.RFC3339)) + continue + } + kept = append(kept, m) + } + return kept +} + +// runScheduledMessages only processes channels that have at least one message +// due before now, using the "scheduled:due_channels" sorted set (score = earliest +// due timestamp). This avoids querying every channel every minute. +// maxDueChannelsPerTick bounds one scheduler pass. +const maxDueChannelsPerTick = 500 + +func runScheduledMessages() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + now := float64(time.Now().Unix()) + + // Get all channels with at least one message due by now + // Bounded: an unbounded read meant a post-outage catch-up, where every + // channel is due at once, produced a tick that ran far past its own budget + // while the next tick started anyway and piled up on top of it. Whatever + // does not fit is still due and is picked up by the following tick. + slugs, err := rdb.ZRangeByScore(ctx, "scheduled:due_channels", &redis.ZRangeBy{ + Min: "0", + Max: fmt.Sprintf("%f", now), + Count: maxDueChannelsPerTick, + }).Result() + if err != nil || len(slugs) == 0 { + return + } + + for _, slug := range slugs { + slug := slug + + // The outer budget was declared and then never consulted: every + // operation inside this loop builds its own context, so the loop could + // run indefinitely. Stop when the tick's time is up and leave the rest + // for the next one. + if ctx.Err() != nil { + log.Printf("runScheduledMessages: tick budget exhausted; %d channels deferred to the next tick\n", len(slugs)) + return + } + + // Claim the channel for this tick. Every instance runs this ticker, and + // dispatch is a read-then-write over the pending list with no atomicity, + // so without a claim two replicas post the same message twice. The value + // is a unique token so only the claimant can release it, and a per-slug + // context is used so one slow channel does not consume the outer budget + // and starve the SetNX of every remaining due channel this tick. + lockKey := scheduledLockKey(slug) + token := generatedRandomID(16) + lockCtx, lockCancel := context.WithTimeout(context.Background(), 5*time.Second) + ok, lerr := rdb.SetNX(lockCtx, lockKey, token, scheduledLockTTL).Result() + lockCancel() + if lerr != nil || !ok { + continue + } + + // The dispatcher is the one writer that never passes through + // channelMiddleware, so neither the super admin's kill switch nor the + // scheduled-messages feature toggle reached it: a disabled channel + // kept posting, firing its webhook and pushing to subscribers every + // minute. The pending list and the due-set entry are left untouched, + // so re-enabling the channel simply resumes it. + ctxCh, cancelCh := context.WithTimeout(context.Background(), 5*time.Second) + channel, cherr := dbGetChannel(ctxCh, slug) + cancelCh() + switch { + case cherr == redis.Nil: + // Deleted: nothing will ever serve this entry, and the slug is + // free to be re-created, so its leftovers go now. + forgetScheduledChannel(slug) + releaseScheduledLock(slug, token) + continue + case cherr != nil: + releaseScheduledLock(slug, token) + continue + case channel.Features.Disabled || !channel.Features.ScheduledMessages: + deferScheduledChannel(slug) + releaseScheduledLock(slug, token) + continue + } + + ctxGet, cancelGet := context.WithTimeout(context.Background(), 5*time.Second) + list, err := dbGetScheduledMessages(ctxGet, slug) + cancelGet() + if err != nil { + releaseScheduledLock(slug, token) + continue + } + + nowTime := time.Now() + // One snapshot per channel per tick, for the re-labelling below. + ops := currentOperators() + newList := make([]Message, 0) + for _, msg := range *list { + if !msg.Timestamp.Before(nowTime) { + newList = append(newList, msg) continue } - now := time.Now() - newList := make([]Message, 0) - for _, msg := range *list { - if msg.Timestamp.Before(now) { - go func(m *Message) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - m.ID = getMessageNextId(ctx) - m.Timestamp = time.Now() - m.Author = "Scheduled" - m.AuthorId = "0" - setMessage(ctx, m, false) - go SendWebhook(context.Background(), "create", m) - go pushFcmMessage(m) - }(&msg) - //*list = slices.Delete(*list, i, i+1) - } else { - newList = append(newList, msg) - } + // Posted synchronously: the save below erases everything not in + // newList, so a message that failed to post must stay pending + // instead of vanishing with only a log line. + postCtx, postCancel := context.WithTimeout(context.Background(), 5*time.Second) + id, ierr := getMessageNextId(postCtx, slug) + if ierr != nil { + log.Printf("Failed to allocate message id for scheduled post on %s: %v\n", slug, ierr) + postCancel() + newList = append(newList, msg) + continue + } + m := msg + m.ID = id + m.Timestamp = time.Now() + // Entries scheduled before the author was recorded carry no name; + // the literal is kept for them (and for the client, which still + // recognises it as a scheduled post). + if m.Author == "" { + m.Author = "Scheduled" + m.AuthorId = "0" + } + // An entry an operator scheduled before operators were anonymised + // still carries their name; the post it becomes must not. + ops.anonymiseOperatorAuthor(&m) + if serr := setMessage(postCtx, slug, &m, false); serr != nil { + log.Printf("Failed to post scheduled message on %s: %v\n", slug, serr) + postCancel() + newList = append(newList, msg) + continue } + // Remembered under the entry's own scheduled time (msg, not m): + // that is what a tab holding a stale snapshot hands back, and + // what updateScheduledMessages drops so the next tick cannot + // post it a second time. + rememberScheduledDispatch(postCtx, slug, msg) + // Posting can take a while under a degraded Redis; refresh the claim + // after each message so it cannot expire mid-run and let a second + // replica re-read the still-unsaved list and re-post everything. + rdb.Expire(postCtx, lockKey, scheduledLockTTL) + postCancel() + + go SendWebhook(context.Background(), slug, "create", &m) + go pushFcmMessage(slug, &m) + } + // The messages above are already posted; if this save fails they stay in + // the pending list and repost on the next tick, so the failure must not + // be swallowed. The save is a plain SET of the pruned list, so retrying + // is idempotent. + var saveErr error + for attempt := 0; attempt < 3; attempt++ { ctxSave, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) - dbSaveScheduledMessages(ctxSave, &newList) + saveErr = dbSaveScheduledMessages(ctxSave, slug, &newList) // also updates the sorted set cancelSave() + if saveErr == nil { + break + } + time.Sleep(time.Duration(attempt+1) * time.Second) } - }() + if saveErr != nil { + log.Printf("CRITICAL: failed to persist scheduled list for %s after dispatch: %v — dispatched messages will repost next tick\n", slug, saveErr) + } + + releaseScheduledLock(slug, token) + } } func getScheduledMessages(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - messages, err := dbGetScheduledMessages(ctx) + slug := channelSlugFromCtx(r) + + messages, err := dbGetScheduledMessages(ctx, slug) if err != nil { http.Error(w, "error getting messages", http.StatusInternalServerError) return @@ -67,13 +339,109 @@ func updateScheduledMessages(w http.ResponseWriter, r *http.Request) { defer cancel() defer r.Body.Close() + slug := channelSlugFromCtx(r) + var messages []Message if err := json.NewDecoder(r.Body).Decode(&messages); err != nil { http.Error(w, "error decoding messages", http.StatusBadRequest) return } + session, _ := store.Get(r, cookieName) + user, _ := session.Values["user"].(Session) + + // Any non-positive Unix timestamp (not just the exact zero time) would be + // stored as an entry the earliest-due computation in dbSaveScheduledMessages + // skips, and a lone such entry takes the channel out of the scheduler's due + // set entirely — stranding it forever. + for i, m := range messages { + if m.Timestamp.Unix() <= 0 { + http.Error(w, "scheduled message requires a timestamp", http.StatusBadRequest) + return + } + // Dispatch stores the text verbatim, so the cap the live write paths + // enforce has to hold here too. + if len(m.Text) > maxMessageTextLen { + http.Error(w, "text too long", http.StatusBadRequest) + return + } + // Only the fields a scheduled post is made of are kept. The dispatcher + // stores the struct as sent, so client-supplied views, deleted or + // reactions used to land in the live message hash — a "deleted" post + // born already hidden. is_ads stays: the composer's ad toggle is an + // ordinary writer control on the live path too. + typ := m.Type + if typ == "" { + typ = "md" + } + messages[i] = Message{Type: typ, Text: m.Text, Timestamp: m.Timestamp, IsAds: m.IsAds} + } + + // Take the same per-channel claim the dispatcher holds: dbSaveScheduledMessages + // is a blind SET of the whole list, so a save landing between the dispatcher's + // read and write would be overwritten by its stale snapshot — additions vanish + // and deleted messages get resurrected and posted. + lockKey := scheduledLockKey(slug) + token := generatedRandomID(16) + acquired := false + for attempt := 0; attempt < 10; attempt++ { + ok, err := rdb.SetNX(ctx, lockKey, token, 10*time.Second).Result() + if err == nil && ok { + acquired = true + break + } + time.Sleep(200 * time.Millisecond) + } + if !acquired { + w.Header().Set("Retry-After", "2") + http.Error(w, "scheduled messages busy, try again", http.StatusServiceUnavailable) + return + } + defer releaseScheduledLock(slug, token) + + // Entries the dispatcher has already posted are not saved back, whatever + // snapshot the client built the list from. + messages = dropDispatchedScheduled(ctx, slug, messages) + + // Dispatch used to post every scheduled message as the English literal + // "Scheduled", shown to moderators and sent to webhooks. The scheduler's + // own name is recorded now: an entry that already exists in the stored + // list (same time and text) keeps its original author, so a moderator + // editing one message does not re-attribute the others, and a new entry + // is stamped with the session — never with a client-supplied name. + // + // An operator's entries are signed operatorName, as addMessage signs an + // operator's post: the stored list is read back by every moderator, and + // the dispatcher posts the entry under the author recorded here. + ops := currentOperators() + if stored, err := dbGetScheduledMessages(ctx, slug); err == nil && stored != nil { + type authorKey struct { + at int64 + text string + } + authors := make(map[authorKey]Message, len(*stored)) + for _, sm := range *stored { + authors[authorKey{sm.Timestamp.UnixNano(), sm.Text}] = sm + } + for i := range messages { + if sm, ok := authors[authorKey{messages[i].Timestamp.UnixNano(), messages[i].Text}]; ok && sm.Author != "" { + messages[i].Author, messages[i].AuthorId = sm.Author, sm.AuthorId + // Recorded before operators were anonymised. + ops.anonymiseOperatorAuthor(&messages[i]) + } + } + } + superAdmin := isSuperAdmin(r) + for i := range messages { + if messages[i].Author == "" { + messages[i].Author = sessionDisplayName(user) + messages[i].AuthorId = user.ID + if superAdmin { + messages[i].Author, messages[i].AuthorId = operatorName, operatorAuthorId + } + } + } - if err := dbSaveScheduledMessages(ctx, &messages); err != nil { + if err := dbSaveScheduledMessages(ctx, slug, &messages); err != nil { http.Error(w, "error saving messages", http.StatusInternalServerError) return } diff --git a/backend/scheduled_dispatch_test.go b/backend/scheduled_dispatch_test.go new file mode 100644 index 00000000..417b0413 --- /dev/null +++ b/backend/scheduled_dispatch_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// A tab that missed the dispatch event still holds the entry the scheduler +// already posted and hands it back on its next save; the next tick then +// posted, pushed and webhooked it a second time. Dispatched entries are +// remembered by (scheduled time, text) and dropped from a submitted list. +func TestDispatchedScheduledEntriesAreDroppedFromSave(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "sched-dispatched" + key := scheduledDispatchedKey(slug) + rdb.Del(ctx, key) + t.Cleanup(func() { rdb.Del(context.Background(), key) }) + + at := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC) + posted := Message{Type: "md", Text: "בוקר טוב", Timestamp: at} + pending := Message{Type: "md", Text: "ערב טוב", Timestamp: at.Add(12 * time.Hour)} + // Same text, another time: a different entry, and it stays. + sameTextLater := Message{Type: "md", Text: "בוקר טוב", Timestamp: at.Add(24 * time.Hour)} + + // Nothing dispatched yet: the list passes through untouched. + if got := dropDispatchedScheduled(ctx, slug, []Message{posted, pending}); len(got) != 2 { + t.Fatalf("with nothing dispatched, %d of 2 entries kept", len(got)) + } + + rememberScheduledDispatch(ctx, slug, posted) + + got := dropDispatchedScheduled(ctx, slug, []Message{posted, pending, sameTextLater}) + if len(got) != 2 { + t.Fatalf("kept %d entries, want 2 (the posted one dropped): %+v", len(got), got) + } + if got[0].Text != pending.Text || !got[1].Timestamp.Equal(sameTextLater.Timestamp) { + t.Errorf("wrong entries kept: %+v", got) + } + + // The memory expires on its own; every dispatch refreshes it. + ttl, err := rdb.TTL(ctx, key).Result() + if err != nil || ttl <= 6*24*time.Hour || ttl > scheduledDispatchedTTL { + t.Errorf("dispatched set TTL = %v (%v), want about %v", ttl, err, scheduledDispatchedTTL) + } +} diff --git a/backend/seo.go b/backend/seo.go new file mode 100644 index 00000000..ea646f88 --- /dev/null +++ b/backend/seo.go @@ -0,0 +1,379 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "html" + "net/http" + "os" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/redis/go-redis/v9" +) + +// channelPagePathRe matches the public page of one channel, the URL people +// forward to each other. +var channelPagePathRe = regexp.MustCompile(`^/channel/([^/]+)/?$`) + +// appOnlyPathRe matches the SPA routes that exist but are nobody's search +// result: the login screen, the "my channels" hub, a channel's management +// screens and the super-admin panel. They answer 200 (the app must load) but +// carry noindex. Keep in step with frontend/src/app/app.routes.ts. +var appOnlyPathRe = regexp.MustCompile(`^/(login|channel|super-admin|channel/[^/]+/manage(/[^/]+)?)/?$`) + +// descriptionMetaRe finds the generic description index.html ships, so a +// page can carry its own instead of a second, conflicting tag. +var descriptionMetaRe = regexp.MustCompile(``) + +// titleRe matches the index.html ships (a default, so the tab is +// never blank in a dev server either), which every served page replaces. +var titleRe = regexp.MustCompile(`<title>[^<]*`) + +// appRootRe is the empty mount point the SPA boots into. Whatever the server +// puts inside it is shown until Angular starts, and then replaced. +var appRootRe = regexp.MustCompile(`\s*`) + +// defaultSiteName is the product name link previews and the tab show when +// the operator has not set a custom title. +const defaultSiteName = "הערוץ" + +// previewDescriptionMax is what WhatsApp, Telegram and Google show before +// cutting a description off. +const previewDescriptionMax = 160 + +// ogImageWidth/Height describe frontend/public/og.jpg, so a crawler can lay +// the card out before fetching the picture. +const ( + ogImageWidth = "1200" + ogImageHeight = "630" +) + +// requestOrigin is the absolute base of this deployment, which link previews +// need because crawlers do not resolve relative og:image / og:url values. +// The operator's configured domain wins; otherwise it is derived from the +// request, trusting the proxy's X-Forwarded-Proto only for the scheme name. +func requestOrigin(r *http.Request, cfg *SettingConfig) string { + if cfg != nil && strings.TrimSpace(cfg.ProjectDomain) != "" { + return strings.TrimRight(strings.TrimSpace(cfg.ProjectDomain), "/") + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if p := strings.ToLower(r.Header.Get("X-Forwarded-Proto")); p == "https" || p == "http" { + scheme = p + } + return scheme + "://" + r.Host +} + +// siteNameOf is the product name every page is branded with. +func siteNameOf(cfg *SettingConfig) string { + if cfg != nil { + if s := strings.TrimSpace(cfg.CustomTitle); s != "" { + return s + } + } + return defaultSiteName +} + +// channelVisibility is what a /channel/ page may say about its channel. +type channelVisibility int + +const ( + // channelUnknown: the lookup failed for a reason other than "no such + // channel" (Redis slow or down). The page is served neutrally and without + // noindex, so a transient error never drops a real channel from search. + channelUnknown channelVisibility = iota + channelPublic + // channelHidden: private or disabled. Indistinguishable from the outside + // beyond "not indexable" — same rule getFavicon applies. + channelHidden + channelMissing +) + +// lookupPageChannel loads the channel a /channel/ page is about. +func lookupPageChannel(ctx context.Context, slug string) (*ChannelData, channelVisibility) { + if !isPlausibleSlug(slug) { + return nil, channelMissing + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + ch, err := dbGetChannel(ctx, slug) + if err == redis.Nil { + return nil, channelMissing + } + if err != nil { + return nil, channelUnknown + } + if ch.Features.Disabled || ch.Features.RequireAuth { + return nil, channelHidden + } + return ch, channelPublic +} + +// previewText collapses whitespace and cuts a description to one preview line. +func previewText(s string) string { + s = strings.Join(strings.Fields(s), " ") + if utf8.RuneCountInString(s) <= previewDescriptionMax { + return s + } + runes := []rune(s) + return strings.TrimSpace(string(runes[:previewDescriptionMax-1])) + "…" +} + +func metaProperty(prop, content string) string { + return `` + "\n" +} + +func metaName(name, content string) string { + return `` + "\n" +} + +// absoluteURL turns the stored logo URL (an /api/channel/... path for +// uploads, or an external https URL) into something a crawler can fetch. +func absoluteURL(origin, u string) string { + if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { + return u + } + if !strings.HasPrefix(u, "/") { + u = "/" + u + } + return origin + u +} + +// jsonLD renders one structured-data block. json.Marshal escapes <, > and & +// as < etc., so free text (a channel name) cannot close the ` + "\n" +} + +// pageMeta is everything the server says about one page before any script +// runs: the head tags crawlers and link previews read, the status code, and +// the readable content shown until the app starts. +type pageMeta struct { + Status int + Title string + Description string + // Canonical is a path; empty means the page has no canonical URL (it is + // noindex, or the lookup behind it failed). + Canonical string + NoIndex bool + Image string + ImageAlt string + Card string + // StructuredData are complete `).FindAllStringSubmatch(body, -1) { + var v any + if err := json.Unmarshal([]byte(m[1]), &v); err != nil { + t.Errorf("%s: invalid JSON-LD: %v", p.Path, err) + } + } + + red := seoGet(t, p.Path+"/") + if red.Code != http.StatusMovedPermanently || red.Header().Get("Location") != p.Path { + t.Errorf("%s/: %d → %q, want 301 to the canonical path", p.Path, red.Code, red.Header().Get("Location")) + } + if !isReservedSlug(strings.TrimPrefix(p.Path, "/")) { + t.Errorf("%s is not a reserved slug; a channel could take its name", p.Path) + } + } +} + +func TestSiteVerification(t *testing.T) { + if got := verificationToken(``); got != "AbC-123_xyz" { + t.Errorf("pasted tag → %q", got) + } + if got := verificationToken(" 0123456789ABCDEF0123456789ABCDEF "); got != "0123456789ABCDEF0123456789ABCDEF" { + t.Errorf("bare token → %q", got) + } + for _, bad := range []string{`">`, "short", "has space inside"} { + if got := verificationToken(bad); got != "" { + t.Errorf("%q accepted as %q", bad, got) + } + } + if err := validateSettings(&Settings{{Key: "google_site_verification", Value: `">`).FindStringSubmatch(page) + if m == nil { + t.Fatalf("no JSON-LD on the home page") + } + var doc struct { + Graph []map[string]any `json:"@graph"` + } + if err := json.Unmarshal([]byte(m[1]), &doc); err != nil { + t.Fatalf("invalid JSON-LD: %v", err) + } + types := map[string]map[string]any{} + for _, n := range doc.Graph { + types[n["@type"].(string)] = n + } + for _, want := range []string{"Organization", "WebSite", "WebApplication", "FAQPage"} { + if types[want] == nil { + t.Errorf("home JSON-LD lacks %s", want) + } + } + if qs, _ := types["FAQPage"]["mainEntity"].([]any); len(qs) != len(homeFAQ) { + t.Errorf("FAQPage has %d questions, want %d", len(qs), len(homeFAQ)) + } + for _, f := range homeFAQ { + if !strings.Contains(page, f.Q) { + t.Errorf("question %q is not in the static HTML", f.Q) + } + } + // Nothing the product cannot back up. + if strings.Contains(page, "aggregateRating") || strings.Contains(page, `"Review"`) { + t.Errorf("home page claims ratings or reviews") + } +} diff --git a/backend/sse_hub.go b/backend/sse_hub.go new file mode 100644 index 00000000..b45c5a94 --- /dev/null +++ b/backend/sse_hub.go @@ -0,0 +1,372 @@ +package main + +import ( + "context" + "log" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// One Redis reader per channel stream, shared by every viewer of that channel. +// +// Each viewer used to run 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 ceiling +// on concurrent viewers. Now the ceiling is per *channel with at least one +// viewer*, and a channel with ten thousand readers costs exactly one connection. +// +// The hub reads from the tip of the stream. A viewer that needs earlier events +// (a reconnect carrying Last-Event-ID) catches up with its own bounded XRANGE +// before joining the live feed; see sseCatchUp. + +const ( + // sseSubBuffer is how many events a single slow viewer may fall behind by + // before it is dropped. Dropping one viewer is correct: the alternative is + // letting it block the hub and stall delivery for everybody else on the + // channel. A dropped viewer's browser reconnects with Last-Event-ID and + // catches up. + sseSubBuffer = 256 + + // sseHubBlock is how long the hub's read blocks before looping. It only + // needs to be short enough to notice that the hub has been told to stop. + sseHubBlock = 5 * time.Second + + // sseCatchUpLimit bounds a reconnecting viewer's replay. The stream itself + // only retains ~1000 entries (publishEvent), so this cannot silently skip + // events a client could otherwise have had. + sseCatchUpLimit = 1000 +) + +// sseChannelGoneEvent and sseChannelDisabledEvent are the control events +// published to a channel's stream right before the channel is deleted or +// disabled. Clients close their EventSource on either, and every hub that +// fans one out retires itself (see run): on the other replicas that is the +// only signal there is. They differ only so the client can show the right +// page: "not found" for a deleted channel, "disabled" for a disabled one. +const ( + sseChannelGoneEvent = `{"type":"channel-deleted"}` + sseChannelDisabledEvent = `{"type":"channel-disabled"}` +) + +// sseEvent is one stream entry, broadcast verbatim. Per-viewer transformation +// (author masking for sub-writer viewers) happens at the subscriber, because it +// depends on who is watching rather than on the event. +type sseEvent struct { + id string + data string +} + +type sseSubscriber struct { + ch chan sseEvent + // closed guards against a double close when a slow subscriber is dropped by + // the hub and then unsubscribes itself. + once sync.Once +} + +func (s *sseSubscriber) close() { + s.once.Do(func() { close(s.ch) }) +} + +type sseHub struct { + streamKey string + + mu sync.Mutex + subs map[*sseSubscriber]struct{} + // stop ends the reader goroutine once the last subscriber leaves. + stop context.CancelFunc +} + +var ( + sseHubsMu sync.Mutex + sseHubs = map[string]*sseHub{} +) + +// sseSubscribe joins the stream's hub, starting it if this is the first viewer. +// The returned function must be called when the viewer goes away. +func sseSubscribe(streamKey string) (*sseSubscriber, func()) { + sub := &sseSubscriber{ch: make(chan sseEvent, sseSubBuffer)} + + sseHubsMu.Lock() + hub, ok := sseHubs[streamKey] + if !ok { + ctx, cancel := context.WithCancel(context.Background()) + hub = &sseHub{ + streamKey: streamKey, + subs: map[*sseSubscriber]struct{}{}, + stop: cancel, + } + sseHubs[streamKey] = hub + go hub.run(ctx) + } + // The subscriber is inserted while sseHubsMu is still held, so the insertion + // is atomic with the registry lookup above. Releasing sseHubsMu first left a + // gap: a concurrent last-viewer unsubscribe re-checks emptiness under + // sseHubsMu (see sseUnsubscribe) and, not yet seeing this subscriber, could + // retire the hub — leaving this viewer attached to a stopped hub that never + // delivers an event and never closes its channel, so its handler sits on + // heartbeats forever and the browser never reconnects. Holding sseHubsMu + // across the add forces the retire to either see this subscriber (and not + // retire) or run first (so the lookup above misses the hub and builds a new + // one). The lock order here — sseHubsMu then hub.mu — matches the retire + // path, so it cannot deadlock. + hub.mu.Lock() + hub.subs[sub] = struct{}{} + hub.mu.Unlock() + sseHubsMu.Unlock() + + return sub, func() { sseUnsubscribe(hub, sub) } +} + +func sseUnsubscribe(hub *sseHub, sub *sseSubscriber) { + hub.mu.Lock() + delete(hub.subs, sub) + remaining := len(hub.subs) + hub.mu.Unlock() + sub.close() + + if remaining > 0 { + return + } + + // Last viewer of this channel left: retire the hub and its connection. + // Re-checked under the registry lock, because a new subscriber may have + // arrived in the meantime and must not be left attached to a stopped hub. + sseHubsMu.Lock() + defer sseHubsMu.Unlock() + if sseHubs[hub.streamKey] != hub { + return + } + hub.mu.Lock() + stillEmpty := len(hub.subs) == 0 + hub.mu.Unlock() + if stillEmpty { + delete(sseHubs, hub.streamKey) + hub.stop() + } +} + +// run is the single reader for one stream. It starts at the tip: viewers that +// need earlier events replay them themselves before joining. +func (h *sseHub) run(ctx context.Context) { + lastID := h.streamTip() + failures := 0 + + for { + if ctx.Err() != nil { + h.shutdown() + return + } + + streams, err := rdbEvents.XRead(ctx, &redis.XReadArgs{ + Streams: []string{h.streamKey, lastID}, + Count: 100, + Block: sseHubBlock, + }).Result() + + if ctx.Err() != nil { + h.shutdown() + return + } + if err != nil { + // redis.Nil is an ordinary "nothing arrived before the block + // elapsed". Anything else is retried, but not for ever: a stream + // that keeps failing must not spin. + if err != redis.Nil { + failures++ + if failures > maxStreamReadFailures { + log.Printf("SSE hub %s: giving up after %d failed reads: %v\n", h.streamKey, failures, err) + h.shutdown() + return + } + time.Sleep(500 * time.Millisecond) + } + continue + } + failures = 0 + + for _, stream := range streams { + for _, msg := range stream.Messages { + lastID = msg.ID + data, _ := msg.Values["data"].(string) + h.broadcast(sseEvent{id: msg.ID, data: data}) + if data == sseChannelGoneEvent || data == sseChannelDisabledEvent { + // The channel is going away. deleteChannel and the + // disable path stop the hub on their own instance + // directly; on every other replica this is the only + // signal, and a hub left running kept a stale writer's + // stream open — unmasked, and later fed by whatever + // tenant re-created the slug. Its viewers' reconnects + // meet channelMiddleware's 404/403 instead. + h.stop() + h.shutdown() + return + } + } + } + } +} + +// streamTip resolves the stream's current last id, so the reader has a fixed +// starting point. +// +// Reading from "$" instead would mean "entries added after THIS call was +// received", re-evaluated on every call: each time the block elapsed with +// nothing new, the next XREAD started from a fresh now, and an entry appended +// in the gap between the two calls — a round trip every five seconds — was +// never delivered to anyone on this instance. Falls back to "$" only when the +// tip cannot be read, which is the old behaviour rather than a replay of old +// events to every viewer. +func (h *sseHub) streamTip() string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + msgs, err := rdbEvents.XRevRangeN(ctx, h.streamKey, "+", "-", 1).Result() + if err != nil { + return "$" + } + if len(msgs) == 0 { + return "0-0" + } + return msgs[0].ID +} + +// broadcast delivers to every subscriber without ever blocking on one of them. +func (h *sseHub) broadcast(ev sseEvent) { + h.mu.Lock() + defer h.mu.Unlock() + for sub := range h.subs { + select { + case sub.ch <- ev: + default: + // This viewer is not draining fast enough. Drop it rather than + // stalling the channel for everyone; its browser will reconnect and + // resume from Last-Event-ID. + delete(h.subs, sub) + sub.close() + log.Printf("SSE hub %s: dropped a subscriber that fell more than %d events behind\n", + h.streamKey, sseSubBuffer) + } + } +} + +// shutdown releases every subscriber still attached when the reader stops, so +// their handlers return instead of waiting on a channel nothing will write to. +func (h *sseHub) shutdown() { + sseHubsMu.Lock() + if sseHubs[h.streamKey] == h { + delete(sseHubs, h.streamKey) + } + sseHubsMu.Unlock() + + h.mu.Lock() + defer h.mu.Unlock() + for sub := range h.subs { + delete(h.subs, sub) + sub.close() + } +} + +// sseCatchUp replays entries after lastID for a reconnecting viewer. +// +// The hub reads from the tip, so anything published between the viewer's last +// received event and the moment it subscribed exists only in the stream. The +// caller subscribes first and replays second, so an event arriving during the +// replay is buffered rather than lost; sending is then deduplicated on id. +// +// Control events are never replayed, only delivered live by the hub. +func sseCatchUp(ctx context.Context, streamKey, lastID string) []sseEvent { + if lastID == "" || lastID == "$" { + return nil + } + // "(" makes the range exclusive, so the client's last event is not resent. + msgs, err := rdbEvents.XRangeN(ctx, streamKey, "("+lastID, "+", sseCatchUpLimit).Result() + if err != nil { + if err != redis.Nil { + log.Printf("SSE catch-up on %s from %s failed: %v\n", streamKey, lastID, err) + } + return nil + } + out := make([]sseEvent, 0, len(msgs)) + for _, m := range msgs { + data, _ := m.Values["data"].(string) + if data == sseChannelGoneEvent || data == sseChannelDisabledEvent { + // A control entry outlives its moment: a disable leaves it in the + // stream, and a viewer whose tab was open then reconnects with a + // last id from before it. Once the channel is re-enabled that + // request passes channelMiddleware, and replaying the entry made + // the client mark a live channel disabled until a manual reload. + // A request that reached the replay has just passed the + // middleware, so any control entry behind it is stale by + // definition. A fresh one still arrives live through the hub. + continue + } + out = append(out, sseEvent{id: m.ID, data: data}) + } + return out +} + +// streamIDLessOrEqual reports whether a <= b for Redis stream ids of the form +// "-". Both parts are compared numerically, because a lexical compare +// misorders them: within one millisecond "1000-9" and "1000-55" have +// single- and double-digit sequence parts, and as strings "1000-9" sorts +// AFTER "1000-55". In the catch-up dedup that meant a brand-new event could be +// skipped as already-seen (message loss) or a duplicate not skipped, whenever a +// channel produced more than nine events in the same millisecond and a viewer +// reconnected across that point. +func streamIDLessOrEqual(a, b string) bool { + ams, aseq := splitStreamID(a) + bms, bseq := splitStreamID(b) + if ams != bms { + return ams < bms + } + return aseq <= bseq +} + +// splitStreamID parses "-" into its two numbers. A missing sequence +// part is 0, matching how Redis treats a bare "". Unparseable input yields +// zeros, which only makes the dedup more conservative (it never causes a live +// event to be dropped, since the ids the hub emits are always well-formed). +func splitStreamID(id string) (ms, seq uint64) { + dash := strings.IndexByte(id, '-') + if dash < 0 { + ms, _ = strconv.ParseUint(id, 10, 64) + return ms, 0 + } + ms, _ = strconv.ParseUint(id[:dash], 10, 64) + seq, _ = strconv.ParseUint(id[dash+1:], 10, 64) + return ms, seq +} + +// sseStopHub retires a stream's hub on this instance, closing every subscriber +// so its handler returns; a browser that reconnects then meets +// channelMiddleware, which refuses a deleted (404) or disabled (403) channel. +// Nothing did this before: a connection resolved its writer role once, when +// it opened, and kept it for as long as it stayed open — so a demoted writer's +// tab kept an unmasked stream, and after a delete and a re-creation of the +// slug it received the new tenant's events unmasked. +func sseStopHub(streamKey string) { + sseHubsMu.Lock() + hub, ok := sseHubs[streamKey] + if ok { + delete(sseHubs, streamKey) + } + sseHubsMu.Unlock() + if !ok { + return + } + hub.stop() + // Not left to the reader: it notices the cancel only once its blocking + // read returns, up to sseHubBlock later. shutdown tolerates running twice. + hub.shutdown() +} + +// sseHubCount reports how many hubs are running, i.e. how many Redis +// connections the event system is holding. Used by tests. +func sseHubCount() int { + sseHubsMu.Lock() + defer sseHubsMu.Unlock() + return len(sseHubs) +} diff --git a/backend/sse_hub_test.go b/backend/sse_hub_test.go new file mode 100644 index 00000000..ca873a92 --- /dev/null +++ b/backend/sse_hub_test.go @@ -0,0 +1,540 @@ +package main + +import ( + "context" + "fmt" + "sync" + "testing" + "time" +) + +func drainHubs(t *testing.T) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for sseHubCount() > 0 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } +} + +// waitFor polls until cond holds or the deadline passes. +func waitFor(t *testing.T, d time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(10 * time.Millisecond) + } + return cond() +} + +// The point of the hub: viewer count and Redis connection count are no longer +// the same number. Many viewers of one channel share a single reader. +func TestSSEHubOneReaderPerChannelRegardlessOfViewers(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + drainHubs(t) + const slug = "hub-fanout" + streamKey := "channel:" + slug + ":events" + t.Cleanup(func() { rdb.Del(context.Background(), streamKey) }) + + const viewers = 200 + subs := make([]*sseSubscriber, viewers) + stops := make([]func(), viewers) + for i := 0; i < viewers; i++ { + subs[i], stops[i] = sseSubscribe(streamKey) + } + defer func() { + for _, stop := range stops { + stop() + } + }() + + if got := sseHubCount(); got != 1 { + t.Fatalf("%d viewers of one channel produced %d hubs, want exactly 1", viewers, got) + } + + // Give the reader a moment to reach its first blocking read at the tip, + // so the event below is genuinely published after it started listening. + time.Sleep(300 * time.Millisecond) + publishEvent(ctx, slug, []byte(`{"type":"new-message","message":{"id":1}}`)) + + // Every viewer must receive it. + var received int + var mu sync.Mutex + var wg sync.WaitGroup + for i := 0; i < viewers; i++ { + wg.Add(1) + go func(s *sseSubscriber) { + defer wg.Done() + select { + case ev, ok := <-s.ch: + if ok && ev.data != "" { + mu.Lock() + received++ + mu.Unlock() + } + case <-time.After(10 * time.Second): + } + }(subs[i]) + } + wg.Wait() + + if received != viewers { + t.Errorf("%d of %d viewers received the event", received, viewers) + } + t.Logf("%d viewers, %d hub(s), %d deliveries", viewers, 1, received) +} + +// Distinct channels each get their own reader, and a channel's reader is +// retired once its last viewer leaves — otherwise connections leak per channel +// ever visited. +func TestSSEHubPerChannelLifecycle(t *testing.T) { + drainHubs(t) + + keys := []string{"channel:hub-a:events", "channel:hub-b:events", "channel:hub-c:events"} + stops := make([]func(), 0, len(keys)) + for _, k := range keys { + _, stop := sseSubscribe(k) + stops = append(stops, stop) + } + if got := sseHubCount(); got != len(keys) { + t.Fatalf("hubs = %d, want %d (one per channel)", got, len(keys)) + } + + // A second viewer on the first channel must not create a second reader. + _, stopExtra := sseSubscribe(keys[0]) + if got := sseHubCount(); got != len(keys) { + t.Errorf("a second viewer created a new hub: %d, want %d", got, len(keys)) + } + stopExtra() + if got := sseHubCount(); got != len(keys) { + t.Errorf("one of two viewers leaving retired the hub: %d, want %d", got, len(keys)) + } + + for _, stop := range stops { + stop() + } + if !waitFor(t, 5*time.Second, func() bool { return sseHubCount() == 0 }) { + t.Errorf("hubs still running after every viewer left: %d", sseHubCount()) + } +} + +// The hub reads from the tip, so a reconnecting viewer's history has to come +// from the stream. This is what stops a reconnect from silently losing events. +func TestSSECatchUpReplaysMissedEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "hub-catchup" + streamKey := "channel:" + slug + ":events" + rdb.Del(ctx, streamKey) + t.Cleanup(func() { rdb.Del(context.Background(), streamKey) }) + + publishEvent(ctx, slug, []byte(`{"n":1}`)) + publishEvent(ctx, slug, []byte(`{"n":2}`)) + publishEvent(ctx, slug, []byte(`{"n":3}`)) + + all, err := rdb.XRangeN(ctx, streamKey, "-", "+", 10).Result() + if err != nil || len(all) != 3 { + t.Fatalf("seed: got %d entries, err %v", len(all), err) + } + + // A client that last saw the first event replays exactly the other two. + missed := sseCatchUp(ctx, streamKey, all[0].ID) + if len(missed) != 2 { + t.Fatalf("replayed %d events, want 2", len(missed)) + } + if missed[0].id != all[1].ID || missed[1].id != all[2].ID { + t.Errorf("replayed the wrong entries: %s,%s want %s,%s", + missed[0].id, missed[1].id, all[1].ID, all[2].ID) + } + // The client's own last event must not be resent. + for _, ev := range missed { + if ev.id == all[0].ID { + t.Error("catch-up resent the event the client already had") + } + } + + // A fresh connection asks for no history. + if got := sseCatchUp(ctx, streamKey, "$"); got != nil { + t.Errorf("fresh connect replayed %d events, want none", len(got)) + } +} + +// A viewer that stops reading must not be able to stall delivery for everyone +// else on the channel. It is dropped instead; its browser reconnects. +func TestSSEHubDropsSlowSubscriberWithoutStallingOthers(t *testing.T) { + drainHubs(t) + + streamKey := "channel:hub-slow:events" + hub := &sseHub{streamKey: streamKey, subs: map[*sseSubscriber]struct{}{}, stop: func() {}} + + slow := &sseSubscriber{ch: make(chan sseEvent, sseSubBuffer)} + fast := &sseSubscriber{ch: make(chan sseEvent, sseSubBuffer)} + hub.subs[slow] = struct{}{} + hub.subs[fast] = struct{}{} + + // Fill the slow viewer's buffer; it never reads. + for i := 0; i < sseSubBuffer; i++ { + hub.broadcast(sseEvent{id: fmt.Sprintf("%d-0", i), data: "x"}) + // Keep the fast one drained so only the slow one backs up. + <-fast.ch + } + + // The next broadcast must not block, and must drop the slow viewer. + done := make(chan struct{}) + go func() { + hub.broadcast(sseEvent{id: "999-0", data: "y"}) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("broadcast blocked on a subscriber that stopped reading") + } + + hub.mu.Lock() + _, slowStillAttached := hub.subs[slow] + _, fastStillAttached := hub.subs[fast] + hub.mu.Unlock() + + if slowStillAttached { + t.Error("the slow subscriber was not dropped") + } + if !fastStillAttached { + t.Error("the healthy subscriber was dropped too") + } + // A dropped subscriber's channel is closed, so its handler returns. + if _, ok := <-slow.ch; ok { + // Buffered events drain first; keep draining to the close. + for range slow.ch { + } + } + // The healthy viewer still received the latest event. + select { + case ev := <-fast.ch: + if ev.id != "999-0" { + t.Errorf("healthy subscriber got %s, want 999-0", ev.id) + } + default: + t.Error("healthy subscriber received nothing after the slow one was dropped") + } +} + +// Redis stream ids are "-", and both parts are numbers. The catch-up +// dedup compares them, and a lexical compare misorders same-millisecond ids +// once the sequence crosses the single->double digit boundary. String compare +// would drop a genuinely new event as already-seen (message loss) or fail to +// skip a duplicate. +func TestStreamIDLessOrEqual(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"1000-0", "1000-0", true}, // equal + {"1000-9", "1000-55", true}, // seq 9 < 55 (the case string compare gets wrong) + {"1000-55", "1000-9", false}, // seq 55 > 9 + {"1000-9", "1000-10", true}, // 9 < 10 (string would say false) + {"1000-10", "1000-9", false}, // 10 > 9 (string would say true -> would drop new event) + {"999-0", "1000-0", true}, // different ms digit lengths + {"1000-0", "999-0", false}, + {"1526919030474-0", "1526919030474-1", true}, + {"1000", "1000-0", true}, // bare ms == "-0" + {"1000-5", "1000", false}, // seq 5 > implied 0 + } + for _, c := range cases { + if got := streamIDLessOrEqual(c.a, c.b); got != c.want { + t.Errorf("streamIDLessOrEqual(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } + + // The specific regression: string comparison would drop a new event. Prove + // the two disagree exactly where it matters, and that ours is right. + if ("1000-10" <= "1000-9") == streamIDLessOrEqual("1000-10", "1000-9") { + t.Error("test precondition: string and numeric compare should disagree on 1000-10 vs 1000-9") + } + if streamIDLessOrEqual("1000-10", "1000-9") { + t.Error("1000-10 (seq 10) must sort AFTER 1000-9 (seq 9); dropping it would lose a live message") + } +} + +// Subscribe/retire race: a viewer arriving on a channel exactly as its last +// existing viewer leaves must end up on a live hub (or cleanly closed), never +// attached to a hub that has just been retired and will never deliver again. +// +// The invariant checked after each round: the subscriber that is still attached +// receives a freshly published event. Under the pre-fix code, a subscriber +// occasionally landed on a retired hub and its channel stayed silent. Runs many +// rounds under -race. +func TestSSEHubSubscribeRetireRace(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + drainHubs(t) + const slug = "hub-race" + streamKey := "channel:" + slug + ":events" + t.Cleanup(func() { rdb.Del(context.Background(), streamKey) }) + + for round := 0; round < 300; round++ { + // One existing viewer, about to leave. + _, stopA := sseSubscribe(streamKey) + + var wg sync.WaitGroup + wg.Add(2) + // A leaves and B arrives concurrently — the exact contended window. + var subB *sseSubscriber + var stopB func() + go func() { defer wg.Done(); stopA() }() + go func() { defer wg.Done(); subB, stopB = sseSubscribe(streamKey) }() + wg.Wait() + + // B is the surviving viewer. It must be on a live hub: publish and it + // must receive the event. + if sseHubCount() == 0 { + t.Fatalf("round %d: hub was retired while a viewer (B) is still subscribed", round) + } + // Let the hub reach its blocking read, then publish. + time.Sleep(5 * time.Millisecond) + publishEvent(ctx, slug, []byte(`{"r":`+itoa(round)+`}`)) + + select { + case ev, ok := <-subB.ch: + if !ok { + t.Fatalf("round %d: surviving viewer's channel was closed (attached to a dead hub)", round) + } + _ = ev + case <-time.After(3 * time.Second): + t.Fatalf("round %d: surviving viewer received no event — attached to a retired hub", round) + } + stopB() + // Drain any buffered events so the next round starts clean. + drainHubs(t) + } +} + +// Retiring a hub closes every subscriber so its handler returns; a viewer +// that comes back builds a fresh hub or, after a delete or disable, meets +// channelMiddleware. This is what ends a stale writer's unmasked stream. +func TestSSEStopHubClosesSubscribers(t *testing.T) { + drainHubs(t) + const streamKey = "channel:hub-stop:events" + sub, stop := sseSubscribe(streamKey) + defer stop() + + sseStopHub(streamKey) + + select { + case _, ok := <-sub.ch: + if ok { + t.Fatal("got an event, want the subscriber channel closed") + } + case <-time.After(2 * time.Second): + t.Fatal("subscriber channel not closed by sseStopHub") + } + if got := sseHubCount(); got != 0 { + t.Errorf("hubs = %d after the stop, want 0", got) + } + // A stream with no hub is a no-op. + sseStopHub("channel:hub-never:events") +} + +// On every other replica the control event is the only signal: a hub that +// fans it out delivers it and then retires itself. +func TestSSEHubRetiresItselfOnChannelGoneEvent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + drainHubs(t) + const slug = "hub-gone" + streamKey := "channel:" + slug + ":events" + t.Cleanup(func() { rdb.Del(context.Background(), streamKey) }) + + sub, stop := sseSubscribe(streamKey) + defer stop() + time.Sleep(300 * time.Millisecond) + publishEvent(ctx, slug, []byte(sseChannelGoneEvent)) + + select { + case ev, ok := <-sub.ch: + if !ok || ev.data != sseChannelGoneEvent { + t.Fatalf("got (%q, %v), want the control event delivered first", ev.data, ok) + } + case <-time.After(10 * time.Second): + t.Fatal("control event not delivered") + } + select { + case _, ok := <-sub.ch: + if ok { + t.Fatal("got another event, want the subscriber channel closed") + } + case <-time.After(10 * time.Second): + t.Fatal("hub did not retire after fanning out the control event") + } + if !waitFor(t, 5*time.Second, func() bool { return sseHubCount() == 0 }) { + t.Errorf("hubs still registered after the control event: %d", sseHubCount()) + } +} + +// The disable path publishes the control event and then retires this +// instance's hub. Stopping the hub right after the publish closed every viewer +// before the hub's blocking read came back with the event, so the local +// viewers — every viewer, on a single replica — never learned the channel was +// disabled. The stop is now delayed; the hub retires itself on the event. +func TestSSEDisableDeliversControlEventToLocalViewer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + drainHubs(t) + // One slug per round: the delayed stop of one round must not hit the hub + // of the next, and each stream is dropped at the end. + const rounds = 8 + for i := 0; i < rounds; i++ { + slug := "hub-disable-" + itoa(i) + streamKey := "channel:" + slug + ":events" + t.Cleanup(func() { rdb.Del(context.Background(), streamKey) }) + + sub, stop := sseSubscribe(streamKey) + time.Sleep(300 * time.Millisecond) // the hub is blocked in XREAD at the tip + + announceChannelDisabled(ctx, slug) + + got := false + drain: + for { + select { + case ev, ok := <-sub.ch: + if !ok { + break drain + } + if ev.data == sseChannelDisabledEvent { + got = true + } + case <-time.After(10 * time.Second): + t.Fatalf("round %d: subscriber neither closed nor delivered", i) + } + } + stop() + if !got { + t.Fatalf("round %d: the local viewer's stream was closed without the channel-disabled event", i) + } + if !waitFor(t, 5*time.Second, func() bool { return sseHubCount() == 0 }) { + t.Fatalf("round %d: hub still registered after the control event: %d", i, sseHubCount()) + } + } +} + +// A disable leaves its control entry in the stream. A viewer that reconnects +// later with a last id from before it — after the channel was re-enabled, so +// the request passed channelMiddleware — must not have it replayed: the client +// would mark a live channel disabled until a manual reload. Live delivery of a +// fresh control event is the hub's job (TestSSEHubRetiresItselfOnChannelGoneEvent). +func TestSSECatchUpSkipsStaleControlEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const slug = "hub-catchup-control" + streamKey := "channel:" + slug + ":events" + rdb.Del(ctx, streamKey) + t.Cleanup(func() { rdb.Del(context.Background(), streamKey) }) + + publishEvent(ctx, slug, []byte(`{"type":"new-message","message":{"id":1}}`)) + tip, err := rdb.XRevRangeN(ctx, streamKey, "+", "-", 1).Result() + if err != nil || len(tip) != 1 { + t.Fatalf("seed: %v", err) + } + lastSeen := tip[0].ID + publishEvent(ctx, slug, []byte(sseChannelDisabledEvent)) // the disable + publishEvent(ctx, slug, []byte(sseChannelGoneEvent)) // a delete that failed past its fence leaves this one + const after = `{"type":"new-message","message":{"id":2}}` + publishEvent(ctx, slug, []byte(after)) // posted after the re-enable + + events := sseCatchUp(ctx, streamKey, lastSeen) + for _, ev := range events { + if ev.data == sseChannelDisabledEvent || ev.data == sseChannelGoneEvent { + t.Errorf("catch-up replayed a stale control event: %+v", events) + } + } + if len(events) != 1 || events[0].data != after { + t.Errorf("catch-up = %+v, want only the message posted after the re-enable", events) + } +} + +// deleteChannel publishes the control event before dbDeleteChannel fences the +// channel, and until the fence channelMiddleware still admits /events: a +// viewer whose stream had just been closed reconnects and builds a fresh hub +// that starts at the tip and never saw the event. On another replica no stop +// reaches it. dbDeleteChannel publishes the event again behind the fence, so +// such a hub retires too instead of outliving the channel on the deleted key. +func TestSSEHubBuiltBeforeFenceRetiresOnDelete(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + drainHubs(t) + const slug = "hub-delete-window" + streamKey := "channel:" + slug + ":events" + t.Cleanup(func() { + cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second) + defer ccancel() + _ = dbDeleteChannel(cctx, slug) + rdb.Del(cctx, streamKey) + }) + if err := dbCreateChannel(ctx, &ChannelData{Slug: slug, Name: "N", CreatedAt: time.Now()}); err != nil { + t.Fatalf("seed channel: %v", err) + } + + // The window: the first control event is already in the stream when the + // reconnect builds its hub, which therefore starts past it. + publishEvent(ctx, slug, []byte(sseChannelGoneEvent)) + sub, stop := sseSubscribe(streamKey) + defer stop() + time.Sleep(300 * time.Millisecond) + + if err := dbDeleteChannel(ctx, slug); err != nil { + t.Fatalf("dbDeleteChannel: %v", err) + } + + select { + case ev, ok := <-sub.ch: + if !ok || ev.data != sseChannelGoneEvent { + t.Fatalf("got (%q, %v), want the control event delivered to the hub built in the window", ev.data, ok) + } + case <-time.After(10 * time.Second): + t.Fatal("a hub built between the control event and the fence never got a control event") + } + select { + case _, ok := <-sub.ch: + if ok { + t.Fatal("got another event, want the subscriber channel closed") + } + case <-time.After(10 * time.Second): + t.Fatal("hub did not retire after the second control event") + } + if !waitFor(t, 5*time.Second, func() bool { return sseHubCount() == 0 }) { + t.Errorf("hubs still registered after the delete: %d", sseHubCount()) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/backend/sse_load_test.go b/backend/sse_load_test.go new file mode 100644 index 00000000..013e9164 --- /dev/null +++ b/backend/sse_load_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/redis/go-redis/v9" +) + +// TestSSEBlockingReadsDoNotStarveMainPool pins the isolation between the SSE +// event loop and the rest of the application. +// +// Every connected viewer sits in a blocking XREAD, which holds its pooled +// connection for the whole block and is re-issued immediately — so a viewer +// occupies one connection for as long as it is connected. While SSE shared the +// main pool, roughly PoolSize concurrent viewers consumed every connection in +// the process and ordinary requests (a login, an upload, a message read) queued +// behind them until they timed out. +// +// This drives more concurrent blocking reads than the main pool has connections +// and asserts that ordinary traffic is still served promptly. +func TestSSEBlockingReadsDoNotStarveMainPool(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if rdbEvents == nil { + t.Fatal("rdbEvents is nil: the SSE client must be initialised alongside rdb") + } + + const streamKey = "sse-load-test:events" + t.Cleanup(func() { + cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second) + defer ccancel() + rdb.Del(cctx, streamKey) + }) + + // More simultaneous blocking readers than the main pool has connections. If + // these ran on rdb, the pool would be fully consumed for the duration. + const readers = 140 + + readCtx, stopReaders := context.WithCancel(ctx) + defer stopReaders() + + var wg sync.WaitGroup + for i := 0; i < readers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Mirrors getEvents: block on the stream, re-issue immediately. + for readCtx.Err() == nil { + rdbEvents.XRead(readCtx, &redis.XReadArgs{ + Streams: []string{streamKey, "$"}, + Count: 50, + Block: 2 * time.Second, + }).Result() + } + }() + } + + // Let every reader get into its blocking read before measuring. + time.Sleep(1500 * time.Millisecond) + + // Ordinary application traffic, on the main client, while all of that is in + // flight. Each of these is a trivial command that should return immediately. + const probes = 25 + slowest := time.Duration(0) + for i := 0; i < probes; i++ { + start := time.Now() + if err := rdb.Ping(ctx).Err(); err != nil { + stopReaders() + wg.Wait() + t.Fatalf("ordinary request %d failed while %d SSE readers were blocked: %v", i, readers, err) + } + if d := time.Since(start); d > slowest { + slowest = d + } + } + + stopReaders() + wg.Wait() + + t.Logf("%d concurrent blocking SSE reads; slowest ordinary request %v", readers, slowest) + + // A starved pool shows up as multi-second waits (the blocking reads only + // release their connections when their block elapses). Served from its own + // pool, ordinary traffic is unaffected. + if slowest > time.Second { + t.Errorf("ordinary request took %v while %d SSE readers were blocked: the "+ + "event loop is starving the main connection pool", slowest, readers) + } +} diff --git a/backend/statistics.go b/backend/statistics.go index 87d905a7..fd84acad 100644 --- a/backend/statistics.go +++ b/backend/statistics.go @@ -3,15 +3,147 @@ package main import ( "context" "encoding/json" + "fmt" + "log" "net/http" + "strconv" + "strings" "sync" "sync/atomic" "time" ) -var openSSEConnections = atomic.Int64{} -var peakSSEConnections = &PeakSSEConnections{} -var peakMu = sync.Mutex{} +// The live SSE connection count is shared through Redis so that the statistics +// screen is correct on multi-instance deployments (the whole point of the +// Redis-Streams SSE layer). Each instance publishes its own local count as a +// timestamped field of one hash per channel; readers sum the fields that are +// still fresh, so a crashed instance's contribution ages out instead of +// inflating the total forever. + +// sseInstanceID identifies this process's field in the per-channel hash. +var sseInstanceID = generatedRandomID(8) + +// channelSSEConnections holds this instance's local per-channel counters. +var channelSSEConnections sync.Map // slug -> *atomic.Int64 + +// sseConnFreshness is how long a published per-instance count stays credible +// without a refresh; the refresher below re-publishes well within it. +const sseConnFreshness = 90 * time.Second + +func sseConnectionsKey(slug string) string { + return fmt.Sprintf("channel:%s:sse_connections", slug) +} + +// channelSSECountersMu orders counter creation against the refresher's +// removal of idle counters. Without it the refresher could read a counter as +// 0, a connect could then LoadOrStore that same counter, add 1 and publish +// it, and the refresher would still withdraw the field and drop the counter: +// a live connection counted nowhere, and its later disconnect taken off a +// fresh counter that another connection had just created. +var channelSSECountersMu sync.Mutex + +// adjustChannelSSE applies delta to the channel's local counter under the +// creation lock and returns the new value, clamped at zero. The add itself +// must happen under the lock: a counter created and then incremented outside +// it could be forgotten by the refresher in between, leaving a live +// connection counted on a counter no longer in the map. +func adjustChannelSSE(slug string, delta int64) int64 { + channelSSECountersMu.Lock() + defer channelSSECountersMu.Unlock() + v, _ := channelSSEConnections.LoadOrStore(slug, &atomic.Int64{}) + counter := v.(*atomic.Int64) + n := counter.Add(delta) + if n < 0 { + // A decrement that outruns its increment must not persist a negative + // datapoint into the statistics series. + counter.Store(0) + n = 0 + } + return n +} + +// forgetIdleChannelCounter drops the local counter for slug if it is still 0 +// once the refresher holds the creation lock; it reports whether it did. +func forgetIdleChannelCounter(slug string, counter *atomic.Int64) bool { + channelSSECountersMu.Lock() + defer channelSSECountersMu.Unlock() + if counter.Load() > 0 { + return false + } + channelSSEConnections.Delete(slug) + return true +} + +// publishLocalSSECount writes this instance's current count for slug. +func publishLocalSSECount(ctx context.Context, slug string, count int64) { + rdb.HSet(ctx, sseConnectionsKey(slug), sseInstanceID, + fmt.Sprintf("%d:%d", count, time.Now().Unix())) +} + +// dbGetSSEConnectionCount sums every instance's fresh published count for slug, +// pruning fields that have gone stale (crashed or scaled-down instances). +func dbGetSSEConnectionCount(ctx context.Context, slug string) int64 { + fields, err := rdb.HGetAll(ctx, sseConnectionsKey(slug)).Result() + if err != nil { + // Best effort: fall back to this instance's own view without + // creating a counter for a channel nobody is connected to. + if v, ok := channelSSEConnections.Load(slug); ok { + return v.(*atomic.Int64).Load() + } + return 0 + } + var total int64 + cutoff := time.Now().Add(-sseConnFreshness).Unix() + for field, v := range fields { + i := strings.LastIndex(v, ":") + if i <= 0 { + rdb.HDel(ctx, sseConnectionsKey(slug), field) + continue + } + count, cerr := strconv.ParseInt(v[:i], 10, 64) + ts, terr := strconv.ParseInt(v[i+1:], 10, 64) + if cerr != nil || terr != nil || ts < cutoff { + rdb.HDel(ctx, sseConnectionsKey(slug), field) + continue + } + total += count + } + return total +} + +// The refresher keeps long-lived quiet connections visible: without it an +// instance whose count has not changed for sseConnFreshness would age out of +// every reader's sum despite still serving clients. +func init() { + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + channelSSEConnections.Range(func(key, value any) bool { + slug := key.(string) + counter := value.(*atomic.Int64) + if counter.Load() > 0 { + publishLocalSSECount(ctx, slug, counter.Load()) + return true + } + // Nothing connected here: withdraw our field, then forget the + // slug — in that order, and only if the counter is still idle + // under the creation lock. A connect that lands between the + // two publishes its own count after the withdrawal and keeps + // the counter, so it is never lost. + rdb.HDel(ctx, sseConnectionsKey(slug), sseInstanceID) + if !forgetIdleChannelCounter(slug, counter) { + // A connect got in before the withdrawal and its publish + // may just have been wiped: publish the live count again. + publishLocalSSECount(ctx, slug, counter.Load()) + } + return true + }) + cancel() + } + }() +} type PeakSSEConnections struct { Value int64 `json:"value" redis:"value"` @@ -22,63 +154,66 @@ type Statistics struct { Labels []string `json:"labels"` } -func init() { +func increaseCounterSSE(slug string) { + local := adjustChannelSSE(slug, 1) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + publishLocalSSECount(ctx, slug, local) - p, _ := dbGetPeakSSEConnections(ctx) - peakMu.Lock() - defer peakMu.Unlock() - peakSSEConnections = p -} - -func statLogger() { - var old int64 - for { - new := openSSEConnections.Load() - if old != new { - dbSaveSSEStatistics(new) - old = new - } - time.Sleep(5 * time.Minute) + total := dbGetSSEConnectionCount(ctx, slug) + if total < local { + total = local } + + // dbSavePeakSSEConnections is a Lua max-write, so publishing the current + // total unconditionally can never regress a larger recorded peak. + go dbSavePeakSSEConnections(slug, &PeakSSEConnections{Value: total, Timestamp: time.Now()}) + go dbSaveSSEStatistics(slug, total) } -func increaseCounterSSE() { - new := openSSEConnections.Add(1) +func decreaseCounterSSE(slug string) { + local := adjustChannelSSE(slug, -1) - peakMu.Lock() - defer peakMu.Unlock() - if new > peakSSEConnections.Value { - peakSSEConnections.Value = new - peakSSEConnections.Timestamp = time.Now() - peak := *peakSSEConnections - go dbSavePeakSSEConnections(&peak) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // A viewer that disconnects after the channel was deleted (a tab that + // missed the channel-deleted event) would recreate the per-channel stats + // keys dbDeleteChannel just removed. Skipped only on a definite "gone": + // a Redis error keeps the old behaviour rather than losing a sample. + if exists, err := dbChannelExists(ctx, slug); err == nil && !exists { + return } -} + publishLocalSSECount(ctx, slug, local) -func decreaseCounterSSE() { - openSSEConnections.Add(-1) + go dbSaveSSEStatistics(slug, dbGetSSEConnectionCount(ctx, slug)) } func getStatistics(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - amount, err := dbGetUsersAmount(ctx) + slug := channelSlugFromCtx(r) + + amount, err := dbGetUsersAmount(ctx, slug) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } - s, err := dbGetSSEStatistics(ctx, 1000) + s, err := dbGetSSEStatistics(ctx, slug, 1000) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } - peakMu.Lock() - defer peakMu.Unlock() + // Redis is authoritative for both the live count and the peak, so every + // replica answers the same numbers and resetStatistics sticks everywhere. + peak, err := dbGetPeakSSEConnections(ctx, slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } response := struct { UsersAmount int64 `json:"usersAmount"` @@ -87,8 +222,8 @@ func getStatistics(w http.ResponseWriter, r *http.Request) { ConnectionsStatistics Statistics `json:"connectionsStatistics"` }{ UsersAmount: amount, - ConnectedUsersAmount: openSSEConnections.Load(), - PeakSSEConnections: peakSSEConnections, + ConnectedUsersAmount: dbGetSSEConnectionCount(ctx, slug), + PeakSSEConnections: peak, ConnectionsStatistics: *s, } @@ -96,15 +231,47 @@ func getStatistics(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// resetStatistics clears everything the statistics endpoint serves: the +// recorded peak AND the monthly connection series. The peak lives only in +// Redis (max-written, read back on demand), so deleting the key is a complete +// reset on every replica. func resetStatistics(w http.ResponseWriter, r *http.Request) { - peakMu.Lock() - defer peakMu.Unlock() - peakSSEConnections.Value = 0 - peakSSEConnections.Timestamp = time.Time{} - p := *peakSSEConnections - go dbSavePeakSSEConnections(&p) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + // Pipelined, and flushed per channel. Each channel means one peak key plus + // 11 years x 12 months of series keys: issued one at a time that is ~133 + // sequential round trips per channel, so a few thousand channels ran well + // past this handler's own deadline and left statistics half-reset — with + // every Del's error discarded, silently. dbDeleteChannel already batches + // the same key set this way. + for _, ch := range channels { + if err := ctx.Err(); err != nil { + // Out of time: say so rather than reporting a reset that did not finish. + log.Printf("resetStatistics: aborted after partial reset: %v\n", err) + http.Error(w, "reset did not complete", http.StatusGatewayTimeout) + return + } + pipe := rdb.Pipeline() + pipe.Del(ctx, fmt.Sprintf("channel:%s:peak_sse_connections", ch.Slug)) + // The monthly series keys have no index; the shared helper enumerates + // the same month/year space channel deletion uses. + for _, key := range sseStatisticsKeysFor(ch.Slug) { + pipe.Del(ctx, key) + } + if _, err := pipe.Exec(ctx); err != nil { + log.Printf("resetStatistics: %s: clearing statistics failed: %v\n", ch.Slug, err) + } + } var response Response response.Success = true + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } diff --git a/backend/storage.go b/backend/storage.go new file mode 100644 index 00000000..dc8cdf1b --- /dev/null +++ b/backend/storage.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "io" + "log" + "os" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +var r2Client *s3.Client +var r2Bucket string +var r2PublicURL string +var r2Enabled bool + +func initR2() { + accountID := os.Getenv("R2_ACCOUNT_ID") + accessKey := os.Getenv("R2_ACCESS_KEY_ID") + secretKey := os.Getenv("R2_SECRET_ACCESS_KEY") + r2Bucket = os.Getenv("R2_BUCKET_NAME") + // Optional: https://pub-xxx.r2.dev. Trailing slashes are dropped because + // the key is appended with its own slash, and a doubled one addresses a + // different (leading-slash) object on S3-style stores. The sample.env + // placeholder is refused outright: copied as-is it redirected every file + // to a host that does not exist, with nothing in the logs. + r2PublicURL = strings.TrimRight(strings.TrimSpace(os.Getenv("R2_PUBLIC_URL")), "/") + if strings.Contains(r2PublicURL, "pub-xxxx") { + log.Printf("R2_PUBLIC_URL %q looks like the sample placeholder; ignoring it and using pre-signed URLs\n", r2PublicURL) + r2PublicURL = "" + } + + // sample.env shipped these as "your_..." placeholders; copied as-is they + // pass the non-empty test below, R2 comes up against a host that does not + // exist and every upload fails with an opaque 500. + for _, v := range []string{accountID, accessKey, secretKey, r2Bucket} { + if strings.HasPrefix(v, "your_") { + log.Println("WARNING: R2_* still holds a sample.env placeholder; R2 disabled, using local file storage") + return + } + } + + if accountID == "" || accessKey == "" || secretKey == "" || r2Bucket == "" { + log.Println("R2 not configured, using local file storage") + return + } + + endpoint := "https://" + accountID + ".r2.cloudflarestorage.com" + + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + config.WithRegion("auto"), + ) + if err != nil { + log.Printf("R2 config error: %v — falling back to local storage\n", err) + return + } + + r2Client = s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + }) + + r2Enabled = true + log.Printf("R2 storage enabled (bucket: %s)\n", r2Bucket) +} + +// r2ObjectKey returns the R2 object key for a file hash. +func r2ObjectKey(hash string) string { + return "files/" + hash[:2] + "/" + hash[2:4] + "/" + hash +} + +// r2Upload uploads a file to R2. Returns an error if failed. +func r2Upload(ctx context.Context, key string, body io.Reader, contentType string) error { + _, err := r2Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(r2Bucket), + Key: aws.String(key), + Body: body, + ContentType: aws.String(contentType), + }) + return err +} + +// r2Exists checks if an object already exists in R2 (deduplication). +func r2Exists(ctx context.Context, key string) bool { + _, err := r2Client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(r2Bucket), + Key: aws.String(key), + }) + return err == nil +} + +// r2Download downloads an object from R2 and returns its body. +// Used as a fallback when neither public URL nor pre-signed URL is available. +func r2Download(ctx context.Context, key string) (io.ReadCloser, *string, error) { + result, err := r2Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(r2Bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, nil, err + } + return result.Body, result.ContentType, nil +} + +// r2PresignURL generates a short-lived pre-signed URL for a private R2 object. +// The client fetches the file directly from R2, bypassing the backend entirely. +// r2PresignURL signs a GET for key. disposition, when set, is baked into the +// signed URL as the Content-Disposition R2 will answer with: the header the +// backend sets on its own 302 is discarded by the browser along with the +// redirect, so without this a private-bucket download was saved under the +// object's hash with no extension. +func r2PresignURL(ctx context.Context, key string, ttl time.Duration, disposition string) (string, error) { + presignClient := s3.NewPresignClient(r2Client) + in := &s3.GetObjectInput{ + Bucket: aws.String(r2Bucket), + Key: aws.String(key), + } + if disposition != "" { + in.ResponseContentDisposition = aws.String(disposition) + } + // R2 sends no caching header of its own; without one the browser would + // fetch the same signed URL again instead of using its copy. + in.ResponseCacheControl = aws.String("private, max-age=3600") + req, err := presignClient.PresignGetObject(ctx, in, s3.WithPresignExpires(ttl)) + if err != nil { + return "", err + } + return req.URL, nil +} + +// r2Delete removes an object from R2. +func r2Delete(ctx context.Context, key string) error { + r2ForgetKey(key) + _, err := r2Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(r2Bucket), + Key: aws.String(key), + }) + return err +} diff --git a/backend/storage_handlers.go b/backend/storage_handlers.go new file mode 100644 index 00000000..a14b299a --- /dev/null +++ b/backend/storage_handlers.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "encoding/json" + "math" + "net/http" + "time" + + "github.com/go-chi/chi" +) + +// ─── Storage Info Response ──────────────────────────────────────────────────── + +type StorageInfo struct { + UsedBytes int64 `json:"usedBytes"` + QuotaBytes int64 `json:"quotaBytes"` + UsedPercent float64 `json:"usedPercent"` + AutoCleanup bool `json:"autoCleanup"` + // Warning levels: "ok" | "warning" (>80%) | "critical" (>90%) + Level string `json:"level"` +} + +// gbToBytes converts a GB quota to bytes, reporting whether the value is usable. +// A negative quota makes every upload fail the used<=quota test platform-wide +// while the dashboard still shows "ok"; a value so large that gb*2^30 overflows +// int64 wraps to a negative number and bypasses a plain ">= 0" guard, producing +// the same silent breakage. Both are rejected here. +func gbToBytes(gb float64) (int64, bool) { + const maxInt64 = float64(1<<63 - 1) + bytesFloat := gb * (1024 * 1024 * 1024) + if gb < 0 || bytesFloat > maxInt64 { + return 0, false + } + return int64(bytesFloat), true +} + +func buildStorageInfo(ctx context.Context, slug string) (*StorageInfo, error) { + quota, err := dbGetEffectiveStorageQuota(ctx, slug) + if err != nil { + return nil, err + } + used, err := dbGetChannelStorageUsed(ctx, slug) + if err != nil { + return nil, err + } + autoCleanup, err := dbGetChannelAutoCleanup(ctx, slug) + if err != nil { + return nil, err + } + + var pct float64 + if quota > 0 { + // One decimal: the super-admin storage bar binds the value as-is and + // printed the raw division ("43.333333333333336%"). + pct = math.Round(float64(used)/float64(quota)*1000) / 10 + } + + level := "ok" + if pct >= 90 { + level = "critical" + } else if pct >= 80 { + level = "warning" + } + + return &StorageInfo{ + UsedBytes: used, + QuotaBytes: quota, + UsedPercent: pct, + AutoCleanup: autoCleanup, + Level: level, + }, nil +} + +// ─── Channel Owner Handlers ─────────────────────────────────────────────────── + +// GET /api/channel/{slug}/admin/storage +func getChannelStorageInfo(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + info, err := buildStorageInfo(ctx, slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// POST /api/channel/{slug}/admin/storage/auto-cleanup +func setChannelAutoCleanup(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if err := dbSetChannelAutoCleanup(ctx, slug, req.Enabled); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +// ─── Super Admin Handlers ───────────────────────────────────────────────────── + +type GlobalStorageConfig struct { + DefaultQuotaGB float64 `json:"defaultQuotaGb"` // in GB for display +} + +// GET /api/super-admin/storage/config +func getSuperAdminStorageConfig(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + quotaBytes, err := dbGetGlobalStorageQuota(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + cfg := GlobalStorageConfig{ + DefaultQuotaGB: float64(quotaBytes) / (1024 * 1024 * 1024), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cfg) +} + +// POST /api/super-admin/storage/config +func setSuperAdminStorageConfig(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + var req GlobalStorageConfig + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // A negative quota would make every upload fail the used+size <= quota test + // platform-wide while the dashboard still shows "ok" (pct math is guarded by + // quota > 0), so it must be rejected up front. Zero is refused too: unlike a + // per-channel quota, the global default has no "use the default" meaning — + // a cleared form field posted 0, and 0 made every channel's storage + // unlimited while the dashboards showed "of 0 B". + bytes, ok := gbToBytes(req.DefaultQuotaGB) + if !ok || req.DefaultQuotaGB <= 0 { + http.Error(w, "quota must be a positive number of gigabytes", http.StatusBadRequest) + return + } + if err := dbSetGlobalStorageQuota(ctx, bytes); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +type ChannelStorageConfig struct { + QuotaGB float64 `json:"quotaGb"` // 0 = use global + StorageInfo StorageInfo `json:"storageInfo"` // current usage +} + +// GET /api/super-admin/channels/{slug}/storage +func getSuperAdminChannelStorage(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + if !requireExistingChannel(ctx, w, slug) { + return + } + + quotaBytes, err := dbGetChannelStorageQuota(ctx, slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + info, err := buildStorageInfo(ctx, slug) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + cfg := ChannelStorageConfig{ + QuotaGB: float64(quotaBytes) / (1024 * 1024 * 1024), + StorageInfo: *info, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cfg) +} + +// PUT /api/super-admin/channels/{slug}/storage +func setSuperAdminChannelStorage(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + // Like the other super-admin writers: a quota written for a slug that + // does not exist is an orphan key behind a success response. + if !requireExistingChannel(ctx, w, slug) { + return + } + + var req struct { + QuotaGB float64 `json:"quotaGb"` // 0 = use global + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + bytes, ok := gbToBytes(req.QuotaGB) + if !ok { + http.Error(w, "quota must be between 0 and a reasonable maximum (0 = use global)", http.StatusBadRequest) + return + } + if err := dbSetChannelStorageQuota(ctx, slug, bytes); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} diff --git a/backend/storage_race_test.go b/backend/storage_race_test.go new file mode 100644 index 00000000..c3484819 --- /dev/null +++ b/backend/storage_race_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestStorageQuotaConcurrentUploadsRespectQuota pins the upload path's quota +// accounting under concurrency. +// +// This used to be a check-then-act: enforceStorageQuota read used_bytes, +// compared, and the counter only moved after the blob was written — a window +// spanning a full R2 PutObject (60s budget). Every request starting inside that +// window read the same "used" and passed a check only one of them should have. +// reserveStorageQuota now claims the bytes with an atomic INCRBY and judges its +// own post-increment total, so concurrent callers get distinct values. +// +// The invariant: once the dust settles, a channel with auto-cleanup off must +// never be storing more than its quota. +func TestStorageQuotaConcurrentUploadsRespectQuota(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + const slug = "quota-race-test" + const quota = 10_000 + const fileSize = 1_000 + const workers = 40 // 40 x 1000 = 40,000 bytes offered against a 10,000 quota + + if err := dbSetChannelStorageQuota(ctx, slug, quota); err != nil { + t.Fatalf("set quota: %v", err) + } + rdb.Del(ctx, "channel:"+slug+":storage:used_bytes") + // Auto-cleanup off: exceeding the quota must be refused outright rather + // than making room, which is what makes the invariant checkable. + rdb.Del(ctx, "channel:"+slug+":storage:auto_cleanup") + t.Cleanup(func() { + cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second) + defer ccancel() + rdb.Del(cctx, + "channel:"+slug+":storage:used_bytes", + "channel:"+slug+":storage:quota_bytes", + "channel:"+slug+":storage:auto_cleanup", + ) + }) + + var admitted int64 + var start sync.WaitGroup + var done sync.WaitGroup + start.Add(1) + + for i := 0; i < workers; i++ { + done.Add(1) + go func() { + defer done.Done() + // Line every worker up so they hit the check in the same window, + // which is exactly the production shape: a burst of uploads. + start.Wait() + if err := reserveStorageQuota(ctx, slug, fileSize); err != nil { + return // refused, as it should be once the quota is reached + } + atomic.AddInt64(&admitted, 1) + // Stands in for the blob write the real upload does while holding + // its reservation — in production an R2 PutObject with a 60-second + // budget. The reservation must already be counted for the whole of + // it, which is exactly what the old code got wrong. + time.Sleep(50 * time.Millisecond) + }() + } + + start.Done() + done.Wait() + + used, err := dbGetChannelStorageUsed(ctx, slug) + if err != nil { + t.Fatalf("read used: %v", err) + } + + t.Logf("quota=%d admitted=%d stored=%d bytes (%.1fx quota)", + quota, atomic.LoadInt64(&admitted), used, float64(used)/float64(quota)) + + if used > quota { + t.Errorf("stored %d bytes against a %d byte quota: %d concurrent uploads were "+ + "admitted when only %d fit — the reservation is not atomic", + used, quota, atomic.LoadInt64(&admitted), quota/fileSize) + } + if want := int64(quota / fileSize); atomic.LoadInt64(&admitted) != want { + t.Errorf("admitted %d uploads, want exactly %d: the quota should be filled "+ + "completely and not one file further", atomic.LoadInt64(&admitted), want) + } +} diff --git a/backend/support.go b/backend/support.go new file mode 100644 index 00000000..71fe35df --- /dev/null +++ b/backend/support.go @@ -0,0 +1,739 @@ +package main + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "log" + "math/rand" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +const ( + // supportTicketTTL bounds how long a ticket and its thread survive. Any + // reply refreshes it, so an active conversation is never cut off mid-way. + supportTicketTTL = 180 * 24 * time.Hour + // maxSupportTicketsListed caps the admin inbox read, which does one GET per + // entry — the same bound the channel-request queue uses. + maxSupportTicketsListed = 500 + // maxSupportMessages caps a single thread. Without it one participant can + // grow a record without limit, and every read of that ticket pays for it. + maxSupportMessages = 100 + + maxSupportSubjectLen = 200 + maxSupportBodyLen = 5000 + maxSupportNameLen = 100 + maxSupportEmailLen = 254 +) + +type SupportStatus string + +const ( + // Open: waiting on the operator. Answered: the operator replied and it is + // with the requester. Closed: done, and no further replies are accepted. + SupportStatusOpen SupportStatus = "open" + SupportStatusAnswered SupportStatus = "answered" + SupportStatusClosed SupportStatus = "closed" +) + +// Sentinels returned by a dbUpdateSupportTicket mutator to abort the +// transaction, judged against the ticket as it stands at write time rather than +// the copy the handler read earlier. +var ( + errTicketClosed = errors.New("this ticket is closed") + errThreadFull = errors.New("this thread has reached its message limit") +) + +// writeTicketUpdateError maps a dbUpdateSupportTicket failure onto a status +// code. A ticket that vanished (expired TTL) reads as not found, exactly as it +// would have before the transaction. +func writeTicketUpdateError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errTicketClosed), errors.Is(err, errThreadFull): + http.Error(w, err.Error(), http.StatusConflict) + case errors.Is(err, redis.Nil): + http.Error(w, "not found", http.StatusNotFound) + default: + http.Error(w, "error", http.StatusInternalServerError) + } +} + +func validSupportStatus(s SupportStatus) bool { + switch s { + case SupportStatusOpen, SupportStatusAnswered, SupportStatusClosed: + return true + } + return false +} + +// SupportMessage is one entry in a ticket thread. +type SupportMessage struct { + // "user" or "admin" — who wrote it, for rendering the two sides. + Author string `json:"author"` + AuthorName string `json:"authorName"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + // "email" when the message arrived as a mail to the channels mailbox + // (support_inbound.go) rather than through the site. Shown to the operator. + Source string `json:"source,omitempty"` +} + +type SupportTicket struct { + ID string `json:"id"` + Subject string `json:"subject"` + Name string `json:"name"` + Email string `json:"email"` + // Set when the ticket was opened from a channel's admin panel, so the + // operator knows which tenant is asking. + ChannelSlug string `json:"channelSlug,omitempty"` + // True when the sender had a session. A ticket from a signed-in user has a + // trustworthy email; an anonymous one carries whatever was typed. + Authenticated bool `json:"authenticated"` + + Status SupportStatus `json:"status"` + Messages []SupportMessage `json:"messages"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + // "email" when the ticket was opened by a mail to the channels mailbox. + Source string `json:"source,omitempty"` + + // AccessToken lets the anonymous sender come back and read the reply. Every + // response goes through publicView, which clears it, so it leaves the server + // exactly once: in the reply to the request that created the ticket. + AccessToken string `json:"accessToken,omitempty"` +} + +const ( + supportTicketKeyPrefix = "support_ticket:" + supportTicketIndexKey = "support_tickets:list" + // One index per email so a signed-in user can list their own tickets + // without scanning the global index. + supportUserIndexPrefix = "support_tickets:user:" + // supportMatchIndexPrefix indexes EVERY ticket by its email, anonymous ones + // included, for one purpose only: pairing an inbound mail with the + // sender's thread (support_inbound.go). It is never listed to anyone and + // never read on a user's behalf — the user index above stays the only + // source of "my tickets", for the reason given in dbSaveSupportTicket. + supportMatchIndexPrefix = "support_tickets:match:" +) + +func supportTicketKey(id string) string { return supportTicketKeyPrefix + id } + +func supportUserIndexKey(email string) string { + return supportUserIndexPrefix + strings.ToLower(email) +} + +func supportMatchIndexKey(email string) string { + return supportMatchIndexPrefix + normEmail(email) +} + +func dbSaveSupportTicket(ctx context.Context, t *SupportTicket) error { + data, err := json.Marshal(t) + if err != nil { + return err + } + if err := rdb.Set(ctx, supportTicketKey(t.ID), data, supportTicketTTL).Err(); err != nil { + return err + } + score := redis.Z{Score: float64(t.UpdatedAt.Unix()), Member: t.ID} + if err := rdb.ZAdd(ctx, supportTicketIndexKey, score).Err(); err != nil { + return err + } + // Only a ticket opened by a signed-in user is listed under that email. + // An anonymous ticket carries whatever address was typed, so indexing it + // let a visitor plant a thread in any user's "my tickets" and, holding + // the access token, read that user's and the operator's replies. + if t.Authenticated && t.Email != "" { + key := supportUserIndexKey(t.Email) + if err := rdb.ZAdd(ctx, key, score).Err(); err != nil { + return err + } + // The per-user index must not outlive the tickets it points at. + rdb.Expire(ctx, key, supportTicketTTL) + } + if t.Email != "" { + key := supportMatchIndexKey(t.Email) + if err := rdb.ZAdd(ctx, key, score).Err(); err != nil { + return err + } + rdb.Expire(ctx, key, supportTicketTTL) + } + return nil +} + +// dbUpdateSupportTicket applies mutate to a ticket under a WATCH-guarded +// read-modify-write, following dbUpdateUsersList. +// +// A ticket is one JSON blob holding the whole thread, so the obvious +// read-mutate-Set loses writes: an operator answering while the requester adds +// a message means both load the same thread, both append, and whichever SET +// lands second silently discards the other's message — with a 200 for both. The +// same race let a reply composed from a pre-close snapshot reopen a ticket an +// operator had just closed. +// +// mutate may be called more than once and must therefore only act on the ticket +// it is handed. It returns an error to abort the transaction and report back +// (used for the conditions that must be judged against fresh state, such as a +// ticket that has since been closed). +func dbUpdateSupportTicket(ctx context.Context, id string, mutate func(*SupportTicket) error) (*SupportTicket, error) { + // Optimistic concurrency: every loser of a race retries, so the budget has + // to cover the contenders, not just a couple of collisions. With a flat + // retry and no pause, simultaneous writers re-collide in lockstep and one + // can exhaust a small budget while its peers commit — an honest error, but + // an avoidable one. The jittered backoff spreads them apart. + const maxRetries = 20 + key := supportTicketKey(id) + var result *SupportTicket + + txf := func(tx *redis.Tx) error { + data, err := tx.Get(ctx, key).Result() + if err != nil { + return err // redis.Nil surfaces as "not found" to the caller + } + var t SupportTicket + if err := json.Unmarshal([]byte(data), &t); err != nil { + return err + } + if err := mutate(&t); err != nil { + return err + } + encoded, err := json.Marshal(&t) + if err != nil { + return err + } + + score := redis.Z{Score: float64(t.UpdatedAt.Unix()), Member: t.ID} + _, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error { + p.Set(ctx, key, encoded, supportTicketTTL) + p.ZAdd(ctx, supportTicketIndexKey, score) + // Same rule as dbSaveSupportTicket: an anonymous ticket's address is + // unverified, so a reply must not plant it in that user's list. + if t.Authenticated && t.Email != "" { + userKey := supportUserIndexKey(t.Email) + p.ZAdd(ctx, userKey, score) + p.Expire(ctx, userKey, supportTicketTTL) + } + if t.Email != "" { + matchKey := supportMatchIndexKey(t.Email) + p.ZAdd(ctx, matchKey, score) + p.Expire(ctx, matchKey, supportTicketTTL) + } + return nil + }) + if err == nil { + result = &t + } + return err + } + + for i := 0; i < maxRetries; i++ { + err := rdb.Watch(ctx, txf, key) + if err != redis.TxFailedErr { + if err != nil { + return nil, err + } + return result, nil + } + // Someone else committed first. Pause briefly, with jitter so the + // contenders do not all wake and collide again together. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(rand.Int63n(int64(2*time.Millisecond))) + time.Millisecond): + } + } + return nil, errors.New("support ticket: too much contention") +} + +func dbGetSupportTicket(ctx context.Context, id string) (*SupportTicket, error) { + data, err := rdb.Get(ctx, supportTicketKey(id)).Result() + if err != nil { + return nil, err + } + var t SupportTicket + if err := json.Unmarshal([]byte(data), &t); err != nil { + return nil, err + } + return &t, nil +} + +// dbListSupportTickets reads an index newest-first, dropping entries whose +// bodies have already expired. +func dbListSupportTickets(ctx context.Context, indexKey string) ([]*SupportTicket, error) { + cutoff := strconv.FormatInt(time.Now().Add(-supportTicketTTL).Unix(), 10) + rdb.ZRemRangeByScore(ctx, indexKey, "-inf", cutoff) + + ids, err := rdb.ZRevRange(ctx, indexKey, 0, maxSupportTicketsListed-1).Result() + if err != nil { + return nil, err + } + out := make([]*SupportTicket, 0, len(ids)) + for _, id := range ids { + t, err := dbGetSupportTicket(ctx, id) + if err != nil { + // Expired body with a surviving index entry: drop it rather than + // letting it reappear on every listing. + rdb.ZRem(ctx, indexKey, id) + continue + } + out = append(out, t) + } + return out, nil +} + +// publicView strips the access token before a ticket is sent anywhere. The +// token is the only credential on an anonymous thread, so it must never travel +// back out in a response body. +// +// It also re-signs every operator reply as operatorName. New replies are +// stored that way already; this covers the ones written before, which carry +// the operator's own display name or email and are still served for as long +// as the ticket lives. +func publicView(t *SupportTicket) SupportTicket { + c := *t + c.AccessToken = "" + // A fresh slice: the struct copy above shares the original's backing array, + // and the stored ticket must come out of this unchanged. + c.Messages = make([]SupportMessage, len(t.Messages)) + copy(c.Messages, t.Messages) + for i := range c.Messages { + if c.Messages[i].Author == "admin" { + c.Messages[i].AuthorName = operatorName + } + } + return c +} + +// supportSubmitLimiters throttles ticket creation. Anonymous submissions are +// keyed by IP, which is all we have; a signed-in sender is keyed by email so a +// shared address (office NAT, mobile carrier) cannot exhaust another user's +// quota. 5 per hour with a burst of 3. +var ( + supportSubmitLimiters sync.Map + supportSubmitMu sync.Mutex +) + +func supportSubmitLimiter(key string) *rate.Limiter { + return getLimiter(&supportSubmitLimiters, &supportSubmitMu, key, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(12*time.Minute), 3) + }) +} + +// clientKey identifies the sender for rate limiting: session email when there +// is one, otherwise the remote address. middleware.RealIP has already resolved +// RemoteAddr from X-Real-IP / X-Forwarded-For. +func clientKey(r *http.Request) string { + if s, ok := sessionEmail(r); ok { + return "email:" + normEmail(s.Email) + } + return "ip:" + remoteHost(r.RemoteAddr) +} + +// remoteHost strips the port from a RemoteAddr. After RealIP the address has +// no port at all, and a bare IPv6 address cut at its last colon lost its +// final hextet, so every host in the same /112 shared one limiter. +func remoteHost(addr string) string { + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return strings.Trim(addr, "[]") +} + +// trimTo bounds a free-text field to max characters. Every one of these lands +// in a Redis value that is read back in full on each view, so length is +// enforced on write. The limit counts characters, as the form's maxlength +// does: counted in bytes, a 3,000-letter Hebrew message (two bytes a letter) +// was silently cut in half, and a cut through a letter left invalid UTF-8 +// that the JSON encoder showed the operator as U+FFFD. +func trimTo(s string, max int) string { + s = strings.TrimSpace(s) + if utf8.RuneCountInString(s) <= max { + return s + } + n := 0 + for i := range s { + if n == max { + return strings.TrimSpace(s[:i]) + } + n++ + } + return s +} + +// looksLikeEmail is a shape check, not validation: the address is only ever +// shown to the operator, never sent to. It exists so an obviously empty or +// junk value is refused at the door. +func looksLikeEmail(s string) bool { + at := strings.Index(s, "@") + if at <= 0 || at == len(s)-1 { + return false + } + return strings.Contains(s[at+1:], ".") && !strings.ContainsAny(s, " \t\r\n") +} + +// POST /api/support/tickets — open to anonymous visitors. +func createSupportTicket(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + var req struct { + Subject string `json:"subject"` + Body string `json:"body"` + Name string `json:"name"` + Email string `json:"email"` + ChannelSlug string `json:"channelSlug"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + subject := trimTo(req.Subject, maxSupportSubjectLen) + body := trimTo(req.Body, maxSupportBodyLen) + if subject == "" || body == "" { + http.Error(w, "subject and body are required", http.StatusBadRequest) + return + } + + // A signed-in sender's identity comes from the session and is never taken + // from the body, so a ticket cannot be attributed to someone else. + session, authed := sessionEmail(r) + name, email := trimTo(req.Name, maxSupportNameLen), trimTo(req.Email, maxSupportEmailLen) + if authed { + email = session.Email + name = sessionDisplayName(session) + } else { + if !looksLikeEmail(email) { + http.Error(w, "a valid email address is required", http.StatusBadRequest) + return + } + if name == "" { + name = email + } + } + + // Rated immediately before the write, never at the top: a refused request + // must not spend quota on a message that failed validation. + if !allowOrRetryAfter(w, supportSubmitLimiter(clientKey(r)), + "too many requests — please try again later") { + return + } + + id, token := generatedRandomID(16), generatedRandomID(32) + if id == "" || token == "" { + http.Error(w, "error", http.StatusInternalServerError) + return + } + // A signed-in sender reaches the thread through the session (my-tickets), + // so the ticket carries no token at all. Handing one out anyway had the + // browser keep it in local storage past logout, where the next person at + // a shared machine could read and answer the thread. + if authed { + token = "" + } + + now := time.Now() + ticket := &SupportTicket{ + ID: id, + Subject: subject, + Name: name, + Email: email, + ChannelSlug: trimTo(req.ChannelSlug, 64), + Authenticated: authed, + Status: SupportStatusOpen, + Messages: []SupportMessage{{ + Author: "user", AuthorName: name, Body: body, CreatedAt: now, + }}, + CreatedAt: now, + UpdatedAt: now, + AccessToken: token, + } + if err := dbSaveSupportTicket(ctx, ticket); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + // The token is returned exactly once, here, so an anonymous sender can keep + // the link and read the answer later. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": id, "accessToken": token}) +} + +// ticketTokenHeader carries the anonymous access token. It used to travel as +// a ?token= query parameter, which the request logger writes to stdout on +// every read and reply — so the only credential on an anonymous thread ended +// up in the container logs for the thread's 180-day life. +const ticketTokenHeader = "X-Ticket-Token" + +// ticketToken returns the caller's access token. A client loaded before the +// header existed (the SPA keeps running for days in an open tab) still sends +// ?token=; hideTicketTokenQuery in main.go lifts that into the header before +// the request is logged or routed, so the header is the only source here. +func ticketToken(r *http.Request) string { + return r.Header.Get(ticketTokenHeader) +} + +// authoriseTicket resolves the ticket for a requester-side request and reports +// whether this caller may see it. Two ways in: the session email matches a +// ticket that was opened signed-in, or the access token does. A ticket opened +// anonymously is token-only: its email is unverified, so a session matching +// it proves nothing (see dbSaveSupportTicket). +func authoriseTicket(ctx context.Context, r *http.Request, id string) (*SupportTicket, bool) { + t, err := dbGetSupportTicket(ctx, id) + if err != nil { + return nil, false + } + if t.Authenticated { + if s, ok := sessionEmail(r); ok && strings.EqualFold(s.Email, t.Email) { + return t, true + } + } + // A signed-in ticket is reachable by its session only. Tickets created + // before tokens stopped being issued to signed-in senders still carry one, + // and the browser keeps sending it from localStorage after logout — so the + // token must not open those either. + if t.Authenticated { + return nil, false + } + token := ticketToken(r) + // Constant time: this is the only credential guarding an anonymous thread. + if token != "" && t.AccessToken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(t.AccessToken)) == 1 { + return t, true + } + return nil, false +} + +// GET /api/support/tickets/{id} (X-Ticket-Token) — the requester's view of a thread. +func getSupportTicket(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + t, ok := authoriseTicket(ctx, r, chi.URLParam(r, "id")) + if !ok { + // Deliberately not 403: a wrong token must not confirm that the ticket + // exists. + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(publicView(t)) +} + +// POST /api/support/tickets/{id}/reply (X-Ticket-Token) — the requester answers. +func replySupportTicket(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + t, ok := authoriseTicket(ctx, r, chi.URLParam(r, "id")) + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + if t.Status == SupportStatusClosed { + http.Error(w, "this ticket is closed", http.StatusConflict) + return + } + body, ok := decodeSupportBody(w, r) + if !ok { + return + } + if !allowOrRetryAfter(w, supportSubmitLimiter(clientKey(r)), + "too many requests — please try again later") { + return + } + + // The thread-length and closed checks are re-made inside the transaction, + // against the ticket as it stands at write time rather than the copy read + // for authorisation. + updated, err := dbUpdateSupportTicket(ctx, t.ID, func(cur *SupportTicket) error { + if cur.Status == SupportStatusClosed { + return errTicketClosed + } + if len(cur.Messages) >= maxSupportMessages { + return errThreadFull + } + appendSupportMessage(cur, "user", cur.Name, body, SupportStatusOpen) + return nil + }) + if err != nil { + writeTicketUpdateError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(publicView(updated)) +} + +// GET /api/support/my-tickets — a signed-in user's own threads. +func listMySupportTickets(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + s, ok := sessionEmail(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + tickets, err := dbListSupportTickets(ctx, supportUserIndexKey(s.Email)) + if err != nil { + // An empty 200 read as "no tickets" during a Redis outage. + log.Printf("listMySupportTickets: %v\n", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + out := make([]SupportTicket, 0, len(tickets)) + for _, t := range tickets { + out = append(out, publicView(t)) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) +} + +// GET /api/super-admin/support/tickets — the operator inbox. +func adminListSupportTickets(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + tickets, err := dbListSupportTickets(ctx, supportTicketIndexKey) + if err != nil { + // Same as listMySupportTickets: the inbox must not look empty on a + // storage error. + log.Printf("adminListSupportTickets: %v\n", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + out := make([]SupportTicket, 0, len(tickets)) + for _, t := range tickets { + out = append(out, publicView(t)) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) +} + +// POST /api/super-admin/support/tickets/{id}/reply +func adminReplySupportTicket(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + t, err := dbGetSupportTicket(ctx, chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + body, ok := decodeSupportBody(w, r) + if !ok { + return + } + + updated, err := dbUpdateSupportTicket(ctx, t.ID, func(cur *SupportTicket) error { + if len(cur.Messages) >= maxSupportMessages { + return errThreadFull + } + // An operator reply reopens a closed ticket: answering it is a + // deliberate act, and leaving it closed would silently discard the + // reply from the requester's view. + // + // Signed as the management and never from the session: this record is + // served to the requester, so whoever answered must not be named in it. + appendSupportMessage(cur, "admin", operatorName, body, SupportStatusAnswered) + return nil + }) + if err != nil { + writeTicketUpdateError(w, err) + return + } + + // Mail the requester the answer, unless the operator is answering a thread + // they opened themselves. updated is the committed thread, so its last + // message is this reply even if the transaction had to retry. + if !answeringOwnTicket(r, updated) { + ticket, replyIndex := *updated, len(updated.Messages)-1 + dispatchMagnetMail(func(mctx context.Context) (*magnetMailEvent, bool) { + return supportReplyMail(&ticket, replyIndex, ticketMailChannel(mctx, &ticket)) + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(publicView(updated)) +} + +// POST /api/super-admin/support/tickets/{id}/status +func adminSetSupportTicketStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + t, err := dbGetSupportTicket(ctx, chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + var req struct { + Status SupportStatus `json:"status"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil || + !validSupportStatus(req.Status) { + http.Error(w, "invalid status", http.StatusBadRequest) + return + } + updated, err := dbUpdateSupportTicket(ctx, t.ID, func(cur *SupportTicket) error { + cur.Status = req.Status + cur.UpdatedAt = time.Now() + return nil + }) + if err != nil { + writeTicketUpdateError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(publicView(updated)) +} + +// answeringOwnTicket reports whether the operator replying is the person who +// opened the thread (an operator testing the box, say): mailing them their +// own answer is noise. Without a session cookie there is nobody to compare. +func answeringOwnTicket(r *http.Request, t *SupportTicket) bool { + if _, err := r.Cookie(cookieName); err != nil { + return false + } + s, ok := sessionEmail(r) + return ok && t.Email != "" && normEmail(s.Email) == normEmail(t.Email) +} + +// decodeSupportBody reads and bounds the one field every reply carries. +func decodeSupportBody(w http.ResponseWriter, r *http.Request) (string, bool) { + var req struct { + Body string `json:"body"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return "", false + } + body := trimTo(req.Body, maxSupportBodyLen) + if body == "" { + http.Error(w, "message body is required", http.StatusBadRequest) + return "", false + } + return body, true +} + +// appendSupportMessage adds one entry and moves the ticket to status, keeping +// UpdatedAt (which is also the index score) in step. +func appendSupportMessage(t *SupportTicket, author, name, body string, status SupportStatus) { + now := time.Now() + t.Messages = append(t.Messages, SupportMessage{ + Author: author, AuthorName: name, Body: body, CreatedAt: now, + }) + t.Status = status + t.UpdatedAt = now +} diff --git a/backend/support_inbound.go b/backend/support_inbound.go new file mode 100644 index 00000000..6d9a3f97 --- /dev/null +++ b/backend/support_inbound.go @@ -0,0 +1,406 @@ +package main + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "log" + "math" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +// Inbound support mail. +// +// Mail to the channels mailbox (channels@magnetads.co.il) lands in Magnet: +// its email-events-webhook files it in Magnet's CRM and then hands it here, +// over the same shared secret magnet_mail.go uses the other way: +// +// POST /api/integrations/magnet/inbound-mail +// x-channels-secret: $MAGNET_MAIL_SECRET +// {"from_email", "from_name", "subject", "text", "message_id", +// "in_reply_to", "references", "ticket_ref", "sender_verified"} +// +// Every such mail becomes part of the support inbox: a reply to one of the +// sender's threads is appended to it, anything else opens a new ticket. +// +// Who may add to an existing thread +// --------------------------------- +// The From address of a mail is whatever the sender typed into their client. +// Appending on From alone would let anyone plant a message in someone else's +// thread — and the operator would answer it, to the real owner, in context. +// So a mail joins a thread only when Magnet reports the sender as verified +// (sender_verified: DMARC pass, or a DKIM signature aligned with the From +// domain, as the receiving server judged it). An unverified sender gets a new +// ticket of their own, which is exactly what the web form gives an anonymous +// visitor today. Even then the ticket ref (below) is not a credential: it +// only picks among the threads whose email already equals the sender's. +// +// Which thread +// ------------ +// 1. ticket_ref — the short id Magnet's support_reply mail carries in its +// subject ("[פנייה #1a2b3c4d]"). A prefix of the ticket id, matched only +// among the sender's own tickets. If it names a ticket that is closed or +// full, the mail opens a new ticket rather than landing in some other +// thread of theirs. +// 2. Otherwise the sender's most recently active ticket that is not closed. +// 3. Otherwise a new ticket. +// +// The sender's tickets are found through supportMatchIndexKey, an index of +// every ticket by email (anonymous ones included) that only this file reads. +// The user index behind "my tickets" is untouched: a ticket opened by mail is +// not authenticated and never appears there. +// +// A mail-opened ticket has no access token. The requester continues the +// conversation by mail — the operator's answer reaches them as support_reply, +// because the ticket has an email — and nothing that could open the thread on +// the site is ever sent anywhere. + +const ( + supportInboundSource = "email" + // defaultInboundSubject names a mail that arrived without one. + defaultInboundSubject = "פנייה במייל" + emptyInboundBody = "(ללא תוכן)" + + // supportInboundSeenPrefix records each message_id already taken in, for + // as long as a ticket lives: Magnet retries a failed hand-off, and the + // provider redelivers webhooks. + supportInboundSeenPrefix = "support_inbound:seen:" + // How many of a sender's tickets the match looks through, newest first. + maxInboundMatchScan = 50 +) + +// inboundTicketRefRe is the shape of a ticket ref: hex, as ticket ids are. +var inboundTicketRefRe = regexp.MustCompile(`^[0-9a-f]{6,32}$`) + +// inboundSubjectNoise is what a reply adds around the subject Magnet sent: +// client prefixes, the support_reply wording, and the ticket tag. +var ( + inboundReplyPrefixRe = regexp.MustCompile(`^\s*(?i:re|fwd?|aw|sv|tr|תגובה|השב|העברה)\s*:\s*`) + inboundTicketTagRe = regexp.MustCompile(`\s*\[\s*פנייה\s*#?\s*[0-9A-Fa-f]{4,32}\s*\]\s*`) +) + +const supportReplyMailSubjectPrefix = "התקבלה תשובה לפנייה שלך:" + +// cleanInboundSubject turns "Re: התקבלה תשובה לפנייה שלך: X [פנייה #1a2b]" +// back into "X", for a mail that opens a new ticket. +func cleanInboundSubject(s string) string { + s = oneLine(s) + s = inboundTicketTagRe.ReplaceAllString(s, " ") + for { + next := inboundReplyPrefixRe.ReplaceAllString(s, "") + next = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(next), supportReplyMailSubjectPrefix)) + if next == s { + break + } + s = next + } + return trimTo(s, maxSupportSubjectLen) +} + +// inboundMailLimiters throttle per sender. A person writes a handful of mails +// an hour at most; this exists for the auto-responder that answers every +// mail, and for a leaked secret. 10 at once, then one per 3 minutes. +var ( + inboundMailLimiters sync.Map + inboundMailMu sync.Mutex +) + +func inboundMailLimiter(email string) *rate.Limiter { + return getLimiter(&inboundMailLimiters, &inboundMailMu, "email:"+email, func() *rate.Limiter { + return rate.NewLimiter(rate.Every(3*time.Minute), 10) + }) +} + +type inboundMailRequest struct { + FromEmail string `json:"from_email"` + FromName string `json:"from_name"` + Subject string `json:"subject"` + Text string `json:"text"` + MessageID string `json:"message_id"` + InReplyTo string `json:"in_reply_to"` + References string `json:"references"` + TicketRef string `json:"ticket_ref"` + SenderVerified bool `json:"sender_verified"` +} + +type inboundMailResult struct { + OK bool `json:"ok"` + TicketID string `json:"ticket_id"` + Action string `json:"action"` // "appended" | "created" + Duplicate bool `json:"duplicate,omitempty"` +} + +func writeInboundJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func inboundError(w http.ResponseWriter, status int, msg string) { + writeInboundJSON(w, status, map[string]string{"error": msg}) +} + +// inboundSecretOK checks x-channels-secret in constant time. The caller has +// already made sure a secret is configured. +func inboundSecretOK(r *http.Request) bool { + got := r.Header.Get("x-channels-secret") + return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(magnetMailSecret)) == 1 +} + +// POST /api/integrations/magnet/inbound-mail — server to server, from Magnet. +func magnetInboundMail(w http.ResponseWriter, r *http.Request) { + // Fail closed: with no secret configured this route is not a door at all. + if magnetMailSecret == "" { + inboundError(w, http.StatusServiceUnavailable, "הקליטה של מיילים נכנסים אינה מוגדרת") + return + } + if !inboundSecretOK(r) { + inboundError(w, http.StatusUnauthorized, "אין הרשאה") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + + var req inboundMailRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + inboundError(w, http.StatusBadRequest, "בקשה לא תקינה") + return + } + from := normEmail(req.FromEmail) + if len(from) > maxSupportEmailLen || !looksLikeEmail(from) { + inboundError(w, http.StatusBadRequest, "כתובת השולח אינה תקינה") + return + } + messageID := strings.TrimSpace(req.MessageID) + if messageID == "" || len(messageID) > 1000 { + inboundError(w, http.StatusBadRequest, "חסר מזהה הודעה") + return + } + + seenKey := inboundSeenKey(messageID) + if prev, err := rdb.Get(ctx, seenKey).Result(); err == nil && prev != "" && prev != "pending" { + writeInboundJSON(w, http.StatusOK, inboundMailResult{OK: true, TicketID: prev, Action: "duplicate", Duplicate: true}) + return + } + + if !allowOrRetryAfterJSON(w, inboundMailLimiter(from)) { + return + } + + // Claim the message id before writing anything, so two concurrent + // deliveries of the same mail cannot both add it. A claim whose request + // died expires quickly and the next retry takes it. + claimed, err := rdb.SetNX(ctx, seenKey, "pending", time.Minute).Result() + if err != nil { + inboundError(w, http.StatusInternalServerError, "שגיאה בשמירה") + return + } + if !claimed { + prev, _ := rdb.Get(ctx, seenKey).Result() + if prev != "" && prev != "pending" { + writeInboundJSON(w, http.StatusOK, inboundMailResult{OK: true, TicketID: prev, Action: "duplicate", Duplicate: true}) + return + } + inboundError(w, http.StatusConflict, "ההודעה נקלטת כרגע") + return + } + + res, err := ingestInboundMail(ctx, &req, from) + if err != nil { + rdb.Del(context.WithoutCancel(ctx), seenKey) + log.Printf("inbound mail: not stored: %v", err) + inboundError(w, http.StatusInternalServerError, "שגיאה בשמירה") + return + } + rdb.Set(context.WithoutCancel(ctx), seenKey, res.TicketID, supportTicketTTL) + writeInboundJSON(w, http.StatusOK, res) +} + +func inboundSeenKey(messageID string) string { + sum := sha256.Sum256([]byte(messageID)) + return supportInboundSeenPrefix + hex.EncodeToString(sum[:]) +} + +// allowOrRetryAfterJSON is allowOrRetryAfter with a JSON body, which is what +// Magnet logs. +func allowOrRetryAfterJSON(w http.ResponseWriter, l *rate.Limiter) bool { + res := l.Reserve() + if !res.OK() { + inboundError(w, http.StatusTooManyRequests, "יותר מדי הודעות מהשולח הזה, נסו שוב מאוחר יותר") + return false + } + if d := res.Delay(); d > 0 { + res.Cancel() + w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds())))) + inboundError(w, http.StatusTooManyRequests, "יותר מדי הודעות מהשולח הזה, נסו שוב מאוחר יותר") + return false + } + return true +} + +// ingestInboundMail appends the mail to the sender's thread or opens a new +// one. from is already normalised. +func ingestInboundMail(ctx context.Context, req *inboundMailRequest, from string) (*inboundMailResult, error) { + body := trimTo(req.Text, maxSupportBodyLen) + if body == "" { + body = emptyInboundBody + } + + if req.SenderVerified { + target, err := findInboundTicket(ctx, from, strings.ToLower(strings.TrimSpace(req.TicketRef))) + if err != nil { + return nil, err + } + if target != "" { + updated, err := dbUpdateSupportTicket(ctx, target, func(cur *SupportTicket) error { + // Judged again on the ticket as it stands at write time. + if !strings.EqualFold(cur.Email, from) { + return errInboundNoMatch + } + if cur.Status == SupportStatusClosed { + return errTicketClosed + } + if len(cur.Messages) >= maxSupportMessages { + return errThreadFull + } + appendSupportMessage(cur, "user", cur.Name, body, SupportStatusOpen) + cur.Messages[len(cur.Messages)-1].Source = supportInboundSource + return nil + }) + switch { + case err == nil: + return &inboundMailResult{OK: true, TicketID: updated.ID, Action: "appended"}, nil + case errors.Is(err, errTicketClosed), errors.Is(err, errThreadFull), + errors.Is(err, errInboundNoMatch), errors.Is(err, redis.Nil): + // Closed, full or gone between the lookup and the write: the + // mail still has to land somewhere, so it opens a new ticket. + default: + return nil, err + } + } + } + + id := generatedRandomID(16) + if id == "" { + return nil, errors.New("random id") + } + name := oneLine(trimTo(req.FromName, maxSupportNameLen)) + if name == "" { + name = from + } + subject := cleanInboundSubject(req.Subject) + if subject == "" { + subject = defaultInboundSubject + } + now := time.Now() + t := &SupportTicket{ + ID: id, + Subject: subject, + Name: name, + Email: from, + // The From of a mail proves no more than an address typed into the + // form, so the ticket is never listed under that user (see + // dbSaveSupportTicket) — verified or not. + Authenticated: false, + Status: SupportStatusOpen, + Source: supportInboundSource, + Messages: []SupportMessage{{ + Author: "user", AuthorName: name, Body: body, CreatedAt: now, Source: supportInboundSource, + }}, + CreatedAt: now, + UpdatedAt: now, + // No AccessToken: the requester continues by mail, and a token that + // is never handed out is one that cannot leak. + } + if err := dbSaveSupportTicket(ctx, t); err != nil { + return nil, err + } + return &inboundMailResult{OK: true, TicketID: id, Action: "created"}, nil +} + +var errInboundNoMatch = errors.New("ticket email does not match the sender") + +// findInboundTicket picks the thread a verified sender's mail belongs to, or +// "" for none. ref, when it names one of the sender's tickets, decides alone: +// a closed or full ticket then yields "" (a new ticket) rather than some other +// thread of theirs. +func findInboundTicket(ctx context.Context, from, ref string) (string, error) { + key := supportMatchIndexKey(from) + ids, err := rdb.ZRevRange(ctx, key, 0, maxInboundMatchScan-1).Result() + if err != nil { + return "", err + } + if !inboundTicketRefRe.MatchString(ref) { + ref = "" + } + + var latestOpen string + for _, id := range ids { + t, err := dbGetSupportTicket(ctx, id) + if err != nil { + if errors.Is(err, redis.Nil) { + // Expired body: drop the stale index entry. + rdb.ZRem(ctx, key, id) + continue + } + return "", err + } + if !strings.EqualFold(t.Email, from) { + continue + } + usable := t.Status != SupportStatusClosed && len(t.Messages) < maxSupportMessages + if ref != "" && strings.HasPrefix(strings.ToLower(t.ID), ref) { + if usable { + return t.ID, nil + } + return "", nil + } + if usable && latestOpen == "" { + latestOpen = t.ID + } + } + return latestOpen, nil +} + +// backfillSupportMatchIndex puts the tickets written before the match index +// existed into it, so a mail answering one of them still finds its thread. +func backfillSupportMatchIndex(ctx context.Context) error { + ids, err := rdb.ZRange(ctx, supportTicketIndexKey, 0, -1).Result() + if err != nil { + return err + } + for _, id := range ids { + if err := budgetExhausted(ctx); err != nil { + return err + } + t, err := dbGetSupportTicket(ctx, id) + if err != nil { + if errors.Is(err, redis.Nil) { + continue + } + return err + } + if t.Email == "" { + continue + } + key := supportMatchIndexKey(t.Email) + if err := rdb.ZAdd(ctx, key, redis.Z{Score: float64(t.UpdatedAt.Unix()), Member: t.ID}).Err(); err != nil { + return err + } + rdb.Expire(ctx, key, supportTicketTTL) + } + return nil +} diff --git a/backend/support_inbound_test.go b/backend/support_inbound_test.go new file mode 100644 index 00000000..6c7b6ecc --- /dev/null +++ b/backend/support_inbound_test.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// Inbound mail runs against real Redis like the other support tests. Each +// test uses its own sender address, so the per-sender limiter and the match +// index never carry state from one test into another. + +func useInboundSecret(t *testing.T, secret string) { + t.Helper() + prev := magnetMailSecret + magnetMailSecret = secret + t.Cleanup(func() { magnetMailSecret = prev }) +} + +// cleanupInbound removes everything a test's mails may have written: the +// sender's match index, every ticket it points at, and the message ids. +func cleanupInbound(t *testing.T, email string, messageIDs ...string) { + t.Helper() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + key := supportMatchIndexKey(email) + ids, _ := rdb.ZRange(ctx, key, 0, -1).Result() + for _, id := range ids { + rdb.Del(ctx, supportTicketKey(id)) + rdb.ZRem(ctx, supportTicketIndexKey, id) + rdb.ZRem(ctx, supportUserIndexKey(email), id) + } + rdb.Del(ctx, key) + for _, m := range messageIDs { + rdb.Del(ctx, inboundSeenKey(m)) + } + }) +} + +func postInbound(t *testing.T, secret string, body map[string]any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + raw, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/api/integrations/magnet/inbound-mail", strings.NewReader(string(raw))) + r.Header.Set("Content-Type", "application/json") + if secret != "" { + r.Header.Set("x-channels-secret", secret) + } + w := httptest.NewRecorder() + magnetInboundMail(w, r) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w, out +} + +func inboundMail(from, msgID string, extra map[string]any) map[string]any { + m := map[string]any{ + "from_email": from, + "from_name": "שולח במייל", + "subject": "Re: התקבלה תשובה לפנייה שלך: נושא", + "text": "תשובה במייל", + "message_id": msgID, + "sender_verified": true, + } + for k, v := range extra { + m[k] = v + } + return m +} + +// saveInboundFixture writes a ticket with a chosen id, email and status. +func saveInboundFixture(t *testing.T, ctx context.Context, id, email string, authed bool, status SupportStatus, age time.Duration) { + t.Helper() + at := time.Now().Add(-age) + tk := &SupportTicket{ + ID: id, Subject: "נושא", Name: "בודק", Email: email, Authenticated: authed, + Status: status, + Messages: []SupportMessage{{Author: "user", AuthorName: "בודק", Body: "פתיחה", CreatedAt: at}}, + CreatedAt: at, UpdatedAt: at, + } + if err := dbSaveSupportTicket(ctx, tk); err != nil { + t.Fatalf("save %s: %v", id, err) + } +} + +func mustTicket(t *testing.T, ctx context.Context, id string) *SupportTicket { + t.Helper() + tk, err := dbGetSupportTicket(ctx, id) + if err != nil { + t.Fatalf("get %s: %v", id, err) + } + return tk +} + +func TestInboundMailSecret(t *testing.T) { + useInboundSecret(t, "") + if w, out := postInbound(t, "anything", inboundMail("s0@example.com", "", nil)); w.Code != http.StatusServiceUnavailable || out["error"] == nil { + t.Errorf("no secret configured: status %d body %s, want 503 with a JSON error", w.Code, w.Body) + } + + useInboundSecret(t, testMailSecret) + if w, _ := postInbound(t, "", inboundMail("s0@example.com", "", nil)); w.Code != http.StatusUnauthorized { + t.Errorf("missing secret: status %d, want 401", w.Code) + } + if w, _ := postInbound(t, testMailSecret+"x", inboundMail("s0@example.com", "", nil)); w.Code != http.StatusUnauthorized { + t.Errorf("wrong secret: status %d, want 401", w.Code) + } + if w, out := postInbound(t, testMailSecret, inboundMail("not-an-address", "", nil)); w.Code != http.StatusBadRequest || out["error"] == nil { + t.Errorf("bad sender: status %d, want 400 with a JSON error", w.Code) + } + if w, _ := postInbound(t, testMailSecret, inboundMail("s0@example.com", "", nil)); w.Code != http.StatusBadRequest { + t.Errorf("no message id: status %d, want 400", w.Code) + } +} + +// The ref picks the thread among the sender's own, even when another of their +// threads is more recent. +func TestInboundMailMatchesByRefAndEmail(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + from := "inb-ref@example.com" + cleanupInbound(t, from, "") + saveInboundFixture(t, ctx, "a1a1a1a1aaaa0001", from, true, SupportStatusAnswered, time.Hour) + saveInboundFixture(t, ctx, "b2b2b2b2bbbb0002", from, true, SupportStatusOpen, time.Minute) + + w, out := postInbound(t, testMailSecret, inboundMail("Inb-Ref@Example.com", "", map[string]any{"ticket_ref": "A1A1A1A1"})) + if w.Code != http.StatusOK || out["action"] != "appended" || out["ticket_id"] != "a1a1a1a1aaaa0001" { + t.Fatalf("status %d body %s, want appended to a1a1…", w.Code, w.Body) + } + tk := mustTicket(t, ctx, "a1a1a1a1aaaa0001") + last := tk.Messages[len(tk.Messages)-1] + if len(tk.Messages) != 2 || last.Author != "user" || last.Body != "תשובה במייל" || last.Source != "email" { + t.Errorf("thread = %+v", tk.Messages) + } + if tk.Status != SupportStatusOpen { + t.Errorf("status = %s, want open (waiting on the operator)", tk.Status) + } + if n := len(mustTicket(t, ctx, "b2b2b2b2bbbb0002").Messages); n != 1 { + t.Errorf("the other thread got the mail too (%d messages)", n) + } +} + +// The ref is printed in a mail and is not a secret: the right ref from the +// wrong address must not reach the thread. +func TestInboundMailRefWithOtherEmailOpensNewTicket(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + owner, other := "inb-owner@example.com", "inb-intruder@example.com" + cleanupInbound(t, owner) + cleanupInbound(t, other, "") + saveInboundFixture(t, ctx, "c3c3c3c3cccc0003", owner, true, SupportStatusOpen, time.Minute) + + w, out := postInbound(t, testMailSecret, inboundMail(other, "", map[string]any{"ticket_ref": "c3c3c3c3"})) + if w.Code != http.StatusOK || out["action"] != "created" || out["ticket_id"] == "c3c3c3c3cccc0003" { + t.Fatalf("status %d body %s, want a new ticket", w.Code, w.Body) + } + if n := len(mustTicket(t, ctx, "c3c3c3c3cccc0003").Messages); n != 1 { + t.Errorf("the owner's thread was written to by another address (%d messages)", n) + } + created := mustTicket(t, ctx, out["ticket_id"].(string)) + if created.Email != other { + t.Errorf("new ticket email = %q", created.Email) + } +} + +// Without a ref: the sender's most recent ticket that is not closed. +func TestInboundMailMatchesByEmailOnly(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + from := "inb-email@example.com" + cleanupInbound(t, from, "") + saveInboundFixture(t, ctx, "d4d4d4d4dddd0004", from, false, SupportStatusAnswered, time.Hour) + saveInboundFixture(t, ctx, "e5e5e5e5eeee0005", from, false, SupportStatusClosed, time.Minute) + + w, out := postInbound(t, testMailSecret, inboundMail(from, "", nil)) + if w.Code != http.StatusOK || out["action"] != "appended" || out["ticket_id"] != "d4d4d4d4dddd0004" { + t.Fatalf("status %d body %s, want appended to the open ticket, skipping the closed one", w.Code, w.Body) + } + if n := len(mustTicket(t, ctx, "e5e5e5e5eeee0005").Messages); n != 1 { + t.Error("a closed ticket was reopened by mail") + } +} + +// A ref naming a closed ticket opens a new one, not some other thread. +func TestInboundMailRefToClosedTicketOpensNewTicket(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + from := "inb-closed@example.com" + cleanupInbound(t, from, "") + saveInboundFixture(t, ctx, "f6f6f6f6ffff0006", from, false, SupportStatusOpen, time.Hour) + saveInboundFixture(t, ctx, "a7a7a7a7aaaa0007", from, false, SupportStatusClosed, time.Minute) + + _, out := postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{"ticket_ref": "a7a7a7a7"})) + if out["action"] != "created" { + t.Fatalf("got %v, want a new ticket", out) + } + if n := len(mustTicket(t, ctx, "f6f6f6f6ffff0006").Messages); n != 1 { + t.Error("the mail landed in a thread it did not reply to") + } +} + +// An unverified From proves nothing, so it never joins an existing thread. +func TestInboundMailUnverifiedSenderOpensNewTicket(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + from := "inb-unverified@example.com" + cleanupInbound(t, from, "") + saveInboundFixture(t, ctx, "b8b8b8b8bbbb0008", from, true, SupportStatusOpen, time.Minute) + + _, out := postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{ + "ticket_ref": "b8b8b8b8", "sender_verified": false, + })) + if out["action"] != "created" || out["ticket_id"] == "b8b8b8b8bbbb0008" { + t.Fatalf("got %v, want a new ticket", out) + } + if n := len(mustTicket(t, ctx, "b8b8b8b8bbbb0008").Messages); n != 1 { + t.Error("an unverified sender wrote into an existing thread") + } +} + +// Magnet retries, and the provider redelivers: one mail, one message. +func TestInboundMailIsIdempotent(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + from := "inb-idem@example.com" + cleanupInbound(t, from, "", "") + saveInboundFixture(t, ctx, "c9c9c9c9cccc0009", from, false, SupportStatusOpen, time.Minute) + + _, first := postInbound(t, testMailSecret, inboundMail(from, "", nil)) + w, second := postInbound(t, testMailSecret, inboundMail(from, "", nil)) + if w.Code != http.StatusOK || second["duplicate"] != true || second["ticket_id"] != first["ticket_id"] { + t.Fatalf("second delivery: status %d body %s", w.Code, w.Body) + } + if n := len(mustTicket(t, ctx, "c9c9c9c9cccc0009").Messages); n != 2 { + t.Errorf("thread has %d messages, want 2", n) + } + + // The same for a mail that opened a ticket: no second ticket. + _, a := postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{"sender_verified": false})) + _, b := postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{"sender_verified": false})) + if a["action"] != "created" || b["duplicate"] != true || a["ticket_id"] != b["ticket_id"] { + t.Errorf("new-ticket redelivery: %v then %v", a, b) + } +} + +// No thread to join: a new ticket, anonymous, marked as mail, with no token. +func TestInboundMailOpensNewTicket(t *testing.T) { + ctx := supportCtx(t) + useInboundSecret(t, testMailSecret) + from := "inb-new@example.com" + cleanupInbound(t, from, "", "") + + _, out := postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{ + "subject": "Re: Fwd: התקבלה תשובה לפנייה שלך: בעיה בערוץ [פנייה #1a2b3c4d]", + })) + if out["action"] != "created" { + t.Fatalf("got %v", out) + } + tk := mustTicket(t, ctx, out["ticket_id"].(string)) + if tk.Authenticated || tk.AccessToken != "" || tk.Source != "email" || tk.Status != SupportStatusOpen { + t.Errorf("ticket = %+v", tk) + } + if tk.Subject != "בעיה בערוץ" || tk.Name != "שולח במייל" || tk.Email != from { + t.Errorf("subject %q name %q email %q", tk.Subject, tk.Name, tk.Email) + } + if len(tk.Messages) != 1 || tk.Messages[0].Source != "email" || tk.Messages[0].Body != "תשובה במייל" { + t.Errorf("messages = %+v", tk.Messages) + } + + _, out = postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{ + "subject": "", "text": "", "from_name": "", "sender_verified": false, + })) + tk = mustTicket(t, ctx, out["ticket_id"].(string)) + if tk.Subject != "פנייה במייל" || tk.Messages[0].Body != "(ללא תוכן)" || tk.Name != from { + t.Errorf("defaults: subject %q body %q name %q", tk.Subject, tk.Messages[0].Body, tk.Name) + } +} + +// The match index covers anonymous tickets, so it must never feed a user's +// "my tickets" — that list stays signed-in tickets only. +func TestInboundMatchIndexIsNotMyTickets(t *testing.T) { + ctx := supportCtx(t) + useTestSessionStore(t) + useInboundSecret(t, testMailSecret) + from := "inb-mine@example.com" + cleanupInbound(t, from, "") + saveInboundFixture(t, ctx, "d0d0d0d0dddd0010", from, false, SupportStatusOpen, time.Minute) + _, out := postInbound(t, testMailSecret, inboundMail(from, "", map[string]any{"sender_verified": false})) + + ids, err := rdb.ZRange(ctx, supportMatchIndexKey(from), 0, -1).Result() + if err != nil || len(ids) != 2 { + t.Fatalf("match index = %v (%v), want both tickets", ids, err) + } + + r := requestAs(t, Session{ID: "inb-mine", Email: from}, "", http.MethodGet, "/api/support/my-tickets", nil, nil) + w := httptest.NewRecorder() + listMySupportTickets(w, r) + if w.Code != http.StatusOK { + t.Fatalf("my-tickets: %d", w.Code) + } + var mine []SupportTicket + _ = json.Unmarshal(w.Body.Bytes(), &mine) + for _, tk := range mine { + if tk.ID == "d0d0d0d0dddd0010" || tk.ID == out["ticket_id"] { + t.Errorf("my-tickets lists %s, which was not opened signed-in", tk.ID) + } + } +} + +func TestCleanInboundSubject(t *testing.T) { + cases := map[string]string{ + "Re: התקבלה תשובה לפנייה שלך: בעיה [פנייה #abcdef12]": "בעיה", + "RE: re: Fwd: שאלה": "שאלה", + "תגובה: נושא כלשהו": "נושא כלשהו", + "[פנייה #abcdef12]": "", + "Regarding the channel": "Regarding the channel", + " שורה\r\nשנייה ": "שורה שנייה", + } + for in, want := range cases { + if got := cleanInboundSubject(in); got != want { + t.Errorf("cleanInboundSubject(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/backend/support_race_test.go b/backend/support_race_test.go new file mode 100644 index 00000000..a269f898 --- /dev/null +++ b/backend/support_race_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "fmt" + "sync" + "testing" + "time" +) + +// A ticket is one JSON blob holding its whole thread, so an unguarded +// read-mutate-Set loses writes: two participants load the same thread, both +// append, and whichever SET lands second discards the other's message — while +// both callers get a 200. dbUpdateSupportTicket closes that with a +// WATCH-guarded transaction. +// +// This appends from many goroutines at once and asserts every message survives. +func TestSupportTicketConcurrentRepliesAllSurvive(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + const id = "sup-race-concurrent" + const writers = 12 + + now := time.Now() + seed := &SupportTicket{ + ID: id, Subject: "race", Name: "tester", Email: "race@example.com", + Status: SupportStatusOpen, + Messages: []SupportMessage{{Author: "user", AuthorName: "tester", Body: "first", CreatedAt: now}}, + CreatedAt: now, + UpdatedAt: now, + AccessToken: "tok-race", + } + if err := dbSaveSupportTicket(ctx, seed); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second) + defer ccancel() + rdb.Del(cctx, supportTicketKey(id)) + rdb.ZRem(cctx, supportTicketIndexKey, id) + rdb.ZRem(cctx, supportUserIndexKey("race@example.com"), id) + }) + + var start sync.WaitGroup + var done sync.WaitGroup + start.Add(1) + + errs := make([]error, writers) + for i := 0; i < writers; i++ { + done.Add(1) + go func(n int) { + defer done.Done() + start.Wait() // all of them collide on the same blob + _, err := dbUpdateSupportTicket(ctx, id, func(cur *SupportTicket) error { + appendSupportMessage(cur, "user", "tester", fmt.Sprintf("msg-%d", n), SupportStatusOpen) + return nil + }) + errs[n] = err + }(i) + } + + start.Done() + done.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("writer %d failed: %v", i, err) + } + } + + final, err := dbGetSupportTicket(ctx, id) + if err != nil { + t.Fatalf("read back: %v", err) + } + + // Seed message plus one per writer, with none lost to a clobbering write. + if want := writers + 1; len(final.Messages) != want { + t.Errorf("thread holds %d messages, want %d: concurrent replies are being lost", + len(final.Messages), want) + } + + seen := map[string]bool{} + for _, m := range final.Messages { + seen[m.Body] = true + } + for i := 0; i < writers; i++ { + if body := fmt.Sprintf("msg-%d", i); !seen[body] { + t.Errorf("message %q is missing from the thread", body) + } + } +} + +// A mutator error must abort the write, so a condition judged against fresh +// state (a ticket closed since the handler read it) cannot be applied anyway. +func TestSupportTicketMutatorErrorAbortsWrite(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const id = "sup-race-abort" + now := time.Now() + if err := dbSaveSupportTicket(ctx, &SupportTicket{ + ID: id, Subject: "abort", Name: "t", Email: "abort@example.com", + Status: SupportStatusClosed, + Messages: []SupportMessage{{Author: "user", Body: "only", CreatedAt: now}}, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + cctx, ccancel := context.WithTimeout(context.Background(), 30*time.Second) + defer ccancel() + rdb.Del(cctx, supportTicketKey(id)) + rdb.ZRem(cctx, supportTicketIndexKey, id) + rdb.ZRem(cctx, supportUserIndexKey("abort@example.com"), id) + }) + + _, err := dbUpdateSupportTicket(ctx, id, func(cur *SupportTicket) error { + if cur.Status == SupportStatusClosed { + return errTicketClosed + } + appendSupportMessage(cur, "user", "t", "should not land", SupportStatusOpen) + return nil + }) + if err != errTicketClosed { + t.Fatalf("got %v, want errTicketClosed", err) + } + + after, err := dbGetSupportTicket(ctx, id) + if err != nil { + t.Fatalf("read back: %v", err) + } + if len(after.Messages) != 1 { + t.Errorf("thread grew to %d messages despite the mutator aborting", len(after.Messages)) + } +} diff --git a/backend/support_test.go b/backend/support_test.go new file mode 100644 index 00000000..8fc92dae --- /dev/null +++ b/backend/support_test.go @@ -0,0 +1,450 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" +) + +func supportCtx(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + return ctx +} + +// newTestTicket writes a ticket straight to the store, bypassing the handler, +// so the storage-level assertions below do not depend on HTTP plumbing. +func newTestTicket(t *testing.T, ctx context.Context, id, email string) *SupportTicket { + t.Helper() + now := time.Now() + ticket := &SupportTicket{ + ID: id, + Subject: "נושא", + Name: "בודק", + Email: email, + Authenticated: true, + Status: SupportStatusOpen, + Messages: []SupportMessage{ + {Author: "user", AuthorName: "בודק", Body: "גוף ההודעה", CreatedAt: now}, + }, + CreatedAt: now, + UpdatedAt: now, + AccessToken: "token-" + id, + } + if err := dbSaveSupportTicket(ctx, ticket); err != nil { + t.Fatalf("save ticket %s: %v", id, err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.Del(cctx, supportTicketKey(id)) + rdb.ZRem(cctx, supportTicketIndexKey, id) + if email != "" { + rdb.ZRem(cctx, supportUserIndexKey(email), id) + } + }) + return ticket +} + +// The access token is the only credential guarding an anonymous thread, so it +// must never travel back out in a response body. Every handler serialises +// through publicView; this pins that. +func TestSupportPublicViewStripsAccessToken(t *testing.T) { + ctx := supportCtx(t) + ticket := newTestTicket(t, ctx, "sup-strip", "a@example.com") + + view := publicView(ticket) + if view.AccessToken != "" { + t.Errorf("access token must not survive publicView, got %q", view.AccessToken) + } + // The struct is copied, so the stored ticket keeps its token. + if ticket.AccessToken == "" { + t.Error("publicView must not clear the token on the original") + } + + data, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(data), "token-sup-strip") { + t.Errorf("serialised ticket leaks the access token: %s", data) + } + // omitempty: the field should be absent entirely, not present and empty. + if strings.Contains(string(data), "accessToken") { + t.Errorf("accessToken key should be omitted, got %s", data) + } +} + +// An operator reply is served to the requester, so it must be signed by the +// management and carry nothing of the operator's own identity. The handler must +// not even look at the session for a name: whatever it would find there (display +// name, Google username, email) is exactly what must stay hidden. +func TestSupportAdminReplyIsSignedByManagement(t *testing.T) { + ctx := supportCtx(t) + newTestTicket(t, ctx, "sup-anon-op", "requester@example.com") + + r := httptest.NewRequest(http.MethodPost, "/api/super-admin/support/tickets/sup-anon-op/reply", + strings.NewReader(`{"body":"תשובה מהמערכת"}`)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "sup-anon-op") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + adminReplySupportTicket(w, r) + if w.Code != http.StatusOK { + t.Fatalf("reply: status %d, body %q", w.Code, w.Body.String()) + } + + stored, err := dbGetSupportTicket(ctx, "sup-anon-op") + if err != nil { + t.Fatalf("get: %v", err) + } + last := stored.Messages[len(stored.Messages)-1] + if last.Author != "admin" || last.AuthorName != operatorName { + t.Errorf("stored operator reply = %+v, want author admin signed %q", last, operatorName) + } +} + +// Replies written before operator replies were signed by the management still +// hold the operator's name or email, and are served for the ticket's lifetime. +// publicView is what every handler sends, so it must re-sign them — without +// touching the requester's own name or the stored ticket. +func TestSupportPublicViewHidesOperatorIdentity(t *testing.T) { + ctx := supportCtx(t) + ticket := newTestTicket(t, ctx, "sup-legacy-op", "requester@example.com") + const operator = "Real Operator op.private@gmail.com" + appendSupportMessage(ticket, "admin", operator, "תשובה", SupportStatusAnswered) + + view := publicView(ticket) + if got := view.Messages[1].AuthorName; got != operatorName { + t.Errorf("operator reply shown as %q, want %q", got, operatorName) + } + if got := view.Messages[0].AuthorName; got != "בודק" { + t.Errorf("the requester's own name must be kept, got %q", got) + } + if ticket.Messages[1].AuthorName != operator { + t.Error("publicView must not rewrite the stored ticket") + } + + data, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, leak := range []string{"Real Operator", "op.private"} { + if strings.Contains(string(data), leak) { + t.Errorf("serialised ticket leaks the operator's identity (%q): %s", leak, data) + } + } +} + +// A ticket must be reachable by its own token and by nothing else. +func TestSupportTicketRoundTrip(t *testing.T) { + ctx := supportCtx(t) + newTestTicket(t, ctx, "sup-rt", "b@example.com") + + got, err := dbGetSupportTicket(ctx, "sup-rt") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.AccessToken != "token-sup-rt" { + t.Errorf("token not persisted, got %q", got.AccessToken) + } + if len(got.Messages) != 1 || got.Messages[0].Body != "גוף ההודעה" { + t.Errorf("thread not persisted: %+v", got.Messages) + } + if got.Status != SupportStatusOpen { + t.Errorf("status = %q, want open", got.Status) + } +} + +// The per-user index is what makes "my tickets" work without scanning every +// ticket, and it must be case-insensitive: Google hands back addresses whose +// capitalisation is not stable. +func TestSupportUserIndexIsCaseInsensitive(t *testing.T) { + ctx := supportCtx(t) + newTestTicket(t, ctx, "sup-case", "Mixed.Case@Example.COM") + + for _, probe := range []string{ + "Mixed.Case@Example.COM", + "mixed.case@example.com", + "MIXED.CASE@EXAMPLE.COM", + } { + tickets, err := dbListSupportTickets(ctx, supportUserIndexKey(probe)) + if err != nil { + t.Fatalf("list for %s: %v", probe, err) + } + if len(tickets) != 1 || tickets[0].ID != "sup-case" { + t.Errorf("%s: expected to find the ticket, got %d", probe, len(tickets)) + } + } +} + +// A user index must only ever surface that user's own tickets. +func TestSupportUserIndexIsolatesUsers(t *testing.T) { + ctx := supportCtx(t) + newTestTicket(t, ctx, "sup-mine", "mine@example.com") + newTestTicket(t, ctx, "sup-theirs", "theirs@example.com") + + tickets, err := dbListSupportTickets(ctx, supportUserIndexKey("mine@example.com")) + if err != nil { + t.Fatalf("list: %v", err) + } + for _, tk := range tickets { + if tk.ID == "sup-theirs" { + t.Fatal("another user's ticket appeared in a personal listing") + } + } + if len(tickets) != 1 { + t.Errorf("expected exactly the caller's ticket, got %d", len(tickets)) + } +} + +// An anonymous ticket carries whatever address the visitor typed, so it must +// never be listed under that address: otherwise anyone could plant a thread in +// a user's "my tickets" and, holding the access token, read the replies. +func TestSupportAnonymousTicketIsNotIndexedByEmail(t *testing.T) { + ctx := supportCtx(t) + victim := "victim@example.com" + newTestTicket(t, ctx, "sup-victim", victim) + + fresh := &SupportTicket{ + ID: "sup-anon-2", Subject: "נושא", Name: "אורח", Email: victim, + Status: SupportStatusOpen, CreatedAt: time.Now(), UpdatedAt: time.Now(), + Messages: []SupportMessage{{Author: "user", Body: "x", CreatedAt: time.Now()}}, + } + if err := dbSaveSupportTicket(ctx, fresh); err != nil { + t.Fatalf("save fresh anonymous ticket: %v", err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.Del(cctx, supportTicketKey("sup-anon-2")) + rdb.ZRem(cctx, supportTicketIndexKey, "sup-anon-2") + rdb.ZRem(cctx, supportUserIndexKey(victim), "sup-anon-2") + }) + + tickets, err := dbListSupportTickets(ctx, supportUserIndexKey(victim)) + if err != nil { + t.Fatalf("list: %v", err) + } + for _, tk := range tickets { + if tk.ID == "sup-anon-2" { + t.Fatal("an anonymous ticket was listed under the typed email") + } + } + if len(tickets) != 1 || tickets[0].ID != "sup-victim" { + t.Errorf("expected only the signed-in user's ticket, got %d", len(tickets)) + } +} + +// The update path (a reply, a status change) re-indexes the ticket, and used +// to do so under the typed e-mail regardless of Authenticated — so the anonymous +// thread the save path kept out of the victim's list reappeared there as soon +// as anyone answered it. +func TestSupportAnonymousTicketStaysUnindexedAfterUpdate(t *testing.T) { + ctx := supportCtx(t) + victim := "victim-upd@example.com" + newTestTicket(t, ctx, "sup-victim-upd", victim) + + anon := &SupportTicket{ + ID: "sup-anon-upd", Subject: "נושא", Name: "אורח", Email: victim, + Status: SupportStatusOpen, CreatedAt: time.Now(), UpdatedAt: time.Now(), + Messages: []SupportMessage{{Author: "user", Body: "x", CreatedAt: time.Now()}}, + } + if err := dbSaveSupportTicket(ctx, anon); err != nil { + t.Fatalf("save anonymous ticket: %v", err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.Del(cctx, supportTicketKey("sup-anon-upd")) + rdb.ZRem(cctx, supportTicketIndexKey, "sup-anon-upd") + rdb.ZRem(cctx, supportUserIndexKey(victim), "sup-anon-upd") + }) + + if _, err := dbUpdateSupportTicket(ctx, "sup-anon-upd", func(tk *SupportTicket) error { + appendSupportMessage(tk, "admin", "מנהל", "תשובה", SupportStatusAnswered) + return nil + }); err != nil { + t.Fatalf("reply to anonymous ticket: %v", err) + } + + tickets, err := dbListSupportTickets(ctx, supportUserIndexKey(victim)) + if err != nil { + t.Fatalf("list: %v", err) + } + for _, tk := range tickets { + if tk.ID == "sup-anon-upd" { + t.Fatal("a reply re-indexed the anonymous ticket under the typed email") + } + } + if len(tickets) != 1 || tickets[0].ID != "sup-victim-upd" { + t.Errorf("expected only the signed-in user's ticket, got %d", len(tickets)) + } + // The same update on a signed-in ticket must still refresh its index entry. + if _, err := dbUpdateSupportTicket(ctx, "sup-victim-upd", func(tk *SupportTicket) error { + appendSupportMessage(tk, "admin", "מנהל", "תשובה", SupportStatusAnswered) + return nil + }); err != nil { + t.Fatalf("reply to signed-in ticket: %v", err) + } + if n, _ := rdb.ZScore(ctx, supportUserIndexKey(victim), "sup-victim-upd").Result(); n == 0 { + t.Error("signed-in ticket lost its per-user index entry after an update") + } +} + +// The operator inbox is ordered by last activity, so a thread that just +// received a reply rises to the top rather than staying at its creation time. +func TestSupportInboxOrdersByLastActivity(t *testing.T) { + ctx := supportCtx(t) + older := newTestTicket(t, ctx, "sup-order-a", "a@example.com") + newTestTicket(t, ctx, "sup-order-b", "b@example.com") + + // The older ticket gets an answer and should overtake the newer one. + appendSupportMessage(older, "admin", "מנהל", "תשובה", SupportStatusAnswered) + older.UpdatedAt = time.Now().Add(time.Minute) + if err := dbSaveSupportTicket(ctx, older); err != nil { + t.Fatalf("save: %v", err) + } + + tickets, err := dbListSupportTickets(ctx, supportTicketIndexKey) + if err != nil { + t.Fatalf("list: %v", err) + } + var seenA, seenB int = -1, -1 + for i, tk := range tickets { + switch tk.ID { + case "sup-order-a": + seenA = i + case "sup-order-b": + seenB = i + } + } + if seenA < 0 || seenB < 0 { + t.Fatalf("both tickets should be listed, got a=%d b=%d", seenA, seenB) + } + if seenA > seenB { + t.Error("the recently answered ticket should sort ahead of the untouched newer one") + } +} + +// appendSupportMessage is what keeps the thread, the status and the index +// score in step; a reply that did not move UpdatedAt would sort wrongly. +func TestSupportAppendMessageAdvancesStatusAndTime(t *testing.T) { + ctx := supportCtx(t) + ticket := newTestTicket(t, ctx, "sup-append", "c@example.com") + before := ticket.UpdatedAt + + time.Sleep(10 * time.Millisecond) + appendSupportMessage(ticket, "admin", "מנהל", "תשובה", SupportStatusAnswered) + + if len(ticket.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(ticket.Messages)) + } + last := ticket.Messages[1] + if last.Author != "admin" || last.Body != "תשובה" { + t.Errorf("unexpected appended message: %+v", last) + } + if ticket.Status != SupportStatusAnswered { + t.Errorf("status = %q, want answered", ticket.Status) + } + if !ticket.UpdatedAt.After(before) { + t.Error("UpdatedAt must advance, or the inbox ordering is wrong") + } +} + +func TestSupportStatusValidation(t *testing.T) { + for _, s := range []SupportStatus{SupportStatusOpen, SupportStatusAnswered, SupportStatusClosed} { + if !validSupportStatus(s) { + t.Errorf("%q should be valid", s) + } + } + // Anything else arrives straight from a request body. + for _, s := range []SupportStatus{"", "deleted", "OPEN", "open "} { + if validSupportStatus(s) { + t.Errorf("%q should be rejected", s) + } + } +} + +func TestSupportEmailShapeCheck(t *testing.T) { + valid := []string{"a@b.co", "first.last@sub.example.com", "x+tag@example.org"} + for _, e := range valid { + if !looksLikeEmail(e) { + t.Errorf("%q should pass", e) + } + } + invalid := []string{"", "nope", "@example.com", "a@", "a@b", "a b@example.com", "a@ex ample.com", "a@example.com\n"} + for _, e := range invalid { + if looksLikeEmail(e) { + t.Errorf("%q should be rejected", e) + } + } +} + +// Free-text fields land in a Redis value read back in full on every view, so +// the cap is enforced on write. Truncation must not panic on any input. +func TestSupportTrimTo(t *testing.T) { + if got := trimTo(" hello ", 100); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } + if got := trimTo("abcdef", 3); got != "abc" { + t.Errorf("got %q, want %q", got, "abc") + } + if got := trimTo(" ", 10); got != "" { + t.Errorf("whitespace-only should trim to empty, got %q", got) + } + if got := trimTo("", 0); got != "" { + t.Errorf("empty input with zero cap should stay empty, got %q", got) + } +} + +// A signed-in ticket is reachable by its session only. Tickets created before +// tokens stopped being issued to signed-in senders still carry one, and the +// browser keeps sending it from localStorage after logout — so the token path +// must not open those, while it stays the one way into an anonymous thread. +func TestSupportTokenOnlyAuthorisesAnonymousTickets(t *testing.T) { + ctx := supportCtx(t) + newTestTicket(t, ctx, "sup-authed-tok", "owner@example.com") // Authenticated, with a legacy token + + anon := &SupportTicket{ + ID: "sup-anon-tok", Subject: "נושא", Name: "אורח", Email: "guest@example.com", + Status: SupportStatusOpen, CreatedAt: time.Now(), UpdatedAt: time.Now(), + Messages: []SupportMessage{{Author: "user", Body: "x", CreatedAt: time.Now()}}, + AccessToken: "token-sup-anon-tok", + } + if err := dbSaveSupportTicket(ctx, anon); err != nil { + t.Fatalf("save anonymous ticket: %v", err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.Del(cctx, supportTicketKey("sup-anon-tok")) + rdb.ZRem(cctx, supportTicketIndexKey, "sup-anon-tok") + }) + + withToken := func(id, token string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/support/tickets/"+id, nil) + r.Header.Set(ticketTokenHeader, token) + return r.WithContext(ctx) + } + + if _, ok := authoriseTicket(ctx, withToken("sup-authed-tok", "token-sup-authed-tok"), "sup-authed-tok"); ok { + t.Fatal("a legacy token opened a signed-in ticket without its session") + } + if _, ok := authoriseTicket(ctx, withToken("sup-anon-tok", "token-sup-anon-tok"), "sup-anon-tok"); !ok { + t.Fatal("the token did not open the anonymous ticket it was issued for") + } + if _, ok := authoriseTicket(ctx, withToken("sup-anon-tok", "wrong"), "sup-anon-tok"); ok { + t.Fatal("a wrong token opened an anonymous ticket") + } + if _, ok := authoriseTicket(ctx, withToken("sup-anon-tok", ""), "sup-anon-tok"); ok { + t.Fatal("an empty token opened an anonymous ticket") + } +} diff --git a/backend/webhook.go b/backend/webhook.go index bea610eb..fe209513 100644 --- a/backend/webhook.go +++ b/backend/webhook.go @@ -3,15 +3,89 @@ package main import ( "bytes" "context" - "crypto/tls" "encoding/json" + "fmt" "log" + "net" "net/http" + "net/url" + "os" + "syscall" "time" ) -// var webhookURL string = os.Getenv("WEBHOOK_URL") -// var verifyToken string = os.Getenv("WEBHOOK_VERIFY_TOKEN") +// webhookClient is shared across deliveries: a per-message http.Transport never +// expires its idle connections and is never collected, so sockets and their +// readLoop goroutines accumulate on busy channels. Certificate verification is +// left at the default (on) — the payload carries the channel's verify token. +var webhookClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + MaxIdleConnsPerHost: 4, + IdleConnTimeout: 30 * time.Second, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + // Control runs after DNS resolution with the address actually about + // to be dialled, which is what makes this cover redirects and DNS + // rebinding as well as the literal URL. + Control: blockPrivateAddress, + }).DialContext, + }, +} + +// allowPrivateWebhookTargets re-enables webhooks to private addresses, for an +// operator running the whole platform inside a trusted network. Off by default: +// the URL is set by a channel owner, who on a self-service platform is an +// untrusted party. +var allowPrivateWebhookTargets = os.Getenv("WEBHOOK_ALLOW_PRIVATE") == "1" + +// blockPrivateAddress refuses to connect anywhere that is not a public address. +// +// The webhook URL comes from the per-channel webhook_url setting, writable by +// any channel owner. Without this an owner could point it at 169.254.169.254 or +// at the platform's own loopback interface and have the server issue requests +// from inside its trust boundary, using the logged response code as an internal +// port scanner. +func blockPrivateAddress(network, address string, _ syscall.RawConn) error { + if allowPrivateWebhookTargets { + return nil + } + if network != "tcp4" && network != "tcp6" && network != "tcp" { + return fmt.Errorf("webhook: refusing non-tcp network %q", network) + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("webhook: unparseable address %q", address) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("webhook: unresolvable address %q", host) + } + if !isPublicIP(ip) { + return fmt.Errorf("webhook: refusing to connect to non-public address %s", ip) + } + return nil +} + +// isPublicIP reports whether ip is routable on the public internet. +func isPublicIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || ip.IsMulticast() { + return false + } + // Carrier-grade NAT (100.64.0.0/10) is not covered by IsPrivate but is just + // as much somebody's internal network. + if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return false + } + // IPv4-mapped IPv6 (::ffff:127.0.0.1) must be judged on the mapped address. + if ip4 := ip.To4(); ip4 != nil && !ip.Equal(ip4) { + return isPublicIP(ip4) + } + return true +} type WebhookPayload struct { Action string `json:"action"` @@ -20,16 +94,36 @@ type WebhookPayload struct { VerifyToken string `json:"verifyToken"` } -func SendWebhook(ctx context.Context, action string, message *Message) { - if settingConfig.WebhookURL == "" { +func SendWebhook(ctx context.Context, slug string, action string, message *Message) { + // Load per-channel config for webhook settings + chCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + cfg := getChannelConfig(chCtx, slug) + if cfg.WebhookURL == "" { + return + } + + // Operator-level kill switch: the super admin can withdraw webhooks from a + // tenant regardless of what the owner has configured. The channel HAS a + // webhook configured at this point, so going silent without a trace would + // make the kill switch undiagnosable. + if ch, err := dbGetChannel(chCtx, slug); err == nil && !ch.Features.Webhook { + log.Printf("webhook suppressed for %s: feature disabled by operator\n", slug) return } + // The webhook goes to wherever the channel owner points it, so an operator + // post must reach it re-labelled like everywhere else. A copy: the caller's + // message is shared with the push notification running alongside. + msg := *message + currentOperators().anonymiseOperatorAuthor(&msg) + payload := WebhookPayload{ Action: action, - Message: *message, + Message: msg, Timestamp: time.Now(), - VerifyToken: settingConfig.VerifyToken, + VerifyToken: cfg.VerifyToken, } jsonData, err := json.Marshal(payload) @@ -38,10 +132,17 @@ func SendWebhook(ctx context.Context, action string, message *Message) { return } - httpCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() + httpCtx, httpCancel := context.WithTimeout(ctx, 5*time.Second) + defer httpCancel() + + // Scheme check before the socket guard: a non-http scheme would never reach + // the dialler, and file:// or gopher:// have no business here. + if u, err := url.Parse(cfg.WebhookURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") { + log.Printf("webhook for %s: refusing URL with unsupported scheme: %q\n", slug, cfg.WebhookURL) + return + } - req, err := http.NewRequestWithContext(httpCtx, "POST", settingConfig.WebhookURL, bytes.NewBuffer(jsonData)) + req, err := http.NewRequestWithContext(httpCtx, "POST", cfg.WebhookURL, bytes.NewBuffer(jsonData)) if err != nil { log.Printf("Error creating webhook request: %v\n", err) return @@ -50,15 +151,7 @@ func SendWebhook(ctx context.Context, action string, message *Message) { req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "TheChannel-Webhook") - // Warning! Default is not secure - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - } - resp, err := client.Do(req) + resp, err := webhookClient.Do(req) if err != nil { log.Printf("Error sending webhook: %v\n", err) return diff --git a/backend/webhook_ssrf_test.go b/backend/webhook_ssrf_test.go new file mode 100644 index 00000000..f88620fa --- /dev/null +++ b/backend/webhook_ssrf_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "net" + "testing" +) + +// The webhook URL is written by a channel owner, who on a self-service platform +// is an untrusted party. Without a guard, an owner could point it at the cloud +// metadata service or at the platform's own loopback interface and have the +// server issue requests from inside its trust boundary. +func TestWebhookRefusesNonPublicAddresses(t *testing.T) { + blocked := []struct{ name, ip string }{ + {"IPv4 loopback", "127.0.0.1"}, + {"IPv4 loopback range", "127.10.20.30"}, + {"cloud metadata service", "169.254.169.254"}, + {"link-local", "169.254.1.1"}, + {"RFC1918 10/8", "10.0.0.5"}, + {"RFC1918 172.16/12", "172.16.31.9"}, + {"RFC1918 192.168/16", "192.168.1.1"}, + {"carrier-grade NAT", "100.64.0.1"}, + {"CGNAT upper bound", "100.127.255.254"}, + {"unspecified", "0.0.0.0"}, + {"multicast", "224.0.0.1"}, + {"IPv6 loopback", "::1"}, + {"IPv6 unique local", "fd00::1"}, + {"IPv6 link-local", "fe80::1"}, + {"IPv6 unspecified", "::"}, + // A mapped address must be judged on the address it maps to, or + // ::ffff:127.0.0.1 walks straight past an IPv4-only check. + {"IPv4-mapped loopback", "::ffff:127.0.0.1"}, + {"IPv4-mapped private", "::ffff:10.0.0.1"}, + } + for _, c := range blocked { + t.Run("blocked/"+c.name, func(t *testing.T) { + ip := net.ParseIP(c.ip) + if ip == nil { + t.Fatalf("test bug: %q is not an IP", c.ip) + } + if isPublicIP(ip) { + t.Errorf("%s (%s) was treated as public", c.name, c.ip) + } + if err := blockPrivateAddress("tcp", net.JoinHostPort(c.ip, "80"), nil); err == nil { + t.Errorf("dial to %s (%s) was allowed", c.name, c.ip) + } + }) + } + + // Ordinary public destinations must keep working — this feature is only + // useful if real webhooks still deliver. + allowed := []struct{ name, ip string }{ + {"public IPv4", "93.184.216.34"}, + {"public IPv4 (CGNAT-adjacent)", "100.128.0.1"}, + {"public IPv4 (below CGNAT)", "100.63.255.255"}, + {"public IPv6", "2606:2800:220:1:248:1893:25c8:1946"}, + } + for _, c := range allowed { + t.Run("allowed/"+c.name, func(t *testing.T) { + ip := net.ParseIP(c.ip) + if ip == nil { + t.Fatalf("test bug: %q is not an IP", c.ip) + } + if !isPublicIP(ip) { + t.Errorf("%s (%s) was treated as non-public", c.name, c.ip) + } + if err := blockPrivateAddress("tcp", net.JoinHostPort(c.ip, "443"), nil); err != nil { + t.Errorf("dial to %s (%s) was refused: %v", c.name, c.ip, err) + } + }) + } +} + +// The operator escape hatch exists for a platform running entirely inside a +// trusted network; it must be off unless explicitly set. +func TestWebhookPrivateEscapeHatchIsOffByDefault(t *testing.T) { + if allowPrivateWebhookTargets { + t.Error("WEBHOOK_ALLOW_PRIVATE must default to off") + } +} diff --git a/captain-definition b/captain-definition new file mode 100644 index 00000000..debfa9b3 --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} \ No newline at end of file diff --git a/frontend/angular.json b/frontend/angular.json index 5cd1a8c1..e5dc11b7 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -33,8 +33,7 @@ } ], "styles": [ - "node_modules/@nebular/theme/styles/prebuilt/default.css", - "node_modules/bootstrap/dist/css/bootstrap.min.css", + "src/bootstrap-subset.scss", "src/styles.scss", "node_modules/prismjs/themes/prism-okaidia.css", "node_modules/viewerjs/dist/viewer.css" @@ -43,20 +42,30 @@ "node_modules/prismjs/prism.js", "node_modules/prismjs/components/prism-csharp.min.js", "node_modules/prismjs/components/prism-css.min.js" - ] + ], + "stylePreprocessorOptions": { + "sass": { + "silenceDeprecations": [ + "import", + "global-builtin", + "color-functions", + "mixed-decls" + ] + } + } }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "2MB", - "maximumError": "5MB" + "maximumWarning": "2.5MB", + "maximumError": "4MB" }, { "type": "anyComponentStyle", - "maximumWarning": "6kB", - "maximumError": "10kB" + "maximumWarning": "10kB", + "maximumError": "20kB" } ], "outputHashing": "all" @@ -108,8 +117,8 @@ } ], "styles": [ - "src/styles.scss", - "node_modules/bootstrap/dist/css/bootstrap.css" + "src/bootstrap-subset.scss", + "src/styles.scss" ], "scripts": [] } @@ -143,4 +152,4 @@ "typeSeparator": "." } } -} \ No newline at end of file +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc82ce00..db7a5b50 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,21 +20,17 @@ "@angular/service-worker": "^20.3.7", "@angular/youtube-player": "^19.2.8", "@ctrl/ngx-emoji-mart": "^9.2.0", - "@nebular/eva-icons": "^16.0.0", "@nebular/theme": "^16.0.0", - "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@ng-icons/core": "^30.3.0", "@ng-icons/heroicons": "^30.3.0", - "@popperjs/core": "^2.11.8", "angular-markdown-editor": "^3.1.1", "bootstrap": "^5.3.3", - "bootstrap-icons": "^1.11.3", "chart.js": "^4.3.0", "chartjs-plugin-zoom": "^2.2.0", + "dompurify": "^3.3.0", "eva-icons": "^1.1.3", "firebase": "^11.10.0", "marked": "^15.0.0", - "moment": "^2.30.1", "ng2-charts": "^8.0.0", "ngx-autosize": "^2.0.4", "ngx-markdown": "^20.1.0", @@ -274,6 +270,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -542,6 +539,7 @@ "version": "20.3.7", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.7.tgz", "integrity": "sha512-viZwWlwc1BAqryRJE0Wq2WgAxDaW9fuwtYHYrOWnIn9sy9KemKmR6RmU9VRydrwUROOlqK49R9+RC1wQ6sYwqA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -617,6 +615,7 @@ "version": "20.3.7", "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-20.3.7.tgz", "integrity": "sha512-FYuuwU9ujiVT+0xjMIutaUT2PErV4AvxeAPWMlYRA1/yQxqn1VyNUd6kHPjAV+yrZg9Q0MDco2/c0Lh8rmAhSA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -757,6 +756,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -771,6 +771,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -780,6 +781,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -810,12 +812,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -825,6 +829,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", @@ -854,6 +859,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -870,6 +876,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -879,6 +886,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -888,6 +896,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -901,6 +910,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -931,6 +941,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -940,6 +951,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -949,6 +961,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -958,6 +971,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -971,6 +985,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -986,6 +1001,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -1000,6 +1016,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -1018,6 +1035,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2789,6 +2807,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2799,6 +2818,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2808,12 +2828,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3406,19 +3428,6 @@ "node": ">= 10" } }, - "node_modules/@nebular/eva-icons": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@nebular/eva-icons/-/eva-icons-16.0.0.tgz", - "integrity": "sha512-TS8KbcIKUmtWLyDXobrwA+2HzsS/yqLNSHEKME/Kd5OTEaQef/AtXIBMTHCjhHNl0SnFfmB1irNUipVbo7M47w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@nebular/theme": "16.0.0", - "eva-icons": "^1.1.3" - } - }, "node_modules/@nebular/theme": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@nebular/theme/-/theme-16.0.0.tgz", @@ -3436,23 +3445,6 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@ng-bootstrap/ng-bootstrap": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-19.0.1.tgz", - "integrity": "sha512-1lErAkwh0F+gWkzpiddViY4GfA9LVXkwLpgBsV9Mb3IC0zo6WNkY8WxCC+LqajirBTu20DCkZSqeRzrwaVLpZw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/forms": "^20.0.0", - "@angular/localize": "^20.0.0", - "@popperjs/core": "^2.11.8", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, "node_modules/@ng-icons/core": { "version": "30.4.0", "resolved": "https://registry.npmjs.org/@ng-icons/core/-/core-30.4.0.tgz", @@ -4120,6 +4112,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -4655,6 +4648,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -4668,6 +4662,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -4677,6 +4672,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -4687,6 +4683,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -5239,6 +5236,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5251,6 +5249,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5307,6 +5306,7 @@ "version": "2.8.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.21.tgz", "integrity": "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -5405,22 +5405,6 @@ "@popperjs/core": "^2.11.8" } }, - "node_modules/bootstrap-icons": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", - "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT" - }, "node_modules/bootstrap-markdown": { "version": "2.10.0", "resolved": "git+ssh://git@github.com/refactory-id/bootstrap-markdown.git#a496d34b9bd34451c8315a850472f794c8df7d53", @@ -5454,6 +5438,7 @@ "version": "4.27.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5650,6 +5635,7 @@ "version": "1.0.30001751", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5743,6 +5729,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -5836,6 +5823,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -5850,6 +5838,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -6024,6 +6013,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -6707,6 +6697,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6838,7 +6829,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6891,12 +6881,14 @@ "version": "1.5.243", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.243.tgz", "integrity": "sha512-ZCphxFW3Q1TVhcgS9blfut1PX8lusVi2SvXQgmEEnK4TCmE1JhH2JkjJN+DNt0pJJwfBri5AROBnz2b/C+YU9g==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, "license": "MIT" }, "node_modules/emoji-toolkit": { @@ -7360,6 +7352,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7578,6 +7571,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7596,6 +7590,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8331,12 +8326,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8366,6 +8363,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -9158,6 +9156,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -9598,15 +9597,6 @@ "pathe": "^2.0.1" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -9621,6 +9611,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "devOptional": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -9867,6 +9858,7 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, "license": "MIT" }, "node_modules/nopt": { @@ -10436,12 +10428,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10690,6 +10684,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -10703,6 +10698,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, "license": "Apache-2.0" }, "node_modules/require-directory": { @@ -10975,6 +10971,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11494,6 +11491,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11567,6 +11565,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -11754,6 +11753,7 @@ "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.4.4", @@ -11849,7 +11849,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11949,6 +11949,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -12498,12 +12499,14 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -12521,6 +12524,7 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" diff --git a/frontend/package.json b/frontend/package.json index 293730c1..4d7914e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,21 +22,17 @@ "@angular/service-worker": "^20.3.7", "@angular/youtube-player": "^19.2.8", "@ctrl/ngx-emoji-mart": "^9.2.0", - "@nebular/eva-icons": "^16.0.0", "@nebular/theme": "^16.0.0", - "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@ng-icons/core": "^30.3.0", "@ng-icons/heroicons": "^30.3.0", - "@popperjs/core": "^2.11.8", "angular-markdown-editor": "^3.1.1", "bootstrap": "^5.3.3", - "bootstrap-icons": "^1.11.3", "chart.js": "^4.3.0", "chartjs-plugin-zoom": "^2.2.0", + "dompurify": "^3.3.0", "eva-icons": "^1.1.3", "firebase": "^11.10.0", "marked": "^15.0.0", - "moment": "^2.30.1", "ng2-charts": "^8.0.0", "ngx-autosize": "^2.0.4", "ngx-markdown": "^20.1.0", @@ -62,4 +58,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.9.3" } -} \ No newline at end of file +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..484574fd Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 00000000..6866e342 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 00000000..d7a76a0a --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 00000000..5fb84dae --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "הערוץ", + "short_name": "הערוץ", + "description": "ערוץ שידור משלכם, פתוח תוך רגע וללא עלות", + "lang": "he", + "dir": "rtl", + "start_url": "/", + "display": "standalone", + "background_color": "#f6f7fb", + "theme_color": "#5a5ff5", + "icons": [ + { "src": "/assets/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/assets/apple-touch-icon.png", "sizes": "180x180", "type": "image/png", "purpose": "any" }, + { "src": "/assets/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/frontend/public/og.jpg b/frontend/public/og.jpg new file mode 100644 index 00000000..c2eb70f7 Binary files /dev/null and b/frontend/public/og.jpg differ diff --git a/frontend/public/video/channel-intro-poster.jpg b/frontend/public/video/channel-intro-poster.jpg new file mode 100644 index 00000000..cafcb6e9 Binary files /dev/null and b/frontend/public/video/channel-intro-poster.jpg differ diff --git a/frontend/public/video/channel-intro.mp4 b/frontend/public/video/channel-intro.mp4 new file mode 100644 index 00000000..d1ee2b4a Binary files /dev/null and b/frontend/public/video/channel-intro.mp4 differ diff --git a/frontend/public/video/channel-intro.webm b/frontend/public/video/channel-intro.webm new file mode 100644 index 00000000..7b32d202 Binary files /dev/null and b/frontend/public/video/channel-intro.webm differ diff --git a/frontend/scripts/check-bootstrap-classes.mjs b/frontend/scripts/check-bootstrap-classes.mjs new file mode 100644 index 00000000..41b94697 --- /dev/null +++ b/frontend/scripts/check-bootstrap-classes.mjs @@ -0,0 +1,46 @@ +// Every Bootstrap-looking class used in a template must exist in the compiled +// stylesheet. Bootstrap is compiled from a subset (src/bootstrap-subset.scss); +// a class dropped from the subset would otherwise silently unstyle markup. +// +// node scripts/check-bootstrap-classes.mjs +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const dist = process.argv[2]; +if (!dist) { console.error('usage: check-bootstrap-classes.mjs '); process.exit(2); } + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (name.endsWith('.html')) out.push(p); + } + return out; +} + +const css = readdirSync(dist).filter((f) => f.endsWith('.css')).map((f) => readFileSync(join(dist, f), 'utf8')).join('\n'); +const defined = new Set(); +for (const m of css.matchAll(/\.((?:[a-zA-Z0-9_-]|\\[.:%/])+)/g)) defined.add(m[1].replace(/\\/g, '')); + +// Bootstrap's utility and component prefixes; everything else is component CSS. +// Component names (card, badge, alert, table) are matched exactly, because +// component styles reuse those words for their own BEM classes (.card-head). +const bs = /^(?:(?:d|flex|justify-content|align-items|align-self|align-content|order|gap|row-gap|column-gap|m[tbsexy]?|p[tbsexy]?|text|fs|fw|fst|lh|rounded|border|bg|shadow|overflow|w|h|mw|mh|vw|vh|position|top|bottom|start|end|container|row|col|g|gx|gy|spinner|img|ratio)(?:-|$)|(?:visually-hidden|text-truncate|btn-close|form-label|form-control|form-text|small|lead|list-unstyled|list-inline|mx-auto|ms-auto|me-auto|card|card-body|card-title|card-subtitle|card-text|card-header|card-footer|card-img|card-img-top|card-link|badge|alert|alert-primary|alert-secondary|alert-success|alert-danger|alert-warning|alert-info|alert-light|alert-dark|alert-link|alert-heading|table|table-sm|table-striped|table-hover|table-bordered|table-responsive)$)/; + +const used = new Map(); +for (const file of walk(join(root, 'src'))) { + const html = readFileSync(file, 'utf8'); + for (const m of html.matchAll(/\sclass="([^"]*)"/g)) { + for (const cls of m[1].split(/\s+/).filter(Boolean)) { + if (bs.test(cls) && !defined.has(cls)) used.set(cls, (used.get(cls) || new Set()).add(file.replace(root + '/', ''))); + } + } +} +if (used.size) { + console.error('classes used in templates but missing from the compiled CSS:'); + for (const [cls, files] of used) console.error(` .${cls} (${[...files].join(', ')})`); + process.exit(1); +} +console.log('every Bootstrap-looking template class exists in the compiled CSS'); diff --git a/frontend/scripts/gen-eva-subset.mjs b/frontend/scripts/gen-eva-subset.mjs new file mode 100644 index 00000000..21e30a5f --- /dev/null +++ b/frontend/scripts/gen-eva-subset.mjs @@ -0,0 +1,87 @@ +// Generates src/app/eva-icons.subset.ts: only the Eva icons the app uses. +// +// NbEvaIconsModule registers the whole pack (eva.min.js, 237 KB raw / 52 KB +// gzipped, ~490 icons) while the templates use a few dozen. This script +// scans the source for icon names, copies those SVGs from node_modules and +// writes them as a map the app registers instead of the module. +// +// node scripts/gen-eva-subset.mjs # (re)generate the subset +// node scripts/gen-eva-subset.mjs --check # fail when the subset is stale +// +// An icon name only exists in the subset when it appears as a quoted string +// somewhere under src/ (`icon="..."` in a template, `icon: '...'` in TS). +// A name built at runtime (`${kind}-outline`) is invisible to this scan, so +// the script prints every template literal that ends in -outline` as a +// warning: add such names to ALWAYS below. +import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const evaDir = join(root, 'node_modules', 'eva-icons'); +const outFile = join(root, 'src', 'app', 'eva-icons.subset.ts'); + +// Icons Nebular's own components render (select, accordion, datepicker, +// toastr, pagination, chat, search...), which never appear in our templates. +const ALWAYS = [ + 'chevron-down-outline', 'chevron-up-outline', 'chevron-left-outline', 'chevron-right-outline', + 'close-outline', 'checkmark-outline', 'alert-triangle-outline', + 'alert-circle-outline', 'question-mark-outline', 'search-outline', 'minus-outline', + 'expand-outline', 'collapse-outline', 'email-outline', + 'paper-plane-outline', 'flash-outline', 'file-text-outline', 'info-outline', +]; + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (/\.(ts|html)$/.test(name) && !name.endsWith('eva-icons.subset.ts')) out.push(p); + } + return out; +} + +const available = new Map(); +for (const [sub, suffix] of [['outline', ''], ['fill', '']]) { + for (const f of readdirSync(join(evaDir, sub, 'svg'))) { + if (f.endsWith('.svg')) available.set(f.slice(0, -4) + suffix, join(evaDir, sub, 'svg', f)); + } +} + +const source = walk(join(root, 'src')).map((p) => readFileSync(p, 'utf8')).join('\n'); +const used = new Set(ALWAYS); +for (const name of available.keys()) { + if (source.includes(`'${name}'`) || source.includes(`"${name}"`) || source.includes('`' + name + '`')) used.add(name); +} +const dynamic = source.match(/`[^`\n]*\$\{[^`\n]*\}[^`\n]*-outline`/g) || []; +for (const d of dynamic) console.warn(`warning: runtime icon name ${d} — add its possible values to ALWAYS in scripts/gen-eva-subset.mjs`); + +const missing = [...used].filter((n) => !available.has(n)); +if (missing.length) { + console.error('icons not in eva-icons: ' + missing.join(', ')); + process.exit(1); +} + +const entries = [...used].sort().map((name) => { + const svg = readFileSync(available.get(name), 'utf8'); + const inner = svg.replace(/^[\s\S]*?]*>/, '').replace(/<\/svg>\s*$/, '').replace(/\s+/g, ' ').trim(); + return ` '${name}': '${inner.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}',`; +}); + +const body = `// GENERATED by scripts/gen-eva-subset.mjs — do not edit; run the script +// after using a new icon name. ${entries.length} icons. +export const EVA_ICON_SUBSET: Record = { +${entries.join('\n')} +}; +`; + +if (process.argv.includes('--check')) { + const current = existsSync(outFile) ? readFileSync(outFile, 'utf8') : ''; + if (current !== body) { + console.error('src/app/eva-icons.subset.ts is stale: run `node scripts/gen-eva-subset.mjs`'); + process.exit(1); + } + console.log(`eva subset up to date (${entries.length} icons)`); +} else { + writeFileSync(outFile, body); + console.log(`wrote ${outFile} (${entries.length} icons)`); +} diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index b5ad037c..7cd488c9 100644 --- a/frontend/src/app/app.component.spec.ts +++ b/frontend/src/app/app.component.spec.ts @@ -13,17 +13,4 @@ describe('AppComponent', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it(`should have the 'channel' title`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual('channel'); - }); - - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, channel'); - }); }); diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index b28dddf6..569c2fdf 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,7 +1,9 @@ import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; +import { channelDisabledInterceptor } from './interceptors/channel-disabled.interceptor'; +import { sessionExpiredInterceptor } from './interceptors/session-expired.interceptor'; import { NbDatepickerModule, NbDialogModule, @@ -15,52 +17,40 @@ import { NbToastrModule } from "@nebular/theme"; import { provideAnimationsAsync } from "@angular/platform-browser/animations/async"; -import { NbEvaIconsModule } from "@nebular/eva-icons"; +import { provideEvaIconSubset } from './eva-icons.provider'; import { provideMarkdown } from "ngx-markdown"; import { MarkdownConfig } from "./markdown.config"; import { NgIconsModule, provideIcons } from "@ng-icons/core"; // Import NgIconsModule and provideIcons -import { - heroBold, - heroCheck, - heroCodeBracket, - heroItalic, - heroLockClosed, - heroLockOpen, - heroPaperAirplane, - heroPaperClip, - heroQuestionMarkCircle, - heroUnderline, - heroXMark -} from "@ng-icons/heroicons/outline"; +import { heroBold, heroItalic, heroUnderline } from "@ng-icons/heroicons/outline"; import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; export const appConfig: ApplicationConfig = { providers: [ + // Only the Eva icons in use (scripts/gen-eva-subset.mjs) instead of the + // whole pack. + provideEvaIconSubset(), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), // withHashLocation() - provideHttpClient(), + provideHttpClient(withInterceptors([channelDisabledInterceptor, sessionExpiredInterceptor])), provideAnimationsAsync(), provideMarkdown(MarkdownConfig), - provideIcons({ - heroBold, - heroItalic, - heroUnderline, - heroCodeBracket, - heroPaperClip, - heroQuestionMarkCircle, - heroPaperAirplane, - heroCheck, - heroXMark, - heroLockClosed, - heroLockOpen - }), + // The composer's three formatting buttons; everything else is Eva. + provideIcons({ heroBold, heroItalic, heroUnderline }), importProvidersFrom( NbThemeModule.forRoot({ name: 'custom' }, undefined, undefined, NbLayoutDirection.RTL), NbIconModule, - NbEvaIconsModule, NbMenuModule.forRoot(), NbDialogModule.forRoot(), - NbToastrModule.forRoot({ position: NbGlobalLogicalPosition.TOP_START }), + NbToastrModule.forRoot({ + position: NbGlobalLogicalPosition.TOP_START, + // Nebular's 3 s default vanished while a writer on a phone was still + // looking at the keyboard; errors are the only feedback channel here, + // so they stay until read and the same error is not stacked twice. + duration: 5000, + destroyByClick: true, + preventDuplicates: true, + duplicatesBehaviour: 'previous', + }), NgIconsModule, NbSidebarModule.forRoot(), NbDatepickerModule.forRoot(), diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 9bb31416..cc6cf5a1 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,17 +1,63 @@ import { Routes } from '@angular/router'; -import { LoginComponent } from './components/login/login.component'; import { ChannelComponent } from './components/channel/channel.component'; -import { AuthGuard } from "./services/chat-guard.guard"; +import { AuthGuard } from './services/chat-guard.guard'; +import { SuperAdminGuard } from './guards/super-admin.guard'; +import { ChannelManagerGuard } from './guards/channel-manager.guard'; + +// Only the channel page itself is in the initial bundle: it is what nearly +// every visitor lands on (a shared link), and its first paint is the one that +// matters. The landing page, login, the manage page and the super-admin panel +// are downloaded when — and only if — someone navigates to them. +const loadManagePage = () => + import('./components/manage/manage-channel.component').then(m => m.ManageChannelComponent); export const routes: Routes = [ - { path: 'login', component: LoginComponent }, { path: '', + loadComponent: () => import('./components/landing/landing-page.component').then(m => m.LandingPageComponent), + }, + { + path: 'login', + loadComponent: () => import('./components/login/login.component').then(m => m.LoginComponent), + }, + { + path: 'channel', + component: ChannelComponent, + canActivate: [AuthGuard], + }, + // The channel's management screen is a page of its own (deep-linkable, + // back-button friendly, full width on a phone) rather than a dialog over + // the feed. `tab` is the section id, e.g. /channel/news/manage/users. + { + path: 'channel/:slug/manage', + canActivate: [AuthGuard, ChannelManagerGuard], + loadComponent: loadManagePage, + }, + { + path: 'channel/:slug/manage/:tab', + canActivate: [AuthGuard, ChannelManagerGuard], + loadComponent: loadManagePage, + }, + { + path: 'channel/:slug', + component: ChannelComponent, + canActivate: [AuthGuard], + }, + // One post's own address: the channel, opened at that post. The server + // gives it the post's title and text, so a search result or a shared link + // can point at a single post (backend/seo_posts.go). + { + path: 'channel/:slug/post/:postId', component: ChannelComponent, canActivate: [AuthGuard], }, + { + path: 'super-admin', + canActivate: [AuthGuard, SuperAdminGuard], + loadComponent: () => import('./components/super-admin/super-admin-panel.component').then(m => m.SuperAdminPanelComponent), + }, { path: '**', - redirectTo: '' - } -]; \ No newline at end of file + redirectTo: '', + }, +]; diff --git a/frontend/src/app/components/admin/_admin-shared.scss b/frontend/src/app/components/admin/_admin-shared.scss new file mode 100644 index 00000000..00ccf31a --- /dev/null +++ b/frontend/src/app/components/admin/_admin-shared.scss @@ -0,0 +1,166 @@ +// Shared look of every manage-page tab: the page column, card headers, form +// fields, hints, empty states and the sticky save bar. Each tab @uses these +// mixins so the same thing looks the same everywhere — the tabs used to carry +// drifting copies of the same twenty lines. Only tokens, never raw colours. + +@mixin admin-page { + display: flex; + flex-direction: column; + gap: 1rem; + // Room for the sticky save bar so the last field is never hidden under it. + padding-bottom: 1rem; +} + +@mixin admin-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-weight: 700; + color: var(--text-basic-color); + + nb-icon { + font-size: 1.2rem; + color: var(--color-primary-600); + } + + h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + } +} + +@mixin admin-lead { + margin: 0 0 1rem; + // The tabs mark this text class="lead", which Bootstrap also styles + // (1.25rem, weight 300): reset both so it reads as a quiet intro line. + font-size: 0.9rem; + font-weight: 400; + color: var(--text-hint-color); + line-height: 1.6; +} + +@mixin admin-hint { + margin: 0.25rem 0 0; + font-size: 0.85rem; + line-height: 1.5; + color: var(--text-hint-color); +} + +@mixin admin-field { + padding: 0.85rem 0; + border-bottom: 1px solid var(--border-basic-color-3); + + &:first-child { + padding-top: 0; + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .field__label { + display: block; + font-weight: 600; + margin-bottom: 0.2rem; + color: var(--text-basic-color); + } + + .field__hint { + @include admin-hint; + margin: 0 0 0.5rem; + } + + .field__counter { + display: block; + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--text-hint-color); + text-align: end; + } +} + +@mixin admin-pill($bg, $fg) { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1rem 0.6rem; + border-radius: 1rem; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + background: $bg; + color: $fg; +} + +@mixin admin-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2rem 1rem; + text-align: center; + color: var(--text-hint-color); + line-height: 1.6; + + nb-icon { + font-size: 2.25rem; + color: var(--color-primary-400); + } + + strong { + color: var(--text-basic-color); + } +} + +@mixin admin-save-bar { + position: sticky; + bottom: 0; + z-index: 5; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin: 0 -0.25rem; + padding: 0.75rem 1rem; + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + border-radius: var(--card-border-radius); + box-shadow: var(--shadow); + + .save-bar__state { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--text-hint-color); + + &.is-dirty { + color: var(--color-warning-700); + } + + &.is-dirty::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-warning-500); + } + } + + .save-bar__spacer { + flex: 1; + } +} + +// A 40×40 minimum for every icon-only button, so it can be tapped. +@mixin admin-icon-button { + min-width: 2.5rem; + min-height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; +} diff --git a/frontend/src/app/components/admin/admin-panel.component.html b/frontend/src/app/components/admin/admin-panel.component.html deleted file mode 100644 index 240f7398..00000000 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - -
- -
- @switch (selectedMenuItem) { - @case (info) { - - } - @case (settings) { - - } - @case (users) { - - } - @case (emojis) { - - } - @case (openReports) { - - } - @case (closedReports) { - - } - @case (allReports) { - - } - @case (statistics) { - - } - } -
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/admin/admin-panel.component.scss b/frontend/src/app/components/admin/admin-panel.component.scss deleted file mode 100644 index 302a00bf..00000000 --- a/frontend/src/app/components/admin/admin-panel.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -.admin-panel-card { - width: 60vw; - - @media (max-width: 768px) { - width: 100vw; - } -} diff --git a/frontend/src/app/components/admin/admin-panel.component.ts b/frontend/src/app/components/admin/admin-panel.component.ts deleted file mode 100644 index 9db0b9d8..00000000 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Component, OnInit } from "@angular/core"; -import { NbCardModule, NbLayoutModule, NbMenuItem, NbMenuModule, NbMenuService, NbSidebarModule } from "@nebular/theme"; -import { EmojisComponent } from "./emojis/emojis.component"; -import { SettingsComponent } from "./settings/settings.component"; -import { PrivilegDashboardComponent } from "./privileg-dashboard/privileg-dashboard.component"; -import { ChannelInfoFormComponent } from "../channel/channel-info-form/channel-info-form.component"; -import { ReportsComponent } from "./reports/reports.component"; -import { StatisticsComponent } from "./statistics/statistics.component"; - -@Component({ - selector: 'admin-dashboard', - imports: [ - NbLayoutModule, - NbSidebarModule, - NbMenuModule, - NbCardModule, - EmojisComponent, - SettingsComponent, - PrivilegDashboardComponent, - ChannelInfoFormComponent, - ReportsComponent, - StatisticsComponent -], - templateUrl: './admin-panel.component.html', - styleUrls: ['./admin-panel.component.scss'] -}) -export class AdminPanelComponent implements OnInit { - - readonly info = "info"; - readonly settings = "settings"; - readonly users = "users"; - readonly emojis = "emojis"; - readonly openReports = "open-reports"; - readonly closedReports = "closed-reports"; - readonly allReports = "all-reports"; - readonly statistics = "statistics"; - - selectedMenuItem = this.info; - - navigationMenu: NbMenuItem[] = [ - { - title: 'פרטי ערוץ', - icon: 'info-outline', - selected: true - }, - { - title: 'הגדרות', - icon: 'settings-2-outline', - }, - { - title: 'הרשאות', - icon: 'shield-outline', - }, - { - title: 'סטטיסטיקות', - icon: 'bar-chart-outline', - }, - { - title: "אימוג'ים", - icon: 'smiling-face-outline', - }, - { - title: 'דיווחים', - icon: 'alert-triangle-outline', - children: [ - { - title: 'פתוחים', - icon: 'alert-triangle-outline', - }, - { - title: 'סגורים', - icon: 'checkmark-circle-outline', - }, - { - title: 'כל הדיווחים', - icon: 'list-outline', - } - ], - } - ]; - - constructor( - private menuService: NbMenuService - ) { } - - ngOnInit(): void { - this.handleMenuItemClick() - } - - handleMenuItemClick() { - this.menuService.onItemClick().subscribe((event) => { - this.navigationMenu.forEach((item) => item.selected = false); - event.item.selected = true; - - switch (event.item.icon) { - case 'info-outline': - this.selectedMenuItem = this.info; - break; - case 'settings-2-outline': - this.selectedMenuItem = this.settings; - break; - case 'shield-outline': - this.selectedMenuItem = this.users; - break; - case 'smiling-face-outline': - this.selectedMenuItem = this.emojis; - break; - case 'alert-triangle-outline': - this.selectedMenuItem = this.openReports; - break; - case 'checkmark-circle-outline': - this.selectedMenuItem = this.closedReports; - break; - case 'list-outline': - this.selectedMenuItem = this.allReports; - break; - case 'bar-chart-outline': - this.selectedMenuItem = this.statistics; - break; - } - }); - } -} \ No newline at end of file diff --git a/frontend/src/app/components/admin/emojis/emojis.component.html b/frontend/src/app/components/admin/emojis/emojis.component.html index 34949f42..514d2bdc 100644 --- a/frontend/src/app/components/admin/emojis/emojis.component.html +++ b/frontend/src/app/components/admin/emojis/emojis.component.html @@ -1,34 +1,74 @@ - - ניהול אימוג'ים מאופשרים +
+ + + +

האימוג'ים שהקוראים יכולים לבחור

+ @if (emojis) { + {{ emojis.length }} + } +
- - - אימוג'ים מאופשרים: - - +

+ לכל הודעה בערוץ הקוראים יכולים להגיב באימוג'י — ורק מהרשימה הזו. רשימה קצרה מרכזת את התגובות; + רשימה ריקה מבטלת את התגובות לגמרי. להסרה לוחצים על ה-× שעל האימוג'י, ובסיום על "שמירה". +

- @if (emojis?.length === 0) { -

אין אימוג'ים מאופשרים כרגע.

- } @else if (emojis) { - @for (item of emojis; track $index) { - - } - }@else { -

טוען אימוגים...

- } -
- - הוספת אימוג'ים: - - - - -
+ @if (loading) { +
+ } @else if (!emojis) { +
+ + לא הצלחנו לטעון את הרשימה + כדי לא למחוק בטעות את הרשימה הקיימת, אי אפשר לשמור עד שהיא נטענת. + +
+ } @else if (emojis.length === 0) { +
+ + אין אימוג'ים ברשימה + במצב הזה הקוראים לא יכולים להגיב. בחרו אימוג'ים מהבורר שלמטה. +
+ } @else { + + @for (item of emojis; track $index) { + + } + + }
- - - -
\ No newline at end of file + + + @if (!loading && emojis) { + + + +

הוספת אימוג'י

+
+ +

לחיצה על אימוג'י מוסיפה אותו לרשימה שלמעלה. אפשר לחפש לפי שם באנגלית.

+
+ +
+
+
+ +
+ + @if (dirty && !saving) { + + } + + + {{ dirty ? 'יש שינויים שלא נשמרו' : 'הכול שמור' }} + +
+ } +
diff --git a/frontend/src/app/components/admin/emojis/emojis.component.scss b/frontend/src/app/components/admin/emojis/emojis.component.scss index e69de29b..57997b7c 100644 --- a/frontend/src/app/components/admin/emojis/emojis.component.scss +++ b/frontend/src/app/components/admin/emojis/emojis.component.scss @@ -0,0 +1,75 @@ +// The emoji-mart picker's stylesheet. It used to be @use'd from the global +// styles.scss, so every visitor downloaded it for a picker only channel +// managers ever open; from here it ships inside the lazily loaded manage +// chunk instead. ViewEncapsulation.None on the component is what makes these +// rules reach the child's own elements. +@use '@ctrl/ngx-emoji-mart/picker'; +@use '../admin-shared' as *; + +// Every rule below is global for the same reason, so each one is scoped +// under .emojis-tab and nothing here may use a generic class name. +.emojis-tab { + @include admin-page; + + .emojis-tab__head { + @include admin-card-header; + } + + .emojis-tab__count { + @include admin-pill(var(--background-basic-color-3), var(--text-hint-color)); + } + + .emojis-tab__lead { + @include admin-lead; + } + + .emojis-tab__loading { + min-height: 5rem; + } + + .emojis-tab__empty { + @include admin-empty; + } + + .emojis-tab__chips { + nb-tag { + font-family: var(--font-emoji); + font-size: 1.35rem; + line-height: 1; + // Each chip is a tap target: the × must be reachable with a thumb. + min-height: 2.5rem; + } + } + + .emojis-tab__picker { + display: flex; + justify-content: center; + } + + .emoji-mart { + // The picker computes a pixel width from perLine; on a phone that can + // exceed the card. The rows wrap on their own once the box is narrower. + max-width: 100%; + border-color: var(--border-basic-color-3); + border-radius: var(--border-radius); + font-family: inherit; + } + + .emoji-mart-bar, + .emoji-mart-search input { + border-color: var(--border-basic-color-3); + } + + .emoji-mart-anchor-selected, + .emoji-mart-anchor:hover { + color: var(--color-primary-600); + } + + .emoji-mart-anchor-bar { + background-color: var(--color-primary-500); + } + + .emojis-tab__save { + @include admin-save-bar; + } +} diff --git a/frontend/src/app/components/admin/emojis/emojis.component.ts b/frontend/src/app/components/admin/emojis/emojis.component.ts index 480676db..f26d9764 100644 --- a/frontend/src/app/components/admin/emojis/emojis.component.ts +++ b/frontend/src/app/components/admin/emojis/emojis.component.ts @@ -1,6 +1,8 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, HostListener, OnInit, ViewEncapsulation } from '@angular/core'; import { ChatService } from '../../../services/chat.service'; -import { NbToastrService, NbCardModule, NbButtonModule, NbIconModule, NbListModule } from '@nebular/theme'; +import { + NbToastrService, NbCardModule, NbButtonModule, NbIconModule, NbTagComponent, NbTagModule, NbSpinnerModule, +} from '@nebular/theme'; import { AdminService } from '../../../services/admin.service'; import { PickerModule } from '@ctrl/ngx-emoji-mart'; @@ -11,13 +13,52 @@ import { PickerModule } from '@ctrl/ngx-emoji-mart'; NbButtonModule, PickerModule, NbIconModule, - NbListModule -], + NbTagModule, + NbSpinnerModule, + ], templateUrl: './emojis.component.html', - styleUrl: './emojis.component.scss' + styleUrl: './emojis.component.scss', + // Global on purpose: the stylesheet is the emoji-mart picker's, whose + // elements live in a child component that emulated encapsulation would miss. + encapsulation: ViewEncapsulation.None, }) export class EmojisComponent implements OnInit { emojis: string[] | undefined = []; + loading = true; + saving = false; + private snapshot = ''; + + /** Emojis per row in the picker — fewer on a narrow phone so it never overflows. */ + perLine = 9; + + /** The picker's own labels, in Hebrew. */ + readonly pickerI18n = { + search: 'חיפוש אימוג\'י', + emojilist: 'רשימת אימוג\'ים', + notfound: 'לא נמצא אימוג\'י', + clear: 'ניקוי', + categories: { + search: 'תוצאות חיפוש', + recent: 'בשימוש לאחרונה', + people: 'סמיילים ואנשים', + nature: 'חיות וטבע', + foods: 'אוכל ושתייה', + activity: 'פעילות', + places: 'נסיעות ומקומות', + objects: 'חפצים', + symbols: 'סמלים', + flags: 'דגלים', + custom: 'מותאם אישית', + }, + skintones: { + 1: 'גוון עור ברירת מחדל', + 2: 'גוון עור בהיר', + 3: 'גוון עור בהיר-בינוני', + 4: 'גוון עור בינוני', + 5: 'גוון עור בינוני-כהה', + 6: 'גוון עור כהה', + }, + }; constructor( private chatService: ChatService, @@ -25,28 +66,60 @@ export class EmojisComponent implements OnInit { private toastrService: NbToastrService ) { } + get dirty(): boolean { + return !!this.emojis && JSON.stringify(this.emojis) !== this.snapshot; + } + + @HostListener('window:resize') + onResize() { + this.updatePerLine(); + } + ngOnInit(): void { + this.updatePerLine(); + this.load(); + } + + load(): void { + this.loading = true; this.chatService.getEmojisList(true) - .then(emojis => this.emojis = emojis) + // Copied: the service resolves with its cached array, the same instance + // every message's reaction picker holds, so push/splice on it showed + // unsaved edits everywhere and kept them after closing without saving. + .then(emojis => { + this.emojis = [...emojis]; + this.snapshot = JSON.stringify(this.emojis); + }) .catch(() => { - this.toastrService.danger('', 'שגיאה בהגדרת אימוגים'); + this.toastrService.danger('', 'לא הצלחנו לטעון את רשימת האימוג\'ים'); this.emojis = undefined; - }); + }) + .finally(() => this.loading = false); } setEmojis() { - // if (!this.emojis?.length) { - // this.toastrService.warning('', 'אין אימוגים להגדיר'); - // return; - // } + // A failed load leaves emojis undefined; saving then would wipe the + // channel's stored list. An intentionally emptied list ([]) still saves. + if (!this.emojis) { + this.toastrService.warning('', 'רשימת האימוג\'ים לא נטענה — לא ניתן לשמור'); + return; + } + this.saving = true; this.adminService.setEmojis(this.emojis) .then(() => { - this.toastrService.success('', 'אימוגים הוגדרו בהצלחה'); + this.toastrService.success('', 'רשימת האימוג\'ים נשמרה'); + this.snapshot = JSON.stringify(this.emojis); + // Nothing else refreshes the cache after a save; without this the + // reaction pickers keep the pre-save list until the next channel load. + this.chatService.getEmojisList(true).catch(() => null); }) - .catch(() => { - this.toastrService.danger('', 'שגיאה בהגדרת אימוגים'); - }); + .catch((err) => { + this.toastrService.danger('', err?.status === 403 || err?.status === 401 + ? 'אין לכם הרשאה לשנות את האימוג\'ים של הערוץ' + : 'שמירת האימוג\'ים נכשלה — נסו שוב'); + }) + .finally(() => this.saving = false); } addEmoji(event: any) { @@ -57,4 +130,16 @@ export class EmojisComponent implements OnInit { removeEmoji(emoji: number) { this.emojis?.splice(emoji, 1); } + + /** nb-tag-list hands back the tag; the chip's text is the emoji itself. */ + onTagRemove(tag: NbTagComponent) { + const i = this.emojis?.indexOf(tag.text) ?? -1; + if (i >= 0) this.removeEmoji(i); + } + + private updatePerLine() { + const width = typeof window === 'undefined' ? 1024 : window.innerWidth; + // ~36px per cell plus the picker's own padding and the page gutters. + this.perLine = Math.max(6, Math.min(9, Math.floor((width - 96) / 36))); + } } diff --git a/frontend/src/app/components/admin/guide/guide.component.html b/frontend/src/app/components/admin/guide/guide.component.html new file mode 100644 index 00000000..ba28d7a4 --- /dev/null +++ b/frontend/src/app/components/admin/guide/guide.component.html @@ -0,0 +1,495 @@ + + +
+ +

מדריך למנהל הערוץ

+
+ @if (dialogMode) { + + } +
+ + +

+ כל מה שצריך כדי להפעיל את הערוץ לבד. כל סעיף מסביר מה זה, איפה זה נמצא ומה עושים. + סעיפים המסומנים בעלים פתוחים רק לבעלי הערוץ; + סעיפים המסומנים מנהל פתוחים גם למנהלים. +

+ + + + + + + + מתחילים כאן — חמשת הצעדים הראשונים + + +
    +
  1. + שיתוף הקישור לערוץ. + זו הדרך היחידה שקוראים מגיעים לערוץ. הכתובת בנויה כך: + {{ channelUrlExample }}. + כפתור השיתוף בכותרת הערוץ מעתיק את הקישור, שולח אותו בוואטסאפ או פותח את חלון השיתוף של הטלפון. +
  2. +
  3. + מילוי פרטי הערוץ. + במסך הניהול, במדור פרטי הערוץ: לוגו, שם, תיאור וקישור ליצירת קשר. +
  4. +
  5. + פרסום ההודעה הראשונה. + תיבת הכתיבה נמצאת בתחתית מסך הערוץ עצמו, לא במסך הניהול. +
  6. +
  7. + הוספת כותבים ומנהלים. + במדור צוות והרשאות — הוספה לפי כתובת המייל שאיתה האדם מתחבר לגוגל. +
  8. +
  9. + מעבר על ההגדרות. + במדור הגדרות: גודל קובץ מרבי, התראות, פרסומת במסגרת וחיבורים חיצוניים. +
  10. +
+

+ מסך הניהול נפתח מתוך הערוץ: לוחצים על תמונת הפרופיל בכותרת ובוחרים ניהול הערוץ. + במחשב המדורים מופיעים בתפריט בצד, בטלפון בשורת לשוניות שגוללים אותה הצידה. הכפתור + חזרה לערוץ בכותרת מחזיר לערוץ. +

+
+
+ + + + + + כמה ערוצים — "הערוצים שלי" ומעבר ביניהם + + +
    +
  • הערוצים שלי — מסך עם כל הערוצים שיש לכם בהם תפקיד (בעלים, מנהל או כותב). + מגיעים אליו מתפריט החשבון בכותרת הערוץ (לחיצה על תמונת הפרופיל, ואז כל הערוצים…), מכפתור + הערוצים שלי בכותרת מסך הניהול, או ישירות בכתובת /channel. + משם נכנסים לערוץ, משתפים אותו או עוברים לניהול שלו. באותו תפריט חשבון מופיעים גם הערוצים שלכם, + למעבר מהיר ביניהם בלי לעבור דרך הרשימה.
  • +
  • מעבר בין ערוצים בזמן ניהול — מי שמנהל יותר מערוץ אחד מקבל בכותרת מסך הניהול + רשימת ערוצים. בחירת ערוץ אחר פותחת את אותו מדור בערוץ ההוא, בלי לחזור אחורה.
  • +
  • פתיחת ערוץ חדש — מתפריט החשבון, פתיחת ערוץ חדש, או מהכפתור במסך "הערוצים שלי". מי שפותח ערוץ הוא הבעלים שלו.
  • +
+
+
+ + + + + + הכתובת של הערוץ ואיך מצטרפים אליו + + +
    +
  • + כתובת הערוץ היא <כתובת האתר>/channel/<כתובת-הערוץ>, + למשל {{ channelUrlExample }}. +
  • +
  • כתובת הערוץ נקבעת בעת פתיחתו ואינה משתנה ממסך הניהול. השם והתיאור — כן.
  • +
  • אין רשימת מנויים ואין אישור הצטרפות: מי שמקבל את הקישור נכנס לערוץ.
  • +
  • + כפתור השיתוף בכותרת הערוץ פותח שלוש אפשרויות: העתקת הקישור, + שיתוף בוואטסאפ, ובטלפון גם שיתוף… דרך חלון השיתוף של המכשיר. +
  • +
  • + בכותרת הערוץ מוצג מונה משתתפים, וליד כל הודעה מונה צפיות + (כאשר ספירת הצפיות מאופשרת לערוץ). +
  • +
  • + להעברת הודעה בודדת: העתקת קישור בכפתור הפעולות של ההודעה מייצר קישור ישיר + בצורה /channel/<כתובת-הערוץ>#<מספר>. +
  • +
+
+
+ + + + + + פרסום הודעה — תיבת הכתיבה + + +

+ תיבת הכתיבה נמצאת בתחתית מסך הערוץ ומוצגת למי שיש לו הרשאת כתיבה ומעלה. + הטקסט נכתב בתחביר עיצוב פשוט (Markdown) — למשל **מודגש** ו-*נטוי* — + ומוצג מעוצב אחרי הפרסום. +

+ +

מה יש בסרגל הכלים

+

מתחת לתיבת הכתיבה יש שורה אחת של סמלים קטנים. כפתור Aa פותח (וסוגר) את כלי עיצוב הטקסט + מעל התיבה, והדפדפן זוכר אם השארתם אותם פתוחים.

+
    +
  • הדגשה / נטוי / קו תחתון: עוטפים את הטקסט המסומן בסימוני Markdown + (**טקסט**, *טקסט*, <u>טקסט</u>).
  • +
  • קטע קוד: עוטף את הטקסט המסומן בשלושה גרשיים אחוריים, והוא מוצג בגופן קבוע-רוחב.
  • +
  • צירוף קובץ: הקובץ עולה מיד, וקישור אליו נוסף לטקסט ההודעה. תמונה, וידאו ואודיו + מוטמעים ומוצגים בתוך ההודעה; כל שאר הקבצים מופיעים כקישור להורדה. מוצג רק כאשר העלאת קבצים + מאופשרת לערוץ.
  • +
  • סימון כפרסומת: מסמן את ההודעה כתוכן פרסומי.
  • +
  • עזרה לעיצוב הטקסט: פותח הסבר קצר בעברית במקום, ומשם קישור למדריך המלא (באנגלית, בחלון חדש).
  • +
+ +

תצוגה מקדימה ו-Enter

+
    +
  • תצוגה מקדימה (סמל העין): מראה את ההודעה כפי שתיראה אחרי הפרסום. מכבים כדי להמשיך לכתוב.
  • +
  • Enter שולח: התווית הקטנה בקצה השורה (↵ שולח / ↵ שורה חדשה), ולחיצה עליה מחליפה. דלוק כברירת מחדל. כשהוא דלוק, Enter מפרסם ו-Ctrl+Enter (או Shift+Enter) + יורד שורה. זו העדפה אישית שנשמרת בדפדפן שלכם בלבד — לא הגדרת ערוץ.
  • +
+ +

עוד דברים שכדאי לדעת

+
    +
  • הדבקת תמונה: אפשר להדביק תמונה מהלוח ישירות לתיבה (Ctrl+V) והיא + תעלה ותוטמע כאילו צורפה.
  • +
  • תזמון הודעה: כפתור השעון פותח בחירת תאריך ושעה, וההודעה מתפרסמת + בזמן שנבחר. מוצג רק כאשר הודעות מתוזמנות מאופשרות לערוץ.
  • +
  • אורך הודעה מוגבל ל-2048 תווים.
  • +
  • אי אפשר לפרסם הודעה ריקה, ולא לשלוח בזמן שקובץ עדיין בהעלאה.
  • +
+
+
+ + + + + + ניהול הודעות שכבר פורסמו + + +

הפעולות נמצאות בכפתור הפעולות (שלוש הנקודות) שבראש כל הודעה בערוץ:

+
    +
  • עריכה: טוענת את ההודעה לתיבת הכתיבה. אחרי השמירה מסומן ליד + ההודעה שהיא נערכה ומתי.
  • +
  • מחיקה: ההודעה מוסתרת מהקוראים. למי שרשאי לערוך היא עדיין + מוצגת מעומעמת, עם התווית "מחוקה" וכפתור שחזור — לחיצה עליו טוענת אותה לעריכה, ואישור העריכה + מפרסם אותה מחדש. כך משחזרים הודעה שנמחקה בטעות. כותב יכול לשחזר כך + רק הודעה שהוא עצמו מחק; הודעה שמנהל מחק יכול לשחזר רק מנהל.
  • +
  • ציטוט: מוסיף את הטקסט לתיבת הכתיבה כבסיס להודעה חדשה.
  • +
  • דיווח: מוצג לכל משתמש מחובר; הדיווח מגיע למדור + דיווחים במסך הניהול.
  • +
  • העתקת קישור: מעתיק ללוח קישור ישיר להודעה. בטלפון יש גם שיתוף ההודעה… + דרך חלון השיתוף של המכשיר.
  • +
  • תגובות אימוג'י: כפתור הסמיילי (+) שמתחת לכל הודעה פותח את הבורר, ולחיצה על אימוג'י + קיים מוסיפה או מסירה את התגובה שלכם; הבורר מציע רק את האימוג'ים שהוגדרו במדור תגובות אימוג'י. + עריכה, מחיקה, ציטוט ודיווח נמצאים בתפריט ⋮ שבראש ההודעה.
  • +
+ + שם הכותב מוצג רק למנהלים ומעלה. לקוראים כל ההודעות מוצגות בשם הערוץ. + +
+
+ + + + + + צוות והרשאות — מי מורשה לעשות מה + בעלים + + +

+ מדור צוות והרשאות במסך הניהול. מזינים כתובת מייל, בוחרים תפקיד (מנהל או + כותב) ולוחצים הוספה לרשימה. בסיום לוחצים שמירה. שינוי תפקיד או הסרה + (כפתור הפח, עם שאלת אישור) נעשים באותה רשימה — וגם הם נשמרים רק ב"שמירה". +

+ +

סולם התפקידים

+
    +
  • מנהל-על — תפקיד מערכתי כלל-אתרי, לא משויך לערוץ ולא ניתן להענקה מכאן.
  • +
  • בעלים — נקבע בפתיחת הערוץ. גישה לכל המדורים. לא ניתן להסירו מהרשימה ולא לשנות את תפקידו כאן.
  • +
  • מנהל — פרטי הערוץ, סטטיסטיקות, אימוג'ים ודיווחים; וכן עריכה ומחיקה + של כל ההודעות בערוץ.
  • +
  • כותב — יכול לפרסם, אבל לא רואה את מסך הניהול כלל.
  • +
+ + + שימו לב: ההרשאה נכנסת לתוקף רק אחרי שהאדם מתחבר לאתר עם חשבון גוגל של אותה כתובת. + כותב רשאי לערוך ולמחוק רק את ההודעות שהוא עצמו כתב; מנהל ומעלה — כל הודעה. + הודעות מערכת (שפורסמו דרך תזמון או דרך פרסום מבחוץ, ואין להן כותב אישי) ניתנות לעריכה גם על ידי כותב. + + +

+ כל בדיקות ההרשאה נאכפות בשרת, ולכן מדור שאינו מוצג לכם אינו נגיש גם בעקיפין. +

+
+
+ + + + + + פרטי הערוץ — לוגו, שם, תיאור וקישור ליצירת קשר + מנהל + + +
    +
  • מדור פרטי הערוץ — המדור הראשון, זה שנפתח כברירת מחדל.
  • +
  • לוגו: לחיצה על העיגול (או על בחירת תמונה) פותחת בחירת קובץ. + הקובץ עולה מיד ומוצג בתצוגה מקדימה, אבל הלוגו מתחלף לקוראים רק בלחיצה על שמירה.
  • +
  • שם: שם הערוץ כפי שמוצג בראש המסך ובכותרת הלשונית בדפדפן (עד 80 תווים).
  • +
  • תיאור: שורת המשנה שמתחת לשם.
  • +
  • קישור ליצירת קשר: כתובת אתר, קישור וואטסאפ או mailto:. + כשיש קישור, בכותרת הערוץ מופיע כפתור צור קשר. ריק — אין כפתור.
  • +
  • בסיום לוחצים שמירה. ביטול שינויים מחזיר למה ששמור.
  • +
+
+
+ + + + + + הגדרות — כל השדות, אחד אחד + בעלים + + +

+ מדור הגדרות. כל השינויים במסך נשמרים יחד בלחיצה על שמירת שינויים + שבתחתית; השורה ליד הכפתור אומרת אם יש משהו שטרם נשמר. ליד כל שדה כתוב מה הוא עושה. +

+ +

קבצים ותמונות

+
    +
  • גודל מרבי לקובץ (MB) — קובץ גדול מזה נדחה בהעלאה. ברירת מחדל 100, מרבי 512 — + ערך גדול יותר (או 0) לא נקלט והמגבלה נשארת 100.
  • +
  • מפתח TinyPNG — כשמוגדר, תמונות נדחסות לפני השמירה ותופסות פחות מקום במכסה. + מפתח חינמי באתר tinypng.com.
  • +
+ +

התראות לנייד ולדפדפן

+
    +
  • לשלוח התראה על כל הודעה חדשה — מתג. פרטים בסעיף ההתראות בהמשך.
  • +
+ +

פרסומת במסגרת

+
    +
  • כתובת עמוד הפרסומת — עמוד שיוצג במסגרת לצד הערוץ. הזנת כתובת מדליקה את המסגרת; + ריקון מכבה.
  • +
  • רוחב המסגרת — מומלץ 300 פיקסלים.
  • +
+ +

חיבור למערכות חיצוניות

+
    +
  • מפתח לפרסום הודעות מבחוץ — מאפשר לתוכנה חיצונית לפרסם בערוץ. ריק = סגור. + ראו "חיבור מערכות חיצוניות" בהמשך.
  • +
  • כתובת לעדכון על שינויים (Webhook) ומחרוזת אימות סודית — עדכון שרת חיצוני + על כל פרסום, עריכה או מחיקה.
  • +
+ +

החלפות אוטומטיות בטקסט

+
    +
  • כללים שרצים על כל הודעה חדשה: מה לחפש (ביטוי רגולרי) ו-במה להחליף.
  • +
  • לדוגמה, חיפוש (.*?\!)(.*) והחלפה ב-**$1**$2 + מדגישים כותרת הודעה שמסתיימת בסימן קריאה.
  • +
  • ביטוי שגוי נדחה בשמירה עם הודעה; כדאי לבדוק על הודעת ניסיון.
  • +
+ +

הגדרות מתקדמות

+
    +
  • מגירה בתחתית המסך, להגדרות שאין להן עדיין שדה בטופס. רק כשנאמר לכם במפורש מה להזין.
  • +
+
+
+ + + + + + תגובות אימוג'י — מה הקוראים יכולים לבחור + מנהל + + +
    +
  • מדור תגובות אימוג'י קובע אילו אימוג'ים הקוראים יכולים לבחור כתגובה להודעה.
  • +
  • הוספה: לחיצה על אימוג'י בבורר שבתחתית המסך. הסרה: ה-× שעל האימוג'י ברשימה.
  • +
  • בסיום לוחצים שמירה. רשימה ריקה משמעה שהקוראים לא יכולים להגיב בכלל.
  • +
+
+
+ + + + + + דיווחים — טיפול בפניות מהקוראים + מנהל + + +
    +
  • מדור דיווחים מסונן לשלוש רשימות: פתוחים, טופלו ו-כל הדיווחים.
  • +
  • בכל דיווח מוצגים מועד הקבלה, מי דיווח, הסיבה ומספר ההודעה.
  • +
  • צפייה בהודעה — פותח את הערוץ על ההודעה, בלשונית חדשה.
  • +
  • סימון כטופל — מעביר לרשימת "טופלו"; אפשר תמיד לפתוח מחדש.
  • +
  • מחיקת ההודעה — מסתיר את ההודעה מהקוראים, אחרי שאלת אישור. הדיווח עצמו + נשאר פתוח עד שמסמנים אותו כטופל.
  • +
+
+
+ + + + + + סטטיסטיקות + מנהל + + +
    +
  • שלושה מספרים: קוראים שנכנסו לערוץ כשהם מחוברים (אי פעם), כמה חלונות מחוברים עכשיו, + ושיא המחוברים בו-זמנית עם מועד השיא.
  • +
  • מתחתם גרף החיבורים לאורך החודש האחרון. מתקרבים בגלגלת או בצביטה, ותצוגה מלאה מחזירה לגרף כולו.
  • +
+
+
+ + + + + + אחסון — מכסת הקבצים של הערוץ + בעלים + + +
    +
  • מדור אחסון מציג כמה מקום תופסים הקבצים שהועלו לערוץ מתוך המכסה שהוקצתה לו.
  • +
  • כשהשימוש מתקרב למכסה מוצגת התראה, וכשהוא כמעט מלא ההתראה הופכת אדומה.
  • +
  • ניקוי אוטומטי — מתג שנשמר מיד. כשהוא דלוק והעלאה לא נכנסת, המערכת מוחקת את + הקבצים הישנים ביותר (לא את הלוגו) עד שמתפנה מקום. הדלקה מבקשת אישור, כי קובץ שנמחק אינו חוזר.
  • +
  • המכסה עצמה נקבעת על ידי הנהלת המערכת. מפתח TinyPNG (במדור ההגדרות) מאט את התמלאותה.
  • +
+
+
+ + + + + + פרסומות — מסגרת פרסומת ומגנט ADS + בעלים + + +

בערוץ קיימות שתי דרכים נפרדות להצגת פרסומות:

+ +

1. פרסומת במסגרת (במדור הגדרות)

+
    +
  • ממלאים כתובת עמוד הפרסומת ו-רוחב המסגרת. המסגרת מוצגת לצד הערוץ; ריקון הכתובת מבטל אותה.
  • +
+ +

2. פרסומות ממגנט (מדור נפרד)

+
    +
  • מגנט ADS היא רשת פרסום חיצונית. צריך חשבון שם וקוד הטמעה — בלי קוד לא יוצג כלום.
  • +
  • הצגת פרסומות ממגנט בערוץ — המתג הראשי; כיבוי מסיר את הפרסומות לגמרי.
  • +
  • קוד ההטמעה — מדביקים כפי שהוא. נטען אצל הקורא בלבד, רק כשהוא מגיע אליו.
  • +
  • תדירות — לפי מספר הודעות (פרסומת כל X הודעות, עם מינימום זמן אופציונלי) או + לפי זמן (פרסומת כל X שניות, עם מינימום הודעות חדשות אופציונלי).
  • +
  • בסיום לוחצים שמירת שינויים. נתוני ההקלקות והתגמולים מוצגים למנהל-על בלבד.
  • +
+ +

+ אפשר גם לסמן הודעה בודדת שכתבתם כתוכן פרסומי, בכפתור הסימון בתיבת הכתיבה. +

+
+
+ + + + + + התראות לנייד ולדפדפן + בעלים + + +
    +
  • במדור הגדרות, תחת "התראות לנייד ולדפדפן", מדליקים את המתג ולוחצים שמירת שינויים.
  • +
  • הקורא עצמו צריך לאשר: בכותרת הערוץ מופיע פעמון, ולחיצה עליו מבקשת מהדפדפן הרשאה להתראות.
  • +
+ + תשתית ההתראות מוגדרת על ידי הנהלת המערכת. אם היא לא הוגדרה, הפעמון לא יופיע והתראות לא יישלחו — + גם אם המתג דלוק. במקרה כזה פונים להנהלת המערכת במדור פנייה לתמיכה. + +
+
+ + + + + + חיבור מערכות חיצוניות — פרסום מבחוץ ועדכונים החוצה + בעלים + + +

פרסום הודעות מבחוץ

+
    +
  • ממלאים מפתח לפרסום הודעות מבחוץ במדור ההגדרות ושומרים.
  • +
  • המערכת החיצונית שולחת בקשת POST לכתובת + /api/channel/<כתובת-הערוץ>/import/post + עם הכותרת X-API-Key ובה המפתח.
  • +
  • גוף הבקשה הוא JSON עם שדה טקסט (Markdown), ואפשר גם חותמת זמן. חותמת ריקה = עכשיו; + חותמת של יותר משעה קדימה נדחית.
  • +
  • הודעות כאלה הן הודעות מערכת בלי כותב אישי, ולכן כל כותב בערוץ רשאי לערוך ולמחוק אותן.
  • +
  • המפתח מקנה זכות פרסום — שומרים עליו כמו על סיסמה ומחליפים אם דלף.
  • +
+ +

עדכון שרת חיצוני (Webhook)

+
    +
  • ממלאים כתובת לעדכון על שינויים ו-מחרוזת אימות סודית במדור ההגדרות; את אותה מחרוזת + מגדירים גם בשרת שמקבל את העדכונים, כדי שיוכל לוודא שהבקשה הגיעה מכאן.
  • +
  • בכל פרסום, עריכה או מחיקה של הודעה נשלחת בקשת POST לכתובת. ריק — לא נשלח דבר.
  • +
+
+
+ + + + + + פנייה לתמיכה + מנהל + + +
    +
  • מדור פנייה לתמיכה (וגם פנייה לתמיכה בתפריט החשבון בכותרת הערוץ) שולח הודעה להנהלת המערכת.
  • +
  • הפניות שלכם מופיעות מתחת לטופס, והתשובה — מסומנת "ניהול" — מופיעה באותו מקום. אפשר להמשיך את השיחה בפנייה עצמה.
  • +
+
+
+ + + + + + משהו שכתוב כאן לא מופיע אצלי? + + +

שלוש סיבות אפשריות, לפי הסדר:

+
    +
  1. + הרשאה. מדור שדורש הרשאת בעלים לא מוצג למנהל כלל. בדקו במדור + צוות והרשאות איזה תפקיד יש לכם, או שאלו את בעלי הערוץ. +
  2. +
  3. + היכולת כובתה לערוץ. הנהלת המערכת יכולה לכבות יכולות לערוץ מסוים: + תגובות אימוג'י, העלאת קבצים, דיווחים, הודעות מתוזמנות, פרסומות, התראות ו-Webhook. + במקרה כזה הכפתור פשוט לא מוצג, גם לבעלים. פונים במדור פנייה לתמיכה. +
  4. +
  5. + הערוץ הושבת. להנהלת המערכת יש מתג שמשבית ערוץ שלם. במצב זה הערוץ + אינו נגיש לאיש, כולל הבעלים. +
  6. +
+

+ אם הכול נראה תקין ובכל זאת משהו לא עובד — רעננו את העמוד; חלק מההגדרות + נטענות פעם אחת בכניסה לערוץ. +

+
+
+ +
+
+
diff --git a/frontend/src/app/components/admin/guide/guide.component.scss b/frontend/src/app/components/admin/guide/guide.component.scss new file mode 100644 index 00000000..540de0c0 --- /dev/null +++ b/frontend/src/app/components/admin/guide/guide.component.scss @@ -0,0 +1,160 @@ +:host { + display: block; +} + +.guide-card { + margin-bottom: 0; +} + +.guide-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.guide-header__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + + nb-icon { + font-size: 1.25rem; + color: var(--color-primary-600); + } + + h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + } +} + +// Opened as a dialog the body scrolls inside the card; as a manage section +// the page scrolls and the cap is lifted (see the phone rule below as well). +.guide-body { + max-height: 75vh; + overflow-y: auto; +} + +:host-context(app-manage-channel) .guide-body { + max-height: none; + overflow: visible; +} + +.guide-lead { + margin: 0 0 1rem; + line-height: 1.6; + color: var(--text-hint-color); +} + +.guide-accordion { + display: block; + + ::ng-deep nb-accordion-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } +} + +.guide-section-title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-basic-color); +} + +.role-badge { + font-size: 0.7rem; + font-weight: 600; + border-radius: 0.75rem; + padding: 0.1rem 0.5rem; + white-space: nowrap; + border: 1px solid transparent; + + &--owner { + color: var(--color-primary-700); + border-color: var(--color-primary-400); + background: var(--color-primary-100); + } + + &--moderator { + color: var(--color-info-700); + border-color: var(--color-info-400); + background: var(--color-info-100); + } +} + +.guide-text { + margin: 0 0 0.75rem; + line-height: 1.6; + color: var(--text-basic-color); +} + +.guide-subtitle { + margin: 1rem 0 0.4rem; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.guide-list { + margin: 0 0 0.5rem; + padding-inline-start: 1.25rem; + line-height: 1.7; + color: var(--text-basic-color); + + li { + margin-bottom: 0.3rem; + } + + .guide-list { + margin-top: 0.3rem; + } + + &--steps li { + margin-bottom: 0.5rem; + } +} + +.guide-note { + margin: 0.75rem 0 0; + font-size: 0.85rem; + line-height: 1.6; + color: var(--text-hint-color); +} + +.guide-alert { + margin: 0.75rem 0 0; + font-size: 0.9rem; + line-height: 1.6; +} + +code { + background: var(--background-basic-color-2); + color: var(--text-basic-color); + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.85em; + direction: ltr; + display: inline-block; + unicode-bidi: embed; + max-width: 100%; + overflow-wrap: anywhere; +} + +@media (max-width: 576px) { + .guide-body { + max-height: none; + } + + .guide-section-title { + font-size: 0.9rem; + } + + .guide-list { + padding-inline-start: 1rem; + } +} diff --git a/frontend/src/app/components/admin/guide/guide.component.ts b/frontend/src/app/components/admin/guide/guide.component.ts new file mode 100644 index 00000000..292e9b74 --- /dev/null +++ b/frontend/src/app/components/admin/guide/guide.component.ts @@ -0,0 +1,51 @@ +import { Component, Input, Optional } from '@angular/core'; +import { + NbAccordionModule, + NbAlertModule, + NbButtonModule, + NbCardModule, + NbDialogRef, + NbIconModule, + NbTooltipModule, +} from '@nebular/theme'; + +/** + * The owner's manual. Pure content — it holds no state and calls no endpoint, + * so it can be dropped into the manage page as a section and opened as a + * dialog from the post-creation screen without any wiring. + * + * Everything documented here was read off the real components; when a screen + * changes, this file changes with it. + */ +@Component({ + selector: 'app-guide', + standalone: true, + imports: [ + NbAccordionModule, + NbCardModule, + NbIconModule, + NbButtonModule, + NbAlertModule, + NbTooltipModule, + ], + templateUrl: './guide.component.html', + styleUrl: './guide.component.scss', +}) +export class GuideComponent { + /** + * Set by the caller that opens the guide as its own dialog (the create + * form). Not derived from the injected NbDialogRef: when the guide is a + * section of the manage page there is no dialog to close, and a stray ref + * from an enclosing dialog must not be closed by the guide's own button. + */ + @Input() dialogMode = false; + + constructor(@Optional() private dialogRef: NbDialogRef | null) { } + + /** Example URL built from the real origin — the domain is never hardcoded. */ + readonly channelUrlExample = `${window.location.origin}/channel/my-channel`; + + close(): void { + if (this.dialogMode) this.dialogRef?.close(); + } +} diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html new file mode 100644 index 00000000..30d460fe --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -0,0 +1,272 @@ +
+ + @if (loading) { + + + + } @else if (loadFailed) { + + + + לא הצלחנו לטעון את ההגדרות + בלי העותק השמור אי אפשר לשמור — אחרת הגדרות אחרות של הערוץ היו נמחקות. נסו שוב. + + + + } @else { + + @if (locked) { + + הנהלת המערכת קבעה הגדרות פרסומת אחידות לכל הערוצים, ולכן ההגדרות כאן נעולות ואי אפשר לשמור אותן כל עוד הנעילה פעילה. + + } + + + + +

מה זה מגנט ADS ואיך מחברים

+ + + {{ enabled ? (enabledWithoutSnippet ? 'דלוק, חסר קוד' : 'פעיל') : 'כבוי' }} + +
+ + +

+ מגנט ADS היא רשת פרסום חיצונית שמשלמת לבעלי אתרים על הקלקות. כדי להשתמש בה צריך חשבון במערכת מגנט + וקוד הטמעה שמקבלים שם — מדביקים אותו כאן, והפרסומת מופיעה בין ההודעות בערוץ, נטענת אצל הקורא רק + כשהוא מגיע אליה. אם למגנט אין פרסומת להציג, פשוט לא מופיע כלום. +

+ +
+
+ + הצגת פרסומות ממגנט בערוץ + +
+

+ כבוי — אין פרסומות בכלל, גם אם יש קוד. דלוק — הפרסומות מופיעות לפי התדירות שבוחרים למטה. +

+
+ +
+ +

+ מעתיקים מהמערכת של מגנט ומדביקים כאן כמו שהוא (HTML/JavaScript). הקוד מוטמע בכל מקום שבו מופיעה + פרסומת. בלי קוד — גם כשהמתג דלוק לא יוצג כלום. +

+ + @if (enabledWithoutSnippet) { +

+ + המתג דלוק אבל אין קוד הטמעה — הקוראים לא יראו פרסומות. +

+ } +
+
+
+ + + + +

כל כמה זמן מופיעה פרסומת

+
+ + +

בוחרים לפי מה לספור: לפי מספר ההודעות שהקורא גלל, או לפי הזמן שעבר.

+ +
+ + לפי מספר הודעות + + + לפי זמן + +
+ + @if (mode === 'by_messages') { +
+ +

למשל 5 — פרסומת אחרי כל חמש הודעות.

+ +
+ +
+ +

+ גם אם עברו מספיק הודעות, פרסומת לא תופיע אחרי הודעה שפורסמה פחות מזה אחרי ההודעה של הפרסומת הקודמת. 0 או ריק — בלי הגבלה. +

+ +
+ } + + @if (mode === 'by_time') { +
+ +

הזמן נמדד בין שעות הפרסום של ההודעות, לא לפי זמן הקריאה. למשל 60 — פרסומת אחרי ההודעה הראשונה שפורסמה דקה או יותר אחרי ההודעה של הפרסומת הקודמת.

+ +
+ +
+ +

+ גם אם עבר הזמן, פרסומת לא תופיע אם לא נוספו מאז הקודמת לפחות כך וכך הודעות. 0 או ריק — בלי הגבלה. +

+ +
+ } +
+
+ + + @if (ownStats === 'locked') { + @if (isSuperAdmin) { + + + +

נתוני מגנט

+ מנהל-על +
+ +

+ הערוץ נעול להגדרות המגנט של המערכת, ולכן הפרסומות בו נספרות בנתוני האתר הראשי. מנהלי הערוץ לא + רואים כאן שום נתון מגנט. +

+ + + לסטטיסטיקות של המערכת + +
+
+ } + } @else if (ownStats === 'ready') { + + + +

הנתונים שלכם ממגנט

+ + + {{ statsKeySet ? 'מחובר' : 'לא מחובר' }} + +
+ + + @if (!statsKeySet || editingKey) { +

+ הפרסומות בערוץ רצות מחשבון המגנט שלכם, ולכן גם הרווח שלכם. כדי לראות כאן קליקים ורווח, צרו מפתח + סטטיסטיקה בחשבון המגנט שלכם: קוד הטמעה ← מפתח סטטיסטיקה, והדביקו אותו כאן. +

+
+ +

+ מתחיל ב-mgs_. נשמר בשרת בלבד ולא מוצג שוב, גם לא לכם — כדי להחליף מדביקים מפתח חדש. +

+
+ + + @if (editingKey) { + + } +
+ @if (keyError) { + + } +
+ } @else { +
+ + + מפתח הסטטיסטיקה של הערוץ שמור. הנתונים למטה מגיעים מחשבון המגנט שלכם. + + + + + +
+ } +
+
+ + @if (statsKeySet) { + + } + } @else if (ownStats === 'error') { + + + + נתוני מגנט לא נטענו + + + + } + +
+ + @if (dirty && !inProgress) { + + } + + + {{ locked ? 'נעול על ידי הנהלת המערכת' : dirty ? 'יש שינויים שלא נשמרו' : 'הכול שמור' }} + +
+ } + +
diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss new file mode 100644 index 00000000..8793a604 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss @@ -0,0 +1,141 @@ +@use '../admin-shared' as *; + +:host { + display: block; +} + +.magnet-page { + @include admin-page; +} + +.magnet-loading { + min-height: 8rem; +} + +.magnet-empty { + @include admin-empty; +} + +.magnet-alert { + margin: 0; +} + +.card-head { + @include admin-card-header; +} + +.lead { + @include admin-lead; +} + +.pill { + @include admin-pill(var(--background-basic-color-3), var(--text-hint-color)); + margin-inline-start: auto; + + &--on { + background: var(--color-success-100); + color: var(--color-success-700); + } + + &--warn { + background: var(--color-warning-100); + color: var(--color-warning-700); + } +} + +.field { + @include admin-field; + + &.is-toggle { + padding-block: 0.65rem; + } + + .field__label--inline { + margin: 0; + font-size: 0.95rem; + } + + .field__hint--toggle { + margin: 0.35rem 0 0; + } + + textarea { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + } +} + +.field__warn { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0.5rem 0 0; + font-size: 0.85rem; + color: var(--color-warning-700); +} + +.toggle-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.input--short { + max-width: 12rem; +} + +.mode-options { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-basic-color-3); +} + +.field__warn--danger { + color: var(--color-danger-700); +} + +// The key field and its buttons on one line; the buttons drop below on a phone. +.key-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + + input { + flex: 1 1 18rem; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + } +} + +.key-set { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + flex-wrap: wrap; + + .key-set__text { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--text-basic-color); + + nb-icon { + color: var(--color-success-600); + } + } + + .key-set__actions { + display: inline-flex; + gap: 0.25rem; + } +} + +.save-bar { + @include admin-save-bar; +} diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts new file mode 100644 index 00000000..65c2cad8 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -0,0 +1,336 @@ +import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { + NbAlertModule, + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbRadioModule, + NbSpinnerModule, + NbToastrService, + NbToggleModule, + NbTooltipModule, +} from '@nebular/theme'; +import { AdminService } from '../../../services/admin.service'; +import { AuthService } from '../../../services/auth.service'; +import { ConfirmService } from '../../../services/confirm.service'; +import { MagnetAdsService } from '../../../services/magnet-ads.service'; +import { Setting } from '../../../models/setting.model'; +import { toBool } from '../settings/settings.schema'; +import { MagnetStatsPanelComponent } from '../../shared/magnet-stats-panel.component'; + +type MagnetMode = 'by_messages' | 'by_time'; + +/** + * The channel's own Magnet numbers, as the server decides them: + * - locked: the channel serves the platform's embed and the earnings are the + * platform's, so its managers get nothing (the super admin gets a pointer + * to the platform's statistics screen); + * - ready: the channel runs its own embed, so it may read its own numbers + * with a stats key from its own Magnet account. + */ +type OwnStatsState = 'loading' | 'locked' | 'ready' | 'error'; + +/** The shape rotate_site_stats_key mints; backend/ads.go checks the same. */ +const STATS_KEY_RE = /^mgs_[0-9a-f]{48}$/; + +const MAGNET_KEYS = [ + 'magnet_enabled', + 'magnet_snippet', + 'magnet_mode', + 'magnet_per_messages', + 'magnet_min_time_seconds', + 'magnet_per_seconds', + 'magnet_min_messages_since', + // Saved by an old UI but consumed by nothing — recognised so it is pruned + // from the payload on the next save instead of surviving as an "other" setting. + 'magnet_api_key', +] as const; + +@Component({ + selector: 'app-magnet-ads', + imports: [ + FormsModule, + RouterLink, + NbAlertModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbInputModule, + NbToggleModule, + NbRadioModule, + NbSpinnerModule, + NbTooltipModule, + MagnetStatsPanelComponent, + ], + templateUrl: './magnet-ads.component.html', + styleUrl: './magnet-ads.component.scss', +}) +export class MagnetAdsComponent implements OnInit { + /** The settings endpoint answered 403: the role changed since sign-in. */ + @Output() accessDenied = new EventEmitter(); + + enabled = false; + snippet = ''; + mode: MagnetMode = 'by_messages'; + perMessages = 5; + minTimeSeconds = 0; + perSeconds = 60; + minMessagesSince = 0; + + otherSettings: Setting[] = []; + inProgress = false; + // Save stays disabled until the server copy arrived: settings/set replaces + // the whole blob (no merge), so a save after a failed load dropped every + // non-magnet key (api_secret_key, webhook_*, regex rules) along with it. + loaded = false; + loading = true; + loadFailed = false; + // Super-admin lock on this area: the public endpoint serves the global + // config, so Save is disabled rather than accepting values nobody sees. + locked = false; + private lastLoaded: Setting[] = []; + private snapshot = ''; + + ownStats: OwnStatsState = 'loading'; + statsKeySet = false; + /** The key field is open over a stored key (replace). */ + editingKey = false; + keyInput = ''; + keyError = ''; + keySaving = false; + /** Replaced (not mutated) after a key change, so the panel reloads. */ + statsSource = () => this.adminService.getMagnetStats(); + + @ViewChild('keyField') private keyField?: ElementRef; + + constructor( + private adminService: AdminService, + private authService: AuthService, + private magnetAdsService: MagnetAdsService, + private confirm: ConfirmService, + private toast: NbToastrService, + ) {} + + get isSuperAdmin(): boolean { + return this.authService.userInfo?.globalRole === 'super_admin'; + } + + get dirty(): boolean { + return this.loaded && JSON.stringify(this.buildPayload()) !== this.snapshot; + } + + /** On, but nothing to show: the toggle alone does not make an ad appear. */ + get enabledWithoutSnippet(): boolean { + return this.enabled && !this.snippet?.trim(); + } + + ngOnInit(): void { + this.loadSettings(); + // The public magnet endpoint is the only place the lock is visible to an + // owner; forced so a stale cached answer from before the lock is not used. + this.magnetAdsService.loadSettings(true) + .then(s => this.locked = this.locked || !!s?.locked); + this.loadOwnStats(); + } + + loadSettings() { + this.loadFailed = false; + this.loading = true; + this.adminService.getSettings() + .then(settings => { + this.lastLoaded = settings || []; + this.load(this.lastLoaded); + this.loaded = true; + this.snapshot = JSON.stringify(this.buildPayload()); + }) + .catch((err) => { + this.loadFailed = true; + if (err?.status === 403 || err?.status === 401) { + this.accessDenied.emit(); + return; + } + this.toast.danger('', 'לא הצלחנו לטעון את ההגדרות — נסו שוב'); + }) + .finally(() => this.loading = false); + } + + resetChanges() { + this.load(this.lastLoaded); + } + + private load(settings: Setting[]) { + this.otherSettings = []; + const known = new Set(MAGNET_KEYS); + this.enabled = false; + this.snippet = ''; + this.mode = 'by_messages'; + this.perMessages = 5; + this.minTimeSeconds = 0; + this.perSeconds = 60; + this.minMessagesSince = 0; + + for (const s of settings) { + if (!known.has(s.key)) { + this.otherSettings.push(s); + continue; + } + const v = s.value; + switch (s.key) { + case 'magnet_enabled': + this.enabled = toBool(v); + break; + case 'magnet_snippet': + this.snippet = v == null ? '' : String(v); + break; + case 'magnet_mode': + this.mode = (String(v) === 'by_time' ? 'by_time' : 'by_messages'); + break; + case 'magnet_per_messages': + this.perMessages = this.toInt(v, 5); + break; + case 'magnet_min_time_seconds': + this.minTimeSeconds = this.toInt(v, 0); + break; + case 'magnet_per_seconds': + this.perSeconds = this.toInt(v, 60); + break; + case 'magnet_min_messages_since': + this.minMessagesSince = this.toInt(v, 0); + break; + } + } + } + + private toInt(v: any, fallback: number): number { + if (v === null || v === undefined || v === '') return fallback; + const n = parseInt(String(v), 10); + return isNaN(n) ? fallback : n; + } + + private buildPayload(): Setting[] { + const out: Setting[] = [...this.otherSettings]; + + if (this.enabled) out.push({ key: 'magnet_enabled', value: '1' as any }); + if (this.snippet?.trim()) out.push({ key: 'magnet_snippet', value: this.snippet as any }); + out.push({ key: 'magnet_mode', value: this.mode as any }); + + if (this.mode === 'by_messages') { + if (this.perMessages > 0) out.push({ key: 'magnet_per_messages', value: String(this.perMessages) as any }); + if (this.minTimeSeconds > 0) out.push({ key: 'magnet_min_time_seconds', value: String(this.minTimeSeconds) as any }); + } else { + if (this.perSeconds > 0) out.push({ key: 'magnet_per_seconds', value: String(this.perSeconds) as any }); + if (this.minMessagesSince > 0) out.push({ key: 'magnet_min_messages_since', value: String(this.minMessagesSince) as any }); + } + return out; + } + + save() { + if (!this.loaded) return; + this.inProgress = true; + const out = this.buildPayload(); + + this.adminService.setSettings(out) + .then(() => { + this.toast.success('', 'הגדרות הפרסומות נשמרו'); + this.lastLoaded = out; + this.snapshot = JSON.stringify(out); + }) + .catch((err) => this.toast.danger('', err?.status === 403 || err?.status === 401 + ? 'רק בעלי הערוץ יכולים לשנות את הגדרות הפרסומות' + : 'שמירת ההגדרות נכשלה — נסו שוב')) + .finally(() => this.inProgress = false); + } + + // ─── The channel's own Magnet numbers ─────────────────────────────────── + + /** Asks the owner route, not the public endpoint: the server alone decides the lock. */ + loadOwnStats() { + this.ownStats = 'loading'; + this.adminService.getMagnetStatsKey() + .then(r => { + this.statsKeySet = !!r?.statsKeySet; + this.editingKey = false; + this.ownStats = 'ready'; + }) + .catch(err => { + if (this.isLockedError(err)) { + this.showLocked(); + return; + } + this.ownStats = 'error'; + }); + } + + startKeyEdit() { + this.editingKey = true; + this.keyError = ''; + setTimeout(() => this.keyField?.nativeElement.focus()); + } + + cancelKeyEdit() { + this.editingKey = false; + this.keyInput = ''; + this.keyError = ''; + } + + saveKey() { + const key = this.keyInput.trim(); + if (!STATS_KEY_RE.test(key)) { + this.keyError = 'זה לא נראה כמו מפתח סטטיסטיקה: הוא מתחיל ב-mgs_ ואחריו 48 תווים (ספרות ואותיות a–f). העתיקו אותו שוב ממגנט.'; + return; + } + this.writeKey({ statsKey: key }, 'מפתח הסטטיסטיקה נשמר'); + } + + async clearKey() { + const ok = await this.confirm.ask({ + title: 'למחוק את מפתח הסטטיסטיקה?', + message: 'הנתונים ממגנט לא יוצגו כאן עד שתדביקו מפתח חדש. הפרסומות עצמן ממשיכות להופיע כרגיל.', + status: 'danger', + confirmLabel: 'מחיקה', + }); + if (ok) this.writeKey({ clearStatsKey: true }, 'מפתח הסטטיסטיקה נמחק'); + } + + private writeKey(body: { statsKey?: string; clearStatsKey?: boolean }, done: string) { + this.keySaving = true; + this.keyError = ''; + this.adminService.setMagnetStatsKey(body) + .then(r => { + this.statsKeySet = !!r?.statsKeySet; + this.keyInput = ''; + this.editingKey = false; + this.statsSource = () => this.adminService.getMagnetStats(); + this.toast.success('', done); + }) + .catch(err => { + if (this.isLockedError(err)) { + this.showLocked(); + return; + } + if (err?.error?.error === 'invalid_stats_key') { + this.keyError = err.error.message || 'מפתח הסטטיסטיקה אינו תקין'; + return; + } + this.toast.danger('', err?.status === 403 || err?.status === 401 + ? 'רק בעלי הערוץ יכולים לשנות את מפתח הסטטיסטיקה' + : 'שמירת המפתח נכשלה — נסו שוב'); + }) + .finally(() => this.keySaving = false); + } + + private isLockedError(err: any): boolean { + return err?.status === 403 && err?.error?.error === 'magnet_locked'; + } + + private showLocked() { + this.ownStats = 'locked'; + this.locked = true; + this.statsKeySet = false; + this.keyInput = ''; + this.editingKey = false; + } +} diff --git a/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.html b/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.html index c4fa38ff..109bf924 100644 --- a/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.html +++ b/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.html @@ -1,114 +1,142 @@ - - - ניהול הרשאות משתמשים - - - - - הוספת משתמש מורשה חדש - - - @if (!addingNewUser) { - - } +
- @if (addingNewUser) { -
-
- שם משתמש:
-
- - מתעדכן עם כניסת המשתמש למערכת - -
-
- כתובת מייל:
- -
-
- שם שיופיע בעת פרסום הודעות:
- -
-
- הרשאות: -
- מנהל ראשי - מנהל - כותב -
-
-
- - -
-
- } +
+ @for (r of roleCards; track r.role) { +
+ +
+ {{ r.label }} + {{ r.text }} +
+
+ } +
+ + @if (!isOwner) { + + + + רק בעלי הערוץ יכולים להוסיף אנשים או לשנות תפקידים. אתם רואים את הרשימה בלבד. + } + + + +

הוספת אדם לצוות

+
+ +

+ מזינים את כתובת המייל שאיתה האדם מתחבר לגוגל ובוחרים תפקיד. ההרשאה נכנסת לתוקף רק אחרי שהוא מתחבר לאתר + עם אותו חשבון — אין צורך לשלוח לו הזמנה, רק את הקישור לערוץ. +

+
+ +
+ + + @for (opt of roleOptions; track opt.value) { + {{ opt.label }} — {{ opt.hint }} + } + +
+
+ + @if (newUserEmail) { + + } +
+
+
+
- @for (item of privilegeUsersList; track $index) { - - - -
-
- - שם משתמש: -
- -
-
- - כתובת מייל: -
- -
-
- - שם שיופיע בעת פרסום הודעות: -
- -
-
- - הרשאות: + + + +

הצוות

+ @if (!loading && !loadFailed) { + {{ usersList.length }} + } +
+ + @if (loading) { +
+ } @else if (loadFailed) { +
+ + לא הצלחנו לטעון את הצוות + +
+ } @else if (!usersList.length) { +
+ + עדיין אין צוות + הוסיפו כותב או מנהל בטופס שלמעלה, ואז לחצו "שמירה". +
+ } @else { +
    + @for (item of usersList; track item.email; let i = $index) { +
  • + + + + + @if (isMe(item.email)) { זה אתם } + @if (isPending(item.email)) { טרם נשמר } + -
    - - מנהל ראשי - - - מנהל - - - כותב - -
    -
-
- -
-
-
-
- } - - - - - \ No newline at end of file + + } + + } + + + + @if (!loading && !loadFailed) { +
+ + @if (dirty && !saving) { + + } + + + {{ dirty ? 'יש שינויים שלא נשמרו' : 'הכול שמור' }} + +
+ } +
diff --git a/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.scss b/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.scss index e69de29b..349900df 100644 --- a/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.scss +++ b/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.scss @@ -0,0 +1,261 @@ +@use '../admin-shared' as *; + +:host { + display: block; +} + +.team-page { + @include admin-page; +} + +.card-head { + @include admin-card-header; +} + +.lead { + @include admin-lead; +} + +// ---------- the three roles ---------- +.roles { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.role { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.85rem 1rem; + border-radius: var(--card-border-radius); + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + box-shadow: var(--card-shadow); + border-inline-start: 4px solid var(--color-basic-500); + + &--owner { + border-inline-start-color: var(--color-primary-500); + + .role__icon { + color: var(--color-primary-600); + } + } + + &--moderator { + border-inline-start-color: var(--color-info-500); + + .role__icon { + color: var(--color-info-600); + } + } + + &--writer { + border-inline-start-color: var(--color-success-500); + + .role__icon { + color: var(--color-success-600); + } + } +} + +.role__icon { + flex-shrink: 0; + font-size: 1.4rem; + margin-top: 0.1rem; +} + +.role__text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.role__label { + color: var(--text-basic-color); +} + +.role__desc { + font-size: 0.85rem; + line-height: 1.5; + color: var(--text-hint-color); +} + +.team-note { + display: flex; + gap: 0.5rem; + align-items: center; + color: var(--text-hint-color); + + nb-icon { + flex-shrink: 0; + font-size: 1.2rem; + color: var(--color-info-600); + } +} + +// ---------- add form ---------- +.add { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1.4fr) auto; + gap: 0.75rem; + align-items: start; +} + +.add__field { + .field__label { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--text-basic-color); + } +} + +.add__error { + display: block; + margin-top: 0.3rem; + font-size: 0.82rem; + color: var(--color-danger-600); +} + +.add__actions { + display: flex; + gap: 0.5rem; + align-items: center; + // Lines the buttons up with the inputs, under the labels. + padding-top: 1.55rem; +} + +// ---------- members ---------- +.pill { + @include admin-pill(var(--background-basic-color-3), var(--text-hint-color)); + + &--owner { + background: var(--color-primary-100); + color: var(--color-primary-700); + } + + &--me { + background: var(--color-info-100); + color: var(--color-info-700); + } + + &--pending { + background: var(--color-warning-100); + color: var(--color-warning-700); + } +} + +.team-loading { + min-height: 6rem; +} + +.team-empty { + @include admin-empty; +} + +.members { + list-style: none; + margin: 0; + padding: 0; +} + +.member { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-child { + border-bottom: none; + } + + &.is-pending { + background: linear-gradient(90deg, var(--color-warning-100), transparent 40%); + border-radius: var(--border-radius); + padding-inline: 0.5rem; + } +} + +.member__avatar { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + background: var(--color-primary-100); + color: var(--color-primary-700); +} + +.member__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.member__email { + display: block; + text-align: start; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-basic-color); +} + +.member__tags { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + + &:empty { + display: none; + } +} + +.member__role { + flex-shrink: 0; + min-width: 7rem; +} + +.member__remove { + @include admin-icon-button; + flex-shrink: 0; +} + +.save-bar { + @include admin-save-bar; +} + +// ---------- phone ---------- +@media (max-width: 767px) { + .roles { + grid-template-columns: 1fr; + } + + .add { + grid-template-columns: 1fr; + } + + .add__actions { + padding-top: 0; + } + + .member { + flex-wrap: wrap; + } + + .member__text { + flex-basis: calc(100% - 3rem); + } + + .member__role { + flex: 1; + } +} diff --git a/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.ts b/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.ts index 79782bda..e6298d83 100644 --- a/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.ts +++ b/frontend/src/app/components/admin/privileg-dashboard/privileg-dashboard.component.ts @@ -1,9 +1,22 @@ -import { Component, OnInit } from '@angular/core'; -import { AdminService, PrivilegeUser } from '../../../services/admin.service'; -import { NbButtonModule, NbCardModule, NbInputModule, NbToastrService, NbIconModule, NbCheckboxModule } from "@nebular/theme"; +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { AdminService, ChannelUser } from '../../../services/admin.service'; +import { + NbButtonModule, NbCardModule, NbInputModule, NbToastrService, NbIconModule, NbSelectModule, + NbSpinnerModule, NbTooltipModule, +} from "@nebular/theme"; import { FormsModule } from '@angular/forms'; - import { AuthService } from '../../../services/auth.service'; +import { SlugService } from '../../../services/slug.service'; +import { ConfirmService } from '../../../services/confirm.service'; + +// Mirrors normEmail on the server, so the duplicate check here sees what the +// server will actually merge on. +function normalizeEmail(email: string | undefined): string { + return (email ?? '').trim().toLowerCase(); +} + +// Deliberately loose: one '@' with something on both sides and a dot after it. +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @Component({ selector: 'app-privileg-dashboard', @@ -13,68 +26,192 @@ import { AuthService } from '../../../services/auth.service'; NbInputModule, FormsModule, NbIconModule, - NbCheckboxModule -], + NbSelectModule, + NbSpinnerModule, + NbTooltipModule, + ], templateUrl: './privileg-dashboard.component.html', styleUrl: './privileg-dashboard.component.scss' }) export class PrivilegDashboardComponent implements OnInit { + /** The users endpoint answered 403: the role changed since sign-in. */ + @Output() accessDenied = new EventEmitter(); constructor( private adminService: AdminService, - private tostService: NbToastrService, + private toastService: NbToastrService, public authService: AuthService, + private slugService: SlugService, + private confirm: ConfirmService, ) { } - privilegeUsersList: PrivilegeUser[] = []; - addingNewUser: boolean = false; - newUser: PrivilegeUser = { - username: '', - publicName: '', - email: '', - privileges: { - admin: false, - moderator: false, - writer: false - } - }; + usersList: ChannelUser[] = []; + // Removed users are sent with an empty role so the server actually revokes them — + // an email that is simply missing from the payload is never touched. + removedUsers: ChannelUser[] = []; + /** Addresses added since the last save, so the list can say "טרם נשמר". */ + pending = new Set(); + newUserEmail = ''; + newUserRole: 'moderator' | 'writer' = 'writer'; + /** Inline validation under the email field (same wording the toasts used). */ + addError = ''; + + loading = true; + loadFailed = false; + saving = false; + private snapshot = ''; + + readonly roleOptions = [ + { value: 'moderator', label: 'מנהל', hint: 'עריכה ומחיקה של כל ההודעות, פרטי הערוץ, דיווחים' }, + { value: 'writer', label: 'כותב', hint: 'פרסום הודעות בלבד' }, + ]; + + /** What each role can do — read off the backend's role gates. */ + readonly roleCards = [ + { + role: 'owner', label: 'בעלים', icon: 'star-outline', + text: 'הכול: הגדרות, צוות, אחסון ופרסומות — וגם כל מה שמנהל יכול. נקבע בפתיחת הערוץ.', + }, + { + role: 'moderator', label: 'מנהל', icon: 'shield-outline', + text: 'פרסום, עריכה ומחיקה של כל ההודעות בערוץ, פרטי הערוץ, אימוג\'ים, סטטיסטיקות ודיווחים.', + }, + { + role: 'writer', label: 'כותב', icon: 'edit-2-outline', + text: 'פרסום הודעות, ועריכה או מחיקה של ההודעות שכתב בעצמו. לא רואה את מסך הניהול.', + }, + ]; + + get isOwner(): boolean { + const roles = this.authService.userInfo?.channelRoles; + if (this.authService.userInfo?.globalRole === 'super_admin') return true; + return roles?.[this.slugService.slug] === 'owner'; + } + + get myEmail(): string { + return normalizeEmail(this.authService.userInfo?.email); + } + + get dirty(): boolean { + return this.removedUsers.length > 0 || JSON.stringify(this.usersList) !== this.snapshot; + } ngOnInit(): void { - this.adminService.getPrivilegeUsersList() - .then(list => this.privilegeUsersList = list) + this.load(); + } + + load(): void { + this.loading = true; + this.loadFailed = false; + this.adminService.getChannelUsers() + .then(list => { + this.usersList = list; + this.removedUsers = []; + this.pending.clear(); + this.snapshot = JSON.stringify(list); + }) + .catch((err) => { + this.loadFailed = true; + if (err?.status === 403 || err?.status === 401) { + this.accessDenied.emit(); + return; + } + this.toastService.danger('', 'לא הצלחנו לטעון את רשימת הצוות — נסו שוב'); + }) + .finally(() => this.loading = false); + } + + isMe(email: string): boolean { + return !!this.myEmail && normalizeEmail(email) === this.myEmail; + } + + isPending(email: string): boolean { + return this.pending.has(normalizeEmail(email)); + } + + initial(email: string): string { + return (email || '?').trim().charAt(0).toUpperCase(); } saveChanges() { - this.adminService.setPrivilegeUsers(this.privilegeUsersList) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); + if (this.saving) return; + // The server applies the list in order, last write wins — so a removal that + // trailed a re-grant of the same address ("delete alice, add alice back as + // writer") revoked her. Removals go first, and one whose address is being + // granted again is dropped altogether. + const granted = new Set(this.usersList.map(u => normalizeEmail(u.email))); + const removals = this.removedUsers.filter(u => !granted.has(normalizeEmail(u.email))); + this.saving = true; + this.adminService.setChannelUsers([...removals, ...this.usersList]) + .then(() => { + this.removedUsers = []; + this.pending.clear(); + this.snapshot = JSON.stringify(this.usersList); + this.toastService.success('', 'הצוות עודכן'); + }) + .catch((err) => { + const text = typeof err?.error === 'string' ? err.error : ''; + if (err?.status === 400 && text.includes('invalid email')) { + this.toastService.danger('', 'אחת הכתובות ברשימה אינה כתובת מייל תקינה'); + } else if (err?.status === 403 || err?.status === 401) { + this.toastService.danger('', 'רק בעלי הערוץ יכולים לשנות את הצוות'); + } else { + this.toastService.danger('', 'שמירת הצוות נכשלה — נסו שוב'); + } + }) + .finally(() => this.saving = false); } - deleteUser(index: number) { - if (!confirm('האם אתה בטוח שברצונך למחוק את המשתמש הזה?')) return; - this.privilegeUsersList.splice(index, 1); + async deleteUser(index: number) { + const user = this.usersList[index]; + if (!user) return; + const ok = await this.confirm.ask({ + title: `להסיר את ${user.email}?`, + message: 'ההרשאה תוסר בלחיצה על "שמירה". ההודעות שפורסמו נשארות בערוץ.', + confirmLabel: 'הסרה', + status: 'danger', + icon: 'person-remove-outline', + }); + if (!ok) return; + const [removed] = this.usersList.splice(index, 1); + if (removed?.email) { + this.removedUsers.push({ email: removed.email, role: '' }); + this.pending.delete(normalizeEmail(removed.email)); + } } saveNewUser() { - if (!this.newUser.email) return; - this.privilegeUsersList.push(this.newUser); - this.newUser = this.nullUser; - this.addingNewUser = false; + // The server normalises and silently skips a blank address, and stores an + // invalid one for good — where it can never match a Google login. Catch the + // typo here, while the owner is still looking at it. + this.addError = ''; + const email = normalizeEmail(this.newUserEmail); + if (!email) { + this.addError = 'יש להזין כתובת מייל'; + return; + } + if (!EMAIL_SHAPE.test(email)) { + this.addError = 'כתובת המייל אינה תקינה'; + return; + } + if (this.usersList.some(u => normalizeEmail(u.email) === email)) { + this.addError = 'כתובת המייל כבר קיימת ברשימה'; + return; + } + this.usersList.push({ email, role: this.newUserRole }); + this.pending.add(email); + this.newUserEmail = ''; + this.newUserRole = 'writer'; } resetNewUser() { - this.newUser = this.nullUser; - this.addingNewUser = false; + this.newUserEmail = ''; + this.newUserRole = 'writer'; + this.addError = ''; } - nullUser: PrivilegeUser = { - username: '', - publicName: '', - email: '', - privileges: { - admin: false, - moderator: false, - writer: false - } - }; + getRoleLabel(role: string): string { + if (role === 'owner') return 'בעלים'; + return this.roleOptions.find(o => o.value === role)?.label ?? role; + } } diff --git a/frontend/src/app/components/admin/reports/reports.component.html b/frontend/src/app/components/admin/reports/reports.component.html index f344c2c7..9be59d01 100644 --- a/frontend/src/app/components/admin/reports/reports.component.html +++ b/frontend/src/app/components/admin/reports/reports.component.html @@ -1,37 +1,96 @@ - - - טיפול בדיווחים שהתקבלו ממשתמשים - - - @if (!reports) { -
{{status === 'open' ? 'אין דיווחים פתוחים' : 'אין דיווחים מטופלים'}}
+
+ + + @if (loading) { + + + + } @else if (loadFailed) { + + + + לא הצלחנו לטעון את הדיווחים + + + + } @else if (!reports.length) { + + + + {{ emptyText }} + כשקורא לוחץ "דיווח על ההודעה" ליד הודעה בערוץ, הדיווח מופיע כאן. + + + } @else { +

{{ reports.length }} דיווחים

+ @for (report of reports; track report.id) { + + +
+ + + {{ report.closed ? 'טופל' : 'פתוח' }} + + התקבל {{ report.createdAt | messageTime }} + @if (report.closed) { + · טופל {{ report.updatedAt | messageTime }} + } +
+ +

{{ report.reason || 'ללא פירוט' }}

+ +
+
+
דווח על ידי
+
{{ report.reporterName || 'קורא' }}
+
+ @if (report.reportedEmail) { +
+
מייל
+
{{ report.reportedEmail }}
} - - - \ No newline at end of file +
+
הודעה
+
+ @if (deletedMessages.has(report.messageId)) { + נמחקה + } @else { + מס' {{ report.messageId }} + } +
+
+
+ +
+ + + +
+
+
+ } + } +
diff --git a/frontend/src/app/components/admin/reports/reports.component.scss b/frontend/src/app/components/admin/reports/reports.component.scss index e69de29b..4092aab7 100644 --- a/frontend/src/app/components/admin/reports/reports.component.scss +++ b/frontend/src/app/components/admin/reports/reports.component.scss @@ -0,0 +1,158 @@ +@use '../admin-shared' as *; + +:host { + display: block; +} + +.reports-page { + @include admin-page; +} + +.reports-loading { + min-height: 6rem; +} + +.reports-empty { + @include admin-empty; +} + +.reports-count { + margin: 0; + font-size: 0.85rem; + color: var(--text-hint-color); +} + +// ---------- open / closed / all ---------- +.filters { + display: flex; + gap: 0.35rem; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.25rem; + + &::-webkit-scrollbar { + display: none; + } +} + +.filters__item { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.5rem; + padding: 0.4rem 0.9rem; + border-radius: 2rem; + border: 1px solid var(--border-basic-color-4); + background: var(--background-basic-color-1); + color: var(--text-basic-color); + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + + &:hover { + text-decoration: none; + background: var(--color-basic-transparent-100); + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } + + &.is-active { + background: var(--color-primary-500); + border-color: var(--color-primary-500); + color: var(--text-control-color); + } +} + +// ---------- one report ---------- +.report { + margin: 0; + + &.is-closed { + opacity: 0.85; + } +} + +.report__head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.6rem; +} + +.pill { + @include admin-pill(var(--background-basic-color-3), var(--text-hint-color)); + + &--open { + background: var(--color-warning-100); + color: var(--color-warning-700); + } + + &--closed { + background: var(--color-success-100); + color: var(--color-success-700); + } +} + +.report__time { + font-size: 0.82rem; + color: var(--text-hint-color); +} + +.report__reason { + margin: 0 0 0.75rem; + padding: 0.65rem 0.9rem; + border-radius: var(--border-radius); + background: var(--background-basic-color-2); + border-inline-start: 3px solid var(--color-warning-500); + line-height: 1.6; + color: var(--text-basic-color); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.report__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + margin: 0 0 0.75rem; + + > div { + display: flex; + gap: 0.35rem; + font-size: 0.88rem; + } + + dt { + color: var(--text-hint-color); + font-weight: 400; + } + + dd { + margin: 0; + color: var(--text-basic-color); + overflow-wrap: anywhere; + } +} + +.report__deleted { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--color-danger-600); +} + +.report__actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + + [nbButton] { + min-height: 2.5rem; + } +} diff --git a/frontend/src/app/components/admin/reports/reports.component.ts b/frontend/src/app/components/admin/reports/reports.component.ts index 93824157..b48abb72 100644 --- a/frontend/src/app/components/admin/reports/reports.component.ts +++ b/frontend/src/app/components/admin/reports/reports.component.ts @@ -1,47 +1,143 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { NbButtonModule, NbCardModule, NbToastrService } from "@nebular/theme"; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NbButtonModule, NbCardModule, NbIconModule, NbSpinnerModule, NbToastrService, NbTooltipModule } from "@nebular/theme"; +import { firstValueFrom } from 'rxjs'; import { AdminService } from '../../../services/admin.service'; import { Reports, Report } from '../../../models/report.model'; - +import { SlugService } from '../../../services/slug.service'; +import { ConfirmService } from '../../../services/confirm.service'; import { MessageTimePipe } from "../../../pipes/message-time.pipe"; +/** + * "דיווחים": what readers flagged. A report is closed (handled) or reopened + * here; the message itself can be opened in the feed or deleted from here, + * the same delete the feed's own button performs. + */ @Component({ selector: 'app-reports', imports: [ + RouterLink, NbCardModule, NbButtonModule, - MessageTimePipe -], + NbIconModule, + NbSpinnerModule, + NbTooltipModule, + MessageTimePipe, + ], templateUrl: './reports.component.html', styleUrl: './reports.component.scss' }) -export class ReportsComponent implements OnInit { +export class ReportsComponent implements OnInit, OnChanges { reports: Reports = []; @Input() status: 'open' | 'closed' | 'all' = 'open'; + /** The reports endpoint answered 403: the role changed since sign-in. */ + @Output() accessDenied = new EventEmitter(); + + loading = true; + loadFailed = false; + /** Report ids with a request in flight, so a button cannot be tapped twice. */ + busy = new Set(); + /** Messages deleted from this screen, so their cards say so. */ + deletedMessages = new Set(); + + readonly filters = [ + { id: 'reports', status: 'open', title: 'פתוחים', icon: 'alert-triangle-outline' }, + { id: 'reports-closed', status: 'closed', title: 'טופלו', icon: 'checkmark-circle-2-outline' }, + { id: 'reports-all', status: 'all', title: 'כל הדיווחים', icon: 'list-outline' }, + ] as const; constructor( private adminService: AdminService, - private toastrService: NbToastrService + private toastrService: NbToastrService, + private slugService: SlugService, + private confirm: ConfirmService, ) { } + get slug(): string { + return this.slugService.slug; + } + + get emptyText(): string { + switch (this.status) { + case 'open': return 'אין דיווחים פתוחים — הכול טופל.'; + case 'closed': return 'עדיין לא טופל אף דיווח.'; + default: return 'אף קורא עדיין לא דיווח על הודעה.'; + } + } + ngOnInit(): void { + this.load(); + } + + ngOnChanges(changes: SimpleChanges): void { + // The manage page keeps one instance alive across the three report tabs, + // so a filter change arrives as an input change, not a fresh component. + if (changes['status'] && !changes['status'].firstChange) this.load(); + } + + load(): void { + this.loading = true; + this.loadFailed = false; this.adminService.getReports(this.status).then(reports => { - this.reports = reports; - }) - .catch(() => this.toastrService.danger('', 'אירעה שגיאה בעת טעינת הדיווחים')); + this.reports = reports ?? []; + }).catch((err) => { + this.loadFailed = true; + if (err?.status === 403 || err?.status === 401) { + this.accessDenied.emit(); + return; + } + this.toastrService.danger('', 'לא הצלחנו לטעון את הדיווחים — נסו שוב'); + }).finally(() => this.loading = false); } toggleReport(report: Report) { - report.closed = !report.closed; - report.updatedAt = new Date(); - this.adminService.setReports(report).then(() => { - this.toastrService.success('', report.closed ? 'דיווח נסגר בהצלחה' : 'דיווח נפתח מחדש'); - this.status === 'all' ? this.reports[this.reports.findIndex(r => r.id === report.id)] = report : this.reports = this.reports.filter(r => r.id !== report.id); - }).catch(() => this.toastrService.danger('', '')); + if (this.busy.has(report.id)) return; + // The flip used to be applied before the request and never undone, so a + // 404/500 left the button reading "reopen" for a report the server still + // has open. Send the intended state and apply it only once it is stored. + const updated: Report = { ...report, closed: !report.closed, updatedAt: new Date() }; + this.busy.add(report.id); + this.adminService.setReports(updated).then(() => { + this.toastrService.success('', updated.closed ? 'הדיווח סומן כטופל' : 'הדיווח נפתח מחדש'); + if (this.status === 'all') { + const i = this.reports.findIndex(r => r.id === report.id); + if (i >= 0) this.reports[i] = updated; + } else { + this.reports = this.reports.filter(r => r.id !== report.id); + } + }).catch((err: any) => this.toastrService.danger('', + err?.status === 404 ? 'הדיווח כבר לא קיים' : 'עדכון הדיווח נכשל — נסו שוב')) + .finally(() => this.busy.delete(report.id)); } + /** The reported message, in the feed, in a new tab — the report stays open here. */ viewReport(messageId: number) { - window.open(`${window.location.origin}/#${messageId}`, '_blank'); + window.open(`${window.location.origin}/channel/${this.slugService.slug}#${messageId}`, '_blank', 'noopener'); + } + + async deleteMessage(report: Report) { + if (this.busy.has(report.id) || this.deletedMessages.has(report.messageId)) return; + const ok = await this.confirm.ask({ + title: 'למחוק את ההודעה שדווחה?', + message: 'ההודעה תוסתר מכל הקוראים. מי שרשאי לערוך עדיין יוכל לשחזר אותה מתוך הערוץ. הדיווח עצמו נשאר — סגרו אותו בנפרד.', + confirmLabel: 'מחיקת ההודעה', + status: 'danger', + }); + if (!ok) return; + this.busy.add(report.id); + try { + await firstValueFrom(this.adminService.deleteMessage(report.messageId)); + this.deletedMessages.add(report.messageId); + this.toastrService.success('', 'ההודעה נמחקה'); + } catch (err: any) { + this.toastrService.danger('', err?.status === 404 + ? 'ההודעה כבר לא קיימת' + : err?.status === 403 || err?.status === 401 + ? 'אין לכם הרשאה למחוק את ההודעה הזו' + : 'מחיקת ההודעה נכשלה — נסו שוב'); + } finally { + this.busy.delete(report.id); + } } } diff --git a/frontend/src/app/components/admin/settings/settings.component.html b/frontend/src/app/components/admin/settings/settings.component.html index 80f3eead..34af65b2 100644 --- a/frontend/src/app/components/admin/settings/settings.component.html +++ b/frontend/src/app/components/admin/settings/settings.component.html @@ -1,31 +1,250 @@ - - - הגדרות ערוץ - - - - @for (setting of settings; track setting; let i = $index) { -
- - - -
+
+
+ } @else { + + @for (cat of schema; track cat.id) { + + + @if (cat.icon) { + + } +

{{ cat.title }}

+ @if (cat.tone === 'warning') { + + רגיש + + } +
+ + + @if (cat.description) { +

{{ cat.description }}

+ } + + @if (cat.id === 'ads' && adsLocked) { + + הנהלת המערכת קבעה פרסומת אחידה לכל הערוצים, ולכן השדות כאן נעולים — מה שתכתבו לא ישפיע על הערוץ. + + } + + @for (field of cat.fields; track field.key) { + @if (isFieldVisible(field)) { +
+ + @if (field.type === 'boolean') { +
+ + {{ field.label }} + + {{ values[field.key] ? 'דלוק' : 'כבוי' }} +
+ @if (field.description) { +

{{ field.description }}

+ } + } @else { + + @if (field.description) { +

{{ field.description }}

+ } + + @switch (field.type) { + @case ('textarea') { + + } + @case ('number') { + + } + @case ('password') { +
+ + + + @if (field.generate) { + + } +
+ @if (field.generate) { +

אפשר להמציא ערך לבד, או ללחוץ על הברק ליצירת ערך אקראי. כפתורי העין וההעתקה מאפשרים לקרוא אותו ולהעתיק אותו למערכת השנייה.

+ } + } + @default { + + } + } + } +
+ } + } +
+
} -
- + + + + + החלפות אוטומטיות בטקסט + למתקדמים: ביטויים רגולריים (Regex) + @if (regexRules.length) { + {{ regexRules.length }} + } + + +

+ כלל החלפה רץ על כל הודעה חדשה שמתפרסמת: בשדה "מה לחפש" כותבים ביטוי רגולרי (Regex), ובשדה "במה להחליף" את + הטקסט שיבוא במקומו. למשל, חיפוש (.*?\!)(.*) והחלפה ב-**$1**$2 + מדגישים כל כותרת הודעה שמסתיימת בסימן קריאה. מיועד למי שמכיר ביטויים רגולריים — אחרת אפשר לדלג. +

+ + @if (regexRules.length === 0) { +

אין כללי החלפה. ההודעות מתפרסמות כפי שנכתבו.

+ } + + @for (rule of regexRules; track $index; let i = $index) { +
+
+
+ + +
+
+ + +
+
+ +
+ } + + +
+
+ + + + + הגדרות מתקדמות + רק אם נאמר לכם במפורש מה להזין + + +

+ כאן נשמרות הגדרות שעדיין אין להן שדה בטופס שלמעלה. מזינים שם הגדרה וערך בדיוק כפי שקיבלתם + מהנהלת המערכת — שם שגוי פשוט יישמר ולא יעשה דבר. +

+ + @if (extraSettings.length === 0) { +

אין הגדרות מתקדמות.

+ } + + @for (s of extraSettings; track $index; let i = $index) { +
+ + + +
+ } + + +
+
+
+ +
+ + @if (dirty && !setInProgress) { + + } + + + {{ dirty ? 'יש שינויים שלא נשמרו' : 'הכול שמור' }} +
- - - - - - \ No newline at end of file + } +
diff --git a/frontend/src/app/components/admin/settings/settings.component.scss b/frontend/src/app/components/admin/settings/settings.component.scss index e69de29b..6ba3a367 100644 --- a/frontend/src/app/components/admin/settings/settings.component.scss +++ b/frontend/src/app/components/admin/settings/settings.component.scss @@ -0,0 +1,186 @@ +@use '../admin-shared' as *; + +:host { + display: block; +} + +.settings-page { + @include admin-page; +} + +.settings-loading { + min-height: 10rem; +} + +.settings-empty { + @include admin-empty; +} + +.settings-card { + margin-bottom: 0; + + &.is-warning { + border-color: var(--color-warning-300); + } +} + +.card-head { + @include admin-card-header; +} + +.lead { + @include admin-lead; + + code { + background: var(--background-basic-color-2); + padding: 0.1rem 0.3rem; + border-radius: 4px; + font-size: 0.85em; + direction: ltr; + display: inline-block; + unicode-bidi: embed; + } +} + +.muted { + margin: 0 0 0.75rem; + font-size: 0.9rem; + color: var(--text-hint-color); +} + +.settings-alert { + margin-bottom: 1rem; +} + +.pill { + @include admin-pill(var(--background-basic-color-3), var(--text-hint-color)); + + &--on { + background: var(--color-success-100); + color: var(--color-success-700); + } + + &--warning { + background: var(--color-warning-100); + color: var(--color-warning-700); + margin-inline-start: auto; + } + + &--count { + background: var(--color-primary-100); + color: var(--color-primary-700); + } +} + +.field { + @include admin-field; + + &.is-toggle { + padding-block: 0.65rem; + } + + .field__label--inline { + margin: 0; + font-size: 0.95rem; + } + + .field__label--small { + font-size: 0.82rem; + font-weight: 500; + } + + .field__hint--toggle, + .field__hint--after { + margin: 0.35rem 0 0; + } +} + +.toggle-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.input--short { + max-width: 12rem; +} + +.secret { + display: flex; + align-items: center; + gap: 0.25rem; + + input { + flex: 1; + min-width: 0; + } +} + +.secret__btn, +.icon-btn { + @include admin-icon-button; + flex-shrink: 0; +} + +.regex-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding: 0.6rem 0; + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-of-type { + border-bottom: none; + margin-bottom: 0.6rem; + } +} + +.regex-fields { + display: flex; + gap: 0.5rem; + flex: 1; + flex-wrap: wrap; + min-width: 0; + + .regex-field { + flex: 1; + min-width: 10rem; + } +} + +.extra-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; + + input:first-of-type { + flex: 0 0 30%; + min-width: 6rem; + } +} + +.advanced-accordion { + display: block; + + ::ng-deep nb-accordion-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .advanced-title { + font-weight: 600; + } + + .advanced-sub { + margin: 0; + font-size: 0.8rem; + } +} + +.save-bar { + @include admin-save-bar; +} diff --git a/frontend/src/app/components/admin/settings/settings.component.ts b/frontend/src/app/components/admin/settings/settings.component.ts index 5191a115..eb71ae1c 100644 --- a/frontend/src/app/components/admin/settings/settings.component.ts +++ b/frontend/src/app/components/admin/settings/settings.component.ts @@ -1,47 +1,346 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { AdminService } from '../../../services/admin.service'; -import { NbButtonModule, NbCardModule, NbToastrService, NbIconModule, NbInputModule } from "@nebular/theme"; +import { AdsService } from '../../../services/ads.service'; +import { + NbAlertModule, + NbButtonModule, + NbCardModule, + NbToastrService, + NbIconModule, + NbInputModule, + NbToggleModule, + NbAccordionModule, + NbTooltipModule, + NbSpinnerModule, +} from "@nebular/theme"; import { FormsModule } from '@angular/forms'; import { Setting } from '../../../models/setting.model'; +import { ShareService } from '../../../services/share.service'; +import { + SETTINGS_SCHEMA, + SettingsCategorySchema, + SettingFieldSchema, + getAllKnownKeys, + PASSTHROUGH_SETTING_KEYS, + toBool, +} from './settings.schema'; + +interface RegexRule { + pattern: string; + replace: string; +} + +interface ExtraSetting { + key: string; + value: string; +} @Component({ selector: 'app-settings', imports: [ + NbAlertModule, NbCardModule, NbButtonModule, NbIconModule, NbInputModule, - FormsModule -], + NbToggleModule, + NbAccordionModule, + NbTooltipModule, + NbSpinnerModule, + FormsModule, + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss' }) export class SettingsComponent implements OnInit { + /** The settings endpoint answered 403: the role changed since sign-in. */ + @Output() accessDenied = new EventEmitter(); + + schema: SettingsCategorySchema[] = SETTINGS_SCHEMA; + values: Record = {}; + regexRules: RegexRule[] = []; + extraSettings: ExtraSetting[] = []; + // Settings another tab owns (magnet ads): never shown, re-sent as loaded. + private passthrough: Setting[] = []; + // The server copy as last loaded — what "ביטול שינויים" goes back to. + private lastLoaded: Setting[] = []; + // Serialised payload at load/save time; anything else means unsaved edits. + private snapshot = ''; + + /** Which password fields are currently shown in clear. */ + revealed: Record = {}; - settings: Setting[] = []; setInProgress: boolean = false; + // Save stays disabled until the server copy arrived: the form starts empty, + // and settings/set replaces the whole blob (no merge), so a save after a + // failed load wiped api_secret_key, webhook_*, regex rules and magnet_*. + loaded = false; + loading = true; + loadFailed = false; + // Super-admin lock on the iframe ad: the ad-iframe-* fields still save, but + // the public endpoint serves the global src/width and they are ignored. + adsLocked = false; + + private static readonly AD_IFRAME_KEYS = new Set(['ad-iframe-src', 'ad-iframe-width']); constructor( private adminService: AdminService, + private adsService: AdsService, private tostService: NbToastrService, + private shareService: ShareService, ) { } ngOnInit(): void { + this.loadSettings(); + // The public ads endpoint is the only place the lock is visible to an + // owner. A failure here just leaves the hint off; the form still loads. + this.adsService.getAds() + .then(ad => this.adsLocked = !!ad?.locked) + .catch(() => { /* hint only */ }); + } + + get dirty(): boolean { + return this.loaded && JSON.stringify(this.buildSettingsArray()) !== this.snapshot; + } + + loadSettings() { + this.loadFailed = false; + this.loading = true; this.adminService.getSettings() - .then(settings => this.settings = settings || []); + .then(settings => { + this.lastLoaded = settings || []; + this.loadFromSettings(this.lastLoaded); + this.loaded = true; + this.snapshot = JSON.stringify(this.buildSettingsArray()); + }) + .catch((err) => { + this.loadFailed = true; + if (err?.status === 403 || err?.status === 401) { + this.accessDenied.emit(); + return; + } + this.tostService.danger('', 'לא הצלחנו לטעון את ההגדרות — נסו שוב'); + }) + .finally(() => this.loading = false); + } + + /** Back to the server copy, without a round trip. */ + resetChanges() { + this.loadFromSettings(this.lastLoaded); + this.revealed = {}; + } + + /** Only the two iframe-ad fields are frozen by the ads lock. */ + isFieldLocked(field: SettingFieldSchema): boolean { + return this.adsLocked && SettingsComponent.AD_IFRAME_KEYS.has(field.key); + } + + toggleReveal(key: string) { + this.revealed[key] = !this.revealed[key]; + } + + /** The owner has to paste the key into the other system; a masked field cannot be read back. */ + async copySecret(key: string) { + const value = String(this.values[key] ?? ''); + if (!value) return; + if (await this.shareService.copy(value)) this.tostService.success('', 'הועתק'); + } + + /** + * Fills a random 32-character key (letters and digits, from the browser's + * CSPRNG) so the owner never has to invent one — the server rejects + * anything under 16 characters, and a short key is guessable. + */ + generateSecret(key: string) { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + this.values[key] = Array.from(bytes, b => alphabet[b % alphabet.length]).join(''); + this.revealed[key] = true; + } + + private loadFromSettings(settings: Setting[]) { + const known = getAllKnownKeys(); + const passthroughKeys = new Set(PASSTHROUGH_SETTING_KEYS); + this.values = {}; + this.regexRules = []; + this.extraSettings = []; + this.passthrough = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + if (f.type === 'boolean') this.values[f.key] = false; + else this.values[f.key] = ''; + } + } + + for (const s of settings) { + if (s.key === 'regex-replace') { + const raw = String(s.value ?? ''); + const idx = regexRuleSeparator(raw); + if (idx >= 0) { + this.regexRules.push({ + pattern: unescapeRuleHash(raw.substring(0, idx)), + replace: raw.substring(idx + 1), + }); + } else if (raw) { + this.regexRules.push({ pattern: unescapeRuleHash(raw), replace: '' }); + } + continue; + } + + // Checked before `known`: a known key with no form field is a legacy one + // and is dropped on save, which is exactly what must not happen here. + if (passthroughKeys.has(s.key)) { + this.passthrough.push(s); + continue; + } + + if (known.has(s.key)) { + const field = this.findField(s.key); + if (field?.type === 'boolean') { + this.values[s.key] = toBool(s.value); + } else { + this.values[s.key] = s.value === undefined || s.value === null ? '' : String(s.value); + } + } else { + this.extraSettings.push({ + key: s.key, + value: s.value === undefined || s.value === null ? '' : String(s.value), + }); + } + } + } + + private findField(key: string): SettingFieldSchema | undefined { + for (const cat of this.schema) { + const f = cat.fields.find(x => x.key === key); + if (f) return f; + } + return undefined; + } + + isFieldVisible(field: SettingFieldSchema): boolean { + if (!field.hideWhen) return true; + return this.values[field.hideWhen.key] !== field.hideWhen.equals; + } + + addRegexRule() { + this.regexRules.push({ pattern: '', replace: '' }); + } + + removeRegexRule(i: number) { + this.regexRules.splice(i, 1); + } + + addExtraSetting() { + this.extraSettings.push({ key: '', value: '' }); + } + + removeExtraSetting(i: number) { + this.extraSettings.splice(i, 1); + } + + private buildSettingsArray(): Setting[] { + const out: Setting[] = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + const v = this.values[f.key]; + if (f.type === 'boolean') { + if (v === true) out.push({ key: f.key, value: '1' as any }); + } else if (f.type === 'number') { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } else { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } + } + } + + for (const r of this.regexRules) { + const p = (r.pattern || '').trim(); + if (!p) continue; + out.push({ key: 'regex-replace', value: `${escapeRuleHash(p)}#${r.replace ?? ''}` as any }); + } + + for (const e of this.extraSettings) { + const k = (e.key || '').trim(); + if (!k) continue; + out.push({ key: k, value: e.value ?? '' as any }); + } + + // Re-appended verbatim so a save here never drops the magnet tab's keys. + out.push(...this.passthrough); + + return out; } saveSettings() { + if (!this.loaded) return; this.setInProgress = true; - this.adminService.setSettings(this.settings) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); - this.setInProgress = false; + const payload = this.buildSettingsArray(); + this.adminService.setSettings(payload) + .then(() => { + this.tostService.success('', 'ההגדרות נשמרו'); + this.lastLoaded = payload; + this.snapshot = JSON.stringify(payload); + }) + .catch((err) => this.tostService.danger('', this.saveErrorText(err))) + .finally(() => this.setInProgress = false); } - removeSetting(index: number) { - // if (!confirm('האם אתה בטוח שברצונך למחוק את ההגדרה הזו?')) return; - this.settings.splice(index, 1); + /** + * validateSettings on the server answers 400 with a plain-text English + * reason that names the offending key ("webhook_url: must be an absolute + * http(s) URL", "regex-replace: invalid pattern: …", "max_file_size: must be + * between 1 and 512 MB", "api_secret_key must be at least 16 characters"). + * Matched on here, never shown as-is. + */ + private saveErrorText(err: any): string { + const text = typeof err?.error === 'string' ? err.error : ''; + if (err?.status === 400) { + if (text.includes('webhook_url')) return 'כתובת העדכון (Webhook) חייבת להתחיל ב-http:// או https://'; + if (text.includes('ad-iframe-src')) return 'כתובת עמוד הפרסומת חייבת להתחיל ב-http:// או https://'; + if (text.includes('regex')) return 'אחד מכללי ההחלפה אינו תקין — בדקו את שדה "מה לחפש"'; + if (text.includes('max_file_size')) return 'גודל הקובץ המרבי חייב להיות בין 1 ל-512 MB'; + if (text.includes('api_secret_key')) return 'המפתח לפרסום מבחוץ חייב להכיל לפחות 16 תווים'; + } + if (err?.status === 403 || err?.status === 401) return 'אין לכם הרשאה לשנות את ההגדרות של הערוץ'; + return 'שמירת ההגדרות נכשלה — נסו שוב'; } } + +/** + * A rule is stored as "#", and '#' is also an ordinary + * regex character (a hashtag rule is the obvious case). Splitting at the first + * bare '#' turned "#(\S+)#**#$1**" into an EMPTY pattern, which the server then + * applied between every two characters of every message. So a '#' inside the + * pattern is stored escaped as "\#" — which the regex engine reads as a + * literal '#' — and the separator is the first '#' that is not escaped. + * The helpers below mirror splitRegexRule in the backend. + */ +function regexRuleSeparator(raw: string): number { + for (let i = 0; i < raw.length; i++) { + if (raw[i] === '\\') { i++; continue; } + if (raw[i] === '#') return i; + } + return -1; +} + +function escapeRuleHash(pattern: string): string { + let out = ''; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] === '\\' && i + 1 < pattern.length) { out += pattern[i] + pattern[i + 1]; i++; continue; } + out += pattern[i] === '#' ? '\\#' : pattern[i]; + } + return out; +} + +function unescapeRuleHash(pattern: string): string { + return pattern.replace(/\\#/g, '#'); +} diff --git a/frontend/src/app/components/admin/settings/settings.schema.ts b/frontend/src/app/components/admin/settings/settings.schema.ts new file mode 100644 index 00000000..d66a2f72 --- /dev/null +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -0,0 +1,185 @@ +export type SettingFieldType = 'boolean' | 'text' | 'number' | 'url' | 'textarea' | 'password'; + +export interface SettingFieldSchema { + key: string; + label: string; + /** What the setting does for readers and writers, in plain Hebrew. */ + description?: string; + type: SettingFieldType; + placeholder?: string; + default?: string | number | boolean; + hideWhen?: { key: string; equals: any }; + // Rendered as the input's min/max (number) or minlength (password) — a + // hint only; the server's validateSettings is what rejects the value. + min?: number; + max?: number; + minLength?: number; + /** A secret: shown masked with a reveal button and tagged as such. */ + sensitive?: boolean; + /** A key the owner has to invent: offer a button that fills a random one. */ + generate?: boolean; +} + +export interface SettingsCategorySchema { + id: string; + title: string; + icon?: string; + description?: string; + /** 'warning' marks a group whose fields hand out access (keys, tokens). */ + tone?: 'warning'; + fields: SettingFieldSchema[]; +} + +// Grouped by what the owner is trying to do, not by where the backend keeps +// the value: files and their size together, every external key together. +export const SETTINGS_SCHEMA: SettingsCategorySchema[] = [ + { + id: 'files', + title: 'קבצים ותמונות', + icon: 'attach-2-outline', + description: 'מה מותר לצרף להודעה וכמה מקום זה תופס. המכסה הכוללת של הערוץ מופיעה במדור "אחסון".', + fields: [ + { + key: 'max_file_size', + label: 'גודל מרבי לקובץ (במגה-בייט)', + description: 'קובץ גדול מזה יידחה בהעלאה עם הודעה לכותב. בלי ערך — 100 MB. אי אפשר לעבור 512 MB, וערך גדול יותר פשוט לא ייקלט.', + type: 'number', + placeholder: '100', + default: 100, + min: 1, + max: 512, + }, + { + key: 'tinypng_api_key', + label: 'מפתח TinyPNG לכיווץ תמונות', + description: 'כשיש מפתח, כל תמונה (PNG, JPEG, WebP) נדחסת לפני השמירה ותופסת הרבה פחות מקום — הקוראים לא ירגישו בהבדל. מפתח חינמי מקבלים באתר tinypng.com. לא חובה.', + type: 'password', + placeholder: 'המפתח מהאתר של TinyPNG', + sensitive: true, + }, + ], + }, + { + id: 'notifications', + title: 'התראות לנייד ולדפדפן', + icon: 'bell-outline', + description: 'התראה קופצת אצל הקוראים על כל הודעה חדשה. הקורא עצמו מאשר קבלת התראות בלחיצה על הפעמון בכותרת הערוץ.', + fields: [ + { + key: 'on_notification', + label: 'לשלוח התראה על כל הודעה חדשה', + description: 'דלוק — מי שאישר התראות לערוץ מקבל התראה בכל פרסום. כבוי — כפתור הפעמון לא מופיע ואף התראה לא נשלחת. תשתית ההתראות מוגדרת על ידי הנהלת המערכת; אם היא לא הוגדרה, המתג לא ישנה דבר.', + type: 'boolean', + }, + ], + }, + { + id: 'ads', + title: 'פרסומת במסגרת', + icon: 'pricetags-outline', + description: 'מסגרת פרסומת שמוצגת לצד הערוץ. הכנסת כתובת מדליקה אותה, ריקון השדה מכבה. פרסומות של מגנט ADS מוגדרות במדור נפרד.', + fields: [ + { + key: 'ad-iframe-src', + label: 'כתובת עמוד הפרסומת', + description: 'כתובת אינטרנט מלאה (https://…) של העמוד שיוצג במסגרת. השאירו ריק כדי לא להציג פרסומת.', + type: 'url', + placeholder: 'https://ad.example.com/banner.html', + }, + { + key: 'ad-iframe-width', + label: 'רוחב המסגרת (פיקסלים)', + description: 'רוחב מומלץ: 300.', + type: 'number', + placeholder: '300', + }, + ], + }, + { + id: 'integrations', + title: 'חיבור למערכות חיצוניות', + icon: 'link-2-outline', + tone: 'warning', + description: 'למי שמחבר את הערוץ לתוכנה אחרת: פרסום הודעות מבחוץ, ועדכון שרת חיצוני על כל שינוי. אם אין לכם כזו — אפשר לדלג על המדור כולו.', + fields: [ + { + key: 'api_secret_key', + label: 'מפתח לפרסום הודעות מבחוץ', + description: 'מערכת חיצונית ששולחת את המפתח הזה יכולה לפרסם הודעות בערוץ בלי להתחבר. כל עוד השדה ריק — הדרך הזו סגורה. לפחות 16 תווים; שמרו עליו כמו על סיסמה והחליפו אותו אם דלף.', + type: 'password', + placeholder: 'מפתח סודי ארוך ואקראי', + minLength: 16, + sensitive: true, + generate: true, + }, + { + key: 'webhook_url', + label: 'כתובת לעדכון על שינויים (Webhook)', + description: 'בכל פרסום, עריכה או מחיקה של הודעה נשלחת בקשה לכתובת הזו. חייבת להתחיל ב-http:// או https://. ריק — לא נשלח דבר.', + type: 'url', + placeholder: 'https://example.com/webhook', + }, + { + key: 'webhook_verify_token', + label: 'מחרוזת אימות סודית לכתובת העדכון', + description: 'מצורפת לכל בקשה שנשלחת לכתובת העדכון, כדי שהשרת שמקבל אותה יוכל לוודא שהיא באמת מכאן. מומלץ כשיש כתובת עדכון; מעתיקים את אותו ערך גם לצד השני.', + generate: true, + type: 'password', + placeholder: 'your-secret-token', + sensitive: true, + }, + ], + }, +]; + +// The single boolean parser for stored setting values — the backend accepts +// booleans, numbers and strings, so every reader must handle all three. +export function toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v !== 0; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; +} + +// Keys that used to be stored on the channel but are no longer part of the form. +// They are still recognised so they are not shown as raw "extra settings", and +// they are dropped from the payload on the next save. +export const LEGACY_SETTING_KEYS = [ + // Moved to a per-user localStorage preference — the server never read it. + 'enter_sends_message', + // Saved by an old owner UI but consumed by nothing on the backend. + 'magnet_api_key', + // Managed by the super-admin channel-features screen, not channel settings. + 'require_auth', + 'require_auth_for_view_files', + 'count_views', + 'contact_us', +]; + +// Keys owned by the magnet-ads tab. They live on the same channel settings list, +// so the settings tab used to show them as raw "advanced" rows — including the +// whole ` + + `${snippet}` + + ``; + } + + private handleMessage(event: MessageEvent): void { + // Only this slot's own frame may size it. The frame's origin is "null", + // so the source window is the only reliable identity. + if (!this.frame || event.source !== this.frame.contentWindow) return; + const data = event.data; + if (!data || data.type !== AD_SIZE_MESSAGE) return; + const height = Number(data.height); + if (!Number.isFinite(height) || height < 0) return; + + const hadContent = !!this.reportedHeight; + this.reportedHeight = height; + if (height === 0 && data[NO_AD_FLAG] === true) { + // Magnet said there is no ad and the frame shows nothing else: hide + // now rather than after the grace period. The frame stays laid out, + // so an ad that arrives on the widget's next poll reopens the slot. + if (this.collapseTimer) clearTimeout(this.collapseTimer); + this.collapseTimer = undefined; + if (!this.collapsed) this.zone.run(() => { this.collapsed = true; }); + return; + } + if (height > 0) { + // Cap what an ad may grow to: the slot sits inside the chat, not over it. + this.frame.style.height = `${Math.min(height, 1200)}px`; + if (this.collapsed) { + // The ad arrived after the clock hid the slot: show it again. + this.zone.run(() => { this.collapsed = false; }); + } + } else if (hadContent) { + // The ad emptied itself (no campaign, closed by the viewer). Give it + // the same grace as at start before hiding the slot, since a script + // replacing its content can pass through zero on the way. + this.armCollapse(EMPTY_SLOT_GRACE_MS); + } + } + + private collapseIfEmpty(): void { + // No report yet, or a reported height of zero: the snippet rendered + // nothing visible, so the slot is hidden instead of leaving a gap. The + // frame stays alive and laid out (see the collapsed style), so an ad + // that renders later can reopen the slot with its size report. + if (!this.reportedHeight && !this.collapsed) { + this.zone.run(() => { this.collapsed = true; }); + } + } + + /** Nothing to render at all: hide the slot and drop the frame. */ + private collapse(): void { + this.collapsed = true; + window.removeEventListener('message', this.onMessage); + this.frame = undefined; + const host = this.hostRef.nativeElement; + host.innerHTML = ''; + } +} diff --git a/frontend/src/app/components/channel/chat/message/message.component.html b/frontend/src/app/components/channel/chat/message/message.component.html index 0f4a7b68..e017032a 100644 --- a/frontend/src/app/components/channel/chat/message/message.component.html +++ b/frontend/src/app/components/channel/chat/message/message.component.html @@ -1,96 +1,138 @@ @if (message && message.text) { -
-
- -
-
-
- {{_authService.userInfo?.privileges?.['admin'] ? message.author : - chatService.channelInfo?.name - }} - {{ message.timestamp | messageTime }} - @if (isEdited(message)) { - - נערכה {{ message.last_edit | messageTime }} - - } - @if (message.deleted) { -
- -
- } - @if (_authService.userInfo?.privileges?.['writer']) { - +
+
+
+ +
+ + +
+ + @if (message.views || canOfferReactions || (message.reactions && chatService.reactionsEnabled)) { +
+
+ @if (message.reactions && chatService.reactionsEnabled) { + @for (reaction of message.reactions | keyvalue; track reaction.key) { + + } } - @if (_authService.userInfo) { - + @if (canOfferReactions) { + } -
-
-
-
- - -
-
- @if (message.views) { -
- {{ message.views }} - -
- } -
- @if (message.reactions) { -
- @for (reaction of message.reactions | keyvalue; track reaction) { -
- {{ reaction.key }} - {{ reaction.value }} -
- } -
+ @if (message.views) { + + + {{ message.views }} + + } + + + @if (emojiOpen) { +
+ @for (react of reacts; track react) { + }
-
-
-
+ } + + } + } - -
- @for (react of reacts; track react) { - + + + - \ No newline at end of file + diff --git a/frontend/src/app/components/channel/chat/message/message.component.scss b/frontend/src/app/components/channel/chat/message/message.component.scss index 331f0176..40644393 100644 --- a/frontend/src/app/components/channel/chat/message/message.component.scss +++ b/frontend/src/app/components/channel/chat/message/message.component.scss @@ -1,139 +1,508 @@ -.message-card { - max-width: 80vw; +:host { + display: block; + width: 100%; + min-width: 0; } -nb-icon { - cursor: pointer; +// The feed marks a message it jumped to by adding this class to the host. +:host(.mark_message) .msg { + box-shadow: 0 0 0 2px var(--color-primary-500), var(--card-shadow); } -.icon-button { - padding: initial !important; +.msg { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + border-radius: 1.1rem; + border: 1px solid var(--border-basic-color-2); + background: var(--background-basic-color-1); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: var(--card-shadow); + } } -.message-card:hover .option-mask { - opacity: 1; - transition: opacity ease-in 0.1s; +.msg--dim { + opacity: 0.6; } -::ng-deep { - .menu-item { - .menu-icon { - margin: 0 0 0 0.5rem !important; - } +.msg--ad { + border-color: var(--color-warning-300); +} + +/* ---------- header ---------- */ + +.msg__head { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.6rem 0.9rem 0; +} + +.msg__avatar { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + overflow: hidden; + background: var(--color-primary-100); + color: var(--color-primary-700); + font-weight: 800; + font-size: 0.8rem; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; } +} + +// Name and time on one line; the tags wrap after them on a narrow screen. +.msg__who { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.5rem; + line-height: 1.3; +} + +.msg__author { + font-weight: 700; + font-size: 0.9rem; + color: var(--text-basic-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.msg__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + color: var(--text-hint-color); +} + +.msg__tag { + padding: 0.05rem 0.45rem; + border-radius: 999px; + background: var(--background-basic-color-3); + color: var(--text-hint-color); + font-weight: 600; + white-space: nowrap; +} - markdown img { - border-radius: 8px; +.msg__tag--warning { + background: var(--color-warning-100); + color: var(--color-warning-800); +} + +.msg__tag--danger { + background: var(--color-danger-100); + color: var(--color-danger-700); +} + +.msg__restore { + flex: 0 0 auto; + white-space: nowrap; +} + +.msg__kebab { + flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + padding: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; + margin-inline-end: -0.5rem; + // Taller than the name line; the negative block margin keeps it from + // pushing the text down. + margin-block: -0.5rem; + color: var(--text-hint-color) !important; + transition: opacity 0.15s ease; + + nb-icon { + font-size: 1.2rem; + } +} + +// With a mouse the ⋮ appears on hover or keyboard focus; on a touch screen +// (no hover) it stays visible, since there is nothing to hover. +@media (hover: hover) { + .msg__kebab { + opacity: 0; + } + + .msg:hover .msg__kebab, + .msg:focus-within .msg__kebab, + .msg__kebab--open { + opacity: 1; + } +} + +/* ---------- a follow-up by the same author ---------- */ + +// Each flash stays its own card with its time on top; only the repeated +// avatar and name go. The name stays in the DOM for screen readers. +.msg--cont { + .msg__avatar { + display: none; + } + + .msg__author { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } +} + +/* ---------- body ---------- */ + +.msg__body { + padding: 0.35rem 0.9rem 0.45rem; + min-width: 0; + overflow-wrap: anywhere; +} + +// What the markdown renderer emits; scoped to this host (the old global +// ::ng-deep reached every menu on the site). +:host ::ng-deep markdown { + display: block; + color: var(--text-basic-color); + + p:last-child { + margin-bottom: 0; + } + + a { + color: var(--color-primary-600); + overflow-wrap: anywhere; + } + + img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius); cursor: pointer; } - markdown blockquote { - border-right: 4px solid gray; - background: rgb(128 128 128 / 9%); - border-radius: 8px; + video, + audio { + max-width: 100%; + } + + blockquote { + margin: 0.5rem 0; + padding: 0.5rem 0.9rem; + border-inline-start: 4px solid var(--color-basic-500); + border-radius: var(--border-radius); + background: var(--background-basic-color-2); + color: var(--text-basic-color); font-style: italic; - padding: 0.5rem 1rem 0.1rem 0.5rem; + + p:last-child { + margin-bottom: 0; + } } - markdown .quote { - border-color: #6a6fff; + // A quoted channel message: clickable, jumps to the original. + .quote { + border-inline-start-color: var(--color-primary-500); cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.15s ease; + + &:hover { + box-shadow: var(--shadow); + transform: translateY(-2px); + } } - markdown .quote:hover { - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.12); - transform: translateY(-5px); + :not(pre) > code { + padding: 0.1em 0.4em; + border-radius: 0.35em; + background: var(--background-basic-color-3); + color: var(--color-primary-700); + direction: ltr; + unicode-bidi: embed; + overflow-wrap: anywhere; } - i { - cursor: pointer; + pre { + direction: ltr; + text-align: left; + border-radius: var(--border-radius); + overflow-x: auto; } - .mark_message { - border: 2px solid #6a6fff; + // Sizing of the media embeds the renderers in markdown.config.ts emit. It was + // inline style on those elements; the feed sanitizer now strips every style + // attribute (a writer's raw-HTML style could overlay the whole page), so the + // renderers' own styling has to be classes — the ones on its allow-list. + .embed-box { + max-width: 300px; + height: auto; } -} -.emojis-section { - border-top: 1px solid rgba(102, 111, 255, 0.15); - padding: 6px 8px 4px 8px; - margin-top: 4px; - background: rgba(255, 255, 255, 0.3); - border-radius: 0 0 10px 10px; + .embed-video { + width: 100%; + height: auto; + } - .emoji-reaction { - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(102, 111, 255, 0.25); - border-radius: 12px; - padding: 6px 6px; - margin: 1px 2px; - display: inline-flex; - align-items: center; - gap: 3px; + .embed-youtube { + position: relative; + display: inline-block; cursor: pointer; - transition: all 0.15s ease; - font-size: 14px; - line-height: 1; + } - &:hover { - background: rgba(102, 111, 255, 0.15); - border-color: rgba(102, 111, 255, 0.4); - transform: translateY(-1px); + // A YouTube play badge drawn in CSS (red rounded rect, white triangle); the + // icon font that used to draw it is no longer loaded. + .embed-youtube-icon { + position: absolute; + inset: 0; + margin: auto; + width: 68px; + height: 48px; + cursor: pointer; + + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + background: #f00; + opacity: 0.92; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); } - .emoji { - font-size: 16px; + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + transform: translate(-40%, -50%); + border-style: solid; + border-width: 10px 0 10px 18px; + border-color: transparent transparent transparent #fff; } + } - .count { - color: #555; - font-weight: 500; - font-size: 11px; - min-width: 12px; - text-align: center; + // Wraps each picture/video an anonymous viewer may not see, under the + // "log in to view" overlay. The minimum size keeps the overlay readable + // over an image that never loads (the server answers 401) and so has no + // box of its own. + .auth-overlay-wrapper { + position: relative; + display: inline-block; + max-width: 100%; + min-width: 200px; + min-height: 120px; + } + + .auth-overlay { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius); + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + color: #fff; + font-size: 14px; + text-align: center; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; } } } -::ng-deep { - .emoji-picker-popover { - .popover-body { - padding: 6px !important; - } +/* ---------- footer: reactions & views ---------- */ + +.msg__foot { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0.75rem 0.6rem; +} + +.msg--dim .msg__reactions { + pointer-events: none; +} + +.msg__reactions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; + min-width: 0; +} + +.react { + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-height: 2.25rem; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--border-basic-color-4); + background: var(--background-basic-color-2); + color: var(--text-basic-color); + font-size: 0.95rem; + line-height: 1; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; + + &:hover { + background: var(--color-primary-transparent-100); + border-color: var(--color-primary-300); + } + + &:active { + transform: scale(0.96); + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 1px; + } +} + +.react--on { + background: var(--color-primary-100); + border-color: var(--color-primary-300); +} + +.react__count { + font-size: 0.78rem; + font-weight: 700; + color: var(--color-primary-700); +} + +.react--add { + color: var(--text-hint-color); + + nb-icon { + font-size: 1.15rem; } +} - ngb-popover-window { - max-width: 50% !important; - //width: 50% !important; +.react__plus { + font-size: 0.8rem; + font-weight: 800; +} + +.msg__views { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 0 0 auto; + font-size: 0.8rem; + color: var(--text-hint-color); + + nb-icon { + font-size: 1rem; } } -.emoji-picker-menu { +.react-picker { + position: absolute; + bottom: calc(100% - 0.25rem); + inset-inline: 0.5rem; + z-index: 3; display: flex; flex-wrap: wrap; - gap: 2px; - - button.emoji-option { - border: none; - background: transparent; - border-radius: 4px; - padding: 4px; - transition: background-color 0.1s ease; - font-size: 16px; - line-height: 1; - cursor: pointer; + gap: 0.15rem; + padding: 0.4rem; + border-radius: var(--border-radius); + border: 1px solid var(--border-basic-color-3); + background: var(--background-basic-color-1); + box-shadow: var(--shadow); +} - &:hover { - background: #f0f0f0; - } +.react-picker__option { + width: 2.4rem; + height: 2.4rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + font-size: 1.3rem; + line-height: 1; + cursor: pointer; - &:active { - background: #e0e0e0; - } + &:hover, + &:focus-visible { + background: var(--color-primary-transparent-100); + outline: none; + } + + &:active { + background: var(--color-primary-transparent-200); } } -.message-card[style*="opacity: 0.5"] .emojis-section { - opacity: 0.6; - pointer-events: none; -} \ No newline at end of file +/* ---------- kebab menu (rendered in a popover) ---------- */ + +.msg-actions { + display: flex; + flex-direction: column; + min-width: 11rem; + margin: -0.25rem -0.5rem; +} + +.msg-actions__item { + display: flex; + align-items: center; + gap: 0.6rem; + min-height: 2.6rem; + padding: 0.4rem 0.75rem; + border: 0; + border-radius: var(--border-radius); + background: transparent; + color: var(--text-basic-color); + font: inherit; + font-size: 0.95rem; + text-align: start; + cursor: pointer; + + nb-icon { + font-size: 1.15rem; + color: var(--text-hint-color); + } + + &:hover, + &:focus-visible { + background: var(--color-basic-transparent-100); + outline: none; + } +} + +.msg-actions__item--danger { + color: var(--color-danger-600); + + nb-icon { + color: inherit; + } +} diff --git a/frontend/src/app/components/channel/chat/message/message.component.ts b/frontend/src/app/components/channel/chat/message/message.component.ts index 3797e121..05592380 100644 --- a/frontend/src/app/components/channel/chat/message/message.component.ts +++ b/frontend/src/app/components/channel/chat/message/message.component.ts @@ -1,36 +1,59 @@ -import { AfterViewInit, Component, ElementRef, HostListener, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { CommonModule } from "@angular/common"; +import { AfterViewInit, Component, ElementRef, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { KeyValuePipe } from "@angular/common"; import { NbButtonModule, - NbCardModule, NbChatModule, NbDialogService, NbIconModule, + NbPopoverDirective, NbPopoverModule, NbPosition, - NbToastrService, NbUserModule + NbToastrService, + NbTooltipModule } from "@nebular/theme"; import { MarkdownComponent } from "ngx-markdown"; -import Viewer from 'viewerjs'; -import { YoutubePlayerComponent } from '../youtube-player/youtube-player.component'; -import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; -import { MessageTimePipe } from '../../../../pipes/message-time.pipe'; +import type Viewer from 'viewerjs'; +import { MessageTimePipe, formatFullDateTime } from '../../../../pipes/message-time.pipe'; import { ChatMessage, ChatService } from '../../../../services/chat.service'; import { AdminService } from '../../../../services/admin.service'; import { AuthService } from '../../../../services/auth.service'; +import { SlugService } from '../../../../services/slug.service'; +import { ConfirmService } from '../../../../services/confirm.service'; +import { ShareService } from '../../../../services/share.service'; import { ReportComponent } from './report/report.component'; +import { EMBED_PREFIX_REGEX } from '../../../../markdown.config'; + +// One window scroll listener shared by every card on the page. Each card used +// to register its own (plus its own timer), so a flick over a long feed ran +// dozens of handlers and dozens of change-detection passes per scroll event, +// all to keep the hover-opened emoji row shut while the page moves. +const scrollState = { scrolling: false, timer: undefined as any, refs: 0 }; +const onWindowScroll = () => { + scrollState.scrolling = true; + clearTimeout(scrollState.timer); + scrollState.timer = setTimeout(() => { scrollState.scrolling = false; }, 150); +}; +function acquireScrollTracking(zone: NgZone) { + if (scrollState.refs++ > 0) return; + // Outside the zone: the flag is only read on hover, nothing needs repainting. + zone.runOutsideAngular(() => window.addEventListener('scroll', onWindowScroll, { capture: true, passive: true })); +} +function releaseScrollTracking() { + if (--scrollState.refs > 0) return; + window.removeEventListener('scroll', onWindowScroll, true); + clearTimeout(scrollState.timer); + scrollState.scrolling = false; +} + @Component({ selector: 'app-message', imports: [ - CommonModule, - NbCardModule, + KeyValuePipe, NbIconModule, NbButtonModule, + NbPopoverModule, + NbTooltipModule, MessageTimePipe, MarkdownComponent, - NbPopoverModule, - NgbPopoverModule, - NbChatModule, - NbUserModule ], templateUrl: './message.component.html', styleUrl: './message.component.scss' @@ -48,7 +71,14 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { @Input() indexId: number | undefined; - @ViewChild(NgbPopover) popover!: NgbPopover; + /** Follows a message by the same author: the card drops the repeated name and avatar, keeping its time on top. */ + @Input() + continued: boolean = false; + + /** The ⋮ menu is open; keeps the button visible while focus is in the overlay. */ + actionsOpen = false; + + @ViewChild('actionsMenu') actionsMenu?: NbPopoverDirective; @ViewChild('media') mediaContainer!: ElementRef; private viewer: Viewer | null = null; @@ -60,79 +90,138 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { protected chatService: ChatService, private toastrService: NbToastrService, public _authService: AuthService, + private slugService: SlugService, + private confirm: ConfirmService, + private share: ShareService, + private host: ElementRef, + private zone: NgZone, ) { } reacts: string[] = []; + /** The inline emoji row under the card is open. */ + emojiOpen = false; private closeEmojiMenuTimeout: any; - private isScrolling = false; private hoverTimer: any; + private get isScrolling(): boolean { return scrollState.scrolling; } private readonly minimalHoverMs = 200; - private readonly matchFindCustomEmbedReg = /^\[(video|audio|image|quote)-embedded#].*/; + // Shared with the renderer so the two can never disagree about what an embed + // token looks like — a private copy here missed the optional size suffix. + private readonly matchFindCustomEmbedReg = EMBED_PREFIX_REGEX; + + private get channelRole(): string | undefined { + return this._authService.userInfo?.channelRoles?.[this.slugService.slug]; + } + + get canWrite(): boolean { + const user = this._authService.userInfo; + if (!user) return false; + if (user.globalRole === 'super_admin') return true; + const role = this.channelRole; + return role === 'owner' || role === 'moderator' || role === 'writer'; + } + + get canModerate(): boolean { + const user = this._authService.userInfo; + if (!user) return false; + if (user.globalRole === 'super_admin') return true; + const role = this.channelRole; + return role === 'owner' || role === 'moderator'; + } + + /** Whether the card shows the "add reaction" chip at all. */ + get canOfferReactions(): boolean { + return this.chatService.reactionsEnabled && this.reacts.length > 0 + && !this.isSchedulingMessage && !this.message?.is_ads; + } + + /** Any menu entry at all? Readers who may only copy the link still get the menu. */ + get hasActions(): boolean { + return !this.isSchedulingMessage || this.canModify(this.message!); + } + + /** Count of all reactions, for the hidden-text summary. */ + get reactionCount(): number { + const r = this.message?.reactions; + return r ? Object.values(r).reduce((a, b) => a + (b || 0), 0) : 0; + } + + /** The exact moment behind the relative label ("אתמול 14:05"), for the tooltip. */ + get fullTime(): string { + const d = this.message?.timestamp ? new Date(this.message.timestamp as any) : null; + return d && !isNaN(d.getTime()) ? formatFullDateTime(d) : ''; + } + + get isoTime(): string | null { + const d = this.message?.timestamp ? new Date(this.message.timestamp as any) : null; + return d && !isNaN(d.getTime()) ? d.toISOString() : null; + } + + /** The OS share sheet exists (phones); shown as one more way to pass a post on. */ + get canShareSheet(): boolean { + return this.share.canShare; + } ngOnInit() { - if (this.isSchedulingMessage && this.message) { - this.message.id = this.indexId; - } this.chatService.getEmojisList() .then(emojis => this.reacts = emojis) .catch(() => this.toastrService.danger('', 'שגיאה בהגדרת אימוגים')); - window.addEventListener('scroll', this.onScroll, true); + acquireScrollTracking(this.zone); } ngOnDestroy() { - window.removeEventListener('scroll', this.onScroll, true); + releaseScrollTracking(); this.cancelEmojiMenuClose(); this.clearHoverTimer(); + this.unlistenOutside(); if (this.viewer) { this.viewer.destroy(); this.viewer = null; } } - private scrollTimeout: any; - - onScroll = () => { - this.isScrolling = true; - if (this.scrollTimeout) { - clearTimeout(this.scrollTimeout); - } - this.scrollTimeout = setTimeout(() => { - this.isScrolling = false; - this.scrollTimeout = undefined; - }, 150) - }; - ngAfterViewInit(): void { - setTimeout(() => { + // Decide the auth-overlay from resolved state instead of racing a fixed + // timer against the channel-info and user-info requests. + Promise.all([ + this.chatService.ensureChannelInfo().catch(() => null), + this._authService.loadUserInfo().catch(() => null), + ]).then(() => { + if (!this.chatService.channelInfo?.require_auth_for_view_files || this._authService.userInfo) return; const media = this.mediaContainer?.nativeElement.querySelectorAll('img, video'); media?.forEach((item: HTMLMediaElement) => { - if (this.chatService.channelInfo?.require_auth_for_view_files && !this._authService.userInfo) { + { + // Sized by CSS (.auth-overlay-wrapper), not copied from the media + // element: an image the server refuses (401) never loads, and legacy + // ones carry no height attribute, so both measured 0px here and the + // call-to-action collapsed to nothing — a broken picture and no hint + // that logging in would fix it. const wrapper = document.createElement('div'); - wrapper.style.position = 'relative'; - wrapper.style.display = 'inline-block'; - wrapper.style.width = item.offsetWidth + 'px'; - wrapper.style.height = item.offsetHeight + 'px'; + wrapper.className = 'auth-overlay-wrapper'; const overlay = document.createElement('div'); - overlay.style.position = 'absolute'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100%'; - overlay.style.height = '100%'; - overlay.style.background = 'rgba(0,0,0,0.5)'; - overlay.style.backdropFilter = 'blur(4px)'; - overlay.style.display = 'flex'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.color = 'white'; - overlay.style.fontSize = '14px'; - overlay.style.cursor = 'pointer'; - overlay.style.zIndex = '1'; - overlay.innerHTML = '
יש להתחבר כדי לצפות בקבצים
לחצו כאן להתחברות
'; - - overlay.addEventListener('click', () => { + overlay.className = 'auth-overlay'; + overlay.setAttribute('role', 'button'); + overlay.setAttribute('tabindex', '0'); + overlay.innerHTML = '
יש להתחבר כדי לצפות בקבצים
לחצו כאן להתחברות
'; + + const login = () => { + // Same as the header's login button: the login page returns to + // returnUrl, and with none recorded a reader who signed in from + // here landed on /channel (my-channel / onboarding) instead of + // the feed they were reading. Written here, not in + // loginWithGoogle(), so the /login page's own button does not + // overwrite the guard's returnUrl with '/login'. + try { + localStorage.setItem('returnUrl', location.pathname + location.search + location.hash); + } catch { + // Storage unavailable — the login page falls back to /channel. + } this._authService.loginWithGoogle(); + }; + overlay.addEventListener('click', login); + overlay.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); login(); } }); const parent = item.parentElement; @@ -143,30 +232,117 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { } } }); - }, 1000); + }); + } + + // Mirrors backend canModifyMessage: moderators+ on anything, writers only on + // their own posts or system posts (authorId "" / "0"). + canModify(message: ChatMessage): boolean { + if (this.canModerate) return true; + if (!this.canWrite) return false; + const a = message.authorId; + return a === '' || a === '0' || a === undefined || a === this._authService.userInfo?.id; + } + + /** Closes the kebab menu before an action runs, so the overlay never lingers. */ + private closeActions() { + this.actionsMenu?.hide(); + } + + /** + * The popover renders into the overlay container at the end of the page and + * manages no focus of its own, so after Enter on the kebab, Tab would have + * carried on through the feed and the actions were mouse-only. Focus goes + * into the menu when it opens and back to the kebab when it closes — unless + * the user has already clicked somewhere else, which must not be undone. + */ + onActionsState(shown: boolean) { + this.actionsOpen = shown; + if (shown) { + setTimeout(() => document.querySelector('.msg-actions .msg-actions__item')?.focus()); + return; + } + const active = document.activeElement; + if (!active || active === document.body || active.closest('.msg-actions')) { + this.host.nativeElement.querySelector('.msg__kebab')?.focus(); + } + } + + onActionsKeydown(event: KeyboardEvent) { + const menu = event.currentTarget as HTMLElement; + const items = Array.from(menu.querySelectorAll('.msg-actions__item')); + const at = items.indexOf(document.activeElement as HTMLElement); + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + items[(at + step + items.length) % items.length]?.focus(); + break; + } + case 'Home': + case 'End': + event.preventDefault(); + items[event.key === 'Home' ? 0 : items.length - 1]?.focus(); + break; + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.closeActions(); + break; + case 'Tab': + // Tab leaves the menu; close it rather than leave an open overlay behind. + this.closeActions(); + break; + } } editMessage(message: ChatMessage) { + this.closeActions(); + // A scheduled entry is handed over as the object itself: the service finds + // it again in the server's list by time and text (locateScheduled), so + // nothing is stamped on it. The index it used to carry went stale as soon + // as the list was reloaded behind the open editor (every SSE reconnect + // does that) and then addressed a neighbour. this._adminService.setEditMessage({ message, isScheduling: this.isSchedulingMessage }); } - deleteMessage(message: ChatMessage) { - const confirm = window.confirm('האם אתה בטוח שברצונך למחוק את ההודעה?'); - if (confirm) { - if (this.isSchedulingMessage) { - this._adminService.deleteScheduledMessage(message.id); - return; - } - this._adminService.deleteMessage(message.id).subscribe(); + async deleteMessage(message: ChatMessage) { + this.closeActions(); + const ok = await this.confirm.ask({ + title: this.isSchedulingMessage ? 'למחוק את ההודעה המתוזמנת?' : 'למחוק את ההודעה?', + message: this.isSchedulingMessage + ? 'ההודעה תימחק מרשימת ההודעות המתוזמנות ולא תתפרסם.' + : 'ההודעה תיעלם לקוראים. אפשר לשחזר אותה מהתפריט.', + status: 'danger', + confirmLabel: 'מחיקה', + }); + if (!ok) return; + if (this.isSchedulingMessage) { + // The service commits a new list only after the server accepted it, + // so the feed's copy has to be refreshed from it; it no longer shares + // the array that used to be spliced in place. + this._adminService.deleteScheduledMessage(message) + .then(() => this._adminService.reloadSchedulingMessage()) + .catch(() => this.toastrService.danger('', 'שגיאה במחיקת ההודעה')); + return; } + this._adminService.deleteMessage(message.id).subscribe({ + next: (res) => { + if (!res?.success) this.toastrService.danger('', 'שגיאה במחיקת ההודעה'); + }, + error: () => this.toastrService.danger('', 'שגיאה במחיקת ההודעה'), + }); } openReportDialog(messageId?: number) { + this.closeActions(); if (this.isSchedulingMessage) return; this.dialogService.open(ReportComponent, { closeOnBackdropClick: true, context: { messageId } }); } quoteMessage(message: ChatMessage) { + this.closeActions(); if (this.isSchedulingMessage) return; let newMsgText: string | undefined = message.text?.trimStart(); let fintEmbedded = newMsgText?.match(this.matchFindCustomEmbedReg); @@ -186,7 +362,12 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { break; } } - newMsgText = newMsgText?.slice(0, 100).replace('>', '').replaceAll(/\n/g, ' ').replaceAll('*', ''); + // Parentheses go too: the quote token is `[quote-embedded#](id@text)`, and + // a ')' inside the text closed it early in the tokenizer, leaking the rest + // of the quote into the message as plain text. + // A markdown link collapses to its label first: the parenthesis strip + // below, or the 100-char cut, otherwise left half a link in the quote. + newMsgText = newMsgText?.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').slice(0, 100).replaceAll('>', '').replaceAll(/\n/g, ' ').replaceAll('*', '').replaceAll(/[()]/g, ''); if (message.text && message.text.length > 100) { newMsgText += '...'; } @@ -198,17 +379,21 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { } this._adminService.setEditMessage({ new: true, message: newMessage, isScheduling: this.isSchedulingMessage }); } else { - m.message.text = `[quote-embedded#](${message.id}@${newMsgText})\n${m.message.text}`; - this._adminService.setEditMessage(m); + // The composer prepends it to the text on screen; the entry itself is + // left as the server knows it (see EditMsg.prepend). + this._adminService.setEditMessage({ ...m, prepend: `[quote-embedded#](${message.id}@${newMsgText})\n` }); } } - viewLargeImage(event: MouseEvent) { + async viewLargeImage(event: MouseEvent) { const target = event.target as HTMLElement; if (target.tagName === 'IMG' || target.tagName === 'I') { const youtubeId = target.getAttribute('youtubeid'); if (youtubeId) { + // The player (and @angular/youtube-player behind it) is fetched on the + // first click only; most readers never open a video. + const { YoutubePlayerComponent } = await import('../youtube-player/youtube-player.component'); this.dialogService.open(YoutubePlayerComponent, { closeOnBackdropClick: true, context: { videoId: youtubeId } }) return; } @@ -216,11 +401,13 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { if (this.target === target && this.viewer) { this.viewer.show(); } else { + // Same for the image viewer: loaded when a picture is first tapped. + const { default: ViewerCtor } = await import('viewerjs'); if (this.viewer) { this.viewer.destroy(); this.viewer = null; } - this.viewer = new Viewer(target, { + this.viewer = new ViewerCtor(target, { toolbar: false, transition: true, navbar: false, @@ -235,28 +422,66 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { setReact(id: number | undefined, react: string) { if (this.isSchedulingMessage) return; if (!this._authService.userInfo) { - this.toastrService.danger('', "יש להתחבר לחשבון בכדי להוסיף אימוג'ים"); + this.toastrService.danger('', "כדי להגיב באימוג'י יש להתחבר לחשבון"); return; } if (id && react) this.chatService.setReact(id, react).catch(() => this.toastrService.danger('', "הייתה בעיה, נסו שנית.")); } + /** Tap on the "add reaction" chip: open or close the emoji row. */ + toggleEmojiMenu() { + this.clearHoverTimer(); + if (this.emojiOpen) { + this.closeEmojiMenu(); + return; + } + if (!this._authService.userInfo) { + this.toastrService.danger('', "כדי להגיב באימוג'י יש להתחבר לחשבון"); + return; + } + this.openEmojiMenu(); + } + + /** Desktop convenience: hovering the chip opens the row after a short delay. */ showEmojiMenu() { if (!this._authService.userInfo || this.isScrolling || this.message?.is_ads || this.isSchedulingMessage) return; + // The channel operator can switch reactions off; the backend answers 403. + if (!this.chatService.reactionsEnabled) return; this.clearHoverTimer(); this.hoverTimer = setTimeout(() => { if (!this.isScrolling) { this.cancelEmojiMenuClose(); - this.popover.open(); + this.openEmojiMenu(); } }, this.minimalHoverMs); } + private openEmojiMenu() { + if (!this.canOfferReactions) return; + this.emojiOpen = true; + this.listenOutside(); + } + + private closeEmojiMenu() { + this.emojiOpen = false; + this.unlistenOutside(); + } + + /** Escape anywhere on the card shuts the emoji row, like any other popup. */ + onEscape() { + if (this.emojiOpen) this.closeEmojiMenu(); + } + + pickReact(react: string) { + this.setReact(this.message?.id, react); + this.closeEmojiMenu(); + } + scheduleEmojiMenuClose() { this.clearHoverTimer(); this.closeEmojiMenuTimeout = setTimeout(() => { - this.popover.close(); + this.closeEmojiMenu(); }, 150); } @@ -275,19 +500,61 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { } } + // A document listener only while the row is open (one per open row, not one + // per message on the page): a tap anywhere outside this card closes it. + private outsideListener?: (e: Event) => void; + + private listenOutside() { + if (this.outsideListener) return; + this.outsideListener = (e: Event) => { + if (!this.host.nativeElement.contains(e.target as Node)) this.closeEmojiMenu(); + }; + // Deferred so the click that opened the row is not the one that closes it. + setTimeout(() => { + if (this.outsideListener) document.addEventListener('click', this.outsideListener, true); + }); + } + + private unlistenOutside() { + if (this.outsideListener) { + document.removeEventListener('click', this.outsideListener, true); + this.outsideListener = undefined; + } + } isEdited(message: ChatMessage): boolean { if (!message.last_edit) return false; - const date = new Date(message.last_edit).getFullYear(); - if (isNaN(date)) return false; - return date !== 1; + // "Never edited" arrives as Go's zero time, 0001-01-01T00:00:00Z. Judged + // by the *local* year that is year 0 anywhere west of UTC, so every + // unedited message read "נערכה 31/12/0000" for viewers in the Americas. + // The zero time sits ~62 billion seconds below the epoch, so an epoch + // test needs no timezone — and NaN (an unparsable value) fails it too. + return new Date(message.last_edit).getTime() > 0; } - copyLink(messageId?: number) { + private messageUrl(messageId: number): string { + // The post's own page, which search engines and link previews read; + // old /channel/# links still open the post too. + return `${window.location.origin}/channel/${this.slugService.slug}/post/${messageId}`; + } + + async copyLink(messageId?: number) { + this.closeActions(); if (!messageId || this.isSchedulingMessage) return; - const url = `${window.location.origin}/#${messageId}`; - navigator.clipboard.writeText(url).then(() => { - this.toastrService.success('', 'הקישור הועתק ללוח'); - }); + // ShareService explains a blocked clipboard itself. + if (await this.share.copy(this.messageUrl(messageId))) { + this.toastrService.success('', 'הקישור להודעה הועתק'); + } + } + + /** The OS share sheet with the post's link; falls back to the clipboard. */ + async shareMessage(messageId?: number) { + this.closeActions(); + if (!messageId || this.isSchedulingMessage) return; + const url = this.messageUrl(messageId); + const outcome = await this.share.share({ title: this.chatService.channelInfo?.name || '', url }); + if (outcome === 'unsupported' && await this.share.copy(url)) { + this.toastrService.success('', 'הקישור להודעה הועתק'); + } } } diff --git a/frontend/src/app/components/channel/chat/message/report/report.component.html b/frontend/src/app/components/channel/chat/message/report/report.component.html index 88972f89..03761901 100644 --- a/frontend/src/app/components/channel/chat/message/report/report.component.html +++ b/frontend/src/app/components/channel/chat/message/report/report.component.html @@ -1,14 +1,24 @@ - - דיווח על הודעה #{{ messageId }} - - - שימו לב!
- חובה לציין את הסיבה לדיווח בכדי שנוכל לטפל בדיווח. -
- -
- - - - -
\ No newline at end of file + + +
+

דיווח על הודעה

+

הדיווח מגיע למנהלי הערוץ בלבד, והם יחליטו מה לעשות. כתבו בקצרה מה הבעיה.

+
+ +
+ + + +
{{ reason.length }} / {{ maxLength }}
+
+ + + + +
diff --git a/frontend/src/app/components/channel/chat/message/report/report.component.scss b/frontend/src/app/components/channel/chat/message/report/report.component.scss index e69de29b..3e07f89e 100644 --- a/frontend/src/app/components/channel/chat/message/report/report.component.scss +++ b/frontend/src/app/components/channel/chat/message/report/report.component.scss @@ -0,0 +1,57 @@ +.report { + width: min(92vw, 440px); + margin: 0; +} + +.report__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.report__title { + margin: 0; + font-size: 1.15rem; + font-weight: 800; + color: var(--text-basic-color); +} + +.report__sub { + margin: 0.25rem 0 0; + font-size: 0.85rem; + font-weight: 400; + line-height: 1.5; + color: var(--text-hint-color); +} + +.report__label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-basic-color); +} + +.report__req { + font-weight: 400; + color: var(--text-hint-color); +} + +.report__counter { + margin-top: 0.25rem; + text-align: end; + font-size: 0.75rem; + color: var(--text-hint-color); +} + +.report__foot { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; +} + +textarea { + resize: vertical; +} diff --git a/frontend/src/app/components/channel/chat/message/report/report.component.ts b/frontend/src/app/components/channel/chat/message/report/report.component.ts index 749f1d84..d79a5d31 100644 --- a/frontend/src/app/components/channel/chat/message/report/report.component.ts +++ b/frontend/src/app/components/channel/chat/message/report/report.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { NbDialogRef, NbCardModule, NbButtonModule, NbInputModule, NbToastrService } from '@nebular/theme'; +import { + NbDialogRef, NbCardModule, NbButtonModule, NbIconModule, NbInputModule, NbToastrService, NbTooltipModule, +} from '@nebular/theme'; import { ChatService } from '../../../../../services/chat.service'; @Component({ @@ -9,6 +11,8 @@ import { ChatService } from '../../../../../services/chat.service'; NbCardModule, NbButtonModule, NbInputModule, + NbIconModule, + NbTooltipModule, FormsModule ], templateUrl: './report.component.html', @@ -17,6 +21,9 @@ import { ChatService } from '../../../../../services/chat.service'; export class ReportComponent implements OnInit{ messageId: number | undefined; reason: string = ''; + sending = false; + // The Hebrew ceiling; see reportErrorMessage for the server's byte limit. + readonly maxLength = 250; constructor( public dialogRef: NbDialogRef, @@ -29,15 +36,34 @@ export class ReportComponent implements OnInit{ } reportMessage() { - if (!this.messageId || !this.reason.trim()) { + if (!this.messageId || !this.reason.trim() || this.sending) { return; } + this.sending = true; this.chatService.reportMessage(this.messageId, this.reason) .then(() => { - this.toastrService.success('', 'הדיווח נשלח בהצלחה!'); + this.toastrService.success('', 'הדיווח נשלח למנהלי הערוץ. תודה!'); this.dialogRef.close(); }) - .catch(() => this.toastrService.danger('', 'אירעה שגיאה בעת שליחת הדיווח')); + .catch((err) => this.toastrService.danger('', reportErrorMessage(err))) + .finally(() => { this.sending = false; }); + } +} + +/** + * The server caps a reason at 500 BYTES (report.go); the input's maxlength of + * 250 characters is the Hebrew ceiling, but emoji weigh four bytes each, so a + * reason can still overflow and come back 400. A 400 for anything else (the + * message was deleted meanwhile) keeps the generic text. The body is a plain + * "reason too long" line, which HttpClient hands over as `error.text` when + * it fails to parse it as JSON. + */ +function reportErrorMessage(err: any): string { + const body = typeof err?.error === 'string' ? err.error : String(err?.error?.text ?? ''); + switch (err?.status) { + case 400: return body.includes('too long') ? 'הסיבה ארוכה מדי — קצרו אותה ונסו שוב' : 'לא הצלחנו לשלוח את הדיווח. נסו שוב בעוד רגע'; + case 429: return 'שלחתם יותר מדי דיווחים. נסו שוב מאוחר יותר'; + default: return 'לא הצלחנו לשלוח את הדיווח. נסו שוב בעוד רגע'; } } diff --git a/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.html b/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.html index 46d46ce5..13f21f09 100644 --- a/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.html +++ b/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.html @@ -1,4 +1,10 @@ -@if (videoId) { - -} \ No newline at end of file +
+ + @if (videoId) { + + } +
diff --git a/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.scss b/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.scss index e69de29b..9828343c 100644 --- a/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.scss +++ b/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.scss @@ -0,0 +1,28 @@ +.yt { + position: relative; + display: block; + border-radius: var(--card-border-radius); + overflow: hidden; + background: #000; + box-shadow: var(--shadow); + line-height: 0; +} + +.yt__close { + position: absolute; + top: 0.5rem; + inset-inline-end: 0.5rem; + z-index: 2; + width: 2.5rem; + height: 2.5rem; + padding: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55) !important; + color: #fff !important; + + nb-icon { + font-size: 1.3rem; + } +} diff --git a/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.ts b/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.ts index f4a4fb13..8a69ac73 100644 --- a/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.ts +++ b/frontend/src/app/components/channel/chat/youtube-player/youtube-player.component.ts @@ -1,18 +1,22 @@ import { Component, OnInit } from '@angular/core'; -import { NbDialogRef } from '@nebular/theme'; +import { NbButtonModule, NbDialogRef, NbIconModule, NbTooltipModule } from '@nebular/theme'; import { YouTubePlayer } from "@angular/youtube-player"; @Component({ selector: 'app-youtube-player', imports: [ - YouTubePlayer -], + YouTubePlayer, + NbButtonModule, + NbIconModule, + NbTooltipModule, + ], templateUrl: './youtube-player.component.html', styleUrl: './youtube-player.component.scss' }) export class YoutubePlayerComponent implements OnInit { - iframeWidth = window.innerWidth / 100 * 80; + // 16:9, as wide as the viewport allows with a margin on either side. + iframeWidth = Math.min(window.innerWidth * 0.92, 960); iframeHeight = this.iframeWidth * 9 / 16; constructor(private dialogRef: NbDialogRef) { } diff --git a/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.html b/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.html new file mode 100644 index 00000000..1b4852a1 --- /dev/null +++ b/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.html @@ -0,0 +1,42 @@ +@if (variant === 'badge') { + + + + + + +} @else { + + + + @if (variant === 'full') { + הוקם ומתופעל ע״י הערוץ · ללא עלות + + לפתיחת ערוץ משלכם לחצו כאן + } @else { + פתחו ערוץ · ללא עלות + } + + +} diff --git a/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.scss b/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.scss new file mode 100644 index 00000000..28384d2e --- /dev/null +++ b/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.scss @@ -0,0 +1,221 @@ +// Colours go through the Nebular CSS custom properties so the credit follows +// the active theme (including dark mode) instead of pinning hex values. +:host { + display: inline-flex; + min-width: 0; +} + +// The brand tile: the same indigo gradient and broadcast mark as the app icon +// and the landing page's top bar, so the credit reads as "הערוץ" at a glance. +.mark { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + border-radius: 0.35rem; + color: var(--color-basic-100); + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + + nb-icon { + font-size: 0.8rem; + width: 0.8rem; + height: 0.8rem; + } +} + +.free { + font-weight: 800; + color: var(--color-primary-600); +} + +/* ---------- the pill ('full' and 'short') ---------- */ + +.credit { + position: relative; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 0.35rem; + // One line, never wrapped: the header measures this box to decide whether + // the pill fits beside the name (ChannelHeaderComponent.pickCreditVariant). + white-space: nowrap; + height: 1.5rem; + padding-block: 0; + padding-inline: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--color-primary-transparent-300); + background: linear-gradient(90deg, var(--color-primary-transparent-100), var(--color-primary-transparent-200)); + font-size: 0.72rem; + font-weight: 600; + line-height: 1; + color: var(--text-hint-color); + text-decoration: none; + transition: + background var(--motion-base, 200ms) var(--ease-out, ease), + border-color var(--motion-base, 200ms) var(--ease-out, ease), + box-shadow var(--motion-base, 200ms) var(--ease-out, ease); + + // The pill is 24px tall to sit on the name's line without making the header + // taller; this grows the tap target to 32px without moving anything. No + // more than that: the description button sits just below and keeps its own. + &::after { + content: ''; + position: absolute; + inset: -0.25rem; + } + + &:hover, + &:focus-visible { + border-color: var(--color-primary-400); + background: linear-gradient(90deg, var(--color-primary-transparent-200), var(--color-primary-transparent-300)); + box-shadow: 0 2px 10px -4px var(--color-primary-transparent-600); + text-decoration: none; + + .credit__arrow { + transform: translateX(-3px); + } + + .mark nb-icon { + animation: credit-pulse 900ms var(--ease-out, ease) 1; + } + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } +} + +.credit__sep { + width: 1px; + height: 0.8rem; + background: var(--color-primary-transparent-400); +} + +.credit__cta { + font-weight: 700; + color: var(--color-primary-600); +} + +.credit__arrow { + flex: 0 0 auto; + font-size: 0.8rem; + width: 0.8rem; + height: 0.8rem; + color: var(--color-primary-600); + transition: transform var(--motion-base, 200ms) var(--ease-out, ease); +} + +.credit--short { + gap: 0.3rem; + padding-inline-end: 0.5rem; +} + +// The broadcast mark "transmits" once on hover. Silenced by the global +// prefers-reduced-motion rule in styles.scss. +@keyframes credit-pulse { + 0% { transform: scale(1); } + 40% { transform: scale(1.25); } + 100% { transform: scale(1); } +} + +/* ---------- the badge on the logo ---------- */ + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--color-basic-100); + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + // A ring in the header's own colour cuts the badge out of the logo. + box-shadow: 0 0 0 2px var(--background-basic-color-1), 0 2px 6px var(--color-primary-transparent-500); + cursor: pointer; + position: relative; + + nb-icon { + font-size: 0.75rem; + width: 0.75rem; + height: 0.75rem; + } + + // 20px to the eye, 40px to the thumb. + &::after { + content: ''; + position: absolute; + inset: -0.625rem; + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } +} + +// The badge's card. Rendered in an overlay, but the template is declared here, +// so these scoped styles still reach it. +.credit-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: min(84vw, 18rem); + font-size: 0.85rem; + line-height: 1.45; + color: var(--text-basic-color); +} + +.credit-card__head { + display: inline-flex; + align-items: center; + gap: 0.45rem; + + .mark { + width: 1.5rem; + height: 1.5rem; + border-radius: 0.45rem; + + nb-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } + } +} + +.credit-card__cta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + min-height: 2.5rem; + padding: 0 0.9rem; + border-radius: 999px; + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + color: var(--color-basic-100); + font-weight: 700; + text-decoration: none; + + nb-icon { + font-size: 0.95rem; + width: 0.95rem; + height: 0.95rem; + } + + &:hover, + &:focus-visible { + color: var(--color-basic-100); + text-decoration: none; + filter: brightness(1.08); + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } +} diff --git a/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.ts b/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.ts new file mode 100644 index 00000000..e318144f --- /dev/null +++ b/frontend/src/app/components/channel/platform-attribution/platform-attribution.component.ts @@ -0,0 +1,56 @@ +import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NbIconModule, NbPopoverDirective, NbPopoverModule } from '@nebular/theme'; + +/** The three forms of the credit, widest first. */ +export type CreditVariant = 'full' | 'short' | 'badge'; + +/** + * The platform credit ("הוקם ומתופעל ע״י הערוץ · ללא עלות") and its call to + * action, drawn inside the channel header. + * + * It used to be a strip of its own under the header. The header now carries it + * on the channel name's line, in whichever form fits the room the name leaves + * (ChannelHeaderComponent measures and picks): + * - 'full' — one pill with both halves of the sentence, the whole pill a link; + * - 'short' — the same pill reduced to the brand mark and the offer; + * - 'badge' — a small brand mark on the logo's corner that opens a card with + * the full sentence and the link. It takes no width at all. + * + * The call to action points at '/' (the landing page) on purpose: + * a logged-out visitor — the audience for this credit — lands on the marketing + * page with the Google sign-in CTA, and a signed-in reader without a channel is + * bounced by LandingPageComponent to /channel, where the creation form waits. + */ +@Component({ + selector: 'app-platform-attribution', + standalone: true, + imports: [RouterLink, NbIconModule, NbPopoverModule], + templateUrl: './platform-attribution.component.html', + styleUrl: './platform-attribution.component.scss', +}) +export class PlatformAttributionComponent { + @Input() variant: CreditVariant = 'full'; + + @ViewChild('pop') private pop?: NbPopoverDirective; + + /** The badge's card is open, for aria-expanded. */ + shown = false; + + constructor(private host: ElementRef) { } + + /** + * The pill's width on one line, whether or not it is the form on show. The + * pill never wraps (nowrap inline-flex), so its box is its max-content + * width even inside the zero-width host of a form that is not in use. + */ + naturalWidth(): number { + return this.host.nativeElement.querySelector('.credit')?.getBoundingClientRect().width ?? 0; + } + + // Nebular's click-triggered popover closes on an outside click only. + @HostListener('document:keydown.escape') + onEscape() { + if (this.shown) this.pop?.hide(); + } +} diff --git a/frontend/src/app/components/landing/landing-hero.scss b/frontend/src/app/components/landing/landing-hero.scss new file mode 100644 index 00000000..33ccc94b --- /dev/null +++ b/frontend/src/app/components/landing/landing-hero.scss @@ -0,0 +1,278 @@ +// The landing hero: the one gradient on the page, and the mock channel card +// that shows a visitor what they are about to open. Tokens only. + +.hero { + position: relative; + overflow: hidden; + padding: clamp(2.5rem, 7vw, 5rem) 1.25rem clamp(3rem, 8vw, 5.5rem); + background: + radial-gradient(110% 80% at 85% 0%, var(--color-primary-400) 0%, transparent 55%), + linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-800) 100%); + color: var(--color-basic-100); +} + +// Nebular's typography gives headings and paragraphs their own dark text +// colour, which beat the white inherited from .hero: the title and lead were +// near-black on the gradient. +.hero__title, +.hero__lead, +.hero__note { + color: inherit; +} + +.hero__inner { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); + align-items: center; + gap: clamp(1.5rem, 5vw, 4rem); + max-width: 1080px; + margin: 0 auto; +} + +.hero__copy { + animation: hero-in 0.5s ease-out both; +} + +.hero__badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.85rem; + margin-bottom: 1.25rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + background: rgba(255, 255, 255, 0.16); + border: 1px solid rgba(255, 255, 255, 0.3); + + nb-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } +} + +.hero__title { + font-size: clamp(2rem, 5.5vw, 3.1rem); + font-weight: 800; + line-height: 1.15; + margin: 0 0 1rem; +} + +.hero__lead { + font-size: clamp(1rem, 2.2vw, 1.18rem); + line-height: 1.7; + opacity: 0.94; + margin: 0 0 1.75rem; + max-width: 560px; +} + +.hero__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.hero__cta { + display: inline-flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); +} + +// An outline button on the gradient: Nebular's outline colours are for a +// light card, so it is redrawn in white here. +.hero__alt { + display: inline-flex; + align-items: center; + border-color: rgba(255, 255, 255, 0.6) !important; + color: var(--color-basic-100) !important; + background: rgba(255, 255, 255, 0.08) !important; + + &:hover, + &:focus-visible { + background: rgba(255, 255, 255, 0.18) !important; + } +} + +.hero__note { + margin: 1.1rem 0 0; + font-size: 0.84rem; + line-height: 1.5; + opacity: 0.82; +} + +/* ---------- the mock channel ---------- */ + +.preview { + justify-self: center; + width: 100%; + max-width: 360px; + border-radius: 1.25rem; + padding: 0.9rem; + background: var(--background-basic-color-1); + color: var(--text-basic-color); + box-shadow: 0 28px 60px -20px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.25); + animation: hero-in 0.6s 0.1s ease-out both; +} + +.preview__head { + display: flex; + align-items: center; + gap: 0.6rem; + padding-bottom: 0.7rem; + border-bottom: 1px solid var(--border-basic-color-3); +} + +.preview__avatar { + flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.8rem; + font-weight: 800; + background: var(--color-primary-100); + color: var(--color-primary-700); +} + +.preview__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.preview__name { + font-weight: 800; + font-size: 0.98rem; +} + +.preview__meta { + font-size: 0.75rem; + color: var(--text-hint-color); + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.preview__live { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-success-500); + box-shadow: 0 0 0 0 var(--color-success-transparent-300); + animation: pulse 1.8s ease-out infinite; +} + +.preview__feed { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.7rem; +} + +.preview__msg { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.6rem 0.75rem; + border-radius: 0.85rem; + background: var(--background-basic-color-2); + font-size: 0.82rem; + line-height: 1.5; + + &--sched { + border: 1px dashed var(--color-primary-300); + background: var(--color-primary-100); + color: var(--color-primary-800); + } + + nb-icon { + font-size: 0.95rem; + width: 0.95rem; + height: 0.95rem; + vertical-align: -0.15em; + } +} + +.preview__img { + display: flex; + align-items: center; + justify-content: center; + height: 4.5rem; + border-radius: 0.6rem; + background: linear-gradient(135deg, var(--color-primary-200), var(--color-info-200)); + color: var(--color-primary-700); + + nb-icon { + font-size: 1.5rem; + width: 1.5rem; + height: 1.5rem; + } +} + +.preview__msg-foot { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; +} + +.preview__react { + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); +} + +.preview__time { + margin-inline-start: auto; + color: var(--text-hint-color); +} + +@media (max-width: 767px) { + .hero { + text-align: center; + } + + .hero__inner { + grid-template-columns: 1fr; + } + + .hero__lead { + margin-inline: auto; + } + + .hero__actions { + flex-direction: column; + align-items: stretch; + } + + .hero__cta, + .hero__alt { + justify-content: center; + } + + .preview { + text-align: start; + } +} + +@keyframes hero-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 var(--color-success-transparent-300); } + 70% { box-shadow: 0 0 0 6px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} diff --git a/frontend/src/app/components/landing/landing-page.component.scss b/frontend/src/app/components/landing/landing-page.component.scss new file mode 100644 index 00000000..e5328f17 --- /dev/null +++ b/frontend/src/app/components/landing/landing-page.component.scss @@ -0,0 +1,349 @@ +:host { + display: block; +} + +// The landing sections are full-bleed (hero gradient, footer), so drop the +// default column padding that nb-layout-column adds. +:host ::ng-deep nb-layout-column { + padding: 0; +} + +// Colours come from the theme's CSS custom properties, so the page follows +// the active theme instead of pinning hex values. The hero and its preview +// mock are in landing-hero.scss. + +.wrap { + width: 100%; + max-width: 1080px; + margin: 0 auto; + padding: 0 1.25rem; +} + +.wrap--narrow { + max-width: 660px; +} + +/* ---------- top bar ---------- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--background-basic-color-1); + border-bottom: 1px solid var(--border-basic-color-3); + + a { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-height: 2.5rem; + } +} + +.topbar__brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 800; + font-size: 1.2rem; + color: var(--text-basic-color); +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.6rem; + font-size: 1.15rem; + color: var(--color-basic-100); + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); +} + +/* ---------- sections ---------- */ + +.section { + padding: clamp(2.75rem, 7vw, 4.5rem) 0; + background: var(--background-basic-color-1); +} + +.section--alt { + background: var(--background-basic-color-2); +} + +.section--tight { + padding: clamp(1.75rem, 4vw, 2.5rem) 0; +} + +.section__title { + text-align: center; + margin: 0 0 0.5rem; + font-size: clamp(1.5rem, 3.5vw, 2rem); + font-weight: 800; + color: var(--text-basic-color); + + &--small { + font-size: 1.1rem; + margin-bottom: 0.9rem; + } +} + +.section__lead { + text-align: center; + margin: 0 auto 2rem; + max-width: 560px; + line-height: 1.6; + color: var(--text-hint-color); +} + +/* ---------- video ---------- */ + +.video { + padding-bottom: 0; +} + +.video__player { + display: block; + width: 100%; + max-width: 960px; + aspect-ratio: 16 / 9; + margin: 0 auto; + border-radius: 16px; + background: var(--color-primary-800); + box-shadow: 0 18px 48px rgba(30, 27, 90, 0.22); +} + +/* ---------- steps ---------- */ + +.steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.step { + position: relative; + padding: 1.9rem 1.25rem 1.5rem; + border-radius: var(--card-border-radius); + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + box-shadow: var(--card-shadow); + text-align: center; +} + +.step__num { + position: absolute; + top: -0.9rem; + // Centred with auto inline margins rather than a 50% offset plus a + // translate, whose sign depends on the writing direction. + inset-inline: 0; + margin-inline: auto; + width: 1.8rem; + height: 1.8rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.85rem; + font-weight: 800; + background: var(--color-primary-500); + color: var(--color-basic-100); + box-shadow: 0 4px 10px var(--color-primary-transparent-300); +} + +.step__icon { + font-size: 2rem; + width: 2rem; + height: 2rem; + color: var(--color-primary-500); +} + +.step__title { + margin: 0.75rem 0 0.35rem; + font-size: 1.05rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.step__text { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: var(--text-hint-color); +} + +/* ---------- features ---------- */ + +.features { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.feature { + display: flex; + align-items: flex-start; + gap: 0.85rem; + padding: 1.25rem; + border-radius: var(--card-border-radius); + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: var(--card-shadow); + } +} + +.feature__icon-wrap { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.85rem; + background: var(--color-primary-100); +} + +.feature__icon { + font-size: 1.4rem; + width: 1.4rem; + height: 1.4rem; + color: var(--color-primary-600); +} + +.feature__title { + margin: 0 0 0.2rem; + font-size: 1rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.feature__text { + margin: 0; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-hint-color); +} + +/* ---------- audience chips ---------- */ + +.audience { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.audience__item { + padding: 0.4rem 0.9rem; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + background: var(--background-basic-color-2); + border: 1px solid var(--border-basic-color-3); + color: var(--text-basic-color); +} + +/* ---------- questions ---------- */ + +.faq { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.faq__item { + border: 1px solid var(--border-basic-color-3); + border-radius: 0.75rem; + background: var(--background-basic-color-1); + padding: 0 1rem; +} + +.faq__q { + cursor: pointer; + padding: 0.85rem 0; + font-weight: 700; + color: var(--text-basic-color); +} + +.faq__a { + margin: 0 0 0.85rem; + line-height: 1.7; + color: var(--text-hint-color); +} + +/* ---------- closing call to action ---------- */ + +.cta__inner { + text-align: center; +} + +.cta__text { + margin: 0 0 1.5rem; + font-size: 1rem; + line-height: 1.7; + color: var(--text-hint-color); +} + +.cta__button { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/* ---------- footer ---------- */ + +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1.5rem 1rem; + font-size: 0.85rem; + background: var(--background-basic-color-2); + border-top: 1px solid var(--border-basic-color-3); + color: var(--text-hint-color); +} + +.footer__brand { + font-weight: 800; + color: var(--text-basic-color); +} + +.footer__sep { + opacity: 0.6; +} + +.footer__link { + display: inline-flex; + align-items: center; + min-height: 2.5rem; + color: var(--color-primary-600); + font-weight: 600; + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: underline; + } +} + +@media (max-width: 767px) { + .steps, + .features { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/components/landing/landing-page.component.ts b/frontend/src/app/components/landing/landing-page.component.ts new file mode 100644 index 00000000..483a182c --- /dev/null +++ b/frontend/src/app/components/landing/landing-page.component.ts @@ -0,0 +1,420 @@ +import { Component, HostListener, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, RouterLink, Router } from '@angular/router'; +import { + NbCardModule, NbButtonModule, NbIconModule, NbLayoutModule, +} from '@nebular/theme'; +import { AuthService } from '../../services/auth.service'; +import { SupportBoxComponent } from '../support/support-box.component'; + +interface LandingStep { + icon: string; + title: string; + text: string; +} + +interface LandingFaq { + q: string; + a: string; +} + +interface LandingFeature { + icon: string; + title: string; + text: string; +} + +@Component({ + selector: 'app-landing-page', + standalone: true, + imports: [ + RouterLink, + NbCardModule, + NbButtonModule, + NbIconModule, + NbLayoutModule, + SupportBoxComponent, + ], + // Two sheets so each stays under the component-style budget: the hero (with + // its mock channel preview) and everything below it. + styleUrls: ['./landing-page.component.scss', './landing-hero.scss'], + template: ` + + + + + + +
+
+
+ + + בחינם · בלי אפליקציה · בלי המתנה לאישור + +

ערוץ עדכונים משלכם, פתוח תוך רגע

+

+ ערוץ הוא דף אחד שבו אתם מפרסמים הודעות, תמונות, סרטונים וקישורים, + והם מגיעים לכל מי שיש לו את הקישור. חלופה לערוץ וואטסאפ ולקבוצות צ׳אט במייל: + הקוראים לא צריכים להתקין כלום ולא להירשם. +

+
+ + + יש לי כבר ערוץ / התחברות + +
+

+ מתחברים עם חשבון גוגל (בלי סיסמה נוספת) — רק כדי שהערוץ יירשם על שמכם. +

+
+ + + +
+
+ + +
+
+

רואים את זה בפעולה

+

סרטון של דקה: פותחים ערוץ, מפרסמים, והקוראים מקבלים את ההודעה באותו רגע.

+ +
+
+ + +
+
+

איך זה עובד

+

שלושה צעדים, דקה אחת — ואתם משדרים.

+
    + @for (step of steps; track step.title; let i = $index) { +
  1. + {{ i + 1 }} + +

    {{ step.title }}

    +

    {{ step.text }}

    +
  2. + } +
+
+
+ + +
+
+

מה יש בערוץ

+

הכול כלול, מהרגע הראשון, בלי תוספות.

+
+ @for (feature of features; track feature.title) { +
+ +
+

{{ feature.title }}

+

{{ feature.text }}

+
+
+ } +
+
+
+ + +
+
+

למי זה מתאים

+
    + @for (item of audience; track item) { +
  • {{ item }}
  • + } +
+
+
+ + + @if (faq.length) { +
+
+

שאלות נפוצות

+
+ @for (item of faq; track item.q) { +
+ {{ item.q }} +

{{ item.a }}

+
+ } +
+
+
+ } + + +
+
+

מוכנים להתחיל?

+

+ התחברות עם חשבון גוגל, שם לערוץ — וזהו. תוך רגע יש לכם קישור לשתף. +

+ +
+
+ + +
+
+

יש שאלה?

+ + @if (!supportLink || authChecked) { + + + } +
+
+ + +
+
+ `, +}) +export class LandingPageComponent implements OnInit { + /** Only decides which fields the contact form asks for; the server takes the + * sender's identity from the session regardless of what is posted. */ + signedIn = false; + readonly year = new Date().getFullYear(); + + /** + * Set by `?support=1`, the link the system's emails carry (the channel + * welcome mail and a support answer): open the contact box, for a signed-in + * visitor too, who would otherwise be sent straight on to their channels. + * `channel` tags a new ticket with that channel; `ticket` opens that thread. + */ + supportLink = false; + supportChannel = ''; + supportTicket = ''; + authChecked = false; + + readonly steps: LandingStep[] = [ + { icon: 'log-in-outline', title: 'מתחברים עם גוגל', text: 'לחיצה אחת על חשבון הגוגל שלכם. בלי סיסמה חדשה ובלי טפסים.' }, + { icon: 'edit-2-outline', title: 'בוחרים שם', text: 'נותנים לערוץ שם; הכתובת שלו נקבעת מהשם ונבדקת שהיא פנויה.' }, + { icon: 'share-outline', title: 'משתפים את הקישור', text: 'שולחים את הקישור בוואטסאפ או בכל דרך — וכל מי שפותח אותו רואה את הערוץ.' }, + ]; + + readonly features: LandingFeature[] = [ + { icon: 'flash-outline', title: 'עדכונים חיים', text: 'הודעה שפרסמתם מופיעה אצל הקוראים באותו רגע, בלי לרענן.' }, + { icon: 'image-outline', title: 'תמונות וסרטונים', text: 'מעלים תמונות, סרטונים וקבצים ישירות לערוץ. יוטיוב מתנגן בפנים.' }, + { icon: 'smiling-face-outline', title: 'תגובות אימוג׳י', text: 'הקוראים מגיבים בלחיצה — ואתם רואים מה תפס.' }, + { icon: 'clock-outline', title: 'הודעות מתוזמנות', text: 'כותבים עכשיו, קובעים מתי יתפרסם — והערוץ מפרסם לבד.' }, + { icon: 'people-outline', title: 'צוות כותבים ומנהלים', text: 'מצרפים עוד אנשים שיכתבו וינהלו, כל אחד עם ההרשאה שלו.' }, + { icon: 'bell-outline', title: 'התראות לנייד', text: 'קורא שמבקש לקבל התראות יקבל הודעה לנייד על כל פרסום חדש.' }, + ]; + + readonly audience = [ + 'קהילות ושכונות', 'בתי ספר וגנים', 'עסקים והלקוחות שלהם', + 'ארגונים ומתנדבים', 'יוצרים והקהל שלהם', 'משפחה וחברים', + ]; + + readonly faq: LandingFaq[] = readServerFaq(); + + /** True from the CTA click until the browser leaves for Google. */ + starting = false; + + constructor( + private authService: AuthService, + private router: Router, + private route: ActivatedRoute, + private titleService: Title, + ) {} + + async ngOnInit() { + // The same title the server sends for "/" (homeTitle in + // backend/seo_content.go), so arriving here from inside the app does not + // change the tab. + const siteName = document.querySelector('meta[name="application-name"]')?.getAttribute('content') || 'הערוץ'; + this.titleService.setTitle(`פתיחת ערוץ עדכונים בחינם, בלי אפליקציה · ${siteName}`); + + const q = this.route.snapshot.queryParamMap; + this.supportLink = q.get('support') === '1'; + if (this.supportLink) { + // Both are only labels/lookups (the server authorises every thread), but + // they come from a URL, so anything that is not a plain id is dropped. + const channel = q.get('channel') ?? ''; + const ticket = q.get('ticket') ?? ''; + this.supportChannel = /^[a-z0-9-]{1,64}$/.test(channel) ? channel : ''; + this.supportTicket = /^[A-Za-z0-9_-]{1,64}$/.test(ticket) ? ticket : ''; + try { + this.signedIn = !!(await this.authService.loadUserInfo()); + } catch { + this.signedIn = false; + } + this.authChecked = true; + // After the box has rendered under the hero. + setTimeout(() => document.getElementById('contact')?.scrollIntoView({ block: 'start' })); + return; + } + + try { + const user = await this.authService.loadUserInfo(); + if (user) { + if (user.globalRole === 'super_admin') { + this.router.navigate(['/super-admin']); + } else { + this.router.navigate(['/channel']); + } + return; + } + } catch { + // Not logged in — the landing page is already on screen. + } + } + + /** + * The footer's "contact" link. A plain fragment link depends on the window + * being the scroll container, which nb-layout does not guarantee, so the + * section is scrolled into view explicitly. + */ + scrollToContact(event: Event): void { + const target = document.getElementById('contact'); + if (!target) return; + event.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + /** + * Instant creation needs a session (the backend takes the owner from it), so + * the primary CTA sends visitors through login and back to /channel, where a + * user with no channel lands on the creation form. + * `returnUrl` lives in localStorage — that is what LoginComponent reads. + */ + async startCreate(): Promise { + if (this.starting) return; + try { + localStorage.setItem('returnUrl', '/channel'); + } catch { + // Storage unavailable — the login page falls back to /channel anyway. + } + // Straight to Google: a second screen with the same button before the + // consent page is a click nobody needs. The login page remains the OAuth + // callback and the place to land (with its explanation and retry) when + // the hand-off itself fails. + this.starting = true; + try { + await this.authService.loginWithGoogle(); + } catch { + this.starting = false; + this.router.navigate(['/login'], { queryParams: { returnUrl: '/channel' } }); + } + } + + // Back from Google's account picker restores this page from the + // back-forward cache with `starting` still true, i.e. both CTAs greyed out. + @HostListener('window:pageshow', ['$event']) + onPageShow(event: PageTransitionEvent) { + if (event.persisted) this.starting = false; + } +} + +/** + * The FAQ from the page's FAQPage structured data. Read once, when the + * module loads: that is the document the server rendered for the first URL, + * which is "/" for nearly everyone who sees this page. + */ +function readServerFaq(): LandingFaq[] { + try { + for (const el of Array.from(document.querySelectorAll('script[type="application/ld+json"]'))) { + const data = JSON.parse(el.textContent || '{}'); + const nodes: any[] = Array.isArray(data?.['@graph']) ? data['@graph'] : [data]; + const page = nodes.find(n => n?.['@type'] === 'FAQPage'); + if (!page || !Array.isArray(page.mainEntity)) continue; + return page.mainEntity + .map((q: any) => ({ q: String(q?.name ?? ''), a: String(q?.acceptedAnswer?.text ?? '') })) + .filter((f: LandingFaq) => f.q && f.a); + } + } catch { + // Malformed or missing data: the page simply has no FAQ section. + } + return []; +} diff --git a/frontend/src/app/components/login/login.component.html b/frontend/src/app/components/login/login.component.html index 1e2ea07d..dcf9787d 100644 --- a/frontend/src/app/components/login/login.component.html +++ b/frontend/src/app/components/login/login.component.html @@ -1,28 +1,61 @@ -
-
-
-
-
-

התחברות

-
-
- @if (status === 'failed') { - - } - @if (!code && !checkUserInfo) { - + + + +
+
-
-
-
\ No newline at end of file + + + + + + + + + + + diff --git a/frontend/src/app/components/login/login.component.scss b/frontend/src/app/components/login/login.component.scss index 7ea0f055..43b503a6 100644 --- a/frontend/src/app/components/login/login.component.scss +++ b/frontend/src/app/components/login/login.component.scss @@ -1,5 +1,194 @@ -h4, -button, -.alert-danger { - font-family: 'Assistant', sans-serif; -} \ No newline at end of file +// Tokens only, so the page follows the active theme. + +:host { + display: block; +} + +.login { + display: flex; + justify-content: center; + align-items: flex-start; + padding: clamp(1.5rem, 8vh, 5rem) 0.25rem 3rem; + animation: login-in 0.35s ease-out both; +} + +.login__card { + width: 100%; + max-width: 420px; + margin: 0; + border-radius: var(--card-border-radius); + box-shadow: var(--shadow); +} + +.login__body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2rem 1.5rem 1.75rem; + text-align: center; +} + +.login__brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 800; + color: var(--text-basic-color); + text-decoration: none; +} + +.login__mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 0.75rem; + font-size: 1.35rem; + color: var(--color-basic-100); + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + box-shadow: 0 8px 18px var(--color-primary-transparent-300); +} + +.login__title { + margin: 0.5rem 0 0; + font-size: 1.6rem; + font-weight: 800; + color: var(--text-basic-color); +} + +.login__lead { + margin: 0 0 0.5rem; + font-size: 1rem; + line-height: 1.6; + color: var(--text-hint-color); +} + +.login__alert { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + margin: 0; + text-align: start; + line-height: 1.5; +} + +.login__alert-icon { + flex: 0 0 auto; + font-size: 1.2rem; + width: 1.2rem; + height: 1.2rem; + margin-top: 0.1rem; +} + +.login__google { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + min-height: 3.25rem; + font-size: 1.05rem; +} + +// Google's "G" sits in a white disc so its colours stay legible on the +// indigo button. +.login__g { + flex: 0 0 auto; + width: 2rem; + height: 2rem; + padding: 0.3rem; + border-radius: 50%; + background: var(--color-basic-100); +} + +.login__busy { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + width: 100%; + min-height: 3.25rem; + padding: 0.75rem 0; + color: var(--color-primary-600); +} + +.login__spinner { + font-size: 2rem; + + nb-icon { + font-size: inherit; + width: 1em; + height: 1em; + } +} + +.login__busy-text { + font-weight: 700; + color: var(--text-basic-color); +} + +.login__privacy { + display: flex; + align-items: flex-start; + gap: 0.4rem; + margin: 0.25rem 0 0; + font-size: 0.82rem; + line-height: 1.5; + text-align: start; + color: var(--text-hint-color); + + nb-icon { + flex: 0 0 auto; + font-size: 1.1rem; + width: 1.1rem; + height: 1.1rem; + margin-top: 0.1rem; + color: var(--color-success-600); + } +} + +.login__what { + margin: 0; + font-size: 0.85rem; + line-height: 1.5; + color: var(--text-hint-color); +} + +.login__home { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin-top: 0.5rem; + min-height: 2.5rem; + font-size: 0.9rem; + font-weight: 600; + color: var(--color-primary-600); + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: underline; + } +} + +.spin { + animation: login-spin 0.9s linear infinite; +} + +@keyframes login-spin { + to { transform: rotate(360deg); } +} + +@keyframes login-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts index 84d65504..63031880 100644 --- a/frontend/src/app/components/login/login.component.ts +++ b/frontend/src/app/components/login/login.component.ts @@ -1,58 +1,184 @@ -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { + NbAlertModule, NbButtonModule, NbCardModule, NbIconModule, NbLayoutModule, NbSpinnerModule, +} from '@nebular/theme'; +import { Subscription } from 'rxjs'; import { AuthService } from '../../services/auth.service'; - +/** + * The sign-in page. There is no password: the only way in is a Google + * account, which Google sends back here with a one-time code that the server + * swaps for a session. The page therefore has three states — the button, the + * "connecting" spinner while that swap runs, and a failure with a retry — + * and otherwise only decides where to send the user afterwards. + */ @Component({ selector: 'app-login', + standalone: true, imports: [ - FormsModule -], + RouterLink, + NbLayoutModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbAlertModule, + NbSpinnerModule, + ], templateUrl: './login.component.html', styleUrl: './login.component.scss' }) -export class LoginComponent implements OnInit { +export class LoginComponent implements OnInit, OnDestroy { code: string = ''; checkUserInfo: boolean = false; status!: 'failed'; + /** What went wrong, in the user's words; shown with the retry button. */ + errorMessage = ''; + /** True while the user is being sent to Google (the button was pressed). */ + redirecting = false; + + private paramsSub?: Subscription; constructor( private _authService: AuthService, private _route: ActivatedRoute, - private router: Router + private router: Router, + private titleService: Title, ) { } + /** The spinner's caption: the initial session check vs. the code exchange. */ + get busyText(): string { + if (this.code) return 'מחברים אתכם…'; + if (this.redirecting) return 'עוברים לגוגל…'; + return 'רק רגע…'; + } + + get busy(): boolean { + return !!this.code || this.checkUserInfo || this.redirecting; + } + async ngOnInit() { + // index.html ships an empty ; without this the tab shows the URL. + this.titleService.setTitle('התחברות · הערוץ'); this.checkUserInfo = true; try { await this._authService.loadUserInfo(); if (this._authService.userInfo) { - this.router.navigate(['/']); + this.checkUserInfo = false; + this.redirectAfterLogin(); return; } } catch { - this._route.queryParams.subscribe(params => { - if (Object.keys(params).length > 0) { - if (params['code'] && params['state'] === localStorage.getItem('google_oauth_state')) { - this.code = params['code']; - this._authService.login(this.code).then(() => { - this.router.navigate(['/']); - }).catch(() => { - this.code = ''; - this.status = 'failed'; - alert('התחברות נכשלה, נסה שוב'); - }); - } - } - }); - } finally { - this.checkUserInfo = false; + // Not logged in — check for OAuth callback params } + + this.checkUserInfo = false; + + this.paramsSub = this._route.queryParams.subscribe(params => { + if (params['error']) { + // Google sends the user back with error=access_denied when they cancel + // the consent screen (and other error codes for a misconfigured app). + // Only params['code'] used to be inspected, so this showed the login + // button again as if nothing had happened. + this.clearOauthState(); + this.fail(params['error'] === 'access_denied' + ? 'ההתחברות בוטלה לפני שהסתיימה. אפשר לנסות שוב.' + : 'גוגל לא השלימה את ההתחברות. נסו שוב — ואם זה חוזר, פנו אלינו מדף הבית.'); + return; + } + if (params['code'] && params['state'] !== localStorage.getItem('google_oauth_state')) { + // Google sent us back but the anti-CSRF state does not match what this + // browser stored (storage cleared, a second tab, a replayed link). + // Previously this fell through silently and the page just showed the + // login button again, as if nothing had happened. + this.fail('ההתחברות לא הושלמה: הקישור פג תוקף או נפתח בדפדפן אחר. נסו להתחבר שוב מכאן.'); + return; + } + if (params['code'] && params['state'] === localStorage.getItem('google_oauth_state')) { + this.code = params['code']; + this.checkUserInfo = true; + this.errorMessage = ''; + // The state is single-use: once the code is consumed the callback URL + // must stop matching, otherwise the Back button (after a logout) lands + // on this history entry and re-posts the already-spent code, which the + // server rejects with 500 and the user sees a failure they did not cause. + this.clearOauthState(); + this._authService.login(this.code).then(async () => { + await this._authService.loadUserInfo(); + this.redirectAfterLogin(); + }).catch(() => { + this.code = ''; + this.checkUserInfo = false; + this.fail('ההתחברות נכשלה בדרך חזרה מגוגל. נסו שוב — ואם זה חוזר, פנו אלינו מדף הבית.'); + }); + } + }); } - login() { - this._authService.loginWithGoogle(); + ngOnDestroy(): void { + this.paramsSub?.unsubscribe(); + } + + private fail(message: string): void { + this.status = 'failed'; + this.errorMessage = message; + } + + private clearOauthState() { + try { + localStorage.removeItem('google_oauth_state'); + } catch { + // Storage unavailable — nothing was stored to begin with. + } + } + + private redirectAfterLogin() { + // Consumed for every role, before any early return: the super-admin branch + // used to return first, leaving the stored URL behind for the next + // (non-admin) login on the same browser, which was then dropped into a + // channel it never asked for. + const returnUrl = localStorage.getItem('returnUrl'); + localStorage.removeItem('returnUrl'); + const hasReturnUrl = !!returnUrl && !returnUrl.startsWith('/login'); + + if (this._authService.userInfo?.globalRole === 'super_admin') { + // A super admin who signed in from a channel page wants that page back, + // not the panel; the panel is only the default. + if (hasReturnUrl) { + this.router.navigateByUrl(returnUrl!); + } else { + this.router.navigate(['/super-admin']); + } + return; + } + + if (hasReturnUrl) { + this.router.navigateByUrl(returnUrl!); + return; + } + + this.router.navigate(['/channel']); + } + + // Back from Google's account picker restores this page from the + // back-forward cache with its state intact — i.e. the spinner that replaced + // the button. A restored page is idle again. + @HostListener('window:pageshow', ['$event']) + onPageShow(event: PageTransitionEvent) { + if (event.persisted) this.redirecting = false; + } + + async login() { + this.errorMessage = ''; + this.redirecting = true; + try { + await this._authService.loginWithGoogle(); + } catch { + // GET /auth/google failed; without this the click did nothing at all. + this.redirecting = false; + this.fail('ההתחברות אינה זמינה כרגע. בדקו את החיבור לאינטרנט ונסו שוב בעוד רגע.'); + } } } diff --git a/frontend/src/app/components/manage/manage-channel.component.html b/frontend/src/app/components/manage/manage-channel.component.html new file mode 100644 index 00000000..52ff4092 --- /dev/null +++ b/frontend/src/app/components/manage/manage-channel.component.html @@ -0,0 +1,193 @@ +<nb-layout> + <nb-layout-header fixed> + <div class="bar"> + <a nbButton ghost status="basic" size="small" class="bar__back" + [routerLink]="['/channel', slug]" aria-label="חזרה לערוץ" nbTooltip="חזרה לערוץ"> + <nb-icon icon="arrow-forward-outline"></nb-icon> + <span class="bar__back-text">חזרה לערוץ</span> + </a> + + <div class="bar__channel"> + <span class="bar__logo" aria-hidden="true"> + @if (logoUrl) { + <img [src]="logoUrl" alt="" width="36" height="36" loading="lazy"> + } @else { + <span class="bar__initial">{{ initial }}</span> + } + </span> + <span class="bar__text"> + <span class="bar__name">{{ channelName }}</span> + <span class="bar__sub">ניהול הערוץ</span> + </span> + </div> + + <div class="bar__actions"> + @if (showSwitcher) { + <nb-select size="small" class="bar__switch" [selected]="slug" (selectedChange)="switchChannel($event)" + aria-label="מעבר לערוץ אחר שאתם מנהלים" nbTooltip="מעבר לערוץ אחר"> + <nb-select-label> + <nb-icon icon="swap-outline" class="switch__icon"></nb-icon> + <span class="switch__current">{{ channelName }}</span> + </nb-select-label> + @for (c of switcherChannels; track c.slug) { + <nb-option [value]="c.slug"> + <span class="switch__name">{{ c.slug === slug ? channelName : (c.name || c.slug) }}</span> + <span class="switch__role">{{ switcherSynthetic && c.slug === slug ? 'מנהל-על' : roleLabels[c.role] }}</span> + </nb-option> + } + </nb-select> + } + <a nbButton ghost status="basic" size="small" routerLink="/channel" + aria-label="הערוצים שלי" nbTooltip="הערוצים שלי"> + <nb-icon icon="grid-outline"></nb-icon> + <span class="bar__hub-text">הערוצים שלי</span> + </a> + </div> + </div> + </nb-layout-header> + + <nb-layout-column> + <div class="manage"> + <aside class="manage__nav"> + <nav class="side" aria-label="מדורי הניהול"> + @for (s of sections; track s.id) { + <a class="side__item" [class.is-active]="isActive(s)" [routerLink]="link(s.id)" + [attr.aria-current]="isActive(s) ? 'page' : null"> + <nb-icon [icon]="s.icon" class="side__icon"></nb-icon> + <span class="side__text"> + <span class="side__title">{{ s.title }}</span> + <span class="side__desc">{{ s.nav }}</span> + </span> + </a> + @if (s.id === 'reports' && isActive(s)) { + <div class="side__sub"> + @for (f of reportFilters; track f.id) { + <a class="side__subitem" [class.is-active]="tab === f.id" [routerLink]="link(f.id)">{{ f.title }}</a> + } + </div> + } + } + </nav> + </aside> + + <nav class="manage__tabs" #tabStrip aria-label="מדורי הניהול"> + @for (s of sections; track s.id) { + <a class="tab" [class.is-active]="isActive(s)" [routerLink]="link(s.id)" + [attr.aria-current]="isActive(s) ? 'page' : null"> + <nb-icon [icon]="s.icon"></nb-icon> + <span>{{ s.title }}</span> + </a> + } + </nav> + + <section class="manage__content"> + @if (loading) { + <nb-card class="state-card"> + <nb-card-body class="state-card__body"> + <nb-spinner status="primary" size="large" message=""></nb-spinner> + <p>טוענים את פרטי הערוץ…</p> + </nb-card-body> + </nb-card> + } @else if (loadError) { + <nb-card class="state-card"> + <nb-card-body class="state-card__body"> + <nb-icon [icon]="loadError === 'network' ? 'wifi-off-outline' : 'lock-outline'" class="state-card__icon"></nb-icon> + @switch (loadError) { + @case ('notfound') { + <h1 class="state-card__title">הערוץ לא נמצא</h1> + <p>ייתכן שהכתובת שגויה או שהערוץ נמחק.</p> + } + @case ('forbidden') { + <h1 class="state-card__title">אין לכם גישה לערוץ הזה</h1> + <p>כדי לנהל ערוץ צריך תפקיד של מנהל או בעלים בו. אם קיבלתם הרשאה הרגע — התחברו מחדש.</p> + } + @default { + <h1 class="state-card__title">לא הצלחנו לטעון את הערוץ</h1> + <p>בדקו את החיבור לאינטרנט ונסו שוב.</p> + } + } + <div class="state-card__actions"> + @if (loadError === 'network') { + <button nbButton status="primary" size="small" type="button" (click)="reload()"> + <nb-icon icon="refresh-outline"></nb-icon> ניסיון נוסף + </button> + } + <a nbButton ghost status="basic" size="small" routerLink="/channel"> + <nb-icon icon="grid-outline"></nb-icon> הערוצים שלי + </a> + </div> + </nb-card-body> + </nb-card> + } @else { + @if (section; as s) { + <header class="intro"> + <h1 class="intro__title">{{ s.title }}</h1> + <p class="intro__description">{{ s.description }}</p> + @if (s.help) { + <nb-accordion class="intro__help"> + <nb-accordion-item> + <nb-accordion-item-header> + <nb-icon icon="question-mark-circle-outline"></nb-icon> + <span>מה זה?</span> + </nb-accordion-item-header> + <nb-accordion-item-body>{{ s.help }}</nb-accordion-item-body> + </nb-accordion-item> + </nb-accordion> + } + </header> + } + + @if (accessDenied) { + <nb-card class="state-card"> + <nb-card-body class="state-card__body"> + <nb-icon icon="lock-outline" class="state-card__icon"></nb-icon> + <h2 class="state-card__title">המסך הזה כבר לא פתוח בפניכם</h2> + <p>ההרשאה שלכם בערוץ השתנתה. אם זו טעות, פנו לבעלי הערוץ.</p> + <div class="state-card__actions"> + <a nbButton status="primary" size="small" [routerLink]="['/channel', slug]"> + <nb-icon icon="arrow-forward-outline"></nb-icon> חזרה לערוץ + </a> + </div> + </nb-card-body> + </nb-card> + } @else { + <!-- Keyed on the role-filtered menu, not the raw id: an owner-only + section a moderator deep-links to lands on the not-found card + instead of being mounted, answering 403 and claiming their + role "changed". --> + @switch (section ? tab : '') { + @case ('info') { <app-channel-info-form></app-channel-info-form> } + @case ('settings') { <app-settings (accessDenied)="onAccessDenied()"></app-settings> } + @case ('users') { <app-privileg-dashboard (accessDenied)="onAccessDenied()"></app-privileg-dashboard> } + @case ('statistics') { <app-statistics (accessDenied)="onAccessDenied()"></app-statistics> } + @case ('emojis') { <app-emojis></app-emojis> } + @case ('magnet-ads') { <app-magnet-ads (accessDenied)="onAccessDenied()"></app-magnet-ads> } + @case ('storage') { <app-channel-storage [slug]="slug" (accessDenied)="onAccessDenied()"></app-channel-storage> } + @case ('reports') { <app-reports [status]="'open'" (accessDenied)="onAccessDenied()"></app-reports> } + @case ('reports-closed') { <app-reports [status]="'closed'" (accessDenied)="onAccessDenied()"></app-reports> } + @case ('reports-all') { <app-reports [status]="'all'" (accessDenied)="onAccessDenied()"></app-reports> } + @case ('guide') { <app-guide></app-guide> } + @case ('support') { + <app-support-box [channelSlug]="slug" [signedIn]="true" title="פנייה לתמיכה" + subtitle="שאלה, תקלה או בקשה — הפנייה מגיעה להנהלת המערכת, והתשובה תופיע כאן."> + </app-support-box> + } + @default { + <nb-card class="state-card"> + <nb-card-body class="state-card__body"> + <nb-icon icon="compass-outline" class="state-card__icon"></nb-icon> + <h1 class="state-card__title">המסך המבוקש לא נמצא</h1> + <p>ייתכן שהוא פתוח רק לבעלי הערוץ, או שהכתובת שגויה.</p> + <div class="state-card__actions"> + <a nbButton status="primary" size="small" [routerLink]="link('info')">לפרטי הערוץ</a> + </div> + </nb-card-body> + </nb-card> + } + } + } + } + </section> + </div> + </nb-layout-column> +</nb-layout> diff --git a/frontend/src/app/components/manage/manage-channel.component.scss b/frontend/src/app/components/manage/manage-channel.component.scss new file mode 100644 index 00000000..66cc9570 --- /dev/null +++ b/frontend/src/app/components/manage/manage-channel.component.scss @@ -0,0 +1,383 @@ +// ---------- header bar ---------- +.bar { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + min-width: 0; +} + +.bar__back { + flex-shrink: 0; + min-height: 2.5rem; +} + +.bar__channel { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; + flex: 1; +} + +.bar__logo { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + color: var(--text-control-color); + font-weight: 700; + box-shadow: 0 0 0 2px var(--background-basic-color-1), 0 0 0 3px var(--color-primary-200); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +} + +.bar__text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} + +.bar__name { + font-weight: 700; + font-size: 1rem; + color: var(--text-basic-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bar__sub { + font-size: 0.78rem; + color: var(--text-hint-color); +} + +.bar__actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + + [nbButton] { + min-height: 2.5rem; + } +} + +.bar__switch { + max-width: 13rem; +} + +.switch__icon { + margin-inline-end: 0.35rem; + color: var(--color-primary-600); +} + +.switch__current { + display: inline-block; + max-width: 9rem; + vertical-align: bottom; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.switch__role { + margin-inline-start: 0.4rem; + font-size: 0.78rem; + color: var(--text-hint-color); +} + +// ---------- page grid ---------- +.manage { + display: grid; + grid-template-columns: 16rem minmax(0, 1fr); + gap: 1.5rem; + max-width: 1120px; + margin: 0 auto; + width: 100%; + // The feed-like fade the whole app uses on entry; styles.scss turns it off + // under prefers-reduced-motion. + animation: manage-in var(--motion-slow) var(--ease-out); +} + +@keyframes manage-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +.manage__nav { + position: sticky; + top: calc(var(--header-height) + 1rem); + align-self: start; +} + +.side { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.5rem; + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + border-radius: var(--card-border-radius); + box-shadow: var(--card-shadow); +} + +.side__item { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0.7rem; + border-radius: var(--border-radius); + color: var(--text-basic-color); + text-decoration: none; + min-height: 2.75rem; + transition: background-color var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); + + &:hover { + background: var(--color-basic-transparent-100); + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } + + &.is-active { + background: var(--color-primary-transparent-100); + color: var(--color-primary-700); + + .side__icon, + .side__desc { + color: inherit; + } + } +} + +.side__icon { + flex-shrink: 0; + font-size: 1.25rem; + color: var(--text-hint-color); +} + +.side__text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.3; +} + +.side__title { + font-weight: 600; + font-size: 0.95rem; +} + +.side__desc { + font-size: 0.75rem; + color: var(--text-hint-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.side__sub { + display: flex; + flex-direction: column; + margin: 0 0 0.25rem; + padding-inline-start: 2.6rem; +} + +.side__subitem { + padding: 0.35rem 0.5rem; + border-radius: var(--border-radius); + font-size: 0.85rem; + color: var(--text-hint-color); + text-decoration: none; + min-height: 2.25rem; + display: flex; + align-items: center; + + &:hover { + background: var(--color-basic-transparent-100); + text-decoration: none; + } + + &.is-active { + color: var(--color-primary-700); + font-weight: 600; + } +} + +// ---------- phone tab strip ---------- +.manage__tabs { + display: none; + gap: 0.35rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0.25rem 0 0.5rem; + margin: 0 -1rem; + padding-inline: 1rem; + scroll-snap-type: x proximity; + + &::-webkit-scrollbar { + display: none; + } +} + +.tab { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.5rem; + padding: 0.4rem 0.85rem; + border-radius: 2rem; + border: 1px solid var(--border-basic-color-4); + background: var(--background-basic-color-1); + color: var(--text-basic-color); + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + scroll-snap-align: center; + + nb-icon { + font-size: 1.1rem; + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } + + &.is-active { + background: var(--color-primary-500); + border-color: var(--color-primary-500); + color: var(--text-control-color); + } +} + +// ---------- content ---------- +.manage__content { + min-width: 0; +} + +.intro { + margin-bottom: 1rem; +} + +.intro__title { + margin: 0 0 0.25rem; + font-size: 1.6rem; + font-weight: 800; + color: var(--text-basic-color); +} + +.intro__description { + margin: 0; + color: var(--text-hint-color); + line-height: 1.6; +} + +.intro__help { + display: block; + margin-top: 0.75rem; + max-width: 44rem; + + ::ng-deep nb-accordion-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + font-size: 0.9rem; + font-weight: 600; + color: var(--color-primary-700); + } + + ::ng-deep nb-accordion-item-body .item-body { + color: var(--text-basic-color); + line-height: 1.6; + } +} + +.state-card__body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2.5rem 1.5rem; + text-align: center; + color: var(--text-hint-color); + line-height: 1.6; + + p { + margin: 0; + max-width: 32rem; + } +} + +.state-card__icon { + font-size: 2.5rem; + color: var(--color-primary-400); +} + +.state-card__title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.state-card__actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: center; + margin-top: 0.75rem; +} + +// ---------- phone ---------- +@media (max-width: 767px) { + .manage { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .manage__nav { + display: none; + } + + .manage__tabs { + display: flex; + } + + .bar { + gap: 0.5rem; + } + + .bar__back-text, + .bar__hub-text, + .bar__sub { + display: none; + } + + .bar__switch { + max-width: 9rem; + } + + .intro__title { + font-size: 1.35rem; + } +} diff --git a/frontend/src/app/components/manage/manage-channel.component.ts b/frontend/src/app/components/manage/manage-channel.component.ts new file mode 100644 index 00000000..657cd760 --- /dev/null +++ b/frontend/src/app/components/manage/manage-channel.component.ts @@ -0,0 +1,320 @@ +import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { + NbAccordionModule, + NbButtonModule, + NbCardModule, + NbIconModule, + NbLayoutModule, + NbSelectModule, + NbSpinnerModule, + NbTooltipModule, +} from '@nebular/theme'; +import { Subscription } from 'rxjs'; +import { SlugService } from '../../services/slug.service'; +import { ChatService } from '../../services/chat.service'; +import { AuthService } from '../../services/auth.service'; +import { NotificationsService } from '../../services/notifications.service'; +import { MyChannelsService } from '../../services/my-channels.service'; +import { MyChannel, ROLE_LABELS, canManage } from '../../models/my-channel.model'; +import { ChannelInfoFormComponent } from '../channel/channel-info-form/channel-info-form.component'; +import { SettingsComponent } from '../admin/settings/settings.component'; +import { PrivilegDashboardComponent } from '../admin/privileg-dashboard/privileg-dashboard.component'; +import { StatisticsComponent } from '../admin/statistics/statistics.component'; +import { EmojisComponent } from '../admin/emojis/emojis.component'; +import { MagnetAdsComponent } from '../admin/magnet-ads/magnet-ads.component'; +import { StorageComponent } from '../admin/storage/storage.component'; +import { ReportsComponent } from '../admin/reports/reports.component'; +import { GuideComponent } from '../admin/guide/guide.component'; +import { SupportBoxComponent } from '../support/support-box.component'; + +type Access = 'moderator' | 'owner'; + +export interface ManageSection { + /** URL segment: /channel/:slug/manage/:id */ + id: string; + title: string; + icon: string; + access: Access; + /** One sentence shown under the title, in the user's words. */ + description: string; + /** A few words under the menu item. */ + nav: string; + /** The optional "מה זה?" note — only where the one-liner is not enough. */ + help?: string; +} + +type LoadError = '' | 'notfound' | 'forbidden' | 'network'; + +/** + * The channel's management screen as a page (/channel/:slug/manage/:tab): + * deep-linkable, back-button friendly and full width on a phone, instead of + * the dialog the header used to open over the feed. Sections are keyed by id + * and routed, so the navigation's selected state follows the URL, and a + * channel switcher in the header moves between the user's channels without + * leaving the section they are in. + */ +@Component({ + selector: 'app-manage-channel', + standalone: true, + imports: [ + RouterLink, + NbLayoutModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbSelectModule, + NbSpinnerModule, + NbTooltipModule, + NbAccordionModule, + ChannelInfoFormComponent, + SettingsComponent, + PrivilegDashboardComponent, + StatisticsComponent, + EmojisComponent, + MagnetAdsComponent, + StorageComponent, + ReportsComponent, + GuideComponent, + SupportBoxComponent, + ], + templateUrl: './manage-channel.component.html', + styleUrl: './manage-channel.component.scss', +}) +export class ManageChannelComponent implements OnInit, OnDestroy { + slug = ''; + tab = 'info'; + sections: ManageSection[] = []; + + loading = true; + loadError: LoadError = ''; + /** Set when the open tab's endpoint answered 403: the role changed under us. */ + accessDenied = false; + + switcherChannels: MyChannel[] = []; + /** The current channel is in the switcher only because a super admin is here, not by role. */ + switcherSynthetic = false; + readonly roleLabels = ROLE_LABELS; + + readonly reportFilters = [ + { id: 'reports', title: 'פתוחים' }, + { id: 'reports-closed', title: 'טופלו' }, + { id: 'reports-all', title: 'כל הדיווחים' }, + ]; + + readonly allSections: ManageSection[] = [ + { + id: 'info', title: 'פרטי הערוץ', icon: 'info-outline', access: 'moderator', + nav: 'שם, תיאור ולוגו', + description: 'שם, תיאור, לוגו וקישור ליצירת קשר — מה שהקוראים רואים בראש הערוץ.', + help: 'השינויים כאן נשמרים רק בלחיצה על "שמירה". כתובת הערוץ (הקישור שמשתפים) נקבעה בפתיחת הערוץ ואינה משתנה מכאן.', + }, + { + id: 'settings', title: 'הגדרות', icon: 'settings-2-outline', access: 'owner', + nav: 'קבצים, התראות, חיבורים', + description: 'גודל קבצים, התראות לנייד, פרסומת במסגרת וחיבור למערכות חיצוניות.', + help: 'כל השדות במסך נשמרים יחד בלחיצה על "שמירת שינויים". יכולת שהנהלת המערכת כיבתה לערוץ (למשל העלאת קבצים) לא מופיעה כאן, ואין דרך להדליק אותה מהמסך הזה.', + }, + { + id: 'users', title: 'צוות והרשאות', icon: 'people-outline', access: 'owner', + nav: 'מי כותב ומי מנהל', + description: 'מי כותב, מי מנהל ומי בעלים — ומוסיפים אנשים לפי כתובת המייל שלהם בגוגל.', + help: 'הוספה היא לפי כתובת מייל: האדם צריך להתחבר לאתר עם חשבון גוגל של אותה כתובת, ורק אז ההרשאה נכנסת לתוקף. השינויים נשמרים בלחיצה על "שמירה".', + }, + { + id: 'statistics', title: 'סטטיסטיקות', icon: 'bar-chart-outline', access: 'moderator', + nav: 'קוראים וחיבורים', + description: 'כמה קוראים נכנסו לערוץ, כמה מחוברים עכשיו, ושיא החיבורים לאורך זמן.', + }, + { + id: 'emojis', title: 'תגובות אימוג\'י', icon: 'smiling-face-outline', access: 'moderator', + nav: 'התגובות המותרות', + description: 'אילו אימוג\'ים הקוראים יכולים להגיב בהם על הודעות בערוץ.', + }, + { + id: 'magnet-ads', title: 'פרסומות ממגנט', icon: 'pricetags-outline', access: 'owner', + nav: 'פרסומות בין ההודעות', + description: 'שילוב פרסומות של מגנט ADS בין ההודעות — מופעל רק אם יש לכם חשבון וקוד הטמעה ממגנט.', + }, + { + id: 'storage', title: 'אחסון', icon: 'hard-drive-outline', access: 'owner', + nav: 'מקום לקבצים', + description: 'כמה מקום הקבצים שהועלו לערוץ תופסים, ומה קורה כשהמקום נגמר.', + }, + { + id: 'reports', title: 'דיווחים', icon: 'alert-triangle-outline', access: 'moderator', + nav: 'הודעות שסומנו', + description: 'הודעות שקוראים סימנו כבעייתיות — כאן סוגרים את הדיווח או מוחקים את ההודעה.', + }, + { + id: 'guide', title: 'מדריך למנהל', icon: 'book-open-outline', access: 'moderator', + nav: 'צעד אחר צעד', + description: 'כל מה שאפשר לעשות בערוץ, מסך אחרי מסך.', + }, + { + id: 'support', title: 'פנייה לתמיכה', icon: 'email-outline', access: 'moderator', + nav: 'שאלה או תקלה', + description: 'שאלה, תקלה או בקשה להנהלת המערכת — התשובה תופיע כאן.', + }, + ]; + + @ViewChild('tabStrip') private tabStrip?: ElementRef<HTMLElement>; + + private paramSub?: Subscription; + + constructor( + private route: ActivatedRoute, + private router: Router, + private slugService: SlugService, + private chatService: ChatService, + private authService: AuthService, + private notificationsService: NotificationsService, + private myChannels: MyChannelsService, + private titleService: Title, + ) {} + + /** Follows the channel info, so a rename in the "פרטי הערוץ" tab shows at once. */ + get channelName(): string { + return this.chatService.channelInfo?.name || this.slug; + } + + get logoUrl(): string { + return this.chatService.channelInfo?.logoUrl || ''; + } + + get initial(): string { + return (this.channelName || '?').trim().charAt(0).toUpperCase(); + } + + get section(): ManageSection | undefined { + return this.sections.find(s => this.isActive(s)); + } + + get showSwitcher(): boolean { + return this.switcherChannels.length > 1; + } + + get isSuperAdmin(): boolean { + return this.authService.userInfo?.globalRole === 'super_admin'; + } + + isActive(s: ManageSection): boolean { + return this.tab === s.id || (s.id === 'reports' && this.tab.startsWith('reports')); + } + + link(id: string): string[] { + return ['/channel', this.slug, 'manage', id]; + } + + ngOnInit(): void { + this.paramSub = this.route.paramMap.subscribe(params => { + const slug = params.get('slug') ?? ''; + const tab = params.get('tab'); + if (slug !== this.slug) { + this.slug = slug; + // The admin sub-components read the channel through SlugService and + // ChatService, exactly as the dialog did. + this.slugService.slug = slug; + this.chatService.channelInfo = undefined; + this.notificationsService.reset(); + this.loadChannel(); + this.buildMenu(); + this.loadSwitcher(); + } + if (!tab) { + this.router.navigate(this.link(this.sections[0]?.id ?? 'info'), { replaceUrl: true }); + return; + } + if (tab !== this.tab) this.accessDenied = false; + this.tab = tab; + this.updateTitle(); + this.revealActiveTab(); + }); + } + + ngOnDestroy(): void { + this.paramSub?.unsubscribe(); + } + + reload(): void { + this.loadChannel(); + } + + /** A tab's endpoint said 403: the role changed since sign-in. Re-read it and rebuild the menu. */ + onAccessDenied(): void { + this.accessDenied = true; + this.authService.reloadUserInfo() + .then(() => this.buildMenu()) + .catch(() => { /* the card already says what to do */ }); + } + + switchChannel(slug: string): void { + if (!slug || slug === this.slug) return; + const target = this.switcherChannels.find(c => c.slug === slug); + const current = this.section; + // A moderator on the other channel cannot open an owner-only section + // there, so land them on the first tab instead of an access card. + const keepTab = current && (current.access !== 'owner' || this.isSuperAdmin || target?.role === 'owner'); + this.router.navigate(['/channel', slug, 'manage', keepTab ? this.tab : 'info']); + } + + private loadChannel(): void { + this.loading = true; + this.loadError = ''; + const slug = this.slug; + this.chatService.updateChannelInfo() + .then(() => { if (slug === this.slug) this.updateTitle(); }) + .catch(err => { + if (slug !== this.slug) return; + const status = err?.status; + this.loadError = status === 404 ? 'notfound' : (status === 401 || status === 403) ? 'forbidden' : 'network'; + }) + .finally(() => { if (slug === this.slug) this.loading = false; }); + } + + private loadSwitcher(): void { + this.myChannels.list() + .then(rows => { + const list = rows.filter(r => this.isSuperAdmin || canManage(r.role)); + // A super admin can manage a channel they hold no role on; keep the + // current one selectable so the select never shows an empty label. + this.switcherSynthetic = !list.some(r => r.slug === this.slug); + if (this.switcherSynthetic) { + list.unshift({ + slug: this.slug, name: this.channelName, description: '', logoUrl: this.logoUrl, + role: 'owner', createdAt: '', disabled: false, participants: 0, + }); + } + this.switcherChannels = list; + }) + .catch(() => { this.switcherChannels = []; }); + } + + private updateTitle(): void { + const s = this.section; + this.titleService.setTitle(s ? `${s.title} · ${this.channelName}` : `ניהול · ${this.channelName}`); + } + + /** Scrolls the phone tab strip so the selected tab is visible after a deep link. */ + private revealActiveTab(): void { + setTimeout(() => { + const el = this.tabStrip?.nativeElement.querySelector<HTMLElement>('.is-active'); + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'nearest', inline: 'center' }); + } + }); + } + + private canAccess(access: Access): boolean { + const user = this.authService.userInfo; + if (user?.globalRole === 'super_admin') return true; + const role = user?.channelRoles?.[this.slug]; + if (access === 'owner') return role === 'owner'; + return role === 'owner' || role === 'moderator'; + } + + private buildMenu(): void { + this.sections = this.allSections.filter(s => this.canAccess(s.access)); + } +} diff --git a/frontend/src/app/components/shared/channel-seo-fields.component.html b/frontend/src/app/components/shared/channel-seo-fields.component.html new file mode 100644 index 00000000..7197d198 --- /dev/null +++ b/frontend/src/app/components/shared/channel-seo-fields.component.html @@ -0,0 +1,55 @@ +<div class="seo"> + <div class="seo__toggle"> + <nb-toggle [checked]="listed" (checkedChange)="listedChange.emit($event)" [disabled]="!!blockedReason"> + <span class="seo__toggle-label">הצגה במנועי חיפוש (גוגל)</span> + </nb-toggle> + <p class="seo__hint"> + @if (blockedReason) { + {{ blockedReason }} + } @else if (listed) { + הערוץ יכול להופיע בתוצאות החיפוש של גוגל, עם הכותרת והתיאור שלמטה. + } @else { + הערוץ לא יוצג בגוגל. הוא ממשיך לעבוד כרגיל לכל מי שיש לו את הקישור. + } + השינוי נכנס לתוקף באתר מיד בשמירה; גוגל מעדכן את התוצאות בביקור הבא שלו, וזה יכול לקחת כמה ימים. + </p> + </div> + + <div class="seo__field"> + <label class="seo__label" [for]="idPrefix + '-title'">כותרת בגוגל</label> + <p class="seo__hint">הכותרת של תוצאת החיפוש, וגם של התצוגה המקדימה כשמשתפים את הקישור. ריק: שם הערוץ.</p> + <input [id]="idPrefix + '-title'" type="text" nbInput fullWidth [ngModel]="seoTitle" (ngModelChange)="seoTitleChange.emit($event)" + [maxlength]="titleMax" [placeholder]="previewTitle" autocomplete="off"> + <span class="seo__counter" aria-live="polite">{{ (seoTitle || '').length }} / {{ titleMax }}</span> + </div> + + <div class="seo__field"> + <label class="seo__label" [for]="idPrefix + '-description'">תיאור בגוגל</label> + <p class="seo__hint">שתי השורות שמתחת לכותרת: במה הערוץ עוסק ולמי הוא מיועד. ריק: תיאור הערוץ.</p> + <textarea [id]="idPrefix + '-description'" nbInput fullWidth rows="2" [ngModel]="seoDescription" (ngModelChange)="seoDescriptionChange.emit($event)" + [maxlength]="descriptionMax" autocomplete="off" + placeholder="למשל: עדכונים, אירועים והודעות חשובות לתושבי השכונה"></textarea> + <span class="seo__counter" aria-live="polite">{{ (seoDescription || '').length }} / {{ descriptionMax }}</span> + </div> + + <figure class="serp" [class.serp--off]="!inSearch"> + <figcaption class="seo__label"> + <nb-icon icon="search-outline" aria-hidden="true"></nb-icon> + {{ inSearch ? 'כך התוצאה עשויה להיראות בגוגל' : 'הערוץ לא יופיע בגוגל' }} + </figcaption> + <div class="serp__card"> + <span class="serp__url" dir="ltr">{{ host }} › channel › {{ slug }}</span> + <span class="serp__title">{{ previewTitle }}</span> + <span class="serp__desc">{{ previewDescription }}</span> + </div> + </figure> + + <p class="seo__netfree"> + <nb-icon icon="info-outline" aria-hidden="true"></nb-icon> + <span> + משתמשי נטפרי? בקשו מנטפרי לפתוח את הערוץ הזה, בכתובת + <bdi class="seo__url" dir="ltr">{{ channelUrl }}</bdi>. + <a href="/netfree-channel" target="_blank" rel="noopener">איך מבקשים</a> + </span> + </p> +</div> diff --git a/frontend/src/app/components/shared/channel-seo-fields.component.scss b/frontend/src/app/components/shared/channel-seo-fields.component.scss new file mode 100644 index 00000000..65f1fb8b --- /dev/null +++ b/frontend/src/app/components/shared/channel-seo-fields.component.scss @@ -0,0 +1,105 @@ +:host { + display: block; +} + +.seo { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.seo__toggle-label, +.seo__label { + font-weight: 600; + color: var(--text-basic-color); +} + +.seo__label { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.2rem; +} + +.seo__hint { + margin: 0.25rem 0 0.5rem; + font-size: 0.85rem; + line-height: 1.5; + color: var(--text-hint-color); +} + +.seo__counter { + display: block; + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--text-hint-color); + text-align: end; +} + +// A search result, drawn with the theme's own tokens: close enough to say +// "this is what Google shows", without imitating its look. +.serp { + margin: 0; +} + +.serp__card { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border-basic-color-3); + border-radius: var(--border-radius); + background: var(--background-basic-color-2); + overflow-wrap: anywhere; +} + +.serp__url { + font-size: 0.8rem; + color: var(--text-hint-color); + text-align: start; +} + +.serp__title { + font-size: 1.1rem; + line-height: 1.35; + color: var(--color-primary-700); +} + +.serp__desc { + font-size: 0.875rem; + line-height: 1.5; + color: var(--text-basic-color); +} + +.serp--off .serp__card { + opacity: 0.55; +} + +.seo__netfree { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0; + padding: 0.65rem 0.85rem; + border-radius: var(--border-radius); + background: var(--color-primary-100); + color: var(--text-basic-color); + font-size: 0.875rem; + line-height: 1.6; + + nb-icon { + flex-shrink: 0; + margin-top: 0.15rem; + color: var(--color-primary-600); + } + + a { + color: var(--color-primary-700); + font-weight: 600; + } +} + +.seo__url { + overflow-wrap: anywhere; + font-weight: 600; +} diff --git a/frontend/src/app/components/shared/channel-seo-fields.component.ts b/frontend/src/app/components/shared/channel-seo-fields.component.ts new file mode 100644 index 00000000..c64c40af --- /dev/null +++ b/frontend/src/app/components/shared/channel-seo-fields.component.ts @@ -0,0 +1,68 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NbIconModule, NbInputModule, NbToggleModule } from '@nebular/theme'; + +/** + * A channel's search-engine settings: whether it may appear in Google, and + * the title and description the result shows, with a preview of that result. + * Used by the channel's own "פרטי הערוץ" screen and by the super admin's + * channel screen; each owns the save. The limits and the fallbacks mirror + * backend/channel_seo.go. + */ +@Component({ + selector: 'app-channel-seo-fields', + standalone: true, + imports: [FormsModule, NbIconModule, NbInputModule, NbToggleModule], + templateUrl: './channel-seo-fields.component.html', + styleUrl: './channel-seo-fields.component.scss', +}) +export class ChannelSeoFieldsComponent { + /** Shown in search (the inverse of the stored seoHidden). */ + @Input() listed = true; + @Output() listedChange = new EventEmitter<boolean>(); + @Input() seoTitle = ''; + @Output() seoTitleChange = new EventEmitter<string>(); + @Input() seoDescription = ''; + @Output() seoDescriptionChange = new EventEmitter<string>(); + + @Input() slug = ''; + @Input() channelName = ''; + @Input() channelDescription = ''; + /** Private or disabled: never in search, whatever the toggle says. */ + @Input() blockedReason = ''; + /** Distinguishes the inputs' ids when two copies share a page. */ + @Input() idPrefix = 'seo'; + + readonly titleMax = 60; + readonly descriptionMax = 155; + + private readonly siteName = + document.querySelector('meta[name="application-name"]')?.getAttribute('content') || 'הערוץ'; + + get host(): string { + return location.host; + } + + get channelUrl(): string { + return `${location.origin}/channel/${this.slug}`; + } + + get previewTitle(): string { + const t = oneLine(this.seoTitle); + return t || `${oneLine(this.channelName) || this.slug} · ${this.siteName}`; + } + + get previewDescription(): string { + const d = oneLine(this.seoDescription) || oneLine(this.channelDescription) + || `ערוץ העדכונים ${oneLine(this.channelName) || this.slug}: הודעות, תמונות ועדכונים, ישירות בדפדפן ובלי אפליקציה.`; + return d.length > 160 ? d.slice(0, 159).trimEnd() + '…' : d; + } + + get inSearch(): boolean { + return this.listed && !this.blockedReason; + } +} + +function oneLine(s: string | undefined | null): string { + return (s || '').split(/\s+/).filter(Boolean).join(' '); +} diff --git a/frontend/src/app/components/shared/confirm-dialog.component.ts b/frontend/src/app/components/shared/confirm-dialog.component.ts new file mode 100644 index 00000000..4a1a8db0 --- /dev/null +++ b/frontend/src/app/components/shared/confirm-dialog.component.ts @@ -0,0 +1,118 @@ +import { Component, Input } from '@angular/core'; +import { NbButtonModule, NbCardModule, NbDialogRef, NbIconModule } from '@nebular/theme'; + +export type ConfirmStatus = 'primary' | 'danger' | 'warning'; + +export interface ConfirmOptions { + title: string; + /** One or two sentences saying what happens, in the user's words. */ + message?: string; + confirmLabel?: string; + cancelLabel?: string; + /** 'danger' for anything that cannot be undone. */ + status?: ConfirmStatus; + /** Eva icon name. */ + icon?: string; +} + +/** + * The in-app replacement for window.confirm(): styled like the rest of the + * app, readable on a phone, closable with Escape or the backdrop, and the + * cancel button is the safe default. Opened through ConfirmService. + */ +@Component({ + selector: 'app-confirm-dialog', + standalone: true, + imports: [NbCardModule, NbButtonModule, NbIconModule], + template: ` + <nb-card class="confirm" role="alertdialog" aria-modal="true" + aria-labelledby="confirm-title" aria-describedby="confirm-message"> + <nb-card-body class="confirm__body"> + <div class="confirm__icon confirm__icon--{{ status }}" aria-hidden="true"> + <nb-icon [icon]="icon"></nb-icon> + </div> + <h2 class="confirm__title" id="confirm-title">{{ title }}</h2> + @if (message) { + <p class="confirm__message" id="confirm-message">{{ message }}</p> + } + <div class="confirm__actions"> + <button nbButton ghost status="basic" type="button" (click)="close(false)"> + {{ cancelLabel }} + </button> + <button nbButton [status]="status" type="button" (click)="close(true)"> + {{ confirmLabel }} + </button> + </div> + </nb-card-body> + </nb-card> + `, + styles: [` + .confirm { + width: min(92vw, 420px); + margin: 0; + } + .confirm__body { + text-align: center; + padding: 2rem 1.5rem 1.5rem; + } + .confirm__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + margin-bottom: 1rem; + font-size: 1.6rem; + background: var(--color-primary-100); + color: var(--color-primary-600); + } + .confirm__icon--danger { + background: var(--color-danger-100); + color: var(--color-danger-600); + } + .confirm__icon--warning { + background: var(--color-warning-100); + color: var(--color-warning-700); + } + .confirm__icon nb-icon { + font-size: inherit; + width: 1em; + height: 1em; + } + .confirm__title { + margin: 0 0 0.5rem; + font-size: 1.2rem; + font-weight: 700; + color: var(--text-basic-color); + } + .confirm__message { + margin: 0 0 1.5rem; + line-height: 1.6; + color: var(--text-hint-color); + } + .confirm__actions { + display: flex; + justify-content: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .confirm__actions button { + min-width: 7rem; + } + `], +}) +export class ConfirmDialogComponent { + @Input() title = 'לאשר את הפעולה?'; + @Input() message = ''; + @Input() confirmLabel = 'אישור'; + @Input() cancelLabel = 'ביטול'; + @Input() status: ConfirmStatus = 'primary'; + @Input() icon = 'question-mark-circle-outline'; + + constructor(private ref: NbDialogRef<ConfirmDialogComponent>) {} + + close(confirmed: boolean): void { + this.ref.close(confirmed); + } +} diff --git a/frontend/src/app/components/shared/magnet-stats-panel.component.html b/frontend/src/app/components/shared/magnet-stats-panel.component.html new file mode 100644 index 00000000..072f4265 --- /dev/null +++ b/frontend/src/app/components/shared/magnet-stats-panel.component.html @@ -0,0 +1,284 @@ +<nb-card class="ms"> + <nb-card-header> + <div class="ms-head"> + <div class="ms-head__text"> + <h2 class="ms-title"> + <nb-icon icon="pricetags-outline"></nb-icon> + {{ heading }} + </h2> + <p class="ms-head__sub"> + הרווחים מפרסומות מגנט + @if (stats?.domain) { + · <span class="ms-ltr ms-domain">{{ stats?.domain }}</span> + } + @if (updatedAgo && !loadingStats) { + · עודכן {{ updatedAgo }} + } + </p> + </div> + <button nbButton ghost status="basic" size="small" type="button" (click)="loadMagnetStats()" [disabled]="loadingStats"> + <nb-icon icon="refresh-outline"></nb-icon> + רענון + </button> + </div> + </nb-card-header> + + <nb-card-body> + @if (loadingStats && !stats) { + <!-- Skeleton: the layout of the real screen, so nothing jumps when it arrives. --> + <div class="ms-skeleton" role="status" aria-label="טוען את נתוני מגנט"> + <div class="ms-cards"> + <div class="ms-sk ms-sk--hero"></div> + <div class="ms-sk ms-sk--card"></div> + <div class="ms-sk ms-sk--card"></div> + <div class="ms-sk ms-sk--card"></div> + </div> + <div class="ms-sk ms-sk--chart"></div> + </div> + } @else if (magnetError) { + <div class="ms-state"> + <span class="ms-state__icon" [class.ms-state__icon--danger]="!magnetNotConfigured"> + <nb-icon [icon]="magnetNotConfigured ? 'pricetags-outline' : 'alert-triangle-outline'"></nb-icon> + </span> + <p class="ms-state__title">{{ magnetError }}</p> + @if (magnetNotConfigured && setupHint) { + <p class="ms-state__text">{{ setupHint }}</p> + } + @if (magnetKeyProblem && fixKeyLabel) { + <button nbButton status="primary" size="small" type="button" (click)="fixKey.emit()"> + <nb-icon icon="arrow-back-outline"></nb-icon> + {{ fixKeyLabel }} + </button> + } @else { + <button nbButton status="primary" size="small" type="button" (click)="loadMagnetStats()"> + <nb-icon icon="refresh-outline"></nb-icon> + ניסיון נוסף + </button> + } + </div> + } @else if (!stats) { + <div class="ms-state"> + <span class="ms-state__icon"><nb-icon icon="bar-chart-outline"></nb-icon></span> + <p class="ms-state__title">אין עדיין נתונים</p> + <p class="ms-state__text">מגנט לא החזיר נתונים לחשבון הזה. בדרך כלל זה אומר שעוד לא הוצגו פרסומות.</p> + </div> + } @else if (stats) { + <!-- Range --> + <div class="ms-seg" role="group" aria-label="טווח זמן"> + @for (r of ranges; track r.id) { + <button type="button" class="ms-seg__btn" [class.ms-seg__btn--on]="range === r.id" + [attr.aria-pressed]="range === r.id" (click)="setRange(r.id)">{{ r.label }}</button> + } + </div> + + <!-- Summary cards --> + @if (current; as cur) { + <div class="ms-cards"> + <div class="ms-card ms-card--hero"> + <span class="ms-card__label">רווח {{ rangeLabel }}</span> + <span class="ms-card__value ms-card__value--hero">{{ fmtMoney(cur.earnings) }}</span> + @if (stats.totalEarnings !== null) { + <span class="ms-card__note">מאז ההתחלה: {{ fmtMoney(stats.totalEarnings ?? 0) }}</span> + } + </div> + <div class="ms-card"> + <span class="ms-card__label"><nb-icon icon="navigation-2-outline"></nb-icon> קליקים</span> + <span class="ms-card__value">{{ fmtInt(cur.clicks) }}</span> + <span class="ms-card__note">{{ rangeLabel }} · קליקים שזיכו אתכם</span> + </div> + @if (impressionsCard; as imp) { + <div class="ms-card"> + <span class="ms-card__label"><nb-icon icon="eye-outline"></nb-icon> חשיפות</span> + <span class="ms-card__value">{{ fmtInt(imp.value) }}</span> + <span class="ms-card__note">{{ imp.note }}</span> + </div> + <div class="ms-card"> + <span class="ms-card__label"><nb-icon icon="trending-up-outline"></nb-icon> שיעור הקלקה</span> + <span class="ms-card__value">{{ fmtPct(imp.ctr) }}</span> + <span class="ms-card__note">קליקים מתוך החשיפות</span> + </div> + } + </div> + } + + @if (stats.balance !== null) { + <p class="ms-balance"> + <nb-icon icon="credit-card-outline"></nb-icon> + יתרה בחשבון מגנט: <strong>{{ fmtMoney(stats.balance ?? 0) }}</strong> + <span class="ms-muted">(כל האתרים בחשבון)</span> + </p> + } + + @if (hasDaily) { + @if (!hasClicksThisMonth) { + <div class="ms-state ms-empty"> + <span class="ms-state__icon"><nb-icon icon="bar-chart-outline"></nb-icon></span> + <p class="ms-state__title">עוד אין קליקים החודש</p> + <p class="ms-state__text"> + ברגע שקוראים ילחצו על פרסומות, הרווח יופיע כאן — יום אחרי יום, לפי מודעה ולפי שעה. + </p> + </div> + } + + <!-- 30-day chart --> + <section class="ms-section" aria-labelledby="ms-daily-title"> + <div class="ms-section__head"> + <h3 class="ms-section__title" id="ms-daily-title">30 הימים האחרונים</h3> + <div class="ms-seg ms-seg--small" role="group" aria-label="מה להציג בגרף"> + @for (m of metrics; track m.id) { + <button type="button" class="ms-seg__btn" [class.ms-seg__btn--on]="metric === m.id" + [attr.aria-pressed]="metric === m.id" (click)="setMetric(m.id)">{{ m.label }}</button> + } + </div> + </div> + + @if (metricHasData) { + <div class="ms-chart" aria-hidden="true" (mouseleave)="hoverIndex = null"> + <div class="ms-chart__plot" dir="ltr"> + @for (t of axisTicks; track t.pct) { + <div class="ms-chart__grid" [style.bottom.%]="t.pct"><span>{{ t.label }}</span></div> + } + <div class="ms-chart__bars"> + @for (bar of bars; track bar.day.date; let i = $index) { + <div class="ms-chart__col" (mouseenter)="hoverIndex = i" (click)="hoverIndex = i"> + @if (bar.value === null) { + <div class="ms-chart__bar ms-chart__bar--missing"></div> + } @else { + <div class="ms-chart__bar" + [class.ms-chart__bar--dim]="!bar.inRange" + [class.ms-chart__bar--hover]="hoverIndex === i" + [style.height.%]="bar.pct"></div> + } + </div> + } + @if (hoverBar; as hb) { + <div class="ms-tip" [style.left.%]="hoverLeft" dir="rtl"> + <span class="ms-tip__date">{{ fmtDayLong(hb.day.date) }}</span> + <span class="ms-tip__row"><span>רווח</span><strong>{{ fmtMoney(hb.day.earnings) }}</strong></span> + <span class="ms-tip__row"><span>קליקים</span><strong>{{ fmtInt(hb.day.clicks) }}</strong></span> + <span class="ms-tip__row"><span>חשיפות</span><strong>{{ formatMetric(hb.day.impressions, 'impressions') }}</strong></span> + </div> + } + </div> + </div> + <div class="ms-chart__x" dir="ltr"> + @for (x of xLabels; track x.index) { + <span [style.left.%]="((x.index + 0.5) / bars.length) * 100">{{ x.label }}</span> + } + </div> + </div> + @if (metric === 'impressions') { + <p class="ms-hint">מגנט שומר חשיפות {{ stats.impressionsDays }} ימים בלבד, ולכן הימים הקודמים ריקים.</p> + } + } @else { + <p class="ms-hint ms-hint--center">אין {{ metric === 'earnings' ? 'רווח' : metric === 'clicks' ? 'קליקים' : 'חשיפות' }} להציג ב-30 הימים האחרונים.</p> + } + + <!-- The same numbers as a table, for screen readers and keyboard users. --> + <table class="ms-sr"> + <caption>נתונים יומיים, 30 הימים האחרונים</caption> + <thead><tr><th scope="col">יום</th><th scope="col">רווח</th><th scope="col">קליקים</th><th scope="col">חשיפות</th></tr></thead> + <tbody> + @for (d of tableDays; track d.date) { + <tr> + <th scope="row">{{ fmtDayLong(d.date) }}</th> + <td>{{ fmtMoney(d.earnings) }}</td> + <td>{{ fmtInt(d.clicks) }}</td> + <td>{{ formatMetric(d.impressions, 'impressions') }}</td> + </tr> + } + </tbody> + </table> + </section> + } + + <!-- Per ad --> + @if (adRows.length) { + <section class="ms-section" aria-labelledby="ms-ads-title"> + <div class="ms-section__head"> + <h3 class="ms-section__title" id="ms-ads-title">לפי מודעה</h3> + <span class="ms-muted">רווח וקליקים ב-30 יום · חשיפות ב-{{ stats.impressionsDays }} ימים</span> + </div> + <ul class="ms-list"> + @for (ad of adRows; track ad.id) { + <li class="ms-row"> + <div class="ms-row__top"> + <span class="ms-row__name">{{ ad.title || 'מודעה ללא כותרת' }}</span> + <strong class="ms-row__money">{{ fmtMoney(ad.earnings) }}</strong> + </div> + <div class="ms-meter" aria-hidden="true"><span [style.width.%]="ad.share"></span></div> + <div class="ms-row__meta"> + <span>{{ fmtInt(ad.clicks) }} קליקים</span> + <span>{{ fmtInt(ad.impressionsWeek) }} חשיפות</span> + <span>שיעור הקלקה {{ fmtPct(ad.ctrWeek) }}</span> + </div> + </li> + } + </ul> + </section> + } + + <!-- Per channel: drawn only once Magnet sends it. --> + @if (placements.length) { + <section class="ms-section" aria-labelledby="ms-ch-title"> + <div class="ms-section__head"> + <h3 class="ms-section__title" id="ms-ch-title">לפי ערוץ</h3> + <span class="ms-muted">30 יום</span> + </div> + <ul class="ms-list"> + @for (p of placements; track p.name) { + <li class="ms-row"> + <div class="ms-row__top"> + <span class="ms-row__name ms-ltr">{{ p.name }}</span> + <strong class="ms-row__money">{{ fmtMoney(p.earnings) }}</strong> + </div> + <div class="ms-meter" aria-hidden="true"><span [style.width.%]="p.share"></span></div> + <div class="ms-row__meta"><span>{{ fmtInt(p.clicks) }} קליקים</span></div> + </li> + } + </ul> + </section> + } + + @if (peakHour !== null || devices.length) { + <div class="ms-split"> + @if (peakHour !== null) { + <section class="ms-section" aria-labelledby="ms-hours-title"> + <div class="ms-section__head"> + <h3 class="ms-section__title" id="ms-hours-title">מתי לוחצים</h3> + <span class="ms-muted">שיא: {{ hourLabel(peakHour) }}</span> + </div> + <div class="ms-hours" dir="ltr" role="img" + [attr.aria-label]="'הכי הרבה קליקים בשעה ' + hourLabel(peakHour)"> + @for (h of hourBars; track h.hour) { + <div class="ms-hours__col" [attr.title]="hourLabel(h.hour) + ' · ' + fmtInt(h.clicks) + ' קליקים'"> + <div class="ms-hours__bar" [class.ms-hours__bar--peak]="h.hour === peakHour" [style.height.%]="h.pct"></div> + </div> + } + </div> + <div class="ms-hours__x" dir="ltr"><span>00</span><span>06</span><span>12</span><span>18</span><span>23</span></div> + </section> + } + @if (devices.length) { + <section class="ms-section" aria-labelledby="ms-dev-title"> + <div class="ms-section__head"> + <h3 class="ms-section__title" id="ms-dev-title">מאיזה מכשיר</h3> + <span class="ms-muted">קליקים ב-30 יום</span> + </div> + <ul class="ms-list ms-list--tight"> + @for (d of devices; track d.id) { + <li class="ms-dev"> + <nb-icon [icon]="d.icon"></nb-icon> + <span class="ms-dev__name">{{ d.label }}</span> + <div class="ms-meter" aria-hidden="true"><span [style.width.%]="d.share"></span></div> + <span class="ms-dev__pct">{{ fmtPct(d.share / 100) }}</span> + </li> + } + </ul> + </section> + } + </div> + } + } + </nb-card-body> +</nb-card> diff --git a/frontend/src/app/components/shared/magnet-stats-panel.component.scss b/frontend/src/app/components/shared/magnet-stats-panel.component.scss new file mode 100644 index 00000000..e67c2295 --- /dev/null +++ b/frontend/src/app/components/shared/magnet-stats-panel.component.scss @@ -0,0 +1,346 @@ +// Every colour here is a theme token, so the panel follows themes.scss. +// It is drawn inside the super-admin panel and inside a channel's manage page, +// so it carries its own copies of the few layout rules it used to borrow from +// the panel's global .sa-* classes. +:host { + display: block; +} + +.ms { margin: 0; } + +.ms-ltr { direction: ltr; unicode-bidi: isolate; display: inline-block; } + +.ms-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--text-basic-color); +} +.ms-title nb-icon { color: var(--color-primary-500); } + +.ms-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.ms-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + text-align: center; + color: var(--text-hint-color); +} +.ms-state__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + font-size: 1.6rem; + background: var(--color-primary-100); + color: var(--color-primary-600); +} +.ms-state__icon--danger { background: var(--color-danger-100); color: var(--color-danger-600); } +.ms-state__title { margin: 0; font-weight: 700; color: var(--text-basic-color); } +.ms-state__text { margin: 0; max-width: 46ch; line-height: 1.6; } +.ms-state [nbButton] { margin-top: 0.5rem; } + +.ms-head__text { min-width: 0; } +.ms-head__sub { + margin: 0.25rem 0 0; + font-size: 0.85rem; + color: var(--text-hint-color); +} +.ms-domain { font-size: 0.8rem; } +.ms-muted { font-size: 0.8rem; color: var(--text-hint-color); } + +// ─── Segmented switch (range, metric) ────────────────────────────────────── +.ms-seg { + display: inline-flex; + gap: 0.25rem; + padding: 0.25rem; + margin-bottom: 1rem; + border-radius: 0.75rem; + background: var(--background-basic-color-2); + border: 1px solid var(--border-basic-color-3); +} +.ms-seg--small { margin: 0; } +.ms-seg__btn { + border: 0; + background: transparent; + color: var(--text-hint-color); + font: inherit; + font-weight: 600; + font-size: 0.875rem; + padding: 0.4rem 0.9rem; + min-height: 2.25rem; + border-radius: 0.5rem; + cursor: pointer; +} +.ms-seg__btn:hover { color: var(--text-basic-color); } +.ms-seg__btn:focus-visible { outline: 2px solid var(--color-primary-500); outline-offset: 1px; } +.ms-seg__btn--on { + background: var(--background-basic-color-1); + color: var(--color-primary-700); + box-shadow: 0 1px 2px var(--color-basic-transparent-300); +} +.ms-seg--small .ms-seg__btn { font-size: 0.8rem; padding: 0.3rem 0.7rem; min-height: 2rem; } + +// ─── Summary cards ───────────────────────────────────────────────────────── +.ms-cards { + display: grid; + grid-template-columns: 1.4fr repeat(3, 1fr); + gap: 0.75rem; +} +.ms-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + padding: 1rem 1.1rem; + border-radius: 0.875rem; + background: var(--background-basic-color-2); + border: 1px solid var(--border-basic-color-3); +} +.ms-card__label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-hint-color); +} +.ms-card__label nb-icon { font-size: 1rem; width: 1rem; height: 1rem; color: var(--color-primary-500); } +.ms-card__value { + font-size: 1.6rem; + font-weight: 800; + line-height: 1.2; + color: var(--text-basic-color); + font-variant-numeric: tabular-nums; +} +.ms-card__note { font-size: 0.78rem; color: var(--text-hint-color); } + +.ms-card--hero { + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + border-color: transparent; +} +.ms-card--hero .ms-card__label, +.ms-card--hero .ms-card__note { color: var(--color-primary-100); } +.ms-card__value--hero { font-size: 2.1rem; color: var(--color-basic-100); } + +.ms-balance { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.75rem 0 0; + font-size: 0.9rem; + color: var(--text-basic-color); +} +.ms-balance nb-icon { color: var(--color-primary-500); } + +// ─── Sections ────────────────────────────────────────────────────────────── +.ms-section { margin-top: 1.5rem; min-width: 0; } +.ms-section__head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.ms-section__title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--text-basic-color); } +.ms-hint { margin: 0.5rem 0 0; font-size: 0.8rem; color: var(--text-hint-color); } +.ms-hint--center { text-align: center; padding: 2rem 0; } +.ms-empty { margin-top: 1rem; } + +// ─── 30-day bar chart ────────────────────────────────────────────────────── +.ms-chart { position: relative; } +.ms-chart__plot { + position: relative; + height: 220px; + border-bottom: 1px solid var(--border-basic-color-4); +} +.ms-chart__grid { + position: absolute; + left: 3.25rem; + right: 0; + border-top: 1px dashed var(--border-basic-color-3); +} +.ms-chart__grid span { + position: absolute; + left: -3.25rem; + width: 3rem; + transform: translateY(-50%); + text-align: right; + font-size: 0.7rem; + color: var(--text-hint-color); + font-variant-numeric: tabular-nums; +} +.ms-chart__bars { + position: absolute; + inset: 0 0 0 3.25rem; + display: flex; + align-items: flex-end; +} +.ms-chart__col { + flex: 1 1 0; + height: 100%; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0 1px; // the 2px gap between bars + cursor: default; +} +.ms-chart__bar { + width: 100%; + max-width: 1.5rem; + border-radius: 4px 4px 0 0; + background: var(--color-primary-500); + transition: background-color 0.15s; +} +.ms-chart__bar--dim { background: var(--color-primary-200); } +.ms-chart__bar--hover { background: var(--color-primary-700); } +.ms-chart__bar--missing { + height: 3px; + background: repeating-linear-gradient(90deg, var(--border-basic-color-4) 0 3px, transparent 3px 6px); +} +.ms-chart__x { + position: relative; + height: 1.4rem; + margin-inline-start: 3.25rem; +} +.ms-chart__x span { + position: absolute; + top: 0.25rem; + transform: translateX(-50%); + font-size: 0.7rem; + color: var(--text-hint-color); + white-space: nowrap; +} + +.ms-tip { + position: absolute; + top: 0; + transform: translateX(-50%); + z-index: 2; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 9.5rem; + padding: 0.5rem 0.7rem; + border-radius: 0.5rem; + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + box-shadow: var(--shadow); + font-size: 0.8rem; + color: var(--text-basic-color); + pointer-events: none; +} +.ms-tip__date { font-weight: 700; margin-bottom: 0.1rem; } +.ms-tip__row { display: flex; justify-content: space-between; gap: 1rem; color: var(--text-hint-color); } +.ms-tip__row strong { color: var(--text-basic-color); font-variant-numeric: tabular-nums; } + +// Visually hidden, read by screen readers. +.ms-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +// ─── Ranked rows (per ad / per channel / devices) ────────────────────────── +.ms-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; } +.ms-list--tight { gap: 0.6rem; } +.ms-row__top { display: flex; justify-content: space-between; align-items: baseline; gap: 0.75rem; } +.ms-row__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + color: var(--text-basic-color); +} +.ms-row__money { white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text-basic-color); } +.ms-row__meta { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-top: 0.3rem; + font-size: 0.78rem; + color: var(--text-hint-color); +} +.ms-meter { + height: 0.5rem; + margin-top: 0.35rem; + border-radius: 999px; + background: var(--background-basic-color-3); + overflow: hidden; +} +.ms-meter span { + display: block; + height: 100%; + min-width: 2px; + border-radius: 999px; + background: var(--color-primary-500); +} + +.ms-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } + +.ms-hours { + display: flex; + align-items: flex-end; + height: 110px; + border-bottom: 1px solid var(--border-basic-color-4); +} +.ms-hours__col { flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; padding: 0 1px; } +.ms-hours__bar { width: 100%; border-radius: 3px 3px 0 0; background: var(--color-primary-300); } +.ms-hours__bar--peak { background: var(--color-primary-600); } +.ms-hours__x { + display: flex; + justify-content: space-between; + margin-top: 0.25rem; + font-size: 0.7rem; + color: var(--text-hint-color); +} + +.ms-dev { display: grid; grid-template-columns: 1.25rem 4.5rem 1fr 3.5rem; align-items: center; gap: 0.5rem; } +.ms-dev nb-icon { color: var(--text-hint-color); } +.ms-dev__name { font-size: 0.875rem; color: var(--text-basic-color); } +.ms-dev .ms-meter { margin: 0; } +.ms-dev__pct { font-size: 0.8rem; text-align: end; color: var(--text-hint-color); font-variant-numeric: tabular-nums; } + +// ─── Skeleton ────────────────────────────────────────────────────────────── +.ms-sk { + border-radius: 0.875rem; + background: linear-gradient(90deg, var(--background-basic-color-2) 25%, var(--background-basic-color-3) 50%, var(--background-basic-color-2) 75%); + background-size: 200% 100%; + animation: ms-shimmer 1.4s ease-in-out infinite; +} +.ms-sk--hero, .ms-sk--card { height: 6.5rem; } +.ms-sk--chart { height: 15rem; margin-top: 1.5rem; } +@keyframes ms-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } +@media (prefers-reduced-motion: reduce) { .ms-sk { animation: none; } } + +// ─── Phones ──────────────────────────────────────────────────────────────── +@media (max-width: 767px) { + .ms-cards { grid-template-columns: 1fr 1fr; } + .ms-card--hero, .ms-sk--hero { grid-column: 1 / -1; } + .ms-card__value { font-size: 1.35rem; } + .ms-card__value--hero { font-size: 1.9rem; } + .ms-split { grid-template-columns: 1fr; } + .ms-chart__plot { height: 180px; } + .ms-seg { display: flex; } + .ms-seg__btn { flex: 1 1 0; } +} diff --git a/frontend/src/app/components/shared/magnet-stats-panel.component.ts b/frontend/src/app/components/shared/magnet-stats-panel.component.ts new file mode 100644 index 00000000..e18af039 --- /dev/null +++ b/frontend/src/app/components/shared/magnet-stats-panel.component.ts @@ -0,0 +1,321 @@ +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbToastrService, +} from '@nebular/theme'; +import { magnetStatsError } from '../../services/magnet-stats-errors'; +import { + MagnetAdRow, + MagnetDay, + MagnetRange, + MagnetStatsView, + fmtAgo, + fmtDayLong, + fmtDayShort, + fmtInt, + fmtMoney, + fmtMoneyShort, + fmtPct, + niceMax, + toMagnetStatsView, +} from '../../services/magnet-stats.model'; + +type ChartMetric = 'earnings' | 'clicks' | 'impressions'; + +interface ChartBar { + day: MagnetDay; + value: number | null; + /** 0..100, the bar's height as a share of the axis. */ + pct: number; + inRange: boolean; + label: string; +} + +const RANGES: { id: MagnetRange; label: string; days: number }[] = [ + { id: 'today', label: 'היום', days: 1 }, + { id: 'week', label: '7 ימים', days: 7 }, + { id: 'month', label: '30 יום', days: 30 }, +]; + +const METRICS: { id: ChartMetric; label: string }[] = [ + { id: 'earnings', label: 'רווח' }, + { id: 'clicks', label: 'קליקים' }, + { id: 'impressions', label: 'חשיפות' }, +]; + +const DEVICE_LABELS: { id: 'mobile' | 'desktop' | 'tablet' | 'unknown'; label: string; icon: string }[] = [ + { id: 'mobile', label: 'נייד', icon: 'smartphone-outline' }, + { id: 'desktop', label: 'מחשב', icon: 'monitor-outline' }, + { id: 'tablet', label: 'טאבלט', icon: 'browser-outline' }, + { id: 'unknown', label: 'לא ידוע', icon: 'question-mark-circle-outline' }, +]; + +/** + * Magnet's numbers for one site: summary cards, range switch, 30-day chart and + * the per-ad / per-hour / per-device breakdowns. Drawn by the super-admin + * statistics screen (the platform's own site, global key) and by an unlocked + * channel's magnet tab (the channel's own site, its own key). Each host only + * says where the answer comes from and where a broken key is fixed, so the + * two screens cannot drift apart in how they read Magnet. + */ +@Component({ + selector: 'app-magnet-stats-panel', + standalone: true, + imports: [ + CommonModule, + NbCardModule, + NbButtonModule, + NbIconModule, + ], + templateUrl: './magnet-stats-panel.component.html', + styleUrl: './magnet-stats-panel.component.scss', +}) +export class MagnetStatsPanelComponent implements OnInit, OnChanges, OnDestroy { + /** Fetches Magnet's raw answer (the backend proxy for this screen). */ + @Input({ required: true }) source!: () => Promise<unknown>; + @Input() heading = 'הפרסומות באתר'; + /** Shown under a "key not set" message: where and how to set one. */ + @Input() setupHint = ''; + /** Label of the button that leads to where the key is fixed. */ + @Input() fixKeyLabel = ''; + /** The key is missing or rejected and the host knows where to fix it. */ + @Output() fixKey = new EventEmitter<void>(); + + stats: MagnetStatsView | null = null; + loadingStats = true; + /** Shown in the magnet card instead of the numbers. */ + magnetError = ''; + /** True when the key is simply not set up — a setup hint, not a failure. */ + magnetNotConfigured = false; + /** The fix is entering or replacing the key. */ + magnetKeyProblem = false; + + readonly ranges = RANGES; + readonly metrics = METRICS; + range: MagnetRange = 'month'; + metric: ChartMetric = 'earnings'; + + /** Computed once per answer / selection, not on every change detection. */ + bars: ChartBar[] = []; + axisMax = 0; + axisTicks: { pct: number; label: string }[] = []; + xLabels: { index: number; label: string }[] = []; + hoverIndex: number | null = null; + adRows: (MagnetAdRow & { share: number })[] = []; + hourBars: { hour: number; clicks: number; pct: number }[] = []; + peakHour: number | null = null; + devices: { id: string; label: string; icon: string; clicks: number; share: number }[] = []; + placements: { name: string; clicks: number; earnings: number; share: number }[] = []; + + updatedAgo = ''; + private agoTimer?: ReturnType<typeof setInterval>; + /** Drops an answer that arrives after a newer request (or a new source). */ + private requestSeq = 0; + + readonly fmtInt = fmtInt; + readonly fmtMoney = fmtMoney; + readonly fmtPct = fmtPct; + readonly fmtDayLong = fmtDayLong; + + constructor(private toastr: NbToastrService) {} + + ngOnInit(): void { + // "עודכן לפני X דקות" keeps moving while the screen stays open. + this.agoTimer = setInterval(() => this.refreshAgo(), 30_000); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['source']) this.loadMagnetStats(); + } + + ngOnDestroy(): void { + if (this.agoTimer) clearInterval(this.agoTimer); + } + + loadMagnetStats() { + if (!this.source) return; + const seq = ++this.requestSeq; + this.loadingStats = true; + this.magnetError = ''; + this.magnetNotConfigured = false; + this.magnetKeyProblem = false; + this.source() + .then(raw => { + if (seq !== this.requestSeq) return; + this.stats = toMagnetStatsView(raw); + this.rebuild(); + }) + .catch(err => { + if (seq !== this.requestSeq) return; + // A missing key is the normal state of a site that never used + // Magnet, not an outage: a setup hint here, and no warning toast. + const { message, notConfigured, keyProblem } = magnetStatsError(err); + this.stats = null; + this.magnetError = message; + this.magnetNotConfigured = notConfigured; + this.magnetKeyProblem = keyProblem; + if (!notConfigured) this.toastr.warning('', 'הנתונים ממגנט לא נטענו'); + }) + .finally(() => { + if (seq === this.requestSeq) this.loadingStats = false; + }); + } + + // ─── What the template reads ──────────────────────────────────────────── + + get current() { + return this.stats?.summary[this.range] ?? null; + } + + get rangeLabel(): string { + switch (this.range) { + case 'today': return 'היום'; + case 'week': return 'ב-7 הימים האחרונים'; + default: return 'ב-30 הימים האחרונים'; + } + } + + /** + * Impressions are kept 7 days, so the 30-day view shows the 7-day figure + * and says so, instead of a blank card. + */ + get impressionsCard(): { value: number; ctr: number | null; note: string } | null { + const s = this.stats; + if (!s) return null; + const cur = s.summary[this.range]; + if (cur.impressions !== null) return { value: cur.impressions, ctr: cur.ctr, note: this.rangeLabel }; + const week = s.summary.week; + if (this.range === 'month' && week.impressions !== null) { + return { value: week.impressions, ctr: week.ctr, note: `ב-${s.impressionsDays} הימים האחרונים · נשמרות ${s.impressionsDays} ימים` }; + } + return null; + } + + get hasClicksThisMonth(): boolean { + return (this.stats?.summary.month.clicks ?? 0) > 0; + } + + get hasDaily(): boolean { + return !!this.stats?.daily?.length; + } + + get metricHasData(): boolean { + return this.bars.some(b => (b.value ?? 0) > 0); + } + + get hoverBar(): ChartBar | null { + return this.hoverIndex === null ? null : this.bars[this.hoverIndex] ?? null; + } + + /** The tooltip's horizontal anchor, kept inside the chart. */ + get hoverLeft(): number { + if (this.hoverIndex === null || !this.bars.length) return 50; + const center = ((this.hoverIndex + 0.5) / this.bars.length) * 100; + return Math.min(85, Math.max(15, center)); + } + + setRange(r: MagnetRange) { + this.range = r; + this.buildChart(); + } + + setMetric(m: ChartMetric) { + this.metric = m; + this.hoverIndex = null; + this.buildChart(); + } + + formatMetric(v: number | null, metric: ChartMetric = this.metric): string { + if (v === null) return 'לא נשמר'; + return metric === 'earnings' ? fmtMoney(v) : fmtInt(v); + } + + hourLabel(h: number): string { + return `${String(h).padStart(2, '0')}:00`; + } + + // ─── Derived data ─────────────────────────────────────────────────────── + + private rebuild() { + this.refreshAgo(); + this.buildChart(); + this.buildBreakdowns(); + } + + private refreshAgo() { + const at = this.stats?.generatedAt; + this.updatedAgo = at ? fmtAgo(at, new Date()) : ''; + } + + private buildChart() { + const daily = this.stats?.daily; + if (!daily?.length) { + this.bars = []; + this.axisTicks = []; + this.xLabels = []; + return; + } + const metric = this.metric; + const rangeDays = RANGES.find(r => r.id === this.range)?.days ?? 30; + const values = daily.map(d => metric === 'impressions' ? d.impressions : d[metric]); + const max = niceMax(Math.max(0, ...values.map(v => v ?? 0))); + this.axisMax = max; + this.bars = daily.map((day, i) => ({ + day, + value: values[i], + pct: max > 0 && values[i] ? Math.max(1.5, ((values[i] as number) / max) * 100) : 0, + inRange: i >= daily.length - rangeDays, + label: `${fmtDayLong(day.date)}: ${this.formatMetric(values[i], metric)}`, + })); + this.axisTicks = max > 0 + ? [1, 0.5].map(f => ({ + pct: f * 100, + label: metric === 'earnings' ? fmtMoneyShort(max * f) : fmtInt(max * f), + })) + : []; + // A date under every 7th bar, counted back from today so today is labelled. + this.xLabels = daily + .map((d, i) => ({ index: i, label: fmtDayShort(d.date) })) + .filter(x => (daily.length - 1 - x.index) % 7 === 0); + } + + private buildBreakdowns() { + const s = this.stats; + const ads = s?.byAd ?? []; + const maxEarn = Math.max(0, ...ads.map(a => a.earnings)); + const maxClicks = Math.max(0, ...ads.map(a => a.clicks)); + this.adRows = ads.map(a => ({ + ...a, + share: maxEarn > 0 ? (a.earnings / maxEarn) * 100 : maxClicks > 0 ? (a.clicks / maxClicks) * 100 : 0, + })); + + const hours = s?.byHour ?? []; + const hourMax = Math.max(0, ...hours); + this.hourBars = hours.map((clicks, hour) => ({ + hour, + clicks, + pct: hourMax > 0 && clicks > 0 ? Math.max(3, (clicks / hourMax) * 100) : 0, + })); + this.peakHour = hourMax > 0 ? hours.indexOf(hourMax) : null; + + const dev = s?.byDevice; + const devTotal = dev ? dev.mobile + dev.desktop + dev.tablet + dev.unknown : 0; + this.devices = dev && devTotal > 0 + ? DEVICE_LABELS + .map(d => ({ ...d, clicks: dev[d.id], share: (dev[d.id] / devTotal) * 100 })) + .filter(d => d.clicks > 0) + : []; + + const pl = s?.byPlacement ?? []; + const plMax = Math.max(0, ...pl.map(p => p.earnings)); + this.placements = pl.map(p => ({ ...p, share: plMax > 0 ? (p.earnings / plMax) * 100 : 0 })); + } + + /** The whole series, newest first, for the table behind the chart. */ + get tableDays(): MagnetDay[] { + return this.stats?.daily ? [...this.stats.daily].reverse() : []; + } +} diff --git a/frontend/src/app/components/shared/netfree-request.component.html b/frontend/src/app/components/shared/netfree-request.component.html new file mode 100644 index 00000000..bc9868a0 --- /dev/null +++ b/frontend/src/app/components/shared/netfree-request.component.html @@ -0,0 +1,43 @@ +<section class="nf" [attr.aria-labelledby]="'nf-title-' + slug"> + <div class="nf__head"> + <nb-icon icon="shield-outline" aria-hidden="true" class="nf__icon"></nb-icon> + <div> + <h3 class="nf__title" [id]="'nf-title-' + slug">גולשי נטפרי? צריך לבקש פתיחה של הערוץ</h3> + <p class="nf__text"> + ערוץ חדש חסום בנטפרי עד שהם מאשרים אותו. הכנו לכם נוסח פנייה עם כתובת הערוץ ועם תשובות לשאלות שנטפרי שואלים. + מעתיקים, ממלאים את הפרטים החסרים (______), ושולחים בפנייה חדשה לנטפרי עם <strong>וידאו סלפי</strong> + שבו אתם מתחייבים לעמוד בכללים. + </p> + </div> + </div> + + <div class="nf__actions"> + <button nbButton status="primary" size="small" type="button" (click)="copy()" aria-live="polite"> + <nb-icon aria-hidden="true" [icon]="copied ? 'checkmark-outline' : 'copy-outline'"></nb-icon> + {{ copied ? 'הנוסח הועתק ✓' : 'העתקת נוסח הפנייה' }} + </button> + <a nbButton appearance="outline" status="primary" size="small" [href]="ticketUrl" target="_blank" rel="noopener"> + <nb-icon aria-hidden="true" icon="external-link-outline"></nb-icon> + פתיחת פנייה בנטפרי + </a> + <button nbButton ghost status="basic" size="small" type="button" (click)="toggle()" + [attr.aria-expanded]="!collapsed"> + <nb-icon aria-hidden="true" [icon]="collapsed ? 'chevron-down-outline' : 'chevron-up-outline'"></nb-icon> + {{ collapsed ? 'הצגת הנוסח' : 'הסתרת הנוסח' }} + </button> + </div> + + @if (copyFailed) { + <p class="nf__warn" role="alert">הדפדפן חסם את ההעתקה. הנוסח מסומן למטה, העתיקו אותו ידנית.</p> + } + + @if (!collapsed) { + <textarea #requestText class="nf__textarea" readonly rows="14" dir="rtl" + aria-label="נוסח הפנייה לנטפרי" [value]="text"></textarea> + } + + <p class="nf__foot"> + כפתור הפנייה דורש חשבון בנטפרי. אין חשבון? אפשר לפנות לנטפרי בכל דרך אחרת עם אותו נוסח. + <a href="/netfree-channel" target="_blank" rel="noopener">כל ההסבר</a> + </p> +</section> diff --git a/frontend/src/app/components/shared/netfree-request.component.scss b/frontend/src/app/components/shared/netfree-request.component.scss new file mode 100644 index 00000000..4ca33e77 --- /dev/null +++ b/frontend/src/app/components/shared/netfree-request.component.scss @@ -0,0 +1,66 @@ +:host { + display: block; +} + +.nf { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--border-basic-color-3); + border-radius: var(--border-radius); + background: var(--background-basic-color-2); + text-align: start; +} + +.nf__head { + display: flex; + align-items: flex-start; + gap: 0.6rem; +} + +.nf__icon { + flex: none; + margin-top: 0.15rem; + color: var(--color-primary-500); +} + +.nf__title { + margin: 0 0 0.25rem; + font-size: 1rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.nf__text, +.nf__foot, +.nf__warn { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--text-hint-color); +} + +.nf__warn { + color: var(--color-danger-600); +} + +.nf__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.nf__textarea { + width: 100%; + box-sizing: border-box; + padding: 0.75rem; + font: inherit; + font-size: 0.85rem; + line-height: 1.55; + color: var(--text-basic-color); + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-4); + border-radius: var(--border-radius); + resize: vertical; +} diff --git a/frontend/src/app/components/shared/netfree-request.component.ts b/frontend/src/app/components/shared/netfree-request.component.ts new file mode 100644 index 00000000..a63ef961 --- /dev/null +++ b/frontend/src/app/components/shared/netfree-request.component.ts @@ -0,0 +1,114 @@ +import { Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { NbButtonModule, NbIconModule } from '@nebular/theme'; +import { ShareService } from '../../services/share.service'; + +/** NetFree's "new ticket" page; it needs a NetFree account. */ +export const NETFREE_TICKET_URL = 'https://netfree.link/app/#/tickets/new'; + +/** + * "פתיחת הערוץ בנטפרי": a ready request for NetFree, filled with the channel's + * address and with an answer to every question NetFree asks before opening a + * content site (ownership, audience, whether readers can write, the policy + * commitment, the personal details and the selfie video). The owner copies + * it, fills the blanks and pastes it into a NetFree ticket. Shown on the + * create-success card and on the channel's "פרטי הערוץ" screen. + */ +@Component({ + selector: 'app-netfree-request', + standalone: true, + imports: [NbButtonModule, NbIconModule], + templateUrl: './netfree-request.component.html', + styleUrl: './netfree-request.component.scss', +}) +export class NetfreeRequestComponent { + @Input() slug = ''; + @Input() channelName = ''; + @Input() channelDescription = ''; + /** Starts with the text hidden (the create-success card is busy enough). */ + @Input() collapsed = false; + + @ViewChild('requestText') private requestText?: ElementRef<HTMLTextAreaElement>; + + readonly ticketUrl = NETFREE_TICKET_URL; + copied = false; + copyFailed = false; + private copiedTimer?: ReturnType<typeof setTimeout>; + + constructor(private share: ShareService) {} + + get channelUrl(): string { + return this.share.channelUrl(this.slug); + } + + get text(): string { + return netfreeRequestText({ + url: this.channelUrl, + host: window.location.host, + name: this.channelName, + description: this.channelDescription, + }); + } + + toggle(): void { + this.collapsed = !this.collapsed; + } + + async copy(): Promise<void> { + clearTimeout(this.copiedTimer); + // quiet: the browser's prompt() fallback is one line and would flatten + // the request; on failure the text is shown selected instead. + this.copied = await this.share.copy(this.text, { quiet: true }); + this.copyFailed = !this.copied; + if (this.copied) { + this.copiedTimer = setTimeout(() => (this.copied = false), 2500); + } else { + this.collapsed = false; + setTimeout(() => this.requestText?.nativeElement.select()); + } + } +} + +/** The request itself. Blanks are left as ______ for the owner to fill. */ +export function netfreeRequestText(c: { url: string; host: string; name: string; description: string }): string { + const blank = '______'; + const name = oneLine(c.name) || blank; + const about = oneLine(c.description) || blank; + return [ + 'שלום רב,', + 'אבקש לפתוח בנטפרי את ערוץ העדכונים שלי, בכתובת:', + c.url, + '', + `שם הערוץ: ${name}`, + `במה הערוץ עוסק: ${about}`, + '', + 'תשובות לשאלות שלכם:', + '', + `1. בעלות ושותפים: פתחתי את הערוץ ואני מנהל/ת אותו. הערוץ הוא דף באתר הערוצים ${c.host}, ואני אחראי/ת על התוכן של הערוץ הזה בלבד. מנהלים נוספים בערוץ: ${blank} (אם יש, תיאמתי איתם ואני מוסמך/ת להתחייב גם בשמם).`, + '', + '2. הוכחת בעלות: אוכל לפרסם בערוץ כל הודעה, תמונה או קובץ שתבקשו.', + '', + `3. קהל היעד: ${blank}`, + '', + '4. האם הציבור יכול לכתוב תוכן או להגיב? לא. רק מנהלי הערוץ, שאני מוסיף/ה בעצמי, מפרסמים הודעות. הקוראים יכולים רק לקרוא, ולכל היותר לסמן אימוג\'י על הודעה. אין בערוץ תגובות כתובות ואין פורום.', + '', + `5. מספר קוראים צפוי: ${blank}`, + '', + '6. קראתי את מדיניות נטפרי ואת המדיניות לאתרי תוכן, ואני מתחייב/ת שהערוץ לא יחרוג מהכללים. ידוע לי שאי עמידה בכללים עלולה לגרור חסימה של הערוץ לתקופה של 3 חודשים.', + '', + 'פרטים אישיים:', + 'שם פרטי ומשפחה: ', + 'טלפון: ', + 'גיל: ', + 'מצב משפחתי: ', + 'מקום לימודים (ישיבה גדולה/סמינר): ', + '', + 'מצורף וידאו סלפי שבו אני מתחייב/ת לעמוד בכללים.', + 'אם יפורסמו בערוץ תמונות או קבצים, אבקש לפתוח גם אותם.', + '', + 'תודה רבה!', + ].join('\n'); +} + +function oneLine(s: string | undefined | null): string { + return (s || '').split(/\s+/).filter(Boolean).join(' '); +} diff --git a/frontend/src/app/components/super-admin/channel-picker/channel-directory.service.ts b/frontend/src/app/components/super-admin/channel-picker/channel-directory.service.ts new file mode 100644 index 00000000..37f280a0 --- /dev/null +++ b/frontend/src/app/components/super-admin/channel-picker/channel-directory.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { ChannelData, SuperAdminService } from '../../../services/super-admin.service'; + +export type ChannelDirectoryState = 'idle' | 'loading' | 'ready' | 'error'; + +/** + * The list of channels for the super-admin channel pickers, shared by the + * picker (to search it) and its host screen (to mark saved slugs that no + * longer exist). Read from GET /api/super-admin/channels. + */ +@Injectable({ providedIn: 'root' }) +export class ChannelDirectoryService { + state: ChannelDirectoryState = 'idle'; + channels: ChannelData[] = []; + private slugs = new Set<string>(); + private pending: Promise<void> | null = null; + + constructor(private superAdminService: SuperAdminService) {} + + /** + * Fetches the list again (channels may have been added since the last + * visit). A list that is already loaded stays usable while refreshing and + * is kept if the refresh fails. + */ + refresh(): Promise<void> { + if (this.pending) return this.pending; + if (this.state !== 'ready') this.state = 'loading'; + this.pending = this.superAdminService.getChannels() + .then(list => { + this.channels = (list || []).filter(c => !!c?.slug); + this.slugs = new Set(this.channels.map(c => c.slug)); + this.state = 'ready'; + }) + .catch(() => { + if (this.state !== 'ready') this.state = 'error'; + }) + .finally(() => this.pending = null); + return this.pending; + } + + /** True only when the list is loaded and the slug is not in it. */ + isMissing(slug: string): boolean { + return this.state === 'ready' && !this.slugs.has(slug); + } +} diff --git a/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.html b/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.html new file mode 100644 index 00000000..fa1ae63d --- /dev/null +++ b/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.html @@ -0,0 +1,84 @@ +@let state = directory.state; +@let v = view(); +<div class="picker" (focusout)="onFocusOut($event)"> + @if (manual) { + <!-- The list did not load: type the slug by hand, as before. --> + <div class="picker__manual"> + <input nbInput fullWidth [id]="inputId" type="text" dir="ltr" + [value]="query" (input)="query = $any($event.target).value" + [placeholder]="placeholder" autocomplete="off" autocapitalize="none" spellcheck="false" + [attr.aria-describedby]="errorId" + (keydown)="onKeydown($event)"> + <button nbButton status="primary" size="medium" type="button" (click)="addManual()" + [disabled]="!query.trim()"> + <nb-icon icon="plus-outline"></nb-icon> + הוספה + </button> + </div> + <p class="picker__error" [id]="errorId" role="alert"> + <nb-icon icon="alert-triangle-outline" aria-hidden="true"></nb-icon> + <span>רשימת הערוצים לא נטענה, אפשר להקליד את כתובת הערוץ ידנית.</span> + <button nbButton ghost size="tiny" status="primary" type="button" (click)="retry()"> + <nb-icon icon="refresh-outline"></nb-icon> + ניסיון נוסף + </button> + </p> + } @else { + <div class="picker__field"> + <nb-icon class="picker__search-icon" icon="search-outline" aria-hidden="true"></nb-icon> + <input nbInput fullWidth class="picker__input" [id]="inputId" type="text" + role="combobox" aria-autocomplete="list" [attr.aria-expanded]="open" + [attr.aria-controls]="open ? listId : null" + [attr.aria-activedescendant]="open && activeSlug ? optionId(activeSlug) : null" + [attr.aria-busy]="state === 'loading' ? true : null" + [value]="query" (input)="onInput($any($event.target).value)" + (focus)="openPanel()" (click)="openPanel()" (keydown)="onKeydown($event)" + [placeholder]="placeholder" autocomplete="off" autocapitalize="none" spellcheck="false"> + </div> + @if (open) { + <!-- mousedown keeps focus in the input (option clicks, list scrollbar). --> + <div class="picker__panel" (mousedown)="$event.preventDefault()"> + @if (state === 'loading' || state === 'idle') { + <p class="picker__status" role="status">טוען את רשימת הערוצים…</p> + } @else if (!v.rows.length) { + <p class="picker__status">{{ directory.channels.length ? 'לא נמצאו ערוצים' : 'אין עדיין ערוצים במערכת' }}</p> + } @else if (allTaken(v)) { + <p class="picker__status">כל הערוצים שנמצאו כבר ברשימה.</p> + } + <ul class="picker__list" [id]="listId" role="listbox" aria-label="ערוצים"> + @for (row of v.rows; track row.channel.slug) { + <li class="picker__option" role="option" [id]="optionId(row.channel.slug)" + [class.picker__option--active]="row.channel.slug === activeSlug" + [class.picker__option--taken]="row.taken" + [attr.aria-selected]="row.channel.slug === activeSlug" + [attr.aria-disabled]="row.taken ? true : null" + (mousemove)="hover(row)" (click)="choose(row)"> + <span class="picker__name">{{ row.channel.name || row.channel.slug }}</span> + <span class="picker__slug" dir="ltr">{{ row.channel.slug }}</span> + @if (row.taken) { + <span class="picker__taken"> + <nb-icon icon="checkmark-outline" aria-hidden="true"></nb-icon> + כבר ברשימה + </span> + } + </li> + } + </ul> + @if (v.hidden) { + <p class="picker__more">ועוד {{ v.hidden }} ערוצים, הקלידו כדי לצמצם.</p> + } + </div> + } + } + @if (missing.length) { + <p class="picker__missing" role="note"> + <nb-icon icon="alert-triangle-outline" aria-hidden="true"></nb-icon> + <span> + {{ missing.length === 1 ? 'ערוץ ברשימה שכבר לא קיים במערכת:' : 'ערוצים ברשימה שכבר לא קיימים במערכת:' }} + <bdi dir="ltr">{{ missing.join(', ') }}</bdi>. + {{ missing.length === 1 ? 'הוא נשאר ברשימה עד שתסירו אותו.' : 'הם נשארים ברשימה עד שתסירו אותם.' }} + </span> + </p> + } + <span class="picker__live" aria-live="polite">{{ announcement }}</span> +</div> diff --git a/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.scss b/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.scss new file mode 100644 index 00000000..da1cf99b --- /dev/null +++ b/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.scss @@ -0,0 +1,168 @@ +:host { + display: block; +} + +.picker__manual { + display: flex; + gap: 0.5rem; + align-items: stretch; +} + +.picker__manual input { + flex: 1 1 auto; + min-width: 0; +} + +.picker__manual [nbButton] { + flex: none; + min-height: 2.5rem; +} + +.picker__field { + position: relative; +} + +.picker__search-icon { + position: absolute; + inset-inline-start: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-hint-color); + pointer-events: none; +} + +// Room for the search icon on the reading-start side. +.picker__field .picker__input { + padding-inline-start: 2.25rem; +} + +// In the page flow (not an overlay): the card around it never clips it. +.picker__panel { + margin-top: 0.25rem; + border: 1px solid var(--border-basic-color-3); + border-radius: var(--border-radius); + background: var(--background-basic-color-1); + overflow: hidden; +} + +.picker__list { + max-height: 16rem; + overflow-y: auto; + margin: 0; + padding: 0.25rem 0; + list-style: none; +} + +.picker__list:empty { + display: none; +} + +.picker__option { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-inline-start: 3px solid transparent; + cursor: pointer; + color: var(--text-basic-color); +} + +.picker__option--active { + border-inline-start-color: var(--color-primary-500); + background: var(--color-primary-transparent-100); +} + +.picker__option--taken { + cursor: default; + color: var(--text-hint-color); +} + +.picker__name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.picker__option--taken .picker__name { + font-weight: 400; +} + +.picker__slug { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8rem; + color: var(--text-hint-color); +} + +.picker__taken { + flex: none; + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.75rem; + color: var(--color-success-700); +} + +.picker__taken nb-icon { + font-size: 0.9rem; +} + +.picker__status, +.picker__more { + margin: 0; + padding: 0.6rem 0.75rem; + font-size: 0.85rem; + color: var(--text-hint-color); +} + +.picker__more { + border-top: 1px solid var(--border-basic-color-3); + font-size: 0.8rem; +} + +.picker__error, +.picker__missing { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin: 0.5rem 0 0; + padding: 0.4rem 0.6rem; + border-radius: var(--border-radius); + font-size: 0.85rem; + line-height: 1.5; +} + +.picker__error { + color: var(--color-danger-700); + background: var(--color-danger-100); +} + +.picker__missing { + color: var(--color-warning-800); + background: var(--color-warning-100); +} + +.picker__error nb-icon, +.picker__missing nb-icon { + flex: none; + font-size: 1rem; +} + +.picker__live { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} diff --git a/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.ts b/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.ts new file mode 100644 index 00000000..cebfb44c --- /dev/null +++ b/frontend/src/app/components/super-admin/channel-picker/channel-picker.component.ts @@ -0,0 +1,254 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { NbButtonModule, NbIconModule, NbInputModule } from '@nebular/theme'; +import { ChannelData } from '../../../services/super-admin.service'; +import { ChannelDirectoryService } from './channel-directory.service'; + +export interface ChannelPickerRow { + channel: ChannelData; + /** Already in the host's list: shown, but cannot be picked again. */ + taken: boolean; +} + +export interface ChannelPickerView { + rows: ChannelPickerRow[]; + /** Matches left out of `rows` to keep the panel short. */ + hidden: number; +} + +/** Rows shown at once; typing more narrows the rest. */ +const MAX_ROWS = 50; + +/** + * Lower-cased, without Hebrew niqqud/cantillation or Latin accents, with + * whitespace collapsed, so "ערוץ" finds "עָרוּץ" and "My Channel" finds + * "my channel". + */ +function normalize(s: string): string { + return (s || '') + .normalize('NFKD') + .replace(/[\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0300-\u036F]/g, '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +} + +let nextId = 0; + +/** + * A searchable channel list (ARIA 1.2 combobox + listbox) for picking a + * channel slug. Searches name and slug. The panel is in the page flow, not an + * overlay, so it is never clipped by the card around it. + * + * If the channel list cannot be loaded, it falls back to typing a slug by + * hand with an "add" button, like the field it replaced. + */ +@Component({ + selector: 'app-channel-picker', + standalone: true, + imports: [NbButtonModule, NbIconModule, NbInputModule], + templateUrl: './channel-picker.component.html', + styleUrl: './channel-picker.component.scss', +}) +export class ChannelPickerComponent implements OnInit { + /** The input's id, for the host's <label for>. */ + @Input({ required: true }) inputId!: string; + /** Slugs already chosen; offered as "already in the list" only. */ + @Input() selected: readonly string[] = []; + /** A slug the operator picked (never one of `selected`). */ + @Output() picked = new EventEmitter<string>(); + + private readonly uid = `channel-picker-${++nextId}`; + readonly listId = `${this.uid}-list`; + readonly errorId = `${this.uid}-error`; + + query = ''; + open = false; + /** The highlighted option (aria-activedescendant), by slug. */ + activeSlug: string | null = null; + /** Read by screen readers through the polite live region. */ + announcement = ''; + + constructor(public directory: ChannelDirectoryService) {} + + ngOnInit(): void { + this.refreshList(); + } + + /** Loads the list; a panel opened while loading gets its highlight after. */ + private refreshList() { + this.directory.refresh().then(() => { + if (this.open) this.syncActive(); + }); + } + + /** The list failed to load: typing a slug by hand is the only way. */ + get manual(): boolean { + return this.directory.state === 'error'; + } + + get placeholder(): string { + return this.manual ? 'כתובת הערוץ, למשל my-channel' : 'חיפוש ערוץ לפי שם או כתובת'; + } + + /** Saved slugs that are no longer channels. */ + get missing(): string[] { + return this.selected.filter(s => this.directory.isMissing(s)); + } + + optionId(slug: string): string { + return `${this.uid}-opt-${encodeURIComponent(slug)}`; + } + + view(): ChannelPickerView { + if (this.directory.state !== 'ready') return { rows: [], hidden: 0 }; + const q = normalize(this.query); + const taken = new Set(this.selected); + const scored: { row: ChannelPickerRow; score: number; name: string }[] = []; + for (const channel of this.directory.channels) { + const name = normalize(channel.name); + const slug = normalize(channel.slug); + let score = 0; + if (q) { + if (name.startsWith(q) || slug.startsWith(q)) score = 0; + else if (name.includes(' ' + q) || slug.includes('-' + q)) score = 1; + else if (name.includes(q) || slug.includes(q)) score = 2; + else continue; + } + const isTaken = taken.has(channel.slug); + // Channels already in the list go last: they cannot be picked. + scored.push({ row: { channel, taken: isTaken }, score: score + (isTaken ? 10 : 0), name: name || slug }); + } + scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name, 'he')); + const rows = scored.slice(0, MAX_ROWS).map(s => s.row); + return { rows, hidden: scored.length - rows.length }; + } + + allTaken(v: ChannelPickerView): boolean { + return v.rows.length > 0 && v.hidden === 0 && v.rows.every(r => r.taken); + } + + private pickable(): ChannelPickerRow[] { + return this.view().rows.filter(r => !r.taken); + } + + /** Keeps the highlight on a pickable row, or the first one. */ + private syncActive() { + const rows = this.pickable(); + if (!rows.some(r => r.channel.slug === this.activeSlug)) { + this.activeSlug = rows.length ? rows[0].channel.slug : null; + } + } + + private announceCount() { + if (this.directory.state !== 'ready') return; + const n = this.pickable().length; + this.announcement = n === 0 ? 'לא נמצאו ערוצים' : n === 1 ? 'נמצא ערוץ אחד' : `נמצאו ${n} ערוצים`; + } + + openPanel() { + if (this.manual) return; + if (!this.open) { + this.open = true; + this.announceCount(); + } + this.syncActive(); + } + + close() { + this.open = false; + this.activeSlug = null; + } + + /** Focus left the picker (not into its own panel): close it. */ + onFocusOut(event: FocusEvent) { + const next = event.relatedTarget as Node | null; + const host = event.currentTarget as HTMLElement; + if (!next || !host.contains(next)) this.close(); + } + + onInput(value: string) { + this.query = value; + if (this.manual) return; + this.open = true; + this.activeSlug = null; + this.syncActive(); + this.announceCount(); + } + + onKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': { + if (this.manual) return; + event.preventDefault(); + if (!this.open) { + this.openPanel(); + return; + } + this.move(event.key === 'ArrowDown' ? 1 : -1); + return; + } + case 'Enter': + // Never submit the settings form from this field. + event.preventDefault(); + if (this.manual) this.addManual(); + else if (this.open && this.activeSlug) this.pickSlug(this.activeSlug); + else this.openPanel(); + return; + case 'Escape': + if (this.open) { + event.preventDefault(); + event.stopPropagation(); + this.close(); + } else if (this.query) { + event.preventDefault(); + this.query = ''; + } + return; + case 'Tab': + this.close(); + return; + } + } + + private move(delta: number) { + const rows = this.pickable(); + if (!rows.length) return; + const i = rows.findIndex(r => r.channel.slug === this.activeSlug); + const next = i < 0 ? (delta > 0 ? 0 : rows.length - 1) : (i + delta + rows.length) % rows.length; + this.activeSlug = rows[next].channel.slug; + const el = document.getElementById(this.optionId(this.activeSlug)); + el?.scrollIntoView({ block: 'nearest' }); + } + + hover(row: ChannelPickerRow) { + if (!row.taken) this.activeSlug = row.channel.slug; + } + + choose(row: ChannelPickerRow) { + if (!row.taken) this.pickSlug(row.channel.slug); + } + + private pickSlug(slug: string) { + if (this.selected.includes(slug)) return; + const channel = this.directory.channels.find(c => c.slug === slug); + this.picked.emit(slug); + this.announcement = `הערוץ ${channel?.name || slug} נוסף לרשימה`; + this.query = ''; + this.close(); + } + + addManual() { + const slug = this.query.trim(); + if (!slug) return; + if (!this.selected.includes(slug)) { + this.picked.emit(slug); + this.announcement = `הערוץ ${slug} נוסף לרשימה`; + } + this.query = ''; + } + + retry() { + this.refreshList(); + } +} diff --git a/frontend/src/app/components/super-admin/channels/channel-features.component.html b/frontend/src/app/components/super-admin/channels/channel-features.component.html new file mode 100644 index 00000000..7e1fb281 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-features.component.html @@ -0,0 +1,126 @@ +@if (loading) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את הגדרות הערוץ…</p> + </div> + </nb-card-body> + </nb-card> +} @else if (loadFailed) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">הגדרות הערוץ לא נטענו</p> + <p class="sa-state__text">בלי העותק מהשרת אי אפשר לשמור — שמירה הייתה מכבה את כל התכונות בבת אחת. נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="loadFeatures()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + </nb-card-body> + </nb-card> +} @else { + <div class="features"> + @for (group of groups; track group.title) { + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon [icon]="group.icon"></nb-icon> + {{ group.title }} + </h2> + </nb-card-header> + <nb-card-body class="group-body"> + @for (item of group.items; track item.key) { + <div class="sa-toggle"> + <nb-toggle [checked]="features[item.key]" (checkedChange)="features[item.key] = $event"> + <span class="toggle-label">{{ item.label }}</span> + </nb-toggle> + <p class="sa-toggle__hint">{{ item.hint }}</p> + </div> + } + </nb-card-body> + </nb-card> + } + + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="search-outline"></nb-icon> + מנועי חיפוש + </h2> + </nb-card-header> + <nb-card-body> + <app-channel-seo-fields + idPrefix="sa-channel-seo" + [slug]="slug" + [channelName]="channelName" + [channelDescription]="channelDescription" + [blockedReason]="seoBlockedReason" + [listed]="!seo.seoHidden" (listedChange)="seo.seoHidden = !$event" + [(seoTitle)]="seo.seoTitle" + [(seoDescription)]="seo.seoDescription"> + </app-channel-seo-fields> + </nb-card-body> + </nb-card> + + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="pricetags-outline"></nb-icon> + נעילות של המערכת + </h2> + </nb-card-header> + <nb-card-body> + <p class="sa-lead">נעילה אומרת שהערוץ מציג את הפרסומת שהמערכת קבעה, ולא את מה שמנהליו הגדירו. משנים אותה במסכי "פרסומת במסגרת" ו"פרסומות מגנט".</p> + <div class="locks"> + <span class="sa-chip" [class.sa-chip--warning]="features.adsLockedByAdmin"> + <nb-icon [icon]="features.adsLockedByAdmin ? 'lock-outline' : 'unlock-outline'"></nb-icon> + פרסומת במסגרת: {{ features.adsLockedByAdmin ? 'נעול להגדרת המערכת' : 'לפי הגדרת הערוץ' }} + </span> + <span class="sa-chip" [class.sa-chip--warning]="features.magnetLockedByAdmin"> + <nb-icon [icon]="features.magnetLockedByAdmin ? 'lock-outline' : 'unlock-outline'"></nb-icon> + מגנט: {{ features.magnetLockedByAdmin ? 'נעול להגדרת המערכת' : 'לפי הגדרת הערוץ' }} + </span> + </div> + </nb-card-body> + </nb-card> + + <nb-card class="danger-card"> + <nb-card-header> + <h2 class="sa-card-title danger-title"> + <nb-icon icon="alert-triangle-outline"></nb-icon> + השבתת הערוץ + </h2> + </nb-card-header> + <nb-card-body> + <div class="sa-toggle"> + <nb-toggle status="danger" [checked]="features.disabled" (checkedChange)="onDisabledChange($event)"> + <span class="toggle-label">{{ features.disabled ? 'הערוץ מושבת' : 'הערוץ פעיל' }}</span> + </nb-toggle> + <p class="sa-toggle__hint"> + כשהערוץ מושבת הוא נסגר לכל המשתמשים, כולל הבעלים, ובמקומו מוצג המסך "הערוץ מושבת". + ההודעות והקבצים נשמרים, וההפעלה מחדש מחזירה הכול כמו שהיה. + </p> + </div> + </nb-card-body> + </nb-card> + + <nb-card class="save-card"> + <nb-card-body> + <div class="sa-save-bar"> + <button nbButton status="primary" type="button" (click)="save()" [disabled]="saving || !loaded"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירת ההגדרות' }} + </button> + @if (dirty) { + <span class="sa-save-bar__note sa-save-bar__note--dirty">יש שינויים שעוד לא נשמרו</span> + } @else { + <span class="sa-save-bar__note">השינויים נכנסים לתוקף מיד לאחר השמירה.</span> + } + </div> + </nb-card-body> + </nb-card> + </div> +} diff --git a/frontend/src/app/components/super-admin/channels/channel-features.component.scss b/frontend/src/app/components/super-admin/channels/channel-features.component.scss new file mode 100644 index 00000000..23f229b6 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-features.component.scss @@ -0,0 +1,55 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.features { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.features nb-card { + margin: 0; +} + +.group-body { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.toggle-label { + font-weight: 600; + color: var(--text-basic-color); +} + +.locks { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.locks .sa-chip { + white-space: normal; +} + +// The kill switch is the one setting that takes the channel down for +// everyone; the theme's danger tokens keep it visibly apart. +.danger-card { + border-color: var(--color-danger-300); +} + +.danger-title { + color: var(--color-danger-700); +} + +.danger-title nb-icon { + color: var(--color-danger-600); +} + +.save-card { + position: sticky; + bottom: 0.5rem; + z-index: 1; + border-color: var(--color-primary-200); +} diff --git a/frontend/src/app/components/super-admin/channels/channel-features.component.ts b/frontend/src/app/components/super-admin/channels/channel-features.component.ts new file mode 100644 index 00000000..7256ba67 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-features.component.ts @@ -0,0 +1,213 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbToggleModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, ChannelFeatures } from '../../../services/super-admin.service'; +import { ConfirmService } from '../../../services/confirm.service'; +import { ChannelSeoSettings } from '../../../models/channel.model'; +import { ChannelSeoFieldsComponent } from '../../shared/channel-seo-fields.component'; + +interface FeatureConfig { + key: keyof ChannelFeatures; + label: string; + /** One plain sentence: what the toggle does for readers and writers. */ + hint: string; +} + +interface FeatureGroup { + title: string; + icon: string; + items: FeatureConfig[]; +} + +@Component({ + selector: 'app-channel-features', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbToggleModule, + ChannelSeoFieldsComponent, + ], + templateUrl: './channel-features.component.html', + styleUrl: './channel-features.component.scss', +}) +export class ChannelFeaturesComponent implements OnInit { + @Input() slug!: string; + + features: ChannelFeatures = this.defaults(); + channelName = ''; + channelDescription = ''; + /** Search settings, saved through their own endpoint (see save()). */ + seo: ChannelSeoSettings = { seoHidden: false, seoTitle: '', seoDescription: '' }; + private seoSnapshot = ''; + + saving = false; + loading = true; + // Save stays disabled until the server copy arrived: `features` starts as + // every flag false, and saving that after a failed load switched off every + // feature of the tenant in one click. + loaded = false; + loadFailed = false; + /** JSON of the last server copy, to tell the operator about unsaved edits. */ + private snapshot = ''; + + readonly groups: FeatureGroup[] = [ + { + title: 'לקוראים ולכותבים', icon: 'message-circle-outline', + items: [ + { key: 'reactions', label: 'תגובות', hint: 'הקוראים יכולים להגיב להודעות באימוג׳י.' }, + { key: 'fileUploads', label: 'העלאת קבצים', hint: 'הכותבים יכולים לצרף תמונות וקבצים להודעות. הקבצים נספרים בנפח האחסון של הערוץ.' }, + { key: 'scheduledMessages', label: 'הודעות מתוזמנות', hint: 'הכותבים יכולים לקבוע מועד פרסום עתידי להודעה.' }, + { key: 'countViews', label: 'ספירת צפיות', hint: 'ליד כל הודעה מוצג מספר הצפיות בה.' }, + { key: 'notifications', label: 'התראות לטלפון', hint: 'הקוראים יכולים להירשם להתראה על כל הודעה חדשה. דורש חיבור התראות בהגדרות המערכת.' }, + { key: 'reports', label: 'דיווחים', hint: 'הקוראים יכולים לדווח על הודעה, והדיווח מגיע למנהלי הערוץ.' }, + ], + }, + { + title: 'מי יכול לקרוא', icon: 'lock-outline', + items: [ + { key: 'requireAuth', label: 'כניסה חובה לצפייה', hint: 'רק מי שנכנס עם חשבון יכול לקרוא את הערוץ. כשכבוי, הערוץ פתוח לכל מי שיש לו את הקישור.' }, + { key: 'requireAuthFiles', label: 'כניסה חובה לקבצים', hint: 'קבצים ותמונות נפתחים רק למי שנכנס עם חשבון, גם אם הערוץ עצמו פתוח.' }, + ], + }, + { + title: 'פרסום וחיבורים חיצוניים', icon: 'link-2-outline', + items: [ + { key: 'ads', label: 'פרסומת בערוץ', hint: 'מנהלי הערוץ יכולים להציג פרסומת בתוך הערוץ. אם הערוץ נעול לפרסומת המערכת, היא זו שמוצגת.' }, + { key: 'webhook', label: 'שליחת הודעות לכתובת חיצונית', hint: 'כל הודעה חדשה נשלחת גם לכתובת אינטרנט שמנהלי הערוץ מגדירים (למשל לאוטומציה).' }, + ], + }, + // magnetLockedByAdmin / adsLockedByAdmin are managed by the global + // ads/magnet locked-channel lists — the single source of truth. The values + // loaded from the channel are passed back through save() untouched and + // shown below as read-only. + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + private confirm: ConfirmService, + ) {} + + ngOnInit(): void { + this.loadFeatures(); + } + + private defaults(): ChannelFeatures { + return { + reactions: false, + fileUploads: false, + reports: false, + ads: false, + notifications: false, + requireAuth: false, + requireAuthFiles: false, + countViews: false, + scheduledMessages: false, + webhook: false, + magnetLockedByAdmin: false, + adsLockedByAdmin: false, + disabled: false, + }; + } + + loadFeatures() { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getChannel(this.slug) + .then(channel => { + // Merge over the defaults: an older backend omits newer flags entirely, + // and an undefined value would leave the toggle unbound. + this.features = { ...this.defaults(), ...channel.features }; + this.channelName = channel.name || ''; + this.channelDescription = channel.description || ''; + this.seo = { + seoHidden: !!channel.seoHidden, + seoTitle: channel.seoTitle || '', + seoDescription: channel.seoDescription || '', + }; + this.snapshot = JSON.stringify(this.features); + this.seoSnapshot = JSON.stringify(this.seo); + this.loaded = true; + }) + .catch((err) => { + this.loadFailed = true; + this.toastr.danger('', err?.status === 404 + ? 'הערוץ לא נמצא — ייתכן שנמחק' + : 'הגדרות הערוץ לא נטענו'); + }) + .finally(() => this.loading = false); + } + + get dirty(): boolean { + return this.loaded && (JSON.stringify(this.features) !== this.snapshot || this.seoDirty); + } + + private get seoDirty(): boolean { + return JSON.stringify(this.seo) !== this.seoSnapshot; + } + + /** Private or disabled (as currently toggled): never in search. */ + get seoBlockedReason(): string { + if (this.features.disabled) return 'הערוץ מושבת, ולכן לא יופיע בגוגל בכל מקרה.'; + if (this.features.requireAuth) return 'הערוץ פרטי (כניסה חובה לצפייה), ולכן לא יופיע בגוגל בכל מקרה.'; + return ''; + } + + /** The kill switch asks first: it takes the channel down for everyone. */ + async onDisabledChange(checked: boolean) { + const was = this.features.disabled; + // Mirror the toggle's own state first: the binding must move to `true` + // now so that flipping it back to `false` after a cancelled dialog is a + // change Angular propagates to the toggle (false → false is not). + this.features.disabled = checked; + if (checked && !was) { + const ok = await this.confirm.ask({ + title: `להשבית את הערוץ "${this.channelName || this.slug}"?`, + message: 'ההשבתה נכנסת לתוקף בשמירה: הערוץ ייסגר לכל הקוראים והכותבים, כולל הבעלים, ובמקומו יוצג המסך "הערוץ מושבת". אפשר להפעיל אותו מחדש מכאן.', + status: 'warning', + icon: 'slash-outline', + confirmLabel: 'השבתה', + }); + if (!ok) this.features.disabled = false; + } + } + + save() { + if (!this.loaded) return; + this.saving = true; + const seo: ChannelSeoSettings = { + seoHidden: this.seo.seoHidden, + seoTitle: this.seo.seoTitle.trim(), + seoDescription: this.seo.seoDescription.trim(), + }; + // The search settings have their own endpoint; sent only when changed, + // so a features save never depends on it. + const seoSave = this.seoDirty + ? this.superAdminService.updateChannelSeo(this.slug, seo) + : Promise.resolve(); + Promise.all([this.superAdminService.updateChannelFeatures(this.slug, this.features), seoSave]) + .then(() => { + this.snapshot = JSON.stringify(this.features); + this.seo = seo; + this.seoSnapshot = JSON.stringify(seo); + this.toastr.success('', 'הגדרות הערוץ נשמרו'); + }) + .catch((err) => this.toastr.danger('', err?.status === 404 + ? 'הערוץ לא נמצא — ייתכן שנמחק' + : err?.status === 400 && String(err?.error ?? '').includes('seo') + ? 'הכותרת או התיאור לגוגל ארוכים מדי (עד 60 תווים לכותרת ועד 155 לתיאור)' + : 'השמירה לא הצליחה, נסו שוב')) + .finally(() => this.saving = false); + } +} diff --git a/frontend/src/app/components/super-admin/channels/channel-users.component.html b/frontend/src/app/components/super-admin/channels/channel-users.component.html new file mode 100644 index 00000000..841d13f2 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.html @@ -0,0 +1,155 @@ +@if (addingUser) { + <nb-card class="add-card"> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="person-add-outline"></nb-icon> + הוספת משתמש לערוץ + </h2> + </nb-card-header> + <nb-card-body> + <form (ngSubmit)="addUser()" novalidate> + <div class="sa-field"> + <label class="sa-field__label" for="newUserEmail">כתובת אימייל</label> + <input nbInput fullWidth id="newUserEmail" type="email" name="newUserEmail" [(ngModel)]="newEmail" + placeholder="user@example.com" dir="ltr" autocomplete="off"> + <p class="sa-field__hint">הכתובת שאיתה המשתמש נכנס ל-Google. ההרשאה חלה מיד לאחר השמירה.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="newUserRole">תפקיד</label> + <nb-select fullWidth id="newUserRole" name="newUserRole" [(ngModel)]="newRole"> + @for (opt of roleOptions; track opt.value) { + <nb-option [value]="opt.value">{{ opt.label }}</nb-option> + } + </nb-select> + <p class="sa-field__hint">{{ roleHint(newRole) }}</p> + </div> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit"> + <nb-icon icon="checkmark-outline"></nb-icon> + הוספה לרשימה + </button> + <button nbButton ghost status="basic" type="button" (click)="cancelAdd()"> + <nb-icon icon="close-outline"></nb-icon> + ביטול + </button> + <span class="sa-save-bar__note">המשתמש נוסף לרשימה למטה, ונשמר רק בלחיצה על "שמירת השינויים".</span> + </div> + </form> + </nb-card-body> + </nb-card> +} + +<nb-card> + <nb-card-header> + <div class="sa-card-head"> + <h2 class="sa-card-title"> + <nb-icon icon="people-outline"></nb-icon> + בעלי תפקיד בערוץ + @if (!loading && !loadFailed) { + <span class="sa-chip">{{ users.length }}</span> + } + </h2> + @if (!addingUser && !loadFailed) { + <button nbButton status="primary" size="small" type="button" (click)="addingUser = true" [disabled]="loading"> + <nb-icon icon="plus-outline"></nb-icon> + הוספת משתמש + </button> + } + </div> + </nb-card-header> + + <nb-card-body> + @if (loading) { + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את משתמשי הערוץ…</p> + </div> + } @else if (loadFailed) { + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">רשימת המשתמשים לא נטענה</p> + <p class="sa-state__text">נסו לטעון שוב. שמירה בלי הרשימה המעודכנת אינה אפשרית.</p> + <button nbButton status="primary" size="small" type="button" (click)="loadUsers()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + } @else { + <p class="sa-lead"> + בעלים שולט בכול, מנהל מנהל וכותב, וכותב רק מפרסם הודעות. שינוי תפקיד או הסרה נשמרים רק בלחיצה על "שמירת השינויים". + </p> + + @if (users.length === 0) { + <div class="sa-state"> + <span class="sa-state__icon"><nb-icon icon="people-outline"></nb-icon></span> + <p class="sa-state__title">לאף אחד אין עדיין תפקיד בערוץ</p> + <p class="sa-state__text">הוסיפו את הבעלים או מנהל כדי שיוכלו לכתוב ולנהל.</p> + @if (!addingUser) { + <button nbButton status="primary" size="small" type="button" (click)="addingUser = true"> + <nb-icon icon="plus-outline"></nb-icon> + הוספת משתמש + </button> + } + </div> + } @else { + <ul class="user-list"> + @for (user of users; track $index; let i = $index) { + <li class="user-row"> + <span class="user-row__avatar" aria-hidden="true">{{ initial(user.email) }}</span> + <span class="user-row__email sa-ltr">{{ user.email }}</span> + <div class="user-row__role"> + <label class="visually-hidden" [for]="'role-' + i">תפקיד של {{ user.email }}</label> + <nb-select [id]="'role-' + i" [name]="'role-' + i" [(ngModel)]="users[i].role" fullWidth + [attr.aria-label]="'תפקיד של ' + user.email"> + @for (opt of roleOptions; track opt.value) { + <nb-option [value]="opt.value">{{ opt.label }}</nb-option> + } + </nb-select> + </div> + <button nbButton ghost status="danger" size="small" type="button" (click)="removeUser(i)" + nbTooltip="הסרה מהערוץ" [attr.aria-label]="'הסרת ' + user.email + ' מהערוץ'"> + <nb-icon icon="trash-2-outline"></nb-icon> + </button> + </li> + } + </ul> + } + + @if (removedUsers.length) { + <div class="removed"> + <p class="removed__title"> + <nb-icon icon="alert-circle-outline"></nb-icon> + יוסרו בשמירה: + </p> + <ul class="removed__list"> + @for (r of removedUsers; track r.email; let i = $index) { + <li> + <span class="sa-ltr">{{ r.email }}</span> + <button nbButton ghost status="basic" size="small" type="button" (click)="undoRemove(i)"> + <nb-icon icon="undo-outline"></nb-icon> + ביטול ההסרה + </button> + </li> + } + </ul> + </div> + } + } + </nb-card-body> + + @if (!loading && !loadFailed) { + <nb-card-footer> + <div class="sa-save-bar"> + <button nbButton status="primary" type="button" (click)="save()" [disabled]="saving"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירת השינויים' }} + </button> + @if (dirty) { + <span class="sa-save-bar__note sa-save-bar__note--dirty">יש שינויים שעוד לא נשמרו</span> + } @else { + <span class="sa-save-bar__note">הרשימה שמורה.</span> + } + </div> + </nb-card-footer> + } +</nb-card> diff --git a/frontend/src/app/components/super-admin/channels/channel-users.component.scss b/frontend/src/app/components/super-admin/channels/channel-users.component.scss new file mode 100644 index 00000000..85fdbda5 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.scss @@ -0,0 +1,107 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.add-card { + margin-bottom: 1rem; + border-color: var(--color-primary-200); +} + +.user-list { + list-style: none; + margin: 0; + padding: 0; +} + +.user-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0; + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-child { + border-bottom: 0; + } +} + +.user-row__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + flex: none; + border-radius: 50%; + font-weight: 800; + color: var(--color-primary-700); + background: var(--color-primary-100); +} + +.user-row__email { + flex: 1 1 auto; + min-width: 0; + font-weight: 600; + color: var(--text-basic-color); + overflow-wrap: anywhere; +} + +.user-row__role { + flex: none; + width: 8.5rem; +} + +.user-row [nbButton] { + min-width: 2.5rem; + min-height: 2.5rem; +} + +.removed { + margin-top: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--border-radius); + background: var(--color-warning-100); + color: var(--color-warning-800); +} + +.removed__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0 0 0.35rem; + font-weight: 700; +} + +.removed__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + + li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; + } +} + +@media (max-width: 480px) { + // The avatar sits on the first line with the address; the role and the + // remove button wrap onto their own line so nothing gets squeezed. + .user-row { + flex-wrap: wrap; + } + + .user-row__email { + flex-basis: calc(100% - 3rem); + } + + .user-row__role { + flex: 1 1 auto; + } +} diff --git a/frontend/src/app/components/super-admin/channels/channel-users.component.ts b/frontend/src/app/components/super-admin/channels/channel-users.component.ts new file mode 100644 index 00000000..3420bf0c --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.ts @@ -0,0 +1,191 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbSelectModule, + NbToastrService, + NbTooltipModule, +} from '@nebular/theme'; +import { SuperAdminService, ChannelUser } from '../../../services/super-admin.service'; + +/** The backend normalises emails (trim + lowercase) before comparing. */ +function sameEmail(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +// Deliberately loose, and the same shape the owner-side privileges screen +// checks: one '@' with something on both sides and a dot after it. +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +@Component({ + selector: 'app-channel-users', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbSelectModule, + NbTooltipModule, + ], + templateUrl: './channel-users.component.html', + styleUrl: './channel-users.component.scss', +}) +export class ChannelUsersComponent implements OnInit { + @Input() slug!: string; + + users: ChannelUser[] = []; + // Removed users are sent with an empty role so the server actually revokes them — + // an email that is simply missing from the payload is never touched. + removedUsers: ChannelUser[] = []; + /** The role each removed user had, so "undo" puts them back as they were. */ + private removedRoles = new Map<string, string>(); + loading = true; + loadFailed = false; + saving = false; + addingUser = false; + newEmail = ''; + newRole: 'owner' | 'moderator' | 'writer' | '' = 'moderator'; + /** JSON of the last server copy, to tell the operator about unsaved edits. */ + private snapshot = ''; + + roleOptions: { value: string; label: string; hint: string }[] = [ + { value: 'owner', label: 'בעלים', hint: 'שליטה מלאה: הגדרות, משתמשים, כתיבה ומחיקה.' }, + { value: 'moderator', label: 'מנהל', hint: 'ניהול הערוץ וכתיבה, בלי למנות בעלים.' }, + { value: 'writer', label: 'כותב', hint: 'פרסום הודעות בלבד.' }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.loadUsers(); + } + + loadUsers() { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getChannelUsers(this.slug) + .then(users => { + this.users = [...(users || [])]; + this.removedUsers = []; + this.removedRoles.clear(); + this.snapshot = JSON.stringify(this.users); + }) + .catch((err) => { + this.loadFailed = true; + this.toastr.danger('', err?.status === 404 + ? 'הערוץ לא נמצא — ייתכן שנמחק' + : 'רשימת המשתמשים לא נטענה'); + }) + .finally(() => this.loading = false); + } + + get dirty(): boolean { + return this.removedUsers.length > 0 || JSON.stringify(this.users) !== this.snapshot; + } + + roleHint(role: string): string { + return this.roleOptions.find(o => o.value === role)?.hint || ''; + } + + /** First letter of the address, for the avatar. */ + initial(email: string): string { + const e = (email || '').trim(); + return e ? e[0].toUpperCase() : '?'; + } + + addUser() { + const email = this.newEmail.trim(); + if (!email) { + this.toastr.warning('', 'יש להזין כתובת אימייל'); + return; + } + // A typo is caught while the operator is still looking at it; stored, it + // would never match a Google login and the row would just sit there. + if (!EMAIL_SHAPE.test(email)) { + this.toastr.warning('', 'כתובת המייל אינה תקינה'); + return; + } + if (this.users.some(u => sameEmail(u.email, email))) { + this.toastr.warning('', 'המשתמש כבר ברשימה — שנו את התפקיד שלו בשורה הקיימת'); + return; + } + // Remove-then-re-add before saving is how a role change is done here; the + // queued revoke must not survive, or (applied after the grant) it would + // silently strip the user while the screen still shows them with a role. + this.removedUsers = this.removedUsers.filter(u => !sameEmail(u.email, email)); + this.users.push({ email, role: this.newRole as any }); + this.newEmail = ''; + this.newRole = 'moderator'; + this.addingUser = false; + } + + cancelAdd() { + this.addingUser = false; + this.newEmail = ''; + this.newRole = 'moderator'; + } + + removeUser(index: number) { + const [removed] = this.users.splice(index, 1); + if (!removed?.email) return; + this.removedRoles.set(removed.email.trim().toLowerCase(), removed.role); + this.removedUsers.push({ email: removed.email, role: '' }); + } + + /** Puts a just-removed user back, with the role they had, before anything was saved. */ + undoRemove(index: number) { + const [restored] = this.removedUsers.splice(index, 1); + if (!restored?.email) return; + const key = restored.email.trim().toLowerCase(); + const role = this.removedRoles.get(key) || 'writer'; + this.removedRoles.delete(key); + if (!this.users.some(u => sameEmail(u.email, restored.email))) { + this.users.push({ email: restored.email, role: role as any }); + } + } + + save() { + this.saving = true; + // The server applies changes in order and the last one for an email wins, + // so revokes go first and a revoke of an email that is also granted is + // dropped — a grant must never be undone by an earlier removal. + const removals = this.removedUsers.filter( + r => !this.users.some(u => sameEmail(u.email, r.email)), + ); + this.superAdminService.setChannelUsers(this.slug, [...removals, ...this.users]) + .then(() => { + this.removedUsers = []; + this.removedRoles.clear(); + this.snapshot = JSON.stringify(this.users); + this.toastr.success('', 'משתמשי הערוץ נשמרו'); + }) + .catch((err) => { + // The server refuses a malformed address with 400 'invalid email' — + // name the field rather than a generic failure. + const text = typeof err?.error === 'string' ? err.error.toLowerCase() : ''; + if (err?.status === 400 && text.includes('invalid email')) { + this.toastr.danger('', 'אחת מכתובות המייל אינה תקינה'); + } else if (err?.status === 404) { + this.toastr.danger('', 'הערוץ לא נמצא — ייתכן שנמחק'); + } else { + this.toastr.danger('', 'השמירה לא הצליחה, נסו שוב'); + } + }) + .finally(() => this.saving = false); + } + + getRoleLabel(role: string): string { + const opt = this.roleOptions.find(o => o.value === role); + return opt ? opt.label : role; + } +} diff --git a/frontend/src/app/components/super-admin/channels/channels-list.component.html b/frontend/src/app/components/super-admin/channels/channels-list.component.html new file mode 100644 index 00000000..6267d4a2 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.html @@ -0,0 +1,251 @@ +<!-- The row actions, once: the table (desktop) and the cards (phone) both use it. --> +<ng-template #actions let-channel> + <div class="sa-actions"> + <a nbButton ghost status="basic" size="small" [href]="channelUrl(channel.slug)" target="_blank" rel="noopener" + nbTooltip="פתיחת הערוץ בלשונית חדשה" [attr.aria-label]="'פתיחת הערוץ ' + channel.name"> + <nb-icon icon="external-link-outline"></nb-icon> + <span class="act-text">פתיחה</span> + </a> + <button nbButton ghost status="primary" size="small" type="button" (click)="editFeatures.emit(channel)" + nbTooltip="תכונות, גישה והשבתה" [attr.aria-label]="'הגדרות הערוץ ' + channel.name"> + <nb-icon icon="settings-2-outline"></nb-icon> + <span class="act-text">הגדרות</span> + </button> + <button nbButton ghost status="primary" size="small" type="button" (click)="manageUsers.emit(channel)" + nbTooltip="בעלים, מנהלים וכותבים" [attr.aria-label]="'משתמשי הערוץ ' + channel.name"> + <nb-icon icon="people-outline"></nb-icon> + <span class="act-text">משתמשים</span> + </button> + <button nbButton ghost status="primary" size="small" type="button" (click)="manageStorage.emit(channel)" + nbTooltip="נפח אחסון של הערוץ" [attr.aria-label]="'אחסון הערוץ ' + channel.name"> + <nb-icon icon="hard-drive-outline"></nb-icon> + <span class="act-text">אחסון</span> + </button> + <button nbButton ghost [status]="channel.features.disabled ? 'success' : 'warning'" size="small" type="button" + (click)="toggleDisabled(channel)" [disabled]="toggling.has(channel.slug)" + [nbTooltip]="channel.features.disabled ? 'הפעלה מחדש של הערוץ' : 'השבתת הערוץ לכולם'" + [attr.aria-label]="(channel.features.disabled ? 'הפעלה מחדש של הערוץ ' : 'השבתת הערוץ ') + channel.name"> + <nb-icon [icon]="channel.features.disabled ? 'checkmark-circle-2-outline' : 'slash-outline'"></nb-icon> + <span class="act-text">{{ channel.features.disabled ? 'הפעלה' : 'השבתה' }}</span> + </button> + <button nbButton ghost status="danger" size="small" type="button" (click)="deleteChannel(channel)" + [disabled]="deleting.has(channel.slug)" nbTooltip="מחיקה לצמיתות" + [attr.aria-label]="'מחיקת הערוץ ' + channel.name"> + <nb-icon icon="trash-2-outline"></nb-icon> + <span class="act-text">מחיקה</span> + </button> + </div> +</ng-template> + +@if (showCreateForm) { + <nb-card class="create-card"> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="plus-circle-outline"></nb-icon> + ערוץ חדש + </h2> + </nb-card-header> + <nb-card-body> + <p class="sa-lead">הערוץ נוצר מיד, והבעלים שתבחרו נכנס אליו עם חשבון Google באותה כתובת מייל ומקבל שליטה מלאה.</p> + <form (ngSubmit)="createChannel()" novalidate> + <div class="sa-field"> + <label class="sa-field__label" for="newName">שם הערוץ</label> + <input nbInput fullWidth id="newName" type="text" name="newName" [(ngModel)]="newName" + placeholder="לדוגמה: חדשות הקהילה" maxlength="80" autocomplete="off"> + <p class="sa-field__hint">השם שהקוראים רואים. אפשר לשנות אותו אחר כך מהגדרות הערוץ.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="newSlug">כתובת הערוץ</label> + <input nbInput fullWidth id="newSlug" type="text" name="newSlug" [(ngModel)]="newSlug" + placeholder="my-channel" dir="ltr" autocomplete="off" autocapitalize="none" spellcheck="false"> + <p class="sa-field__hint"> + אותיות אנגליות קטנות, ספרות ומקפים, 3 עד 50 תווים. לא ניתן לשינוי אחרי היצירה. + @if (normalizedSlug) { + <br>הקוראים יגיעו אל <code class="sa-ltr">/channel/{{ normalizedSlug }}</code> + } + </p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="newOwnerEmail">אימייל הבעלים</label> + <input nbInput fullWidth id="newOwnerEmail" type="email" name="newOwnerEmail" [(ngModel)]="newOwnerEmail" + placeholder="owner@example.com" dir="ltr" autocomplete="off"> + <p class="sa-field__hint">הכתובת שאיתה הבעלים נכנס ל-Google. אפשר להוסיף מנהלים וכותבים נוספים אחר כך.</p> + </div> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit" [disabled]="creating"> + <nb-icon icon="checkmark-outline"></nb-icon> + {{ creating ? 'יוצר…' : 'יצירת הערוץ' }} + </button> + <button nbButton ghost status="basic" type="button" (click)="cancelCreate()" [disabled]="creating"> + <nb-icon icon="close-outline"></nb-icon> + ביטול + </button> + </div> + </form> + </nb-card-body> + </nb-card> +} + +<nb-card> + <nb-card-header> + <div class="sa-card-head"> + <h2 class="sa-card-title"> + <nb-icon icon="list-outline"></nb-icon> + רשימת הערוצים + @if (!loading && !loadFailed) { + <span class="sa-chip">{{ channels.length }}</span> + @if (disabledCount) { + <span class="sa-chip sa-chip--danger">{{ disabledCount }} מושבתים</span> + } + } + </h2> + @if (!showCreateForm) { + <button nbButton status="primary" size="small" type="button" (click)="openCreate()"> + <nb-icon icon="plus-outline"></nb-icon> + ערוץ חדש + </button> + } + </div> + </nb-card-header> + + <nb-card-body> + @if (loading) { + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את רשימת הערוצים…</p> + </div> + } @else if (loadFailed) { + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">רשימת הערוצים לא נטענה</p> + <p class="sa-state__text">ייתכן שהחיבור לשרת נותק. נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="loadChannels()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + } @else if (channels.length === 0) { + <div class="sa-state"> + <span class="sa-state__icon"><nb-icon icon="grid-outline"></nb-icon></span> + <p class="sa-state__title">עדיין אין ערוצים במערכת</p> + <p class="sa-state__text">הערוץ הראשון נפתח כאן, או מהאתר על ידי כל משתמש שנכנס.</p> + <button nbButton status="primary" size="small" type="button" (click)="openCreate()"> + <nb-icon icon="plus-outline"></nb-icon> + ערוץ חדש + </button> + </div> + } @else { + <div class="sa-toolbar"> + <div class="sa-toolbar__search"> + <label class="visually-hidden" for="channelSearch">חיפוש ערוץ</label> + <nb-form-field> + <nb-icon nbPrefix icon="search-outline"></nb-icon> + <input nbInput fullWidth id="channelSearch" type="search" [(ngModel)]="query" + placeholder="חיפוש לפי שם, כתובת או אימייל הבעלים" autocomplete="off"> + </nb-form-field> + </div> + <div class="sa-filters" role="group" aria-label="סינון לפי מצב"> + @for (f of statusFilters; track f.value) { + <button nbButton size="small" type="button" + [status]="statusFilter === f.value ? 'primary' : 'basic'" + [appearance]="statusFilter === f.value ? 'filled' : 'outline'" + [attr.aria-pressed]="statusFilter === f.value" + (click)="statusFilter = f.value">{{ f.label }}</button> + } + </div> + </div> + + @if (filtered.length === 0) { + <div class="sa-state"> + @if (query.trim()) { + <span class="sa-state__icon"><nb-icon icon="search-outline"></nb-icon></span> + <p class="sa-state__title">לא נמצאו ערוצים שמתאימים לחיפוש</p> + <p class="sa-state__text">נסו מילה אחרת, או הציגו את כל הערוצים.</p> + <button nbButton ghost status="primary" size="small" type="button" (click)="clearFilter()">ניקוי החיפוש</button> + } @else { + <!-- No search word: it is the status filter that has nothing to show. --> + <span class="sa-state__icon"><nb-icon [icon]="statusFilter === 'disabled' ? 'slash-outline' : 'checkmark-circle-2-outline'"></nb-icon></span> + <p class="sa-state__title">{{ statusFilter === 'disabled' ? 'אין ערוצים מושבתים' : 'אין ערוצים פעילים' }}</p> + <p class="sa-state__text">{{ statusFilter === 'disabled' ? 'כל הערוצים במערכת פעילים כרגע.' : 'כל הערוצים במערכת מושבתים כרגע.' }}</p> + <button nbButton ghost status="primary" size="small" type="button" (click)="clearFilter()">הצגת כל הערוצים</button> + } + </div> + } @else { + <!-- Desktop: a table. --> + <div class="sa-table-wrap channels-table"> + <table class="table sa-table"> + <thead> + <tr> + <th scope="col">ערוץ</th> + <th scope="col">בעלים</th> + <th scope="col">נוצר</th> + <th scope="col">מצב</th> + <th scope="col">פעולות</th> + </tr> + </thead> + <tbody> + @for (channel of filtered; track channel.slug) { + <tr [class.row--disabled]="channel.features.disabled"> + <td> + <div class="identity"> + @if (channel.logoUrl) { + <img class="identity__logo" [src]="channel.logoUrl" alt="" width="40" height="40" loading="lazy"> + } @else { + <span class="identity__logo identity__logo--initial" aria-hidden="true">{{ initial(channel) }}</span> + } + <div class="identity__text"> + <span class="identity__name">{{ channel.name }}</span> + <code class="identity__slug sa-ltr">{{ channel.slug }}</code> + </div> + </div> + </td> + <td><span class="sa-ltr owner">{{ channel.ownerEmail }}</span></td> + <td class="nowrap">{{ channel.createdAt | date:'dd/MM/yyyy' }}</td> + <td> + @if (channel.features.disabled) { + <span class="sa-chip sa-chip--danger"><nb-icon icon="slash-outline"></nb-icon> מושבת</span> + } @else { + <span class="sa-chip sa-chip--success"><nb-icon icon="checkmark-outline"></nb-icon> פעיל</span> + } + </td> + <td> + <ng-container *ngTemplateOutlet="actions; context: { $implicit: channel }"></ng-container> + </td> + </tr> + } + </tbody> + </table> + </div> + + <!-- Phone: one card per channel. --> + <ul class="channels-cards"> + @for (channel of filtered; track channel.slug) { + <li class="channel-card" [class.row--disabled]="channel.features.disabled"> + <div class="identity"> + @if (channel.logoUrl) { + <img class="identity__logo" [src]="channel.logoUrl" alt="" width="40" height="40" loading="lazy"> + } @else { + <span class="identity__logo identity__logo--initial" aria-hidden="true">{{ initial(channel) }}</span> + } + <div class="identity__text"> + <span class="identity__name">{{ channel.name }}</span> + <code class="identity__slug sa-ltr">{{ channel.slug }}</code> + </div> + @if (channel.features.disabled) { + <span class="sa-chip sa-chip--danger">מושבת</span> + } @else { + <span class="sa-chip sa-chip--success">פעיל</span> + } + </div> + <dl class="channel-card__meta"> + <div><dt>בעלים</dt><dd class="sa-ltr">{{ channel.ownerEmail }}</dd></div> + <div><dt>נוצר</dt><dd>{{ channel.createdAt | date:'dd/MM/yyyy' }}</dd></div> + </dl> + <ng-container *ngTemplateOutlet="actions; context: { $implicit: channel }"></ng-container> + </li> + } + </ul> + } + } + </nb-card-body> +</nb-card> diff --git a/frontend/src/app/components/super-admin/channels/channels-list.component.scss b/frontend/src/app/components/super-admin/channels/channels-list.component.scss new file mode 100644 index 00000000..44c30eeb --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.scss @@ -0,0 +1,141 @@ +// Shared classes (.sa-*) come from the panel stylesheet; only what is specific +// to this list lives here. +:host { + display: block; +} + +.create-card { + margin-bottom: 1rem; + border-color: var(--color-primary-200); +} + +.identity { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.identity__logo { + width: 2.5rem; + height: 2.5rem; + flex: none; + border-radius: 0.75rem; + object-fit: cover; + background: var(--color-basic-300); +} + +.identity__logo--initial { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 1.1rem; + color: var(--color-primary-700); + background: var(--color-primary-100); +} + +.identity__text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.identity__name { + font-weight: 700; + color: var(--text-basic-color); + overflow-wrap: anywhere; +} + +.identity__slug { + font-size: 0.78rem; + color: var(--text-hint-color); + background: transparent; + padding: 0; +} + +.owner { + font-size: 0.9rem; + overflow-wrap: anywhere; +} + +.nowrap { + white-space: nowrap; +} + +// Desktop table: the icon-only actions are one toolbar. The auto table layout +// wrapped them 4+2 as soon as a long owner e-mail made the columns compete for +// width (the shared .sa-actions wraps on phones, where the rows are cards). +.channels-table .sa-actions { flex-wrap: nowrap; } +.channels-table .sa-actions [nbButton] { padding-inline: 0.25rem; } + +.row--disabled .identity, +.row--disabled .owner { + opacity: 0.6; +} + +// Desktop: icon-only actions (each has a tooltip and an aria-label); the +// text labels are for the phone cards, where there is no hover. +.act-text { + display: none; +} + +.channels-cards { + display: none; + list-style: none; + margin: 0; + padding: 0; +} + +.channel-card { + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-child { + border-bottom: 0; + } +} + +.channel-card__meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.75rem; + margin: 0.6rem 0; + font-size: 0.85rem; + + > div { + display: contents; + } + + dt { + color: var(--text-hint-color); + font-weight: 600; + } + + dd { + margin: 0; + color: var(--text-basic-color); + overflow-wrap: anywhere; + } +} + +@media (max-width: 767px) { + .channels-table { + display: none; + } + + .channels-cards { + display: block; + } + + .act-text { + display: inline; + } + + // Every action is a labelled, thumb-sized button on a phone. + .channel-card .sa-actions [nbButton] { + flex: 1 1 30%; + justify-content: center; + } +} diff --git a/frontend/src/app/components/super-admin/channels/channels-list.component.ts b/frontend/src/app/components/super-admin/channels/channels-list.component.ts new file mode 100644 index 00000000..e1a345e7 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.ts @@ -0,0 +1,260 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbFormFieldModule, + NbIconModule, + NbInputModule, + NbToastrService, + NbTooltipModule, +} from '@nebular/theme'; +import { SuperAdminService, ChannelData } from '../../../services/super-admin.service'; +import { SLUG_PATTERN } from '../../../services/channel.service'; +import { ConfirmService } from '../../../services/confirm.service'; +import { ShareService } from '../../../services/share.service'; + +type StatusFilter = 'all' | 'active' | 'disabled'; + +@Component({ + selector: 'app-channels-list', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbFormFieldModule, + NbTooltipModule, + ], + templateUrl: './channels-list.component.html', + styleUrl: './channels-list.component.scss', +}) +export class ChannelsListComponent implements OnInit { + @Output() editFeatures = new EventEmitter<ChannelData>(); + @Output() manageUsers = new EventEmitter<ChannelData>(); + @Output() manageStorage = new EventEmitter<ChannelData>(); + + channels: ChannelData[] = []; + loading = true; + loadFailed = false; + + query = ''; + statusFilter: StatusFilter = 'all'; + readonly statusFilters: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'הכל' }, + { value: 'active', label: 'פעילים' }, + { value: 'disabled', label: 'מושבתים' }, + ]; + + showCreateForm = false; + newSlug = ''; + newName = ''; + newOwnerEmail = ''; + creating = false; + + // Slugs whose DELETE is in flight: the button stays clickable otherwise, and + // a second click sent another DELETE that answered 404 once the first had + // finished — "deleted" and "failed to delete" toasts for the same channel. + deleting = new Set<string>(); + // Same guard for the kill switch, which is two requests (read, then write). + toggling = new Set<string>(); + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + private confirm: ConfirmService, + private share: ShareService, + ) {} + + ngOnInit(): void { + this.loadChannels(); + } + + loadChannels() { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getChannels() + .then(channels => this.channels = channels || []) + .catch(() => { + this.loadFailed = true; + this.toastr.danger('', 'רשימת הערוצים לא נטענה'); + }) + .finally(() => this.loading = false); + } + + /** The rows that match the search box and the status filter. */ + get filtered(): ChannelData[] { + const q = this.query.trim().toLowerCase(); + return this.channels.filter(c => { + if (this.statusFilter === 'active' && c.features?.disabled) return false; + if (this.statusFilter === 'disabled' && !c.features?.disabled) return false; + if (!q) return true; + return (c.name || '').toLowerCase().includes(q) + || (c.slug || '').toLowerCase().includes(q) + || (c.ownerEmail || '').toLowerCase().includes(q); + }); + } + + get disabledCount(): number { + return this.channels.filter(c => c.features?.disabled).length; + } + + get hasFilter(): boolean { + return !!this.query.trim() || this.statusFilter !== 'all'; + } + + clearFilter() { + this.query = ''; + this.statusFilter = 'all'; + } + + channelUrl(slug: string): string { + return this.share.channelUrl(slug); + } + + /** First letter of the name, for a channel without a logo. */ + initial(channel: ChannelData): string { + const name = (channel.name || channel.slug || '').trim(); + return name ? name[0].toUpperCase() : '?'; + } + + openCreate() { + this.showCreateForm = true; + } + + /** Live preview of what the slug will be after normalisation. */ + get normalizedSlug(): string { + return this.normalizeSlug(this.newSlug); + } + + private normalizeSlug(raw: string): string { + return raw.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + } + + createChannel() { + // The same normalisation the self-service form applies as the user types: + // the server's slug regex is lowercase-only, so " My Channel" was refused + // with an English 400 while the operator saw nothing wrong with it. + this.newSlug = this.normalizeSlug(this.newSlug); + this.newName = this.newName.trim(); + this.newOwnerEmail = this.newOwnerEmail.trim(); + if (!this.newSlug || !this.newName || !this.newOwnerEmail) { + this.toastr.warning('', 'יש למלא שם, כתובת ערוץ ואימייל של הבעלים'); + return; + } + if (!SLUG_PATTERN.test(this.newSlug)) { + this.toastr.warning('', 'כתובת הערוץ לא תקינה — 3 עד 50 תווים, אותיות אנגליות קטנות, ספרות ומקפים'); + return; + } + this.creating = true; + this.superAdminService.createChannel(this.newSlug, this.newName, this.newOwnerEmail) + .then(channel => { + this.channels.push(channel); + this.cancelCreate(); + this.clearFilter(); + this.toastr.success('', `הערוץ "${channel.name}" נוצר`); + }) + .catch((err) => this.toastr.danger('', this.createErrorText(err))) + .finally(() => this.creating = false); + } + + /** + * The server answers failures as plain English text (http.Error). This path + * says "Invalid slug…", "Slug is reserved", "Invalid owner email", "Channel + * already exists"; the self-service path it mirrors says "invalid slug", + * "slug is reserved", "slug already taken", "another channel is already + * being created". Matched case-insensitively, never shown as-is. + */ + private createErrorText(err: any): string { + const text = (typeof err?.error === 'string' ? err.error : '').toLowerCase(); + switch (err?.status) { + case 409: + return text.includes('already being created') + ? 'יצירת ערוץ אחר עדיין בתהליך, נסו שוב בעוד רגע' + : 'כתובת הערוץ כבר תפוסה, בחרו כתובת אחרת'; + case 400: + if (text.includes('reserved')) return 'כתובת הערוץ שמורה למערכת, בחרו כתובת אחרת'; + if (text.includes('slug')) return 'כתובת הערוץ לא תקינה — 3 עד 50 תווים, אותיות אנגליות קטנות, ספרות ומקפים'; + if (text.includes('email')) return 'כתובת המייל של הבעלים אינה תקינה'; + if (text.includes('too long')) return 'שם הערוץ ארוך מדי (עד 80 תווים)'; + if (text.includes('name is required')) return 'יש להזין שם לערוץ'; + return 'הפרטים שהוזנו אינם תקינים'; + default: + return 'יצירת הערוץ לא הצליחה, נסו שוב'; + } + } + + async deleteChannel(channel: ChannelData) { + const slug = channel.slug; + if (this.deleting.has(slug)) return; + // Say what is actually destroyed — the server wipes the whole tenant. + const ok = await this.confirm.ask({ + title: `למחוק את הערוץ "${channel.name || slug}"?`, + message: `כל ההודעות, הקבצים וההרשאות של הערוץ (${slug}) יימחקו לצמיתות. אי אפשר לשחזר אותם.`, + status: 'danger', + confirmLabel: 'מחיקה לצמיתות', + }); + if (!ok) return; + this.deleting.add(slug); + this.superAdminService.deleteChannel(slug) + .then(() => { + this.channels = this.channels.filter(c => c.slug !== slug); + this.toastr.success('', `הערוץ "${channel.name || slug}" נמחק`); + }) + .catch((err) => this.toastr.danger('', this.deleteErrorText(err))) + .finally(() => this.deleting.delete(slug)); + } + + private deleteErrorText(err: any): string { + switch (err?.status) { + case 503: return 'הערוץ עסוק כרגע (פרסום מתוזמן בעיצומו), נסו שוב בעוד רגע'; + case 404: return 'הערוץ כבר לא קיים — רעננו את הרשימה'; + default: return 'מחיקת הערוץ לא הצליחה, נסו שוב'; + } + } + + /** The kill switch, with a confirmation that names the channel. */ + async toggleDisabled(channel: ChannelData) { + const slug = channel.slug; + if (this.toggling.has(slug)) return; + const disable = !channel.features?.disabled; + const name = channel.name || slug; + const ok = await this.confirm.ask(disable + ? { + title: `להשבית את הערוץ "${name}"?`, + message: 'הערוץ ייסגר מיד לכל הקוראים והכותבים, כולל הבעלים, ובמקומו יוצג המסך "הערוץ מושבת". אפשר להפעיל אותו מחדש מכאן בכל רגע.', + status: 'warning', + icon: 'slash-outline', + confirmLabel: 'השבתה', + } + : { + title: `להפעיל מחדש את הערוץ "${name}"?`, + message: 'הערוץ יחזור להיות זמין לכולם מיד.', + status: 'primary', + icon: 'checkmark-circle-2-outline', + confirmLabel: 'הפעלה מחדש', + }); + if (!ok) return; + this.toggling.add(slug); + this.superAdminService.setChannelDisabled(slug, disable) + .then(features => { + channel.features = features; + this.toastr.success('', disable ? `הערוץ "${name}" הושבת` : `הערוץ "${name}" פעיל שוב`); + }) + .catch((err) => this.toastr.danger('', err?.status === 404 + ? 'הערוץ כבר לא קיים — רעננו את הרשימה' + : 'עדכון הערוץ לא הצליח, נסו שוב')) + .finally(() => this.toggling.delete(slug)); + } + + cancelCreate() { + this.showCreateForm = false; + this.newSlug = ''; + this.newName = ''; + this.newOwnerEmail = ''; + } +} diff --git a/frontend/src/app/components/super-admin/global-ads/global-ads.component.html b/frontend/src/app/components/super-admin/global-ads/global-ads.component.html new file mode 100644 index 00000000..75473f2c --- /dev/null +++ b/frontend/src/app/components/super-admin/global-ads/global-ads.component.html @@ -0,0 +1,110 @@ +@if (loading) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את הגדרות הפרסומת…</p> + </div> + </nb-card-body> + </nb-card> +} @else if (loadFailed) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">הגדרות הפרסומת לא נטענו</p> + <p class="sa-state__text">בלי העותק מהשרת שמירה הייתה דורסת את ההגדרות הקיימות. נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + </nb-card-body> + </nb-card> +} @else { + <form class="ads" (ngSubmit)="save()" novalidate> + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="film-outline"></nb-icon> + הפרסומת של המערכת + </h2> + </nb-card-header> + <nb-card-body> + <p class="sa-lead">דף אינטרנט שמוצג במסגרת (iframe) בתוך דף הערוץ. הוא מגיע רק לערוצים שנעולים אליו (למטה).</p> + <div class="sa-field"> + <label class="sa-field__label" for="adsSrc">כתובת הפרסומת</label> + <input nbInput fullWidth id="adsSrc" name="adsSrc" type="url" dir="ltr" [(ngModel)]="config.src" + placeholder="https://example.com/ad.html" autocomplete="off" spellcheck="false" + [status]="srcInvalid ? 'danger' : 'basic'"> + @if (srcInvalid) { + <p class="sa-field__error">כתובת מלאה שמתחילה ב-https:// (או http://).</p> + } @else { + <p class="sa-field__hint">דף אינטרנט שייטען בתוך המסגרת. השאירו ריק כדי לא להציג פרסומת של המערכת.</p> + } + </div> + <div class="sa-field"> + <label class="sa-field__label" for="adsWidth">רוחב הפרסומת (פיקסלים)</label> + <input nbInput id="adsWidth" name="adsWidth" type="number" min="1" step="1" inputmode="numeric" dir="ltr" + class="width-input" [(ngModel)]="config.width" placeholder="300"> + <p class="sa-field__hint">בטלפון המסגרת מצטמצמת לרוחב המסך בכל מקרה.</p> + </div> + </nb-card-body> + </nb-card> + + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="lock-outline"></nb-icon> + על אילו ערוצים זה חל + </h2> + </nb-card-header> + <nb-card-body> + @if (appliesNowhere) { + <nb-alert status="warning" class="applies-alert" role="status"> + כרגע ההגדרות לא חלות על אף ערוץ: כל ערוץ מציג את הפרסומת שמנהליו הגדירו. נעלו את כל הערוצים, או הוסיפו ערוץ לרשימה. + </nb-alert> + } + <div class="sa-toggle"> + <nb-toggle name="lockAll" [(checked)]="config.lockAll"> + <span class="toggle-label">נעילת כל הערוצים</span> + </nb-toggle> + <p class="sa-toggle__hint">כל ערוץ במערכת יציג את הפרסומת הזאת, וההגדרה של מנהלי הערוץ לא תשפיע. הרשימה למטה מיותרת כשזה פעיל.</p> + </div> + <div class="sa-field locked"> + <label class="sa-field__label" for="adsLockedChannel">ערוצים נעולים</label> + <app-channel-picker inputId="adsLockedChannel" [selected]="config.lockedChannels" + (picked)="addLockedChannel($event)"></app-channel-picker> + <p class="sa-field__hint">חפשו לפי שם הערוץ או הכתובת שלו. ערוץ ברשימה מציג את פרסומת המערכת גם כשהנעילה הכללית כבויה.</p> + @if (config.lockedChannels.length) { + <nb-tag-list class="locked__tags" (tagRemove)="removeLockedChannel($event.text)" aria-label="ערוצים נעולים"> + @for (slug of config.lockedChannels; track slug) { + <nb-tag [text]="slug" removable [status]="channelDirectory.isMissing(slug) ? 'warning' : 'primary'" + appearance="outline" size="small" + [attr.title]="channelDirectory.isMissing(slug) ? 'הערוץ לא קיים במערכת' : null"></nb-tag> + } + </nb-tag-list> + } @else { + <p class="locked__empty">אין ערוצים ברשימה.</p> + } + </div> + </nb-card-body> + </nb-card> + + <nb-card class="save-card"> + <nb-card-body> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit" [disabled]="saving"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירת ההגדרות' }} + </button> + @if (dirty) { + <span class="sa-save-bar__note sa-save-bar__note--dirty">יש שינויים שעוד לא נשמרו</span> + } @else { + <span class="sa-save-bar__note">הנעילות מתעדכנות בערוצים תוך רגע לאחר השמירה.</span> + } + </div> + </nb-card-body> + </nb-card> + </form> +} diff --git a/frontend/src/app/components/super-admin/global-ads/global-ads.component.scss b/frontend/src/app/components/super-admin/global-ads/global-ads.component.scss new file mode 100644 index 00000000..abd0be51 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-ads/global-ads.component.scss @@ -0,0 +1,49 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.ads { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.ads nb-card { + margin: 0; +} + +.toggle-label { + font-weight: 600; + color: var(--text-basic-color); +} + +.width-input { + max-width: 10rem; +} + +.applies-alert { + margin-bottom: 1rem; +} + +.locked { + margin-top: 0.5rem; +} + +.locked__tags { + display: block; + margin-top: 0.75rem; +} + +.locked__empty { + margin: 0.75rem 0 0; + font-size: 0.85rem; + color: var(--text-hint-color); +} + +.save-card { + position: sticky; + bottom: 0.5rem; + z-index: 1; + border-color: var(--color-primary-200); +} diff --git a/frontend/src/app/components/super-admin/global-ads/global-ads.component.ts b/frontend/src/app/components/super-admin/global-ads/global-ads.component.ts new file mode 100644 index 00000000..b158ba43 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-ads/global-ads.component.ts @@ -0,0 +1,124 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbAlertModule, + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbTagModule, + NbToggleModule, + NbToastrService, +} from '@nebular/theme'; +import { ChannelPickerComponent } from '../channel-picker/channel-picker.component'; +import { ChannelDirectoryService } from '../channel-picker/channel-directory.service'; +import { SuperAdminService, GlobalAdsConfig } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-global-ads', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbToggleModule, + NbTagModule, + NbAlertModule, + ChannelPickerComponent, + ], + templateUrl: './global-ads.component.html', + styleUrl: './global-ads.component.scss', +}) +export class GlobalAdsComponent implements OnInit { + config: GlobalAdsConfig = { + src: '', + width: 300, + lockAll: false, + lockedChannels: [], + }; + + loading = true; + loadFailed = false; + saving = false; + /** JSON of the last server copy, to tell the operator about unsaved edits. */ + private snapshot = ''; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + /** Marks saved channels that no longer exist (see ChannelPickerComponent). */ + public channelDirectory: ChannelDirectoryService, + ) {} + + ngOnInit(): void { + this.load(); + } + + load() { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getAdsConfig() + .then(cfg => { + // A server without a stored width answers 0; the field's min is 1, + // so keep the component's 300 default rather than show 0. + this.config = { + ...this.config, ...cfg, + width: cfg?.width > 0 ? cfg.width : this.config.width || 300, + lockedChannels: [...(cfg?.lockedChannels || [])], + }; + this.snapshot = JSON.stringify(this.config); + }) + .catch(() => { + this.loadFailed = true; + this.toastr.danger('', 'הגדרות הפרסומת לא נטענו'); + }) + .finally(() => this.loading = false); + } + + get dirty(): boolean { + return !!this.snapshot && JSON.stringify(this.config) !== this.snapshot; + } + + /** Same rule the server applies (isFramableURL): http(s) or protocol-relative, or nothing. */ + get srcInvalid(): boolean { + const v = (this.config.src || '').trim(); + return !!v && !/^(https?:)?\/\/\S+$/i.test(v); + } + + /** Whether the saved settings reach any channel at all. */ + get appliesNowhere(): boolean { + return !this.config.lockAll && this.config.lockedChannels.length === 0; + } + + addLockedChannel(slug: string) { + slug = slug.trim(); + if (slug && !this.config.lockedChannels.includes(slug)) { + this.config.lockedChannels = [...this.config.lockedChannels, slug]; + } + } + + removeLockedChannel(slug: string) { + this.config.lockedChannels = this.config.lockedChannels.filter(s => s !== slug); + } + + save() { + if (this.srcInvalid) { + this.toastr.warning('', 'כתובת הפרסומת חייבת להיות כתובת מלאה שמתחילה ב-http:// או https://'); + return; + } + this.saving = true; + this.superAdminService.setAdsConfig(this.config) + .then(() => { + this.snapshot = JSON.stringify(this.config); + this.toastr.success('', 'הגדרות הפרסומת נשמרו'); + }) + .catch((err) => this.toastr.danger('', err?.status === 400 + ? 'כתובת הפרסומת חייבת להיות כתובת מלאה שמתחילה ב-http:// או https://' + : 'השמירה לא הצליחה, נסו שוב')) + .finally(() => this.saving = false); + } +} diff --git a/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.html b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.html new file mode 100644 index 00000000..aa7b7092 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.html @@ -0,0 +1,214 @@ +@if (loading) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את הגדרות המגנט…</p> + </div> + </nb-card-body> + </nb-card> +} @else if (loadFailed) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">הגדרות המגנט לא נטענו</p> + <p class="sa-state__text">בלי העותק מהשרת שמירה הייתה דורסת את ההגדרות הקיימות. נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + </nb-card-body> + </nb-card> +} @else { + <form class="magnet" (ngSubmit)="save()" novalidate> + <!-- These settings only reach a channel that is locked to them: otherwise + each channel uses its own magnet settings and this whole form has no + effect. That is easy to miss, because the lock lives at the bottom of + the form, so say so here rather than letting it fail silently. --> + @if (appliesNowhere) { + <nb-alert status="warning" class="applies-alert" role="status"> + <strong>ההגדרות האלה עדיין לא חלות על אף ערוץ.</strong> + מגנט של המערכת חל רק על ערוצים נעולים. הפעילו למטה "נעילת כל הערוצים" או הוסיפו ערוץ לרשימה — בלי זה כל ערוץ משתמש בהגדרות המגנט שלו. + </nb-alert> + } + + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="pricetags-outline"></nb-icon> + חיבור למגנט + </h2> + </nb-card-header> + <nb-card-body> + <div class="sa-toggle"> + <nb-toggle name="enabled" [(checked)]="config.enabled"> + <span class="toggle-label">הצגת פרסומות מגנט</span> + </nb-toggle> + <p class="sa-toggle__hint">כשכבוי, שום ערוץ נעול לא מציג פרסומות מגנט, גם אם שאר הפרטים מלאים.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="magnetApiKey">מפתח ההטמעה של מגנט</label> + <nb-form-field> + <input nbInput fullWidth id="magnetApiKey" name="magnetApiKey" dir="ltr" + [type]="showApiKey ? 'text' : 'password'" autocomplete="new-password" spellcheck="false" + [(ngModel)]="config.apiKey" placeholder="המפתח מקוד ההטמעה של האתר במגנט"> + <button nbSuffix nbButton ghost size="small" type="button" (click)="showApiKey = !showApiKey" + [nbTooltip]="showApiKey ? 'הסתרת המפתח' : 'הצגת המפתח'" + [attr.aria-label]="showApiKey ? 'הסתרת המפתח' : 'הצגת המפתח'" [attr.aria-pressed]="showApiKey"> + <nb-icon [icon]="showApiKey ? 'eye-off-outline' : 'eye-outline'"></nb-icon> + </button> + </nb-form-field> + <p class="sa-field__hint">המפתח מקוד ההטמעה של האתר במגנט. הוא ציבורי ואינו נותן גישה לנתונים — לסטטיסטיקה משמש מפתח הסטטיסטיקה.</p> + </div> + <!-- The stats key is a server secret: the server never sends it back, + so the field is always empty and only replaces the stored key + when something is typed in it. --> + <div class="sa-field"> + <div class="stats-key__head"> + <label class="sa-field__label" for="magnetStatsKey">מפתח סטטיסטיקה</label> + @if (clearStatsKey) { + <span class="stats-key__chip stats-key__chip--warn">יימחק בשמירה</span> + } @else if (config.statsKeySet) { + <span class="stats-key__chip stats-key__chip--set">מוגדר</span> + } @else { + <span class="stats-key__chip">לא מוגדר</span> + } + </div> + @if (clearStatsKey) { + <div class="stats-key__clearing" role="status"> + <span>המפתח יימחק בשמירה</span> + <button nbButton ghost size="small" type="button" (click)="cancelStatsKeyClearing()">ביטול</button> + </div> + } @else { + <div class="stats-key__row"> + <input nbInput fullWidth id="magnetStatsKey" name="magnetStatsKey" type="password" dir="ltr" + autocomplete="new-password" spellcheck="false" autocapitalize="none" + [status]="statsKeyError ? 'danger' : 'basic'" + [attr.aria-invalid]="statsKeyError ? true : null" + [attr.aria-describedby]="statsKeyError ? 'magnetStatsKeyError magnetStatsKeyHint' : 'magnetStatsKeyHint'" + [(ngModel)]="statsKeyInput" (ngModelChange)="statsKeyError = ''" + [placeholder]="statsKeyPlaceholder"> + @if (config.statsKeySet) { + <button nbButton status="danger" ghost size="medium" type="button" (click)="markStatsKeyForClearing()"> + <nb-icon icon="trash-2-outline"></nb-icon> + מחיקת המפתח + </button> + } + </div> + } + @if (statsKeyError) { + <p class="sa-field__error" id="magnetStatsKeyError" role="alert">{{ statsKeyError }}</p> + } + <p class="sa-field__hint" id="magnetStatsKeyHint">מאפשר להציג במסך "סטטיסטיקות" את הקליקים והתגמולים של האתר במגנט. יוצרים אותו במגנט: קוד הטמעה ← מפתח סטטיסטיקה לשרת. סודי — אחרי השמירה הוא לא מוצג שוב.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="magnetSnippet">קוד ההטמעה</label> + <textarea nbInput fullWidth id="magnetSnippet" name="magnetSnippet" rows="4" dir="ltr" spellcheck="false" + [(ngModel)]="config.snippet" placeholder="<script>…</script>"></textarea> + <p class="sa-field__hint">הקוד שמגנט נותן למפרסם, כפי שהוא. מוזרק לדף הערוץ בכל ערוץ נעול.</p> + </div> + </nb-card-body> + </nb-card> + + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="clock-outline"></nb-icon> + כל כמה זמן מוצגת פרסומת + </h2> + </nb-card-header> + <nb-card-body> + <div class="sa-field"> + <label class="sa-field__label" for="magnetMode">שיטת הספירה</label> + <nb-select fullWidth id="magnetMode" name="magnetMode" [(ngModel)]="config.mode"> + @for (opt of modeOptions; track opt.value) { + <nb-option [value]="opt.value">{{ opt.label }}</nb-option> + } + </nb-select> + <p class="sa-field__hint">{{ modeHint(config.mode) }}</p> + </div> + + <div class="sa-grid-2"> + <div class="sa-field" [class.field--muted]="byTime"> + <label class="sa-field__label" for="magnetPerMessages">פרסומת כל כמה הודעות</label> + <input nbInput fullWidth id="magnetPerMessages" name="magnetPerMessages" type="number" min="1" step="1" + inputmode="numeric" dir="ltr" [(ngModel)]="config.perMessages" placeholder="10"> + <p class="sa-field__hint">{{ byTime ? 'לא בשימוש בשיטה "לפי זמן".' : 'למשל 10 — פרסומת אחרי כל עשר הודעות.' }}</p> + </div> + <div class="sa-field" [class.field--muted]="!byTime"> + <label class="sa-field__label" for="magnetPerSeconds">פרסומת כל כמה שניות</label> + <input nbInput fullWidth id="magnetPerSeconds" name="magnetPerSeconds" type="number" min="1" step="1" + inputmode="numeric" dir="ltr" [(ngModel)]="config.perSeconds" placeholder="300"> + <p class="sa-field__hint">{{ byTime ? 'נמדד בין שעות הפרסום של ההודעות. למשל 300 — פרסומת אחרי ההודעה הראשונה שפורסמה חמש דקות או יותר אחרי ההודעה של הפרסומת הקודמת.' : 'לא בשימוש בשיטה "לפי הודעות".' }}</p> + </div> + <!-- Each gap guards the other mode's counter: the message counter + is held back by time, the timer by messages. --> + <div class="sa-field" [class.field--muted]="byTime"> + <label class="sa-field__label" for="magnetMinTime">הפסקה מינימלית בין פרסומות (שניות)</label> + <input nbInput fullWidth id="magnetMinTime" name="magnetMinTime" type="number" min="0" step="1" + inputmode="numeric" dir="ltr" [(ngModel)]="config.minTimeSeconds" placeholder="60"> + <p class="sa-field__hint">{{ byTime ? 'לא בשימוש בשיטה "לפי זמן".' : 'גם אם הספירה הגיעה — לא תוצג פרסומת אחרי הודעה שפורסמה פחות מהזמן הזה אחרי ההודעה של הפרסומת הקודמת.' }}</p> + </div> + <div class="sa-field" [class.field--muted]="!byTime"> + <label class="sa-field__label" for="magnetMinMessages">מינימום הודעות בין פרסומות</label> + <input nbInput fullWidth id="magnetMinMessages" name="magnetMinMessages" type="number" min="0" step="1" + inputmode="numeric" dir="ltr" [(ngModel)]="config.minMessagesSinceLast" placeholder="5"> + <p class="sa-field__hint">{{ byTime ? 'גם אם הזמן עבר — לא תוצג פרסומת לפני שהופיעו כל כך הרבה הודעות מהקודמת.' : 'לא בשימוש בשיטה "לפי הודעות".' }}</p> + </div> + </div> + </nb-card-body> + </nb-card> + + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="lock-outline"></nb-icon> + על אילו ערוצים זה חל + </h2> + </nb-card-header> + <nb-card-body> + <div class="sa-toggle"> + <nb-toggle name="lockAll" [(checked)]="config.lockAll"> + <span class="toggle-label">נעילת כל הערוצים</span> + </nb-toggle> + <p class="sa-toggle__hint">כל ערוץ במערכת ישתמש בהגדרות האלה, וההגדרות של מנהלי הערוץ לא ישפיעו.</p> + </div> + <div class="sa-field locked"> + <label class="sa-field__label" for="magnetLockedChannel">ערוצים נעולים</label> + <app-channel-picker inputId="magnetLockedChannel" [selected]="config.lockedChannels" + (picked)="addLockedChannel($event)"></app-channel-picker> + <p class="sa-field__hint">חפשו לפי שם הערוץ או הכתובת שלו. ערוץ ברשימה נעול גם כשהנעילה הכללית כבויה.</p> + @if (config.lockedChannels.length) { + <nb-tag-list class="locked__tags" (tagRemove)="removeLockedChannel($event.text)" aria-label="ערוצים נעולים"> + @for (slug of config.lockedChannels; track slug) { + <nb-tag [text]="slug" removable [status]="channelDirectory.isMissing(slug) ? 'warning' : 'primary'" + appearance="outline" size="small" + [attr.title]="channelDirectory.isMissing(slug) ? 'הערוץ לא קיים במערכת' : null"></nb-tag> + } + </nb-tag-list> + } @else { + <p class="locked__empty">אין ערוצים ברשימה.</p> + } + </div> + </nb-card-body> + </nb-card> + + <nb-card class="save-card"> + <nb-card-body> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit" [disabled]="saving"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירת ההגדרות' }} + </button> + @if (dirty) { + <span class="sa-save-bar__note sa-save-bar__note--dirty">יש שינויים שעוד לא נשמרו</span> + } @else { + <span class="sa-save-bar__note">הנעילות מתעדכנות בערוצים תוך רגע לאחר השמירה.</span> + } + </div> + </nb-card-body> + </nb-card> + </form> +} diff --git a/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.scss b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.scss new file mode 100644 index 00000000..faf98ad8 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.scss @@ -0,0 +1,115 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.magnet { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.magnet nb-card, +.applies-alert { + margin: 0; +} + +.toggle-label { + font-weight: 600; + color: var(--text-basic-color); +} + +// A field the chosen counting method ignores stays editable (its value is +// still saved) but steps back visually. +.field--muted { + opacity: 0.6; +} + +.locked { + margin-top: 0.5rem; +} + +.stats-key__head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.stats-key__chip { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-hint-color); + background: var(--background-basic-color-3); +} + +.stats-key__chip--set { + color: var(--color-success-700); + background: var(--color-success-100); +} + +.stats-key__chip--warn { + color: var(--color-warning-700); + background: var(--color-warning-100); +} + +.stats-key__row, +.stats-key__clearing { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.stats-key__row input { + flex: 1 1 auto; + min-width: 0; +} + +.stats-key__row [nbButton] { + flex: none; +} + +.stats-key__clearing { + justify-content: space-between; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + color: var(--color-warning-700); + background: var(--color-warning-100); +} + +.locked__add { + display: flex; + gap: 0.5rem; + align-items: stretch; +} + +.locked__add input { + flex: 1 1 auto; + min-width: 0; +} + +.locked__add [nbButton] { + flex: none; + min-height: 2.5rem; +} + +.locked__tags { + display: block; + margin-top: 0.75rem; +} + +.locked__empty { + margin: 0.75rem 0 0; + font-size: 0.85rem; + color: var(--text-hint-color); +} + +.save-card { + position: sticky; + bottom: 0.5rem; + z-index: 1; + border-color: var(--color-primary-200); +} diff --git a/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.ts b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.ts new file mode 100644 index 00000000..dcc46770 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.ts @@ -0,0 +1,203 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbAlertModule, + NbButtonModule, + NbCardModule, + NbFormFieldModule, + NbIconModule, + NbInputModule, + NbSelectModule, + NbTagModule, + NbToggleModule, + NbToastrService, + NbTooltipModule, +} from '@nebular/theme'; +import { ChannelPickerComponent } from '../channel-picker/channel-picker.component'; +import { ChannelDirectoryService } from '../channel-picker/channel-directory.service'; +import { SuperAdminService, GlobalMagnetConfig } from '../../../services/super-admin.service'; + +/** The shape Magnet mints: "mgs_" and 48 lowercase hex characters. */ +const STATS_KEY_RE = /^mgs_[0-9a-f]{48}$/; + +@Component({ + selector: 'app-global-magnet', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbSelectModule, + NbToggleModule, + NbAlertModule, + NbTagModule, + NbFormFieldModule, + NbTooltipModule, + ChannelPickerComponent, + ], + templateUrl: './global-magnet.component.html', + styleUrl: './global-magnet.component.scss', +}) +export class GlobalMagnetComponent implements OnInit { + config: GlobalMagnetConfig = { + enabled: false, + snippet: '', + mode: 'by_messages', + perMessages: 10, + minTimeSeconds: 60, + perSeconds: 300, + minMessagesSinceLast: 5, + apiKey: '', + lockAll: false, + lockedChannels: [], + statsKeySet: false, + }; + + /** + * A new stats key typed by the operator. Kept outside `config` and never + * pre-filled: the server does not send the stored key back, and an empty + * field on save means "keep the stored one". + */ + statsKeyInput = ''; + /** Set by "מחיקת המפתח"; the key is removed on the next save. */ + clearStatsKey = false; + statsKeyError = ''; + + loading = true; + loadFailed = false; + saving = false; + newLockedChannel = ''; + /** The embed key is public, but masked by default like any key field. */ + showApiKey = false; + /** JSON of the last server copy, to tell the operator about unsaved edits. */ + private snapshot = ''; + + // These are the values every consumer understands (MagnetAdsService and the + // per-channel form): anything else silently falls back to message spacing. + modeOptions = [ + { value: 'by_messages', label: 'לפי מספר הודעות', hint: 'פרסומת אחרי כל כמה הודעות שהקורא גלל.' }, + { value: 'by_time', label: 'לפי זמן', hint: 'פרסומת אחרי כל פרק זמן בין שעות הפרסום של ההודעות (לא זמן הקריאה).' }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + /** Marks saved channels that no longer exist (see ChannelPickerComponent). */ + public channelDirectory: ChannelDirectoryService, + ) {} + + ngOnInit(): void { + this.load(); + } + + load() { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getMagnetConfig() + .then(cfg => this.applyServerConfig(cfg)) + .catch(() => { + this.loadFailed = true; + this.toastr.danger('', 'הגדרות המגנט לא נטענו'); + }) + .finally(() => this.loading = false); + } + + // Values saved by an earlier version of this form (per_messages/per_seconds) + // are migrated on load, so the next save stores the value clients read. + private normalizeMode(mode: string | undefined): string { + return mode === 'by_time' || mode === 'per_seconds' ? 'by_time' : 'by_messages'; + } + + private applyServerConfig(cfg: GlobalMagnetConfig) { + this.config = { + ...this.config, + ...cfg, + mode: this.normalizeMode(cfg?.mode), + lockedChannels: [...(cfg?.lockedChannels || [])], + statsKeySet: !!cfg?.statsKeySet, + }; + this.statsKeyInput = ''; + this.clearStatsKey = false; + this.statsKeyError = ''; + this.snapshot = JSON.stringify(this.config); + } + + get dirty(): boolean { + return !!this.snapshot && ( + JSON.stringify(this.config) !== this.snapshot || + !!this.statsKeyInput.trim() || + this.clearStatsKey + ); + } + + get statsKeyPlaceholder(): string { + return this.config.statsKeySet ? 'מוגדר — השאירו ריק כדי לשמור את המפתח הקיים' : 'mgs_…'; + } + + markStatsKeyForClearing() { + this.clearStatsKey = true; + this.statsKeyInput = ''; + this.statsKeyError = ''; + } + + cancelStatsKeyClearing() { + this.clearStatsKey = false; + } + + get byTime(): boolean { + return this.config.mode === 'by_time'; + } + + modeHint(mode: string): string { + return this.modeOptions.find(o => o.value === mode)?.hint || ''; + } + + /** Whether the saved settings reach any channel at all. */ + get appliesNowhere(): boolean { + return this.config.enabled && !this.config.lockAll && !this.config.lockedChannels.length; + } + + addLockedChannel(slug: string) { + slug = slug.trim(); + if (slug && !this.config.lockedChannels.includes(slug)) { + this.config.lockedChannels = [...this.config.lockedChannels, slug]; + } + } + + removeLockedChannel(slug: string) { + this.config.lockedChannels = this.config.lockedChannels.filter(s => s !== slug); + } + + save() { + const statsKey = this.statsKeyInput.trim(); + // Same rule as the server: catching a mis-paste here keeps the rest of + // the form from being saved alongside a key that would only fail later. + if (statsKey && !this.clearStatsKey && !STATS_KEY_RE.test(statsKey)) { + this.statsKeyError = 'מפתח הסטטיסטיקה אינו תקין — הוא מתחיל ב-mgs_ ואחריו 48 תווים. העתיקו אותו שוב ממגנט.'; + this.toastr.danger(this.statsKeyError, 'השמירה לא בוצעה'); + return; + } + this.statsKeyError = ''; + this.saving = true; + this.superAdminService.setMagnetConfig({ + ...this.config, + statsKey: this.clearStatsKey ? '' : statsKey, + clearStatsKey: this.clearStatsKey, + }) + .then(cfg => { + this.applyServerConfig(cfg); + this.toastr.success('', 'הגדרות המגנט נשמרו'); + }) + .catch(err => { + // The server's own Hebrew message when it rejected the key. + const msg = err?.status === 400 && err?.error?.error === 'invalid_stats_key' ? err.error.message : ''; + if (msg) this.statsKeyError = msg; + this.toastr.danger(msg, 'השמירה לא הצליחה, נסו שוב'); + }) + .finally(() => this.saving = false); + } +} diff --git a/frontend/src/app/components/super-admin/global-settings/global-settings.component.html b/frontend/src/app/components/super-admin/global-settings/global-settings.component.html new file mode 100644 index 00000000..12044c68 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.html @@ -0,0 +1,202 @@ +@if (loading) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את הגדרות המערכת…</p> + </div> + </nb-card-body> + </nb-card> +} @else if (loadFailed) { + <nb-card> + <nb-card-body> + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">הגדרות המערכת לא נטענו</p> + <p class="sa-state__text">בלי העותק מהשרת שמירה הייתה דורסת את ההגדרות הקיימות. נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + </nb-card-body> + </nb-card> +} @else { + <form class="settings" (ngSubmit)="save()" novalidate> + + <!-- General --> + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="globe-2-outline"></nb-icon> + האתר + </h2> + </nb-card-header> + <nb-card-body> + <div class="sa-field"> + <label class="sa-field__label" for="customTitle">כותרת האתר</label> + <input nbInput fullWidth id="customTitle" name="customTitle" type="text" [(ngModel)]="values['custom_title']" + placeholder="לדוגמה: הערוצים שלנו" maxlength="120"> + <p class="sa-field__hint">מופיעה בלשונית הדפדפן ובתוצאות החיפוש של כל הערוצים. השאירו ריק לשם ברירת המחדל.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="analyticsHead">קוד אנליטיקס</label> + <textarea nbInput fullWidth id="analyticsHead" name="analyticsHead" rows="5" dir="ltr" spellcheck="false" + [(ngModel)]="values['analytics_head']" + placeholder="<!-- Google tag --> <script async src="…"></script>"></textarea> + <p class="sa-field__hint">קוד מ-Google Analytics, Tag Manager, Meta Pixel וכדומה. מוזרק כמו שהוא לכל עמוד באתר — להדביק רק קוד ממקור שסומכים עליו.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="googleVerification">קוד אימות של Google Search Console</label> + <input nbInput fullWidth id="googleVerification" name="googleVerification" type="text" dir="ltr" spellcheck="false" + [(ngModel)]="values['google_site_verification']" placeholder='<meta name="google-site-verification" content="…">'> + <p class="sa-field__hint">באימות בשיטת "תג HTML" — אפשר להדביק את התג כולו או רק את הקוד. נכנס לראש כל עמוד באתר.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="bingVerification">קוד אימות של Bing Webmaster Tools</label> + <input nbInput fullWidth id="bingVerification" name="bingVerification" type="text" dir="ltr" spellcheck="false" + [(ngModel)]="values['bing_site_verification']" placeholder='<meta name="msvalidate.01" content="…">'> + <p class="sa-field__hint">לא חובה. אותו דבר עבור Bing: התג כולו או רק הקוד.</p> + </div> + </nb-card-body> + </nb-card> + + <!-- Push notifications --> + <nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="bell-outline"></nb-icon> + התראות לטלפון + </h2> + </nb-card-header> + <nb-card-body> + <p class="sa-lead"> + ההתראות נשלחות דרך Firebase של Google. החיבור כאן משותף לכל הערוצים; כל מנהל ערוץ מחליט בנפרד אם להציע התראות לקוראים שלו. + </p> + <div class="sa-toggle"> + <nb-toggle name="onNotification" [(checked)]="values['on_notification']"> + <span class="toggle-label">הפעלת ההתראות במערכת</span> + </nb-toggle> + <p class="sa-toggle__hint"> + @if (values['on_notification']) { + @if (missingPushFields) { + עוד {{ missingPushFields }} שדות חובה ריקים למטה — עד שימולאו, ההתראות לא יישלחו. + } @else { + כל פרטי החיבור מלאים. + } + } @else { + כשכבוי, אף ערוץ לא מציע התראות. אפשר למלא את הפרטים כבר עכשיו ולהפעיל אחר כך. + } + </p> + </div> + + @if (values['on_notification']) { + <div class="push"> + <div class="sa-field"> + <label class="sa-field__label" for="projectDomain">כתובת האתר</label> + <input nbInput fullWidth id="projectDomain" name="projectDomain" type="url" dir="ltr" spellcheck="false" + [(ngModel)]="values['project_domain']" placeholder="https://example.com"> + <p class="sa-field__hint">לחיצה על התראה פותחת את הערוץ בכתובת הזאת. כתובת מלאה, בלי קו נטוי בסוף.</p> + </div> + <div class="sa-field"> + <label class="sa-field__label" for="vapidKey">מפתח VAPID</label> + <nb-form-field> + <input nbInput fullWidth id="vapidKey" name="vapidKey" dir="ltr" spellcheck="false" + [type]="showVapid ? 'text' : 'password'" autocomplete="new-password" + [(ngModel)]="values['vapid']" placeholder="המפתח הציבורי מ-Web Push certificates"> + <button nbSuffix nbButton ghost size="small" type="button" (click)="showVapid = !showVapid" + [nbTooltip]="showVapid ? 'הסתרת המפתח' : 'הצגת המפתח'" + [attr.aria-label]="showVapid ? 'הסתרת המפתח' : 'הצגת המפתח'" [attr.aria-pressed]="showVapid"> + <nb-icon [icon]="showVapid ? 'eye-off-outline' : 'eye-outline'"></nb-icon> + </button> + </nb-form-field> + <p class="sa-field__hint">ב-Firebase Console: Project settings ← Cloud Messaging ← Web Push certificates ← Key pair.</p> + </div> + + <h3 class="sub-title">פרטי האפליקציה (Firebase SDK)</h3> + <p class="sa-field__hint sub-hint">ב-Firebase Console: Project settings ← Your apps ← האפליקציה של האתר ← SDK setup and configuration. השמות באנגלית הם כפי שהם מופיעים שם.</p> + <div class="sa-grid-2"> + @for (f of sdkFields; track f.key) { + <div class="sa-field"> + <label class="sa-field__label" [for]="'f-' + f.key"><span class="sa-ltr">{{ f.label }}</span></label> + <input nbInput fullWidth [id]="'f-' + f.key" [name]="f.key" type="text" dir="ltr" spellcheck="false" + autocomplete="off" [(ngModel)]="values[f.key]"> + @if (f.hint) { <p class="sa-field__hint">{{ f.hint }}</p> } + </div> + } + </div> + + <h3 class="sub-title">חשבון השירות (קובץ JSON)</h3> + <p class="sa-field__hint sub-hint"> + ב-Firebase Console: Project settings ← Service accounts ← Generate new private key. הדביקו את כל תוכן הקובץ כאן — השדות למטה יתמלאו לבד. + </p> + <div class="sa-field paste"> + <label class="sa-field__label" for="fcmJsonPaste">הדבקת קובץ ה-JSON</label> + <textarea nbInput fullWidth id="fcmJsonPaste" name="fcmJsonPaste" rows="4" dir="ltr" spellcheck="false" + [(ngModel)]="fcmJsonPaste" placeholder="{ "type": "service_account", … }"></textarea> + @if (fcmJsonError) { + <p class="sa-field__error">{{ fcmJsonError }}</p> + } + <button nbButton status="primary" appearance="outline" size="small" type="button" class="paste__btn" + (click)="applyFcmJsonPaste()" [disabled]="!fcmJsonPaste.trim()"> + <nb-icon icon="flash-outline"></nb-icon> + מילוי השדות מהקובץ + </button> + </div> + + <div class="sa-grid-2"> + @for (f of serviceAccountFields; track f.key) { + <div class="sa-field" [class.wide]="f.wide"> + <label class="sa-field__label" [for]="'f-' + f.key"><span class="sa-ltr">{{ f.label }}</span></label> + <input nbInput fullWidth [id]="'f-' + f.key" [name]="f.key" type="text" dir="ltr" spellcheck="false" + autocomplete="off" [(ngModel)]="values[f.key]"> + </div> + } + </div> + + <div class="sa-field"> + <label class="sa-field__label" for="privateKey"><span class="sa-ltr">private_key</span></label> + <!-- A secret, so masked by default. A password input cannot hold + the key's line breaks, so editing happens only in the + revealed textarea; the masked field is read-only. --> + @if (showPrivateKey) { + <textarea nbInput fullWidth id="privateKey" name="privateKey" rows="5" dir="ltr" spellcheck="false" + [(ngModel)]="values['fcm_json_private_key']" + placeholder="-----BEGIN PRIVATE KEY-----"></textarea> + } @else { + <nb-form-field> + <input nbInput fullWidth id="privateKey" type="password" readonly dir="ltr" autocomplete="off" + [value]="values['fcm_json_private_key'] || ''" placeholder="ריק — עדיין לא הוזן מפתח"> + </nb-form-field> + } + <div class="key-actions"> + <button nbButton ghost status="basic" size="small" type="button" (click)="showPrivateKey = !showPrivateKey" + [attr.aria-pressed]="showPrivateKey"> + <nb-icon [icon]="showPrivateKey ? 'eye-off-outline' : 'eye-outline'"></nb-icon> + {{ showPrivateKey ? 'הסתרת המפתח' : 'הצגה ועריכה של המפתח' }} + </button> + </div> + <p class="sa-field__hint">המפתח הפרטי של חשבון השירות. סודי — מי שמחזיק בו יכול לשלוח התראות בשם האתר.</p> + </div> + </div> + } + </nb-card-body> + </nb-card> + + <nb-card class="save-card"> + <nb-card-body> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit" [disabled]="saving"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירת ההגדרות' }} + </button> + @if (dirty) { + <span class="sa-save-bar__note sa-save-bar__note--dirty">יש שינויים שעוד לא נשמרו</span> + } @else { + <span class="sa-save-bar__note">ההגדרות חלות על כל הערוצים מיד לאחר השמירה.</span> + } + </div> + </nb-card-body> + </nb-card> + </form> +} diff --git a/frontend/src/app/components/super-admin/global-settings/global-settings.component.scss b/frontend/src/app/components/super-admin/global-settings/global-settings.component.scss new file mode 100644 index 00000000..988b7771 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.scss @@ -0,0 +1,62 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.settings { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.settings nb-card { + margin: 0; +} + +.toggle-label { + font-weight: 600; + color: var(--text-basic-color); +} + +.push { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-basic-color-3); +} + +.sub-title { + margin: 1.5rem 0 0.25rem; + font-size: 1rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.sub-hint { + margin-bottom: 0.75rem; +} + +.wide { + grid-column: 1 / -1; +} + +.paste { + padding: 0.75rem 1rem; + border-radius: var(--border-radius); + background: var(--background-basic-color-2); + border: 1px dashed var(--border-basic-color-4); +} + +.paste__btn { + margin-top: 0.5rem; +} + +.key-actions { + margin-top: 0.5rem; +} + +.save-card { + position: sticky; + bottom: 0.5rem; + z-index: 1; + border-color: var(--color-primary-200); +} diff --git a/frontend/src/app/components/super-admin/global-settings/global-settings.component.ts b/frontend/src/app/components/super-admin/global-settings/global-settings.component.ts new file mode 100644 index 00000000..b7c23f2a --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.ts @@ -0,0 +1,201 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, NbCardModule, NbFormFieldModule, NbIconModule, NbInputModule, + NbToastrService, NbToggleModule, NbTooltipModule, +} from '@nebular/theme'; +import { SuperAdminService, Setting } from '../../../services/super-admin.service'; +import { toBool } from '../../admin/settings/settings.schema'; + +// FCM JSON key map for autofill +const FCM_JSON_KEY_MAP: Record<string, string> = { + type: 'fcm_json_type', + project_id: 'fcm_json_project_id', + private_key_id: 'fcm_json_private_key_id', + private_key: 'fcm_json_private_key', + client_email: 'fcm_json_client_email', + client_id: 'fcm_json_client_id', + auth_uri: 'fcm_json_auth_uri', + token_uri: 'fcm_json_token_uri', + auth_provider_x509_cert_url: 'fcm_json_auth_provider_x509_cert_url', + client_x509_cert_url: 'fcm_json_client_x509_cert_url', + universe_domain: 'fcm_json_universe_domain', +}; + +/** One text field of the Firebase forms: the key Firebase shows, and a Hebrew hint. */ +interface FieldDef { + key: string; + label: string; + hint?: string; + /** Whole-row field (long URLs). */ + wide?: boolean; +} + +@Component({ + selector: 'app-global-settings', + standalone: true, + imports: [ + CommonModule, FormsModule, NbCardModule, NbButtonModule, NbInputModule, NbIconModule, + NbToggleModule, NbFormFieldModule, NbTooltipModule, + ], + templateUrl: './global-settings.component.html', + styleUrl: './global-settings.component.scss', +}) +export class GlobalSettingsComponent implements OnInit { + values: Record<string, any> = {}; + saving = false; + loading = true; + loadFailed = false; + fcmJsonPaste = ''; + fcmJsonError = ''; + /** Secrets are masked until the operator asks to see them. */ + showVapid = false; + showPrivateKey = false; + /** JSON of the last server copy, to tell the operator about unsaved edits. */ + private snapshot = ''; + + /** Firebase SDK ("web app") config — what Firebase Console shows under "Your apps". */ + readonly sdkFields: FieldDef[] = [ + { key: 'fcm_api_key', label: 'apiKey' }, + { key: 'fcm_auth_domain', label: 'authDomain' }, + { key: 'fcm_project_id', label: 'projectId' }, + { key: 'fcm_storage_bucket', label: 'storageBucket' }, + { key: 'fcm_messaging_sender_id', label: 'messagingSenderId' }, + { key: 'fcm_app_id', label: 'appId' }, + { key: 'fcm_measurement_id', label: 'measurementId', hint: 'לא חובה.' }, + ]; + + /** Service-account JSON fields, except the private key which has its own masked field. */ + readonly serviceAccountFields: FieldDef[] = [ + { key: 'fcm_json_type', label: 'type' }, + { key: 'fcm_json_project_id', label: 'project_id' }, + { key: 'fcm_json_private_key_id', label: 'private_key_id' }, + { key: 'fcm_json_client_email', label: 'client_email' }, + { key: 'fcm_json_client_id', label: 'client_id' }, + { key: 'fcm_json_universe_domain', label: 'universe_domain' }, + { key: 'fcm_json_auth_uri', label: 'auth_uri', wide: true }, + { key: 'fcm_json_token_uri', label: 'token_uri', wide: true }, + { key: 'fcm_json_auth_provider_x509_cert_url', label: 'auth_provider_x509_cert_url', wide: true }, + { key: 'fcm_json_client_x509_cert_url', label: 'client_x509_cert_url', wide: true }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getGlobalSettings() + .then(settings => { + this.loadFromSettings(settings || []); + this.snapshot = JSON.stringify(this.values); + }) + .catch(() => { + this.loadFailed = true; + this.toastr.danger('', 'הגדרות המערכת לא נטענו'); + }) + .finally(() => this.loading = false); + } + + get dirty(): boolean { + return !!this.snapshot && JSON.stringify(this.values) !== this.snapshot; + } + + /** How many of the required Firebase fields are still empty, for the hint. */ + get missingPushFields(): number { + const required = [ + 'project_domain', 'vapid', + 'fcm_api_key', 'fcm_auth_domain', 'fcm_project_id', 'fcm_storage_bucket', + 'fcm_messaging_sender_id', 'fcm_app_id', + 'fcm_json_project_id', 'fcm_json_private_key', 'fcm_json_client_email', + ]; + return required.filter(k => !String(this.values[k] ?? '').trim()).length; + } + + private loadFromSettings(settings: Setting[]): void { + // Initialize defaults + this.values = { + custom_title: '', + analytics_head: '', + google_site_verification: '', + bing_site_verification: '', + on_notification: false, + project_domain: '', + vapid: '', + fcm_api_key: '', fcm_auth_domain: '', fcm_project_id: '', + fcm_storage_bucket: '', fcm_messaging_sender_id: '', fcm_app_id: '', fcm_measurement_id: '', + fcm_json_type: '', fcm_json_project_id: '', fcm_json_private_key_id: '', + fcm_json_private_key: '', fcm_json_client_email: '', fcm_json_client_id: '', + fcm_json_auth_uri: '', fcm_json_token_uri: '', + fcm_json_auth_provider_x509_cert_url: '', fcm_json_client_x509_cert_url: '', + fcm_json_universe_domain: '', + }; + for (const s of settings) { + if (s.key === 'on_notification') { + this.values[s.key] = toBool(s.value); + } else if (s.key in this.values) { + this.values[s.key] = s.value ?? ''; + } + } + } + + private buildSettingsArray(): Setting[] { + const out: Setting[] = []; + if (this.values['on_notification'] === true) { + out.push({ key: 'on_notification', value: '1' as any }); + } + const textKeys = [ + 'custom_title', 'analytics_head', + 'google_site_verification', 'bing_site_verification', + 'project_domain', 'vapid', + 'fcm_api_key', 'fcm_auth_domain', 'fcm_project_id', + 'fcm_storage_bucket', 'fcm_messaging_sender_id', 'fcm_app_id', 'fcm_measurement_id', + 'fcm_json_type', 'fcm_json_project_id', 'fcm_json_private_key_id', + 'fcm_json_private_key', 'fcm_json_client_email', 'fcm_json_client_id', + 'fcm_json_auth_uri', 'fcm_json_token_uri', + 'fcm_json_auth_provider_x509_cert_url', 'fcm_json_client_x509_cert_url', + 'fcm_json_universe_domain', + ]; + for (const k of textKeys) { + const v = this.values[k]; + if (v && v !== '') out.push({ key: k, value: v }); + } + return out; + } + + applyFcmJsonPaste(): void { + this.fcmJsonError = ''; + if (!this.fcmJsonPaste.trim()) return; + let parsed: any; + try { parsed = JSON.parse(this.fcmJsonPaste); } catch { + this.fcmJsonError = 'הטקסט שהודבק אינו קובץ JSON תקין. הדביקו את כל תוכן הקובץ, מהסוגר הפותח ועד הסוגר הסוגר.'; return; + } + let count = 0; + for (const [jsonKey, settingKey] of Object.entries(FCM_JSON_KEY_MAP)) { + if (parsed[jsonKey] !== undefined) { this.values[settingKey] = String(parsed[jsonKey]); count++; } + } + if (count === 0) { this.fcmJsonError = 'לא נמצאו בקובץ שדות של חשבון שירות. ודאו שזה הקובץ שהורדתם מ-Firebase.'; return; } + this.fcmJsonPaste = ''; + this.toastr.success('', `${count} שדות מולאו מהקובץ. זכרו לשמור.`); + } + + save(): void { + this.saving = true; + this.superAdminService.setGlobalSettings(this.buildSettingsArray()) + .then(() => { + this.snapshot = JSON.stringify(this.values); + this.toastr.success('', 'הגדרות המערכת נשמרו'); + }) + .catch((err) => this.toastr.danger('', err?.status === 400 + ? 'אחד הערכים אינו תקין, בדקו את השדות ונסו שוב' + : 'השמירה לא הצליחה, נסו שוב')) + .finally(() => this.saving = false); + } +} diff --git a/frontend/src/app/components/super-admin/global-storage/global-storage.component.html b/frontend/src/app/components/super-admin/global-storage/global-storage.component.html new file mode 100644 index 00000000..b71f2d5b --- /dev/null +++ b/frontend/src/app/components/super-admin/global-storage/global-storage.component.html @@ -0,0 +1,55 @@ +<nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="hard-drive-outline"></nb-icon> + נפח ברירת מחדל לערוץ + </h2> + </nb-card-header> + <nb-card-body> + @if (loading) { + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את הגדרות האחסון…</p> + </div> + } @else if (loadFailed) { + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">הגדרות האחסון לא נטענו</p> + <p class="sa-state__text">נסו לטעון שוב. בלי הערך הנוכחי לא כדאי לשמור.</p> + <button nbButton status="primary" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + } @else { + <p class="sa-lead"> + כל ערוץ מקבל את הנפח הזה לקבצים ולתמונות שלו, אלא אם נקבע לו נפח אחר מרשימת הערוצים. + כשהנפח מתמלא, העלאות חדשות נדחות (או שהקבצים הישנים נמחקים, אם הערוץ הפעיל ניקוי אוטומטי). + </p> + <form (ngSubmit)="save()" novalidate> + <div class="sa-field"> + <label class="sa-field__label" for="defaultQuota">נפח לכל ערוץ (GB)</label> + <nb-form-field class="quota__field"> + <nb-icon nbPrefix icon="hard-drive-outline"></nb-icon> + <input nbInput fullWidth id="defaultQuota" name="defaultQuota" type="number" min="1" step="1" + inputmode="numeric" dir="ltr" [(ngModel)]="defaultQuotaGb"> + </nb-form-field> + @if (quotaInvalid) { + <p class="sa-field__error">יש להזין נפח של 1 GB לפחות. 0 היה מבטל את המגבלה לכל הערוצים.</p> + } @else { + <p class="sa-field__hint">מספר שלם של ג׳יגה-בייט. השינוי חל מיד על כל ערוץ שלא נקבע לו נפח משלו.</p> + } + </div> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit" [disabled]="saving || quotaInvalid"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירה' }} + </button> + @if (dirty) { + <span class="sa-save-bar__note sa-save-bar__note--dirty">הערך השתנה ועוד לא נשמר</span> + } + </div> + </form> + } + </nb-card-body> +</nb-card> diff --git a/frontend/src/app/components/super-admin/global-storage/global-storage.component.scss b/frontend/src/app/components/super-admin/global-storage/global-storage.component.scss new file mode 100644 index 00000000..f14537bd --- /dev/null +++ b/frontend/src/app/components/super-admin/global-storage/global-storage.component.scss @@ -0,0 +1,8 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.quota__field { + max-width: 16rem; +} diff --git a/frontend/src/app/components/super-admin/global-storage/global-storage.component.ts b/frontend/src/app/components/super-admin/global-storage/global-storage.component.ts new file mode 100644 index 00000000..8e52a6ff --- /dev/null +++ b/frontend/src/app/components/super-admin/global-storage/global-storage.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbCardModule, NbButtonModule, NbInputModule, + NbFormFieldModule, NbIconModule, NbToastrService +} from '@nebular/theme'; +import { SuperAdminService } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-global-storage', + standalone: true, + imports: [ + CommonModule, FormsModule, + NbCardModule, NbButtonModule, NbInputModule, + NbFormFieldModule, NbIconModule + ], + templateUrl: './global-storage.component.html', + styleUrl: './global-storage.component.scss', +}) +export class GlobalStorageComponent implements OnInit { + // null when the number input is cleared (ngModel posts null, which the + // server reads as 0). + defaultQuotaGb: number | null = 5; + loading = true; + loadFailed = false; + saving = false; + /** The value on the server, to tell the operator about an unsaved edit. */ + private savedQuotaGb: number | null = null; + + /** + * A stored 0 is not "use the default": the upload path treats an effective + * quota of 0 as unlimited, so an empty or 0 field silently lifted every + * channel's limit. min="1" on the input is only a hint. + */ + get quotaInvalid(): boolean { + const gb = Number(this.defaultQuotaGb); + return this.defaultQuotaGb === null || this.defaultQuotaGb === undefined + || !Number.isFinite(gb) || gb < 1; + } + + get dirty(): boolean { + return this.savedQuotaGb !== null && Number(this.defaultQuotaGb) !== this.savedQuotaGb; + } + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService + ) {} + + ngOnInit() { + this.load(); + } + + async load() { + this.loading = true; + this.loadFailed = false; + try { + const cfg = await this.superAdminService.getGlobalStorageConfig(); + this.defaultQuotaGb = cfg.defaultQuotaGb; + this.savedQuotaGb = Number(cfg.defaultQuotaGb); + } catch { + this.loadFailed = true; + this.toastr.danger('', 'הגדרות האחסון לא נטענו'); + } finally { + this.loading = false; + } + } + + async save() { + if (this.quotaInvalid) return; + this.saving = true; + try { + await this.superAdminService.setGlobalStorageConfig(Number(this.defaultQuotaGb)); + this.savedQuotaGb = Number(this.defaultQuotaGb); + this.toastr.success('', `ברירת המחדל עודכנה ל-${this.savedQuotaGb} GB לכל ערוץ`); + } catch (err: any) { + this.toastr.danger('', err?.status === 400 + ? 'יש להזין נפח חיובי של ג׳יגה-בייט' + : 'השמירה לא הצליחה, נסו שוב'); + } finally { + this.saving = false; + } + } +} diff --git a/frontend/src/app/components/super-admin/global-users/global-users.component.html b/frontend/src/app/components/super-admin/global-users/global-users.component.html new file mode 100644 index 00000000..3a4f256d --- /dev/null +++ b/frontend/src/app/components/super-admin/global-users/global-users.component.html @@ -0,0 +1,102 @@ +<nb-card> + <nb-card-header> + <div class="sa-card-head"> + <h2 class="sa-card-title"> + <nb-icon icon="people-outline"></nb-icon> + כל המשתמשים + @if (!loading && !loadFailed) { + <span class="sa-chip">{{ users.length }}</span> + @if (operatorCount) { + <span class="sa-chip sa-chip--danger">{{ operatorCount }} מנהלי מערכת</span> + } + } + </h2> + @if (!loading) { + <button nbButton ghost status="basic" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + רענון + </button> + } + </div> + </nb-card-header> + + <nb-card-body> + @if (loading) { + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את רשימת המשתמשים…</p> + </div> + } @else if (loadFailed) { + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">רשימת המשתמשים לא נטענה</p> + <p class="sa-state__text">נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + } @else if (users.length === 0) { + <div class="sa-state"> + <span class="sa-state__icon"><nb-icon icon="people-outline"></nb-icon></span> + <p class="sa-state__title">עדיין אין משתמשים</p> + <p class="sa-state__text">משתמש מופיע כאן אחרי הכניסה הראשונה שלו, או כשמישהו נותן לו תפקיד בערוץ.</p> + </div> + } @else { + <p class="sa-lead"> + הרשימה נראית רק למנהלי המערכת. תפקיד בערוץ (בעלים, מנהל, כותב) משנים במסך המשתמשים של אותו ערוץ. + </p> + <div class="sa-toolbar"> + <div class="sa-toolbar__search"> + <label class="visually-hidden" for="userSearch">חיפוש משתמש</label> + <nb-form-field> + <nb-icon nbPrefix icon="search-outline"></nb-icon> + <input nbInput fullWidth id="userSearch" type="search" [(ngModel)]="query" + placeholder="חיפוש לפי אימייל, שם או כתובת ערוץ" autocomplete="off"> + </nb-form-field> + </div> + </div> + + @if (filtered.length === 0) { + <div class="sa-state"> + <span class="sa-state__icon"><nb-icon icon="search-outline"></nb-icon></span> + <p class="sa-state__title">לא נמצאו משתמשים שמתאימים לחיפוש</p> + <button nbButton ghost status="primary" size="small" type="button" (click)="query = ''">ניקוי החיפוש</button> + </div> + } @else { + <!-- Tracked by email: id stays "" for every user granted a role + before their first login, and two such rows made the id key + a duplicate (NG0955) on every render. Emails are normalised + and unique in this list. --> + <ul class="user-list"> + @for (user of filtered; track user.email) { + <li class="user"> + <span class="user__avatar" aria-hidden="true">{{ initial(user) }}</span> + <div class="user__main"> + <div class="user__head"> + <span class="user__name">{{ user.publicName || user.username || user.email }}</span> + <span class="sa-chip" + [class.sa-chip--danger]="user.globalRole === 'super_admin'" + [class.sa-chip--primary]="user.globalRole === 'admin'"> + {{ getRoleLabel(user.globalRole) }} + </span> + </div> + <span class="user__email sa-ltr">{{ user.email }}</span> + <div class="user__channels"> + @for (c of channelRoles(user); track c.slug) { + <span class="sa-chip sa-chip--info"> + <span class="sa-ltr">{{ c.slug }}</span> + · {{ c.label }} + </span> + } @empty { + <span class="user__none">ללא תפקיד בערוצים</span> + } + </div> + </div> + </li> + } + </ul> + } + } + </nb-card-body> +</nb-card> diff --git a/frontend/src/app/components/super-admin/global-users/global-users.component.scss b/frontend/src/app/components/super-admin/global-users/global-users.component.scss new file mode 100644 index 00000000..98bff178 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-users/global-users.component.scss @@ -0,0 +1,74 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.user-list { + list-style: none; + margin: 0; + padding: 0; +} + +.user { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-child { + border-bottom: 0; + } +} + +.user__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + flex: none; + border-radius: 50%; + font-weight: 800; + color: var(--color-primary-700); + background: var(--color-primary-100); +} + +.user__main { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + flex: 1 1 auto; +} + +.user__head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.user__name { + font-weight: 700; + color: var(--text-basic-color); + overflow-wrap: anywhere; +} + +.user__email { + font-size: 0.85rem; + color: var(--text-hint-color); + overflow-wrap: anywhere; +} + +.user__channels { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.25rem; +} + +.user__none { + font-size: 0.8rem; + color: var(--text-hint-color); +} diff --git a/frontend/src/app/components/super-admin/global-users/global-users.component.ts b/frontend/src/app/components/super-admin/global-users/global-users.component.ts new file mode 100644 index 00000000..3f6c7687 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-users/global-users.component.ts @@ -0,0 +1,100 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbFormFieldModule, + NbIconModule, + NbInputModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, SuperAdminUser } from '../../../services/super-admin.service'; +import { ROLE_LABELS } from '../../../models/my-channel.model'; + +interface ChannelRoleChip { + slug: string; + label: string; +} + +@Component({ + selector: 'app-global-users', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbInputModule, + NbFormFieldModule, + ], + templateUrl: './global-users.component.html', + styleUrl: './global-users.component.scss', +}) +export class GlobalUsersComponent implements OnInit { + users: SuperAdminUser[] = []; + loading = true; + loadFailed = false; + query = ''; + + globalRoleLabels: Record<string, string> = { + super_admin: 'מנהל מערכת', + admin: 'מנהל', + user: 'משתמש', + '': 'משתמש', + }; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadFailed = false; + this.superAdminService.getGlobalUsers() + .then(users => this.users = users || []) + .catch(() => { + this.loadFailed = true; + this.toastr.danger('', 'רשימת המשתמשים לא נטענה'); + }) + .finally(() => this.loading = false); + } + + /** Rows matching the search box: email, name, or a channel they hold a role on. */ + get filtered(): SuperAdminUser[] { + const q = this.query.trim().toLowerCase(); + if (!q) return this.users; + return this.users.filter(u => + (u.email || '').toLowerCase().includes(q) + || (u.publicName || '').toLowerCase().includes(q) + || (u.username || '').toLowerCase().includes(q) + || Object.keys(u.channelRoles || {}).some(slug => slug.toLowerCase().includes(q))); + } + + get operatorCount(): number { + return this.users.filter(u => u.globalRole === 'super_admin').length; + } + + channelRoles(user: SuperAdminUser): ChannelRoleChip[] { + return Object.entries(user.channelRoles || {}).map(([slug, role]) => ({ + slug, + label: (ROLE_LABELS as Record<string, string>)[role] || role, + })); + } + + getRoleLabel(role: string): string { + return this.globalRoleLabels[role] || role; + } + + /** First letter of the display name (or the address), for the avatar. */ + initial(user: SuperAdminUser): string { + const s = (user.publicName || user.email || '').trim(); + return s ? s[0].toUpperCase() : '?'; + } +} diff --git a/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.html b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.html new file mode 100644 index 00000000..b8a0bc20 --- /dev/null +++ b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.html @@ -0,0 +1,27 @@ +<div class="stats"> + <app-magnet-stats-panel + [source]="loadMagnetStats" + heading="הפרסומות באתר" + setupHint='צרו מפתח במגנט (קוד הטמעה ← מפתח סטטיסטיקה לשרת), הזינו אותו במסך "פרסומות מגנט" ושמרו; הנתונים יופיעו כאן.' + fixKeyLabel='למסך "פרסומות מגנט"' + (fixKey)="openMagnet.emit()"></app-magnet-stats-panel> + + <nb-card class="danger-card"> + <nb-card-header> + <h2 class="sa-card-title danger-title"> + <nb-icon icon="alert-triangle-outline"></nb-icon> + איפוס מוני החיבורים + </h2> + </nb-card-header> + <nb-card-body> + <p class="sa-lead"> + המערכת סופרת לכל ערוץ כמה קוראים מחוברים בו-זמנית, ושומרת שיא והיסטוריה חודשית — זה מה שמנהלי הערוצים רואים בגרפים שלהם. + האיפוס מוחק את השיא ואת ההיסטוריה של <strong>כל</strong> הערוצים, ואי אפשר לשחזר. + </p> + <button nbButton status="danger" appearance="outline" type="button" (click)="resetStatistics()" [disabled]="resetting"> + <nb-icon icon="refresh-outline"></nb-icon> + {{ resetting ? 'מאפס…' : 'איפוס מוני החיבורים של כל הערוצים' }} + </button> + </nb-card-body> + </nb-card> +</div> diff --git a/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.scss b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.scss new file mode 100644 index 00000000..2c9c3e94 --- /dev/null +++ b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.scss @@ -0,0 +1,28 @@ +// Shared classes (.sa-*) come from the panel stylesheet; the Magnet card is +// app-magnet-stats-panel. Every colour here is a theme token. +:host { + display: block; +} + +.stats { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.stats nb-card { + margin: 0; +} + +// The reset cannot be undone; the danger tokens keep the card visibly apart. +.danger-card { + border-color: var(--color-danger-300); +} + +.danger-title { + color: var(--color-danger-700); +} + +.danger-title nb-icon { + color: var(--color-danger-600); +} diff --git a/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.ts b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.ts new file mode 100644 index 00000000..b76625de --- /dev/null +++ b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.ts @@ -0,0 +1,59 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService } from '../../../services/super-admin.service'; +import { ConfirmService } from '../../../services/confirm.service'; +import { MagnetStatsPanelComponent } from '../../shared/magnet-stats-panel.component'; + +@Component({ + selector: 'app-super-admin-statistics', + standalone: true, + imports: [ + NbCardModule, + NbButtonModule, + NbIconModule, + MagnetStatsPanelComponent, + ], + templateUrl: './super-admin-statistics.component.html', + styleUrl: './super-admin-statistics.component.scss', +}) +export class SuperAdminStatisticsComponent { + /** The key is fixed on the "פרסומות מגנט" screen; the panel switches to it. */ + @Output() openMagnet = new EventEmitter<void>(); + + resetting = false; + + /** The platform's own site, read with the global key. A stable reference: + * the panel reloads whenever its source changes. */ + readonly loadMagnetStats = () => this.superAdminService.getMagnetStats(); + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + private confirm: ConfirmService, + ) {} + + async resetStatistics() { + // The reset clears the recorded peak AND every channel's monthly + // connection-history series (the graphs on the owners' statistics + // screens). The prompt used to name only the peak. + const ok = await this.confirm.ask({ + title: 'לאפס את מוני החיבורים של כל הערוצים?', + message: 'שיא החיבורים והיסטוריית החיבורים של כל הערוצים (הגרפים במסכי הסטטיסטיקה של מנהלי הערוצים) יימחקו. אי אפשר לשחזר אותם.', + status: 'danger', + confirmLabel: 'איפוס', + }); + if (!ok) return; + this.resetting = true; + this.superAdminService.resetStatistics() + .then(() => this.toastr.success('', 'מוני החיבורים של כל הערוצים אופסו')) + .catch((err) => this.toastr.danger('', err?.status === 504 + ? 'האיפוס לא הסתיים בזמן — חלק מהערוצים אופסו. הריצו שוב כדי להשלים.' + : 'האיפוס לא הצליח, נסו שוב')) + .finally(() => this.resetting = false); + } +} diff --git a/frontend/src/app/components/super-admin/storage/super-admin-storage.component.html b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.html new file mode 100644 index 00000000..9ad66212 --- /dev/null +++ b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.html @@ -0,0 +1,72 @@ +<nb-card> + <nb-card-header> + <h2 class="sa-card-title"> + <nb-icon icon="hard-drive-outline"></nb-icon> + שימוש באחסון + </h2> + </nb-card-header> + <nb-card-body> + @if (loading) { + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את נתוני האחסון…</p> + </div> + } @else if (loadFailed || !config) { + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">נתוני האחסון לא נטענו</p> + <p class="sa-state__text">נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="load()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + } @else { + <div class="usage"> + <div class="usage__numbers"> + <span class="usage__used sa-ltr">{{ formatBytes(config.storageInfo.usedBytes) }}</span> + <span class="usage__of">מתוך <span class="sa-ltr">{{ formatBytes(config.storageInfo.quotaBytes) }}</span></span> + </div> + <nb-progress-bar [value]="barValue" [status]="progressStatus(config.storageInfo.level)" [displayValue]="true" + aria-label="אחוז האחסון בשימוש"></nb-progress-bar> + <p class="sa-field__hint"> + @if (config.storageInfo.level === 'critical') { + האחסון כמעט מלא — העלאות חדשות ייכשלו בקרוב. הגדילו את הנפח או בקשו ממנהלי הערוץ למחוק קבצים. + } @else if (config.storageInfo.level === 'warning') { + האחסון מתקרב למגבלה. + } @else { + יש מספיק מקום. + } + @if (config.storageInfo.autoCleanup) { + ניקוי אוטומטי פעיל: כשהמקום נגמר, הקבצים הישנים ביותר נמחקים. + } + </p> + </div> + + <form class="quota" (ngSubmit)="save()" novalidate> + <div class="sa-field"> + <label class="sa-field__label" for="channelQuota">נפח מותר לערוץ (GB)</label> + <nb-form-field class="quota__field"> + <nb-icon nbPrefix icon="hard-drive-outline"></nb-icon> + <input nbInput fullWidth id="channelQuota" name="channelQuota" type="number" min="0" step="0.5" + inputmode="decimal" dir="ltr" [(ngModel)]="quotaGb"> + </nb-form-field> + @if (quotaInvalid) { + <p class="sa-field__error">יש להזין מספר 0 ומעלה.</p> + } @else if (usesDefault) { + <p class="sa-field__hint">0 = ברירת המחדל של המערכת (כרגע <span class="sa-ltr">{{ formatBytes(config.storageInfo.quotaBytes) }}</span>). שינוי ברירת המחדל במסך "אחסון" יחול גם על הערוץ הזה.</p> + } @else { + <p class="sa-field__hint">נפח קבוע לערוץ הזה בלבד. 0 מחזיר לברירת המחדל של המערכת.</p> + } + </div> + <div class="sa-save-bar"> + <button nbButton status="primary" type="submit" [disabled]="saving || quotaInvalid"> + <nb-icon icon="save-outline"></nb-icon> + {{ saving ? 'שומר…' : 'שמירת הנפח' }} + </button> + <span class="sa-save-bar__note">השינוי חל מיד על ההעלאות הבאות.</span> + </div> + </form> + } + </nb-card-body> +</nb-card> diff --git a/frontend/src/app/components/super-admin/storage/super-admin-storage.component.scss b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.scss new file mode 100644 index 00000000..0360acbf --- /dev/null +++ b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.scss @@ -0,0 +1,32 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.usage { + padding-bottom: 1.25rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--border-basic-color-3); +} + +.usage__numbers { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.usage__used { + font-size: 1.4rem; + font-weight: 800; + color: var(--text-basic-color); +} + +.usage__of { + color: var(--text-hint-color); +} + +.quota__field { + max-width: 16rem; +} diff --git a/frontend/src/app/components/super-admin/storage/super-admin-storage.component.ts b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.ts new file mode 100644 index 00000000..8992e3ee --- /dev/null +++ b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.ts @@ -0,0 +1,114 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { formatBytes, storageLevelStatus } from '../../../utils/storage-format'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbCardModule, NbButtonModule, NbInputModule, + NbFormFieldModule, NbProgressBarModule, NbToastrService, NbIconModule +} from '@nebular/theme'; +import { SuperAdminService } from '../../../services/super-admin.service'; + +interface ChannelStorageConfig { + quotaGb: number; + storageInfo: { + usedBytes: number; + quotaBytes: number; + usedPercent: number; + autoCleanup: boolean; + level: string; + }; +} + +@Component({ + selector: 'app-super-admin-storage', + standalone: true, + imports: [ + CommonModule, FormsModule, + NbCardModule, NbButtonModule, NbInputModule, + NbFormFieldModule, NbProgressBarModule, NbIconModule + ], + templateUrl: './super-admin-storage.component.html', + styleUrl: './super-admin-storage.component.scss', +}) +export class SuperAdminStorageComponent implements OnChanges { + @Input() slug!: string; + + config?: ChannelStorageConfig; + loading = true; + loadFailed = false; + saving = false; + // Separate from config.quotaGb so a cleared field (ngModel posts null) + // can be refused before anything is sent. + quotaGb: number | null = 0; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService + ) {} + + // ngOnChanges fires for the initial slug binding too, so no ngOnInit needed. + ngOnChanges() { this.load(); } + + /** 0 means "use the system default"; anything negative or empty is refused. */ + get quotaInvalid(): boolean { + const gb = Number(this.quotaGb); + return this.quotaGb === null || this.quotaGb === undefined || !Number.isFinite(gb) || gb < 0; + } + + get usesDefault(): boolean { + return Number(this.quotaGb) === 0; + } + + /** Percent capped for the bar; the label still shows the true number. */ + get barValue(): number { + const pct = Number(this.config?.storageInfo?.usedPercent) || 0; + return Math.max(0, Math.min(100, Math.round(pct))); + } + + async load() { + if (!this.slug) { + // No channel to show: better an honest error card than a spinner forever. + this.loading = false; + this.loadFailed = true; + return; + } + this.loading = true; + this.loadFailed = false; + try { + this.config = await this.superAdminService.getChannelStorage(this.slug); + this.quotaGb = Number(this.config?.quotaGb) || 0; + } catch (err: any) { + this.loadFailed = true; + this.toastr.danger('', err?.status === 404 + ? 'הערוץ לא נמצא — ייתכן שנמחק' + : 'נתוני האחסון של הערוץ לא נטענו'); + } finally { + this.loading = false; + } + } + + async save() { + if (!this.config || this.quotaInvalid) return; + this.saving = true; + try { + await this.superAdminService.setChannelStorage(this.slug, Number(this.quotaGb)); + this.toastr.success('', this.usesDefault + ? 'הערוץ חזר לנפח ברירת המחדל של המערכת' + : `הנפח של הערוץ עודכן ל-${Number(this.quotaGb)} GB`); + this.load(); + } catch (err: any) { + if (err?.status === 400) { + this.toastr.danger('', 'הנפח חייב להיות מספר 0 ומעלה'); + } else if (err?.status === 404) { + this.toastr.danger('', 'הערוץ לא נמצא — ייתכן שנמחק'); + } else { + this.toastr.danger('', 'השמירה לא הצליחה, נסו שוב'); + } + } finally { + this.saving = false; + } + } + + progressStatus = storageLevelStatus; + formatBytes = formatBytes; +} diff --git a/frontend/src/app/components/super-admin/super-admin-panel.component.html b/frontend/src/app/components/super-admin/super-admin-panel.component.html new file mode 100644 index 00000000..895d735f --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.html @@ -0,0 +1,117 @@ +<nb-layout> + <nb-layout-header fixed> + <div class="sa-header"> + <div class="sa-header__brand"> + <span class="sa-header__logo" aria-hidden="true"> + <nb-icon icon="shield-outline"></nb-icon> + </span> + <div class="sa-header__text"> + <span class="sa-header__title">ניהול מערכת</span> + <span class="sa-header__sub">מסך המפעיל של כל הערוצים</span> + </div> + </div> + + <div class="sa-header__actions"> + <a nbButton ghost status="basic" size="small" routerLink="/" + nbTooltip="לדף הבית של האתר" aria-label="לדף הבית של האתר" class="sa-header__btn"> + <nb-icon icon="home-outline"></nb-icon> + <span class="sa-header__btn-text">לאתר</span> + </a> + <button nbButton ghost status="basic" size="small" type="button" (click)="logout()" + [disabled]="loggingOut" nbTooltip="התנתקות מהחשבון" aria-label="התנתקות מהחשבון" class="sa-header__btn"> + <nb-icon icon="log-out-outline"></nb-icon> + <span class="sa-header__btn-text">התנתקות</span> + </button> + </div> + </div> + </nb-layout-header> + + <nb-layout-column> + <div class="sa-panel"> + <nav class="sa-nav" aria-label="מדורי הניהול"> + <ul class="sa-nav__list"> + @for (s of sections; track s.id) { + <li> + <button type="button" class="sa-nav__item" + [class.sa-nav__item--active]="activeSection === s.id" + [attr.aria-current]="activeSection === s.id ? 'page' : null" + (click)="select(s.id)"> + <nb-icon [icon]="s.icon" class="sa-nav__icon"></nb-icon> + <span>{{ s.title }}</span> + @if (s.id === VIEW_SUPPORT && openTickets > 0) { + <span class="sa-nav__badge" role="status" + [attr.aria-label]="openTickets === 1 ? 'פנייה אחת ממתינה לתשובה' : openTickets + ' פניות ממתינות לתשובה'"> + {{ openTickets > 99 ? '99+' : openTickets }} + </span> + } + </button> + </li> + } + </ul> + </nav> + + <main class="sa-content"> + <header class="sa-intro"> + @if (isChannelView) { + <button nbButton ghost status="basic" size="small" type="button" + class="sa-intro__back" (click)="backToChannels()"> + <nb-icon icon="arrow-forward-outline"></nb-icon> + חזרה לרשימת הערוצים + </button> + } + <h1 class="sa-intro__title"> + <nb-icon [icon]="current.icon" class="sa-intro__icon" aria-hidden="true"></nb-icon> + {{ current.title }} + @if (selectedChannel) { + <span class="sa-intro__channel"> + — {{ selectedChannel.name || selectedChannel.slug }} + <code class="sa-ltr sa-intro__slug">{{ selectedChannel.slug }}</code> + </span> + } + </h1> + <p class="sa-intro__description">{{ current.description }}</p> + </header> + + @switch (selectedView) { + @case (VIEW_SUPPORT) { + <app-support-inbox></app-support-inbox> + } + @case (VIEW_CHANNELS) { + <app-channels-list + (editFeatures)="onEditFeatures($event)" + (manageUsers)="onManageUsers($event)" + (manageStorage)="onManageStorage($event)"> + </app-channels-list> + } + @case (VIEW_CHANNEL_FEATURES) { + <app-channel-features [slug]="selectedChannel?.slug || ''"></app-channel-features> + } + @case (VIEW_CHANNEL_USERS) { + <app-channel-users [slug]="selectedChannel?.slug || ''"></app-channel-users> + } + @case (VIEW_CHANNEL_STORAGE) { + <app-super-admin-storage [slug]="selectedChannel?.slug || ''"></app-super-admin-storage> + } + @case (VIEW_GLOBAL_STORAGE) { + <app-global-storage></app-global-storage> + } + @case (VIEW_ADS) { + <app-global-ads></app-global-ads> + } + @case (VIEW_MAGNET) { + <app-global-magnet></app-global-magnet> + } + @case (VIEW_USERS) { + <app-global-users></app-global-users> + } + @case (VIEW_SETTINGS) { + <app-global-settings></app-global-settings> + } + @case (VIEW_STATISTICS) { + <app-super-admin-statistics (openMagnet)="select(VIEW_MAGNET)"></app-super-admin-statistics> + } + } + </main> + </div> + </nb-layout-column> +</nb-layout> diff --git a/frontend/src/app/components/super-admin/super-admin-panel.component.scss b/frontend/src/app/components/super-admin/super-admin-panel.component.scss new file mode 100644 index 00000000..7a29d1db --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.scss @@ -0,0 +1,405 @@ +// Encapsulation is off (see the component): every selector is prefixed `sa-` +// and only ever matches inside this lazy-loaded panel. Tokens come from the +// theme as custom properties, so nothing here hardcodes a colour. + +// ---------- header ---------- +.sa-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-width: 0; +} + +.sa-header__brand { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.sa-header__logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + flex: none; + border-radius: 0.75rem; + color: var(--color-basic-100); + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-700)); + font-size: 1.3rem; +} + +.sa-header__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sa-header__title { + font-weight: 800; + font-size: 1.1rem; + color: var(--text-basic-color); + white-space: nowrap; +} + +.sa-header__sub { + font-size: 0.78rem; + color: var(--text-hint-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sa-header__actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex: none; +} + +.sa-header__btn { + min-height: 2.5rem; +} + +// ---------- layout ---------- +.sa-panel { + display: grid; + grid-template-columns: 14.5rem minmax(0, 1fr); + gap: 1.5rem; + width: 100%; + max-width: 1180px; + margin: 0 auto; +} + +.sa-nav { + position: sticky; + top: calc(var(--header-height) + 1rem); + align-self: start; + padding: 0.5rem; + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); + border-radius: var(--card-border-radius); + box-shadow: var(--card-shadow); +} + +.sa-nav__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.sa-nav__item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + min-height: 2.75rem; + padding: 0.5rem 0.75rem; + border: 0; + border-radius: var(--border-radius); + background: transparent; + color: var(--text-basic-color); + font: inherit; + font-weight: 600; + text-align: start; + cursor: pointer; + transition: background-color var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); + + &:hover { background: var(--color-basic-transparent-100); } + &:focus-visible { outline: 2px solid var(--color-primary-500); outline-offset: 2px; } +} + +.sa-nav__item--active { + background: var(--color-primary-transparent-100); + color: var(--color-primary-700); +} + +/* Tickets waiting for an answer: a red count at the end of "פניות למערכת". */ +.sa-nav__badge { + margin-inline-start: auto; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.35rem; + border-radius: 999px; + background: var(--color-danger-500); + color: var(--color-basic-100); + font-size: 0.75rem; + font-weight: 700; + line-height: 1.25rem; + text-align: center; + box-shadow: 0 0 0 2px var(--background-basic-color-1); + animation: sa-badge-pop 0.25s ease-out; +} + +@keyframes sa-badge-pop { + from { transform: scale(0.4); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .sa-nav__badge { animation: none; } +} + +.sa-nav__icon { + font-size: 1.25rem; + width: 1.25rem; + height: 1.25rem; + flex: none; +} + +.sa-content { + min-width: 0; + animation: sa-rise var(--motion-slow) var(--ease-out); +} + +@keyframes sa-rise { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +// ---------- section intro ---------- +.sa-intro { margin-bottom: 1rem; } + +.sa-intro__back { margin-bottom: 0.5rem; margin-inline-start: -0.5rem; } + +.sa-intro__title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 0.35rem; + font-size: 1.5rem; + font-weight: 800; + color: var(--text-basic-color); +} + +.sa-intro__icon { color: var(--color-primary-500); font-size: 1.5rem; } + +.sa-intro__channel { font-weight: 600; font-size: 1.1rem; color: var(--text-hint-color); } + +.sa-intro__slug { + font-size: 0.85rem; + padding: 0.1rem 0.4rem; + border-radius: 0.375rem; + background: var(--color-basic-300); + color: var(--text-basic-color); +} + +.sa-intro__description { + margin: 0; + max-width: 62ch; + color: var(--text-hint-color); + line-height: 1.6; +} + +// ---------- shared vocabulary for the sections ---------- +.sa-ltr { direction: ltr; unicode-bidi: isolate; display: inline-block; } + +.sa-card-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--text-basic-color); +} + +.sa-card-title nb-icon { color: var(--color-primary-500); } + +.sa-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.sa-lead { + margin: 0 0 1rem; + color: var(--text-hint-color); + line-height: 1.6; +} + +.sa-field { margin-bottom: 1rem; } +.sa-field:last-child { margin-bottom: 0; } + +.sa-field__label { + display: block; + margin-bottom: 0.35rem; + font-weight: 600; + color: var(--text-basic-color); +} + +.sa-field__hint { + margin: 0.35rem 0 0; + font-size: 0.8rem; + line-height: 1.5; + color: var(--text-hint-color); +} + +.sa-field__error { + margin: 0.35rem 0 0; + font-size: 0.8rem; + color: var(--color-danger-600); +} + +.sa-grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 1rem; +} + +.sa-toggle { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-basic-color-3); +} +.sa-toggle:last-child { border-bottom: 0; } +.sa-toggle nb-toggle { min-height: 2.5rem; } +.sa-toggle__hint { margin: 0; font-size: 0.8rem; line-height: 1.5; color: var(--text-hint-color); } + +.sa-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + line-height: 1.4; + white-space: nowrap; + background: var(--color-basic-300); + color: var(--color-basic-800); +} +.sa-chip nb-icon { font-size: 0.9rem; width: 0.9rem; height: 0.9rem; } +.sa-chip--success { background: var(--color-success-100); color: var(--color-success-700); } +.sa-chip--danger { background: var(--color-danger-100); color: var(--color-danger-700); } +.sa-chip--warning { background: var(--color-warning-100); color: var(--color-warning-800); } +.sa-chip--info { background: var(--color-info-100); color: var(--color-info-700); } +.sa-chip--primary { background: var(--color-primary-100); color: var(--color-primary-700); } + +.sa-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.sa-toolbar__search { flex: 1 1 14rem; min-width: 0; } +.sa-toolbar__search nb-form-field { width: 100%; } + +.sa-filters { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} +.sa-filters [nbButton] { min-height: 2.5rem; } + +.sa-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2.5rem 1rem; + text-align: center; + color: var(--text-hint-color); +} +.sa-state__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + font-size: 1.6rem; + background: var(--color-primary-100); + color: var(--color-primary-600); +} +.sa-state__icon--danger { background: var(--color-danger-100); color: var(--color-danger-600); } +.sa-spinner { + display: inline-block; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + border: 3px solid var(--color-primary-200); + border-top-color: var(--color-primary-600); + animation: sa-spin 0.8s linear infinite; +} +@keyframes sa-spin { to { transform: rotate(360deg); } } +.sa-state__title { margin: 0; font-weight: 700; color: var(--text-basic-color); } +.sa-state__text { margin: 0; max-width: 46ch; line-height: 1.6; } +.sa-state [nbButton] { margin-top: 0.5rem; } + +.sa-save-bar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.sa-save-bar__note { font-size: 0.85rem; color: var(--text-hint-color); } +.sa-save-bar__note--dirty { color: var(--color-warning-800); font-weight: 600; } + +.sa-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } +.sa-table { width: 100%; margin: 0; } +.sa-table th { font-size: 0.8rem; font-weight: 700; color: var(--text-hint-color); white-space: nowrap; } +.sa-table td { vertical-align: middle; } + +.sa-actions { display: flex; gap: 0.375rem; flex-wrap: wrap; } +.sa-actions [nbButton] { min-height: 2.5rem; min-width: 2.5rem; } + +// Every small button in a section is still a thumb-sized target. +.sa-content [nbButton].size-small { min-height: 2.5rem; } + +// ---------- phones ---------- +@media (max-width: 767px) { + .sa-header__sub, + .sa-header__btn-text { display: none; } + .sa-header__btn { min-width: 2.5rem; } + + .sa-panel { grid-template-columns: 1fr; gap: 0.75rem; min-width: 0; } + + // The menu becomes a strip of chips that stays under the fixed header. + // It keeps the layout's own side padding: a negative margin here was + // one screen width away from a horizontal scrollbar. + .sa-nav { + position: sticky; + top: calc(var(--header-height) + 0.5rem); + z-index: 2; + padding: 0.5rem; + // A grid item is as wide as its content unless told otherwise, so the + // nowrap chip strip widened the whole page to 1300px on a phone instead + // of scrolling inside its box. + min-width: 0; + max-width: 100%; + } + .sa-nav__list { + flex-direction: row; + gap: 0.375rem; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.125rem; + } + .sa-nav__list::-webkit-scrollbar { display: none; } + .sa-nav__item { + width: auto; + min-height: 2.5rem; + padding: 0.4rem 0.75rem; + white-space: nowrap; + border: 1px solid var(--border-basic-color-3); + border-radius: 999px; + font-size: 0.875rem; + } + .sa-nav__item--active { border-color: var(--color-primary-300); } + + .sa-intro__title { font-size: 1.3rem; } + .sa-grid-2 { grid-template-columns: 1fr; } +} diff --git a/frontend/src/app/components/super-admin/super-admin-panel.component.ts b/frontend/src/app/components/super-admin/super-admin-panel.component.ts new file mode 100644 index 00000000..1a2d3ed6 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.ts @@ -0,0 +1,259 @@ +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { + NbButtonModule, + NbIconModule, + NbLayoutModule, + NbToastrService, + NbTooltipModule, +} from '@nebular/theme'; +import { AuthService } from '../../services/auth.service'; +import { SupportService } from '../../services/support.service'; +import { ChannelData } from '../../services/super-admin.service'; +import { ChannelsListComponent } from './channels/channels-list.component'; +import { ChannelFeaturesComponent } from './channels/channel-features.component'; +import { ChannelUsersComponent } from './channels/channel-users.component'; +import { GlobalAdsComponent } from './global-ads/global-ads.component'; +import { GlobalMagnetComponent } from './global-magnet/global-magnet.component'; +import { GlobalUsersComponent } from './global-users/global-users.component'; +import { GlobalSettingsComponent } from './global-settings/global-settings.component'; +import { SuperAdminStatisticsComponent } from './statistics/super-admin-statistics.component'; +import { SuperAdminStorageComponent } from './storage/super-admin-storage.component'; +import { GlobalStorageComponent } from './global-storage/global-storage.component'; +import { SupportInboxComponent } from './support/support-inbox.component'; + +type ViewName = 'channels' | 'channel-features' | 'channel-users' | 'channel-storage' | 'ads' | 'magnet' | 'users' | 'settings' | 'statistics' | 'global-storage' | 'support'; + +interface SectionInfo { + id: ViewName; + title: string; + icon: string; + /** One plain sentence: what the screen is for and what happens there. */ + description: string; +} + +/** The channel a sub-screen (features / users / storage) is opened for. */ +interface SelectedChannel { + slug: string; + name: string; +} + +/** + * The platform operator's panel: one shell, a section menu, and one section + * at a time. The menu is a plain list of buttons rather than nb-menu so the + * same items can lay out as a sidebar on a desktop and as a scrollable strip + * on a phone without reaching into Nebular's markup. + * + * Encapsulation is off on purpose: the `.sa-*` classes below (fields, hints, + * chips, empty states, save bars) are the shared vocabulary of every section + * component, and the panel is the only place they ever render. One copy here + * beats twelve copies that drift. Nothing outside this lazy chunk uses them. + */ +@Component({ + selector: 'app-super-admin-panel', + standalone: true, + imports: [ + CommonModule, + RouterLink, + NbLayoutModule, + NbButtonModule, + NbIconModule, + NbTooltipModule, + ChannelsListComponent, + ChannelFeaturesComponent, + ChannelUsersComponent, + GlobalAdsComponent, + GlobalMagnetComponent, + GlobalUsersComponent, + GlobalSettingsComponent, + SuperAdminStatisticsComponent, + SuperAdminStorageComponent, + GlobalStorageComponent, + SupportInboxComponent, + ], + templateUrl: './super-admin-panel.component.html', + styleUrl: './super-admin-panel.component.scss', + encapsulation: ViewEncapsulation.None, +}) +export class SuperAdminPanelComponent implements OnInit, OnDestroy { + /** + * Tickets waiting for an answer ("open"): drives the red dot on + * "פניות למערכת" so the operator knows to look. Polled, because a ticket + * arrives from a visitor or by mail while the panel sits open. + */ + openTickets = 0; + private ticketsTimer: ReturnType<typeof setInterval> | null = null; + private readonly onVisible = () => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') this.refreshOpenTickets(); + }; + + selectedView: ViewName = 'channels'; + selectedChannel: SelectedChannel | null = null; + loggingOut = false; + + readonly VIEW_CHANNELS: ViewName = 'channels'; + readonly VIEW_CHANNEL_FEATURES: ViewName = 'channel-features'; + readonly VIEW_CHANNEL_USERS: ViewName = 'channel-users'; + readonly VIEW_CHANNEL_STORAGE: ViewName = 'channel-storage'; + readonly VIEW_ADS: ViewName = 'ads'; + readonly VIEW_MAGNET: ViewName = 'magnet'; + readonly VIEW_USERS: ViewName = 'users'; + readonly VIEW_SETTINGS: ViewName = 'settings'; + readonly VIEW_STATISTICS: ViewName = 'statistics'; + readonly VIEW_GLOBAL_STORAGE: ViewName = 'global-storage'; + readonly VIEW_SUPPORT: ViewName = 'support'; + + /** The menu, in the order it is shown. */ + readonly sections: SectionInfo[] = [ + { + id: 'channels', title: 'ערוצים', icon: 'list-outline', + description: 'כל הערוצים במערכת. מכאן פותחים ערוץ, משנים את התכונות שלו, מנהלים משתמשים ואחסון, משביתים או מוחקים.', + }, + { + id: 'support', title: 'פניות למערכת', icon: 'email-outline', + description: 'כל הפניות שנשלחו להנהלת המערכת — ממנהלי ערוצים ומגולשים באתר. התשובה מופיעה לפונה במקום שממנו פנה.', + }, + { + id: 'users', title: 'משתמשים', icon: 'people-outline', + description: 'כל מי שנכנס למערכת או קיבל הרשאה, והתפקיד שלו בכל ערוץ. תפקידים משנים במסך המשתמשים של הערוץ.', + }, + { + id: 'ads', title: 'פרסומת במסגרת', icon: 'film-outline', + description: 'פרסומת אחת של המערכת, במסגרת (iframe) בתוך דף הערוץ, שמוצגת בערוצים הנעולים אליה. ערוץ שאינו נעול מציג את הפרסומת שמנהליו הגדירו.', + }, + { + id: 'magnet', title: 'פרסומות מגנט', icon: 'pricetags-outline', + description: 'הגדרות המגנט של המערכת. הן חלות רק על ערוצים שנעולים אליהן — שאר הערוצים משתמשים בהגדרות שלהם.', + }, + { + id: 'global-storage', title: 'אחסון', icon: 'hard-drive-outline', + description: 'נפח האחסון שכל ערוץ מקבל כברירת מחדל לקבצים ולתמונות. לערוץ מסוים קובעים נפח אחר מרשימת הערוצים.', + }, + { + id: 'settings', title: 'הגדרות מערכת', icon: 'settings-2-outline', + description: 'כותרת האתר, קוד אנליטיקס וחיבור ההתראות לטלפון — הגדרות משותפות לכל הערוצים.', + }, + { + id: 'statistics', title: 'סטטיסטיקות', icon: 'bar-chart-outline', + description: 'נתוני הפרסומות ממגנט, ואיפוס מוני החיבורים של כל הערוצים.', + }, + ]; + + /** Sub-screens of a single channel, reached from the channels list. */ + private readonly channelViews: Record<string, Omit<SectionInfo, 'id'>> = { + 'channel-features': { + title: 'הגדרות הערוץ', icon: 'settings-2-outline', + description: 'מה מופעל בערוץ, מי יכול לקרוא בו, והשבתה זמנית. השינויים נכנסים לתוקף מיד לאחר השמירה.', + }, + 'channel-users': { + title: 'משתמשי הערוץ', icon: 'people-outline', + description: 'מי מנהל את הערוץ ומי כותב בו. בעלים שולט בכול, מנהל מנהל וכותב, וכותב רק מפרסם הודעות.', + }, + 'channel-storage': { + title: 'אחסון הערוץ', icon: 'hard-drive-outline', + description: 'כמה מקום הקבצים של הערוץ תופסים, וכמה מותר לו. 0 משאיר את ברירת המחדל של המערכת.', + }, + }; + + constructor( + private authService: AuthService, + private router: Router, + private toastr: NbToastrService, + private titleService: Title, + private support: SupportService, + route: ActivatedRoute, + ) { + this.titleService.setTitle('ניהול מערכת · הערוץ'); + // ?view=<section> opens a top-level section directly: a locked channel's + // magnet tab sends the super admin to the platform's statistics this way. + const view = route.snapshot.queryParamMap.get('view'); + const section = this.sections.find(s => s.id === view); + if (section) this.selectedView = section.id; + } + + ngOnInit() { + this.refreshOpenTickets(); + this.ticketsTimer = setInterval(() => this.refreshOpenTickets(), 60_000); + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', this.onVisible); + } + + ngOnDestroy() { + if (this.ticketsTimer) clearInterval(this.ticketsTimer); + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', this.onVisible); + } + + /** A failed fetch keeps the last count: the dot is a hint, not a report. */ + async refreshOpenTickets() { + try { + const tickets = await this.support.adminList(); + this.openTickets = (tickets || []).filter(t => t.status === 'open').length; + } catch { + // keep the previous value + } + } + + get isChannelView(): boolean { + return this.selectedView === this.VIEW_CHANNEL_FEATURES + || this.selectedView === this.VIEW_CHANNEL_USERS + || this.selectedView === this.VIEW_CHANNEL_STORAGE; + } + + /** The intro (title + explanation) of whatever is on screen. */ + get current(): Omit<SectionInfo, 'id'> { + if (this.isChannelView) return this.channelViews[this.selectedView]; + return this.sections.find(s => s.id === this.selectedView) || this.sections[0]; + } + + /** The menu item to highlight — channel sub-screens belong to "ערוצים". */ + get activeSection(): ViewName { + return this.isChannelView ? this.VIEW_CHANNELS : this.selectedView; + } + + select(view: ViewName) { + this.selectedView = view; + // Leaving or entering the inbox is when an answer or a status change has + // just happened: recount instead of waiting for the next poll. + this.refreshOpenTickets(); + this.selectedChannel = null; + // A phone shows the menu strip above the content: bring the new + // section's title into view instead of leaving the reader mid-page. + if (typeof window !== 'undefined') window.scrollTo({ top: 0 }); + } + + async logout() { + this.loggingOut = true; + try { + if (await this.authService.logout()) { + this.router.navigate(['/login']); + } else { + this.toastr.danger('', 'ההתנתקות לא הצליחה, נסו שוב'); + } + } finally { + this.loggingOut = false; + } + } + + onEditFeatures(channel: ChannelData) { + this.openChannelView(channel, this.VIEW_CHANNEL_FEATURES); + } + + onManageUsers(channel: ChannelData) { + this.openChannelView(channel, this.VIEW_CHANNEL_USERS); + } + + onManageStorage(channel: ChannelData) { + this.openChannelView(channel, this.VIEW_CHANNEL_STORAGE); + } + + backToChannels() { + this.select(this.VIEW_CHANNELS); + } + + private openChannelView(channel: ChannelData, view: ViewName) { + this.selectedChannel = { slug: channel.slug, name: channel.name }; + this.selectedView = view; + if (typeof window !== 'undefined') window.scrollTo({ top: 0 }); + } +} diff --git a/frontend/src/app/components/super-admin/support/support-inbox.component.html b/frontend/src/app/components/super-admin/support/support-inbox.component.html new file mode 100644 index 00000000..2bdfc95f --- /dev/null +++ b/frontend/src/app/components/super-admin/support/support-inbox.component.html @@ -0,0 +1,146 @@ +<nb-card> + <nb-card-header> + <div class="sa-card-head"> + <h2 class="sa-card-title"> + <nb-icon icon="email-outline"></nb-icon> + תיבת הפניות + @if (!loading && openCount) { + <span class="sa-chip sa-chip--warning">{{ openCount }} ממתינות</span> + } + </h2> + <button nbButton ghost status="basic" size="small" type="button" (click)="reload()" [disabled]="loading"> + <nb-icon icon="refresh-outline"></nb-icon> + רענון + </button> + </div> + </nb-card-header> + + <nb-card-body> + @if (loading) { + <div class="sa-state"> + <span class="sa-spinner" role="status" aria-label="טוען"></span> + <p class="sa-state__text">טוען את הפניות…</p> + </div> + } @else if (loadFailed) { + <div class="sa-state"> + <span class="sa-state__icon sa-state__icon--danger"><nb-icon icon="alert-triangle-outline"></nb-icon></span> + <p class="sa-state__title">הפניות לא נטענו</p> + <p class="sa-state__text">נסו לטעון שוב.</p> + <button nbButton status="primary" size="small" type="button" (click)="reload()"> + <nb-icon icon="refresh-outline"></nb-icon> + טעינה מחדש + </button> + </div> + } @else { + <div class="sa-toolbar"> + <div class="sa-filters" role="group" aria-label="סינון לפי מצב הפנייה"> + @for (f of filters; track f.value) { + <button nbButton size="small" type="button" + [status]="filter === f.value ? 'primary' : 'basic'" + [appearance]="filter === f.value ? 'filled' : 'outline'" + [attr.aria-pressed]="filter === f.value" + (click)="filter = f.value">{{ f.label }} ({{ count(f.value) }})</button> + } + </div> + </div> + + @if (visible().length === 0) { + <div class="sa-state"> + <span class="sa-state__icon"><nb-icon icon="inbox-outline"></nb-icon></span> + <p class="sa-state__title">אין פניות להצגה</p> + <p class="sa-state__text">{{ emptyText }}</p> + </div> + } @else { + <ul class="tickets"> + @for (t of visible(); track t.id) { + <li class="ticket" [class.ticket--open]="openId === t.id"> + <button type="button" class="ticket__head" (click)="toggle(t.id)" + [attr.aria-expanded]="openId === t.id" [attr.aria-controls]="'thread-' + t.id"> + <span class="ticket__main"> + <span class="ticket__subject">{{ t.subject }}</span> + <span class="ticket__from"> + {{ t.name }} · <span class="sa-ltr">{{ t.email }}</span> + </span> + <span class="ticket__tags"> + @if (t.source === 'email') { + <span class="sa-chip sa-chip--info">התקבל במייל</span> + } @else if (!t.authenticated) { + <span class="sa-chip sa-chip--info">נשלחה ללא כניסה</span> + } + @if (t.channelSlug) { <span class="sa-chip">ערוץ: <span class="sa-ltr">{{ t.channelSlug }}</span></span> } + <span class="sa-chip" + [class.sa-chip--warning]="t.status === 'open'" + [class.sa-chip--success]="t.status === 'answered'">{{ statusText(t.status) }}</span> + </span> + </span> + <span class="ticket__side"> + <span class="ticket__date">{{ t.updatedAt | date:'dd/MM/yy HH:mm' }}</span> + <nb-icon [icon]="openId === t.id ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true"></nb-icon> + </span> + </button> + + @if (openId === t.id) { + <div class="thread" [id]="'thread-' + t.id"> + <ol class="thread__list"> + @for (m of t.messages; track $index) { + <li class="msg" [class.msg--admin]="m.author === 'admin'"> + <div class="msg__meta"> + <strong>{{ m.author === 'admin' ? 'ניהול' : m.authorName }}</strong> + · {{ m.createdAt | date:'dd/MM/yyyy HH:mm' }} + @if (m.source === 'email') { <span class="sa-chip sa-chip--info msg__source">התקבל במייל</span> } + </div> + <!-- Plain text via interpolation, never markdown: a ticket + body must not be able to inject markup into this panel. --> + <div class="msg__body">{{ m.body }}</div> + </li> + } + </ol> + + <form class="reply" (ngSubmit)="reply(t)" novalidate> + <div class="sa-field"> + <label class="sa-field__label" [for]="'reply-' + t.id">תשובה לפונה</label> + <textarea nbInput fullWidth [id]="'reply-' + t.id" name="replyBody" rows="4" [(ngModel)]="replyBody" + placeholder="כתבו כאן את התשובה…" maxlength="5000"></textarea> + <p class="sa-field__hint"> + התשובה נחתמת כ"ניהול". + @if (t.source === 'email') { + הפנייה התקבלה במייל — התשובה תישלח לפונה במייל, והוא יכול להשיב עליה במייל. + } @else if (t.authenticated) { + הפונה יראה אותה במסך הפניות שלו בכניסה הבאה. + } @else { + הפנייה נשלחה בלי כניסה — התשובה תופיע לפונה בדפדפן שממנו שלח אותה. + } + @if (t.status === 'closed') { + שליחת תשובה פותחת את הפנייה מחדש. + } + </p> + </div> + <div class="sa-save-bar"> + <button nbButton size="small" status="primary" type="submit" [disabled]="busy || !replyBody.trim()"> + <nb-icon icon="paper-plane-outline"></nb-icon> + שליחת התשובה + </button> + @if (t.status !== 'closed') { + <button nbButton size="small" ghost status="basic" type="button" + [disabled]="busy" (click)="setStatus(t, 'closed')"> + <nb-icon icon="checkmark-circle-2-outline"></nb-icon> + סגירת הפנייה + </button> + } @else { + <button nbButton size="small" ghost status="basic" type="button" + [disabled]="busy" (click)="setStatus(t, 'open')"> + <nb-icon icon="undo-outline"></nb-icon> + פתיחה מחדש + </button> + } + </div> + </form> + </div> + } + </li> + } + </ul> + } + } + </nb-card-body> +</nb-card> diff --git a/frontend/src/app/components/super-admin/support/support-inbox.component.scss b/frontend/src/app/components/super-admin/support/support-inbox.component.scss new file mode 100644 index 00000000..dbf07407 --- /dev/null +++ b/frontend/src/app/components/super-admin/support/support-inbox.component.scss @@ -0,0 +1,134 @@ +// Shared classes (.sa-*) come from the panel stylesheet. +:host { + display: block; +} + +.tickets { + list-style: none; + margin: 0; + padding: 0; +} + +.ticket { + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-child { + border-bottom: 0; + } +} + +// Highlighted in flow (no negative margins): nb-card-body scrolls its +// overflow, and anything pulled past its padding earned a horizontal bar. +.ticket--open { + background: var(--background-basic-color-2); + padding: 0 0.75rem; + border-radius: var(--border-radius); + border-bottom-color: transparent; +} + +// The whole header is one button so it opens on tap and on Enter alike. +.ticket__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + width: 100%; + min-height: 3rem; + padding: 0.75rem 0; + border: 0; + background: transparent; + font: inherit; + color: inherit; + text-align: start; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + border-radius: var(--border-radius); + } +} + +.ticket__main { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.ticket__subject { + font-weight: 700; + color: var(--text-basic-color); + overflow-wrap: anywhere; +} + +.ticket__from { + font-size: 0.8rem; + color: var(--text-hint-color); + overflow-wrap: anywhere; +} + +.ticket__tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.125rem; +} + +.ticket__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + flex: none; + color: var(--text-hint-color); +} + +.ticket__date { + font-size: 0.75rem; + white-space: nowrap; +} + +.thread { + padding: 0 0 1rem; +} + +.thread__list { + list-style: none; + margin: 0 0 1rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.msg { + padding: 0.6rem 0.85rem; + border-radius: var(--border-radius); + background: var(--background-basic-color-1); + border: 1px solid var(--border-basic-color-3); +} + +.msg--admin { + background: var(--color-primary-transparent-100); + border-color: var(--color-primary-200); + border-inline-start: 3px solid var(--color-primary-500); +} + +.msg__meta { + font-size: 0.75rem; + color: var(--text-hint-color); + margin-bottom: 0.25rem; +} + +.msg__source { + margin-inline-start: 0.375rem; + vertical-align: middle; +} + +.msg__body { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; + color: var(--text-basic-color); +} diff --git a/frontend/src/app/components/super-admin/support/support-inbox.component.ts b/frontend/src/app/components/super-admin/support/support-inbox.component.ts new file mode 100644 index 00000000..ea33e847 --- /dev/null +++ b/frontend/src/app/components/super-admin/support/support-inbox.component.ts @@ -0,0 +1,153 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, NbCardModule, NbIconModule, + NbInputModule, NbToastrService, +} from '@nebular/theme'; +import { + SupportService, SupportStatus, SupportTicket, +} from '../../../services/support.service'; + +/** + * The operator's inbox: every ticket from every channel, plus the ones sent + * from the public landing page by people with no account at all. + * + * Ordered by last activity, so a thread the requester just added to comes back + * to the top rather than staying buried at its creation time. + */ +@Component({ + selector: 'app-support-inbox', + standalone: true, + imports: [ + CommonModule, FormsModule, NbCardModule, NbButtonModule, + NbInputModule, NbIconModule, + ], + templateUrl: './support-inbox.component.html', + styleUrl: './support-inbox.component.scss', +}) +export class SupportInboxComponent implements OnInit { + tickets: SupportTicket[] = []; + loading = true; + loadFailed = false; + busy = false; + openId = ''; + replyBody = ''; + + filter: 'all' | SupportStatus = 'open'; + readonly filters: { value: 'all' | SupportStatus; label: string }[] = [ + { value: 'open', label: 'ממתינות למענה' }, + { value: 'answered', label: 'נענו' }, + { value: 'closed', label: 'סגורות' }, + { value: 'all', label: 'הכל' }, + ]; + + constructor( + private support: SupportService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.reload(); + } + + async reload(): Promise<void> { + this.loading = true; + this.loadFailed = false; + try { + this.tickets = await this.support.adminList(); + } catch { + this.tickets = []; + this.loadFailed = true; + this.toastr.danger('', 'הפניות לא נטענו'); + } finally { + this.loading = false; + } + } + + visible(): SupportTicket[] { + return this.filter === 'all' + ? this.tickets + : this.tickets.filter(t => t.status === this.filter); + } + + count(status: 'all' | SupportStatus): number { + return status === 'all' ? this.tickets.length : this.tickets.filter(t => t.status === status).length; + } + + get openCount(): number { + return this.count('open'); + } + + /** The empty-state sentence for the active filter. */ + get emptyText(): string { + switch (this.filter) { + case 'open': return 'אין פניות שממתינות למענה. כל הכבוד.'; + case 'answered': return 'אין פניות שנענו ועדיין פתוחות.'; + case 'closed': return 'אין פניות סגורות.'; + default: return 'עדיין לא הגיעו פניות. הן נשלחות מהאתר ומהמסך "פנייה לתמיכה" של כל ערוץ.'; + } + } + + toggle(id: string): void { + this.openId = this.openId === id ? '' : id; + this.replyBody = ''; + } + + async reply(t: SupportTicket): Promise<void> { + const body = this.replyBody.trim(); + if (!body) { + this.toastr.warning('', 'כתבו תשובה לפני השליחה'); + return; + } + this.busy = true; + try { + this.replace(await this.support.adminReply(t.id, body)); + this.replyBody = ''; + this.toastr.success('', 'התשובה נשלחה לפונה'); + } catch (err: any) { + this.toastr.danger('', this.updateErrorText(err, 'שליחת התשובה לא הצליחה, נסו שוב')); + } finally { + this.busy = false; + } + } + + async setStatus(t: SupportTicket, status: SupportStatus): Promise<void> { + this.busy = true; + try { + this.replace(await this.support.adminSetStatus(t.id, status)); + this.toastr.success('', status === 'closed' ? 'הפנייה נסגרה' : 'הפנייה נפתחה מחדש'); + } catch (err: any) { + this.toastr.danger('', this.updateErrorText(err, 'עדכון הפנייה לא הצליח, נסו שוב')); + } finally { + this.busy = false; + } + } + + /** + * The server's plain-text refusals: 404 for a ticket that is gone, 409 + * "this thread has reached its message limit". Never shown as-is. + */ + private updateErrorText(err: any, fallback: string): string { + const text = typeof err?.error === 'string' ? err.error.toLowerCase() : ''; + if (err?.status === 404) return 'הפנייה כבר לא קיימת — רעננו את הרשימה'; + if (err?.status === 409 && text.includes('limit')) return 'השרשור הגיע למגבלת ההודעות שלו. סגרו אותו ובקשו מהפונה לפתוח פנייה חדשה.'; + if (err?.status === 400) return 'התשובה ריקה או ארוכה מדי (עד 5,000 תווים)'; + return fallback; + } + + statusText(s: string): string { + switch (s) { + case 'open': return 'ממתינה למענה'; + case 'answered': return 'נענתה'; + case 'closed': return 'סגורה'; + default: return s; + } + } + + /** Swap the server's updated copy in without reloading the whole inbox. */ + private replace(updated: SupportTicket): void { + const i = this.tickets.findIndex(x => x.id === updated.id); + if (i >= 0) this.tickets[i] = updated; + } +} diff --git a/frontend/src/app/components/support/support-box.component.html b/frontend/src/app/components/support/support-box.component.html new file mode 100644 index 00000000..d5d48c3e --- /dev/null +++ b/frontend/src/app/components/support/support-box.component.html @@ -0,0 +1,154 @@ +<nb-card class="support-box"> + <nb-card-header class="support-head"> + <div class="support-head__text"> + <h2 class="support-head__title"> + <nb-icon icon="email-outline"></nb-icon> + {{ title }} + </h2> + @if (subtitle) { <p class="support-sub">{{ subtitle }}</p> } + </div> + @if (dialogMode) { + <button nbButton ghost size="small" type="button" aria-label="סגירה" nbTooltip="סגירה" (click)="close()"> + <nb-icon icon="close-outline"></nb-icon> + </button> + } + </nb-card-header> + + <nb-card-body> + @if (ticketNotFound) { + <nb-alert status="info" class="support-alert" role="status"> + <strong>הפנייה שבקישור לא מוצגת כאן.</strong> + @if (signedIn) { + היא נשלחה מחשבון אחר או בלי התחברות, ולכן מוצגת רק בחשבון או בדפדפן שממנו נשלחה. + } @else { + פנייה שנשלחה בזמן התחברות מוצגת אחרי התחברות; פנייה שנשלחה בלי התחברות מוצגת רק בדפדפן שממנו נשלחה. + } + התשובה המלאה נמצאת גם במייל שקיבלתם, ואפשר להשיב ישירות אליו. + @if (!signedIn) { + <div class="support-alert__action"> + <button nbButton size="small" status="info" type="button" (click)="signInForTicket()"> + <nb-icon icon="log-in-outline"></nb-icon> + התחברות + </button> + </div> + } + </nb-alert> + } + + @if (sent) { + <nb-alert status="success" class="support-alert"> + <strong>הפנייה נשלחה.</strong> + @if (signedIn) { + נחזור אליכם בהקדם. התשובה תופיע כאן, במסך הזה. + } @else { + נחזור אליכם בהקדם. שמרו את הדף הזה — התשובה תופיע כאן, באותו דפדפן. + } + </nb-alert> + } + + @if (error) { <nb-alert status="danger" class="support-alert" role="alert" id="support-error">{{ error }}</nb-alert> } + + <form (ngSubmit)="submit()" #f="ngForm" class="support-form" novalidate> + @if (!signedIn) { + <div class="support-form__row"> + <div class="field"> + <label class="field__label" for="support-email">כתובת אימייל</label> + <input id="support-email" nbInput fullWidth type="email" name="email" dir="ltr" inputmode="email" + [(ngModel)]="email" placeholder="name@example.com" required maxlength="254" autocomplete="email" + [attr.aria-invalid]="error && !email.trim() ? true : null" [attr.aria-describedby]="error ? 'support-error' : null"> + <p class="field__hint">חובה — לשם נשלחת התשובה.</p> + </div> + <div class="field"> + <label class="field__label" for="support-name">שם</label> + <input id="support-name" nbInput fullWidth type="text" name="name" + [(ngModel)]="name" placeholder="איך לפנות אליכם" maxlength="100" autocomplete="name"> + <p class="field__hint">לא חובה.</p> + </div> + </div> + } + + <div class="field"> + <label class="field__label" for="support-subject">נושא הפנייה</label> + <input id="support-subject" nbInput fullWidth type="text" name="subject" [(ngModel)]="subject" + placeholder="במילים ספורות — על מה הפנייה" required [maxlength]="subjectMax" + [attr.aria-invalid]="error && !subject.trim() ? true : null" [attr.aria-describedby]="error ? 'support-error' : null"> + </div> + + <div class="field"> + <label class="field__label" for="support-body">תוכן הפנייה</label> + <textarea id="support-body" nbInput fullWidth name="body" [(ngModel)]="body" rows="5" + placeholder="מה קרה, מה ציפיתם שיקרה, ובאיזה ערוץ. ככל שיש יותר פרטים — נוכל לעזור מהר יותר." + required [maxlength]="bodyMax" + [attr.aria-invalid]="error && !body.trim() ? true : null" [attr.aria-describedby]="error ? 'support-error' : null"></textarea> + <span class="field__counter">{{ body.length }} / {{ bodyMax }}</span> + </div> + + <div class="support-form__actions"> + <button nbButton status="primary" type="submit" [disabled]="submitting" + [nbSpinner]="submitting" nbSpinnerStatus="control" nbSpinnerSize="small"> + <nb-icon icon="paper-plane-outline"></nb-icon> + {{ submitting ? 'שולחים…' : 'שליחת פנייה' }} + </button> + </div> + </form> + </nb-card-body> +</nb-card> + +@if (tickets.length || ticketsLoading) { + <nb-card class="support-box"> + <nb-card-header class="support-head"> + <h2 class="support-head__title"> + <nb-icon icon="inbox-outline"></nb-icon> + הפניות שלי + </h2> + </nb-card-header> + <nb-card-body> + @if (ticketsLoading && !tickets.length) { + <div class="support-loading" [nbSpinner]="true" nbSpinnerStatus="primary" nbSpinnerMessage="טוענים פניות…"></div> + } + @for (t of tickets; track t.id) { + <div class="ticket" [class.is-open]="openId === t.id"> + <button type="button" class="ticket__head" (click)="toggle(t.id)" + [attr.aria-expanded]="openId === t.id" [attr.aria-controls]="'ticket-' + t.id"> + <span class="ticket__subject">{{ t.subject }}</span> + <span class="ticket__status st-{{ t.status }}"> + <nb-icon [icon]="statusIcon(t.status)"></nb-icon> + {{ statusText(t.status) }} + </span> + <nb-icon class="ticket__chevron" [icon]="openId === t.id ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true"></nb-icon> + </button> + + @if (openId === t.id) { + <div class="thread" [id]="'ticket-' + t.id"> + @for (m of t.messages; track $index) { + <div class="msg" [class.msg--admin]="m.author === 'admin'"> + <div class="msg__meta"> + <strong>{{ m.author === 'admin' ? 'ניהול' : m.authorName }}</strong> + <span>· {{ m.createdAt | date:'dd/MM/yyyy HH:mm' }}</span> + </div> + <div class="msg__body">{{ m.body }}</div> + </div> + } + + @if (t.status !== 'closed') { + <div class="reply"> + <label class="field__label" [attr.for]="'reply-' + t.id">הוספת הודעה לפנייה</label> + <textarea [id]="'reply-' + t.id" nbInput fullWidth rows="3" [(ngModel)]="replyBody" + [ngModelOptions]="{standalone: true}" + placeholder="המשך השיחה באותה פנייה" [maxlength]="bodyMax"></textarea> + <button nbButton size="small" status="primary" type="button" class="reply__send" + [disabled]="replying || !replyBody.trim()" (click)="sendReply(t)"> + <nb-icon icon="paper-plane-outline"></nb-icon> + {{ replying ? 'שולחים…' : 'שליחה' }} + </button> + </div> + } @else { + <p class="thread__closed">הפנייה נסגרה. לשאלה חדשה — פתחו פנייה חדשה למעלה.</p> + } + </div> + } + </div> + } + </nb-card-body> + </nb-card> +} diff --git a/frontend/src/app/components/support/support-box.component.scss b/frontend/src/app/components/support/support-box.component.scss new file mode 100644 index 00000000..a150f20e --- /dev/null +++ b/frontend/src/app/components/support/support-box.component.scss @@ -0,0 +1,241 @@ +:host { + display: block; +} + +// Opened as its own dialog (header menu): without a width the overlay sizes +// the card to its content, and without a height cap a long thread runs past a +// phone screen with nothing behind it that can scroll. +:host(.support-dialog) { + width: min(640px, 95vw); + max-height: 85vh; + max-height: 85dvh; + overflow-y: auto; +} + +.support-box { + margin-bottom: 1rem; +} + +.support-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.support-head__text { + min-width: 0; +} + +.support-head__title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--text-basic-color); + + nb-icon { + font-size: 1.2rem; + color: var(--color-primary-600); + } +} + +.support-sub { + margin: 0.25rem 0 0; + font-size: 0.88rem; + line-height: 1.5; + color: var(--text-hint-color); +} + +.support-alert { + margin-bottom: 1rem; +} + +.support-alert__action { + margin-top: 0.75rem; +} + +.support-loading { + min-height: 4rem; +} + +// ---------- form ---------- +.support-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.support-form__row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.field { + display: flex; + flex-direction: column; +} + +.field__label { + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--text-basic-color); +} + +.field__hint { + margin: 0.25rem 0 0; + font-size: 0.8rem; + color: var(--text-hint-color); +} + +.field__counter { + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--text-hint-color); + text-align: end; +} + +.support-form__actions { + display: flex; + justify-content: flex-start; +} + +// ---------- tickets ---------- +.ticket { + border-bottom: 1px solid var(--border-basic-color-3); + + &:last-child { + border-bottom: 0; + } +} + +.ticket__head { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.75rem; + padding: 0.6rem 0.25rem; + border: 0; + background: none; + color: var(--text-basic-color); + text-align: start; + cursor: pointer; + font: inherit; + border-radius: var(--border-radius); + + &:hover { + background: var(--color-basic-transparent-100); + } + + &:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + } +} + +.ticket__subject { + flex: 1; + min-width: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +.ticket__status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.55rem; + border-radius: 1rem; + white-space: nowrap; + + nb-icon { + font-size: 0.9rem; + } +} + +.st-open { + background: var(--color-warning-100); + color: var(--color-warning-700); +} + +.st-answered { + background: var(--color-success-100); + color: var(--color-success-700); +} + +.st-closed { + background: var(--background-basic-color-3); + color: var(--text-hint-color); +} + +.ticket__chevron { + flex-shrink: 0; + color: var(--text-hint-color); +} + +.thread { + padding: 0.25rem 0.25rem 0.75rem; +} + +.msg { + padding: 0.6rem 0.85rem; + border-radius: var(--border-radius); + margin-bottom: 0.4rem; + background: var(--background-basic-color-2); +} + +// The operator's replies: brand tint and a bar, and only ever "ניהול" as the +// name — the identity behind it is never shown. +.msg--admin { + background: var(--color-primary-transparent-100); + border-inline-start: 3px solid var(--color-primary-500); +} + +.msg__meta { + display: flex; + gap: 0.3rem; + font-size: 0.75rem; + color: var(--text-hint-color); + margin-bottom: 0.2rem; + + strong { + color: var(--text-basic-color); + } +} + +// Bodies are plain text, rendered by interpolation — never markdown, so a +// ticket cannot inject markup into the operator's inbox. +.msg__body { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; +} + +.reply { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.75rem; +} + +.reply__send { + align-self: flex-start; +} + +.thread__closed { + margin: 0.5rem 0 0; + font-size: 0.88rem; + color: var(--text-hint-color); +} + +@media (max-width: 575px) { + .support-form__row { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/components/support/support-box.component.ts b/frontend/src/app/components/support/support-box.component.ts new file mode 100644 index 00000000..d0857dc8 --- /dev/null +++ b/frontend/src/app/components/support/support-box.component.ts @@ -0,0 +1,258 @@ +import { Component, HostBinding, Input, OnInit, Optional } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { + NbAlertModule, NbButtonModule, NbCardModule, NbDialogRef, + NbIconModule, NbInputModule, NbSpinnerModule, NbToastrService, NbTooltipModule, +} from '@nebular/theme'; +import { SupportService, SupportTicket } from '../../services/support.service'; + +/** + * "Contact the operator" — the same box on the public landing page, inside a + * channel's manage page and as a dialog from the channel header. + * + * The surfaces differ only in what the sender has to type: signed in, the + * name and email come from the session (the server ignores them in the body + * either way), so the form is just a subject and a message. Anonymous, the + * email is required, because it is the only way the operator knows who asked. + * + * Existing threads are listed underneath so a reply is read in the same place + * it was sent from. A signed-in user's threads come from the server by session + * email; an anonymous sender's come from the tokens this browser kept. The + * operator's replies are labelled "ניהול" and never carry a name. + */ +@Component({ + selector: 'app-support-box', + standalone: true, + imports: [ + DatePipe, FormsModule, NbCardModule, NbButtonModule, NbInputModule, + NbIconModule, NbAlertModule, NbSpinnerModule, NbTooltipModule, + ], + templateUrl: './support-box.component.html', + styleUrl: './support-box.component.scss', +}) +export class SupportBoxComponent implements OnInit { + @Input() title = 'פנייה למערכת'; + @Input() subtitle = ''; + /** Set when the box is shown inside a channel, so the operator sees which. */ + @Input() channelSlug = ''; + /** Drives which fields are asked for; the server decides identity regardless. */ + @Input() signedIn = false; + /** + * Set by the caller that opens the box as its own dialog (the header menu). + * Not derived from the injected NbDialogRef: as a manage-page section there + * is no dialog of its own to close, and a stray ref from an enclosing dialog + * must not be closed by this button. + */ + @Input() dialogMode = false; + /** + * A thread to open on arrival — the `ticket` of an emailed support answer. + * The link never carries the anonymous access token (the mail goes to an + * unverified address), so the thread opens only where it is already + * readable: the requester's session, or the browser that sent it. + */ + @Input() openTicketId = ''; + /** The linked thread is not readable here; says why and what to do instead. */ + ticketNotFound = false; + + @HostBinding('class.support-dialog') get isDialog(): boolean { return this.dialogMode; } + + readonly subjectMax = 200; + readonly bodyMax = 5000; + + subject = ''; + body = ''; + name = ''; + email = ''; + + submitting = false; + sent = false; + error = ''; + + tickets: SupportTicket[] = []; + ticketsLoading = false; + openId = ''; + replyBody = ''; + replying = false; + + constructor( + private support: SupportService, + private toastr: NbToastrService, + @Optional() private dialogRef: NbDialogRef<SupportBoxComponent> | null, + private router: Router, + ) {} + + close(): void { + if (this.dialogMode) this.dialogRef?.close(); + } + + async ngOnInit(): Promise<void> { + await this.loadTickets(); + if (!this.openTicketId) return; + if (this.tickets.some(t => t.id === this.openTicketId)) { + this.openId = this.openTicketId; + setTimeout(() => document.getElementById('ticket-' + this.openTicketId) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' })); + } else { + this.ticketNotFound = true; + } + } + + /** Sign in and come back to this very link, with the thread to open. */ + signInForTicket(): void { + try { + localStorage.setItem('returnUrl', window.location.pathname + window.location.search); + } catch { + // Storage unavailable: the login still works, it just lands elsewhere. + } + this.router.navigate(['/login']); + } + + async submit(): Promise<void> { + this.error = ''; + // A new attempt clears the previous outcome too, or the green "sent" + // banner and a red validation error stood on screen together. + this.sent = false; + if (!this.subject.trim() || !this.body.trim()) { + this.error = 'יש למלא נושא ותוכן.'; + return; + } + const email = this.email.trim(); + if (!this.signedIn && !email) { + this.error = 'יש להזין כתובת אימייל כדי שנוכל לחזור אליכם.'; + return; + } + // Same shape check as the server's looksLikeEmail, so a typo is caught + // here instead of by a 400 round trip. + if (!this.signedIn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + this.error = 'יש להזין כתובת אימייל תקינה.'; + return; + } + + this.submitting = true; + try { + await this.support.createTicket({ + subject: this.subject.trim(), + body: this.body.trim(), + name: this.name.trim(), + email: this.email.trim(), + channelSlug: this.channelSlug, + }); + this.sent = true; + this.subject = ''; + this.body = ''; + await this.loadTickets(); + } catch (err: any) { + this.error = this.errorText(err); + } finally { + this.submitting = false; + } + } + + toggle(id: string): void { + this.openId = this.openId === id ? '' : id; + this.replyBody = ''; + } + + async sendReply(t: SupportTicket): Promise<void> { + const body = this.replyBody.trim(); + if (!body) return; + this.replying = true; + try { + const updated = await this.support.reply(t.id, body, this.tokenFor(t.id)); + // Replace in place so the open thread updates without a full reload. + const i = this.tickets.findIndex(x => x.id === t.id); + if (i >= 0) this.tickets[i] = updated; + this.replyBody = ''; + } catch (err: any) { + this.toastr.danger('', this.errorText(err)); + } finally { + this.replying = false; + } + } + + statusText(s: string): string { + switch (s) { + case 'open': return 'ממתין למענה'; + case 'answered': return 'נענתה'; + case 'closed': return 'נסגרה'; + default: return s; + } + } + + statusIcon(s: string): string { + switch (s) { + case 'open': return 'clock-outline'; + case 'answered': return 'message-circle-outline'; + case 'closed': return 'checkmark-circle-2-outline'; + default: return 'email-outline'; + } + } + + private tokenFor(id: string): string | undefined { + return this.support.anonTickets().find(t => t.id === id)?.token; + } + + /** + * Signed in, the server finds the threads by session email. Anonymous, they + * are fetched one by one with the tokens this browser kept — a ticket opened + * elsewhere is simply not listed, which is the point of the token. + */ + private async loadTickets(): Promise<void> { + this.ticketsLoading = true; + try { + if (this.signedIn) { + try { + this.tickets = await this.support.myTickets(); + } catch { + this.tickets = []; + } + return; + } + + const stored = this.support.anonTickets(); + const loaded = await Promise.all( + stored.map(s => this.support.getTicket(s.id, s.token).catch(() => null)), + ); + // A ticket that has expired server-side drops out rather than erroring. + this.tickets = loaded.filter((t): t is SupportTicket => t !== null); + } finally { + this.ticketsLoading = false; + } + } + + private errorText(err: any): string { + // The server's bodies are English one-liners meant for its logs; they are + // only ever matched on here, never shown in the Hebrew form. + const text = typeof err?.error === 'string' && err.error ? err.error : ''; + switch (err?.status) { + case 429: return 'נשלחו יותר מדי פניות בזמן קצר. נסו שוב בעוד כמה דקות.'; + case 400: + // A signed-in box hides the email field, so once the session has lapsed + // (cookie expired, logged out in another tab) the server's "email + // required" cannot be satisfied from here — only a fresh sign-in can. + if (text.includes('email')) { + return this.signedIn + ? 'ההתחברות פגה. יש להתחבר מחדש ולשלוח את הפנייה שוב.' + : 'יש להזין כתובת אימייל תקינה.'; + } + if (text.includes('subject and body')) return 'יש למלא נושא ותוכן.'; + if (text.includes('message body')) return 'יש להזין תוכן להודעה.'; + return 'הפרטים שהוזנו אינם תקינים.'; + case 401: + return this.signedIn + ? 'ההתחברות פגה. יש להתחבר מחדש ולנסות שוב.' + : 'אין הרשאה לבצע פעולה זו.'; + case 409: + // Both a closed ticket and a full thread answer 409; the body tells + // them apart. A full thread used to read as "closed", which the + // requester took for the operator closing it. + return text.includes('message limit') + ? 'הפנייה הגיעה למספר ההודעות המרבי. לשאלה נוספת, פתחו פנייה חדשה.' + : 'הפנייה נסגרה ולא ניתן להוסיף לה הודעות.'; + case 404: return 'הפנייה לא נמצאה.'; + default: return 'שגיאה בשליחה. נסו שוב.'; + } + } +} diff --git a/frontend/src/app/eva-icons.provider.ts b/frontend/src/app/eva-icons.provider.ts new file mode 100644 index 00000000..fb7f24d7 --- /dev/null +++ b/frontend/src/app/eva-icons.provider.ts @@ -0,0 +1,25 @@ +import { inject, provideAppInitializer } from '@angular/core'; +import { NbIconLibraries, NbSvgIcon } from '@nebular/theme'; +import { EVA_ICON_SUBSET } from './eva-icons.subset'; + +// Registers only the Eva icons the app uses (see scripts/gen-eva-subset.mjs) +// in place of NbEvaIconsModule, which shipped the whole 237 KB pack for a few +// dozen names. Same pack name and default, so `<nb-icon icon="...">` and +// menu items are unchanged. +class EvaSubsetIcon extends NbSvgIcon { + constructor(name: string, inner: string) { + super(name, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor" class="eva eva-${name}">${inner}</svg>`); + } +} + +export function provideEvaIconSubset() { + return provideAppInitializer(() => { + const libraries = inject(NbIconLibraries); + const icons: Record<string, NbSvgIcon> = {}; + for (const [name, inner] of Object.entries(EVA_ICON_SUBSET)) { + icons[name] = new EvaSubsetIcon(name, inner); + } + libraries.registerSvgPack('eva', icons); + libraries.setDefaultPack('eva'); + }); +} diff --git a/frontend/src/app/eva-icons.subset.ts b/frontend/src/app/eva-icons.subset.ts new file mode 100644 index 00000000..115f9d01 --- /dev/null +++ b/frontend/src/app/eva-icons.subset.ts @@ -0,0 +1,105 @@ +// GENERATED by scripts/gen-eva-subset.mjs — do not edit; run the script +// after using a new icon name. 101 icons. +export const EVA_ICON_SUBSET: Record<string, string> = { + 'activity-outline': '<g data-name="Layer 2"><g data-name="activity"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M14.33 20h-.21a2 2 0 0 1-1.76-1.58L9.68 6l-2.76 6.4A1 1 0 0 1 6 13H3a1 1 0 0 1 0-2h2.34l2.51-5.79a2 2 0 0 1 3.79.38L14.32 18l2.76-6.38A1 1 0 0 1 18 11h3a1 1 0 0 1 0 2h-2.34l-2.51 5.79A2 2 0 0 1 14.33 20z"/></g></g>', + 'alert-circle-outline': '<g data-name="Layer 2"><g data-name="alert-circle"><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><circle cx="12" cy="16" r="1"/><path d="M12 7a1 1 0 0 0-1 1v5a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z"/></g></g>', + 'alert-triangle-outline': '<g data-name="Layer 2"><g data-name="alert-triangle"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M22.56 16.3L14.89 3.58a3.43 3.43 0 0 0-5.78 0L1.44 16.3a3 3 0 0 0-.05 3A3.37 3.37 0 0 0 4.33 21h15.34a3.37 3.37 0 0 0 2.94-1.66 3 3 0 0 0-.05-3.04zm-1.7 2.05a1.31 1.31 0 0 1-1.19.65H4.33a1.31 1.31 0 0 1-1.19-.65 1 1 0 0 1 0-1l7.68-12.73a1.48 1.48 0 0 1 2.36 0l7.67 12.72a1 1 0 0 1 .01 1.01z"/><circle cx="12" cy="16" r="1"/><path d="M12 8a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1z"/></g></g>', + 'arrow-back-outline': '<g data-name="Layer 2"><g data-name="arrow-back"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M19 11H7.14l3.63-4.36a1 1 0 1 0-1.54-1.28l-5 6a1.19 1.19 0 0 0-.09.15c0 .05 0 .08-.07.13A1 1 0 0 0 4 12a1 1 0 0 0 .07.36c0 .05 0 .08.07.13a1.19 1.19 0 0 0 .09.15l5 6A1 1 0 0 0 10 19a1 1 0 0 0 .64-.23 1 1 0 0 0 .13-1.41L7.14 13H19a1 1 0 0 0 0-2z"/></g></g>', + 'arrow-downward-outline': '<g data-name="Layer 2"><g data-name="arrow-downward"><rect width="24" height="24" opacity="0"/><path d="M18.77 13.36a1 1 0 0 0-1.41-.13L13 16.86V5a1 1 0 0 0-2 0v11.86l-4.36-3.63a1 1 0 1 0-1.28 1.54l6 5 .15.09.13.07a1 1 0 0 0 .72 0l.13-.07.15-.09 6-5a1 1 0 0 0 .13-1.41z"/></g></g>', + 'arrow-forward-outline': '<g data-name="Layer 2"><g data-name="arrow-forward"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M5 13h11.86l-3.63 4.36a1 1 0 0 0 1.54 1.28l5-6a1.19 1.19 0 0 0 .09-.15c0-.05.05-.08.07-.13A1 1 0 0 0 20 12a1 1 0 0 0-.07-.36c0-.05-.05-.08-.07-.13a1.19 1.19 0 0 0-.09-.15l-5-6A1 1 0 0 0 14 5a1 1 0 0 0-.64.23 1 1 0 0 0-.13 1.41L16.86 11H5a1 1 0 0 0 0 2z"/></g></g>', + 'attach-2-outline': '<g data-name="Layer 2"><g data-name="attach-2"><rect width="24" height="24" opacity="0"/><path d="M12 22a5.86 5.86 0 0 1-6-5.7V6.13A4.24 4.24 0 0 1 10.33 2a4.24 4.24 0 0 1 4.34 4.13v10.18a2.67 2.67 0 0 1-5.33 0V6.92a1 1 0 0 1 1-1 1 1 0 0 1 1 1v9.39a.67.67 0 0 0 1.33 0V6.13A2.25 2.25 0 0 0 10.33 4 2.25 2.25 0 0 0 8 6.13V16.3a3.86 3.86 0 0 0 4 3.7 3.86 3.86 0 0 0 4-3.7V6.13a1 1 0 1 1 2 0V16.3a5.86 5.86 0 0 1-6 5.7z"/></g></g>', + 'bar-chart-2-outline': '<g data-name="Layer 2"><g data-name="bar-chart-2"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M12 8a1 1 0 0 0-1 1v11a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1z"/><path d="M19 4a1 1 0 0 0-1 1v15a1 1 0 0 0 2 0V5a1 1 0 0 0-1-1z"/><path d="M5 12a1 1 0 0 0-1 1v7a1 1 0 0 0 2 0v-7a1 1 0 0 0-1-1z"/></g></g>', + 'bar-chart-outline': '<g data-name="Layer 2"><g data-name="bar-chart"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M12 4a1 1 0 0 0-1 1v15a1 1 0 0 0 2 0V5a1 1 0 0 0-1-1z"/><path d="M19 12a1 1 0 0 0-1 1v7a1 1 0 0 0 2 0v-7a1 1 0 0 0-1-1z"/><path d="M5 8a1 1 0 0 0-1 1v11a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1z"/></g></g>', + 'bell-outline': '<g data-name="Layer 2"><g data-name="bell"><rect width="24" height="24" opacity="0"/><path d="M20.52 15.21l-1.8-1.81V8.94a6.86 6.86 0 0 0-5.82-6.88 6.74 6.74 0 0 0-7.62 6.67v4.67l-1.8 1.81A1.64 1.64 0 0 0 4.64 18H8v.34A3.84 3.84 0 0 0 12 22a3.84 3.84 0 0 0 4-3.66V18h3.36a1.64 1.64 0 0 0 1.16-2.79zM14 18.34A1.88 1.88 0 0 1 12 20a1.88 1.88 0 0 1-2-1.66V18h4zM5.51 16l1.18-1.18a2 2 0 0 0 .59-1.42V8.73A4.73 4.73 0 0 1 8.9 5.17 4.67 4.67 0 0 1 12.64 4a4.86 4.86 0 0 1 4.08 4.9v4.5a2 2 0 0 0 .58 1.42L18.49 16z"/></g></g>', + 'book-open-outline': '<g data-name="Layer 2"><g data-name="book-open"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M20.62 4.22a1 1 0 0 0-.84-.2L12 5.77 4.22 4A1 1 0 0 0 3 5v12.2a1 1 0 0 0 .78 1l8 1.8h.44l8-1.8a1 1 0 0 0 .78-1V5a1 1 0 0 0-.38-.78zM5 6.25l6 1.35v10.15L5 16.4zM19 16.4l-6 1.35V7.6l6-1.35z"/></g></g>', + 'browser-outline': '<g data-name="Layer 2"><g data-name="browser"><rect width="24" height="24" opacity="0"/><path d="M18 3H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3zm1 15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-7h14zM5 9V6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3z"/><circle cx="8" cy="7.03" r="1"/><circle cx="12" cy="7.03" r="1"/></g></g>', + 'checkmark-circle-2-outline': '<g data-name="Layer 2"><g data-name="checkmark-circle-2"><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M14.7 8.39l-3.78 5-1.63-2.11a1 1 0 0 0-1.58 1.23l2.43 3.11a1 1 0 0 0 .79.38 1 1 0 0 0 .79-.39l4.57-6a1 1 0 1 0-1.6-1.22z"/></g></g>', + 'checkmark-outline': '<g data-name="Layer 2"><g data-name="checkmark"><rect width="24" height="24" opacity="0"/><path d="M9.86 18a1 1 0 0 1-.73-.32l-4.86-5.17a1 1 0 1 1 1.46-1.37l4.12 4.39 8.41-9.2a1 1 0 1 1 1.48 1.34l-9.14 10a1 1 0 0 1-.73.33z"/></g></g>', + 'chevron-down-outline': '<g data-name="Layer 2"><g data-name="chevron-down"><rect width="24" height="24" opacity="0"/><path d="M12 15.5a1 1 0 0 1-.71-.29l-4-4a1 1 0 1 1 1.42-1.42L12 13.1l3.3-3.18a1 1 0 1 1 1.38 1.44l-4 3.86a1 1 0 0 1-.68.28z"/></g></g>', + 'chevron-left-outline': '<g data-name="Layer 2"><g data-name="chevron-left"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M13.36 17a1 1 0 0 1-.72-.31l-3.86-4a1 1 0 0 1 0-1.4l4-4a1 1 0 1 1 1.42 1.42L10.9 12l3.18 3.3a1 1 0 0 1 0 1.41 1 1 0 0 1-.72.29z"/></g></g>', + 'chevron-right-outline': '<g data-name="Layer 2"><g data-name="chevron-right"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M10.5 17a1 1 0 0 1-.71-.29 1 1 0 0 1 0-1.42L13.1 12 9.92 8.69a1 1 0 0 1 0-1.41 1 1 0 0 1 1.42 0l3.86 4a1 1 0 0 1 0 1.4l-4 4a1 1 0 0 1-.7.32z"/></g></g>', + 'chevron-up-outline': '<g data-name="Layer 2"><g data-name="chevron-up"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M16 14.5a1 1 0 0 1-.71-.29L12 10.9l-3.3 3.18a1 1 0 0 1-1.41 0 1 1 0 0 1 0-1.42l4-3.86a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1 0 1.42 1 1 0 0 1-.69.28z"/></g></g>', + 'clock': '<g data-name="Layer 2"><g data-name="clock"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm4 11h-4a1 1 0 0 1-1-1V8a1 1 0 0 1 2 0v3h3a1 1 0 0 1 0 2z"/></g></g>', + 'clock-outline': '<g data-name="Layer 2"><g data-name="clock"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M16 11h-3V8a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2z"/></g></g>', + 'close-circle-outline': '<g data-name="Layer 2"><g data-name="close-circle"><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M14.71 9.29a1 1 0 0 0-1.42 0L12 10.59l-1.29-1.3a1 1 0 0 0-1.42 1.42l1.3 1.29-1.3 1.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l1.29-1.3 1.29 1.3a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42L13.41 12l1.3-1.29a1 1 0 0 0 0-1.42z"/></g></g>', + 'close-outline': '<g data-name="Layer 2"><g data-name="close"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M13.41 12l4.3-4.29a1 1 0 1 0-1.42-1.42L12 10.59l-4.29-4.3a1 1 0 0 0-1.42 1.42l4.3 4.29-4.3 4.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l4.29-4.3 4.29 4.3a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42z"/></g></g>', + 'code': '<g data-name="Layer 2"><g data-name="code"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M8.64 5.23a1 1 0 0 0-1.41.13l-5 6a1 1 0 0 0 0 1.27l4.83 6a1 1 0 0 0 .78.37 1 1 0 0 0 .78-1.63L4.29 12l4.48-5.36a1 1 0 0 0-.13-1.41z"/><path d="M21.78 11.37l-4.78-6a1 1 0 0 0-1.41-.15 1 1 0 0 0-.15 1.41L19.71 12l-4.48 5.37a1 1 0 0 0 .13 1.41A1 1 0 0 0 16 19a1 1 0 0 0 .77-.36l5-6a1 1 0 0 0 .01-1.27z"/></g></g>', + 'code-outline': '<g data-name="Layer 2"><g data-name="code"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M8.64 5.23a1 1 0 0 0-1.41.13l-5 6a1 1 0 0 0 0 1.27l4.83 6a1 1 0 0 0 .78.37 1 1 0 0 0 .78-1.63L4.29 12l4.48-5.36a1 1 0 0 0-.13-1.41z"/><path d="M21.78 11.37l-4.78-6a1 1 0 0 0-1.41-.15 1 1 0 0 0-.15 1.41L19.71 12l-4.48 5.37a1 1 0 0 0 .13 1.41A1 1 0 0 0 16 19a1 1 0 0 0 .77-.36l5-6a1 1 0 0 0 .01-1.27z"/></g></g>', + 'collapse-outline': '<g data-name="Layer 2"><g data-name="collapse"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M19 9h-2.58l3.29-3.29a1 1 0 1 0-1.42-1.42L15 7.57V5a1 1 0 0 0-1-1 1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h5a1 1 0 0 0 0-2z"/><path d="M10 13H5a1 1 0 0 0 0 2h2.57l-3.28 3.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0L9 16.42V19a1 1 0 0 0 1 1 1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1z"/></g></g>', + 'compass-outline': '<g data-name="Layer 2"><g data-name="compass"><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M15.68 8.32a1 1 0 0 0-1.1-.25l-4.21 1.7a1 1 0 0 0-.55.55l-1.75 4.26a1 1 0 0 0 .18 1h.05A1 1 0 0 0 9 16a1 1 0 0 0 .38-.07l4.21-1.7a1 1 0 0 0 .55-.55l1.75-4.26a1 1 0 0 0-.21-1.1zm-4.88 4.89l.71-1.74 1.69-.68-.71 1.74z"/></g></g>', + 'copy': '<g data-name="Layer 2"><g data-name="copy"><rect width="24" height="24" opacity="0"/><path d="M18 9h-3V5.67A2.68 2.68 0 0 0 12.33 3H5.67A2.68 2.68 0 0 0 3 5.67v6.66A2.68 2.68 0 0 0 5.67 15H9v3a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3v-6a3 3 0 0 0-3-3zm-9 3v1H5.67a.67.67 0 0 1-.67-.67V5.67A.67.67 0 0 1 5.67 5h6.66a.67.67 0 0 1 .67.67V9h-1a3 3 0 0 0-3 3z"/></g></g>', + 'copy-outline': '<g data-name="Layer 2"><g data-name="copy"><rect width="24" height="24" opacity="0"/><path d="M18 21h-6a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3zm-6-10a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1z"/><path d="M9.73 15H5.67A2.68 2.68 0 0 1 3 12.33V5.67A2.68 2.68 0 0 1 5.67 3h6.66A2.68 2.68 0 0 1 15 5.67V9.4h-2V5.67a.67.67 0 0 0-.67-.67H5.67a.67.67 0 0 0-.67.67v6.66a.67.67 0 0 0 .67.67h4.06z"/></g></g>', + 'corner-up-left-outline': '<g data-name="Layer 2"><g data-name="corner-up-left"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M16 10H7.08l2.7-3.38a1 1 0 1 0-1.56-1.24l-4 5a1 1 0 0 0 0 1.24l4 5A1 1 0 0 0 9 17a1 1 0 0 0 .62-.22 1 1 0 0 0 .16-1.4L7.08 12H16a1 1 0 0 1 1 1v5a1 1 0 0 0 2 0v-5a3 3 0 0 0-3-3z"/></g></g>', + 'credit-card-outline': '<g data-name="Layer 2"><g data-name="credit-card"><rect width="24" height="24" opacity="0"/><path d="M19 5H5a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3zM4 8a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v1H4zm16 8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-5h16z"/><path d="M7 15h4a1 1 0 0 0 0-2H7a1 1 0 0 0 0 2z"/><path d="M15 15h2a1 1 0 0 0 0-2h-2a1 1 0 0 0 0 2z"/></g></g>', + 'edit-2-outline': '<g data-name="Layer 2"><g data-name="edit-2"><rect width="24" height="24" opacity="0"/><path d="M19 20H5a1 1 0 0 0 0 2h14a1 1 0 0 0 0-2z"/><path d="M5 18h.09l4.17-.38a2 2 0 0 0 1.21-.57l9-9a1.92 1.92 0 0 0-.07-2.71L16.66 2.6A2 2 0 0 0 14 2.53l-9 9a2 2 0 0 0-.57 1.21L4 16.91a1 1 0 0 0 .29.8A1 1 0 0 0 5 18zM15.27 4L18 6.73l-2 1.95L13.32 6zm-8.9 8.91L12 7.32l2.7 2.7-5.6 5.6-3 .28z"/></g></g>', + 'edit-outline': '<g data-name="Layer 2"><g data-name="edit"><rect width="24" height="24" opacity="0"/><path d="M19.4 7.34L16.66 4.6A2 2 0 0 0 14 4.53l-9 9a2 2 0 0 0-.57 1.21L4 18.91a1 1 0 0 0 .29.8A1 1 0 0 0 5 20h.09l4.17-.38a2 2 0 0 0 1.21-.57l9-9a1.92 1.92 0 0 0-.07-2.71zM9.08 17.62l-3 .28.27-3L12 9.32l2.7 2.7zM16 10.68L13.32 8l1.95-2L18 8.73z"/></g></g>', + 'email': '<g data-name="Layer 2"><g data-name="email"><rect width="24" height="24" opacity="0"/><path d="M19 4H5a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3zm0 2l-6.5 4.47a1 1 0 0 1-1 0L5 6z"/></g></g>', + 'email-outline': '<g data-name="Layer 2"><g data-name="email"><rect width="24" height="24" opacity="0"/><path d="M19 4H5a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3zm-.67 2L12 10.75 5.67 6zM19 18H5a1 1 0 0 1-1-1V7.25l7.4 5.55a1 1 0 0 0 .6.2 1 1 0 0 0 .6-.2L20 7.25V17a1 1 0 0 1-1 1z"/></g></g>', + 'expand-outline': '<g data-name="Layer 2"><g data-name="expand"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M20 5a1 1 0 0 0-1-1h-5a1 1 0 0 0 0 2h2.57l-3.28 3.29a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0L18 7.42V10a1 1 0 0 0 1 1 1 1 0 0 0 1-1z"/><path d="M10.71 13.29a1 1 0 0 0-1.42 0L6 16.57V14a1 1 0 0 0-1-1 1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h5a1 1 0 0 0 0-2H7.42l3.29-3.29a1 1 0 0 0 0-1.42z"/></g></g>', + 'external-link-outline': '<g data-name="Layer 2"><g data-name="external-link"><rect width="24" height="24" opacity="0"/><path d="M20 11a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6a1 1 0 0 0 0-2H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-6a1 1 0 0 0-1-1z"/><path d="M16 5h1.58l-6.29 6.28a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0L19 6.42V8a1 1 0 0 0 1 1 1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-4a1 1 0 0 0 0 2z"/></g></g>', + 'eye-off-outline': '<g data-name="Layer 2"><g data-name="eye-off"><rect width="24" height="24" opacity="0"/><path d="M4.71 3.29a1 1 0 0 0-1.42 1.42l5.63 5.63a3.5 3.5 0 0 0 4.74 4.74l5.63 5.63a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42zM12 13.5a1.5 1.5 0 0 1-1.5-1.5v-.07l1.56 1.56z"/><path d="M12.22 17c-4.3.1-7.12-3.59-8-5a13.7 13.7 0 0 1 2.24-2.72L5 7.87a15.89 15.89 0 0 0-2.87 3.63 1 1 0 0 0 0 1c.63 1.09 4 6.5 9.89 6.5h.25a9.48 9.48 0 0 0 3.23-.67l-1.58-1.58a7.74 7.74 0 0 1-1.7.25z"/><path d="M21.87 11.5c-.64-1.11-4.17-6.68-10.14-6.5a9.48 9.48 0 0 0-3.23.67l1.58 1.58a7.74 7.74 0 0 1 1.7-.25c4.29-.11 7.11 3.59 8 5a13.7 13.7 0 0 1-2.29 2.72L19 16.13a15.89 15.89 0 0 0 2.91-3.63 1 1 0 0 0-.04-1z"/></g></g>', + 'eye-outline': '<g data-name="Layer 2"><g data-name="eye"><rect width="24" height="24" opacity="0"/><path d="M21.87 11.5c-.64-1.11-4.16-6.68-10.14-6.5-5.53.14-8.73 5-9.6 6.5a1 1 0 0 0 0 1c.63 1.09 4 6.5 9.89 6.5h.25c5.53-.14 8.74-5 9.6-6.5a1 1 0 0 0 0-1zM12.22 17c-4.31.1-7.12-3.59-8-5 1-1.61 3.61-4.9 7.61-5 4.29-.11 7.11 3.59 8 5-1.03 1.61-3.61 4.9-7.61 5z"/><path d="M12 8.5a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 12 8.5zm0 5a1.5 1.5 0 1 1 1.5-1.5 1.5 1.5 0 0 1-1.5 1.5z"/></g></g>', + 'file': '<g data-name="Layer 2"><g data-name="file"><rect width="24" height="24" opacity="0"/><path d="M19.74 7.33l-4.44-5a1 1 0 0 0-.74-.33h-8A2.53 2.53 0 0 0 4 4.5v15A2.53 2.53 0 0 0 6.56 22h10.88A2.53 2.53 0 0 0 20 19.5V8a1 1 0 0 0-.26-.67zM14 4l3.74 4h-3a.79.79 0 0 1-.74-.85z"/></g></g>', + 'file-text-outline': '<g data-name="Layer 2"><g data-name="file-text"><rect width="24" height="24" opacity="0"/><path d="M15 16H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2z"/><path d="M9 14h3a1 1 0 0 0 0-2H9a1 1 0 0 0 0 2z"/><path d="M19.74 8.33l-5.44-6a1 1 0 0 0-.74-.33h-7A2.53 2.53 0 0 0 4 4.5v15A2.53 2.53 0 0 0 6.56 22h10.88A2.53 2.53 0 0 0 20 19.5V9a1 1 0 0 0-.26-.67zM14 5l2.74 3h-2a.79.79 0 0 1-.74-.85zm3.44 15H6.56a.53.53 0 0 1-.56-.5v-15a.53.53 0 0 1 .56-.5H12v3.15A2.79 2.79 0 0 0 14.71 10H18v9.5a.53.53 0 0 1-.56.5z"/></g></g>', + 'film-outline': '<g data-name="Layer 2"><g data-name="film"><rect width="24" height="24" opacity="0"/><path d="M18.26 3H5.74A2.74 2.74 0 0 0 3 5.74v12.52A2.74 2.74 0 0 0 5.74 21h12.52A2.74 2.74 0 0 0 21 18.26V5.74A2.74 2.74 0 0 0 18.26 3zM7 11H5V9h2zm-2 2h2v2H5zm4-8h6v14H9zm10 6h-2V9h2zm-2 2h2v2h-2zm2-7.26V7h-2V5h1.26a.74.74 0 0 1 .74.74zM5.74 5H7v2H5V5.74A.74.74 0 0 1 5.74 5zM5 18.26V17h2v2H5.74a.74.74 0 0 1-.74-.74zm14 0a.74.74 0 0 1-.74.74H17v-2h2z"/></g></g>', + 'flash-outline': '<g data-name="Layer 2"><g data-name="flash"><rect width="24" height="24" opacity="0"/><path d="M11.11 23a1 1 0 0 1-.34-.06 1 1 0 0 1-.65-1.05l.77-7.09H5a1 1 0 0 1-.83-1.56l7.89-11.8a1 1 0 0 1 1.17-.38 1 1 0 0 1 .65 1l-.77 7.14H19a1 1 0 0 1 .83 1.56l-7.89 11.8a1 1 0 0 1-.83.44zM6.87 12.8H12a1 1 0 0 1 .74.33 1 1 0 0 1 .25.78l-.45 4.15 4.59-6.86H12a1 1 0 0 1-1-1.11l.45-4.15z"/></g></g>', + 'globe-2-outline': '<g data-name="Layer 2"><g data-name="globe-2"><rect width="24" height="24" opacity="0"/><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 2a8.19 8.19 0 0 1 1.79.21 2.61 2.61 0 0 1-.78 1c-.22.17-.46.31-.7.46a4.56 4.56 0 0 0-1.85 1.67 6.49 6.49 0 0 0-.62 3.3c0 1.36 0 2.16-.95 2.87-1.37 1.07-3.46.47-4.76-.07A8.33 8.33 0 0 1 4 12a8 8 0 0 1 8-8zM5 15.8a8.42 8.42 0 0 0 2 .27 5 5 0 0 0 3.14-1c1.71-1.34 1.71-3.06 1.71-4.44a4.76 4.76 0 0 1 .37-2.34 2.86 2.86 0 0 1 1.12-.91 9.75 9.75 0 0 0 .92-.61 4.55 4.55 0 0 0 1.4-1.87A8 8 0 0 1 19 8.12c-1.43.2-3.46.67-3.86 2.53A7 7 0 0 0 15 12a2.93 2.93 0 0 1-.29 1.47l-.1.17c-.65 1.08-1.38 2.31-.39 4 .12.21.25.41.38.61a2.29 2.29 0 0 1 .52 1.08A7.89 7.89 0 0 1 12 20a8 8 0 0 1-7-4.2zm11.93 2.52a6.79 6.79 0 0 0-.63-1.14c-.11-.16-.22-.32-.32-.49-.39-.68-.25-1 .38-2l.1-.17a4.77 4.77 0 0 0 .54-2.43 5.42 5.42 0 0 1 .09-1c.16-.73 1.71-.93 2.67-1a7.94 7.94 0 0 1-2.86 8.28z"/></g></g>', + 'google': '<g data-name="Layer 2"><g data-name="google"><polyline points="0 0 24 0 24 24 0 24" opacity="0"/><path d="M17.5 14a5.51 5.51 0 0 1-4.5 3.93 6.15 6.15 0 0 1-7-5.45A6 6 0 0 1 12 6a6.12 6.12 0 0 1 2.27.44.5.5 0 0 0 .64-.21l1.44-2.65a.52.52 0 0 0-.23-.7A10 10 0 0 0 2 12.29 10.12 10.12 0 0 0 11.57 22 10 10 0 0 0 22 12.52v-2a.51.51 0 0 0-.5-.5h-9a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h5"/></g></g>', + 'grid-outline': '<g data-name="Layer 2"><g data-name="grid"><rect width="24" height="24" opacity="0"/><path d="M9 3H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM5 9V5h4v4z"/><path d="M19 3h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-4 6V5h4v4z"/><path d="M9 13H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm-4 6v-4h4v4z"/><path d="M19 13h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm-4 6v-4h4v4z"/></g></g>', + 'hard-drive-outline': '<g data-name="Layer 2"><g data-name="hard-drive"><rect width="24" height="24" opacity="0"/><path d="M20.79 11.34l-3.34-6.68A3 3 0 0 0 14.76 3H9.24a3 3 0 0 0-2.69 1.66l-3.34 6.68a2 2 0 0 0-.21.9V18a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-5.76a2 2 0 0 0-.21-.9zM8.34 5.55a1 1 0 0 1 .9-.55h5.52a1 1 0 0 1 .9.55L18.38 11H5.62zM18 19H6a1 1 0 0 1-1-1v-5h14v5a1 1 0 0 1-1 1z"/><path d="M16 15h-4a1 1 0 0 0 0 2h4a1 1 0 0 0 0-2z"/><circle cx="8" cy="16" r="1"/></g></g>', + 'home-outline': '<g data-name="Layer 2"><g data-name="home"><rect width="24" height="24" opacity="0"/><path d="M20.42 10.18L12.71 2.3a1 1 0 0 0-1.42 0l-7.71 7.89A2 2 0 0 0 3 11.62V20a2 2 0 0 0 1.89 2h14.22A2 2 0 0 0 21 20v-8.38a2.07 2.07 0 0 0-.58-1.44zM10 20v-6h4v6zm9 0h-3v-7a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v7H5v-8.42l7-7.15 7 7.19z"/></g></g>', + 'image': '<g data-name="Layer 2"><g data-name="image"><rect width="24" height="24" opacity="0"/><path d="M18 3H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3zM6 5h12a1 1 0 0 1 1 1v8.36l-3.2-2.73a2.77 2.77 0 0 0-3.52 0L5 17.7V6a1 1 0 0 1 1-1z"/><circle cx="8" cy="8.5" r="1.5"/></g></g>', + 'image-outline': '<g data-name="Layer 2"><g data-name="image"><rect width="24" height="24" opacity="0"/><path d="M18 3H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3zM6 5h12a1 1 0 0 1 1 1v8.36l-3.2-2.73a2.77 2.77 0 0 0-3.52 0L5 17.7V6a1 1 0 0 1 1-1zm12 14H6.56l7-5.84a.78.78 0 0 1 .93 0L19 17v1a1 1 0 0 1-1 1z"/><circle cx="8" cy="8.5" r="1.5"/></g></g>', + 'inbox-outline': '<g data-name="Layer 2"><g data-name="inbox"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M20.79 11.34l-3.34-6.68A3 3 0 0 0 14.76 3H9.24a3 3 0 0 0-2.69 1.66l-3.34 6.68a2 2 0 0 0-.21.9V18a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-5.76a2 2 0 0 0-.21-.9zM8.34 5.55a1 1 0 0 1 .9-.55h5.52a1 1 0 0 1 .9.55L18.38 11H16a1 1 0 0 0-1 1v3H9v-3a1 1 0 0 0-1-1H5.62zM18 19H6a1 1 0 0 1-1-1v-5h2v3a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-3h2v5a1 1 0 0 1-1 1z"/></g></g>', + 'info': '<g data-name="Layer 2"><g data-name="info"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm1 14a1 1 0 0 1-2 0v-5a1 1 0 0 1 2 0zm-1-7a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/></g></g>', + 'info-outline': '<g data-name="Layer 2"><g data-name="info"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><circle cx="12" cy="8" r="1"/><path d="M12 10a1 1 0 0 0-1 1v5a1 1 0 0 0 2 0v-5a1 1 0 0 0-1-1z"/></g></g>', + 'link': '<g data-name="Layer 2"><g data-name="link"><rect width="24" height="24" opacity="0"/><path d="M8 12a1 1 0 0 0 1 1h6a1 1 0 0 0 0-2H9a1 1 0 0 0-1 1z"/><path d="M9 16H7.21A4.13 4.13 0 0 1 3 12.37 4 4 0 0 1 7 8h2a1 1 0 0 0 0-2H7.21a6.15 6.15 0 0 0-6.16 5.21A6 6 0 0 0 7 18h2a1 1 0 0 0 0-2z"/><path d="M23 11.24A6.16 6.16 0 0 0 16.76 6h-1.51C14.44 6 14 6.45 14 7a1 1 0 0 0 1 1h1.79A4.13 4.13 0 0 1 21 11.63 4 4 0 0 1 17 16h-2a1 1 0 0 0 0 2h2a6 6 0 0 0 6-6.76z"/></g></g>', + 'link-2-outline': '<g data-name="Layer 2"><g data-name="link-2"><rect width="24" height="24" opacity="0"/><path d="M13.29 9.29l-4 4a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l4-4a1 1 0 0 0-1.42-1.42z"/><path d="M12.28 17.4L11 18.67a4.2 4.2 0 0 1-5.58.4 4 4 0 0 1-.27-5.93l1.42-1.43a1 1 0 0 0 0-1.42 1 1 0 0 0-1.42 0l-1.27 1.28a6.15 6.15 0 0 0-.67 8.07 6.06 6.06 0 0 0 9.07.6l1.42-1.42a1 1 0 0 0-1.42-1.42z"/><path d="M19.66 3.22a6.18 6.18 0 0 0-8.13.68L10.45 5a1.09 1.09 0 0 0-.17 1.61 1 1 0 0 0 1.42 0L13 5.3a4.17 4.17 0 0 1 5.57-.4 4 4 0 0 1 .27 5.95l-1.42 1.43a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l1.42-1.42a6.06 6.06 0 0 0-.6-9.06z"/></g></g>', + 'list': '<g data-name="Layer 2"><g data-name="list"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><circle cx="4" cy="7" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="17" r="1"/><rect x="7" y="11" width="14" height="2" rx=".94" ry=".94"/><rect x="7" y="16" width="14" height="2" rx=".94" ry=".94"/><rect x="7" y="6" width="14" height="2" rx=".94" ry=".94"/></g></g>', + 'list-outline': '<g data-name="Layer 2"><g data-name="list"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><circle cx="4" cy="7" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="17" r="1"/><rect x="7" y="11" width="14" height="2" rx=".94" ry=".94"/><rect x="7" y="16" width="14" height="2" rx=".94" ry=".94"/><rect x="7" y="6" width="14" height="2" rx=".94" ry=".94"/></g></g>', + 'loader-outline': '<g data-name="Layer 2"><g data-name="loader"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12 2a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0V3a1 1 0 0 0-1-1z"/><path d="M21 11h-2a1 1 0 0 0 0 2h2a1 1 0 0 0 0-2z"/><path d="M6 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h2a1 1 0 0 0 1-1z"/><path d="M6.22 5a1 1 0 0 0-1.39 1.47l1.44 1.39a1 1 0 0 0 .73.28 1 1 0 0 0 .72-.31 1 1 0 0 0 0-1.41z"/><path d="M17 8.14a1 1 0 0 0 .69-.28l1.44-1.39A1 1 0 0 0 17.78 5l-1.44 1.42a1 1 0 0 0 0 1.41 1 1 0 0 0 .66.31z"/><path d="M12 18a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0v-2a1 1 0 0 0-1-1z"/><path d="M17.73 16.14a1 1 0 0 0-1.39 1.44L17.78 19a1 1 0 0 0 .69.28 1 1 0 0 0 .72-.3 1 1 0 0 0 0-1.42z"/><path d="M6.27 16.14l-1.44 1.39a1 1 0 0 0 0 1.42 1 1 0 0 0 .72.3 1 1 0 0 0 .67-.25l1.44-1.39a1 1 0 0 0-1.39-1.44z"/></g></g>', + 'lock-outline': '<g data-name="Layer 2"><g data-name="lock"><rect width="24" height="24" opacity="0"/><path d="M17 8h-1V6.11a4 4 0 1 0-8 0V8H7a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-8a3 3 0 0 0-3-3zm-7-1.89A2.06 2.06 0 0 1 12 4a2.06 2.06 0 0 1 2 2.11V8h-4zM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z"/><path d="M12 12a3 3 0 1 0 3 3 3 3 0 0 0-3-3zm0 4a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/></g></g>', + 'log-in-outline': '<g data-name="Layer 2"><g data-name="log-in"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M19 4h-2a1 1 0 0 0 0 2h1v12h-1a1 1 0 0 0 0 2h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"/><path d="M11.8 7.4a1 1 0 0 0-1.6 1.2L12 11H4a1 1 0 0 0 0 2h8.09l-1.72 2.44a1 1 0 0 0 .24 1.4 1 1 0 0 0 .58.18 1 1 0 0 0 .81-.42l2.82-4a1 1 0 0 0 0-1.18z"/></g></g>', + 'log-out-outline': '<g data-name="Layer 2"><g data-name="log-out"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M7 6a1 1 0 0 0 0-2H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h2a1 1 0 0 0 0-2H6V6z"/><path d="M20.82 11.42l-2.82-4a1 1 0 0 0-1.39-.24 1 1 0 0 0-.24 1.4L18.09 11H10a1 1 0 0 0 0 2h8l-1.8 2.4a1 1 0 0 0 .2 1.4 1 1 0 0 0 .6.2 1 1 0 0 0 .8-.4l3-4a1 1 0 0 0 .02-1.18z"/></g></g>', + 'menu': '<g data-name="Layer 2"><g data-name="menu"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><rect x="3" y="11" width="18" height="2" rx=".95" ry=".95"/><rect x="3" y="16" width="18" height="2" rx=".95" ry=".95"/><rect x="3" y="6" width="18" height="2" rx=".95" ry=".95"/></g></g>', + 'message-circle-outline': '<g data-name="Layer 2"><g data-name="message-circle"><circle cx="12" cy="12" r="1"/><circle cx="16" cy="12" r="1"/><circle cx="8" cy="12" r="1"/><path d="M19.07 4.93a10 10 0 0 0-16.28 11 1.06 1.06 0 0 1 .09.64L2 20.8a1 1 0 0 0 .27.91A1 1 0 0 0 3 22h.2l4.28-.86a1.26 1.26 0 0 1 .64.09 10 10 0 0 0 11-16.28zm.83 8.36a8 8 0 0 1-11 6.08 3.26 3.26 0 0 0-1.25-.26 3.43 3.43 0 0 0-.56.05l-2.82.57.57-2.82a3.09 3.09 0 0 0-.21-1.81 8 8 0 0 1 6.08-11 8 8 0 0 1 9.19 9.19z"/><rect width="24" height="24" opacity="0"/></g></g>', + 'message-square-outline': '<g data-name="Layer 2"><g data-name="message-square"><rect width="24" height="24" opacity="0"/><circle cx="12" cy="11" r="1"/><circle cx="16" cy="11" r="1"/><circle cx="8" cy="11" r="1"/><path d="M19 3H5a3 3 0 0 0-3 3v15a1 1 0 0 0 .51.87A1 1 0 0 0 3 22a1 1 0 0 0 .51-.14L8 19.14a1 1 0 0 1 .55-.14H19a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3zm1 13a1 1 0 0 1-1 1H8.55a3 3 0 0 0-1.55.43l-3 1.8V6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1z"/></g></g>', + 'minus-outline': '<g data-name="Layer 2"><g data-name="minus"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M19 13H5a1 1 0 0 1 0-2h14a1 1 0 0 1 0 2z"/></g></g>', + 'monitor-outline': '<g data-name="Layer 2"><g data-name="monitor"><rect width="24" height="24" opacity="0"/><path d="M19 3H5a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h6v2H7a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2h-4v-2h6a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3zm1 11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1z"/></g></g>', + 'more-vertical-outline': '<g data-name="Layer 2"><g data-name="more-vertical"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="12" cy="19" r="2"/></g></g>', + 'navigation-2-outline': '<g data-name="Layer 2"><g data-name="navigation-2"><rect width="24" height="24" opacity="0"/><path d="M13.67 22h-.06a1 1 0 0 1-.92-.8L11 13l-8.2-1.69a1 1 0 0 1-.12-1.93l16-5.33A1 1 0 0 1 20 5.32l-5.33 16a1 1 0 0 1-1 .68zm-6.8-11.9l5.19 1.06a1 1 0 0 1 .79.78l1.05 5.19 3.52-10.55z"/></g></g>', + 'options': '<g data-name="Layer 2"><g data-name="options"><rect width="24" height="24" opacity="0"/><path d="M7 14.18V3a1 1 0 0 0-2 0v11.18a3 3 0 0 0 0 5.64V21a1 1 0 0 0 2 0v-1.18a3 3 0 0 0 0-5.64z"/><path d="M21 13a3 3 0 0 0-2-2.82V3a1 1 0 0 0-2 0v7.18a3 3 0 0 0 0 5.64V21a1 1 0 0 0 2 0v-5.18A3 3 0 0 0 21 13z"/><path d="M15 5a3 3 0 1 0-4 2.82V21a1 1 0 0 0 2 0V7.82A3 3 0 0 0 15 5z"/></g></g>', + 'options-2-outline': '<g data-name="Layer 2"><g data-name="options-2"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M19 9a3 3 0 0 0-2.82 2H3a1 1 0 0 0 0 2h13.18A3 3 0 1 0 19 9zm0 4a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/><path d="M3 7h1.18a3 3 0 0 0 5.64 0H21a1 1 0 0 0 0-2H9.82a3 3 0 0 0-5.64 0H3a1 1 0 0 0 0 2zm4-2a1 1 0 1 1-1 1 1 1 0 0 1 1-1z"/><path d="M21 17h-7.18a3 3 0 0 0-5.64 0H3a1 1 0 0 0 0 2h5.18a3 3 0 0 0 5.64 0H21a1 1 0 0 0 0-2zm-10 2a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/></g></g>', + 'paper-plane-outline': '<g data-name="Layer 2"><g data-name="paper-plane"><rect width="24" height="24" opacity="0"/><path d="M21 4a1.31 1.31 0 0 0-.06-.27v-.09a1 1 0 0 0-.2-.3 1 1 0 0 0-.29-.19h-.09a.86.86 0 0 0-.31-.15H20a1 1 0 0 0-.3 0l-18 6a1 1 0 0 0 0 1.9l8.53 2.84 2.84 8.53a1 1 0 0 0 1.9 0l6-18A1 1 0 0 0 21 4zm-4.7 2.29l-5.57 5.57L5.16 10zM14 18.84l-1.86-5.57 5.57-5.57z"/></g></g>', + 'people-outline': '<g data-name="Layer 2"><g data-name="people"><rect width="24" height="24" opacity="0"/><path d="M9 11a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0-6a2 2 0 1 1-2 2 2 2 0 0 1 2-2z"/><path d="M17 13a3 3 0 1 0-3-3 3 3 0 0 0 3 3zm0-4a1 1 0 1 1-1 1 1 1 0 0 1 1-1z"/><path d="M17 14a5 5 0 0 0-3.06 1.05A7 7 0 0 0 2 20a1 1 0 0 0 2 0 5 5 0 0 1 10 0 1 1 0 0 0 2 0 6.9 6.9 0 0 0-.86-3.35A3 3 0 0 1 20 19a1 1 0 0 0 2 0 5 5 0 0 0-5-5z"/></g></g>', + 'person-add-outline': '<g data-name="Layer 2"><g data-name="person-add"><rect width="24" height="24" opacity="0"/><path d="M21 6h-1V5a1 1 0 0 0-2 0v1h-1a1 1 0 0 0 0 2h1v1a1 1 0 0 0 2 0V8h1a1 1 0 0 0 0-2z"/><path d="M10 11a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0-6a2 2 0 1 1-2 2 2 2 0 0 1 2-2z"/><path d="M10 13a7 7 0 0 0-7 7 1 1 0 0 0 2 0 5 5 0 0 1 10 0 1 1 0 0 0 2 0 7 7 0 0 0-7-7z"/></g></g>', + 'person-remove-outline': '<g data-name="Layer 2"><g data-name="person-remove"><rect width="24" height="24" opacity="0"/><path d="M21 6h-4a1 1 0 0 0 0 2h4a1 1 0 0 0 0-2z"/><path d="M10 11a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0-6a2 2 0 1 1-2 2 2 2 0 0 1 2-2z"/><path d="M10 13a7 7 0 0 0-7 7 1 1 0 0 0 2 0 5 5 0 0 1 10 0 1 1 0 0 0 2 0 7 7 0 0 0-7-7z"/></g></g>', + 'plus-circle-outline': '<g data-name="Layer 2"><g data-name="plus-circle"><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M15 11h-2V9a1 1 0 0 0-2 0v2H9a1 1 0 0 0 0 2h2v2a1 1 0 0 0 2 0v-2h2a1 1 0 0 0 0-2z"/></g></g>', + 'plus-outline': '<g data-name="Layer 2"><g data-name="plus"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M19 11h-6V5a1 1 0 0 0-2 0v6H5a1 1 0 0 0 0 2h6v6a1 1 0 0 0 2 0v-6h6a1 1 0 0 0 0-2z"/></g></g>', + 'pricetags-outline': '<g data-name="Layer 2"><g data-name="pricetags"><rect width="24" height="24" opacity="0"/><path d="M12.87 22a1.84 1.84 0 0 1-1.29-.53l-6.41-6.42a1 1 0 0 1-.29-.61L4 5.09a1 1 0 0 1 .29-.8 1 1 0 0 1 .8-.29l9.35.88a1 1 0 0 1 .61.29l6.42 6.41a1.82 1.82 0 0 1 0 2.57l-7.32 7.32a1.82 1.82 0 0 1-1.28.53zm-6-8.11l6 6 7.05-7.05-6-6-7.81-.73z"/><circle cx="10.5" cy="10.5" r="1.5"/></g></g>', + 'question-mark-circle-outline': '<g data-name="Layer 2"><g data-name="menu-arrow-circle"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/><path d="M12 6a3.5 3.5 0 0 0-3.5 3.5 1 1 0 0 0 2 0A1.5 1.5 0 1 1 12 11a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0v-1.16A3.49 3.49 0 0 0 12 6z"/><circle cx="12" cy="17" r="1"/></g></g>', + 'question-mark-outline': '<g data-name="Layer 2"><g data-name="question-mark"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M17 9A5 5 0 0 0 7 9a1 1 0 0 0 2 0 3 3 0 1 1 3 3 1 1 0 0 0-1 1v2a1 1 0 0 0 2 0v-1.1A5 5 0 0 0 17 9z"/><circle cx="12" cy="19" r="1"/></g></g>', + 'radio-outline': '<g data-name="Layer 2"><g data-name="radio"><rect width="24" height="24" opacity="0"/><path d="M12 8a3 3 0 0 0-1 5.83 1 1 0 0 0 0 .17v6a1 1 0 0 0 2 0v-6a1 1 0 0 0 0-.17A3 3 0 0 0 12 8zm0 4a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/><path d="M3.5 11a6.87 6.87 0 0 1 2.64-5.23 1 1 0 1 0-1.28-1.54A8.84 8.84 0 0 0 1.5 11a8.84 8.84 0 0 0 3.36 6.77 1 1 0 1 0 1.28-1.54A6.87 6.87 0 0 1 3.5 11z"/><path d="M16.64 6.24a1 1 0 0 0-1.28 1.52A4.28 4.28 0 0 1 17 11a4.28 4.28 0 0 1-1.64 3.24A1 1 0 0 0 16 16a1 1 0 0 0 .64-.24A6.2 6.2 0 0 0 19 11a6.2 6.2 0 0 0-2.36-4.76z"/><path d="M8.76 6.36a1 1 0 0 0-1.4-.12A6.2 6.2 0 0 0 5 11a6.2 6.2 0 0 0 2.36 4.76 1 1 0 0 0 1.4-.12 1 1 0 0 0-.12-1.4A4.28 4.28 0 0 1 7 11a4.28 4.28 0 0 1 1.64-3.24 1 1 0 0 0 .12-1.4z"/><path d="M19.14 4.23a1 1 0 1 0-1.28 1.54A6.87 6.87 0 0 1 20.5 11a6.87 6.87 0 0 1-2.64 5.23 1 1 0 0 0 1.28 1.54A8.84 8.84 0 0 0 22.5 11a8.84 8.84 0 0 0-3.36-6.77z"/></g></g>', + 'refresh-outline': '<g data-name="Layer 2"><g data-name="refresh"><rect width="24" height="24" opacity="0"/><path d="M20.3 13.43a1 1 0 0 0-1.25.65A7.14 7.14 0 0 1 12.18 19 7.1 7.1 0 0 1 5 12a7.1 7.1 0 0 1 7.18-7 7.26 7.26 0 0 1 4.65 1.67l-2.17-.36a1 1 0 0 0-1.15.83 1 1 0 0 0 .83 1.15l4.24.7h.17a1 1 0 0 0 .34-.06.33.33 0 0 0 .1-.06.78.78 0 0 0 .2-.11l.09-.11c0-.05.09-.09.13-.15s0-.1.05-.14a1.34 1.34 0 0 0 .07-.18l.75-4a1 1 0 0 0-2-.38l-.27 1.45A9.21 9.21 0 0 0 12.18 3 9.1 9.1 0 0 0 3 12a9.1 9.1 0 0 0 9.18 9A9.12 9.12 0 0 0 21 14.68a1 1 0 0 0-.7-1.25z"/></g></g>', + 'save-outline': '<g data-name="Layer 2"><g data-name="save"><rect width="24" height="24" opacity="0"/><path d="M20.12 8.71l-4.83-4.83A3 3 0 0 0 13.17 3H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-7.17a3 3 0 0 0-.88-2.12zM10 19v-2h4v2zm9-1a1 1 0 0 1-1 1h-2v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h2v5a1 1 0 0 0 1 1h4a1 1 0 0 0 0-2h-3V5h3.17a1.05 1.05 0 0 1 .71.29l4.83 4.83a1 1 0 0 1 .29.71z"/></g></g>', + 'search': '<g data-name="Layer 2"><g data-name="search"><rect width="24" height="24" opacity="0"/><path d="M20.71 19.29l-3.4-3.39A7.92 7.92 0 0 0 19 11a8 8 0 1 0-8 8 7.92 7.92 0 0 0 4.9-1.69l3.39 3.4a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42zM5 11a6 6 0 1 1 6 6 6 6 0 0 1-6-6z"/></g></g>', + 'search-outline': '<g data-name="Layer 2"><g data-name="search"><rect width="24" height="24" opacity="0"/><path d="M20.71 19.29l-3.4-3.39A7.92 7.92 0 0 0 19 11a8 8 0 1 0-8 8 7.92 7.92 0 0 0 4.9-1.69l3.39 3.4a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42zM5 11a6 6 0 1 1 6 6 6 6 0 0 1-6-6z"/></g></g>', + 'settings': '<g data-name="Layer 2"><g data-name="settings"><rect width="24" height="24" opacity="0"/><circle cx="12" cy="12" r="1.5"/><path d="M21.89 10.32L21.1 7.8a2.26 2.26 0 0 0-2.88-1.51l-.34.11a1.74 1.74 0 0 1-1.59-.26l-.11-.08a1.76 1.76 0 0 1-.69-1.43v-.28a2.37 2.37 0 0 0-.68-1.68 2.26 2.26 0 0 0-1.6-.67h-2.55a2.32 2.32 0 0 0-2.29 2.33v.24a1.94 1.94 0 0 1-.73 1.51l-.13.1a1.93 1.93 0 0 1-1.78.29 2.14 2.14 0 0 0-1.68.12 2.18 2.18 0 0 0-1.12 1.33l-.82 2.6a2.34 2.34 0 0 0 1.48 2.94h.16a1.83 1.83 0 0 1 1.12 1.22l.06.16a2.06 2.06 0 0 1-.23 1.86 2.37 2.37 0 0 0 .49 3.3l2.07 1.57a2.25 2.25 0 0 0 1.35.43A2 2 0 0 0 9 22a2.25 2.25 0 0 0 1.47-1l.23-.33a1.8 1.8 0 0 1 1.43-.77 1.75 1.75 0 0 1 1.5.78l.12.17a2.24 2.24 0 0 0 3.22.53L19 19.86a2.38 2.38 0 0 0 .5-3.23l-.26-.38A2 2 0 0 1 19 14.6a1.89 1.89 0 0 1 1.21-1.28l.2-.07a2.36 2.36 0 0 0 1.48-2.93zM12 15.5a3.5 3.5 0 1 1 3.5-3.5 3.5 3.5 0 0 1-3.5 3.5z"/></g></g>', + 'settings-2-outline': '<g data-name="Layer 2"><g data-name="settings-2"><rect width="24" height="24" transform="rotate(180 12 12)" opacity="0"/><path d="M12.94 22h-1.89a1.68 1.68 0 0 1-1.68-1.68v-1.09a.34.34 0 0 0-.22-.29.38.38 0 0 0-.41 0l-.74.8a1.67 1.67 0 0 1-2.37 0L4.26 18.4a1.66 1.66 0 0 1-.5-1.19 1.72 1.72 0 0 1 .5-1.21l.74-.74a.34.34 0 0 0 0-.37c-.06-.15-.16-.26-.3-.26H3.68A1.69 1.69 0 0 1 2 12.94v-1.89a1.68 1.68 0 0 1 1.68-1.68h1.09a.34.34 0 0 0 .29-.22.38.38 0 0 0 0-.41L4.26 8a1.67 1.67 0 0 1 0-2.37L5.6 4.26a1.65 1.65 0 0 1 1.18-.5 1.72 1.72 0 0 1 1.22.5l.74.74a.34.34 0 0 0 .37 0c.15-.06.26-.16.26-.3V3.68A1.69 1.69 0 0 1 11.06 2H13a1.68 1.68 0 0 1 1.68 1.68v1.09a.34.34 0 0 0 .22.29.38.38 0 0 0 .41 0l.69-.8a1.67 1.67 0 0 1 2.37 0l1.37 1.34a1.67 1.67 0 0 1 .5 1.19 1.63 1.63 0 0 1-.5 1.21l-.74.74a.34.34 0 0 0 0 .37c.06.15.16.26.3.26h1.09A1.69 1.69 0 0 1 22 11.06V13a1.68 1.68 0 0 1-1.68 1.68h-1.09a.34.34 0 0 0-.29.22.34.34 0 0 0 0 .37l.77.77a1.67 1.67 0 0 1 0 2.37l-1.31 1.33a1.65 1.65 0 0 1-1.18.5 1.72 1.72 0 0 1-1.19-.5l-.77-.74a.34.34 0 0 0-.37 0c-.15.06-.26.16-.26.3v1.09A1.69 1.69 0 0 1 12.94 22zm-1.57-2h1.26v-.77a2.33 2.33 0 0 1 1.46-2.14 2.36 2.36 0 0 1 2.59.47l.54.54.88-.88-.54-.55a2.34 2.34 0 0 1-.48-2.56 2.33 2.33 0 0 1 2.14-1.45H20v-1.29h-.77a2.33 2.33 0 0 1-2.14-1.46 2.36 2.36 0 0 1 .47-2.59l.54-.54-.88-.88-.55.54a2.39 2.39 0 0 1-4-1.67V4h-1.3v.77a2.33 2.33 0 0 1-1.46 2.14 2.36 2.36 0 0 1-2.59-.47l-.54-.54-.88.88.54.55a2.39 2.39 0 0 1-1.67 4H4v1.26h.77a2.33 2.33 0 0 1 2.14 1.46 2.36 2.36 0 0 1-.47 2.59l-.54.54.88.88.55-.54a2.39 2.39 0 0 1 4 1.67z" data-name="<Group>"/><path d="M12 15.5a3.5 3.5 0 1 1 3.5-3.5 3.5 3.5 0 0 1-3.5 3.5zm0-5a1.5 1.5 0 1 0 1.5 1.5 1.5 1.5 0 0 0-1.5-1.5z"/></g></g>', + 'share': '<g data-name="Layer 2"><g data-name="share"><rect width="24" height="24" opacity="0"/><path d="M18 15a3 3 0 0 0-2.1.86L8 12.34V12v-.33l7.9-3.53A3 3 0 1 0 15 6v.34L7.1 9.86a3 3 0 1 0 0 4.28l7.9 3.53V18a3 3 0 1 0 3-3z"/></g></g>', + 'share-outline': '<g data-name="Layer 2"><g data-name="share"><rect width="24" height="24" opacity="0"/><path d="M18 15a3 3 0 0 0-2.1.86L8 12.34V12v-.33l7.9-3.53A3 3 0 1 0 15 6v.34L7.1 9.86a3 3 0 1 0 0 4.28l7.9 3.53V18a3 3 0 1 0 3-3zm0-10a1 1 0 1 1-1 1 1 1 0 0 1 1-1zM5 13a1 1 0 1 1 1-1 1 1 0 0 1-1 1zm13 6a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/></g></g>', + 'shield-outline': '<g data-name="Layer 2"><g data-name="shield"><rect width="24" height="24" opacity="0"/><path d="M12 21.85a2 2 0 0 1-1-.25l-.3-.17A15.17 15.17 0 0 1 3 8.23v-.14a2 2 0 0 1 1-1.75l7-3.94a2 2 0 0 1 2 0l7 3.94a2 2 0 0 1 1 1.75v.14a15.17 15.17 0 0 1-7.72 13.2l-.3.17a2 2 0 0 1-.98.25zm0-17.7L5 8.09v.14a13.15 13.15 0 0 0 6.7 11.45l.3.17.3-.17A13.15 13.15 0 0 0 19 8.23v-.14z"/></g></g>', + 'slash-outline': '<g data-name="Layer 2"><g data-name="slash"><rect width="24" height="24" opacity="0"/><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm8 10a7.92 7.92 0 0 1-1.69 4.9L7.1 5.69A7.92 7.92 0 0 1 12 4a8 8 0 0 1 8 8zM4 12a7.92 7.92 0 0 1 1.69-4.9L16.9 18.31A7.92 7.92 0 0 1 12 20a8 8 0 0 1-8-8z"/></g></g>', + 'smartphone-outline': '<g data-name="Layer 2"><g data-name="smartphone"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M17 2H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3zm1 17a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z"/><circle cx="12" cy="16.5" r="1.5"/><path d="M14.5 6h-5a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2z"/></g></g>', + 'smiling-face-outline': '<defs><style></style></defs><g id="Layer_2" data-name="Layer 2"><g id="smiling-face"><g id="smiling-face" data-name="smiling-face"><rect width="24" height="24" opacity="0"/><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm5 9a5 5 0 0 1-10 0z" id="🎨-Icon-Сolor"/></g></g></g>', + 'star-outline': '<g data-name="Layer 2"><g data-name="star"><rect width="24" height="24" transform="rotate(90 12 12)" opacity="0"/><path d="M17.56 21a1 1 0 0 1-.46-.11L12 18.22l-5.1 2.67a1 1 0 0 1-1.45-1.06l1-5.63-4.12-4a1 1 0 0 1-.25-1 1 1 0 0 1 .81-.68l5.7-.83 2.51-5.13a1 1 0 0 1 1.8 0l2.54 5.12 5.7.83a1 1 0 0 1 .81.68 1 1 0 0 1-.25 1l-4.12 4 1 5.63a1 1 0 0 1-.4 1 1 1 0 0 1-.62.18zM12 16.1a.92.92 0 0 1 .46.11l3.77 2-.72-4.21a1 1 0 0 1 .29-.89l3-2.93-4.2-.62a1 1 0 0 1-.71-.56L12 5.25 10.11 9a1 1 0 0 1-.75.54l-4.2.62 3 2.93a1 1 0 0 1 .29.89l-.72 4.16 3.77-2a.92.92 0 0 1 .5-.04z"/></g></g>', + 'swap-outline': '<g data-name="Layer 2"><g data-name="swap"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M4 9h13l-1.6 1.2a1 1 0 0 0-.2 1.4 1 1 0 0 0 .8.4 1 1 0 0 0 .6-.2l4-3a1 1 0 0 0 0-1.59l-3.86-3a1 1 0 0 0-1.23 1.58L17.08 7H4a1 1 0 0 0 0 2z"/><path d="M20 16H7l1.6-1.2a1 1 0 0 0-1.2-1.6l-4 3a1 1 0 0 0 0 1.59l3.86 3a1 1 0 0 0 .61.21 1 1 0 0 0 .79-.39 1 1 0 0 0-.17-1.4L6.92 18H20a1 1 0 0 0 0-2z"/></g></g>', + 'text': '<g data-name="Layer 2"><g data-name="text"><rect width="24" height="24" opacity="0"/><path d="M20 4H4a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V6h6v13H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2h-2V6h6v2a1 1 0 0 0 2 0V5a1 1 0 0 0-1-1z"/></g></g>', + 'trash-2-outline': '<g data-name="Layer 2"><g data-name="trash-2"><rect width="24" height="24" opacity="0"/><path d="M21 6h-5V4.33A2.42 2.42 0 0 0 13.5 2h-3A2.42 2.42 0 0 0 8 4.33V6H3a1 1 0 0 0 0 2h1v11a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V8h1a1 1 0 0 0 0-2zM10 4.33c0-.16.21-.33.5-.33h3c.29 0 .5.17.5.33V6h-4zM18 19a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V8h12z"/><path d="M9 17a1 1 0 0 0 1-1v-4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1z"/><path d="M15 17a1 1 0 0 0 1-1v-4a1 1 0 0 0-2 0v4a1 1 0 0 0 1 1z"/></g></g>', + 'trending-up-outline': '<g data-name="Layer 2"><g data-name="trending-up"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M21 7a.78.78 0 0 0 0-.21.64.64 0 0 0-.05-.17 1.1 1.1 0 0 0-.09-.14.75.75 0 0 0-.14-.17l-.12-.07a.69.69 0 0 0-.19-.1h-.2A.7.7 0 0 0 20 6h-5a1 1 0 0 0 0 2h2.83l-4 4.71-4.32-2.57a1 1 0 0 0-1.28.22l-5 6a1 1 0 0 0 .13 1.41A1 1 0 0 0 4 18a1 1 0 0 0 .77-.36l4.45-5.34 4.27 2.56a1 1 0 0 0 1.27-.21L19 9.7V12a1 1 0 0 0 2 0V7z"/></g></g>', + 'undo': '<g data-name="Layer 2"><g data-name="undo"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M20.22 21a1 1 0 0 1-1-.76 8.91 8.91 0 0 0-7.8-6.69v1.12a1.78 1.78 0 0 1-1.09 1.64A2 2 0 0 1 8.18 16l-5.06-4.41a1.76 1.76 0 0 1 0-2.68l5.06-4.42a2 2 0 0 1 2.18-.3 1.78 1.78 0 0 1 1.09 1.64V7A10.89 10.89 0 0 1 21.5 17.75a10.29 10.29 0 0 1-.31 2.49 1 1 0 0 1-1 .76z"/></g></g>', + 'undo-outline': '<g data-name="Layer 2"><g data-name="undo"><rect width="24" height="24" transform="rotate(-90 12 12)" opacity="0"/><path d="M20.22 21a1 1 0 0 1-1-.76 8.91 8.91 0 0 0-7.8-6.69v1.12a1.78 1.78 0 0 1-1.09 1.64A2 2 0 0 1 8.18 16l-5.06-4.41a1.76 1.76 0 0 1 0-2.68l5.06-4.42a2 2 0 0 1 2.18-.3 1.78 1.78 0 0 1 1.09 1.64V7A10.89 10.89 0 0 1 21.5 17.75a10.29 10.29 0 0 1-.31 2.49 1 1 0 0 1-1 .76zm-9.77-9.5a11.07 11.07 0 0 1 8.81 4.26A9 9 0 0 0 10.45 9a1 1 0 0 1-1-1V6.08l-4.82 4.17 4.82 4.21v-2a1 1 0 0 1 1-.96z"/></g></g>', + 'unlock-outline': '<g data-name="Layer 2"><g data-name="unlock"><rect width="24" height="24" opacity="0"/><path d="M17 8h-7V6a2 2 0 0 1 4 0 1 1 0 0 0 2 0 4 4 0 0 0-8 0v2H7a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-8a3 3 0 0 0-3-3zm1 11a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z"/><path d="M12 12a3 3 0 1 0 3 3 3 3 0 0 0-3-3zm0 4a1 1 0 1 1 1-1 1 1 0 0 1-1 1z"/></g></g>', + 'video': '<g data-name="Layer 2"><g data-name="video"><rect width="24" height="24" opacity="0"/><path d="M21 7.15a1.7 1.7 0 0 0-1.85.3l-2.15 2V8a3 3 0 0 0-3-3H5a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h9a3 3 0 0 0 3-3v-1.45l2.16 2a1.74 1.74 0 0 0 1.16.45 1.68 1.68 0 0 0 .69-.15 1.6 1.6 0 0 0 1-1.48V8.63A1.6 1.6 0 0 0 21 7.15z"/></g></g>', + 'wifi-off-outline': '<g data-name="Layer 2"><g data-name="wifi-off"><rect width="24" height="24" opacity="0"/><circle cx="12" cy="19" r="1"/><path d="M12.44 11l-1.9-1.89-2.46-2.44-1.55-1.55-1.82-1.83a1 1 0 0 0-1.42 1.42l1.38 1.37 1.46 1.46 2.23 2.24 1.55 1.54 2.74 2.74 2.79 2.8 3.85 3.85a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42z"/><path d="M21.72 7.93A13.93 13.93 0 0 0 12 4a14.1 14.1 0 0 0-4.44.73l1.62 1.62a11.89 11.89 0 0 1 11.16 3 1 1 0 0 0 .69.28 1 1 0 0 0 .72-.31 1 1 0 0 0-.03-1.39z"/><path d="M3.82 6.65a14.32 14.32 0 0 0-1.54 1.28 1 1 0 0 0 1.38 1.44 13.09 13.09 0 0 1 1.6-1.29z"/><path d="M17 13.14a1 1 0 0 0 .71.3 1 1 0 0 0 .72-1.69A9 9 0 0 0 12 9h-.16l2.35 2.35A7 7 0 0 1 17 13.14z"/><path d="M7.43 10.26a8.8 8.8 0 0 0-1.9 1.49A1 1 0 0 0 7 13.14a7.3 7.3 0 0 1 2-1.41z"/><path d="M8.53 15.4a1 1 0 1 0 1.39 1.44 3.06 3.06 0 0 1 3.84-.25l-2.52-2.52a5 5 0 0 0-2.71 1.33z"/></g></g>', +}; diff --git a/frontend/src/app/guards/channel-manager.guard.ts b/frontend/src/app/guards/channel-manager.guard.ts new file mode 100644 index 00000000..124c56df --- /dev/null +++ b/frontend/src/app/guards/channel-manager.guard.ts @@ -0,0 +1,27 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +/** + * The manage page is moderator level and above (every tab of it is backed by + * an endpoint gated at least that high). Anyone else who lands on the URL is + * sent to the channel itself rather than to a page of 403 errors. The real + * enforcement is the backend's; this only keeps the UI honest. + */ +export const ChannelManagerGuard: CanActivateFn = async (route) => { + const router = inject(Router); + const authService = inject(AuthService); + const slug = route.paramMap.get('slug') ?? ''; + + try { + const user = await authService.loadUserInfo(); + const role = user?.channelRoles?.[slug]; + if (user?.globalRole === 'super_admin' || role === 'owner' || role === 'moderator') { + return true; + } + } catch { + // Not signed in or the request failed — fall through to the channel page, + // whose own guard handles the login redirect. + } + return router.createUrlTree(['/channel', slug]); +}; diff --git a/frontend/src/app/guards/super-admin.guard.ts b/frontend/src/app/guards/super-admin.guard.ts new file mode 100644 index 00000000..c4dfcf61 --- /dev/null +++ b/frontend/src/app/guards/super-admin.guard.ts @@ -0,0 +1,23 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +// Returns UrlTrees instead of navigating itself: a visitor whose session ended +// mid-edit used to be bounced to the marketing page with no word about it. +// AuthGuard runs first on this route and stores the returnUrl, so /login +// brings the operator back here after signing in. +export const SuperAdminGuard: CanActivateFn = async () => { + const router = inject(Router); + const authService = inject(AuthService); + + try { + const userInfo = await authService.loadUserInfo(); + if (userInfo?.globalRole === 'super_admin') { + return true; + } + // Signed in, but not an operator. + return router.createUrlTree(['/']); + } catch { + return router.createUrlTree(['/login']); + } +}; diff --git a/frontend/src/app/interceptors/channel-disabled.interceptor.ts b/frontend/src/app/interceptors/channel-disabled.interceptor.ts new file mode 100644 index 00000000..3bc3a5b6 --- /dev/null +++ b/frontend/src/app/interceptors/channel-disabled.interceptor.ts @@ -0,0 +1,54 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { ChannelStatusService } from '../services/channel-status.service'; + +const CHANNEL_API = /^\/api\/channel\/([^/?#]+)/; +// Only the channel's own info endpoint decides whether the channel exists: +// a 404 from any other route (a removed file, an unknown message id) says +// nothing about the channel itself. +const CHANNEL_INFO_API = /^\/api\/channel\/([^/?#]+)\/info$/; + +/** + * A disabled channel answers every /api/channel/{slug}/* request with + * 403 {"error":"channel_disabled"}. Detecting it once here means every caller + * benefits: the flag is raised centrally and ChannelComponent renders the + * "הערוץ מושבת" page, instead of each service surfacing its own broken state + * or a generic error toast. + * + * The error is still rethrown so existing per-caller handling (spinners, + * catch blocks) keeps working unchanged. + */ +export const channelDisabledInterceptor: HttpInterceptorFn = (req, next) => { + const channelStatus = inject(ChannelStatusService); + + return next(req).pipe( + catchError((err: unknown) => { + if (err instanceof HttpErrorResponse && err.status === 403) { + const match = CHANNEL_API.exec(new URL(req.url, window.location.origin).pathname); + if (match && isChannelDisabledBody(err.error)) { + channelStatus.markDisabled(decodeURIComponent(match[1])); + } + } + if (err instanceof HttpErrorResponse && err.status === 404) { + const match = CHANNEL_INFO_API.exec(new URL(req.url, window.location.origin).pathname); + if (match) { + channelStatus.markNotFound(decodeURIComponent(match[1])); + } + } + return throwError(() => err); + }) + ); +}; + +/** + * The body arrives parsed when the response carried a JSON content type, but a + * plain-text answer (or a blob request) leaves it as a string — accept both. + */ +function isChannelDisabledBody(body: unknown): boolean { + if (typeof body === 'string') { + return body.includes('channel_disabled'); + } + return !!body && typeof body === 'object' + && (body as { error?: unknown }).error === 'channel_disabled'; +} diff --git a/frontend/src/app/interceptors/session-expired.interceptor.ts b/frontend/src/app/interceptors/session-expired.interceptor.ts new file mode 100644 index 00000000..98a0528d --- /dev/null +++ b/frontend/src/app/interceptors/session-expired.interceptor.ts @@ -0,0 +1,57 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { NbToastrService } from '@nebular/theme'; +import { catchError, throwError } from 'rxjs'; +import { AuthService } from '../services/auth.service'; + +// Routes only a signed-in operator ever calls: the super-admin panel and a +// channel's own management API. A 401 there is a session that has ended, +// never an anonymous reader (readers have no way to reach these calls). +const OPERATOR_API = /^\/api\/(super-admin\/|channel\/[^/?#]+\/admin\/)/; + +// Several requests tend to fail together (a section loads two or three lists); +// one toast and one redirect are enough. +let lastRedirect = 0; + +/** + * A session that expires while the operator is mid-edit used to surface as + * the generic "השמירה לא הצליחה, נסו שוב" of whichever screen made the call, + * with no hint that signing in again is what is needed; the operator found + * out on the next full page load. Detecting it once here covers every + * super-admin and manage-page writer: say what happened, remember where they + * were, and send them to sign in — the login page brings them back. + * + * The error is still rethrown so the caller's own handling (spinners, + * dirty state) keeps working unchanged. + */ +export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => { + const router = inject(Router); + const toastr = inject(NbToastrService); + const authService = inject(AuthService); + + return next(req).pipe( + catchError((err: unknown) => { + if (err instanceof HttpErrorResponse && err.status === 401 + && OPERATOR_API.test(new URL(req.url, window.location.origin).pathname) + && !router.url.startsWith('/login') + && Date.now() - lastRedirect > 2000) { + lastRedirect = Date.now(); + // Before the redirect: the login page re-checks the session, and with + // the stale user still cached it would have sent the operator right + // back to the screen whose request just failed. + authService.sessionEnded(); + try { + localStorage.setItem('returnUrl', router.url); + } catch { + // Storage unavailable — the login page falls back to /channel. + } + // The toast comes after the navigation: Nebular mounts its overlays + // inside the page's <nb-layout>, and the panel's layout — with a toast + // raised now — is destroyed by the route change a moment later. + router.navigate(['/login']).then(() => setTimeout(() => toastr.warning('', 'ההתחברות פגה — יש להתחבר מחדש'))); + } + return throwError(() => err); + }) + ); +}; diff --git a/frontend/src/app/markdown.config.ts b/frontend/src/app/markdown.config.ts index db341e94..ed3759b6 100644 --- a/frontend/src/app/markdown.config.ts +++ b/frontend/src/app/markdown.config.ts @@ -1,37 +1,191 @@ -import { Token, Tokens, TokensList } from "marked"; +import { Hooks, Token, Tokens, TokensList } from "marked"; import { MarkdownModuleConfig, MARKED_EXTENSIONS, MARKED_OPTIONS, MarkedRenderer } from "ngx-markdown"; +import DOMPurify, { UponSanitizeAttributeHookEvent } from "dompurify"; -const matchCustomEmbedRegEx = /^\[(video|audio|image|quote)-embedded#]\((.*?)\)/; +/** + * Embed token: `[image-embedded#](url)`, optionally carrying the media's pixel + * size as `[image-embedded#800x600](url)`. + * + * The size group is optional so every message written before the backend + * started recording dimensions still tokenizes exactly as it did — it simply + * renders without an aspect ratio, as it always has. The size lives in the + * token rather than in the URL so the `src` stays byte-identical to the path + * the server handed out. + */ +const matchCustomEmbedRegEx = /^\[(video|audio|image|quote)-embedded#(\d+x\d+)?]\((.*?)\)/; -//https://regexr.com/3dj5t -const matchYoutubeRegEx = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)(?<id>[\w\-]+)(\S+)?$/; +/** + * The quote token is matched on its own, before the generic pattern, and runs + * to the last ')' on its line. The generic pattern is non-greedy, so a quoted + * text that itself contained ')' — "(ראו למטה)" — ended the token at that + * first ')' and the remainder of the quote leaked into the message as plain + * text. The composer always terminates a quote token with a newline + * (message.component.ts quoteMessage), so nothing else shares its line. + */ +// Greedy to the last ')' so a quoted text with parentheses is not cut, but +// only when the token ends the line (the composer always terminates it with a +// newline); a legacy message with reply text on the same line falls back to +// the non-greedy generic pattern below, or that text would be swallowed. +const matchQuoteEmbedRegEx = /^\[quote-embedded#]\(([^\n]*)\)[ \t]*(?=\n|$)/; + +/** + * The embed token's opening bracket, without the payload — for callers that + * only need to know a message *is* an embed and of which kind (capture group 1). + * + * Exported so it cannot drift from the tokenizer above. When the optional size + * was added, a private copy of this pattern in the message component still + * required `#]` immediately, so an image posted with its dimensions stopped + * being recognised and quoting one embedded raw markdown into the reply. + */ +export const EMBED_PREFIX_REGEX = /^\[(video|audio|image|quote)-embedded#(?:\d+x\d+)?]/; + +/** + * Only the URL forms that carry a video id: watch?v=, embed/, v/, shorts/ and + * youtu.be/, each followed by the 11-character id. The previous pattern + * (regexr.com/3dj5t) made the path segment optional, so youtube.com/shorts/ID, + * /playlist?list=… and /channel/UC… all matched with "shorts", "playlist" or + * "channel" captured as the id — a broken thumbnail and a player that could + * not play. Its trailing `$` (no `m` flag) also meant a URL followed by a line + * break and more text was never embedded at all; the lookahead accepts any + * whitespace, so the `breaks: true` line break no longer defeats it. The + * scheme stays optional, as before, so a bare www.youtube.com/… still embeds. + */ +const matchYoutubeRegEx = /^(?:(?:https?:)?\/\/)?(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?(?:[^\s#]*&)?v=|embed\/|v\/|shorts\/)|youtu\.be\/)(?<id>[\w-]{11})(?:[^\s]*)?(?=\s|$)/; + +/** + * Every image the feed renders goes out with native lazy loading: messages + * arrive 20 at a time through nbInfiniteList, but without this each one's + * media is fetched the moment the message enters the DOM, so a media-heavy + * channel opens by downloading every picture in the batch at once. + * + * `decoding="async"` keeps the decode off the main thread, so a picture + * arriving mid-scroll cannot stall the list. + * + * Lazy loading alone makes the feed grow as pictures arrive, which shoves the + * reader's position around. `sizeAttrs` below prevents that wherever the size + * is known. + */ +const LAZY_IMG_ATTRS = ' loading="lazy" decoding="async"'; + +/** + * Renders a `WxH` size token 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 land and nothing below it + * jumps when they do. + * + * An absent or malformed size yields no attributes at all — every message + * predating dimension recording, and any upload whose header could not be + * parsed, keeps exactly the old behaviour rather than getting a guessed ratio + * that would letterbox or distort it. + */ +function sizeAttrs(size: string | undefined): string { + const m = /^(\d+)x(\d+)$/.exec(size ?? ''); + if (!m) return ''; + const w = Number(m[1]), h = Number(m[2]); + if (!w || !h) return ''; + return ` width="${w}" height="${h}"`; +} + + +/** + * Everything below builds HTML by string concatenation and the feed renders it + * with `[disableSanitizer]="true"`, so Angular does not get a second look at + * it. Every value interpolated into markup therefore has to be neutralised + * here — this is the only line of defence. + * + * Without it, message text was executable: `[image-embedded#](x" onerror="...)` + * closed the src attribute and added an event handler, and the quote branch + * interpolated free text straight into the document. Any writer could run + * script in every viewer's browser, including a channel owner's or a super + * admin's. + */ +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Neutralises a URL destined for a src/href attribute. + * + * A scheme is only honoured when it is http or https, so `javascript:`, + * `data:` and `vbscript:` collapse to an empty attribute instead of executing. + * Relative URLs — which is what the upload path produces + * (/api/channel/<slug>/files/<id>) — carry no scheme and pass through. Control + * characters are dropped first, since `java\tscript:` is the same URL to a + * browser but not to a naive prefix test. + */ +function safeUrl(value: unknown): string { + return cleanUrl(value, /^https?$/i); +} + +/** + * The same, for an anchor's href, where mailto: and tel: are honoured as well. + * GFM autolinks turn office@example.com into a mailto: link, and safeUrl's + * http(s)-only rule blanked that href: the address rendered as a link that + * went nowhere. src attributes keep the strict rule — the browser would only + * ever be asked to *fetch* a src, and there is nothing to fetch from mailto:. + */ +function safeHref(value: unknown): string { + return cleanUrl(value, /^(https?|mailto|tel)$/i); +} + +function cleanUrl(value: unknown, allowedSchemes: RegExp): string { + const url = String(value ?? '').replace(/[\u0000-\u001F\u007F]/g, '').trim(); + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url); + if (scheme && !allowedSchemes.test(scheme[1])) return ''; + return escapeHtml(url); +} const customEmbedExtension = { extensions: [{ name: 'custom_embed', level: 'inline', - start: (src: string) => src.match(matchCustomEmbedRegEx)?.index ?? src.match(matchYoutubeRegEx)?.index, + start: (src: string) => src.match(matchQuoteEmbedRegEx)?.index ?? src.match(matchCustomEmbedRegEx)?.index ?? src.match(matchYoutubeRegEx)?.index, tokenizer: (src: string, tokens: Token[] | TokensList) => { + const quote = src.match(matchQuoteEmbedRegEx); + // The strict pattern runs to the LAST ')' of the line, so a legacy + // same-line reply that ends with a markdown link — + // `[quote-embedded#](12@abc) see [link](https://…)` — was swallowed + // whole: the link gone, its URL shown as quote text. Decide by what the + // strict match takes BEYOND the non-greedy one: a link there belongs to + // the reply, so the non-greedy branch below wins; a link inside the + // quoted text itself (old quotes, before the composer stripped + // parentheses) stays with the quote. + const loose = src.match(matchCustomEmbedRegEx); + if (quote && !(loose && quote[0].slice(loose[0].length).includes(']('))) { + const s = quote[1].split(/@(.*)/); + return { + type: 'custom_embed', + raw: quote[0], + meta: { type: 'quote', id: s[0], url: s[1] }, + }; + } + let match = src.match(matchCustomEmbedRegEx); if (match) { - switch (match[1]) { - case 'quote': - const s = match[2].split(/@(.*)/); - return { - type: 'custom_embed', - raw: match[0], - meta: { type: 'quote', id: s[0], url: s[1] }, - }; - - default: - return { - type: 'custom_embed', - raw: match[0], - meta: { type: match[1], url: match[2] }, - }; + if (match[1] === 'quote') { + // A legacy quote with reply text on the same line lands here (the + // strict pattern above needs the token to end its line). Its payload + // is still `id@text`; handing it over unsplit rendered an empty + // quote-id and the raw "12@abc" as the quote's text. + const s = match[3].split(/@(.*)/); + return { + type: 'custom_embed', + raw: match[0], + meta: { type: 'quote', id: s[0], url: s[1] }, + }; } - + return { + type: 'custom_embed', + raw: match[0], + meta: { type: match[1], url: match[3], size: match[2] }, + }; } match = src.match(matchYoutubeRegEx); @@ -46,19 +200,32 @@ const customEmbedExtension = { return undefined; }, renderer: (token: Tokens.Generic) => { - const { type, url, id } = token['meta']; + const { type, url, id, size } = token['meta']; + // No inline style anywhere below: the sanitizer strips every `style` + // attribute (see sanitizeFeedHtml), so the embeds' own sizing lives in + // the embed-* classes, defined in message.component.scss and listed in + // FEED_CLASSES. switch (type) { case 'video': - return `<div style="max-width: 300px; height: auto;"><video controls style="width: 100%; height: auto;"><source src="${url}" type="video/mp4"></video></div>`; + // metadata, not auto: enough for the poster frame and duration + // without pulling the whole file for a message nobody scrolled to. + // The size token is not emitted for video, so this is inert today; + // it costs nothing and lets a recorded size reserve the player box + // if the upload path ever measures video too. + return `<div class="embed-box"><video controls preload="metadata" class="embed-video"${sizeAttrs(size)}><source src="${safeUrl(url)}" type="video/mp4"></video></div>`; case 'audio': - return `<div><audio src="${url}" controls></audio></div>`; + return `<div><audio src="${safeUrl(url)}" controls preload="metadata"></audio></div>`; case 'image': - return `<div style="max-width: 300px; height: auto;"><img src="${url}" class="img-fluid" width="300px"></div>`; + // The real size when the upload path recorded it, so the box + // reserves the right height; the old flat width="300" otherwise. + return `<div class="embed-box"><img src="${safeUrl(url)}"${LAZY_IMG_ATTRS} class="img-fluid"${sizeAttrs(size) || ' width="300"'}></div>`; case 'youtube': - return `<div style="position: relative; max-width: 300px; height: auto;"><img youtubeid="${id}" src="https://ytimg.googleusercontent.com/vi/${id}/hqdefault.jpg" class="img-fluid" width="300px"><i - class="bi bi-youtube" youtubeid="${id}" style="position: absolute; place-self: anchor-center; color: red; font-size: 70px;"></i></div>`; + // The play badge is drawn in CSS on .embed-youtube-icon (message + // component); the icon font that used to render it is gone. + return `<div class="embed-box embed-youtube"><img youtubeid="${escapeHtml(id)}" src="https://ytimg.googleusercontent.com/vi/${encodeURIComponent(String(id))}/hqdefault.jpg"${LAZY_IMG_ATTRS} class="img-fluid" width="300" height="225"><i + class="embed-youtube-icon" youtubeid="${escapeHtml(id)}" role="button" aria-label="הפעלת הסרטון"></i></div>`; case 'quote': - return `<blockquote class="quote" quote-id="${id}"><p>${url}</p></blockquote>`; + return `<blockquote class="quote" quote-id="${escapeHtml(id)}"><p>${escapeHtml(url)}</p></blockquote>`; default: return ''; } @@ -70,10 +237,118 @@ const renderer = new MarkedRenderer(); //https://github.com/jfcere/ngx-markdown/issues/79#issuecomment-2484682034 renderer.link = ({ href, text }) => { - return `<a target="_blank" href="${href}">${text}</a>`; + // Overriding marked's link renderer also discards its escaping and its + // cleanUrl scheme check, so both have to be reapplied here. + const url = safeHref(href); + // mailto:/tel: hand off to the mail or phone app; opened in a new tab they + // leave an empty tab behind. Everything else — including uploaded files at + // /api/... — still opens in a new tab so the reader keeps their place. + const target = /^(mailto|tel):/i.test(url) ? '' : ' target="_blank" rel="noopener noreferrer"'; + return `<a${target} href="${url}">${escapeHtml(text)}</a>`; +} + +// Plain markdown images (![alt](url)) bypass the custom_embed extension, so +// they need the same lazy treatment — this is the second and last place the +// feed can emit an <img>. +renderer.image = ({ href, title, text }) => { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''; + return `<img src="${safeUrl(href)}" alt="${escapeHtml(text)}"${titleAttr}${LAZY_IMG_ATTRS} class="img-fluid">`; } //renderer.paragraph = ({ tokens }) => Parser.parseInline(tokens); +/** + * Final sanitization of the rendered feed HTML. + * + * The feed binds this output with `[disableSanitizer]="true"`, so Angular never + * gets a second look — and marked passes RAW HTML in the message body straight + * through its default renderer. Per-value escaping in the custom renderers above + * only covers what THEY build; a message body of `<img src=x onerror=alert(1)>` + * or `<a href="javascript:...">` reached the DOM verbatim and executed. This is + * the actual trust boundary, and it has to allow the small set of markup the + * feed legitimately produces (the media/quote/link embeds, and the `<u>` the + * formatting toolbar inserts) while removing everything dangerous. + * + * DOMPurify strips event handlers and javascript:/data: URLs by default; the + * config below additionally forbids elements the feed never needs and that are + * common injection vectors, and re-permits the two non-standard attributes the + * embeds rely on (youtubeid, quote-id) plus target/rel on links. + */ +/** + * DOMPurify allows `data:` URIs on media tags (img/video/audio/source) + * independent of ALLOWED_URI_REGEXP, as a legitimate way to inline images. The + * feed never inlines media that way — every image is a relative /api/... URL or + * an https thumbnail — so this hook strips any src that is not http(s) or + * relative, and any href that is not that, mailto: or tel: (the schemes + * safeHref honours). A `data:text/html` src on an img is inert on its own (an + * img never runs its src as a document), but removing it leaves no room for + * doubt and matches the backend's own scheme allow-list. + */ +const URL_ATTR_SCHEMES: ReadonlyArray<[string, RegExp]> = [ + ['src', /^https?$/i], + ['href', /^(https?|mailto|tel)$/i], +]; + +DOMPurify.addHook('afterSanitizeAttributes', (node: Element) => { + for (const [attr, allowed] of URL_ATTR_SCHEMES) { + const value = node.getAttribute?.(attr); + if (value === null || value === undefined) continue; + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value.trim()); + if (scheme && !allowed.test(scheme[1])) { + node.removeAttribute(attr); + } + } +}); + +/** + * The only classes the feed's own markup uses: what the renderers above emit, + * plus the `language-*` marker marked puts on fenced code for Prism. + * + * bootstrap.min.css is loaded globally, so a `class` attribute in raw HTML + * handed a writer the whole layout toolkit: `<div class="position-fixed top-0 + * start-0 vw-100 vh-100 bg-white z-3">` covered the page for every viewer, and + * a `style` attribute did the same with no framework at all — a phishing + * overlay in a message. `style` is therefore forbidden outright in + * sanitizeFeedHtml, and a `class` attribute survives only when every one of + * its tokens is on this list; otherwise the attribute is dropped and the + * element itself (the `<u>`, `<b>`, `<br>` older messages carry) stays as it + * was. + */ +const FEED_CLASSES: ReadonlySet<string> = new Set([ + 'img-fluid', 'quote', 'bi', 'bi-youtube', + 'embed-box', 'embed-video', 'embed-youtube', 'embed-youtube-icon', +]); +const FEED_CLASS_PATTERN = /^language-[\w-]+$/; + +DOMPurify.addHook('uponSanitizeAttribute', (_node: Element, data: UponSanitizeAttributeHookEvent) => { + if (data.attrName !== 'class') return; + const tokens = data.attrValue.split(/\s+/).filter(Boolean); + if (!tokens.every(t => FEED_CLASSES.has(t) || FEED_CLASS_PATTERN.test(t))) { + data.keepAttr = false; + } +}); + +function sanitizeFeedHtml(html: string): string { + return DOMPurify.sanitize(html, { + // Non-standard attributes the embed renderers emit and the message + // component reads (message.component.ts reads youtubeid); target/rel are on + // the link renderer's anchors. + ADD_ATTR: ['youtubeid', 'quote-id', 'target', 'rel'], + // The feed emits none of these, and each is a classic injection vector. + // Event handlers and dangerous URL schemes are removed by DOMPurify + // regardless of this list. + FORBID_TAGS: ['style', 'iframe', 'form', 'input', 'button', 'object', 'embed', 'svg', 'math', 'link', 'meta', 'base'], + // Inline style is a page-wide overlay waiting to happen (see FEED_CLASSES); + // nothing the feed renders needs it. + FORBID_ATTR: ['style'], + // Only the schemes the feed actually uses: https/http (youtube, external + // links), mailto and tel (autolinked addresses and numbers), and relative + // URLs (uploaded files are /api/channel/...). This also removes the + // inert-but-pointless data: image case, matching the backend's own safeUrl + // scheme allow-list. + ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, + }) as unknown as string; +} + export const MarkdownConfig: MarkdownModuleConfig = { markedExtensions: [ { @@ -87,6 +362,23 @@ export const MarkdownConfig: MarkdownModuleConfig = { useValue: { renderer: renderer, breaks: true, + // Sanitize after marked has produced the HTML — including any raw HTML in + // the message body — so it is the last thing before the string is bound. + // + // This MUST be a real marked Hooks instance, not a plain + // `{ postprocess }` object. ngx-markdown passes markedOptions straight to + // marked.parse(text, options), and marked v15 there calls every hook + // method (provideLexer, preprocess, processAllTokens, ...) on the object; + // a plain object missing them throws "opt.hooks.provideLexer is not a + // function" on EVERY message, rendering them all empty. A Hooks instance + // carries the defaults and we override only postprocess. + hooks: buildSanitizingHooks(), }, } +} + +function buildSanitizingHooks(): Hooks { + const hooks = new Hooks(); + hooks.postprocess = (html: string) => sanitizeFeedHtml(html); + return hooks; } \ No newline at end of file diff --git a/frontend/src/app/models/channel-user.model.ts b/frontend/src/app/models/channel-user.model.ts new file mode 100644 index 00000000..63276689 --- /dev/null +++ b/frontend/src/app/models/channel-user.model.ts @@ -0,0 +1,5 @@ +// An empty role revokes the user's channel privileges on save. +export interface ChannelUser { + email: string; + role: 'owner' | 'moderator' | 'writer' | ''; +} diff --git a/frontend/src/app/models/channel.model.ts b/frontend/src/app/models/channel.model.ts index 8efc8e30..c0a9ac43 100644 --- a/frontend/src/app/models/channel.model.ts +++ b/frontend/src/app/models/channel.model.ts @@ -1,5 +1,16 @@ +// Operator-controlled toggles reported by GET /api/channel/{slug}/info. Every +// key is optional: an older backend (or a stale cached response) omits them, and +// the UI must then behave as if the features are on rather than hiding controls. +export interface ChannelFeatureFlags { + reactions?: boolean; + fileUploads?: boolean; + reports?: boolean; + scheduledMessages?: boolean; +} + export interface Channel { - id?: number; + // The backend serialises the channel slug into the "id" field (as a string). + id?: string; name?: string; description?: string; created_at?: string; @@ -7,4 +18,18 @@ export interface Channel { views?: number; require_auth_for_view_files?: boolean; contact_us?: string; -} \ No newline at end of file + features?: ChannelFeatureFlags; + // Search-engine settings (backend/channel_seo.go). Absent means listed, + // with the name and description as the search result's text. + require_auth?: boolean; + seo_hidden?: boolean; + seo_title?: string; + seo_description?: string; +} + +/** The search part of a channel edit, as the backend's request names it. */ +export interface ChannelSeoSettings { + seoHidden: boolean; + seoTitle: string; + seoDescription: string; +} diff --git a/frontend/src/app/models/my-channel.model.ts b/frontend/src/app/models/my-channel.model.ts new file mode 100644 index 00000000..19e2d691 --- /dev/null +++ b/frontend/src/app/models/my-channel.model.ts @@ -0,0 +1,26 @@ +export type ChannelRole = 'owner' | 'moderator' | 'writer'; + +/** One row of GET /api/my-channels — a channel the signed-in user holds a role on. */ +export interface MyChannel { + slug: string; + name: string; + description: string; + logoUrl: string; + role: ChannelRole; + createdAt: string; + /** Switched off by the platform operator; readers get "הערוץ מושבת". */ + disabled: boolean; + /** Signed-in readers who ever opened the channel. */ + participants: number; +} + +export const ROLE_LABELS: Record<ChannelRole, string> = { + owner: 'בעלים', + moderator: 'מנהל', + writer: 'כותב', +}; + +/** Whether the role may open the manage page (moderator level and above). */ +export function canManage(role: ChannelRole | string | undefined): boolean { + return role === 'owner' || role === 'moderator'; +} diff --git a/frontend/src/app/models/user.model.ts b/frontend/src/app/models/user.model.ts index 9375d19d..39a54873 100644 --- a/frontend/src/app/models/user.model.ts +++ b/frontend/src/app/models/user.model.ts @@ -1,7 +1,11 @@ export interface User { - id: string; - username: string; - picture: string; - privileges: Record<string, boolean>; - email: string; -} \ No newline at end of file + id: string; + username: string; + email: string; + picture: string; + publicName: string; + globalRole: string; + channelRoles: Record<string, string>; + // Legacy field for backwards compatibility + privileges?: Record<string, boolean>; +} diff --git a/frontend/src/app/pipes/message-time.pipe.ts b/frontend/src/app/pipes/message-time.pipe.ts index d6f469e6..8b161eff 100644 --- a/frontend/src/app/pipes/message-time.pipe.ts +++ b/frontend/src/app/pipes/message-time.pipe.ts @@ -1,31 +1,57 @@ import { Pipe, PipeTransform } from '@angular/core'; -import moment from 'moment'; -import 'moment/locale/he' -moment.defineLocale('he', {}); +// Built once: Intl formatters are expensive to construct and the pipe runs +// for every message on every change-detection pass. +const TIME = new Intl.DateTimeFormat('he-IL', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }); +const WEEKDAY = new Intl.DateTimeFormat('he-IL', { weekday: 'long' }); +const DAY_MS = 86_400_000; +function startOfDay(d: Date): number { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +/** Whole calendar days from `now` to `d` (negative = past), in local time. */ +export function calendarDayDiff(d: Date, now: Date = new Date()): number { + return Math.round((startOfDay(d) - startOfDay(now)) / DAY_MS); +} + +export function formatDayMonthYear(d: Date): string { + return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`; +} + +export function formatWeekday(d: Date): string { + return WEEKDAY.format(d); +} + +/** The absolute moment, "3.3.2026 14:05" — the tooltip behind every relative label. */ +export function formatFullDateTime(d: Date): string { + return `${formatDayMonthYear(d)} ${TIME.format(d)}`; +} + +/** + * Relative, Hebrew message time: "14:05" today, "אתמול 14:05", the weekday + * within the last six days, "מחר 14:05" / weekday / full date for scheduled + * (future) entries, and "3.3.2026 14:05" otherwise. The same calendar rules + * moment's he locale applied, without the ~70 KB moment bundle: Intl is in + * every browser already. + */ @Pipe({ name: 'messageTime' }) export class MessageTimePipe implements PipeTransform { - transform(value: any | string, ...args: unknown[]): any { - let m = moment(value); - const today = moment().startOf('day'); - const messageDay = m.clone().startOf('day'); - const daysDiff = messageDay.diff(today, 'days'); - switch (true) { - case daysDiff > 0: // Future - return m.calendar(); - case daysDiff == 0: // Today - return m.format('LT'); - case daysDiff == -1: // Yesterday - return m.calendar(); - case daysDiff >= -6: // This week - return `${m.format('dddd')} ${m.format('LT')}`; - default: - return m.format('L LT'); - } + transform(value: unknown): string { + if (value === null || value === undefined || value === '') return ''; + const d = value instanceof Date ? value : new Date(value as string | number); + if (isNaN(d.getTime())) return ''; + + const time = TIME.format(d); + const diff = calendarDayDiff(d); + if (diff === 0) return time; + if (diff === -1) return `אתמול ${time}`; + if (diff === 1) return `מחר ${time}`; + if (diff >= -6 && diff <= 6) return `${WEEKDAY.format(d)} ${time}`; + return `${formatDayMonthYear(d)} ${time}`; } } diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts index 34aa77dd..5eb04d1f 100644 --- a/frontend/src/app/services/admin.service.ts +++ b/frontend/src/app/services/admin.service.ts @@ -6,19 +6,22 @@ import { ResponseResult } from '../models/response-result.model'; import { Setting } from '../models/setting.model'; import { Reports, Report } from '../models/report.model'; import { Statistics } from '../models/statistics.model'; +import { SlugService } from './slug.service'; +import { ChannelUser } from '../models/channel-user.model'; -export interface PrivilegeUser { - id?: string; - username: string; - email: string; - publicName: string; - privileges: Record<string, boolean>; -} +export type { ChannelUser }; export type EditMsg = { new?: boolean; isScheduling?: boolean; message: ChatMessage; + /** + * Text to put in front of what is typed in the open editor, leaving + * `message` untouched. A quote used to be written into the edit target's + * own text, and a scheduled entry is found again on the server by that + * text — so the save failed once it carried the quote. + */ + prepend?: string; } @Injectable({ @@ -35,8 +38,15 @@ export class AdminService { constructor( private http: HttpClient, + private slugService: SlugService, ) { } + private get slug() { return this.slugService.slug; } + + clearCache() { + this.schedulingMessages = null; + } + reloadSchedulingMessage() { this.schedulingBus.next(); } @@ -50,55 +60,68 @@ export class AdminService { } getStatistics(): Promise<Statistics> { - return firstValueFrom(this.http.get<Statistics>('/api/admin/statistics')); - } - - resetPeakStatistics(): Promise<ResponseResult> { - return firstValueFrom(this.http.post<ResponseResult>('/api/admin/statistics/reset', {})); + return firstValueFrom(this.http.get<Statistics>(`/api/channel/${this.slug}/admin/statistics`)); } addMessage(message: ChatMessage): Observable<ChatMessage> { - return this.http.post<ChatMessage>('/api/admin/new', message); + return this.http.post<ChatMessage>(`/api/channel/${this.slug}/admin/new`, message); } - editMessage(message: ChatMessage): Observable<ChatMessage> { - return this.http.post<ChatMessage>(`/api/admin/edit-message`, message); + // Both endpoints answer with the {success} envelope, not the message itself. + editMessage(message: ChatMessage): Observable<ResponseResult> { + return this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/edit-message`, message); } - deleteMessage(id: number | undefined): Observable<ChatMessage> { - return this.http.get<ChatMessage>(`/api/admin/delete-message/${id}`); + deleteMessage(id: number | undefined): Observable<ResponseResult> { + return this.http.delete<ResponseResult>(`/api/channel/${this.slug}/admin/delete-message/${id}`); } uploadFile(formData: FormData) { - return this.http.post<ChatFile>('/api/admin/upload', formData, { + return this.http.post<ChatFile>(`/api/channel/${this.slug}/admin/upload`, formData, { reportProgress: true, observe: 'events', responseType: 'json' }); } - getPrivilegeUsersList(): Promise<PrivilegeUser[]> { - return firstValueFrom(this.http.get<PrivilegeUser[]>('/api/admin/privilegs-users/get-list')); + getChannelUsers(): Promise<ChannelUser[]> { + return firstValueFrom(this.http.get<ChannelUser[]>(`/api/channel/${this.slug}/admin/users/get`)); } - setPrivilegeUsers(privilegeUsers: PrivilegeUser[]): Promise<ResponseResult> { - return firstValueFrom(this.http.post<ResponseResult>('/api/admin/privilegs-users/set', { list: privilegeUsers })); + setChannelUsers(users: ChannelUser[]): Promise<ResponseResult> { + return firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/users/set`, { users })); } setEmojis(emojis: string[] | undefined) { - return firstValueFrom(this.http.post<ResponseResult>('/api/admin/set-emojis', { emojis })); + return firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/set-emojis`, { emojis })); } getSettings(): Promise<Setting[]> { - return firstValueFrom(this.http.get<Setting[]>('/api/admin/settings/get')); + return firstValueFrom(this.http.get<Setting[]>(`/api/channel/${this.slug}/admin/settings/get`)); } setSettings(settings: Setting[]): Promise<ResponseResult> { - return firstValueFrom(this.http.post<ResponseResult>('/api/admin/settings/set', settings)); + return firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/settings/set`, settings)); + } + + // The channel's own Magnet numbers. Every one of these answers 403 + // magnet_locked on a channel locked to the platform's Magnet settings, and + // the key itself never comes back — only whether one is stored. + + getMagnetStatsKey(): Promise<{ statsKeySet: boolean }> { + return firstValueFrom(this.http.get<{ statsKeySet: boolean }>(`/api/channel/${this.slug}/admin/magnet/stats-key`)); + } + + setMagnetStatsKey(body: { statsKey?: string; clearStatsKey?: boolean }): Promise<{ statsKeySet: boolean }> { + return firstValueFrom(this.http.post<{ statsKeySet: boolean }>(`/api/channel/${this.slug}/admin/magnet/stats-key`, body)); + } + + getMagnetStats(): Promise<unknown> { + return firstValueFrom(this.http.get<unknown>(`/api/channel/${this.slug}/admin/magnet/stats`)); } getReports(status: string): Promise<Reports> { - return firstValueFrom(this.http.get<Reports>('/api/admin/reports/get', { + return firstValueFrom(this.http.get<Reports>(`/api/channel/${this.slug}/admin/reports/get`, { params: { status: status } @@ -106,7 +129,11 @@ export class AdminService { } setReports(report: Report): Promise<ResponseResult> { - return firstValueFrom(this.http.post<ResponseResult>('/api/admin/reports/set', report)); + return firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/reports/set`, report)); + } + + private fetchScheduledMessages(): Promise<ChatMessage[]> { + return firstValueFrom(this.http.get<ChatMessage[]>(`/api/channel/${this.slug}/admin/scheduled-messages/get`)); } async getScheduledMessages(reload?: boolean): Promise<ChatMessage[]> { @@ -115,31 +142,91 @@ export class AdminService { } try { - this.schedulingMessages = await firstValueFrom(this.http.get<ChatMessage[]>('/api/admin/scheduled-messages/get')); + this.schedulingMessages = await this.fetchScheduledMessages(); return this.schedulingMessages; } catch { return this.schedulingMessages || []; } } - setScheduledMessage(message: ChatMessage): Promise<ResponseResult> { - this.schedulingMessages?.unshift(message); - return this.updateSchedulingMessages(); - } - - editScheduledMessage(message: ChatMessage): Promise<ResponseResult> { - if (message.id === undefined || !this.schedulingMessages) return Promise.reject('Message ID is undefined'); - this.schedulingMessages[message.id] = message; - return this.updateSchedulingMessages(); - } - - deleteScheduledMessage(id: number | undefined): Promise<ResponseResult> { - if (id === undefined || !this.schedulingMessages) return Promise.reject('Message ID is undefined'); - this.schedulingMessages.splice(id, 1); - return this.updateSchedulingMessages(); - } - - private updateSchedulingMessages(): Promise<ResponseResult> { - return firstValueFrom(this.http.post<ResponseResult>('/api/admin/scheduled-messages/update', this.schedulingMessages)); + async setScheduledMessage(message: ChatMessage): Promise<ResponseResult> { + // The update endpoint replaces the whole list, so it is built on the + // server's current copy, never on the cache. The cache is refreshed only + // by the dispatch event, and a tab that missed it (an SSE gap, or the + // event skipped while hasNewMessages is set) re-posted the already + // published entry with its past timestamp — and the dispatcher posted it + // a second time. A failed load rejects instead of saving, as before an + // empty/null cache posted an empty body (400) or wiped the list. + const list = await this.fetchScheduledMessages(); + this.schedulingMessages = list; + return this.commitSchedulingMessages([message, ...list]); + } + + /** + * `original` is the entry object the feed displayed (the one the editor was + * opened on); `updated` is the copy to store in its place. + */ + async editScheduledMessage(original: ChatMessage | undefined, updated: ChatMessage): Promise<ResponseResult> { + const { list, at } = await this.locateScheduled(original); + if (at < 0) return Promise.reject('Scheduled message is no longer in the list'); + const next = list.slice(); + next[at] = updated; + return this.commitSchedulingMessages(next); + } + + async deleteScheduledMessage(original: ChatMessage | undefined): Promise<ResponseResult> { + const { list, at } = await this.locateScheduled(original); + if (at < 0) return Promise.reject('Scheduled message is no longer in the list'); + return this.commitSchedulingMessages(list.filter((_, i) => i !== at)); + } + + /** + * Re-reads the list from the server and finds the entry the caller holds — + * the feed's own object — by its time and text. The key used to be the + * entry's index in the CACHED list, but the cache is replaced under an open + * editor (every SSE reconnect reloads it) and after a rejected save while + * the feed keeps its older array, and once a dispatched entry earlier in the + * list had shifted the indexes, that index resolved to a neighbour. The + * object the user actually saw cannot drift that way. -1 means it is no + * longer scheduled (dispatched, or removed by another writer); the feed is + * then refreshed from the fresh list so the stale row disappears. + */ + private async locateScheduled(wanted: ChatMessage | undefined): Promise<{ list: ChatMessage[]; at: number }> { + const list = await this.fetchScheduledMessages(); + this.schedulingMessages = list; + if (!wanted) return { list, at: -1 }; + // The timestamp is a string off the wire but may be a Date on a client + // copy kept after a failed re-read, so both are compared as epoch millis + // (NaN never equals itself, hence the fallback for an unparsable value). + const when = (m: ChatMessage) => new Date(m.timestamp as any).getTime() || 0; + const at = list.findIndex(m => when(m) === when(wanted) && m.text === wanted.text); + if (at < 0) this.reloadSchedulingMessage(); + return { list, at }; + } + + /** + * Copy, post, then commit. The list is shared with the feed's scheduled + * section, so mutating it before the POST left every viewer of it with a + * list the server had rejected. Callers refresh the feed through + * reloadSchedulingMessage() once this resolves. + */ + private async commitSchedulingMessages(next: ChatMessage[]): Promise<ResponseResult> { + let res: ResponseResult; + try { + res = await firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/scheduled-messages/update`, next)); + } catch (e) { + // Any non-2xx — including the 503 the server answers while the + // dispatcher holds the list's lock — leaves the server's list as it + // was, but the cache was already replaced by the fresh read above while + // the feed still shows its older array. Both are realigned here, so a + // retry acts on what is on screen and no stale row lingers. + this.schedulingMessages = await this.fetchScheduledMessages().catch(() => this.schedulingMessages); + this.reloadSchedulingMessage(); + throw e; + } + // The server stamps fields the client does not know (the scheduler's + // name), so the cache is re-read rather than kept as the client copy. + this.schedulingMessages = await this.fetchScheduledMessages().catch(() => next); + return res; } } diff --git a/frontend/src/app/services/ads.service.ts b/frontend/src/app/services/ads.service.ts index eb465ad6..fb90c36c 100644 --- a/frontend/src/app/services/ads.service.ts +++ b/frontend/src/app/services/ads.service.ts @@ -1,10 +1,15 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; +import { SlugService } from './slug.service'; export interface Ad { src: string; width: number; // Width in pixels + // True when the super admin locked the iframe ad (globally or for this + // channel): src/width are then the global ones and the channel's own + // ad-iframe-* settings are ignored. Optional — an older backend omits it. + locked?: boolean; } @Injectable({ @@ -13,10 +18,11 @@ export interface Ad { export class AdsService { constructor( - private http: HttpClient + private http: HttpClient, + private slugService: SlugService, ) { } async getAds(): Promise<Ad> { - return firstValueFrom(this.http.get<Ad>('/api/ads/settings')) + return firstValueFrom(this.http.get<Ad>(`/api/channel/${this.slugService.slug}/ads/settings`)); } } diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index ecb32b52..fb239688 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -14,6 +14,14 @@ interface GoogleAuthValues { }) export class AuthService { public userInfo?: User; + // One in-flight /api/user-info shared by every concurrent caller: each + // rendered message asks on first paint, and only a success was memoised, so + // an anonymous visitor issued one request per message on the page and one + // more per message arriving over SSE. + private userInfoRequest?: Promise<User | undefined>; + // The 401 that answered "anonymous", replayed to later callers (they branch + // on err.status) until login/logout/reload drops it. + private anonymousError?: unknown; constructor( private _http: HttpClient, @@ -40,6 +48,10 @@ export class AuthService { } async login(code: string) { + // The login page's own check has just memoised "anonymous"; once the code + // is exchanged for a session that answer is stale, and the page re-reads + // user-info right after this call. + this.forgetUserInfo(); try { let res = await firstValueFrom(this._http.post<ResponseResult>('/auth/login', { code })); return res.success; @@ -52,18 +64,65 @@ export class AuthService { async logout() { let res = await firstValueFrom(this._http.post<ResponseResult>('/auth/logout', {})); if (res.success) { - this.userInfo = undefined; + this.forgetUserInfo(); } return res.success; } - async loadUserInfo() { - try { - this.userInfo = this.userInfo || await firstValueFrom(this._http.get<User>('/api/user-info')) - } catch (err: any) { - this.userInfo = undefined; - throw err; + async loadUserInfo(): Promise<User | undefined> { + if (this.userInfo) return this.userInfo; + if (this.anonymousError !== undefined) throw this.anonymousError; + if (!this.userInfoRequest) { + const request: Promise<User | undefined> = firstValueFrom(this._http.get<User>('/api/user-info')) + .then(user => { + this.userInfo = user; + return user; + }, (err: any) => { + this.userInfo = undefined; + if (err?.status === 401) this.anonymousError = err; + throw err; + }) + .finally(() => { + // Only release our own slot: reloadUserInfo may already have started + // a newer request that has to stay the shared one. + if (this.userInfoRequest === request) this.userInfoRequest = undefined; + }); + this.userInfoRequest = request; } - return this.userInfo; + return this.userInfoRequest; + } + + /** + * The server answered 401 to a signed-in call: the session ended behind the + * cached user. Dropping the cache here matters — the login page and the + * guards trust loadUserInfo(), and with the stale user still cached the + * login page sent the operator straight back to the panel, whose first + * request got 401 again, and so on in a loop. + */ + sessionEnded() { + this.forgetUserInfo(); + } + + private forgetUserInfo() { + this.userInfo = undefined; + this.anonymousError = undefined; + this.userInfoRequest = undefined; + } + + /** + * loadUserInfo() memoises `userInfo`, so after something changes the user's + * roles server-side (opening a channel makes them its owner) a plain re-read + * hands back the stale object. Drop the cache first and refetch. + */ + async reloadUserInfo() { + this.forgetUserInfo(); + return this.loadUserInfo(); + } + + // Fire-and-forget visit registration: the channel-scoped user-info handler is + // what writes the viewer into channel:<slug>:registered_emails, which feeds + // the participant counter. Errors (e.g. anonymous visitor) are irrelevant. + registerChannelVisit(slug: string): void { + firstValueFrom(this._http.get<User>(`/api/channel/${slug}/user-info`)).catch(() => { }); } } diff --git a/frontend/src/app/services/channel-status.service.ts b/frontend/src/app/services/channel-status.service.ts new file mode 100644 index 00000000..a116bb63 --- /dev/null +++ b/frontend/src/app/services/channel-status.service.ts @@ -0,0 +1,49 @@ +import { Injectable, signal } from '@angular/core'; + +/** + * Central holder for the "this channel was switched off by an operator" state. + * + * A disabled channel makes every /api/channel/{slug}/* call answer 403 with + * {"error":"channel_disabled"}. That hits many callers at once (chat, ads, + * notifications, user-info…), so the detection lives in the HTTP interceptor + * (channel-disabled.interceptor.ts) and lands here; the channel view renders + * off this signal instead of every caller growing its own error branch. + */ +@Injectable({ providedIn: 'root' }) +export class ChannelStatusService { + private readonly _disabledSlug = signal<string | null>(null); + private readonly _notFoundSlug = signal<string | null>(null); + + /** Slug of the channel that answered channel_disabled, or null. */ + readonly disabledSlug = this._disabledSlug.asReadonly(); + + /** + * Slug whose /info answered 404, or null. A mistyped or deleted channel + * address used to render the full chat shell with a "loading failed" + * banner, as if the server were down; this lets the page say plainly that + * there is no such channel. + */ + readonly notFoundSlug = this._notFoundSlug.asReadonly(); + + markDisabled(slug: string): void { + if (this._disabledSlug() !== slug) { + this._disabledSlug.set(slug); + } + } + + markNotFound(slug: string): void { + if (this._notFoundSlug() !== slug) { + this._notFoundSlug.set(slug); + } + } + + /** Called when navigating to another channel so a stale flag cannot leak. */ + reset(): void { + if (this._disabledSlug() !== null) { + this._disabledSlug.set(null); + } + if (this._notFoundSlug() !== null) { + this._notFoundSlug.set(null); + } + } +} diff --git a/frontend/src/app/services/channel.service.ts b/frontend/src/app/services/channel.service.ts new file mode 100644 index 00000000..8bad0ed5 --- /dev/null +++ b/frontend/src/app/services/channel.service.ts @@ -0,0 +1,182 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, firstValueFrom } from 'rxjs'; + +export type SlugUnavailableReason = 'taken' | 'invalid' | 'reserved' | ''; + +export interface SlugAvailability { + available: boolean; + reason: SlugUnavailableReason; +} + +export interface CreatedChannel { + slug: string; + name: string; +} + +/** + * Mirror of the backend slug rule (`^[a-z0-9][a-z0-9\-]{1,48}[a-z0-9]$`): 3–50 + * characters, lowercase alphanumerics and hyphens, never starting or ending + * with a hyphen. Kept here so the form can pre-validate before spending a + * round-trip on /slug-available. + */ +export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/; +export const SLUG_MIN_LENGTH = 3; +export const SLUG_MAX_LENGTH = 50; + +/** + * Mirrors of backend/channel_create.go (maxChannelNameLen, maxChannelDescLen, + * maxChannelsPerOwner). The form counts characters exactly like the server + * does, so a Hebrew name is never cut short of the advertised limit. + */ +export const CHANNEL_NAME_MAX = 80; +export const CHANNEL_DESCRIPTION_MAX = 2000; +export const MAX_CHANNELS_PER_ACCOUNT = 5; + +/** + * Letter pairs written with a geresh to spell sounds Hebrew has no letter for. + * Matched before the single-letter table, so ג׳ becomes "j" rather than "g". + */ +const HEBREW_DIGRAPHS: Record<string, string> = { + 'ג׳': 'j', + 'ז׳': 'zh', + 'צ׳': 'ch', + 'ד׳': 'dh', + 'ת׳': 'th', +}; + +/** + * Hebrew letters to Latin. Unpointed Hebrew omits most vowels, so a faithful + * transliteration is impossible — this aims for a recognisable, stable starting + * point the user can edit, not scholarly romanisation. ו maps to "o" and י to + * "i" because in unpointed text they far more often carry a vowel than the + * consonants "v"/"y", which keeps words like הבוקר readable ("hboker", not + * "hbvkr"). Final forms map to the same letter as their regular form. + */ +const HEBREW_LETTERS: Record<string, string> = { + 'א': 'a', 'ב': 'b', 'ג': 'g', 'ד': 'd', 'ה': 'h', 'ו': 'o', 'ז': 'z', + 'ח': 'ch', 'ט': 't', 'י': 'i', 'כ': 'k', 'ך': 'k', 'ל': 'l', 'מ': 'm', + 'ם': 'm', 'נ': 'n', 'ן': 'n', 'ס': 's', 'ע': 'a', 'פ': 'p', 'ף': 'p', + 'צ': 'tz', 'ץ': 'tz', 'ק': 'k', 'ר': 'r', 'ש': 'sh', 'ת': 't', +}; + +/** + * Rewrites Hebrew text to Latin so a Hebrew channel name still yields a usable + * slug. Anything already Latin passes through untouched. + */ +export function transliterateHebrew(input: string): string { + // Niqqud and cantillation first, so pointed text maps exactly like the + // unpointed spelling of the same word. + let s = (input || '').replace(/[֑-ׇ]/g, ''); + // Maqaf is Hebrew's hyphen; typed apostrophes and the curly quote all stand + // in for a geresh, so normalise them to one form for the digraph lookup. + s = s.replace(/־/g, '-').replace(/['’]/g, '׳'); + + let out = ''; + for (let i = 0; i < s.length; i++) { + const digraph = HEBREW_DIGRAPHS[s.slice(i, i + 2)]; + if (digraph) { + out += digraph; + i++; + continue; + } + out += HEBREW_LETTERS[s[i]] ?? s[i]; + } + return out; +} + +/** + * Derives a slug suggestion from a free-text channel name: transliterated to + * Latin, lowercased, spaces to hyphens, anything outside [a-z0-9-] dropped. + * The length cap is applied before the final hyphen trim, or truncating mid + * word could leave a trailing hyphen and produce a slug the backend rejects. + */ +export function slugifyChannelName(name: string): string { + return transliterateHebrew(name) + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-{2,}/g, '-') + .slice(0, SLUG_MAX_LENGTH) + .replace(/^-+|-+$/g, ''); +} + +/** + * Normalises what the user types into the slug field as they type, so the + * field can never hold an illegal character (uppercase, spaces, Hebrew). + */ +export function sanitizeSlugInput(value: string): string { + return (value || '').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); +} + +/** + * The n-th fallback address for a taken slug: `news` → `news-2`, `news-3`… + * The base is trimmed first so the suffix never pushes the result past the + * length limit, and a trailing hyphen left by the trim is dropped so the + * variant is always a valid slug. + */ +export function slugVariant(base: string, n: number): string { + const suffix = `-${n}`; + const room = SLUG_MAX_LENGTH - suffix.length; + const trimmed = base.replace(/-\d+$/, '').slice(0, room).replace(/-+$/g, ''); + return `${trimmed}${suffix}`; +} + +/** + * Seconds-to-minutes of a 429's Retry-After header, rounded up, or null when + * the server sent none. The creation limiter refills one slot every 20 + * minutes, so the number shown is the real wait rather than a guess. + */ +export function retryAfterMinutes(err: any): number | null { + const raw = err?.headers?.get?.('Retry-After'); + const seconds = Number(raw); + if (!raw || !Number.isFinite(seconds) || seconds <= 0) return null; + return Math.max(1, Math.ceil(seconds / 60)); +} + +@Injectable({ + providedIn: 'root' +}) +export class ChannelService { + + constructor(private http: HttpClient) {} + + /** + * Instant channel creation. The owner is taken from the session on the + * backend, so there is deliberately no owner field here. + * 400 invalid slug / missing name · 409 slug taken · 403 channel limit · + * 429 rate limited — all answered as plain text in the body. + */ + createChannel(slug: string, name: string, description: string): Promise<CreatedChannel> { + return firstValueFrom( + this.http.post<CreatedChannel>('/api/channels/create', { slug, name, description }) + ); + } + + /** Left as an Observable so callers can debounce/switchMap the keystrokes. */ + checkSlugAvailability(slug: string): Observable<SlugAvailability> { + return this.http.get<SlugAvailability>('/api/channels/slug-available', { params: { slug } }); + } + + /** + * The first free `base-N` variant of a taken slug, or null when none of the + * first `maxTries` is free (or the checks failed). Sequential on purpose: + * the availability probe is rate limited per user, and the first hit ends + * the search — usually after a single request. + */ + async findFreeSlugVariant(base: string, maxTries = 5): Promise<string | null> { + for (let n = 2; n < 2 + maxTries; n++) { + const candidate = slugVariant(base, n); + if (!SLUG_PATTERN.test(candidate)) return null; + try { + const result = await firstValueFrom(this.checkSlugAvailability(candidate)); + if (result?.available) return candidate; + } catch { + // A failed probe (rate limit, network) is not worth a second one. + return null; + } + } + return null; + } +} diff --git a/frontend/src/app/services/chat-guard.guard.ts b/frontend/src/app/services/chat-guard.guard.ts index 8ebe0b64..2dc3b870 100644 --- a/frontend/src/app/services/chat-guard.guard.ts +++ b/frontend/src/app/services/chat-guard.guard.ts @@ -1,21 +1,85 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { AuthService } from './auth.service'; +import { ChatService, FEED_PAGE_SIZE } from './chat.service'; +import { SlugService } from './slug.service'; +import { User } from '../models/user.model'; + +/** + * The backend never exposes a channel's `require_auth` flag as data: when the + * feature is on, the `channelIfRequireAuth` middleware wraps every + * /api/channel/{slug}/* route — including /info — with a login check, so an + * anonymous visitor gets 401 there. A channel whose /info answers anonymously + * is therefore public and has to stay reachable without logging in. + */ +const channelRequiresAuth = async (chatService: ChatService): Promise<boolean> => { + try { + await chatService.updateChannelInfo(); + return false; + } catch (err: any) { + return err?.status === 401; + } +}; export const AuthGuard: CanActivateFn = async (route, state) => { const router = inject(Router); const authService = inject(AuthService); + const chatService = inject(ChatService); + const slugService = inject(SlugService); + + // The channel's /info is needed either way — by this guard for an anonymous + // visitor, by every component right after — so it starts now, beside the + // session check, instead of after it. The answer lands in ChatService's + // cache, which initChannel keeps for this slug. + const slug = route.paramMap.get('slug'); + let infoProbe: Promise<boolean> | undefined; + if (slug) { + slugService.slug = slug; + chatService.clearCache(); + infoProbe = channelRequiresAuth(chatService); + // The feed itself, in parallel with the two probes rather than after them. + if (route.routeConfig?.path === 'channel/:slug') chatService.prefetchFirstPage(FEED_PAGE_SIZE); + } + let userInfo: User | null | undefined; try { - let userInfo = await authService.loadUserInfo(); - if (userInfo) return true; + userInfo = await authService.loadUserInfo(); } catch (err: any) { - if (err.status === 401) { - router.navigate(['/login']); - return false; - } + // Only "not authenticated" is an answer about the session. Anything else + // (server error, network blip, a proxy answering with HTML) says nothing + // about who the visitor is, so let the route render and surface its own + // error instead of bouncing the visitor through a login they don't need. + if (err?.status !== 401) return true; + userInfo = undefined; + } + + if (userInfo) { + // Settled before the route activates, so a missing or disabled channel is + // already flagged when ChannelComponent initialises and it renders the + // card at once. Without this the signed-in path (user-info is memoised, + // so it resolved first) mounted the chat shell for a few frames, fired + // eight requests for a channel that does not exist and logged them all. + if (infoProbe) await infoProbe; return true; } - return false; + // No session — either /api/user-info answered 401, or it resolved with an + // empty body (204, a body-stripping proxy, an offline interstitial), which + // leaves loadUserInfo() returning null. Both mean "anonymous", and both are + // handled the same way below. + // + // A public channel stays open to anonymous visitors. + if (infoProbe && !(await infoProbe)) return true; + + // Redirect via a UrlTree rather than `router.navigate(...) + false`. + // Returning false cancels the navigation outright: the router then has no + // active route to render and the visitor is left staring at a blank white + // page with nothing in the console — which is exactly the bug this replaces. + try { + localStorage.setItem('returnUrl', state.url); + } catch { + // Storage can be unavailable (privacy mode, blocked cookies). Losing the + // return URL is survivable; failing the guard is not. + } + return router.createUrlTree(['/login']); }; diff --git a/frontend/src/app/services/chat.service.ts b/frontend/src/app/services/chat.service.ts index 74324e00..5d970a5f 100644 --- a/frontend/src/app/services/chat.service.ts +++ b/frontend/src/app/services/chat.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom, Observable } from 'rxjs'; -import { Channel } from '../models/channel.model'; +import { Channel, ChannelFeatureFlags, ChannelSeoSettings } from '../models/channel.model'; import { ResponseResult } from '../models/response-result.model'; +import { SlugService } from './slug.service'; export type MessageType = 'md' | 'text' | 'image' | 'video' | 'audio' | 'document' | 'other'; export type Reactions = { [key: string]: number } @@ -27,6 +28,13 @@ export interface ChatFile { url: string; filename: string; filetype: string; + /** + * Pixel size, sent by the upload endpoint only for images whose header it + * could read. Absent for every other upload, and for anything uploaded + * before the backend started recording it. + */ + width?: number; + height?: number; } export interface Attachment { @@ -37,55 +45,173 @@ export interface Attachment { embedded?: string; } +// Messages per page of the channel feed. +export const FEED_PAGE_SIZE = 20; + @Injectable({ providedIn: 'root' }) export class ChatService { private eventSource!: EventSource; - private emojis: string[] = []; + // null = not loaded yet; an empty array is a legitimately empty, loaded list. + private emojis: string[] | null = null; + private emojisRequest?: Promise<string[]>; public channelInfo?: Channel; + private channelInfoRequest?: Promise<void>; + // Which channel the cached info / in-flight request belong to, so + // clearCache can keep them across the route change that loads that very + // channel (the guard fetches /info before the components mount). + private channelInfoSlug = ''; + private channelInfoRequestSlug = ''; + + constructor(private http: HttpClient, private slugService: SlugService) { } + + private get slug() { return this.slugService.slug; } + + updateChannelInfo(): Promise<void> { + // Deduplicated: several components need the channel info (and the feature + // flags it carries) on the same first paint. + // + // The slug is captured when the request is issued and re-checked on + // arrival: a channel switch can leave the previous channel's /info still in + // flight, and if it resolves after the new channel's it would otherwise + // overwrite the wrong channel's info (name, feature flags, require-auth). + const requestedSlug = this.slug; + if (!this.channelInfoRequest) { + this.channelInfoRequestSlug = requestedSlug; + this.channelInfoRequest = firstValueFrom(this.http.get<Channel>(`/api/channel/${requestedSlug}/info`)) + .then(info => { + if (requestedSlug === this.slug) { + this.channelInfo = info; + this.channelInfoSlug = requestedSlug; + } + }) + .finally(() => { this.channelInfoRequest = undefined; }); + } + return this.channelInfoRequest; + } + + // Resolves once the feature flags are known, without issuing a second request + // when the header already has one in flight. + ensureChannelInfo(): Promise<void> { + return this.channelInfo ? Promise.resolve() : this.updateChannelInfo(); + } + + // A missing features object means an older backend or a response cached before + // the flags existed — treat it as "everything enabled" so the UI is never + // stripped by an answer that simply does not mention the feature. + isFeatureEnabled(feature: keyof ChannelFeatureFlags): boolean { + return this.channelInfo?.features?.[feature] !== false; + } - constructor(private http: HttpClient) { } + get reactionsEnabled(): boolean { return this.isFeatureEnabled('reactions'); } + get fileUploadsEnabled(): boolean { return this.isFeatureEnabled('fileUploads'); } + get reportsEnabled(): boolean { return this.isFeatureEnabled('reports'); } + get scheduledMessagesEnabled(): boolean { return this.isFeatureEnabled('scheduledMessages'); } - async updateChannelInfo() { - this.channelInfo = await firstValueFrom(this.http.get<Channel>('/api/channel/info')); - return; + editChannelInfo(name: string, description: string, logoUrl: string, contactUs: string, seo?: ChannelSeoSettings): Observable<ResponseResult> { + return this.http.post<ResponseResult>(`/api/channel/${this.slug}/admin/edit-channel-info`, { name, description, logoUrl, contactUs, ...seo }); } - editChannelInfo(name: string, description: string, logoUrl: string): Observable<ResponseResult> { - return this.http.post<ResponseResult>('/api/admin/edit-channel-info', { name, description, logoUrl }); + // The newest page of the feed, started by the route guard while it is still + // waiting for /user-info and /info, so the messages no longer wait for the + // channel view to mount before they are even requested. Handed out once. + private firstPage?: { slug: string; limit: number; at: number; request: Promise<ChatResponse | null> }; + + prefetchFirstPage(limit: number) { + const request = firstValueFrom(this.getMessages(0, limit, 'desc')).catch(() => null); + this.firstPage = { slug: this.slug, limit, at: Date.now(), request }; + } + + // Resolves to null when the prefetch failed; the caller then asks again and + // handles that request's error itself. Older than a few seconds it is not + // used: the feed would start from a page the live stream did not follow. + takeFirstPage(limit: number): Promise<ChatResponse | null> | undefined { + const page = this.firstPage; + this.firstPage = undefined; + // The prefetch never asks for deleted posts, so it cannot answer for the toggle. + if (!page || this.showDeleted || page.slug !== this.slug || page.limit !== limit || Date.now() - page.at > 10_000) return undefined; + return page.request; } + /** + * Staff toggle in the feed: deleted posts are left out for everyone unless + * this is on, so a writer can still find and restore one. The server only + * honours it for writers and above. + */ + showDeleted = false; + getMessages(offset: number, limit: number, direction: string): Observable<ChatResponse> { - return this.http.get<ChatResponse>('/api/messages', { - params: { - offset: offset.toString(), - limit: limit.toString(), - direction: direction - } - }); + const params: Record<string, string> = { + offset: offset.toString(), + limit: limit.toString(), + direction: direction + }; + if (this.showDeleted) params['deleted'] = '1'; + return this.http.get<ChatResponse>(`/api/channel/${this.slug}/messages`, { params }); } setReact(messageId: number, react: string) { - return firstValueFrom(this.http.post<ResponseResult>('/api/reactions/set-reactions', { messageId, emoji: react })); + return firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/reactions/set-reactions`, { messageId, emoji: react })); + } + + /** + * Drops everything cached for the previous channel. With `keepInfoFor`, the + * channel info (or the request for it) is kept when it is already the + * named channel's: AuthGuard starts /info while the route resolves, and + * throwing that answer away here made the header fetch it a second time + * and pushed every other first-paint request one round trip later. + */ + clearCache(keepInfoFor?: string) { + this.showDeleted = false; + this.emojis = null; + this.emojisRequest = undefined; + if (!keepInfoFor || this.channelInfoSlug !== keepInfoFor) this.channelInfo = undefined; + if (!keepInfoFor || this.channelInfoRequestSlug !== keepInfoFor) this.channelInfoRequest = undefined; } async getEmojisList(reload: boolean = false): Promise<string[]> { - if (this.emojis && !reload) return Promise.resolve(this.emojis); // הסרתי את בדיקת האורך, משום שזה יוצר קריאות מיותרות לשרת כאשר לא מוגדר אימוגים - this.emojis = await firstValueFrom(this.http.get<string[]>('/api/emojis/list')); - return this.emojis; + if (this.emojis && !reload) return this.emojis; + // Deduplicated: every message component asks for the list on first paint. + // Same slug guard as updateChannelInfo — without it, a request for the + // previous channel resolving after a switch would cache that channel's + // reaction set under the new slug. clearCache also drops any in-flight + // request on switch so a forced reload actually re-fetches. + const requestedSlug = this.slug; + this.emojisRequest ??= firstValueFrom(this.http.get<string[]>(`/api/channel/${requestedSlug}/emojis/list`)) + .then(list => { + if (requestedSlug !== this.slug) return list; + // Every rendered message captured the cached array once, so a reload + // after the admin saved a new set updates that instance in place + // instead of leaving the open pickers on the old list. + if (this.emojis) { + this.emojis.splice(0, this.emojis.length, ...list); + } else { + this.emojis = list; + } + return this.emojis; + }) + .finally(() => { this.emojisRequest = undefined; }); + return this.emojisRequest; } reportMessage(messageId: number, reason: string): Promise<ResponseResult> { - return firstValueFrom(this.http.post<ResponseResult>('/api/messages/report', { messageId, reason })); + return firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slug}/messages/report`, { messageId, reason })); } - sseListener(): EventSource { + /** + * `lastEventId` is the id of the last stream entry the caller received. Only + * the browser's own retry sends it as Last-Event-ID; an EventSource created + * here starts at the tip, so the caller hands it over and the server resumes + * from `?last_id=` whenever the header is absent. + */ + sseListener(lastEventId: string = ''): EventSource { if (this.eventSource) { this.eventSource.close(); } - this.eventSource = new EventSource('/api/events'); + const url = `/api/channel/${this.slug}/events`; + this.eventSource = new EventSource(lastEventId ? `${url}?last_id=${encodeURIComponent(lastEventId)}` : url); this.eventSource.onopen = () => { console.log('Connection opened'); diff --git a/frontend/src/app/services/confirm.service.ts b/frontend/src/app/services/confirm.service.ts new file mode 100644 index 00000000..d84d1057 --- /dev/null +++ b/frontend/src/app/services/confirm.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { NbDialogService } from '@nebular/theme'; +import { firstValueFrom } from 'rxjs'; +import { ConfirmDialogComponent, ConfirmOptions } from '../components/shared/confirm-dialog.component'; + +/** + * `await confirm.ask({...})` → true only when the user pressed the confirm + * button. Backdrop, Escape and the cancel button all resolve false, so a + * caller can never mistake "closed" for "confirmed". + */ +@Injectable({ providedIn: 'root' }) +export class ConfirmService { + constructor(private dialog: NbDialogService) {} + + async ask(options: ConfirmOptions): Promise<boolean> { + const defaults: Partial<ConfirmOptions> = {}; + if (options.status === 'danger') { + defaults.icon = 'alert-triangle-outline'; + defaults.confirmLabel = 'מחיקה'; + } + const ref = this.dialog.open(ConfirmDialogComponent, { + context: { ...defaults, ...options }, + closeOnBackdropClick: true, + closeOnEsc: true, + autoFocus: true, + }); + const result = await firstValueFrom(ref.onClose); + return result === true; + } +} diff --git a/frontend/src/app/services/magnet-ads.service.ts b/frontend/src/app/services/magnet-ads.service.ts new file mode 100644 index 00000000..9848804f --- /dev/null +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -0,0 +1,236 @@ +import { Injectable } from '@angular/core'; +import { ChatMessage } from './chat.service'; +import { SlugService } from './slug.service'; + +export interface MagnetSettings { + enabled: boolean; + snippet: string; + mode: 'by_messages' | 'by_time' | string; + perMessages: number; + minTimeSeconds: number; + perSeconds: number; + minMessagesSinceLast: number; + /** + * True when the super admin locked magnet ads (globally or for this + * channel): the values above are then the global ones and the channel's own + * settings are ignored. Optional — an older backend omits it. + */ + locked?: boolean; +} + +/** Lifetime of a channel's Magnet viewer id (see viewerId). */ +const VIEWER_ID_TTL_MS = 24 * 60 * 60 * 1000; + +@Injectable({ providedIn: 'root' }) +export class MagnetAdsService { + private settings: MagnetSettings | null = null; + private settingsPromise: Promise<MagnetSettings | null> | null = null; + + /** + * Slot decisions already taken, by message id, for the channel in + * `decisionsSlug`. A slot is placed by counting messages or seconds from + * the previous slot, and counting afresh from the oldest LOADED message + * meant every older page prepended by a scroll-up shifted every count: the + * slots landed on different messages (0/6 overlap for per=3 on a + * 20-message page), each moved slot destroyed and recreated its + * <app-magnet-ad-slot>, and the snippet ran again in a fresh frame. A + * decision, once taken, is replayed on every later fill and only undecided + * messages are placed, so a slot never moves once it is on screen. The map + * is dropped with the channel and whenever the placement settings change. + */ + private decisions = new Map<number, boolean>(); + private decisionsSlug: string | undefined; + private viewerIds = new Map<string, { id: string; t: number }>(); + + constructor(private slugService: SlugService) {} + + clearCache() { + this.settings = null; + this.settingsPromise = null; + this.decisions.clear(); + this.decisionsSlug = undefined; + } + + loadSettings(force = false): Promise<MagnetSettings | null> { + if (this.settings && !force) return Promise.resolve(this.settings); + if (this.settingsPromise && !force) return this.settingsPromise; + + this.settingsPromise = fetch(`/api/channel/${this.slugService.slug}/ads/magnet`) + .then(r => r.ok ? r.json() : null) + .then((data: MagnetSettings | null) => { + // Other placement settings would place the loaded window differently, + // so the decisions taken under the old ones are dropped. A re-read + // that brings the same values keeps them: recomputing from scratch + // would move the slots a prepended page had been placed around. + if (this.placementKey(data) !== this.placementKey(this.settings)) this.decisions.clear(); + this.settings = data; + return data; + }) + .catch(() => null); + + return this.settingsPromise; + } + + getSettings(): MagnetSettings | null { + return this.settings; + } + + /** + * A random id for this browser in this channel, handed to every ad frame + * as Magnet's viewer id (see storageShim in magnet-ad-slot). The frames + * have an opaque origin and no storage of their own, so without it Magnet + * saw every slot as a new viewer. Kept per channel so that the snippets of + * different channel owners, which can all read it, cannot use it to link + * one reader across channels; and renewed every 24 hours, which is as long + * as Magnet remembers what a viewer saw, so the owner's snippet gets no + * lasting identifier out of it. + */ + viewerId(): string { + const key = `magnet_vid:${this.slugService.slug ?? ''}`; + const now = Date.now(); + const fresh = (v: { id: string; t: number } | undefined | null) => + !!v && v.t <= now && now - v.t < VIEWER_ID_TTL_MS; + const known = this.viewerIds.get(key); + if (fresh(known)) return known!.id; + let entry: { id: string; t: number } | null = null; + try { + const m = /^([0-9a-f]{24})\.(\d{1,15})$/.exec(localStorage.getItem(key) ?? ''); + const stored = m ? { id: m[1], t: Number(m[2]) } : null; + if (fresh(stored)) entry = stored; + } catch { /* storage blocked: the in-memory id still covers this visit */ } + if (!entry) { + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + entry = { id: Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''), t: now }; + try { localStorage.setItem(key, `${entry.id}.${entry.t}`); } catch { /* see above */ } + } + this.viewerIds.set(key, entry); + return entry.id; + } + + /** + * Compute which message IDs should have an ad slot rendered after them. + * Input: messages array as held in chat.component (newest at index 0). + * Output: a Set of message IDs. The chat template iterates messages and, + * for any message whose id is in this set, renders an <app-magnet-ad-slot> + * directly below it. The list is rendered by a flex-column-reverse + * container so the slots appear visually after the message in chronological + * reading order. + */ + computeAdSlots(messages: ChatMessage[]): Set<number> { + const result = new Set<number>(); + if (!messages || messages.length === 0) return result; + + const s = this.settings; + if (!s || !s.enabled || !s.snippet?.trim()) return result; + + // Decisions belong to one channel's id sequence. clearCache() drops them + // on a switch; this guard covers a fill that arrives before it. + const slug = this.slugService.slug; + if (slug !== this.decisionsSlug) { + this.decisions.clear(); + this.decisionsSlug = slug; + } + + const chrono = [...messages].reverse(); + + // 'per_seconds' is a legacy value stored by an older super-admin form. + if (s.mode === 'by_time' || s.mode === 'per_seconds') { + this.fillByTime(result, chrono, s); + } else { + this.fillByMessages(result, chrono, s); + } + + return result; + } + + /** + * The placement-relevant settings as a comparable string; the snippet is + * left out because a new snippet does not move slots. + */ + private placementKey(s: MagnetSettings | null): string { + if (!s) return ''; + return [s.mode, s.perMessages, s.minTimeSeconds, s.perSeconds, s.minMessagesSinceLast].join('|'); + } + + /** + * Both fillers walk the loaded window oldest-first with the documented + * rules (SET.md, "תדירות הצגה"): a slot every `per` visible messages, or + * every `per` seconds, counted from the previous slot, each honouring its + * min-gap setting. A message already in `decisions` replays its decision + * and feeds the counters as if placed now; an undecided one is decided by + * the rules and remembered. A window that grows at its newest end is thus + * a plain continuation. An older page prepended by a scroll-up sits BEFORE + * the decided messages in the walk and is placed from its own start, so + * the decided slots never move; only the boundary between that page and + * the old window can fall short of the gap — the page's last slot and the + * window's first may be closer than `per` (or than the min-gap setting) — + * which is accepted, the alternative being slots that move on screen. + */ + private fillByMessages(out: Set<number>, chrono: ChatMessage[], s: MagnetSettings): void { + const per = Math.max(1, s.perMessages || 5); + const minTime = Math.max(0, s.minTimeSeconds || 0); + + let countSinceLast = 0; + let lastAdTime: number | null = null; + + for (const m of chrono) { + countSinceLast++; + const id = m.id ?? null; + const msgTime = this.toEpoch(m.timestamp); + + let place = id !== null ? this.decisions.get(id) : undefined; + if (place === undefined) { + const enoughTimePassed = !minTime || lastAdTime === null || + (msgTime !== null && (msgTime - lastAdTime) >= minTime * 1000); + place = id !== null && countSinceLast >= per && enoughTimePassed; + if (id !== null) this.decisions.set(id, place); + } + + if (place && id !== null) { + out.add(id); + countSinceLast = 0; + if (msgTime !== null) lastAdTime = msgTime; + } + } + } + + private fillByTime(out: Set<number>, chrono: ChatMessage[], s: MagnetSettings): void { + const per = Math.max(1, s.perSeconds || 60); + const minMsgs = Math.max(0, s.minMessagesSinceLast || 0); + + let lastAdTime: number | null = null; + let msgsSinceLast = 0; + + for (const m of chrono) { + msgsSinceLast++; + + const msgTime = this.toEpoch(m.timestamp); + if (msgTime === null) continue; + + const id = m.id ?? null; + let place = id !== null ? this.decisions.get(id) : undefined; + if (place === undefined) { + // The oldest timed message of a fresh walk only starts the clock. + place = id !== null && lastAdTime !== null && + (msgTime - lastAdTime) / 1000 >= per && msgsSinceLast >= minMsgs; + if (id !== null) this.decisions.set(id, place); + } + + if (place && id !== null) { + out.add(id); + lastAdTime = msgTime; + msgsSinceLast = 0; + } else if (lastAdTime === null) { + lastAdTime = msgTime; + } + } + } + + private toEpoch(ts: any): number | null { + if (!ts) return null; + if (ts instanceof Date) return ts.getTime(); + const t = new Date(ts).getTime(); + return isNaN(t) ? null : t; + } +} diff --git a/frontend/src/app/services/magnet-stats-errors.ts b/frontend/src/app/services/magnet-stats-errors.ts new file mode 100644 index 00000000..cdf6a3e3 --- /dev/null +++ b/frontend/src/app/services/magnet-stats-errors.ts @@ -0,0 +1,50 @@ +/** + * Turns a failed Magnet stats request into Hebrew text — the platform's + * (GET /api/super-admin/magnet/stats) or an unlocked channel's own + * (GET /api/channel/<slug>/admin/magnet/stats), both drawn by + * app-magnet-stats-panel. Screens used to read the status on their own, and a 401 + * meant "no permission" there although it now means Magnet rejected the key. + * + * The error code in the body decides first: the backend answers Magnet's 401 + * as 502 (a 401 would sign the super admin out through the session-expired + * interceptor), so the status alone cannot tell a bad key from an outage. + */ +export function magnetStatsError(err: any): { message: string; notConfigured: boolean; keyProblem: boolean } { + const status: number = err?.status ?? 0; + const body = err?.error; + const code = String(typeof body === 'string' ? body : (body?.error ?? '')); + // keyProblem: the fix is on the "פרסומות מגנט" screen (enter or replace the + // key), so a screen can offer a way there instead of "try again". + const result = (message: string, notConfigured = false, keyProblem = false) => + ({ message, notConfigured, keyProblem: keyProblem || notConfigured }); + + // A channel locked to the platform's Magnet settings: there is nothing of + // its own to show, and no key to fix. + if (code === 'magnet_locked') { + return result('הערוץ נעול להגדרות המגנט של הנהלת המערכת, ולכן אין כאן נתוני מגנט.'); + } + if (code === 'missing_stats_key') { + return result('מפתח הסטטיסטיקה של מגנט עוד לא הוגדר', true); + } + // No status === 401 check: the backend never forwards a 401 here (see + // above), so a rejected key is only ever recognisable by its code. + if (code === 'invalid_key' || code === 'missing_key') { + return result('מפתח הסטטיסטיקה שגוי או שבוטל במגנט', false, true); + } + // A 401 from Magnet's platform gate rather than from site-stats: a setup + // fault (the function deployed with JWT verification on), not an outage. + if (code === 'upstream_unauthorized') { + return result('מגנט דחה את הבקשה — בדקו את מפתח הסטטיסטיקה ואת פריסת site-stats במגנט'); + } + if (code === 'invalid_key_format') { + return result('מפתח הסטטיסטיקה השמור אינו תקין — הזינו אותו מחדש במסך "פרסומות מגנט"', false, true); + } + switch (status) { + // site_not_active, or the function not deployed yet. + case 404: return result('האתר לא נמצא במערכת מגנט או שאינו מאושר.'); + case 403: return result('אין הרשאה לגשת לנתוני מגנט.'); + case 502: return result('מגנט לא ענה. נסו שוב בעוד רגע.'); + case 0: return result('שגיאת רשת בקריאה לשרת מגנט'); + default: return result('הנתונים ממגנט לא נטענו. נסו שוב בעוד רגע.'); + } +} diff --git a/frontend/src/app/services/magnet-stats.model.ts b/frontend/src/app/services/magnet-stats.model.ts new file mode 100644 index 00000000..c25da6b3 --- /dev/null +++ b/frontend/src/app/services/magnet-stats.model.ts @@ -0,0 +1,265 @@ +/** + * Magnet's site-stats answer, turned into what the super-admin statistics + * screen draws. Pure functions only, so the shape rules live in one place. + * + * Two shapes reach this file. The original one carries six numbers (clicks and + * earnings for today, 7 days and 30 days). The extended one (hebrew-ad-flow + * site_stats_report_by_key, 14.10.2026) adds impressions, CTR, a 30-day daily + * series and per-ad / per-hour / per-device breakdowns after those six, + * unchanged. Everything the old shape lacks comes out null here, and the + * screen hides what is null — so this side can ship before or after Magnet. + */ + +export type MagnetRange = 'today' | 'week' | 'month'; + +export interface MagnetSummary { + clicks: number; + earnings: number; + /** null: not in the answer, or not kept that long (impressions: 7 days). */ + impressions: number | null; + /** A fraction (0.02 = 2%); null when there is nothing to divide by. */ + ctr: number | null; +} + +export interface MagnetDay { + /** YYYY-MM-DD, an Israel calendar day. */ + date: string; + clicks: number; + earnings: number; + /** null: the day is older than Magnet keeps impressions. */ + impressions: number | null; +} + +export interface MagnetAdRow { + id: string; + title: string; + clicks: number; + earnings: number; + impressionsWeek: number; + clicksWeek: number; + ctrWeek: number | null; +} + +export interface MagnetPlacementRow { + /** The slot's label as Magnet stored it (a channel slug). */ + name: string; + clicks: number; + earnings: number; +} + +export interface MagnetDevices { + mobile: number; + tablet: number; + desktop: number; + unknown: number; +} + +export interface MagnetStatsView { + domain: string; + generatedAt: Date | null; + summary: Record<MagnetRange, MagnetSummary>; + totalEarnings: number | null; + totalClicks: number | null; + balance: number | null; + /** How many days back Magnet keeps impressions. */ + impressionsDays: number; + daily: MagnetDay[] | null; + byAd: MagnetAdRow[] | null; + byHour: number[] | null; + byDevice: MagnetDevices | null; + /** Not sent by Magnet yet; drawn only once it is. */ + byPlacement: MagnetPlacementRow[] | null; +} + +type Obj = Record<string, unknown>; + +function isObj(v: unknown): v is Obj { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function obj(v: unknown): Obj { + return isObj(v) ? v : {}; +} + +/** A finite number, or 0. */ +export function num(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v); + return v !== null && v !== '' && Number.isFinite(n) ? n : 0; +} + +/** A finite number, or null when absent or not a number. */ +export function numOrNull(v: unknown): number | null { + if (v === null || v === undefined || v === '') return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +export function ratio(clicks: number, impressions: number | null): number | null { + return impressions && impressions > 0 ? clicks / impressions : null; +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Normalises either shape. Returns null for something that is not stats. */ +export function toMagnetStatsView(raw: unknown): MagnetStatsView | null { + if (!isObj(raw)) return null; + const clicks = obj(raw['clicks']); + const earnings = obj(raw['earnings']); + if (!isObj(raw['clicks']) && !isObj(raw['earnings'])) return null; + + const imp = isObj(raw['impressions']) ? raw['impressions'] : null; + const ctr = isObj(raw['ctr']) ? raw['ctr'] : null; + + const summaryFor = (range: MagnetRange): MagnetSummary => { + const c = num(clicks[range]); + const i = imp ? numOrNull(imp[range]) : null; + const sentCtr = ctr ? numOrNull(ctr[range]) : null; + return { + clicks: c, + earnings: num(earnings[range]), + impressions: i, + ctr: sentCtr ?? ratio(c, i), + }; + }; + + const generated = typeof raw['generated_at'] === 'string' ? new Date(raw['generated_at']) : null; + + return { + domain: String(obj(raw['site'])['domain'] ?? ''), + generatedAt: generated && !isNaN(generated.getTime()) ? generated : null, + summary: { today: summaryFor('today'), week: summaryFor('week'), month: summaryFor('month') }, + totalEarnings: numOrNull(earnings['total']), + totalClicks: numOrNull(clicks['total']), + balance: numOrNull(raw['balance']), + impressionsDays: numOrNull(raw['impressions_days']) || 7, + daily: parseDaily(raw['daily']), + byAd: parseByAd(raw['by_ad']), + byHour: parseByHour(raw['by_hour']), + byDevice: parseDevices(raw['by_device']), + byPlacement: parsePlacements(raw['by_placement']), + }; +} + +function parseDaily(v: unknown): MagnetDay[] | null { + if (!Array.isArray(v)) return null; + const days = v + .filter((d): d is Obj => isObj(d) && typeof d['date'] === 'string' && DATE_RE.test(d['date'] as string)) + .map((d) => ({ + date: d['date'] as string, + clicks: num(d['clicks']), + earnings: num(d['earnings']), + impressions: numOrNull(d['impressions']), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + return days.length ? days : null; +} + +function parseByAd(v: unknown): MagnetAdRow[] | null { + if (!Array.isArray(v)) return null; + return v.filter(isObj).map((a, i) => { + const impressionsWeek = num(a['impressions_week']); + const clicksWeek = num(a['clicks_week']); + return { + id: String(a['id'] ?? i), + title: String(a['title'] ?? '').trim(), + clicks: num(a['clicks']), + earnings: num(a['earnings']), + impressionsWeek, + clicksWeek, + ctrWeek: numOrNull(a['ctr_week']) ?? ratio(clicksWeek, impressionsWeek), + }; + }); +} + +function parseByHour(v: unknown): number[] | null { + if (!Array.isArray(v) || v.length === 0) return null; + return Array.from({ length: 24 }, (_, h) => num(v[h])); +} + +function parseDevices(v: unknown): MagnetDevices | null { + if (!isObj(v)) return null; + return { mobile: num(v['mobile']), tablet: num(v['tablet']), desktop: num(v['desktop']), unknown: num(v['unknown']) }; +} + +function parsePlacements(v: unknown): MagnetPlacementRow[] | null { + if (!Array.isArray(v)) return null; + const rows = v.filter(isObj).map((p) => ({ + name: String(p['placement'] ?? p['name'] ?? '').trim(), + clicks: num(p['clicks']), + earnings: num(p['earnings']), + })).filter((p) => p.name); + return rows.length ? rows : null; +} + +/** The last n days of the series (the whole of it when n is larger). */ +export function lastDays(daily: MagnetDay[], n: number): MagnetDay[] { + return daily.slice(Math.max(0, daily.length - n)); +} + +/** + * A round ceiling for a chart axis: 1, 2 or 5 times a power of ten, at least + * the largest value. 0 for an empty series, so the caller can tell. + */ +export function niceMax(max: number): number { + if (!(max > 0)) return 0; + const pow = Math.pow(10, Math.floor(Math.log10(max))); + for (const step of [1, 2, 2.5, 5, 10]) { + if (step * pow >= max) return step * pow; + } + return 10 * pow; +} + +// ─── Hebrew formatting ──────────────────────────────────────────────────── + +const intFmt = new Intl.NumberFormat('he-IL', { maximumFractionDigits: 0 }); +const moneyFmt = new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS', minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const moneyShortFmt = new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS', maximumFractionDigits: 0 }); +const pctFmt = new Intl.NumberFormat('he-IL', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 2 }); + +export function fmtInt(n: number): string { + return intFmt.format(n); +} + +export function fmtMoney(n: number): string { + return moneyFmt.format(n); +} + +/** Whole shekels, for axis labels. */ +export function fmtMoneyShort(n: number): string { + return moneyShortFmt.format(n); +} + +export function fmtPct(n: number | null): string { + return n === null ? '—' : pctFmt.format(n); +} + +/** A YYYY-MM-DD calendar date as a Date at noon UTC, so no zone shifts the day. */ +function dayDate(date: string): Date { + return new Date(`${date}T12:00:00Z`); +} + +const dayLongFmt = new Intl.DateTimeFormat('he-IL', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }); +const dayShortFmt = new Intl.DateTimeFormat('he-IL', { day: 'numeric', month: 'numeric', timeZone: 'UTC' }); + +/** "יום שלישי, 14 באוקטובר" */ +export function fmtDayLong(date: string): string { + return dayLongFmt.format(dayDate(date)); +} + +/** "14.10" */ +export function fmtDayShort(date: string): string { + return dayShortFmt.format(dayDate(date)); +} + +const rtf = new Intl.RelativeTimeFormat('he', { numeric: 'auto' }); + +/** "עכשיו", "לפני 5 דקות", "לפני שעתיים", "אתמול". */ +export function fmtAgo(then: Date, now: Date): string { + const sec = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000)); + if (sec < 45) return 'עכשיו'; + const min = Math.round(sec / 60); + if (min < 60) return rtf.format(-min, 'minute'); + const hours = Math.round(min / 60); + if (hours < 24) return rtf.format(-hours, 'hour'); + return rtf.format(-Math.round(hours / 24), 'day'); +} diff --git a/frontend/src/app/services/my-channels.service.ts b/frontend/src/app/services/my-channels.service.ts new file mode 100644 index 00000000..764574fc --- /dev/null +++ b/frontend/src/app/services/my-channels.service.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { MyChannel } from '../models/my-channel.model'; +import { AuthService } from './auth.service'; +import { User } from '../models/user.model'; + +/** + * The channels of the signed-in user (every role, not only owner), with names + * and logos — what the "my channels" page and the channel switcher in the + * header show. Cached per user object: AuthService hands out the same User + * instance until login/logout/reload replaces it, so a role change that goes + * through reloadUserInfo() refreshes this list on the next call without any + * explicit wiring. + */ +@Injectable({ providedIn: 'root' }) +export class MyChannelsService { + private cache?: Promise<MyChannel[]>; + private cachedFor?: User; + + constructor( + private http: HttpClient, + private authService: AuthService, + ) {} + + list(force = false): Promise<MyChannel[]> { + const user = this.authService.userInfo; + if (force || !this.cache || this.cachedFor !== user) { + this.cachedFor = user; + this.cache = firstValueFrom(this.http.get<MyChannel[]>('/api/my-channels')) + .then(rows => rows ?? []) + .catch(err => { + // Never memoise a failure, or one blip hides the list until reload. + this.cache = undefined; + throw err; + }); + } + return this.cache; + } + + /** Drop the cached list; the next list() fetches again. */ + invalidate(): void { + this.cache = undefined; + this.cachedFor = undefined; + } +} diff --git a/frontend/src/app/services/notifications.service.ts b/frontend/src/app/services/notifications.service.ts index ab848175..d48ab003 100644 --- a/frontend/src/app/services/notifications.service.ts +++ b/frontend/src/app/services/notifications.service.ts @@ -2,8 +2,9 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { NbToastrService } from '@nebular/theme'; import { firstValueFrom } from 'rxjs'; -import { FirebaseApp, FirebaseOptions, initializeApp } from 'firebase/app'; -import { getMessaging, onMessage, getToken } from 'firebase/messaging'; +import type { FirebaseApp, FirebaseOptions } from 'firebase/app'; +import { ResponseResult } from '../models/response-result.model'; +import { SlugService } from './slug.service'; interface NotificationsConfig { @@ -21,29 +22,95 @@ export class NotificationsService { private messaging: any; public config: NotificationsConfig | null = null; + // Firebase is ~150 KB of JavaScript that only matters on a channel whose + // operator switched push on and entered FCM keys. Imported on demand at that + // point, so every other channel (and the landing, login and manage pages) + // never downloads it. Both modules are fetched together, once. + private firebase?: Promise<{ + app: typeof import('firebase/app'); + messaging: typeof import('firebase/messaging'); + }>; + + private loadFirebase() { + if (!this.firebase) { + this.firebase = Promise.all([import('firebase/app'), import('firebase/messaging')]) + .then(([app, messaging]) => ({ app, messaging })) + .catch(err => { + // A failed chunk download must not poison every later attempt. + this.firebase = undefined; + throw err; + }); + } + return this.firebase; + } + constructor( private http: HttpClient, private tostrService: NbToastrService, + private slugService: SlugService, ) { } + // Cleared on channel switch: the push toggle is per channel, so the state + // fetched for the previous channel must not leak into the next one. + reset() { + this.initialized = false; + this.config = null; + this.app = null; + this.messaging = undefined; + } + async init() { + // Called at bootstrap too, before any channel slug is resolved. + if (!this.slugService.slug) return; if (this.initialized) return; - await firstValueFrom(this.http.get<NotificationsConfig>('/api/channel/notifications-config')) - .then((config) => { - this.config = config; - }); + // Nobody awaits init() and reset() cannot cancel a request already in + // flight: switching channels while the previous channel's config was + // loading used to store that config (and initialized=true) for the new + // channel, whose own init() then returned early — the bell of channel A + // on channel B, subscribing the device to A. Same guard as chat.service. + const requestedSlug = this.slugService.slug; + const config = await firstValueFrom(this.http.get<NotificationsConfig>(`/api/channel/${requestedSlug}/notifications-config`)); + if (requestedSlug !== this.slugService.slug) return; + this.config = config; if (!this.config) return; if (this.config.enableNotifications) { - if (!this.config.firebaseConfig) return; - - this.app = initializeApp(this.config.firebaseConfig); - this.messaging = getMessaging(this.app); + // The server always emits a firebaseConfig object, with empty strings + // when the operator never entered the FCM keys, so testing for the + // object itself never fired: initializeApp({apiKey: ''}) succeeded, + // the bell appeared, and every tap failed in getToken with no VAPID. + if (!this.config.firebaseConfig?.apiKey || !this.config.vapid) return; + + // Safari on iOS outside a home-screen app, and any browser without + // service workers or the Push API, has no Messaging at all: getMessaging() + // throws there, and since nobody awaits init() the throw surfaced as an + // unhandled rejection on every channel load. Ask first, and keep the bell + // hidden where it could never work. + let fb: Awaited<ReturnType<NotificationsService['loadFirebase']>>; + try { + fb = await this.loadFirebase(); + } catch (err) { + console.warn('Push notifications unavailable: firebase failed to load', err); + return; + } + if (!(await fb.messaging.isSupported().catch(() => false))) return; + // More awaits — the channel may have changed meanwhile. + if (requestedSlug !== this.slugService.slug) return; + + try { + // initializeApp() refuses a second default app with different options; + // reuse the one from a previous channel visit instead of tripping it. + this.app = fb.app.getApps().length ? fb.app.getApp() : fb.app.initializeApp(this.config.firebaseConfig); + this.messaging = fb.messaging.getMessaging(this.app); + } catch (err) { + console.warn('Push notifications unavailable in this browser', err); + return; + } this.initialized = true; - onMessage(this.messaging, (payload) => { + fb.messaging.onMessage(this.messaging, (payload) => { //this.tostrService.success("", 'התראה חדשה!'); }); return; @@ -53,31 +120,52 @@ export class NotificationsService { async requestPermission() { + if (typeof Notification === 'undefined' || !this.messaging) { + this.tostrService.warning("", 'הדפדפן הזה אינו תומך בהתראות'); + return; + } + // Permission is per browser origin, and every channel on this platform + // shares one origin. Being 'granted' therefore only says the browser may + // show notifications — it says nothing about THIS channel, whose + // subscription list is separate. Enabling push on one channel and then + // tapping the bell on another used to answer "already subscribed" while + // never registering the device with the second channel at all. if (Notification.permission === 'granted') { - this.tostrService.success("", 'כבר אישרתם קבלת התראות!'); + this.subscribeThisDevice('ההתראות לערוץ זה פעילות'); + return; + } + + // Once denied, the browser answers 'denied' straight away without asking + // again — so without this the bell simply did nothing when tapped. + if (Notification.permission === 'denied') { + this.tostrService.warning("", 'ההתראות חסומות בהגדרות הדפדפן לאתר זה'); return; } Notification.requestPermission() .then((permission) => { if (permission === 'granted') { - getToken(this.messaging, { - vapidKey: this.config?.vapid, - }) - .then((currentToken) => { - if (currentToken) { - this.subscribeNotifications(currentToken) - .then((success) => { - if (success) { - this.tostrService.success("", 'התראות הופעלו בהצלחה!'); - } else { - this.tostrService.danger("", 'שגיאה בהגדרת התראות!'); - } - }) - .catch(() => { - this.tostrService.danger("", 'שגיאה בהגדרת התראות!'); - }); + this.subscribeThisDevice('התראות הופעלו בהצלחה!'); + } + }); + } + + /** + * Registers this device's push token with the current channel. Safe to + * repeat: the server keeps one entry per token. + */ + private subscribeThisDevice(successMessage: string): void { + // init() resolved the module before setting this.messaging, so this is the + // cached promise, not a second download. + this.loadFirebase() + .then(fb => fb.messaging.getToken(this.messaging, { vapidKey: this.config?.vapid })) + .then((currentToken) => { + if (currentToken) { + this.subscribeNotifications(currentToken) + .then((success) => { + if (success) { + this.tostrService.success("", successMessage); } else { this.tostrService.danger("", 'שגיאה בהגדרת התראות!'); } @@ -85,13 +173,19 @@ export class NotificationsService { .catch(() => { this.tostrService.danger("", 'שגיאה בהגדרת התראות!'); }); + } else { + this.tostrService.danger("", 'שגיאה בהגדרת התראות!'); } + }) + .catch(() => { + this.tostrService.danger("", 'שגיאה בהגדרת התראות!'); }); } async subscribeNotifications(token: string): Promise<boolean> { if (!token) return false; - return firstValueFrom(this.http.post<boolean>('/api/channel/notifications-subscribe', { token })) + const res = await firstValueFrom(this.http.post<ResponseResult>(`/api/channel/${this.slugService.slug}/notifications-subscribe`, { token })); + return res.success; } } \ No newline at end of file diff --git a/frontend/src/app/services/share.service.ts b/frontend/src/app/services/share.service.ts new file mode 100644 index 00000000..6e6805f8 --- /dev/null +++ b/frontend/src/app/services/share.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@angular/core'; + +export interface ShareData { + title?: string; + text?: string; + url: string; +} + +export type ShareOutcome = 'shared' | 'cancelled' | 'unsupported'; + +/** + * Everything that hands a channel link to someone else: the link itself, the + * clipboard, WhatsApp and the OS share sheet. One service, so every screen + * that offers "share" (the create-success card, my channels, the channel + * header) behaves the same and fails the same way. The clipboard in particular + * is unavailable on plain HTTP and inside some in-app browsers, where a button + * flipping to "copied" with nothing copied is worse than no button. + */ +@Injectable({ providedIn: 'root' }) +export class ShareService { + /** The direct, shareable URL of a channel — never a hardcoded domain. */ + channelUrl(slug: string): string { + return `${window.location.origin}/channel/${slug}`; + } + + /** Whether the OS share sheet (Web Share API) exists here. */ + get canShare(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.share === 'function'; + } + + /** + * Copies text to the clipboard and resolves true on success. On failure it + * hands the user the text itself to copy by hand — in a prompt, which shows + * it preselected and works on HTTP and in in-app browsers; a toast that + * only said "select the link" pointed at a link most callers never show — + * unless `quiet` is set. + */ + async copy(text: string, opts: { quiet?: boolean } = {}): Promise<boolean> { + try { + // `navigator.clipboard?.writeText(...)` resolves to undefined in an + // insecure context, which an `await` happily treats as success. + if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable'); + await navigator.clipboard.writeText(text); + return true; + } catch { + if (!opts.quiet) { + window.prompt('ההעתקה נחסמה בדפדפן — העתיקו את הקישור ידנית:', text); + } + return false; + } + } + + /** A WhatsApp "send to anyone" link carrying the given text. */ + whatsappUrl(text: string): string { + return `https://wa.me/?text=${encodeURIComponent(text)}`; + } + + /** The invitation sentence used by every share button, so it reads the same everywhere. */ + inviteText(channelName: string, url: string): string { + const name = channelName?.trim(); + return name ? `הצטרפו לערוץ "${name}": ${url}` : `הצטרפו לערוץ: ${url}`; + } + + /** Opens the OS share sheet. Never throws; the caller decides what to do on 'unsupported'. */ + async share(data: ShareData): Promise<ShareOutcome> { + if (!this.canShare) return 'unsupported'; + try { + await navigator.share(data); + return 'shared'; + } catch (err: any) { + // The user closing the sheet is not an error worth a toast. + return err?.name === 'AbortError' ? 'cancelled' : 'unsupported'; + } + } +} diff --git a/frontend/src/app/services/slug.service.ts b/frontend/src/app/services/slug.service.ts new file mode 100644 index 00000000..485f6758 --- /dev/null +++ b/frontend/src/app/services/slug.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class SlugService { + slug = ''; +} diff --git a/frontend/src/app/services/super-admin.service.ts b/frontend/src/app/services/super-admin.service.ts new file mode 100644 index 00000000..0fb934bf --- /dev/null +++ b/frontend/src/app/services/super-admin.service.ts @@ -0,0 +1,193 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ChannelUser } from '../models/channel-user.model'; +import { Setting } from '../models/setting.model'; +import { ChannelSeoSettings } from '../models/channel.model'; + +export type { ChannelUser, Setting }; + +export interface ChannelFeatures { + reactions: boolean; + fileUploads: boolean; + reports: boolean; + ads: boolean; + notifications: boolean; + requireAuth: boolean; + requireAuthFiles: boolean; + countViews: boolean; + scheduledMessages: boolean; + webhook: boolean; + magnetLockedByAdmin: boolean; + adsLockedByAdmin: boolean; + // When on, every /api/channel/{slug}/* call answers 403 channel_disabled and + // the channel view shows the "הערוץ מושבת" page. + disabled: boolean; +} + +export interface ChannelData { + slug: string; + name: string; + description: string; + logoUrl: string; + ownerEmail: string; + createdAt: string; + features: ChannelFeatures; + contactUs: string; + // Search settings; an older backend omits them (listed, default text). + seoHidden?: boolean; + seoTitle?: string; + seoDescription?: string; +} + +export interface GlobalAdsConfig { + src: string; + width: number; + lockAll: boolean; + lockedChannels: string[]; +} + +export interface GlobalMagnetConfig { + enabled: boolean; + snippet: string; + mode: string; + perMessages: number; + minTimeSeconds: number; + perSeconds: number; + minMessagesSinceLast: number; + apiKey: string; + lockAll: boolean; + lockedChannels: string[]; + /** Whether a Magnet stats key is stored. The key itself never leaves the server. */ + statsKeySet?: boolean; +} + +/** + * The save body. An empty statsKey keeps the stored key (the screen never + * holds it), so removing it takes the explicit clearStatsKey flag. + */ +export interface GlobalMagnetConfigSave extends GlobalMagnetConfig { + statsKey?: string; + clearStatsKey?: boolean; +} + +export interface SuperAdminUser { + id: string; + username: string; + email: string; + publicName: string; + globalRole: string; + channelRoles: Record<string, string>; +} + +@Injectable({ + providedIn: 'root' +}) +export class SuperAdminService { + + constructor(private http: HttpClient) {} + + getChannels(): Promise<ChannelData[]> { + return firstValueFrom(this.http.get<ChannelData[]>('/api/super-admin/channels')); + } + + createChannel(slug: string, name: string, ownerEmail: string): Promise<ChannelData> { + return firstValueFrom(this.http.post<ChannelData>('/api/super-admin/channels/create', { slug, name, ownerEmail })); + } + + getChannel(slug: string): Promise<ChannelData> { + return firstValueFrom(this.http.get<ChannelData>(`/api/super-admin/channels/${slug}`)); + } + + deleteChannel(slug: string): Promise<void> { + return firstValueFrom(this.http.delete<void>(`/api/super-admin/channels/${slug}`)); + } + + updateChannelFeatures(slug: string, features: ChannelFeatures): Promise<void> { + return firstValueFrom(this.http.put<void>(`/api/super-admin/channels/${slug}/features`, features)); + } + + /** The channel's search settings (backend/channel_seo.go); works on a disabled channel too. */ + updateChannelSeo(slug: string, seo: ChannelSeoSettings): Promise<void> { + return firstValueFrom(this.http.put<void>(`/api/super-admin/channels/${slug}/seo`, seo)); + } + + /** + * Flips only the kill switch. The features endpoint replaces the whole + * record, so the current flags are fetched first and sent back untouched — + * a stale copy from the channels list would silently revert a setting the + * operator changed on the features screen a moment earlier. + */ + async setChannelDisabled(slug: string, disabled: boolean): Promise<ChannelFeatures> { + const channel = await this.getChannel(slug); + const features: ChannelFeatures = { ...channel.features, disabled }; + await this.updateChannelFeatures(slug, features); + return features; + } + + getChannelUsers(slug: string): Promise<ChannelUser[]> { + return firstValueFrom(this.http.get<ChannelUser[]>(`/api/super-admin/channels/${slug}/users`)); + } + + setChannelUsers(slug: string, users: ChannelUser[]): Promise<void> { + return firstValueFrom(this.http.post<void>(`/api/super-admin/channels/${slug}/users`, { users })); + } + + getGlobalUsers(): Promise<SuperAdminUser[]> { + return firstValueFrom(this.http.get<SuperAdminUser[]>('/api/super-admin/users/list')); + } + + setGlobalUsers(users: SuperAdminUser[]): Promise<void> { + return firstValueFrom(this.http.post<void>('/api/super-admin/users/set', { list: users })); + } + + getGlobalSettings(): Promise<Setting[]> { + return firstValueFrom(this.http.get<Setting[]>('/api/super-admin/global-settings/get')); + } + + setGlobalSettings(settings: Setting[]): Promise<void> { + return firstValueFrom(this.http.post<void>('/api/super-admin/global-settings/set', settings)); + } + + getAdsConfig(): Promise<GlobalAdsConfig> { + return firstValueFrom(this.http.get<GlobalAdsConfig>('/api/super-admin/ads/config')); + } + + setAdsConfig(cfg: GlobalAdsConfig): Promise<void> { + return firstValueFrom(this.http.post<void>('/api/super-admin/ads/config', cfg)); + } + + getMagnetConfig(): Promise<GlobalMagnetConfig> { + return firstValueFrom(this.http.get<GlobalMagnetConfig>('/api/super-admin/magnet/config')); + } + + /** Answers with the saved config, redacted like getMagnetConfig. */ + setMagnetConfig(cfg: GlobalMagnetConfigSave): Promise<GlobalMagnetConfig> { + return firstValueFrom(this.http.post<GlobalMagnetConfig>('/api/super-admin/magnet/config', cfg)); + } + + getMagnetStats(): Promise<any> { + return firstValueFrom(this.http.get<any>('/api/super-admin/magnet/stats')); + } + + resetStatistics(): Promise<void> { + return firstValueFrom(this.http.post<void>('/api/super-admin/statistics/reset', {})); + } + + getGlobalStorageConfig(): Promise<{ defaultQuotaGb: number }> { + return firstValueFrom(this.http.get<{ defaultQuotaGb: number }>('/api/super-admin/storage/config')); + } + + setGlobalStorageConfig(defaultQuotaGb: number): Promise<void> { + return firstValueFrom(this.http.post<void>('/api/super-admin/storage/config', { defaultQuotaGb })); + } + + getChannelStorage(slug: string): Promise<any> { + return firstValueFrom(this.http.get<any>(`/api/super-admin/channels/${slug}/storage`)); + } + + setChannelStorage(slug: string, quotaGb: number): Promise<void> { + return firstValueFrom(this.http.put<void>(`/api/super-admin/channels/${slug}/storage`, { quotaGb })); + } +} + diff --git a/frontend/src/app/services/support.service.ts b/frontend/src/app/services/support.service.ts new file mode 100644 index 00000000..0857151c --- /dev/null +++ b/frontend/src/app/services/support.service.ts @@ -0,0 +1,157 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +export type SupportStatus = 'open' | 'answered' | 'closed'; + +export interface SupportMessage { + author: 'user' | 'admin'; + authorName: string; + body: string; + createdAt: string; + /** 'email' when it arrived as a mail to the channels mailbox. */ + source?: 'email'; +} + +export interface SupportTicket { + id: string; + subject: string; + name: string; + email: string; + channelSlug?: string; + authenticated: boolean; + status: SupportStatus; + messages: SupportMessage[]; + createdAt: string; + updatedAt: string; + /** 'email' when the ticket was opened by a mail to the channels mailbox. */ + source?: 'email'; +} + +export interface CreatedTicket { + id: string; + accessToken: string; +} + +/** + * Local record of a ticket opened without a session. The server hands the + * access token back exactly once, at creation, so an anonymous sender who + * loses it has no way to read the reply — it is kept here so the thread + * survives a page reload on the same browser. + * + * Signed-in users need none of this: their tickets are found by session email + * through /api/support/my-tickets. + */ +const ANON_TICKETS_KEY = 'supportTickets'; + +interface StoredTicket { + id: string; + token: string; + subject: string; + createdAt: string; +} + +@Injectable({ providedIn: 'root' }) +export class SupportService { + constructor(private http: HttpClient) {} + + /** + * Opens a ticket. Works with or without a session: when there is one the + * server takes the name and email from it and ignores whatever is passed + * here, so a ticket can never be attributed to someone else. + */ + async createTicket(input: { + subject: string; + body: string; + name?: string; + email?: string; + channelSlug?: string; + }): Promise<CreatedTicket> { + const created = await firstValueFrom( + this.http.post<CreatedTicket>('/api/support/tickets', input), + ); + if (created?.accessToken) { + this.rememberAnonTicket({ + id: created.id, + token: created.accessToken, + subject: input.subject, + createdAt: new Date().toISOString(), + }); + } + return created; + } + + /** A signed-in user's own threads. */ + myTickets(): Promise<SupportTicket[]> { + return firstValueFrom(this.http.get<SupportTicket[]>('/api/support/my-tickets')); + } + + getTicket(id: string, token?: string): Promise<SupportTicket> { + return firstValueFrom( + this.http.get<SupportTicket>(`/api/support/tickets/${id}`, { + headers: this.tokenHeaders(token), + }), + ); + } + + reply(id: string, body: string, token?: string): Promise<SupportTicket> { + return firstValueFrom( + this.http.post<SupportTicket>(`/api/support/tickets/${id}/reply`, { body }, { + headers: this.tokenHeaders(token), + }), + ); + } + + /** + * The anonymous access token travels in a header. As a query parameter it + * was written to the server's request log on every read and reply, and it + * is the only credential on an anonymous thread. + */ + private tokenHeaders(token?: string): Record<string, string> { + return token ? { 'X-Ticket-Token': token } : {}; + } + + // --- super admin --- + + adminList(): Promise<SupportTicket[]> { + return firstValueFrom(this.http.get<SupportTicket[]>('/api/super-admin/support/tickets')); + } + + adminReply(id: string, body: string): Promise<SupportTicket> { + return firstValueFrom( + this.http.post<SupportTicket>(`/api/super-admin/support/tickets/${id}/reply`, { body }), + ); + } + + adminSetStatus(id: string, status: SupportStatus): Promise<SupportTicket> { + return firstValueFrom( + this.http.post<SupportTicket>(`/api/super-admin/support/tickets/${id}/status`, { status }), + ); + } + + // --- anonymous ticket bookkeeping --- + + /** Tickets opened from this browser without a session, newest first. */ + anonTickets(): StoredTicket[] { + try { + const raw = localStorage.getItem(ANON_TICKETS_KEY); + const list = raw ? JSON.parse(raw) : []; + return Array.isArray(list) ? list : []; + } catch { + // Storage unavailable or holding something that is not our list. Losing + // the local record is survivable; throwing here is not. + return []; + } + } + + private rememberAnonTicket(entry: StoredTicket): void { + try { + // Bounded: this is a convenience record, not an archive. + const list = [entry, ...this.anonTickets().filter(t => t.id !== entry.id)].slice(0, 20); + localStorage.setItem(ANON_TICKETS_KEY, JSON.stringify(list)); + } catch { + // Private mode or a full quota. The ticket still exists server-side; the + // sender simply cannot re-open it from this browser. + } + } +} diff --git a/frontend/src/app/services/upload-error.ts b/frontend/src/app/services/upload-error.ts new file mode 100644 index 00000000..60666702 --- /dev/null +++ b/frontend/src/app/services/upload-error.ts @@ -0,0 +1,16 @@ +/** + * The upload endpoint answers each refusal with its own status, and the forms + * used to fold every one of them into the same "שגיאה בהעלאת קובץ": a writer + * whose channel was full could not tell whether to wait, delete something or + * ask the owner. + */ +export function uploadErrorMessage(status: number | undefined): string { + switch (status) { + case 413: return 'הקובץ גדול מדי'; + case 507: return 'האחסון של הערוץ מלא. יש למחוק קבצים או להגדיל את המכסה'; + case 429: return 'יותר מדי העלאות בזמן קצר, נסו שוב בעוד רגע'; + case 403: return 'העלאת קבצים כבויה בערוץ זה'; + case 503: return 'השרת עמוס בהעלאות כרגע, נסו שוב בעוד רגע'; + default: return 'שגיאה בהעלאת קובץ'; + } +} diff --git a/frontend/src/app/utils/storage-format.ts b/frontend/src/app/utils/storage-format.ts new file mode 100644 index 00000000..474abde7 --- /dev/null +++ b/frontend/src/app/utils/storage-format.ts @@ -0,0 +1,24 @@ +/** + * Shared storage formatting, used by both the channel-owner storage panel and + * the super-admin one. They previously carried byte-identical copies that had + * already drifted (one guarded zero as `bytes === 0`, the other as `!bytes`), + * so a change to one screen's number formatting silently diverged from the + * other. Centralised here so they cannot. + */ + +/** Human-readable size. `!bytes` also covers NaN/undefined defensively. */ +export function formatBytes(bytes: number): string { + if (!bytes) return '0 B'; + const gb = bytes / (1024 ** 3); + if (gb >= 1) return gb.toFixed(2) + ' GB'; + const mb = bytes / (1024 ** 2); + if (mb >= 1) return mb.toFixed(1) + ' MB'; + return (bytes / 1024).toFixed(0) + ' KB'; +} + +/** Maps a storage level to a Nebular status colour. */ +export function storageLevelStatus(level: string | undefined): string { + if (level === 'critical') return 'danger'; + if (level === 'warning') return 'warning'; + return 'success'; +} diff --git a/frontend/src/assets/fonts/NotoColorEmoji-Regular/NotoColorEmoji-Regular.ttf b/frontend/src/assets/fonts/NotoColorEmoji-Regular/NotoColorEmoji-Regular.ttf deleted file mode 100644 index 041d9d97..00000000 Binary files a/frontend/src/assets/fonts/NotoColorEmoji-Regular/NotoColorEmoji-Regular.ttf and /dev/null differ diff --git a/frontend/src/assets/fonts/NotoColorEmoji-Regular/OFL.txt b/frontend/src/assets/fonts/NotoColorEmoji-Regular/OFL.txt deleted file mode 100644 index 979c943e..00000000 --- a/frontend/src/assets/fonts/NotoColorEmoji-Regular/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2021 Google Inc. All Rights Reserved. - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/bootstrap-subset.scss b/frontend/src/bootstrap-subset.scss new file mode 100644 index 00000000..9f38ef49 --- /dev/null +++ b/frontend/src/bootstrap-subset.scss @@ -0,0 +1,48 @@ +// Bootstrap, trimmed to what the templates use: the grid, spacing/flex/text +// utilities, tables, forms, and a few small components. The full bootstrap.min.css +// that was loaded before also shipped dropdowns, modals, carousels, offcanvas, +// toasts, navbars and the dark-mode variable set — none of them used — on every +// page load. Add a component's partial here before using its classes. +// +// This compiles the LTR build, exactly like the file it replaces: the templates +// were written against it under <html dir="rtl">, so swapping in the RTL build +// would flip every ms-/me-/text-start class they rely on. +@import 'bootstrap/scss/functions'; + +// Defaults overridden before Bootstrap reads them, so .btn-primary, .alert and +// friends follow the brand theme in themes.scss instead of Bootstrap blue. +$enable-dark-mode: false; +$font-family-sans-serif: Assistant, 'Segoe UI', system-ui, -apple-system, sans-serif; +$primary: #5a5ff5; +$secondary: #6b7280; +$body-color: #1b1f2e; +$body-bg: #f6f7fb; +$border-color: #e6e8f0; +$border-radius: 0.625rem; +$border-radius-sm: 0.5rem; +$border-radius-lg: 0.875rem; +$border-radius-xl: 1rem; +$border-radius-xxl: 1.25rem; + +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/variables-dark'; +@import 'bootstrap/scss/maps'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/utilities'; + +@import 'bootstrap/scss/root'; +@import 'bootstrap/scss/reboot'; +@import 'bootstrap/scss/type'; +@import 'bootstrap/scss/images'; +@import 'bootstrap/scss/containers'; +@import 'bootstrap/scss/grid'; +@import 'bootstrap/scss/tables'; +@import 'bootstrap/scss/forms'; +@import 'bootstrap/scss/buttons'; +@import 'bootstrap/scss/badge'; +@import 'bootstrap/scss/alert'; +@import 'bootstrap/scss/card'; +@import 'bootstrap/scss/spinners'; +@import 'bootstrap/scss/close'; +@import 'bootstrap/scss/helpers'; +@import 'bootstrap/scss/utilities/api'; diff --git a/frontend/src/index.html b/frontend/src/index.html index 019799d6..b5e211ca 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -3,15 +3,29 @@ <head> <meta charset="utf-8"> - <title> + הערוץ - - + + + + + + + + + + + - \ No newline at end of file + diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 292e8c6b..deeac69c 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1,25 +1,112 @@ @use 'themes' as *; @use '@nebular/theme/styles/globals' as *; -@use '@ctrl/ngx-emoji-mart/picker'; +// Nebular's global styles (layout, typography, scrollbars), compiled once for +// the registered `custom` theme. The prebuilt default.css that used to be +// loaded beside this was a second full copy of the same framework CSS with the +// default palette, which this compilation then had to override rule by rule — +// roughly half of the stylesheet bytes every visitor downloaded. @include nb-install() { - @include nb-theme-global(); + @include nb-theme-global(); } -/* You can add global styles to this file, and also import other style files */ -@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css'); +:root { + // Measured at runtime by the channel page (fixed header / composer heights). + // Declared here so a selector reading them before the first measurement + // resolves to 0 instead of an invalid value. + --chrome-height: 0px; + --input-height: 0px; -markdown, -markdown p { - font-weight: 500; - font-size: clamp(0.875rem, 2.5vw, 1.15rem); + // Emoji render with the colour-emoji font every OS ships. The 24 MB + // NotoColorEmoji.ttf that was served from /assets for this purpose — larger + // than the rest of the app several times over — is gone. + --font-emoji: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', 'Twemoji Mozilla', sans-serif; + + // Type scale. Screens used to pick sizes ad hoc (fs-4, fs-5, h6, small), + // so the same kind of text was a different size on every page. + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 1rem; + --text-feed: 1.0625rem; + --text-lg: 1.15rem; + --text-xl: 1.375rem; + --text-2xl: 1.75rem; + --text-3xl: 2.25rem; + --leading-tight: 1.3; + --leading-body: 1.6; + + // Motion. One speed and one curve for every hover, press and reveal, so + // the app moves as one thing; the reduced-motion block below turns it off. + --motion-fast: 120ms; + --motion-base: 200ms; + --motion-slow: 320ms; + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +// A shorter header on phones: the theme's 4.75rem bar left a 360px screen +// with little room for the feed. Every layout rule reads the token, so the +// fixed-header offsets follow. +@media (max-width: 575.98px) { + .nb-theme-custom { + --header-height: 4.25rem; + --header-padding: 0.75rem; + } +} + +// Nebular's toast is a fixed 400px: on a 360px phone its left 48px, and the +// end of the text, were off-screen. 1rem covers its 0.5rem margin on each side. +nb-toast { + max-width: calc(100vw - 1rem); +} + +// Buttons answer the pointer: a short colour/shadow transition and a slight +// press. Nebular styles them without any transition. +[nbButton], +.appearance-filled, +.appearance-ghost, +.appearance-outline { + transition: + background-color var(--motion-fast) var(--ease-out), + border-color var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out), + box-shadow var(--motion-fast) var(--ease-out), + transform var(--motion-fast) var(--ease-out); + + &:active:not([disabled]) { + transform: scale(0.97); + } +} + +html { + -webkit-text-size-adjust: 100%; } -@font-face { - font-family: 'Noto Color Emoji'; - src: url('assets/fonts/NotoColorEmoji-Regular/NotoColorEmoji-Regular.ttf') format('truetype'); +body { + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } .emoji { - font-family: 'Noto Color Emoji', sans-serif; -} \ No newline at end of file + font-family: var(--font-emoji); +} + +// Feed text. Weight 500 used to be requested here although only 400/600/700/800 +// are loaded, so browsers synthesised it or fell back silently. +markdown, +markdown p { + font-weight: 400; + font-size: var(--text-feed); + line-height: var(--leading-body); +} + +// Honour the OS "reduce motion" setting for every transition the theme adds. +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/frontend/src/themes.scss b/frontend/src/themes.scss index 896c1b2b..7bb02d6e 100644 --- a/frontend/src/themes.scss +++ b/frontend/src/themes.scss @@ -2,8 +2,121 @@ @use '@nebular/theme/styles/theming' as *; @use '@nebular/theme/styles/themes/default'; -$nb-themes: nb-register-theme((font-family-primary: 'Assistant, sans-serif', - font-family-secondary: 'Assistant, sans-serif', - ), - custom, - default); \ No newline at end of file +// The brand theme. Nebular emits every token below as a CSS custom property +// (--color-primary-500, --card-border-radius, ...), and component styles read +// them through nb-theme() / var(), so the look of the whole app is decided here +// and nowhere else. Only the tokens that differ from Nebular's default theme +// are listed; everything else is inherited. +// +// The font tokens are Sass lists on purpose. The previous value was the quoted +// string 'Assistant, sans-serif', which CSS reads as ONE family literally named +// "Assistant, sans-serif" — no such font exists, so body text silently fell +// back to the browser default and the web font was never actually used. +$brand-font: (Assistant, 'Segoe UI', system-ui, -apple-system, sans-serif); + +$nb-themes: nb-register-theme(( + // ---------- typography ---------- + font-family-primary: $brand-font, + font-family-secondary: $brand-font, + text-heading-1-font-weight: 800, + text-heading-2-font-weight: 800, + text-heading-1-font-size: 2.25rem, + text-heading-2-font-size: 1.75rem, + text-heading-3-font-size: 1.375rem, + text-heading-4-font-size: 1.15rem, + text-paragraph-line-height: 1.6rem, + + // ---------- brand palette (indigo) ---------- + color-primary-100: #e8e9fe, + color-primary-200: #c9cbfd, + color-primary-300: #a3a6fb, + color-primary-400: #7b7ff8, + color-primary-500: #5a5ff5, + color-primary-600: #4b50e0, + color-primary-700: #3d41c4, + color-primary-800: #2f33a3, + color-primary-900: #232680, + color-primary-transparent-100: rgba(90, 95, 245, 0.08), + color-primary-transparent-200: rgba(90, 95, 245, 0.16), + color-primary-transparent-300: rgba(90, 95, 245, 0.24), + color-primary-transparent-400: rgba(90, 95, 245, 0.32), + color-primary-transparent-500: rgba(90, 95, 245, 0.4), + color-primary-transparent-600: rgba(90, 95, 245, 0.48), + + // ---------- neutrals: cool greys that sit well under the indigo ---------- + color-basic-100: #ffffff, + color-basic-200: #f6f7fb, + color-basic-300: #eef0f6, + color-basic-400: #e6e8f0, + color-basic-500: #d5d8e3, + color-basic-600: #9aa0b4, + color-basic-700: #6b7280, + color-basic-800: #3b4052, + color-basic-900: #262a3a, + color-basic-1000: #1b1f2e, + color-basic-1100: #12141f, + color-basic-transparent-100: rgba(107, 114, 128, 0.08), + color-basic-transparent-200: rgba(107, 114, 128, 0.16), + color-basic-transparent-300: rgba(107, 114, 128, 0.24), + color-basic-transparent-400: rgba(107, 114, 128, 0.32), + color-basic-transparent-500: rgba(107, 114, 128, 0.4), + color-basic-transparent-600: rgba(107, 114, 128, 0.48), + + // Text: the default maps body text to basic-800 and hints to basic-600, which + // with the lighter neutrals above would fail contrast. Pin them one step darker. + text-basic-color: color-basic-1000, + text-hint-color: color-basic-700, + text-disabled-color: color-basic-600, + button-ghost-basic-text-color: color-basic-700, + button-ghost-basic-focus-text-color: color-basic-700, + button-ghost-basic-hover-text-color: color-basic-700, + button-ghost-basic-active-text-color: color-basic-700, + + // ---------- shape ---------- + border-radius: 0.625rem, + card-border-radius: 1rem, + button-rectangle-border-radius: 0.625rem, + input-rectangle-border-radius: 0.625rem, + select-rectangle-border-radius: 0.625rem, + toastr-border-radius: 0.75rem, + popover-border-radius: 0.75rem, + context-menu-border-radius: 0.75rem, + tooltip-border-radius: 0.5rem, + menu-item-border-radius: 0.625rem, + + // ---------- depth ---------- + shadow: (0 12px 32px -16px rgba(27, 31, 46, 0.28), 0 2px 6px rgba(27, 31, 46, 0.06)), + card-shadow: (0 1px 2px rgba(27, 31, 46, 0.04), 0 10px 30px -18px rgba(27, 31, 46, 0.18)), + card-border-color: border-basic-color-3, + card-padding: 1rem 1.25rem, + header-shadow: 0 1px 0 0 rgba(27, 31, 46, 0.06), + header-padding: 1rem, + divider-color: border-basic-color-3, + layout-background-color: background-basic-color-2, + user-picture-box-border-color: border-basic-color-4, + + // Latin text in buttons and tabs (WhatsApp, URLs) must not be shouted. + button-filled-text-transform: none, + button-outline-text-transform: none, + button-ghost-text-transform: none, + tabset-tab-text-transform: none, + route-tabset-tab-text-transform: none, + tabset-tab-padding: 0.75rem 1.25rem, + route-tabset-tab-padding: 0.75rem 1.25rem, + + // Menu (the manage page's side navigation). + menu-item-padding: 0.65rem 0.9rem, + menu-item-hover-background-color: color-basic-transparent-100, + menu-item-active-background-color: color-primary-transparent-100, + + scrollbar-color: color-basic-500, + scrollbar-background-color: transparent, + scrollbar-width: 0.375rem, +), custom, default); + +// Only the brand theme is emitted. nb-install() otherwise writes a CSS block +// for every registered theme, so the `default` parent — which no element ever +// carries, NbThemeModule is started with `custom` — cost 211 KB of custom +// properties in every visitor's stylesheet. It stays registered: the brand +// theme inherits the tokens it does not override from it. +$nb-enabled-themes: (custom); diff --git a/sample.env b/sample.env index 04e42125..662444d9 100644 --- a/sample.env +++ b/sample.env @@ -1,10 +1,39 @@ # Backend SERVER_PORT=3000 -SECRET_KEY=supersecretkey +# Signs session cookies. Use a random string of at least 32 bytes (e.g. `openssl rand -hex 32`). +# Empty is a fatal boot error; shorter than 32 bytes logs a warning on every start. +SECRET_KEY=change-me-32-random-bytes-min REDIS_ADDR=/app/data/kvrocks.sock REDIS_PROTOCOL=unix # Google-oauth2 GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret -ADMIN_USERS=example@gmail.com,example1@gmail.com \ No newline at end of file +ADMIN_USERS=example@gmail.com,example1@gmail.com + +# Cloudflare R2 Storage (optional — if not set, files stored locally in /app/files/) +# To enable R2, uncomment all four lines below and fill in real values. +# A value still starting with "your_" is treated as unset: R2 stays off, files +# stay local and the server logs a warning at boot. Only real values switch it on. +# R2_ACCOUNT_ID=your_cloudflare_account_id +# R2_ACCESS_KEY_ID=your_r2_access_key_id +# R2_SECRET_ACCESS_KEY=your_r2_secret_access_key +# R2_BUCKET_NAME=your_bucket_name +# Optional: public bucket URL for direct redirects. Leave unset for a private +# bucket (pre-signed URLs). Comments stay on their own line on purpose: with +# `docker run --env-file` and the CapRover bulk editor, an inline "# ..." after a +# value becomes part of the value. +# R2_PUBLIC_URL=https://pub-xxxx.r2.dev + +# System emails through Magnet (optional — both unset means no emails are sent). +# Magnet sends the channel welcome mail and support answers under its own sender. +# MAGNET_MAIL_URL=https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/channels-mail +# MAGNET_MAIL_SECRET=the_shared_secret_from_magnet +# Origin for links in those emails when project_domain is not https. +# PUBLIC_BASE_URL=https://arootz.magnetads.co.il + +# Search-engine ownership proofs (optional). The super-admin "site" settings +# take precedence; these are for a deployment configured only by environment. +# Either the bare code or the whole tag the console shows. +# GOOGLE_SITE_VERIFICATION= +# BING_SITE_VERIFICATION=