diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..286efea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# 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 + +# 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/.gitignore b/.gitignore index c462de3..b462729 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ frontend/node_modules/ frontend/dist/* package-lock.json backend/__debug* +backend/channel caddy_data/ redis_data/ channel_data/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..62d25c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,265 @@ +# 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 (12 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 +- `enforceStorageQuota` — 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 +/api/user-info + +/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 + +/api/channel/{slug}/import/post (API key auth) + +/api/channel/{slug}/* (channelMiddleware + channelIfRequireAuth) + GET /info + GET /messages + GET /events + GET /files/{fileid} + POST /files + 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 owner+) + PUT /info + POST /messages + PUT /messages/{id} + DELETE /messages/{id} + POST /emojis + GET/POST /settings + GET/POST /users + GET/PUT /storage + POST /storage/auto-cleanup + GET/PUT /scheduled-messages + GET /statistics + GET/POST /reports +``` + +### 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`): < 80% used + - Orange (`warning`): 80–90% used + - Red (`critical`): > 90% used +- **Auto-cleanup toggle**: When enabled, automatically deletes oldest files to reach 80% usage before new uploads, ensuring uploads always succeed +- 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 +R2_ACCOUNT_ID=your_account_id +R2_ACCESS_KEY_ID=your_access_key_id +R2_SECRET_ACCESS_KEY=your_secret_access_key +R2_BUCKET_NAME=your_bucket_name +R2_PUBLIC_URL=https://pub-xxxx.r2.dev # optional, for direct CDN links +``` + +--- + +## 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` diff --git a/Dockerfile b/Dockerfile index 9e0a4bb..3076aef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,24 @@ -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 diff --git a/SET.md b/SET.md index 63d9d02..360afa1 100644 --- a/SET.md +++ b/SET.md @@ -148,6 +148,69 @@ POST https://example.com/api/import/post |`fcm_json_universe_domain`| +## ממשק ניהול ההגדרות (UI חזותי) +ממשק ההגדרות עוצב מחדש לתצוגה חזותית מסודרת במקום טבלת key/value. ההגדרות מחולקות לקטגוריות בכרטיסים נפרדים, כל הגדרה מוצגת עם תווית והסבר בעברית, ולכל סוג שדה רכיב מתאים: +* בוליאנים מוצגים כ-toggle (כן/לא). +* שדות מספריים, טקסט, סיסמה ו-URL מקבלים רכיבים מתאימים. +* שדות ארוכים (כגון snippets ו-private keys) מוצגים כ-textarea. +* שדות התלויים בהפעלת תכונה ראשית (כגון FCM) מסתתרים אוטומטית כשהתכונה מכובה. + +הקטגוריות הקיימות: +* כללי +* אנליטיקס וטראקינג +* הזדהות ואבטחה +* מונה צפיות +* פרסומות (iframe סטטי בצד הערוץ) +* וובהוק +* התראות דחיפה (FCM) +* חשבון שירות FCM (Service Account) - כולל אפשרות הדבקת קובץ JSON שלם עם פיצול אוטומטי לכל השדות +* החלפות טקסט אוטומטיות (רשימה דינמית של כללי regex עם שדות תבנית והחלפה נפרדים) +* הגדרות מתקדמות / מותאמות אישית (אזור מתקפל בתחתית עם הטבלה הישנה של key/value חופשי, לטובת הגדרות שאינן בסכמת ה-UI) + +תחת המכסה, השמירה ממירה את הכל בחזרה למבנה ה-API הקיים `[{key, value}]` כך שאין שינוי בהיסט ה-Redis (`settings:list`). + +## תג אנליטיקס בראש האתר +ניתן להזין כל קוד HTML/JS של שירות אנליטיקס (לדוגמא Google Analytics gtag.js, Google Tag Manager, Meta Pixel וכו') תחת ההגדרה: +`analytics_head` + +הקוד יוזרק על ידי השרת לתוך תגית `` של ה-`index.html` בכל בקשת עמוד SPA. ההזרקה מתבצעת בצד השרת לפני שהדפדפן מקבל את ה-HTML, כך שהקוד פועל מהרגע הראשון. + +## שילוב פרסומות ממגנט (Magnet) +Magnet היא פלטפורמת פרסומות חיצונית. ניתן לשלב פרסומות שלה כהודעות בתוך הערוץ. הניהול דרך לשונית ייעודית בממשק הניהול: **שילוב פרסומות ממגנט**. + +### זרימה ועקרונות +* הפרסומת מוטמעת כקוד embed (HTML/JS) שמספקת מגנט. +* כל פרסומת מתרנדרת רק כשהגולש גולל אליה (`IntersectionObserver` עם `rootMargin: 200px`), כך שהקריאה לשרת מגנט מתבצעת בזמן אמת לכל גולש בנפרד. +* כל טעינה מחדש של הסלוט מבצעת קריאה חדשה — כל גולש יכול לקבל פרסומת אחרת ובכל גלילה גם אותה גולש יכול לראות פרסומת אחרת. +* אם בתוך 5 שניות מההזרקה הסלוט נשאר ריק (אין DOM/אין גובה), הוא מתקפל אוטומטית (`display: none`) — נופל בשקט לכלום במקום להציג אזור ריק. +* הפרסומת מתרנדרת בתוך בועת הודעה כמו הודעת מנהל רגילה (לוגו הערוץ + שם + תווית "פרסומת" + הקוד המוטמע בתוך `.message-card`). + +### תדירות הצגה +שני מצבים בלעדיים, נבחרים דרך ה-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 לביטול. + +### נתוני הקלקות ותגמולים +בלשונית מגנט קיים כרטיס ייעודי לצפייה בנתוני הקלקות ותגמולים בזמן אמת ממגנט. +* יש להזין `magnet_api_key` (publisher key שמתקבל ממגנט) ולשמור. +* כפתור **הצג נתוני הקלקות ותגמולים** טוען את הנתונים. הקריאה למגנט מתבצעת מצד השרת בלבד דרך endpoint מוגן `GET /api/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 של פרסומות לתוך רשימת הצ'אט נעשה ע"י תוספת בתוך ה-`` של ההודעה (לא יצירת `` שני בכל iteration), כדי לא לשבור את ה-content children של `` של Nebular. ה-component `chat.component` שומר על `Set` של ה-IDs של ההודעות שאחריהן יש להציג פרסומת, ומחשב מחדש את ה-Set בכל שינוי ב-`messages` (טעינה ראשונית, scroll, SSE). + ## ריכוז הגדרות בממשק ניהול |setting |value | הסבר | |---------------|------|------| @@ -156,11 +219,26 @@ POST https://example.com/api/import/post |`api_secret_key`|`1`|מפתח עבור יבוא הודעות באמצעות API| |`webhook_url`|`https://example.com/webhook`|כתובת לשליחת וובהוק| |`webhook_verify_token`|`your-secret-token`|טוקן לשליחה יחד עם וובהוק| -|`ad-iframe-src`| |קישור HTML להטמעת פרסומת| -|`ad-iframe-width`|`300`|רוחב פרסום| +|`ad-iframe-src`| |קישור HTML להטמעת פרסומת בעמודה הצידית| +|`ad-iframe-width`|`300`|רוחב פרסום בעמודה הצידית| |`count_views`|`1`|הפעלת מונה צפיות פר הודעה| |`regex-replace`|`(.*?\!)(.*)#**$1**$2`|ערך של רגקס והחלפה בכדי ליצור החלפות אוטומטיות לטקסטים| |`on_notification`|`1`|הפעלת התראות דחיפה| |`max_file_size`|`50`|הגבלת משקל קבצים| |`custom_title`||title מותאם אישית| -|`contact_us`|url|הפעלת כפתור צור קשר| \ No newline at end of file +|`contact_us`|url|הפעלת כפתור צור קשר| +|`analytics_head`|``|קוד אנליטיקס שיוזרק לתוך תגית `` של כל עמוד| +|`magnet_enabled`|`1`|הפעלת שילוב פרסומות ממגנט בערוץ| +|`magnet_snippet`|``|קוד הטמעה (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 = ללא החרגה)| +|`magnet_api_key`|`d8059fc0-...`|מפתח API של מגנט לשליפת נתוני הקלקות ותגמולים| + +## API endpoints נוספים שנוספו +| Method | Path | הסבר | +|--------|------|------| +|GET|`/api/ads/magnet`|מחזיר את הגדרות מגנט הציבוריות (snippet, mode, thresholds) לקליינט. ה-snippet מוחזר רק כש-`magnet_enabled=1`. `magnet_api_key` לעולם לא מוחזר.| +|GET|`/api/admin/magnet/stats`|Admin protected. עושה proxy ל-publisher-stats של מגנט עם ה-API key מההגדרות, כך שה-key לא נחשף לדפדפן.| \ No newline at end of file diff --git a/backend/ads.go b/backend/ads.go index b868cb4..93e835e 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -1,8 +1,13 @@ package main import ( + "context" "encoding/json" + "io" "net/http" + "net/url" + "slices" + "time" ) type AdsSettings struct { @@ -10,12 +15,264 @@ type AdsSettings struct { Width int64 `json:"width"` } +// 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 + } + if ch != nil && 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} + 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() + + 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}) +} + +// 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. +func syncLockFlags(lockAll bool, lockedChannels []string, get func(*ChannelFeatures) *bool) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + return + } + + for _, ch := range channels { + shouldLock := lockAll || slices.Contains(lockedChannels, ch.Slug) + flag := get(&ch.Features) + if *flag != shouldLock { + *flag = shouldLock + dbSetChannelFeatures(ctx, ch.Slug, &ch.Features) + } + } +} + +// 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"` +} + +// 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 + } + if ch != nil && 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) { + // Super admin override: use global magnet config + settings = MagnetAdsSettings{ + Enabled: globalMagnet.Enabled, + Mode: globalMagnet.Mode, + PerMessages: globalMagnet.PerMessages, + MinTimeSeconds: globalMagnet.MinTimeSeconds, + PerSeconds: globalMagnet.PerSeconds, + MinMessagesSinceLast: globalMagnet.MinMessagesSinceLast, + } + 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) } + +// 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 { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(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 cfg GlobalMagnetConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + 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) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} + +// 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 }) +} + +const magnetStatsURL = "https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/publisher-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 || globalMagnet.ApiKey == "" { + http.Error(w, `{"error":"missing_api_key","message":"Magnet API key is not configured"}`, http.StatusBadRequest) + return + } + + q := url.Values{} + q.Set("k", globalMagnet.ApiKey) + reqURL := magnetStatsURL + "?" + q.Encode() + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reqURL, nil) + if err != nil { + http.Error(w, `{"error":"request_build_failed"}`, http.StatusInternalServerError) + return + } + + 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(resp.Body) + if err != nil { + http.Error(w, `{"error":"upstream_read_failed"}`, http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} diff --git a/backend/api.go b/backend/api.go index a02c291..f3a7136 100644 --- a/backend/api.go +++ b/backend/api.go @@ -2,16 +2,31 @@ 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() + + 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 { + http.Error(w, "error", http.StatusUnauthorized) return } @@ -19,6 +34,10 @@ func addNewPost(w http.ResponseWriter, r *http.Request) { 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 (same cap style as submitChannelRequest). + 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,18 +45,34 @@ func addNewPost(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + if len(body.Text) > 100_000 { + 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 diff --git a/backend/auth.go b/backend/auth.go index 0fa5069..7b74072 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 { @@ -94,21 +94,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("", email) u, err := getUser(ctx, payload.Claims) if err != nil { @@ -118,15 +133,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_ 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 +196,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 +204,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) } @@ -221,19 +252,31 @@ 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 - } - 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 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 { user = User{ ID: id, @@ -244,3 +287,13 @@ func getUser(ctx context.Context, claims map[string]any) (*User, error) { 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/channelInfo.go b/backend/channelInfo.go index 9f2ef23..b82d6e3 100644 --- a/backend/channelInfo.go +++ b/backend/channelInfo.go @@ -4,50 +4,61 @@ import ( "context" "encoding/json" "net/http" - "strconv" "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"` } 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.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) @@ -57,10 +68,16 @@ func editChannelInfo(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + + // ContactUs is a pointer so an absent field (the shape the current client + // posts) can be told apart from a deliberately cleared one. Only the latter + // may overwrite the stored value. 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"` } var req Request @@ -70,17 +87,17 @@ func editChannelInfo(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - if _, err := rdb.HSet(ctx, "channel:1", "name", req.Name, "description", req.Description, "logoUrl", req.LogoUrl).Result(); err != nil { + hashKey := "channel:" + slug + args := []any{"name", req.Name, "description", req.Description, "logoUrl", req.LogoUrl} + if req.ContactUs != nil { + args = append(args, "contactUs", *req.ContactUs) + } + if _, err := rdb.HSet(ctx, hashKey, args...).Result(); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } 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 0000000..5296491 --- /dev/null +++ b/backend/channel_create.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "time" +) + +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 + // Mirrors the limits submitChannelRequest already enforces on the public path. + maxChannelNameLen = 100 + maxChannelDescLen = 2000 +) + +// reservedSlugs are the top-level frontend route paths (see +// frontend/src/app/app.routes.ts) plus the prefixes the backend serves itself. +// A channel taking one of these would be unreachable at / and would +// shadow the real page. +var reservedSlugs = map[string]struct{}{ + "login": {}, + "channel": {}, + "channels": {}, + "super-admin": {}, + "admin": {}, + "api": {}, + "assets": {}, + "auth": {}, + "static": {}, +} + +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 + } + for _, u := range users { + if 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: the public /api/channel-request path accepts an arbitrary +// email, and auto-creating from that 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 primary channel creation path: a logged-in user gets their channel +// instantly instead of waiting for a super admin to approve a +// /api/channel-request. That older public endpoint stays as the fallback for +// visitors who are not logged in. +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 + } + + if !channelCreateLimiter(s.Email).Allow() { + http.Error(w, "too many requests — please try again later", http.StatusTooManyRequests) + return + } + + 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 + } + + if body.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + if len(body.Name) > maxChannelNameLen || len(body.Description) > maxChannelDescLen { + http.Error(w, "field too long", http.StatusBadRequest) + return + } + + 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 + } + + owned, err := countOwnedChannels(ctx, s.Email) + 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 + } + + channel := &ChannelData{ + Slug: body.Slug, + Name: body.Name, + Description: body.Description, + OwnerEmail: s.Email, + 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 + } + + dbAssignChannelRole(ctx, s.Email, channel.Slug, RoleOwner) + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after createChannelSelfService(%s): %v", channel.Slug, err) + } + + // Recorded as an already-approved request so the super admin's channel + // requests screen becomes an audit log of self-service creations instead of + // going empty now that this is the primary path. + req := &ChannelRequest{ + ID: generatedRandomID(12), + Name: sessionDisplayName(s), + Email: s.Email, + DesiredSlug: channel.Slug, + Description: channel.Description, + Status: RequestStatusApproved, + Notes: "self-service creation: " + channel.Name, + ApprovedSlug: channel.Slug, + CreatedAt: channel.CreatedAt, + } + if err := dbSaveChannelRequest(ctx, req); err != nil { + // The channel exists and is usable; losing the audit record must not fail + // the creation the user already paid for. + log.Printf("save self-service channel request record for %s: %v", channel.Slug, err) + } + + 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= (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 + } + + if !slugCheckLimiter(s.Email).Allow() { + http.Error(w, "too many requests — please try again later", http.StatusTooManyRequests) + 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 0000000..6c75c88 --- /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_requests.go b/backend/channel_requests.go new file mode 100644 index 0000000..76e496c --- /dev/null +++ b/backend/channel_requests.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/mail" + "strconv" + "time" + + "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" +) + +const ( + // channelRequestTTL bounds how long a pending request (and its index entry) + // survives, since anyone can create them. + channelRequestTTL = 30 * 24 * time.Hour + // maxChannelRequestsListed caps the admin queue read, which does one GET per + // entry. + maxChannelRequestsListed = 500 +) + +type RequestStatus string + +const ( + RequestStatusPending RequestStatus = "pending" + RequestStatusApproved RequestStatus = "approved" + RequestStatusRejected RequestStatus = "rejected" +) + +type ChannelRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + DesiredSlug string `json:"desiredSlug"` + Description string `json:"description"` + Status RequestStatus `json:"status"` + Notes string `json:"notes"` // admin notes / rejection reason + ApprovedSlug string `json:"approvedSlug"` // final slug after approval + CreatedAt time.Time `json:"createdAt"` +} + +func dbSaveChannelRequest(ctx context.Context, req *ChannelRequest) error { + data, err := json.Marshal(req) + if err != nil { + return err + } + // TTL so an unbounded public endpoint cannot accumulate records forever. + if err := rdb.Set(ctx, "channel_request:"+req.ID, data, channelRequestTTL).Err(); err != nil { + return err + } + return rdb.ZAdd(ctx, "channel_requests:list", redis.Z{ + Score: float64(req.CreatedAt.Unix()), + Member: req.ID, + }).Err() +} + +func dbGetChannelRequest(ctx context.Context, id string) (*ChannelRequest, error) { + data, err := rdb.Get(ctx, "channel_request:"+id).Result() + if err != nil { + return nil, err + } + var req ChannelRequest + if err := json.Unmarshal([]byte(data), &req); err != nil { + return nil, err + } + return &req, nil +} + +func dbListChannelRequests(ctx context.Context) ([]*ChannelRequest, error) { + // Drop index entries whose bodies have already expired, then read a bounded + // page: the index is fed by an unauthenticated endpoint. + cutoff := strconv.FormatInt(time.Now().Add(-channelRequestTTL).Unix(), 10) + rdb.ZRemRangeByScore(ctx, "channel_requests:list", "-inf", cutoff) + + ids, err := rdb.ZRevRange(ctx, "channel_requests:list", 0, maxChannelRequestsListed-1).Result() + if err != nil { + return nil, err + } + requests := make([]*ChannelRequest, 0, len(ids)) + for _, id := range ids { + req, err := dbGetChannelRequest(ctx, id) + if err != nil { + continue + } + requests = append(requests, req) + } + return requests, nil +} + +// POST /api/channel-request (public) +func submitChannelRequest(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Unauthenticated endpoint: cap the body, throttle per IP and validate every + // field before it becomes a persistent record in the super admin's queue. + if !channelRequestLimiter(r).Allow() { + http.Error(w, "too many requests — please try again later", http.StatusTooManyRequests) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + defer r.Body.Close() + + var body struct { + Name string `json:"name"` + Email string `json:"email"` + DesiredSlug string `json:"desiredSlug"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if body.Name == "" || body.Email == "" || body.DesiredSlug == "" { + http.Error(w, "name, email, and desiredSlug are required", http.StatusBadRequest) + return + } + if len(body.Name) > 100 || len(body.Email) > 254 || len(body.Description) > 2000 { + http.Error(w, "field too long", http.StatusBadRequest) + return + } + if !slugRegex.MatchString(body.DesiredSlug) { + http.Error(w, "invalid slug", http.StatusBadRequest) + return + } + if _, err := mail.ParseAddress(body.Email); err != nil { + http.Error(w, "invalid email", http.StatusBadRequest) + return + } + + req := &ChannelRequest{ + ID: generatedRandomID(12), + Name: body.Name, + Email: body.Email, + DesiredSlug: body.DesiredSlug, + Description: body.Description, + Status: RequestStatusPending, + CreatedAt: time.Now(), + } + + if err := dbSaveChannelRequest(ctx, req); err != nil { + http.Error(w, "error saving request", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok", "id": req.ID}) +} + +// GET /api/super-admin/channel-requests +func listChannelRequests(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + requests, err := dbListChannelRequests(ctx) + if err != nil { + requests = []*ChannelRequest{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(requests) +} + +// POST /api/super-admin/channel-requests/{id}/approve +func approveChannelRequest(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + id := chi.URLParam(r, "id") + + var body struct { + Slug string `json:"slug"` + Notes string `json:"notes"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + req, err := dbGetChannelRequest(ctx, id) + if err != nil { + http.Error(w, "request not found", http.StatusNotFound) + return + } + + // Without this, replaying approve with a different slug creates a second + // channel and a second owner grant for the same applicant. + if req.Status != RequestStatusPending { + http.Error(w, "request already processed", http.StatusConflict) + return + } + + finalSlug := body.Slug + if finalSlug == "" { + finalSlug = req.DesiredSlug + } + + if !slugRegex.MatchString(finalSlug) { + http.Error(w, "invalid slug format", http.StatusBadRequest) + return + } + + exists, err := dbChannelExists(ctx, finalSlug) + if err != nil { + http.Error(w, "error checking slug", http.StatusInternalServerError) + return + } + if exists { + http.Error(w, "slug already taken", http.StatusConflict) + return + } + + channel := &ChannelData{ + Slug: finalSlug, + Name: req.Name, + OwnerEmail: req.Email, + CreatedAt: time.Now(), + Features: defaultChannelFeatures(), + } + + if err := dbCreateChannel(ctx, channel); err != nil { + if errors.Is(err, errChannelExists) { + http.Error(w, "slug already taken", http.StatusConflict) + return + } + http.Error(w, "error creating channel", http.StatusInternalServerError) + return + } + + dbAssignChannelRole(ctx, req.Email, finalSlug, RoleOwner) + initializePrivilegeUsers() + + req.Status = RequestStatusApproved + req.ApprovedSlug = finalSlug + req.Notes = body.Notes + dbSaveChannelRequest(ctx, req) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "approved", + "channelSlug": finalSlug, + "channelName": channel.Name, + "ownerEmail": req.Email, + }) +} + +// POST /api/super-admin/channel-requests/{id}/reject +func rejectChannelRequest(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + id := chi.URLParam(r, "id") + + var body struct { + Notes string `json:"notes"` + } + json.NewDecoder(r.Body).Decode(&body) + + req, err := dbGetChannelRequest(ctx, id) + if err != nil { + http.Error(w, "request not found", http.StatusNotFound) + return + } + + if req.Status != RequestStatusPending { + http.Error(w, "request already processed", http.StatusConflict) + return + } + + req.Status = RequestStatusRejected + req.Notes = body.Notes + dbSaveChannelRequest(ctx, req) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "rejected"}) +} diff --git a/backend/channels.go b/backend/channels.go new file mode 100644 index 0000000..2c9e023 --- /dev/null +++ b/backend/channels.go @@ -0,0 +1,463 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "regexp" + "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 +} + +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"` +} + +type ctxKey string + +const channelCtxKey ctxKey = "channel" + +var slugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9\-]{1,48}[a-z0-9]$`) + +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() + + 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 + } + + 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 + } + + 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: 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, + }, + } + + 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 != "" { + dbAssignChannelRole(ctx, req.OwnerEmail, req.Slug, RoleOwner) + 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. + if !slugRegex.MatchString(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 + } + + if err := dbDeleteChannel(ctx, slug); 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: 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") + + 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() + + if err := dbSetChannelFeatures(ctx, slug, &features); 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: 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) +} + +// 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") + + var req struct { + Users []struct { + Email string `json:"email"` + Role ChannelRole `json:"role"` + } `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() + + // Read-modify-write under a WATCH so a concurrent role edit elsewhere is not + // silently overwritten by this one. + if err := dbUpdateUsersList(ctx, func(users []User) []User { + userMap := make(map[string]int) + for i, u := range users { + userMap[u.Email] = i + } + + for _, ru := range req.Users { + if i, exists := userMap[ru.Email]; exists { + 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 + }); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after superAdminSetChannelUsers: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: 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") + + 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 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) +} + +// Channel owner: get channel users (for this channel only) +func getChannelUsers(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + + 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 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) +} + +// Channel owner: set channel users (cannot assign owner role) +func setChannelUsers(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + + var req struct { + Users []struct { + Email string `json:"email"` + Role ChannelRole `json:"role"` + } `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() + + // Read-modify-write under a WATCH so a concurrent role edit elsewhere is not + // silently overwritten by this one. + if err := dbUpdateUsersList(ctx, func(users []User) []User { + userMap := make(map[string]int) + for i, u := range users { + userMap[u.Email] = i + } + + for _, ru := range req.Users { + if ru.Role == RoleOwner { + continue // owner cannot promote others to owner + } + if i, exists := userMap[ru.Email]; exists { + 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 + }); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after setChannelUsers: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{Success: true}) +} diff --git a/backend/db.go b/backend/db.go index ee23fda..86061f8 100644 --- a/backend/db.go +++ b/backend/db.go @@ -1,8 +1,10 @@ package main import ( + "cmp" "context" "encoding/json" + "errors" "fmt" "log" "os" @@ -14,10 +16,12 @@ 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 type Message struct { ID int `json:"id" redis:"id"` @@ -35,11 +39,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 +55,21 @@ 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, + // Support single-instance, Redis Sentinel, and Redis Cluster via env vars: + // REDIS_ADDRS — comma-separated list; multiple = cluster mode + // REDIS_MASTER — master name for Sentinel + 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, }) _, err := rdb.Ping(ctx).Result() @@ -65,22 +80,49 @@ func init() { log.Println("Connection to DB successful!") } -func getMessageNextId(ctx context.Context) int { - id, err := rdb.Incr(ctx, "message:next_id").Result() +// 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) { + rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: fmt.Sprintf("channel:%s:events", slug), + MaxLen: 1000, + Approx: true, + Values: map[string]interface{}{"data": string(data)}, + }) +} + +// 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 getLastModified(ctx context.Context, slug string) string { + v, _ := rdb.Get(ctx, fmt.Sprintf("channel:%s:last_modified", slug)).Result() + return v +} + +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 { - log.Fatalf("Failed to get id: %v\n", err) + return 0, fmt.Errorf("failed to get next message id for %s: %w", slug, err) } - return int(id) + return int(id), nil } -func setMessage(ctx context.Context, m *Message, isUpdate bool) error { - messageKey := fmt.Sprintf("messages:%d", m.ID) - - for _, regex := range settingConfig.RegexReplace { - if !strings.HasPrefix(m.Text, "[quote-embedded#]") { - t := regex.Pattern.ReplaceAllString(m.Text, regex.Replace) - m.Text = t +func setMessage(ctx context.Context, slug string, m *Message, isUpdate bool) error { + messageKey := fmt.Sprintf("channel:%s:messages:%d", slug, m.ID) + + // Load per-channel settings for regex replace + settings, err := dbGetSettings(ctx, slug) + if err == nil { + cfg := settings.ToConfig() + for _, regex := range cfg.RegexReplace { + if !strings.HasPrefix(m.Text, "[quote-embedded#]") { + t := regex.Pattern.ReplaceAllString(m.Text, regex.Replace) + m.Text = t + } } } @@ -91,7 +133,7 @@ func setMessage(ctx context.Context, m *Message, isUpdate bool) error { // Add message timestamp to sorted set if !isUpdate { - if err := rdb.ZAdd(ctx, "m_times:1", redis.Z{Score: float64(m.Timestamp.Unix()), Member: messageKey}).Err(); err != nil { + if err := rdb.ZAdd(ctx, fmt.Sprintf("channel:%s:m_times", slug), redis.Z{Score: float64(m.Timestamp.Unix()), Member: messageKey}).Err(); err != nil { return err } } @@ -107,13 +149,14 @@ 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) +func setReaction(ctx context.Context, slug string, messageId int, emoji string, userId string) error { + kay := fmt.Sprintf("channel:%s:message:%d:reactions", slug, messageId) userId = fmt.Sprintf("%v", userId) react := map[string]string{ @@ -135,12 +178,12 @@ func setReaction(ctx context.Context, messageId int, emoji string, userId string return err } - r, err := funcGetSumReactions(ctx, messageId) + r, err := funcGetSumReactions(ctx, slug, messageId) if err != nil { return err } - if err := updateMessageReactions(ctx, messageId, r); err != nil { + if err := updateMessageReactions(ctx, slug, messageId, r); err != nil { return err } @@ -153,7 +196,7 @@ func setReaction(ctx context.Context, messageId int, emoji string, userId string } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, "events", pushMessageData) + publishEvent(ctx, slug, pushMessageData) return nil } @@ -168,23 +211,34 @@ var getMessageRange = redis.NewScript(` local direction = ARGV[4] or 'desc' - 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 + 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 + -- 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 +252,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,7 +292,7 @@ var getMessageRange = redis.NewScript(` message[key] = value end end - + if not message['deleted'] or isAdmin then table.insert(messages, message) end @@ -251,9 +305,12 @@ 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() +func funcGetMessageRange(ctx context.Context, slug string, start, stop int64, isAdmin, countViews 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}).Result() if err != nil { return []Message{}, err } @@ -283,12 +340,14 @@ 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() +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,8 +361,8 @@ 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) +func updateMessageReactions(ctx context.Context, slug string, messageId int, reactions Reactions) error { + messageKey := fmt.Sprintf("channel:%s:messages:%d", slug, messageId) exists, err := rdb.Exists(ctx, messageKey).Result() if err != nil { @@ -325,8 +384,21 @@ func updateMessageReactions(ctx context.Context, messageId int, reactions Reacti return nil } -func funcDeleteMessage(ctx context.Context, id string) error { - msgKey := fmt.Sprintf("messages:%s", id) +// 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 nil, err + } + if len(fields) == 0 { + return nil, redis.Nil + } + return fields, nil +} + +func funcDeleteMessage(ctx context.Context, slug string, id string) error { + msgKey := fmt.Sprintf("channel:%s:messages:%s", slug, id) rdb.HSet(ctx, msgKey, "deleted", true) var m Message @@ -342,26 +414,31 @@ 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 } for _, m := range messages { - rdb.HIncrBy(ctx, fmt.Sprintf("messages:%d", m.ID), "views", 1) + rdb.HIncrBy(ctx, fmt.Sprintf("channel:%s:messages:%d", slug, m.ID), "views", 1) } } // 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,11 +446,15 @@ func addSubscription(token string) error { return nil } -func getSubcriptionsList() ([]string, error) { +func getSubcriptionsList(slug string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - subscriptionsSet, err := rdb.SMembers(ctx, "subscriptions").Result() + key := "subscriptions" + if slug != "" { + key = fmt.Sprintf("channel:%s:subscriptions", slug) + } + subscriptionsSet, err := rdb.SMembers(ctx, key).Result() if err != nil { log.Printf("Failed to get subscriptions: %v\n", err) return []string{}, err @@ -381,33 +462,30 @@ func getSubcriptionsList() ([]string, error) { return subscriptionsSet, 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) } @@ -449,21 +527,63 @@ 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 { + const maxRetries = 5 + + 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 + } + } + 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 +599,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 +719,56 @@ 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) + 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,14 +780,16 @@ func dbSetReports(ctx context.Context, report *Report) error { return nil } -func dbSavePeakSSEConnections(peak *PeakSSEConnections) { +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) + rdb.HSet(ctx, key, "value", peak.Value, "timestamp", peak.Timestamp.Unix()) } -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 } @@ -626,18 +804,18 @@ func dbGetPeakSSEConnections(ctx context.Context) (*PeakSSEConnections, error) { return &peak, nil } -func dbSaveSSEStatistics(amount int64) { +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}) } -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 +844,44 @@ 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 + } + } + if earliest > 0 { + rdb.ZAdd(ctx, "scheduled:due_channels", redis.Z{Score: earliest, Member: slug}) + } else { + rdb.ZRem(ctx, "scheduled:due_channels", slug) + } + 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 +896,498 @@ 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 + } + 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"], + } + if h["slug"] == "" { + channel.Slug = slug + } + + if t, err := time.Parse(time.RFC3339, h["createdAt"]); err == nil { + channel.CreatedAt = t + } + + // Load features + featuresKey := fmt.Sprintf("channel:%s:features", slug) + featuresJSON, err := rdb.Get(ctx, featuresKey).Result() + if err == nil { + var features ChannelFeatures + if err := json.Unmarshal([]byte(featuresJSON), &features); err == nil { + channel.Features = features + } + } + + return channel, nil +} + +func dbListChannels(ctx context.Context) ([]*ChannelData, error) { + slugs, err := rdb.ZRange(ctx, "channels:list", 0, -1).Result() + if err != nil { + return nil, err + } + + var channels []*ChannelData + for _, slug := range slugs { + ch, err := dbGetChannel(ctx, slug) + 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 1: collect all message keys and reaction keys from the sorted set (avoids SCAN) + messageKeys, _ := rdb.ZRange(ctx, fmt.Sprintf("channel:%s:m_times", p), 0, -1).Result() + reactionKeys := 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)) + } + + // Step 1b: release every uploaded file. Drop the tracking set first so the + // per-file cleanup below does not re-scan it once for each file, then reuse + // the normal delete path (deleteFileByID) so hash refcounts are decremented + // and the R2/disk blob is removed once the last reference is gone. + fileMembers, _ := rdb.ZRange(ctx, fmt.Sprintf("channel:%s:files", p), 0, -1).Result() + rdb.Del(ctx, fmt.Sprintf("channel:%s:files", p)) + fileKeys := make([]string, 0, len(fileMembers)) + for _, m := range fileMembers { + // member format: "fileID:size" + i := strings.LastIndex(m, ":") + if i <= 0 { + continue + } + fileID := m[:i] + if len(fileID) >= 4 { + deleteFileByID(ctx, p, fileID) + } + fileKeys = append(fileKeys, "file:"+fileID) + } + + // Step 1c: collect the individual report hashes referenced by the reports index + reportKeys, _ := rdb.ZRange(ctx, fmt.Sprintf("channel:%s:reports:list", p), 0, -1).Result() + + // Step 1d: monthly SSE statistics keys have no index; enumerate the possible + // month/year combinations instead of running a SCAN across the keyspace. + currentYear := time.Now().Year() + statsKeys := make([]string, 0, 12*11) + for year := currentYear - 10; year <= currentYear; year++ { + for month := time.January; month <= time.December; month++ { + statsKeys = append(statsKeys, fmt.Sprintf("channel:%s:sse_statistics:%d:%d", p, month, year)) + } + } + + // 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: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:events", p), + fmt.Sprintf("channel:%s:last_modified", p), + } + allKeys := append(fixedKeys, messageKeys...) + allKeys = append(allKeys, reactionKeys...) + 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 + } + + // Step 3: revoke every per-channel role for this slug, otherwise recreating + // the same slug would immediately hand the old grantees their access back. + 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 { + log.Printf("dbDeleteChannel: clear roles for %s: %v\n", slug, err) + } else if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after dbDeleteChannel: %v\n", 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 +} + +func dbAssignChannelRole(ctx context.Context, email, slug string, role ChannelRole) error { + return dbUpdateUsersList(ctx, func(users []User) []User { + for i, u := range users { + if 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 +} + +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() +} + +// 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() +} + +func dbDecrChannelStorageUsed(ctx context.Context, slug string, bytes int64) error { + return rdb.DecrBy(ctx, "channel:"+slug+":storage:used_bytes", bytes).Err() +} + +// 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 { + // member encodes fileID and size separated by ":" + member := fmt.Sprintf("%s:%d", fileID, size) + return rdb.ZAdd(ctx, "channel:"+slug+":files", redis.Z{ + Score: float64(uploadedAt), + Member: member, + }).Err() +} + +// dbRemoveChannelFile removes a file from the channel's tracking set. +func dbRemoveChannelFile(ctx context.Context, slug, fileID string) error { + // We need to find and remove the member that starts with fileID + members, err := rdb.ZRange(ctx, "channel:"+slug+":files", 0, -1).Result() + if err != nil { + return err + } + for _, m := range members { + if len(m) >= len(fileID) && m[:len(fileID)] == fileID { + return rdb.ZRem(ctx, "channel:"+slug+":files", 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, "channel:"+slug+":files", 0, limit-1).Result() + if err != nil { + return nil, err + } + result := make([]struct { + ID string + Size int64 + }, 0, len(members)) + for _, m := range members { + // format: "fileID:size" + for i := len(m) - 1; i >= 0; i-- { + if m[i] == ':' { + fileID := m[:i] + var size int64 + fmt.Sscanf(m[i+1:], "%d", &size) + result = append(result, struct { + ID string + Size int64 + }{fileID, size}) + break + } + } + } + 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() +} + +// dbDelFileHashRefs removes the ref counter entirely (used once it reaches zero). +func dbDelFileHashRefs(ctx context.Context, hash string) error { + return rdb.Del(ctx, "file:hash:"+hash+":refs").Err() +} + +// 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/files.go b/backend/files.go index 4a9506f..427f6a0 100644 --- a/backend/files.go +++ b/backend/files.go @@ -1,13 +1,16 @@ package main import ( + "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" + "fmt" "io" + "log" "net/http" "net/url" "os" @@ -22,6 +25,63 @@ import ( "gopkg.in/yaml.v3" ) +// 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 { @@ -30,43 +90,160 @@ type FileResponse struct { FileType string `json:"filetype"` } -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 +} -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" + +// 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() +} - metadataFilePath := filepath.Join(rootUploadPath, fileId[:2], fileId[2:4], fileId+".yaml") - metadataFile, err := os.ReadFile(metadataFilePath) +// 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 { + return nil, err + } + return &meta, nil + } + + // Fallback: read from YAML (legacy local files) + metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") + yamlData, err := os.ReadFile(metadataFilePath) if err != nil { + return nil, fmt.Errorf("file not found") + } + 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 +} + +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", `attachment; filename*=UTF-8''`+url.QueryEscape(meta.Filename)) - w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(originalFileName)) + if r2Enabled { + key := r2ObjectKey(meta.Hash) + if r2PublicURL != "" { + // Public bucket: redirect to CDN URL (fastest, no auth needed) + http.Redirect(w, r, r2PublicURL+"/"+key, http.StatusFound) + return + } + // Private bucket: generate a pre-signed URL (1-hour TTL). + // The client fetches directly from R2 — backend is not in the data path. + presignedURL, err := r2PresignURL(ctx, key, time.Hour) + if err == nil { + http.Redirect(w, r, presignedURL, http.StatusFound) + return + } + // Fallback: proxy through backend if presigning fails + body, contentType, err := r2Download(ctx, key) + if err != nil { + 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 := filepath.Join(rootUploadPath, meta.Hash[:2], meta.Hash[2:4], meta.Hash) 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) 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) { http.Error(w, "File too large", http.StatusRequestEntityTooLarge) return } @@ -75,97 +252,183 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() - if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { + fileBytes, err := io.ReadAll(file) + if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } - head := make([]byte, 512) - file.Read(head) + t, _ := filetype.Match(fileBytes[:min(512, len(fileBytes))]) - t, _ := filetype.Match(head) + // 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() + } - file.Seek(0, io.SeekStart) - fileHash, err := generatedFileHash(file) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) + fileSize := int64(len(fileBytes)) + hashBytes := sha256.Sum256(fileBytes) + fileHash := hex.EncodeToString(hashBytes[:]) + + // Quota check + auto-cleanup + if err := enforceStorageQuota(ctx, slug, fileSize); err != nil { + http.Error(w, err.Error(), http.StatusInsufficientStorage) return } - hashSubDir := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4]) - if err := os.MkdirAll(hashSubDir, os.ModePerm); err != nil { + id := generatedRandomID(20) + if id == "" { http.Error(w, "error", http.StatusInternalServerError) return } - var isDuplicateFile bool - testISDuplicateFilePath := filepath.Join(hashSubDir, fileHash) - _, err = os.Stat(testISDuplicateFilePath) - if err == nil { //|| !os.IsNotExist(err) - isDuplicateFile = true + safeFilename := gozaru.Sanitize(handler.Filename) + contentType := t.MIME.Value + if contentType == "" { + contentType = "application/octet-stream" } - if !isDuplicateFile { - destPath := filepath.Join(hashSubDir, fileHash) - - file.Seek(0, io.SeekStart) - destFile, err := os.Create(destPath) - if err != nil { + 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 { + http.Error(w, "error uploading file", http.StatusInternalServerError) + return + } + } + } else { + if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } - defer destFile.Close() - - if _, err := io.Copy(destFile, file); err != nil { + 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 } + destPath := filepath.Join(hashSubDir, fileHash) + if _, statErr := os.Stat(destPath); os.IsNotExist(statErr) { + if err := os.WriteFile(destPath, fileBytes, 0644); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } else { + isNewHash = false + } } - - id := generatedRandomID(20) - if id == "" { + _ = isNewHash + + dbIncrFileHashRefs(ctx, fileHash) + + meta := &FileMetadata{ + ID: id, + Filename: safeFilename, + Hash: fileHash, + Type: t.MIME.Type, + Delete: false, + Size: fileSize, + ChannelSlug: slug, + } + if err := dbSaveFileMetadata(ctx, meta); err != nil { 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 { - http.Error(w, "error", http.StatusInternalServerError) - return + dbIncrChannelStorageUsed(ctx, slug, fileSize) + dbAddChannelFile(ctx, slug, id, time.Now().Unix(), fileSize) + + 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, + }) +} + +// enforceStorageQuota checks quota and runs auto-cleanup if needed. +// Returns error if quota is exceeded and auto-cleanup is disabled or insufficient. +func enforceStorageQuota(ctx context.Context, slug string, newFileSize int64) error { + quota, err := dbGetEffectiveStorageQuota(ctx, slug) + if err != nil || quota == 0 { + return nil } - safeFilename := gozaru.Sanitize(handler.Filename) + used, err := dbGetChannelStorageUsed(ctx, slug) + if err != nil { + return nil + } - fileMetadata := map[string]any{ - "id": id, - "filename": safeFilename, - "hash": fileHash, - "type": t.MIME.Type, - "delete": false, + if used+newFileSize <= quota { + return nil // within quota } - metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") - metadataFile, err := os.Create(metadataFilePath) + + autoCleanup, err := dbGetChannelAutoCleanup(ctx, slug) if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return + return fmt.Errorf("storage configuration unavailable") } - defer metadataFile.Close() + if !autoCleanup { + return fmt.Errorf("storage quota exceeded (%d/%d bytes)", used, quota) + } + + // Auto-cleanup: delete oldest files until we have enough space (target: 80% of quota) + target := int64(float64(quota) * 0.80) + needToFree := (used + newFileSize) - target - yamlData, err := yaml.Marshal(fileMetadata) + files, err := dbGetOldestChannelFiles(ctx, slug, 200) if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return + return fmt.Errorf("storage quota exceeded") } - metadataFile.Write(yamlData) - fileUrl := "/api/files/" + id + for _, f := range files { + if needToFree <= 0 { + break + } + deleteFileByID(ctx, slug, f.ID) + needToFree -= f.Size + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(FileResponse{ - URL: fileUrl, - Filename: handler.Filename, - FileType: t.MIME.Type, - }) + // 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 { + return fmt.Errorf("storage quota exceeded (%d/%d bytes); auto-cleanup could not free enough space", used, quota) + } + + return nil +} + +// deleteFileByID marks a file as deleted, decrements storage counter, and removes from R2/disk if no more refs. +func deleteFileByID(ctx context.Context, slug, fileID string) { + meta, err := dbGetFileMetadata(ctx, fileID) + if err != nil || meta.Delete { + return + } + + meta.Delete = true + dbSaveFileMetadata(ctx, meta) + dbDecrChannelStorageUsed(ctx, slug, meta.Size) + dbRemoveChannelFile(ctx, slug, fileID) + + // Decrement hash refs and delete from storage if no more references + refs, err := dbDecrFileHashRefs(ctx, meta.Hash) + if err == nil && refs <= 0 { + if r2Enabled { + r2Delete(ctx, r2ObjectKey(meta.Hash)) + } else { + os.Remove(filepath.Join(rootUploadPath, meta.Hash[:2], meta.Hash[2:4], meta.Hash)) + } + // Drop the counter itself; a fresh upload of the same hash starts at 1 again. + dbDelFileHashRefs(ctx, meta.Hash) + } } func generatedFileHash(file io.Reader) (string, error) { @@ -173,57 +436,76 @@ func generatedFileHash(file io.Reader) (string, error) { 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 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 == "" { + http.ServeFile(w, r, "assets/favicon.ico") + return + } + + c, err := getChannelDetails(ctx, slug) if err != nil { - http.Error(w, "error", http.StatusInternalServerError) + http.ServeFile(w, r, "assets/favicon.ico") return } logoUrl := c["logoUrl"] if logoUrl == "" { - logoUrl = "assets/favicon.ico" - } - fileId := path.Base(logoUrl) - - 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") return } - var metaData map[string]any - if err := yaml.Unmarshal(metadataFile, &metaData); err != nil { + fileId := path.Base(logoUrl) + if len(fileId) < 4 { http.ServeFile(w, r, "assets/favicon.ico") return } - if delete := metaData["delete"].(bool); delete { + 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) { http.ServeFile(w, r, "assets/favicon.ico") return } - fileHash, _ := dyno.GetString(metaData["hash"]) - filePath := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4], fileHash) + if r2Enabled { + key := r2ObjectKey(meta.Hash) + if r2PublicURL != "" { + http.Redirect(w, r, r2PublicURL+"/"+key, http.StatusFound) + return + } + body, _, err := r2Download(ctx, key) + if err != nil { + http.ServeFile(w, r, "assets/favicon.ico") + return + } + defer body.Close() + io.Copy(w, body) + return + } + filePath := filepath.Join(rootUploadPath, meta.Hash[:2], meta.Hash[2:4], meta.Hash) http.ServeFile(w, r, filePath) } + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/go.mod b/backend/go.mod index a58c270..b9ed5b1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module channel -go 1.24 +go 1.25.0 require github.com/redis/go-redis/v9 v9.7.0 @@ -20,6 +20,24 @@ require ( 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 v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // 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/s3 v1.101.0 // 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,7 +72,7 @@ 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 + golang.org/x/time v0.15.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 diff --git a/backend/go.sum b/backend/go.sum index 7bfddbc..db68322 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/logs.go b/backend/logs.go index f1d2fdc..1fa13da 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/main.go b/backend/main.go index a33d03c..5a1635b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -16,107 +16,161 @@ 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 { - 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) - } - }) -} - func main() { gob.Register(Session{}) - initializePrivilegeUsers() - go statLogger() + initR2() + if err := initializePrivilegeUsers(); err != nil { + panic(err) + } + // 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. 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) 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() + + go statLogger() + r := chi.NewRouter() r.Use(middleware.Logger) - // 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) + // 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) }) - 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.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("/channel-requests", listChannelRequests) + r.Post("/channel-requests/{id}/approve", approveChannelRequest) + r.Post("/channel-requests/{id}/reject", rejectChannelRequest) + }) + + // Public: submit channel request (no auth required) + r.Post("/api/channel-request", submitChannelRequest) + + // 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) + r.Post("/notifications-subscribe", 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)) + // Deletion is a state change, so it belongs on DELETE. The GET + // route stays registered for existing clients until they migrate. + r.Delete("/delete-message/{id}", protectedWithChannelRole(RoleWriter, deleteMessage)) + r.Get("/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)) }) }) }) - 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/*", 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) @@ -124,15 +178,24 @@ func main() { } func serveSpaFile(w http.ResponseWriter, r *http.Request) { - htmlPath := filepath.Join(settingConfig.RootStaticFolder, "index.html") + 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)) + if cfg.CustomTitle != "" { + content = bytes.ReplaceAll(content, []byte(""), []byte(cfg.CustomTitle)) + } + + if cfg.AnalyticsHead != "" { + content = bytes.Replace(content, []byte(""), []byte(cfg.AnalyticsHead+""), 1) } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/backend/messages.go b/backend/messages.go index 4f56b7e..5afde2d 100644 --- a/backend/messages.go +++ b/backend/messages.go @@ -6,16 +6,31 @@ import ( "fmt" "log" "net/http" + "regexp" "strconv" "time" "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" ) +// 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 + +// 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,12 +40,32 @@ 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 + + // ETag: skip expensive query if content hasn't changed since client's copy. + // The body varies by viewer (admins see real authors and soft-deleted + // posts), so the validator has to carry those inputs and the response must + // never be reused across identities. + w.Header().Set("Cache-Control", "private, no-cache") + w.Header().Set("Vary", "Cookie") + if lm := getLastModified(ctx, slug); lm != "" { + etag := `"` + lm + "-" + strconv.FormatBool(isAdmin) + "-" + strconv.FormatBool(countViews) + `"` + 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, direction) if err != nil { log.Printf("Failed to get messages: %v\n", err) http.Error(w, "error", http.StatusInternalServerError) @@ -40,13 +75,15 @@ func getMessages(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) - addViewsToMessages(ctx, messages) + addViewsToMessages(ctx, slug, countViews, messages) } 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() @@ -61,7 +98,11 @@ func addMessage(w http.ResponseWriter, r *http.Request) { 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 = body.Type message.Author = user.PublicName message.AuthorId = user.ID @@ -71,42 +112,99 @@ func addMessage(w http.ResponseWriter, r *http.Request) { 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) } +// 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 } + // 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 + } + + // 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"] + 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 +214,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); 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 +256,90 @@ func getEvents(w http.ResponseWriter, r *http.Request) { return } - clientCtx := r.Context() - heartbeat := time.NewTicker(25 * time.Second) - defer heartbeat.Stop() + slug := channelSlugFromCtx(r) + streamKey := fmt.Sprintf("channel:%s:events", slug) + + // 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 !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() - - pubsub := rdb.Subscribe(r.Context(), "events") - defer pubsub.Close() + // 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) - if _, err := pubsub.Receive(clientCtx); err != nil { - http.Error(w, "Failed to subscribe to events", http.StatusInternalServerError) - return - } + clientCtx := r.Context() + lastHeartbeat := time.Now() + failures := 0 + const blockDuration = 5 * time.Second + const heartbeatInterval = 25 * time.Second - ch := pubsub.Channel() for { - select { - case <-clientCtx.Done(): + if clientCtx.Err() != nil { return + } - case <-heartbeat.C: - _, err := fmt.Fprintf(w, "data: {\"type\": \"heartbeat\"}\n\n") - if err != nil { + // Send heartbeat if it's been too long + if time.Since(lastHeartbeat) >= heartbeatInterval { + if _, err := fmt.Fprintf(w, "data: {\"type\": \"heartbeat\"}\n\n"); err != nil { return } flusher.Flush() + lastHeartbeat = time.Now() + } - case msg, ok := <-ch: - if !ok { - return + // XREAD blocks until new messages arrive or timeout expires. + // A short block duration lets us send periodic heartbeats and check for disconnect. + streams, err := rdb.XRead(clientCtx, &redis.XReadArgs{ + Streams: []string{streamKey, lastID}, + Count: 50, + Block: blockDuration, + }).Result() + + if clientCtx.Err() != nil { + return + } + if err != nil { + // redis.Nil = block timeout with no messages; other errors: brief pause + // then retry, but a stream that keeps failing must not be retried + // forever at 2 Hz. + if err != redis.Nil { + failures++ + if failures > maxStreamReadFailures { + log.Printf("SSE stream %s: giving up after %d failed reads: %v\n", streamKey, failures, err) + return + } + time.Sleep(500 * time.Millisecond) } - _, err := fmt.Fprintf(w, "data: %s\n\n", msg.Payload) - if err != nil { - return + continue + } + failures = 0 + + for _, stream := range streams { + for _, msg := range stream.Messages { + lastID = msg.ID + data, _ := msg.Values["data"].(string) + if _, err := fmt.Fprintf(w, "id: %s\ndata: %s\n\n", lastID, data); err != nil { + return + } } - flusher.Flush() } + flusher.Flush() } } diff --git a/backend/migrations.go b/backend/migrations.go new file mode 100644 index 0000000..91063c9 --- /dev/null +++ b/backend/migrations.go @@ -0,0 +1,259 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + "time" +) + +// migrationsAppliedKey holds the set of migration IDs that have already run. +const migrationsAppliedKey = "migrations:applied" + +// 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" + +// 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}, + } + + for _, m := range migrations { + 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 + } + + log.Printf("migrations: running %s", m.id) + if err := m.run(ctx); err != nil { + 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) + } +} + +// settingString returns the value of key, or "" when it is absent. +func settingString(settings Settings, key string) string { + for i := range settings { + if settings[i].Key == key { + return settings[i].GetString() + } + } + return "" +} + +// 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 for channels that +// were already using the corresponding feature. +// +// Why this is needed: createChannel and approveChannelRequest 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. +// +// 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) + } + + // A channel under an ads lock serves the GLOBAL ad source and ignores its own + // settings (see isChannelAdsLocked / getAdsSettings), so "has no ad-iframe-src + // of its own" does not mean "shows no ads". + globalAds, err := dbGetGlobalAdsConfig(ctx) + if err != nil { + return fmt.Errorf("get global ads config: %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 { + 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. + f.Reactions = true + f.FileUploads = true + f.Reports = true + f.ScheduledMessages = true + + // Configured to call out to a webhook. + if settingString(settings, "webhook_url") != "" { + f.Webhook = true + } + + // Owner switched push on for this channel. + if settingBool(settings, "on_notification") { + f.Notifications = true + } + + // Either the channel has its own iframe ad, or it is locked to a global + // one that is actually configured. + if settingString(settings, "ad-iframe-src") != "" { + f.Ads = true + } else if isChannelAdsLocked(globalAds, ch) && globalAds.Src != "" { + 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 +} + +// 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, fmt.Sprintf("channel:%s:files", ch.Slug), 0, -1).Result() + if err != nil { + return fmt.Errorf("list files for %s: %w", ch.Slug, err) + } + + for _, m := range members { + // member format: "fileID:size" + i := strings.LastIndex(m, ":") + if i <= 0 { + continue + } + fileID := m[:i] + + meta, err := dbGetFileMetadata(ctx, fileID) + if err != nil { + // 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 +} + +// migrationContext is the budget for the whole migration step at boot. +func migrationContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 2*time.Minute) +} diff --git a/backend/migrations_test.go b/backend/migrations_test.go new file mode 100644 index 0000000..c3b2a9c --- /dev/null +++ b/backend/migrations_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "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 +} + +func TestMigrationBackfillsFeaturesFromSettings(t *testing.T) { + ctx := testCtx(t) + + // Ads must be off globally, otherwise every channel would qualify via the + // lock branch and the per-channel assertions would be meaningless. + if err := dbSetGlobalAdsConfig(ctx, &GlobalAdsConfig{}); err != nil { + t.Fatalf("reset global ads: %v", err) + } + + seedChannel(t, ctx, "mig-test-webhook", Settings{ + {Key: "webhook_url", Value: "https://example.com/hook"}, + }) + seedChannel(t, ctx, "mig-test-push", Settings{ + {Key: "on_notification", Value: "1"}, + }) + seedChannel(t, ctx, "mig-test-ads", Settings{ + {Key: "ad-iframe-src", Value: "https://ads.example.com/b.html"}, + }) + 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"}, + }) + // Uses nothing: must come out untouched, so enforcement stays off for it. + seedChannel(t, ctx, "mig-test-bare", Settings{}) + // An empty webhook_url is configuration debris, not usage. + seedChannel(t, ctx, "mig-test-empty", Settings{ + {Key: "webhook_url", Value: ""}, + {Key: "on_notification", Value: "0"}, + }) + + if err := backfillChannelFeatures(ctx); err != nil { + t.Fatalf("backfill: %v", err) + } + + if f := featuresOf(t, ctx, "mig-test-webhook"); !f.Webhook { + t.Error("channel with webhook_url should have Webhook enabled") + } else if f.Ads || f.Notifications { + t.Error("webhook channel should not have gained Ads or Notifications") + } + + if f := featuresOf(t, ctx, "mig-test-push"); !f.Notifications { + t.Error("channel with on_notification=1 should have Notifications enabled") + } + + if f := featuresOf(t, ctx, "mig-test-ads"); !f.Ads { + t.Error("channel with ad-iframe-src should have Ads enabled") + } + + // 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) + } + + if f := featuresOf(t, ctx, "mig-test-bare"); f.Ads || f.Notifications || f.Webhook { + t.Errorf("unused channel should not have gained anything, got %+v", f) + } + // The flags the old creation path did set must survive untouched. + if f := featuresOf(t, ctx, "mig-test-bare"); !f.Reactions || !f.FileUploads || !f.Reports || !f.ScheduledMessages { + t.Errorf("pre-existing true flags must be preserved, got %+v", f) + } + + if f := featuresOf(t, ctx, "mig-test-empty"); f.Webhook || f.Notifications { + t.Errorf("empty/false settings must not enable anything, 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) + + if err := dbSetGlobalAdsConfig(ctx, &GlobalAdsConfig{}); err != nil { + t.Fatalf("reset global ads: %v", err) + } + 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) + + if err := dbSetGlobalAdsConfig(ctx, &GlobalAdsConfig{}); err != nil { + t.Fatalf("reset global ads: %v", err) + } + + // 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) + } +} + +// A channel with no ad of its own still shows ads when the super admin has +// locked it to a configured global ad, so the migration must enable Ads for it. +func TestMigrationEnablesAdsForGloballyLockedChannel(t *testing.T) { + ctx := testCtx(t) + + seedChannel(t, ctx, "mig-test-locked", Settings{}) + + if err := dbSetGlobalAdsConfig(ctx, &GlobalAdsConfig{ + Src: "https://ads.example.com/global.html", + Width: 300, + LockAll: true, + }); err != nil { + t.Fatalf("set global ads: %v", err) + } + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dbSetGlobalAdsConfig(cctx, &GlobalAdsConfig{}) + }) + + if err := backfillChannelFeatures(ctx); err != nil { + t.Fatalf("backfill: %v", err) + } + + if f := featuresOf(t, ctx, "mig-test-locked"); !f.Ads { + t.Error("channel locked to a configured global ad should have Ads enabled") + } +} + +// 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. +func TestMigrationIsOneShotAndOnlyEverEnables(t *testing.T) { + ctx := testCtx(t) + + if err := dbSetGlobalAdsConfig(ctx, &GlobalAdsConfig{}); err != nil { + t.Fatalf("reset global ads: %v", err) + } + 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 now: the ID is already in the applied set. + rdb.SAdd(ctx, migrationsAppliedKey, channelFeaturesBackfillID) + t.Cleanup(func() { + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rdb.SRem(cctx, migrationsAppliedKey, channelFeaturesBackfillID) + }) + + 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") + } +} diff --git a/backend/notifications.go b/backend/notifications.go index 045c691..7808761 100644 --- a/backend/notifications.go +++ b/backend/notifications.go @@ -2,11 +2,14 @@ package main import ( "context" + "crypto/sha256" "encoding/json" "html/template" "log" "net/http" "slices" + "strings" + "sync" "time" "firebase.google.com/go/v4/messaging" @@ -43,17 +46,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 +120,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) @@ -121,6 +137,8 @@ func getFirebaseMessagingSW(w http.ResponseWriter, r *http.Request) { } func subscribeNotifications(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + var req struct { Token string `json:"token"` } @@ -135,7 +153,7 @@ func subscribeNotifications(w http.ResponseWriter, r *http.Request) { return } - if err := addSubscription(req.Token); err != nil { + if err := addSubscription(slug, req.Token); err != nil { http.Error(w, "Failed to subscribe to notifications", http.StatusInternalServerError) return } @@ -147,16 +165,57 @@ func subscribeNotifications(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -func pushFcmMessage(m *Message) { - if !settingConfig.OnNotification { +// 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() + // Operator-level kill switch, independent of the owner's on_notification. + if ch, err := dbGetChannel(ctx, slug); err == nil && !ch.Features.Notifications { + return + } + + channelCfg := getChannelConfig(ctx, slug) + if !channelCfg.OnNotification { + return + } + + list, err := getSubcriptionsList(slug) if err != nil { log.Println("Failed to get subscription list:", err) return @@ -167,24 +226,24 @@ func pushFcmMessage(m *Message) { return } - channelName, err := getChannelDetails(ctx) + channelName, err := getChannelDetails(ctx, slug) if err != nil { log.Println("Failed to get channel details:", 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, + Type: cfg.FcmJson.Type, + ProjectId: cfg.FcmJson.ProjectId, + PrivateKeyId: cfg.FcmJson.PrivateKeyId, + PrivateKey: cfg.FcmJson.PrivateKey, + ClientEmail: cfg.FcmJson.ClientEmail, + ClientId: cfg.FcmJson.ClientId, + AuthUri: cfg.FcmJson.AuthUri, + TokenUri: cfg.FcmJson.TokenUri, + AuthProviderX509CertUrl: cfg.FcmJson.AuthProviderX509CertUrl, + ClientX509CertUrl: cfg.FcmJson.ClientX509CertUrl, + UniverseDomain: cfg.FcmJson.UniverseDomain, } fcmSetJson, err := json.Marshal(fcmSet) @@ -193,36 +252,38 @@ func pushFcmMessage(m *Message) { 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 } data := map[string]string{ - "url": settingConfig.ProjectDomain, + // 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": channelName["name"], "body": 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) - // } } } diff --git a/backend/privileges.go b/backend/privileges.go index 88b8465..5828822 100644 --- a/backend/privileges.go +++ b/backend/privileges.go @@ -3,69 +3,160 @@ package main import ( "context" "encoding/json" + "fmt" "log" "net/http" "os" "strings" "sync" "time" - - "github.com/redis/go-redis/v9" ) -type Privilege string //privilege type -type Privileges map[Privilege]bool // map of privileges -var privilegesUsers sync.Map -var adminUsers []string = strings.Split(os.Getenv("ADMIN_USERS"), ",") +type GlobalRole string +type ChannelRole string + +const ( + RoleSuperAdmin GlobalRole = "super_admin" +) const ( - Admin Privilege = "admin" // root privilege - Moderator Privilege = "moderator" // admin privilege - Writer Privilege = "writer" // can write posts + RoleOwner ChannelRole = "owner" + RoleModerator ChannelRole = "moderator" + RoleWriter ChannelRole = "writer" ) -func initializePrivilegeUsers() { +var channelRoleLevels = map[ChannelRole]int{ + RoleWriter: 1, + RoleModerator: 2, + RoleOwner: 3, +} + +var privilegesUsers sync.Map + +// 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 { + superAdminEmails := strings.Split(os.Getenv("ADMIN_USERS"), ",") + 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()) - } - existsEmails := make(map[string]bool) - for _, user := range users { - existsEmails[user.Email] = true - } - for _, admin := range adminUsers { - if !existsEmails[admin] { - users = append(users, User{ - Username: "", - Email: admin, - Privileges: Privileges{ - Admin: true, - Moderator: true, - Writer: true, - }, - }) + // 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 { + emailToIdx := make(map[string]int) + for i, u := range users { + emailToIdx[u.Email] = i + } + + for _, email := range superAdminEmails { + email = strings.TrimSpace(email) + if email == "" { + continue + } + 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) } - for _, user := range users { + // 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. + seen := make(map[string]struct{}, len(merged)) + for _, user := range merged { privilegesUsers.Store(user.Email, user) + seen[user.Email] = struct{}{} } + 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 + }) + return nil +} + +// 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(s.Email) + if !ok { + return User{}, false + } + u, ok := v.(User) + if !ok { + return User{}, false + } + return u, true +} - if err := dbSetUsersList(ctx, users); err != nil { - panic("Failed to set users list in database: " + err.Error()) +func isSuperAdmin(r *http.Request) bool { + u, ok := sessionUser(r) + return ok && u.GlobalRole == RoleSuperAdmin +} + +func hasChannelRole(r *http.Request, slug string, minRole ChannelRole) bool { + if isSuperAdmin(r) { + return true + } + u, ok := sessionUser(r) + if !ok || u.ChannelRoles == nil { + return false + } + role, exists := u.ChannelRoles[slug] + if !exists { + return false } + return channelRoleLevels[role] >= channelRoleLevels[minRole] } -func (p Privileges) MarshalBinary() ([]byte, error) { - return json.Marshal(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 (p *Privileges) UnmarshalBinary(data []byte) error { - return json.Unmarshal(data, p) +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) { @@ -101,9 +192,10 @@ func setPrivilegeUsers(w http.ResponseWriter, r *http.Request) { 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/ratelimit.go b/backend/ratelimit.go new file mode 100644 index 0000000..1ee014a --- /dev/null +++ b/backend/ratelimit.go @@ -0,0 +1,77 @@ +package main + +import ( + "net" + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// 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 { + if v, ok := uploadLimiters.Load(key); ok { + return v.(*rate.Limiter) + } + uploadLimiterMu.Lock() + defer uploadLimiterMu.Unlock() + if v, ok := uploadLimiters.Load(key); ok { + return v.(*rate.Limiter) + } + l := rate.NewLimiter(rate.Every(2*time.Second), 10) + uploadLimiters.Store(key, l) + return l +} + +// channelRequestLimiters throttles the public channel-request endpoint per +// client IP — 3 submissions/min, burst of 3. +var ( + channelRequestLimiters sync.Map + channelRequestMu sync.Mutex +) + +func channelRequestLimiter(r *http.Request) *rate.Limiter { + key, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + key = r.RemoteAddr + } + + if v, ok := channelRequestLimiters.Load(key); ok { + return v.(*rate.Limiter) + } + channelRequestMu.Lock() + defer channelRequestMu.Unlock() + if v, ok := channelRequestLimiters.Load(key); ok { + return v.(*rate.Limiter) + } + l := rate.NewLimiter(rate.Every(20*time.Second), 3) + channelRequestLimiters.Store(key, l) + return l +} + +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 26dcc42..e15469a 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 e98f8d1..a03d431 100644 --- a/backend/report.go +++ b/backend/report.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "log" "net/http" "time" @@ -39,6 +40,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,12 +54,29 @@ 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 + } + report.Closed = false report.CreatedAt = time.Now() report.ReporterID = s.ID report.ReportedEmail = s.Email report.ReporterName = s.Username - if err := dbReportMessage(ctx, &report); err != nil { + if err := dbReportMessage(ctx, slug, &report); err != nil { log.Println("Error saving report:", err) http.Error(w, "Error saving report", http.StatusInternalServerError) return @@ -72,13 +92,15 @@ 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) @@ -93,6 +115,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 +126,7 @@ 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 { http.Error(w, "Error saving reports", http.StatusInternalServerError) return } diff --git a/backend/scheduled.go b/backend/scheduled.go index 586db4d..c22da08 100644 --- a/backend/scheduled.go +++ b/backend/scheduled.go @@ -3,8 +3,12 @@ package main import ( "context" "encoding/json" + "fmt" + "log" "net/http" "time" + + "github.com/redis/go-redis/v9" ) func init() { @@ -12,47 +16,100 @@ 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() + } + }() +} + +// 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. +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 + slugs, err := rdb.ZRangeByScore(ctx, "scheduled:due_channels", &redis.ZRangeBy{ + Min: "0", + Max: fmt.Sprintf("%f", now), + }).Result() + if err != nil || len(slugs) == 0 { + return + } + + for _, slug := range slugs { + slug := slug + + // 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. + lockKey := "scheduled:lock:" + slug + ok, lerr := rdb.SetNX(ctx, lockKey, 1, 55*time.Second).Result() + if lerr != nil || !ok { + continue + } + + ctxGet, cancelGet := context.WithTimeout(context.Background(), 5*time.Second) + list, err := dbGetScheduledMessages(ctxGet, slug) + cancelGet() + if err != nil { + rdb.Del(ctx, lockKey) + continue + } + + nowTime := time.Now() + 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() + m.Author = "Scheduled" + m.AuthorId = "0" + 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 + } + postCancel() - ctxSave, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) - dbSaveScheduledMessages(ctxSave, &newList) - cancelSave() + go SendWebhook(context.Background(), slug, "create", &m) + go pushFcmMessage(slug, &m) } - }() + + ctxSave, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) + dbSaveScheduledMessages(ctxSave, slug, &newList) // also updates the sorted set + cancelSave() + + rdb.Del(ctx, lockKey) + } } 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 +124,24 @@ 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 } - if err := dbSaveScheduledMessages(ctx, &messages); err != nil { + // A zero timestamp would be stored as a permanently-overdue entry and take + // the channel out of the scheduler's due set (see dbSaveScheduledMessages). + for _, m := range messages { + if m.Timestamp.IsZero() { + http.Error(w, "scheduled message requires a timestamp", http.StatusBadRequest) + return + } + } + + if err := dbSaveScheduledMessages(ctx, slug, &messages); err != nil { http.Error(w, "error saving messages", http.StatusInternalServerError) return } diff --git a/backend/settings.go b/backend/settings.go index c9286a0..b601b21 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -3,14 +3,24 @@ package main import ( "context" "encoding/json" + "log" "net/http" "regexp" "strings" + "sync" "time" "github.com/icza/dyno" ) +const ( + // defaultMaxFileSizeMB is used when a channel has no (or an invalid) max_file_size. + defaultMaxFileSizeMB int64 = 100 + // maxAllowedFileSizeMB is the absolute server-side ceiling a channel owner + // cannot raise, since the whole upload is buffered in memory. + maxAllowedFileSizeMB int64 = 512 +) + type ReplaceRegex struct { Pattern *regexp.Regexp Replace string @@ -41,6 +51,16 @@ type SettingConfig struct { MaxFileSize int64 CustomTitle string ContactUs string + MagnetEnabled bool + MagnetSnippet string + MagnetMode string + MagnetPerMessages int64 + MagnetMinTimeSeconds int64 + MagnetPerSeconds int64 + MagnetMinMessagesSince int64 + MagnetApiKey string + AnalyticsHead string + TinyPngApiKey string } type Setting struct { @@ -48,7 +68,26 @@ type Setting struct { Value any `json:"value"` } -var settingConfig *SettingConfig +// settingConfig is the global config (FCM/VAPID/global notifications). It is +// replaced wholesale by an HTTP handler, so every access goes through the +// accessors below; readers must take one snapshot per function rather than +// dereferencing the global repeatedly, or a swap mid-read splices two configs. +var ( + settingMu sync.RWMutex + settingConfig *SettingConfig +) + +func getGlobalConfig() *SettingConfig { + settingMu.RLock() + defer settingMu.RUnlock() + return settingConfig +} + +func setGlobalConfigCache(c *SettingConfig) { + settingMu.Lock() + settingConfig = c + settingMu.Unlock() +} type Settings []Setting @@ -56,12 +95,12 @@ func init() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - s, err := dbGetSettings(ctx) + s, err := dbGetGlobalSettings(ctx) if err != nil { panic("Failed to load settings from database: " + err.Error()) } - settingConfig = s.ToConfig() + setGlobalConfigCache(s.ToConfig()) } func (s *Settings) ToConfig() *SettingConfig { @@ -74,7 +113,7 @@ func (s *Settings) ToConfig() *SettingConfig { config.RootStaticFolder = "/usr/share/ng" } - config.MaxFileSize = 100 + config.MaxFileSize = defaultMaxFileSizeMB for _, setting := range *s { switch setting.Key { @@ -103,14 +142,19 @@ func (s *Settings) ToConfig() *SettingConfig { config.CountViews = setting.GetBool() case "regex-replace": + // The rule is encoded as "#" and the UI splits + // it on the FIRST '#', so splitting on every '#' here would silently + // drop any rule that legitimately contains one (e.g. a hashtag). if r := setting.GetString(); r != "" { - parts := strings.Split(r, "#") - if len(parts) == 2 { - if r, err := regexp.Compile(parts[0]); err == nil { + if i := strings.Index(r, "#"); i >= 0 { + pat, rep := r[:i], r[i+1:] + if re, err := regexp.Compile(pat); err == nil { config.RegexReplace = append(config.RegexReplace, &ReplaceRegex{ - Pattern: r, - Replace: parts[1], + Pattern: re, + Replace: rep, }) + } else { + log.Printf("regex-replace: bad pattern %q: %v\n", pat, err) } } } @@ -146,7 +190,11 @@ func (s *Settings) ToConfig() *SettingConfig { config.ProjectDomain = setting.GetString() case "max_file_size": - config.MaxFileSize = setting.GetInt() + // A zero/unparseable or oversized value keeps the default instead of + // bricking uploads or letting an owner exhaust server memory. + if v := setting.GetInt(); v > 0 && v <= maxAllowedFileSizeMB { + config.MaxFileSize = v + } case "custom_title": config.CustomTitle = setting.GetString() @@ -186,6 +234,36 @@ func (s *Settings) ToConfig() *SettingConfig { case "contact_us": config.ContactUs = setting.GetString() + + case "magnet_enabled": + config.MagnetEnabled = setting.GetBool() + + case "magnet_snippet": + config.MagnetSnippet = setting.GetString() + + case "magnet_mode": + config.MagnetMode = setting.GetString() + + case "magnet_per_messages": + config.MagnetPerMessages = setting.GetInt() + + case "magnet_min_time_seconds": + config.MagnetMinTimeSeconds = setting.GetInt() + + case "magnet_per_seconds": + config.MagnetPerSeconds = setting.GetInt() + + case "magnet_min_messages_since": + config.MagnetMinMessagesSince = setting.GetInt() + + case "magnet_api_key": + config.MagnetApiKey = setting.GetString() + + case "analytics_head": + config.AnalyticsHead = setting.GetString() + + case "tinypng_api_key": + config.TinyPngApiKey = setting.GetString() } } @@ -207,23 +285,35 @@ func (s *Setting) GetInt() int64 { return i } +// getChannelConfig loads per-channel settings from DB and returns a SettingConfig +func getChannelConfig(ctx context.Context, slug string) *SettingConfig { + s, err := dbGetSettings(ctx, slug) + if err != nil { + // Route the failure through the same defaulting logic, or MaxFileSize + // lands at 0 and MaxBytesReader rejects every upload as "too large". + return (&Settings{}).ToConfig() + } + return s.ToConfig() +} + +// Per-channel settings handlers (owner level) func setSettings(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + var newSettings Settings if err := json.NewDecoder(r.Body).Decode(&newSettings); err != nil { http.Error(w, "error decoding settings", http.StatusBadRequest) return } - if err := dbSetSettings(ctx, &newSettings); err != nil { + if err := dbSetSettings(ctx, slug, &newSettings); err != nil { http.Error(w, "error saving settings", http.StatusInternalServerError) return } - settingConfig = newSettings.ToConfig() - res := Response{ Success: true, } @@ -235,7 +325,9 @@ func getSettings(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - s, err := dbGetSettings(ctx) + slug := channelSlugFromCtx(r) + + s, err := dbGetSettings(ctx, slug) if err != nil { http.Error(w, "error getting settings", http.StatusInternalServerError) return @@ -244,3 +336,42 @@ func getSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(s) } + +// Global settings handlers (super admin level) +func getGlobalSettings(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + s, err := dbGetGlobalSettings(ctx) + if err != nil { + http.Error(w, "error getting global settings", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s) +} + +func setGlobalSettings(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var newSettings Settings + if err := json.NewDecoder(r.Body).Decode(&newSettings); err != nil { + http.Error(w, "error decoding settings", http.StatusBadRequest) + return + } + + if err := dbSetGlobalSettings(ctx, &newSettings); err != nil { + http.Error(w, "error saving global settings", http.StatusInternalServerError) + return + } + + setGlobalConfigCache(newSettings.ToConfig()) + + res := Response{ + Success: true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) +} diff --git a/backend/statistics.go b/backend/statistics.go index 87d905a..2d16b3f 100644 --- a/backend/statistics.go +++ b/backend/statistics.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "net/http" "sync" "sync/atomic" @@ -10,8 +11,11 @@ import ( ) var openSSEConnections = atomic.Int64{} -var peakSSEConnections = &PeakSSEConnections{} -var peakMu = sync.Mutex{} + +// Per-channel SSE counters +var channelSSEConnections sync.Map // slug -> *atomic.Int64 +var channelPeakSSE sync.Map // slug -> *PeakSSEConnections +var channelPeakMu sync.Map // slug -> *sync.Mutex type PeakSSEConnections struct { Value int64 `json:"value" redis:"value"` @@ -22,14 +26,31 @@ type Statistics struct { Labels []string `json:"labels"` } -func init() { +func getOrCreateChannelCounter(slug string) *atomic.Int64 { + v, _ := channelSSEConnections.LoadOrStore(slug, &atomic.Int64{}) + return v.(*atomic.Int64) +} + +func getOrCreateChannelPeak(slug string) (*PeakSSEConnections, *sync.Mutex) { + mu, _ := channelPeakMu.LoadOrStore(slug, &sync.Mutex{}) + m := mu.(*sync.Mutex) + + if v, ok := channelPeakSSE.Load(slug); ok { + return v.(*PeakSSEConnections), m + } + + // Publish the entry only once it is fully loaded. Storing a placeholder + // first and replacing it afterwards hands concurrent callers a pointer that + // is then orphaned, so their peak updates are silently dropped. + p := &PeakSSEConnections{} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + if loaded, err := dbGetPeakSSEConnections(ctx, slug); err == nil && loaded != nil { + p = loaded + } - p, _ := dbGetPeakSSEConnections(ctx) - peakMu.Lock() - defer peakMu.Unlock() - peakSSEConnections = p + actual, _ := channelPeakSSE.LoadOrStore(slug, p) + return actual.(*PeakSSEConnections), m } func statLogger() { @@ -37,48 +58,71 @@ func statLogger() { for { new := openSSEConnections.Load() if old != new { - dbSaveSSEStatistics(new) old = new } time.Sleep(5 * time.Minute) } } -func increaseCounterSSE() { - new := openSSEConnections.Add(1) - - peakMu.Lock() - defer peakMu.Unlock() - if new > peakSSEConnections.Value { - peakSSEConnections.Value = new - peakSSEConnections.Timestamp = time.Now() - peak := *peakSSEConnections - go dbSavePeakSSEConnections(&peak) +func increaseCounterSSE(slug string) { + // Global counter + openSSEConnections.Add(1) + + // Per-channel counter + counter := getOrCreateChannelCounter(slug) + newVal := counter.Add(1) + + peak, mu := getOrCreateChannelPeak(slug) + mu.Lock() + defer mu.Unlock() + if newVal > peak.Value { + peak.Value = newVal + peak.Timestamp = time.Now() + p := *peak + go dbSavePeakSSEConnections(slug, &p) } + + // Save stat + go dbSaveSSEStatistics(slug, newVal) } -func decreaseCounterSSE() { +func decreaseCounterSSE(slug string) { openSSEConnections.Add(-1) + + counter := getOrCreateChannelCounter(slug) + newVal := counter.Add(-1) + // A decrement that outruns its increment must not persist a negative + // datapoint into the statistics series. + if newVal < 0 { + counter.Store(0) + newVal = 0 + } + go dbSaveSSEStatistics(slug, newVal) } 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() + counter := getOrCreateChannelCounter(slug) + peak, mu := getOrCreateChannelPeak(slug) + + mu.Lock() + defer mu.Unlock() response := struct { UsersAmount int64 `json:"usersAmount"` @@ -87,8 +131,8 @@ func getStatistics(w http.ResponseWriter, r *http.Request) { ConnectionsStatistics Statistics `json:"connectionsStatistics"` }{ UsersAmount: amount, - ConnectedUsersAmount: openSSEConnections.Load(), - PeakSSEConnections: peakSSEConnections, + ConnectedUsersAmount: counter.Load(), + PeakSSEConnections: peak, ConnectionsStatistics: *s, } @@ -96,15 +140,31 @@ func getStatistics(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// resetStatistics clears the recorded peak for every channel. Everything the +// statistics endpoint serves comes from the per-channel state (channelPeakSSE +// plus channel::peak_sse_connections), so both have to be cleared or the +// reset is reported as successful while changing nothing. 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(), 10*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + for _, ch := range channels { + peak, mu := getOrCreateChannelPeak(ch.Slug) + mu.Lock() + peak.Value = 0 + peak.Timestamp = time.Time{} + mu.Unlock() + rdb.Del(ctx, fmt.Sprintf("channel:%s:peak_sse_connections", ch.Slug)) + } 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 0000000..29bb9fb --- /dev/null +++ b/backend/storage.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "io" + "log" + "os" + "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") + r2PublicURL = os.Getenv("R2_PUBLIC_URL") // optional: https://pub-xxx.r2.dev + + 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. +func r2PresignURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + presignClient := s3.NewPresignClient(r2Client) + req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(r2Bucket), + Key: aws.String(key), + }, 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 { + _, 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 0000000..d202a79 --- /dev/null +++ b/backend/storage_handlers.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "encoding/json" + "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"` +} + +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 { + pct = float64(used) / float64(quota) * 100 + } + + 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() + + bytes := int64(req.DefaultQuotaGB * 1024 * 1024 * 1024) + 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") + + 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") + + 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 := int64(req.QuotaGB * 1024 * 1024 * 1024) + 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/webhook.go b/backend/webhook.go index bea610e..92cfb73 100644 --- a/backend/webhook.go +++ b/backend/webhook.go @@ -3,15 +3,23 @@ package main import ( "bytes" "context" - "crypto/tls" "encoding/json" "log" "net/http" "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, + }, +} type WebhookPayload struct { Action string `json:"action"` @@ -20,8 +28,19 @@ 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() + + // Operator-level kill switch: the super admin can withdraw webhooks from a + // tenant regardless of what the owner has configured. + if ch, err := dbGetChannel(chCtx, slug); err == nil && !ch.Features.Webhook { + return + } + + cfg := getChannelConfig(chCtx, slug) + if cfg.WebhookURL == "" { return } @@ -29,7 +48,7 @@ func SendWebhook(ctx context.Context, action string, message *Message) { Action: action, Message: *message, Timestamp: time.Now(), - VerifyToken: settingConfig.VerifyToken, + VerifyToken: cfg.VerifyToken, } jsonData, err := json.Marshal(payload) @@ -38,10 +57,10 @@ 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() - 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 +69,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/captain-definition b/captain-definition new file mode 100644 index 0000000..debfa9b --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc82ce0..2af4dc8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,7 +28,6 @@ "@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", "eva-icons": "^1.1.3", @@ -5405,22 +5404,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", diff --git a/frontend/package.json b/frontend/package.json index 293730c..a8a2b13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,6 @@ "@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", "eva-icons": "^1.1.3", diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index b5ad037..7cd488c 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.routes.ts b/frontend/src/app/app.routes.ts index 9bb3141..6d4f9e6 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,16 +2,30 @@ 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 { SuperAdminGuard } from './guards/super-admin.guard'; +import { SuperAdminPanelComponent } from './components/super-admin/super-admin-panel.component'; +import { LandingPageComponent } from './components/landing/landing-page.component'; export const routes: Routes = [ + { path: '', component: LandingPageComponent }, { path: 'login', component: LoginComponent }, { - path: '', + path: 'channel', component: ChannelComponent, canActivate: [AuthGuard], }, + { + path: 'channel/:slug', + component: ChannelComponent, + canActivate: [AuthGuard], + }, + { + path: 'super-admin', + component: SuperAdminPanelComponent, + canActivate: [AuthGuard, SuperAdminGuard], + }, { path: '**', redirectTo: '' } -]; \ No newline at end of file +]; diff --git a/frontend/src/app/components/admin/admin-panel.component.html b/frontend/src/app/components/admin/admin-panel.component.html index 240f739..66e2ab0 100644 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ b/frontend/src/app/components/admin/admin-panel.component.html @@ -1,6 +1,6 @@ - +
@@ -31,6 +31,12 @@ @case (statistics) { } + @case (magnetAds) { + + } + @case (storage) { + + } }
diff --git a/frontend/src/app/components/admin/admin-panel.component.ts b/frontend/src/app/components/admin/admin-panel.component.ts index 9db0b9d..b2057b3 100644 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ b/frontend/src/app/components/admin/admin-panel.component.ts @@ -1,11 +1,18 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { NbCardModule, NbLayoutModule, NbMenuItem, NbMenuModule, NbMenuService, NbSidebarModule } from "@nebular/theme"; +import { filter, Subscription } from "rxjs"; +import { SlugService } from "../../services/slug.service"; +import { AuthService } from "../../services/auth.service"; 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"; +import { MagnetAdsComponent } from "./magnet-ads/magnet-ads.component"; +import { StorageComponent } from "./storage/storage.component"; + +type MenuAccess = 'any' | 'moderator' | 'owner'; @Component({ selector: 'admin-dashboard', @@ -19,12 +26,16 @@ import { StatisticsComponent } from "./statistics/statistics.component"; PrivilegDashboardComponent, ChannelInfoFormComponent, ReportsComponent, - StatisticsComponent + StatisticsComponent, + MagnetAdsComponent, + StorageComponent ], templateUrl: './admin-panel.component.html', styleUrls: ['./admin-panel.component.scss'] }) -export class AdminPanelComponent implements OnInit { +export class AdminPanelComponent implements OnInit, OnDestroy { + + readonly menuTag = "admin-panel"; readonly info = "info"; readonly settings = "settings"; @@ -34,90 +45,162 @@ export class AdminPanelComponent implements OnInit { readonly closedReports = "closed-reports"; readonly allReports = "all-reports"; readonly statistics = "statistics"; + readonly magnetAds = "magnet-ads"; + readonly storage = "storage"; selectedMenuItem = this.info; - navigationMenu: NbMenuItem[] = [ + navigationMenu: NbMenuItem[] = []; + + // Each tab is backed by an endpoint with its own role gate, so a tab the user + // cannot use is never rendered — otherwise it just answers 403 with a blank form. + private readonly menuDefinition: { access: MenuAccess, item: NbMenuItem }[] = [ { - title: 'פרטי ערוץ', - icon: 'info-outline', - selected: true + // Backed by /admin/edit-channel-info, which the backend gates at moderator — + // a writer would only ever get a 403 out of this form. + access: 'moderator', + item: { + title: 'פרטי ערוץ', + icon: 'info-outline', + selected: true + } }, { - title: 'הגדרות', - icon: 'settings-2-outline', + access: 'owner', + item: { + title: 'הגדרות', + icon: 'settings-2-outline', + } }, { - title: 'הרשאות', - icon: 'shield-outline', + access: 'owner', + item: { + title: 'הרשאות', + icon: 'shield-outline', + } }, { - title: 'סטטיסטיקות', - icon: 'bar-chart-outline', + access: 'moderator', + item: { + title: 'סטטיסטיקות', + icon: 'bar-chart-outline', + } }, { - title: "אימוג'ים", - icon: 'smiling-face-outline', + access: 'moderator', + item: { + 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', - } - ], + access: 'owner', + item: { + title: 'שילוב פרסומות ממגנט', + icon: 'pricetags-outline', + } + }, + { + access: 'owner', + item: { + title: 'אחסון', + icon: 'hard-drive-outline', + } + }, + { + access: 'moderator', + item: { + title: 'דיווחים', + icon: 'alert-triangle-outline', + children: [ + { + title: 'פתוחים', + icon: 'alert-triangle-outline', + }, + { + title: 'סגורים', + icon: 'checkmark-circle-outline', + }, + { + title: 'כל הדיווחים', + icon: 'list-outline', + } + ], + } } ]; + get channelSlug(): string { + return this.slugService.slug; + } + + private menuSub?: Subscription; + constructor( - private menuService: NbMenuService + private menuService: NbMenuService, + private slugService: SlugService, + private authService: AuthService, ) { } ngOnInit(): void { + this.navigationMenu = this.menuDefinition + .filter(entry => this.canAccess(entry.access)) + .map(entry => entry.item); this.handleMenuItemClick() } + ngOnDestroy(): void { + this.menuSub?.unsubscribe(); + } + + private canAccess(access: MenuAccess): boolean { + if (access === 'any') return true; + const user = this.authService.userInfo; + if (user?.globalRole === 'super_admin') return true; + const role = user?.channelRoles?.[this.slugService.slug]; + if (access === 'owner') return role === 'owner'; + return role === 'owner' || role === 'moderator'; + } + handleMenuItemClick() { - this.menuService.onItemClick().subscribe((event) => { - this.navigationMenu.forEach((item) => item.selected = false); - event.item.selected = true; + this.menuSub = this.menuService.onItemClick() + .pipe(filter(({ tag }) => tag === this.menuTag)) + .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; - } - }); + 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; + case 'pricetags-outline': + this.selectedMenuItem = this.magnetAds; + break; + case 'hard-drive-outline': + this.selectedMenuItem = this.storage; + break; + } + }); } } \ No newline at end of file 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 0000000..6a2f2dc --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -0,0 +1,234 @@ +
+ + + + + שילוב פרסומות ממגנט + + + +

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

+ +
+ + הפעלת שילוב פרסומות מגנט + +

כיבוי יסיר לחלוטין את הצגת הפרסומות בערוץ.

+
+ +
+ +

+ הדביקו את קוד ההטמעה שקיבלתם ממגנט. הקוד יוטמע כפי שהוא בכל מיקום פרסומת בערוץ + (תגיות <script> ירוצו בכל הצגה). +

+ +
+
+
+ + + + + מפתח API למגנט + + +
+ +

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

+ +
+
+
+ + + + + תדירות הצגה + + + +

בחרו את שיטת התזמון להצגת פרסומות בין ההודעות.

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

בכל X הודעות תוצג פרסומת אחת. לדוגמא: ערך 5 = פרסומת אחרי כל 5 הודעות.

+ +
+ +
+ +

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

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

פרסומת תוצג בערוץ אחת לכל פרק זמן. לדוגמא: ערך 60 = פרסומת בכל דקה.

+ +
+ +
+ +

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

+ +
+
+ } +
+
+ + + @if (isSuperAdmin) { + + + + נתוני הקלקות ותגמולים + + + +

+ נתונים אלו מתקבלים ישירות ממגנט בזמן אמת לפי המפתח שמוגדר למעלה. + רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ, בש"ח. + "היום" מתחיל מ-00:00 שעון ישראל. "שבוע"/"חודש" = 7/30 ימים אחורה. + מגנט שומר תוצאות במטמון לכ-30 שניות. +

+ +
+ @if (!stats && !statsError) { + + } @else { + + @if (statsLoadedAt) { + עודכן: {{ statsLoadedAt | date:'HH:mm:ss' }} + } + } +
+ + @if (statsError) { +
+ + {{ statsError }} +
+ } + + @if (stats && !statsError) { + @if (stats.site?.domain) { +
+ אתר: + {{ stats.site?.domain }} +
+ } + +
+
+
+ + הקלקות +
+
+ היום + {{ formatNumber(stats.clicks?.today) }} +
+
+ שבוע אחרון + {{ formatNumber(stats.clicks?.week) }} +
+
+ חודש אחרון + {{ formatNumber(stats.clicks?.month) }} +
+
+ +
+
+ + תגמולים ({{ stats.currency || 'ILS' }} - ללא מע"מ) +
+
+ היום + {{ formatMoney(stats.earnings?.today, stats.currency) }} +
+
+ שבוע אחרון + {{ formatMoney(stats.earnings?.week, stats.currency) }} +
+
+ חודש אחרון + {{ formatMoney(stats.earnings?.month, stats.currency) }} +
+
+
+ } +
+
+ } + +
+ +
+ +
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 0000000..298c082 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss @@ -0,0 +1,196 @@ +:host { + display: block; + direction: rtl; +} + +.magnet-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 5rem; +} + +.card-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + + nb-icon { + font-size: 1.25rem; + } +} + +.intro { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.5; +} + +.setting-block { + margin-bottom: 1.25rem; + + &:last-child { + margin-bottom: 0; + } + + .bold { + font-weight: 600; + display: block; + margin-bottom: 0.25rem; + } + + .hint { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.4; + } + + textarea { + font-family: monospace; + font-size: 0.85rem; + } + + input[type="number"] { + max-width: 200px; + } +} + +.mode-options { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--divider-color, #edf1f7); +} + +.mode-section { + padding-top: 0.25rem; +} + +.stats-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } + + .stats-meta { + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + } +} + +.stats-error { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--background-basic-color-2, #f7f9fc); + border-inline-start: 3px solid var(--color-danger-default, #ff3d71); + color: var(--color-danger-default, #ff3d71); + border-radius: 0.4rem; + font-size: 0.9rem; +} + +.stats-site { + margin-bottom: 1rem; + font-size: 0.95rem; + + .bold { + font-weight: 600; + margin-inline-end: 0.4rem; + } + + a { + direction: ltr; + display: inline-block; + color: var(--color-primary-default); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.stats-section { + background: var(--background-basic-color-2, #f7f9fc); + padding: 1rem; + border-radius: 0.5rem; + + .stats-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + nb-icon { + font-size: 1.1rem; + } + + .stats-currency { + font-weight: 400; + font-size: 0.8rem; + color: var(--text-hint-color, #8f9bb3); + margin-inline-start: auto; + } + } + + .stats-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0; + + & + .stats-row { + border-top: 1px dashed var(--divider-color, #edf1f7); + } + + .stats-label { + color: var(--text-hint-color, #8f9bb3); + font-size: 0.9rem; + } + + .stats-value { + font-weight: 600; + font-size: 1rem; + direction: ltr; + } + } +} + +.save-bar { + position: sticky; + bottom: 0; + background: var(--background-basic-color-1); + padding: 0.75rem 0; + border-top: 1px solid var(--divider-color, #edf1f7); + display: flex; + justify-content: flex-start; + z-index: 5; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } +} 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 0000000..a51a57a --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -0,0 +1,217 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbRadioModule, + NbToastrService, + NbToggleModule, +} from '@nebular/theme'; +import { AdminService } from '../../../services/admin.service'; +import { AuthService } from '../../../services/auth.service'; +import { Setting } from '../../../models/setting.model'; + +type MagnetMode = 'by_messages' | 'by_time'; + +interface MagnetStatsBucket { + today: number; + week: number; + month: number; +} + +interface MagnetStatsResponse { + site?: { id?: string; domain?: string }; + currency?: string; + clicks?: MagnetStatsBucket; + earnings?: MagnetStatsBucket; +} + +const MAGNET_KEYS = [ + 'magnet_enabled', + 'magnet_snippet', + 'magnet_mode', + 'magnet_per_messages', + 'magnet_min_time_seconds', + 'magnet_per_seconds', + 'magnet_min_messages_since', + 'magnet_api_key', +] as const; + +@Component({ + selector: 'app-magnet-ads', + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbInputModule, + NbToggleModule, + NbRadioModule, + ], + templateUrl: './magnet-ads.component.html', + styleUrl: './magnet-ads.component.scss', +}) +export class MagnetAdsComponent implements OnInit { + enabled = false; + snippet = ''; + mode: MagnetMode = 'by_messages'; + perMessages = 5; + minTimeSeconds = 0; + perSeconds = 60; + minMessagesSince = 0; + apiKey = ''; + + otherSettings: Setting[] = []; + inProgress = false; + + stats: MagnetStatsResponse | null = null; + statsLoading = false; + statsError = ''; + statsLoadedAt: Date | null = null; + + constructor( + private adminService: AdminService, + private authService: AuthService, + private toast: NbToastrService, + ) {} + + get isSuperAdmin(): boolean { + return this.authService.userInfo?.globalRole === 'super_admin'; + } + + ngOnInit(): void { + this.adminService.getSettings() + .then(settings => this.load(settings || [])) + .catch(() => this.toast.danger('', 'אין הרשאה לצפות בהגדרות')); + } + + private load(settings: Setting[]) { + this.otherSettings = []; + const known = new Set(MAGNET_KEYS); + + 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 = this.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; + case 'magnet_api_key': + this.apiKey = v == null ? '' : String(v); + break; + } + } + } + + private 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; + } + + 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; + } + + save() { + this.inProgress = true; + 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 }); + } + + if (this.apiKey?.trim()) out.push({ key: 'magnet_api_key', value: this.apiKey.trim() as any }); + + this.adminService.setSettings(out) + .then(() => this.toast.success('', 'הגדרות מגנט נשמרו בהצלחה')) + .catch(() => this.toast.danger('', 'שגיאה בשמירת ההגדרות')) + .finally(() => this.inProgress = false); + } + + async loadStats() { + this.statsLoading = true; + this.statsError = ''; + + try { + const res = await fetch('/api/super-admin/magnet/stats', { credentials: 'include' }); + const text = await res.text(); + let data: any = null; + try { data = text ? JSON.parse(text) : null; } catch {} + + if (!res.ok) { + if (res.status === 400) { + this.statsError = data?.message || 'מפתח API לא תקין או חסר. שמרו תחילה מפתח תקין ונסו שוב.'; + } else if (res.status === 404) { + this.statsError = 'האתר לא נמצא במערכת מגנט או שאינו מאושר.'; + } else if (res.status === 401 || res.status === 403) { + this.statsError = 'אין הרשאה לגשת לנתוני מגנט.'; + } else { + this.statsError = data?.message || `שגיאה בקבלת נתונים (HTTP ${res.status})`; + } + this.stats = null; + return; + } + + this.stats = data as MagnetStatsResponse; + this.statsLoadedAt = new Date(); + } catch { + this.statsError = 'שגיאת רשת בקריאה לשרת מגנט'; + this.stats = null; + } finally { + this.statsLoading = false; + } + } + + formatMoney(n: number | undefined, currency: string | undefined): string { + if (n === null || n === undefined) return '-'; + const code = (currency || 'ILS').toUpperCase(); + const symbol = code === 'ILS' ? '₪' : code === 'USD' ? '$' : code === 'EUR' ? '€' : code + ' '; + const formatted = Number(n).toLocaleString('he-IL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return code === 'ILS' ? `${formatted} ${symbol}` : `${symbol}${formatted}`; + } + + formatNumber(n: number | undefined): string { + if (n === null || n === undefined) return '-'; + return Number(n).toLocaleString('he-IL'); + } +} 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 c4fa38f..046ba59 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 @@ -3,13 +3,12 @@ ניהול הרשאות משתמשים + - - הוספת משתמש מורשה חדש - + הוספת משתמש מורשה חדש @if (!addingNewUser) { - @@ -17,28 +16,17 @@ @if (addingNewUser) {
-
- שם משתמש:
-
- - מתעדכן עם כניסת המשתמש למערכת - -
כתובת מייל:
- +
- שם שיופיע בעת פרסום הודעות:
- -
-
- הרשאות: -
- מנהל ראשי - מנהל - כותב -
+ תפקיד:
+ + @for (opt of roleOptions; track opt.value) { + {{ opt.label }} + } +
-
+
+ {{ item.email }} + @if (item.role === 'owner') { + בעלים + } @else { + + @for (opt of roleOptions; track opt.value) { + {{ opt.label }} + } + + } +
} + + @if (!usersList.length) { +

אין משתמשים מורשים לערוץ זה.

+ } + - + - \ No newline at end of file + 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 79782bd..bf08104 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,9 @@ import { Component, OnInit } from '@angular/core'; -import { AdminService, PrivilegeUser } from '../../../services/admin.service'; -import { NbButtonModule, NbCardModule, NbInputModule, NbToastrService, NbIconModule, NbCheckboxModule } from "@nebular/theme"; +import { AdminService, ChannelUser } from '../../../services/admin.service'; +import { NbButtonModule, NbCardModule, NbInputModule, NbToastrService, NbIconModule, NbSelectModule } from "@nebular/theme"; import { FormsModule } from '@angular/forms'; - import { AuthService } from '../../../services/auth.service'; +import { SlugService } from '../../../services/slug.service'; @Component({ selector: 'app-privileg-dashboard', @@ -13,8 +13,8 @@ import { AuthService } from '../../../services/auth.service'; NbInputModule, FormsModule, NbIconModule, - NbCheckboxModule -], + NbSelectModule, + ], templateUrl: './privileg-dashboard.component.html', styleUrl: './privileg-dashboard.component.scss' }) @@ -22,59 +22,69 @@ export class PrivilegDashboardComponent implements OnInit { constructor( private adminService: AdminService, - private tostService: NbToastrService, + private toastService: NbToastrService, public authService: AuthService, + private slugService: SlugService, ) { } - 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[] = []; + addingNewUser = false; + newUserEmail = ''; + newUserRole: 'moderator' | 'writer' = 'writer'; + + readonly roleOptions = [ + { value: 'moderator', label: 'מנהל' }, + { value: 'writer', label: 'כותב' }, + ]; + + get isOwner(): boolean { + const roles = this.authService.userInfo?.channelRoles; + if (this.authService.userInfo?.globalRole === 'super_admin') return true; + return roles?.[this.slugService.slug] === 'owner'; + } ngOnInit(): void { - this.adminService.getPrivilegeUsersList() - .then(list => this.privilegeUsersList = list) + this.adminService.getChannelUsers() + .then(list => { + this.usersList = list; + this.removedUsers = []; + }) + .catch(() => this.toastService.danger('', 'שגיאה בטעינת המשתמשים')); } saveChanges() { - this.adminService.setPrivilegeUsers(this.privilegeUsersList) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); + this.adminService.setChannelUsers([...this.usersList, ...this.removedUsers]) + .then(() => { + this.removedUsers = []; + this.toastService.success('', 'השינויים נשמרו בהצלחה!'); + }) + .catch(() => this.toastService.danger('', 'שגיאה בשמירת השינויים')); } deleteUser(index: number) { if (!confirm('האם אתה בטוח שברצונך למחוק את המשתמש הזה?')) return; - this.privilegeUsersList.splice(index, 1); + const [removed] = this.usersList.splice(index, 1); + if (removed?.email) this.removedUsers.push({ email: removed.email, role: '' }); } saveNewUser() { - if (!this.newUser.email) return; - this.privilegeUsersList.push(this.newUser); - this.newUser = this.nullUser; + if (!this.newUserEmail) return; + this.usersList.push({ email: this.newUserEmail, role: this.newUserRole }); + this.newUserEmail = ''; + this.newUserRole = 'writer'; this.addingNewUser = false; } resetNewUser() { - this.newUser = this.nullUser; + this.newUserEmail = ''; + this.newUserRole = 'writer'; this.addingNewUser = false; } - nullUser: PrivilegeUser = { - username: '', - publicName: '', - email: '', - privileges: { - admin: false, - moderator: false, - writer: false - } - }; + getRoleLabel(role: string): string { + 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 f344c2c..807e01e 100644 --- a/frontend/src/app/components/admin/reports/reports.component.html +++ b/frontend/src/app/components/admin/reports/reports.component.html @@ -3,7 +3,7 @@ טיפול בדיווחים שהתקבלו ממשתמשים - @if (!reports) { + @if (!reports || reports.length === 0) {
{{status === 'open' ? 'אין דיווחים פתוחים' : 'אין דיווחים מטופלים'}}
} @for (report of reports; track report) { diff --git a/frontend/src/app/components/admin/reports/reports.component.ts b/frontend/src/app/components/admin/reports/reports.component.ts index 9382415..28750b5 100644 --- a/frontend/src/app/components/admin/reports/reports.component.ts +++ b/frontend/src/app/components/admin/reports/reports.component.ts @@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { NbButtonModule, NbCardModule, NbToastrService } from "@nebular/theme"; import { AdminService } from '../../../services/admin.service'; import { Reports, Report } from '../../../models/report.model'; +import { SlugService } from '../../../services/slug.service'; import { MessageTimePipe } from "../../../pipes/message-time.pipe"; @@ -22,7 +23,8 @@ export class ReportsComponent implements OnInit { constructor( private adminService: AdminService, - private toastrService: NbToastrService + private toastrService: NbToastrService, + private slugService: SlugService, ) { } ngOnInit(): void { @@ -42,6 +44,6 @@ export class ReportsComponent implements OnInit { } viewReport(messageId: number) { - window.open(`${window.location.origin}/#${messageId}`, '_blank'); + window.open(`${window.location.origin}/channel/${this.slugService.slug}#${messageId}`, '_blank'); } } diff --git a/frontend/src/app/components/admin/settings/settings.component.html b/frontend/src/app/components/admin/settings/settings.component.html index 80f3eea..0f0817a 100644 --- a/frontend/src/app/components/admin/settings/settings.component.html +++ b/frontend/src/app/components/admin/settings/settings.component.html @@ -1,31 +1,193 @@ - - - הגדרות ערוץ - - - - @for (setting of settings; track setting; let i = $index) { -
- - - -
- } -
- +
+
+ } + + @for (field of cat.fields; track field.key) { + @if (isFieldVisible(field)) { +
+ + @if (field.type === 'boolean') { +
+ + {{ field.label }} + +
+ @if (field.description) { +

{{ field.description }}

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

{{ field.description }}

+ } + + @switch (field.type) { + @case ('textarea') { + + } + @case ('number') { + + } + @case ('password') { + + } + @default { + + } + } + } +
+ } + } +
+
+ } + + + + + החלפות טקסט אוטומטיות + + +

+ ניתן להגדיר ביטויים רגולריים (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) { +
+ + + +
+ } + + + + + - - - -
\ 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 e69de29..819ebcb 100644 --- a/frontend/src/app/components/admin/settings/settings.component.scss +++ b/frontend/src/app/components/admin/settings/settings.component.scss @@ -0,0 +1,191 @@ +:host { + display: block; + direction: rtl; +} + +.settings-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 5rem; +} + +.settings-card { + margin-bottom: 0 !important; +} + +.settings-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + + nb-icon { + font-size: 1.25rem; + } +} + +.category-description { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.5; + + code { + background: var(--background-basic-color-2, #f7f9fc); + padding: 0.1rem 0.3rem; + border-radius: 4px; + font-size: 0.85em; + direction: ltr; + display: inline-block; + } +} + +.setting-field { + padding: 0.85rem 0; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + &:last-child { + border-bottom: none; + } + + &.is-toggle { + padding: 0.65rem 0; + } +} + +.field-label { + display: block; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 0.25rem; + color: var(--text-basic-color); + + &.small { + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.2rem; + } +} + +.field-description { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.4; +} + +.toggle-row { + display: flex; + align-items: center; + + ::ng-deep nb-toggle .toggle-label { + margin-inline-start: 0.5rem; + } +} + +.toggle-description { + margin-top: 0.35rem; + margin-inline-start: 3.25rem; +} + +.fcm-json-paste { + background: var(--background-basic-color-2, #f7f9fc); + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + + textarea { + direction: ltr; + font-family: monospace; + font-size: 0.85rem; + } + + .error-text { + color: var(--color-danger-default, #ff3d71); + font-size: 0.85rem; + margin-top: 0.4rem; + } +} + +.regex-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding: 0.6rem 0; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + &:last-of-type { + border-bottom: none; + margin-bottom: 0.6rem; + } +} + +.regex-fields { + display: flex; + gap: 0.5rem; + flex: 1; + flex-wrap: wrap; + + .regex-field { + flex: 1; + min-width: 180px; + } +} + +.extra-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; + + input:first-of-type { + flex: 0 0 30%; + } +} + +.empty-state { + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + font-style: italic; + margin: 0 0 0.75rem; +} + +.advanced-accordion { + ::ng-deep nb-accordion-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .advanced-title { + font-weight: 600; + } +} + +.save-bar { + position: sticky; + bottom: 0; + background: var(--background-basic-color-1); + padding: 0.75rem 0; + border-top: 1px solid var(--divider-color, #edf1f7); + display: flex; + justify-content: flex-start; + z-index: 5; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } +} + +hr { + border: none; + border-top: 1px solid var(--divider-color, #edf1f7); + margin: 1rem 0; +} + +.mt-2 { + margin-top: 0.5rem; +} diff --git a/frontend/src/app/components/admin/settings/settings.component.ts b/frontend/src/app/components/admin/settings/settings.component.ts index 5191a11..7ca6070 100644 --- a/frontend/src/app/components/admin/settings/settings.component.ts +++ b/frontend/src/app/components/admin/settings/settings.component.ts @@ -1,26 +1,63 @@ import { Component, OnInit } from '@angular/core'; import { AdminService } from '../../../services/admin.service'; -import { NbButtonModule, NbCardModule, NbToastrService, NbIconModule, NbInputModule } from "@nebular/theme"; +import { + NbButtonModule, + NbCardModule, + NbToastrService, + NbIconModule, + NbInputModule, + NbToggleModule, + NbAccordionModule, + NbTooltipModule, +} from "@nebular/theme"; import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; import { Setting } from '../../../models/setting.model'; +import { + SETTINGS_SCHEMA, + SettingsCategorySchema, + SettingFieldSchema, + FCM_JSON_KEY_MAP, + getAllKnownKeys, +} from './settings.schema'; + +interface RegexRule { + pattern: string; + replace: string; +} + +interface ExtraSetting { + key: string; + value: string; +} @Component({ selector: 'app-settings', imports: [ + CommonModule, NbCardModule, NbButtonModule, NbIconModule, NbInputModule, - FormsModule -], + NbToggleModule, + NbAccordionModule, + NbTooltipModule, + FormsModule, + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss' }) export class SettingsComponent implements OnInit { - settings: Setting[] = []; + schema: SettingsCategorySchema[] = SETTINGS_SCHEMA; + values: Record = {}; + regexRules: RegexRule[] = []; + extraSettings: ExtraSetting[] = []; + setInProgress: boolean = false; + fcmJsonPaste: string = ''; + fcmJsonError: string = ''; constructor( private adminService: AdminService, @@ -29,19 +66,162 @@ export class SettingsComponent implements OnInit { ngOnInit(): void { this.adminService.getSettings() - .then(settings => this.settings = settings || []); + .then(settings => this.loadFromSettings(settings || [])) + .catch(() => this.tostService.danger('', 'אין הרשאה לצפות בהגדרות')); } - saveSettings() { - this.setInProgress = true; - this.adminService.setSettings(this.settings) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); - this.setInProgress = false; + private loadFromSettings(settings: Setting[]) { + const known = getAllKnownKeys(); + this.values = {}; + this.regexRules = []; + this.extraSettings = []; + + 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 ?? ''); + if (raw.includes('#')) { + const idx = raw.indexOf('#'); + this.regexRules.push({ + pattern: raw.substring(0, idx), + replace: raw.substring(idx + 1), + }); + } else if (raw) { + this.regexRules.push({ pattern: raw, replace: '' }); + } + continue; + } + + if (known.has(s.key)) { + const field = this.findField(s.key); + if (field?.type === 'boolean') { + this.values[s.key] = this.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), + }); + } + } } - removeSetting(index: number) { - // if (!confirm('האם אתה בטוח שברצונך למחוק את ההגדרה הזו?')) return; - this.settings.splice(index, 1); + 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; + } + + private 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; + } + + 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); + } + + applyFcmJsonPaste() { + this.fcmJsonError = ''; + if (!this.fcmJsonPaste.trim()) return; + let parsed: any; + try { + parsed = JSON.parse(this.fcmJsonPaste); + } catch (e) { + 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 = 'לא נמצאו שדות מוכרים בקובץ ה-JSON'; + return; + } + + this.fcmJsonPaste = ''; + this.tostService.success('', `${count} שדות מולאו אוטומטית מה-JSON`); + } + + 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: `${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 }); + } + + return out; + } + + saveSettings() { + this.setInProgress = true; + const payload = this.buildSettingsArray(); + this.adminService.setSettings(payload) + .then(() => this.tostService.success('', 'השינויים נשמרו בהצלחה!')) + .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינויים')) + .finally(() => this.setInProgress = false); } } 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 0000000..425adbe --- /dev/null +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -0,0 +1,154 @@ +export type SettingFieldType = 'boolean' | 'text' | 'number' | 'url' | 'textarea' | 'password'; + +export interface SettingFieldSchema { + key: string; + label: string; + description?: string; + type: SettingFieldType; + placeholder?: string; + default?: string | number | boolean; + hideWhen?: { key: string; equals: any }; +} + +export interface SettingsCategorySchema { + id: string; + title: string; + icon?: string; + description?: string; + fields: SettingFieldSchema[]; +} + +export const SETTINGS_SCHEMA: SettingsCategorySchema[] = [ + { + id: 'general', + title: 'הגדרות כלליות', + icon: 'settings-2-outline', + fields: [ + { + key: 'max_file_size', + label: 'הגבלת גודל קובץ להעלאה (MB)', + description: 'גודל מקסימלי בקבצים שניתן להעלות לערוץ. ברירת מחדל: 100 MB.', + type: 'number', + placeholder: '100', + default: 100, + }, + ], + }, + { + id: 'security', + title: 'אבטחה ו-API', + icon: 'shield-outline', + fields: [ + { + key: 'api_secret_key', + label: 'מפתח API ליבוא הודעות', + description: 'מפתח סודי שיש לכלול בכותרת X-API-Key בעת קריאות יבוא הודעות.', + type: 'password', + placeholder: 'מפתח סודי חזק', + }, + ], + }, + { + id: 'storage', + title: 'אחסון ומדיה', + icon: 'hard-drive-outline', + fields: [ + { + key: 'tinypng_api_key', + label: 'מפתח API של TinyPNG לכיווץ תמונות', + description: 'כשמוגדר מפתח, תמונות PNG/JPEG/WebP ייכוצו אוטומטית לפני העלאה לאחסון ויחסכו מקום. קבלו מפתח חינמי ב-tinypng.com.', + type: 'password', + placeholder: 'YOUR_TINYPNG_API_KEY', + }, + ], + }, + { + id: 'ads', + title: 'פרסומות', + icon: 'pricetags-outline', + fields: [ + { + key: 'ad-iframe-src', + label: 'קישור HTML של פרסומת להטמעה', + description: 'הכנסת קישור תפעיל הצגת מסגרת פרסומת בערוץ.', + type: 'url', + placeholder: 'https://ad.example.com/banner.html', + }, + { + key: 'ad-iframe-width', + label: 'רוחב חלון הפרסומת (פיקסלים)', + description: 'רוחב מומלץ: 300.', + type: 'number', + placeholder: '300', + }, + ], + }, + { + id: 'webhook', + title: 'וובהוק (Webhook)', + icon: 'link-2-outline', + description: 'שליחת התראה לשרת חיצוני בעת יצירה, עדכון או מחיקה של הודעות.', + fields: [ + { + key: 'webhook_url', + label: 'כתובת ה-Webhook', + description: 'כתובת ה-URL שאליה תישלח בקשת POST בעת שינוי בהודעות.', + type: 'url', + placeholder: 'https://example.com/webhook', + }, + { + key: 'webhook_verify_token', + label: 'טוקן אימות', + description: 'טוקן סודי שיישלח עם כל בקשה לאימות שהבקשה הגיעה ממערכת זו (מומלץ).', + type: 'password', + placeholder: 'your-secret-token', + }, + ], + }, + { + id: 'notifications', + title: 'התראות דחיפה (Push)', + icon: 'bell-outline', + description: 'שליחת התראה לנייד/דפדפן בעת פרסום הודעה חדשה. תשתית ה-FCM מוגדרת על ידי מנהל המערכת.', + fields: [ + { + key: 'on_notification', + label: 'הפעלת התראות דחיפה בערוץ זה', + description: 'מנויי הערוץ יקבלו התראת Push בכל הודעה חדשה.', + type: 'boolean', + }, + ], + }, +]; + +export const FCM_JSON_KEY_MAP: Record = { + 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', +}; + +// 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', +]; + +export function getAllKnownKeys(): Set { + const keys = new Set(); + for (const cat of SETTINGS_SCHEMA) { + for (const f of cat.fields) keys.add(f.key); + } + for (const k of LEGACY_SETTING_KEYS) keys.add(k); + keys.add('regex-replace'); + return keys; +} diff --git a/frontend/src/app/components/admin/statistics/statistics.component.html b/frontend/src/app/components/admin/statistics/statistics.component.html index 877f501..fdb1dad 100644 --- a/frontend/src/app/components/admin/statistics/statistics.component.html +++ b/frontend/src/app/components/admin/statistics/statistics.component.html @@ -12,7 +12,6 @@
-
\ No newline at end of file diff --git a/frontend/src/app/components/admin/statistics/statistics.component.ts b/frontend/src/app/components/admin/statistics/statistics.component.ts index 8bbd6e7..e9e9452 100644 --- a/frontend/src/app/components/admin/statistics/statistics.component.ts +++ b/frontend/src/app/components/admin/statistics/statistics.component.ts @@ -69,21 +69,12 @@ export class StatisticsComponent implements OnInit { this.updateData(); } - resetPeakStatistics(): void { - this.adminService.resetPeakStatistics() - .then(() => { - this.toastrService.success("", 'נקודת השיא אופסה בהצלחה!'); - this.updateData(); - }) - .catch(() => this.toastrService.danger("", 'שגיאה באיפוס נקודת השיא!')); - } - updateData() { this.adminService.getStatistics().then(statistics => { this.statistics = statistics; this.lineChartData.datasets[0].data = statistics.connectionsStatistics.date; this.lineChartData.labels = statistics.connectionsStatistics.labels; this.chart?.update(); - }); + }).catch(() => this.toastrService.danger('', 'שגיאה בטעינת הסטטיסטיקות')); } } diff --git a/frontend/src/app/components/admin/storage/storage.component.ts b/frontend/src/app/components/admin/storage/storage.component.ts new file mode 100644 index 0000000..52c602b --- /dev/null +++ b/frontend/src/app/components/admin/storage/storage.component.ts @@ -0,0 +1,122 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbCardModule, NbButtonModule, NbToggleModule, + NbProgressBarModule, NbToastrService, NbAlertModule, NbIconModule +} from '@nebular/theme'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +interface StorageInfo { + usedBytes: number; + quotaBytes: number; + usedPercent: number; + autoCleanup: boolean; + level: 'ok' | 'warning' | 'critical'; +} + +@Component({ + selector: 'app-channel-storage', + standalone: true, + imports: [ + CommonModule, FormsModule, + NbCardModule, NbButtonModule, NbToggleModule, + NbProgressBarModule, NbAlertModule, NbIconModule + ], + template: ` + + אחסון + + @if (info) { +
+
+ שימוש: {{ formatBytes(info.usedBytes) }} + מתוך: {{ formatBytes(info.quotaBytes) }} +
+ + +
+ + @if (info.level === 'critical') { + + + שטח האחסון כמעט מלא! פנה מקום או הפעל ניקוי אוטומטי. + + } @else if (info.level === 'warning') { + + + שטח האחסון מתמלא ({{ info.usedPercent | number:'1.0-0' }}%). + + } + +
+ + ניקוי אוטומטי של מדיה ישנה + + + כשהאחסון עומד לעגמור, מוחק קבצים ישנים אוטומטית כדי לפנות מקום + +
+ } @else { +

טוען...

+ } +
+
+ ` +}) +export class StorageComponent implements OnInit { + @Input() slug!: string; + + info?: StorageInfo; + + constructor(private http: HttpClient, private toastr: NbToastrService) {} + + ngOnInit() { + this.load(); + } + + async load() { + try { + this.info = await firstValueFrom( + this.http.get(`/api/channel/${this.slug}/admin/storage`) + ); + } catch { + this.toastr.danger('שגיאה בטעינת מידע אחסון', 'שגיאה'); + } + } + + async saveAutoCleanup() { + if (!this.info) return; + try { + await firstValueFrom(this.http.post( + `/api/channel/${this.slug}/admin/storage/auto-cleanup`, + { enabled: this.info.autoCleanup } + )); + this.toastr.success('הגדרות נשמרו', 'אחסון'); + } catch { + this.toastr.danger('שגיאה בשמירה', 'שגיאה'); + } + } + + progressStatus(): string { + if (!this.info) return 'primary'; + if (this.info.level === 'critical') return 'danger'; + if (this.info.level === 'warning') return 'warning'; + return 'success'; + } + + formatBytes(bytes: number): string { + if (bytes === 0) 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'; + } +} diff --git a/frontend/src/app/components/channel/channel-header/channel-header.component.ts b/frontend/src/app/components/channel/channel-header/channel-header.component.ts index 0108a40..144375d 100644 --- a/frontend/src/app/components/channel/channel-header/channel-header.component.ts +++ b/frontend/src/app/components/channel/channel-header/channel-header.component.ts @@ -1,4 +1,5 @@ -import { Component, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Subscription } from 'rxjs'; import { NbButtonModule, @@ -16,6 +17,7 @@ import { Title } from '@angular/platform-browser'; import { AuthService } from '../../../services/auth.service'; import { ChatService } from '../../../services/chat.service'; import { NotificationsService } from '../../../services/notifications.service'; +import { SlugService } from '../../../services/slug.service'; import { AdminPanelComponent } from "../../admin/admin-panel.component"; import { User } from '../../../models/user.model'; @@ -30,21 +32,12 @@ import { User } from '../../../models/user.model'; templateUrl: './channel-header.component.html', styleUrl: './channel-header.component.scss' }) -export class ChannelHeaderComponent implements OnInit { +export class ChannelHeaderComponent implements OnInit, OnDestroy { + private menuSub?: Subscription; @Input() set userInfo(user: User | undefined) { this._userInfo = user; - this.userMenu = [ - ...((user?.privileges?.['admin'] || user?.privileges?.['moderator']) ? [{ - title: 'ניהול ערוץ', - icon: 'people-outline', - }] : []), - { - title: 'התנתק', - icon: 'log-out', - } - ]; } get userInfo() { @@ -53,11 +46,49 @@ export class ChannelHeaderComponent implements OnInit { private _userInfo?: User; + // Derived lazily rather than in the input setter: the user object is cached by + // AuthService, so switching channels re-emits the very same reference and the + // setter never runs again — the menu would keep the previous channel's answer. + // Rebuilt only when the entries actually change, so the context menu directive + // is not handed a fresh array on every change detection pass. + get userMenu(): NbMenuItem[] { + const user = this._userInfo; + const role = user?.channelRoles?.[this._slugService.slug]; + // Only the role on the channel being viewed counts; a super_admin is granted + // everything by the backend even with an empty channelRoles map. + const isSuperAdmin = user?.globalRole === 'super_admin'; + // Every tab of the panel is moderator level or above, so a writer would open + // it onto an empty sidebar. + const canManageChannel = !!user && (isSuperAdmin || role === 'owner' || role === 'moderator'); + + if (!this._menuState || this._menuState.manage !== canManageChannel || this._menuState.superAdmin !== isSuperAdmin) { + this._menuState = { manage: canManageChannel, superAdmin: isSuperAdmin }; + this._userMenu = [ + ...(canManageChannel ? [{ + title: 'ניהול ערוץ', + icon: 'people-outline', + }] : []), + ...(isSuperAdmin ? [{ + title: 'פאנל מנהל-על', + icon: 'shield-outline', + }] : []), + { + title: 'התנתק', + icon: 'log-out', + } + ]; + } + + return this._userMenu; + } + + private _userMenu: NbMenuItem[] = []; + private _menuState?: { manage: boolean, superAdmin: boolean }; + @Output() userInfoChange: EventEmitter = new EventEmitter(); userMenuTag = 'user-menu'; - userMenu: NbMenuItem[] = []; isSmallScreen = false; constructor( @@ -69,6 +100,7 @@ export class ChannelHeaderComponent implements OnInit { public notificationsService: NotificationsService, private titleService: Title, private dialogService: NbDialogService, + private _slugService: SlugService, ) { } @@ -81,7 +113,7 @@ export class ChannelHeaderComponent implements OnInit { this.chatService.updateChannelInfo() .then(() => this.titleService.setTitle(this.chatService.channelInfo?.name || 'TheChannel')); - this.contextMenuService.onItemClick() + this.menuSub = this.contextMenuService.onItemClick() .pipe(filter(({ tag }) => tag === this.userMenuTag)) .subscribe(value => { switch (value.item.icon) { @@ -91,29 +123,32 @@ export class ChannelHeaderComponent implements OnInit { case 'people-outline': this.dialogService.open(AdminPanelComponent, { closeOnBackdropClick: true }); break; + case 'shield-outline': + this.router.navigate(['/super-admin']); + break; } }); this.updateScreenSize(); } + ngOnDestroy() { + this.menuSub?.unsubscribe(); + } + async logout() { if (await this._authService.logout()) { this.userInfo = undefined; this.userInfoChange.emit(undefined); try { await this._authService.loadUserInfo(); + // Still logged in somehow — go to root and let AuthGuard decide + this.router.navigate(['/']); } catch (err: any) { if (err.status === 401) { this.router.navigate(['/login']); } } - - const path = this.router.url; - if (path !== '/') { - this.router.navigate(['/']); - } - } else { this.toastrService.danger("", "שגיאה בהתנתקות"); } diff --git a/frontend/src/app/components/channel/channel.component.html b/frontend/src/app/components/channel/channel.component.html index c1c4bc5..0790d17 100644 --- a/frontend/src/app/components/channel/channel.component.html +++ b/frontend/src/app/components/channel/channel.component.html @@ -1,21 +1,85 @@ - - - - +@if (noChannel) { + + +
+ + TheChannel +
+
- - - + +
+ + +
בקשת פתיחת ערוץ
+
+ + @if (reqSubmitted) { +
+ +

הבקשה נשלחה בהצלחה!

+

נחזור אליך בהקדם לאחר בדיקת הבקשה.

+
+ } @else { +

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

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ } +
+
+
+
+
+} + +@if (slugReady) { + + + + - @if (ad) { - - + + - } - @if (userInfo?.privileges?.['writer']) { - - - - } - + @if (ad) { + + + + } + + @if (userInfo?.globalRole === 'super_admin' || hasAnyRole(userInfo)) { + + + + } +
+} diff --git a/frontend/src/app/components/channel/channel.component.scss b/frontend/src/app/components/channel/channel.component.scss index 96e7244..35c5741 100644 --- a/frontend/src/app/components/channel/channel.component.scss +++ b/frontend/src/app/components/channel/channel.component.scss @@ -1,6 +1,18 @@ @use '../../../themes' as *; @use "sass:meta"; +.no-channel-container { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 2rem 1rem; +} + +.request-card { + width: 100%; + max-width: 520px; +} + nb-layout-column.chat-column { } diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index 68a83f0..70af8f9 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -1,26 +1,43 @@ -import { Component, ElementRef, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { AdvertisingComponent } from "./advertising/advertising.component"; import { Ad, AdsService } from '../../services/ads.service'; import { NbButtonModule, + NbCardModule, NbIconModule, + NbInputModule, NbLayoutModule, NbListModule, NbMenuModule, NbSidebarModule, + NbSpinnerModule, + NbToastrService, } from "@nebular/theme"; import { InputFormComponent } from "./chat/input-form/input-form.component"; import { AuthService } from "../../services/auth.service"; import { ChannelHeaderComponent } from "./channel-header/channel-header.component"; import { ChatComponent } from "./chat/chat.component"; import { User } from '../../models/user.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { SlugService } from '../../services/slug.service'; +import { AdminService } from '../../services/admin.service'; +import { ChatService } from '../../services/chat.service'; +import { MagnetAdsService } from '../../services/magnet-ads.service'; +import { ChannelRequestService } from '../../services/channel-request.service'; +import { NotificationsService } from '../../services/notifications.service'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-channel', imports: [ + FormsModule, AdvertisingComponent, NbLayoutModule, + NbCardModule, + NbInputModule, + NbSpinnerModule, InputFormComponent, ChannelHeaderComponent, NbButtonModule, @@ -29,11 +46,11 @@ import { User } from '../../models/user.model'; NbSidebarModule, NbListModule, ChatComponent -], + ], templateUrl: './channel.component.html', styleUrl: './channel.component.scss' }) -export class ChannelComponent implements OnInit { +export class ChannelComponent implements OnInit, OnDestroy { @ViewChild('inputForm', { static: false }) set inputForm(element: ElementRef) { @@ -48,21 +65,120 @@ export class ChannelComponent implements OnInit { private adsService: AdsService, private _authService: AuthService, private renderer: Renderer2, - private el: ElementRef + private el: ElementRef, + private route: ActivatedRoute, + private router: Router, + private slugService: SlugService, + private adminService: AdminService, + private chatService: ChatService, + private magnetAds: MagnetAdsService, + private channelRequestService: ChannelRequestService, + private notificationsService: NotificationsService, + private toastr: NbToastrService, ) { } ad: Ad = { src: '', width: 0 }; userInfo?: User; + slugReady = false; + noChannel = false; + private paramSub?: Subscription; + + // Channel request form state + reqName = ''; + reqEmail = ''; + reqSlug = ''; + reqDescription = ''; + reqSubmitting = false; + reqSubmitted = false; ngOnInit(): void { + this.paramSub = this.route.paramMap.subscribe(params => { + const slug = params.get('slug'); + this.initChannel(slug); + }); + } + + ngOnDestroy(): void { + this.paramSub?.unsubscribe(); + } + + private async initChannel(slug: string | null): Promise { + this.slugReady = false; + this.noChannel = false; + // Yield to Angular's change detection so the @if (slugReady) block + // actually destroys ChatComponent before we reinitialise with the new slug. + // Without this, false→true in the same synchronous frame is collapsed and + // the child is never torn down, leaving stale state (isVisible, messages, etc). + await Promise.resolve(); + + if (!slug) { + const user = await this._authService.loadUserInfo(); + const roles = user?.channelRoles; + const firstSlug = roles ? Object.keys(roles)[0] : ''; + if (firstSlug) { + this.router.navigate(['/channel', firstSlug], { replaceUrl: true }); + } else { + this.userInfo = user ?? undefined; + this.reqName = user?.publicName || ''; + this.reqEmail = user?.email || ''; + this.noChannel = true; + } + return; + } + + this.slugService.slug = slug; + this.chatService.clearCache(); + this.adminService.clearCache(); + this.notificationsService.reset(); + // Drop any in-progress edit/compose state from the previous channel, otherwise + // the replayed BehaviorSubject value makes the input form post the old message + // id into the new channel. + this.adminService.setEditMessage(undefined); + this.magnetAds.clearCache(); + this.slugReady = true; + this.adsService.getAds().then(ad => { this.ad = ad; }); this._authService.loadUserInfo().then(res => { - this.userInfo = res + this.userInfo = res; + }).catch(() => { + // Anonymous visitor on a public channel — read-only view. + this.userInfo = undefined; }); } + async submitChannelRequest(): Promise { + if (!this.reqName || !this.reqEmail || !this.reqSlug || !this.reqDescription) { + this.toastr.warning('', 'יש למלא את כל השדות'); + return; + } + this.reqSubmitting = true; + try { + await this.channelRequestService.submitRequest(this.reqName, this.reqEmail, this.reqSlug, this.reqDescription); + this.reqSubmitted = true; + } catch { + this.toastr.danger('', 'שגיאה בשליחת הבקשה, נסה שוב'); + } finally { + this.reqSubmitting = false; + } + } + + async logout(): Promise { + if (await this._authService.logout()) { + this.router.navigate(['/login']); + } else { + this.toastr.danger('', 'שגיאה בהתנתקות'); + } + } + + // A role on some other channel must not open the composer here — gate on the + // role the user holds on the channel currently being viewed. + hasAnyRole(user: User | undefined): boolean { + const role = user?.channelRoles?.[this.slugService.slug]; + return role === 'owner' || role === 'moderator' || role === 'writer'; + } + onInputHeightChanged() { this.updateInputBottomOffset(); } diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 5ed82b7..8e78ae4 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -1,5 +1,13 @@ +@if (isOffline) { +
+ + אין חיבור לאינטרנט — ההודעות יתעדכנו אוטומטית עם חזרת החיבור +
+} +
+ (bottomThreshold)="loadMessages({ scrollDown: true })" [listenWindowScroll]="true" + [class.chat-hidden]="!isVisible"> @for (msg of scheduledMessages; track $index) { @@ -34,6 +42,11 @@
} + @if (adSlotsAfter.has(message.id!)) { +
+ +
+ }
} diff --git a/frontend/src/app/components/channel/chat/chat.component.scss b/frontend/src/app/components/channel/chat/chat.component.scss index 0fb83bb..5a7d3f4 100644 --- a/frontend/src/app/components/channel/chat/chat.component.scss +++ b/frontend/src/app/components/channel/chat/chat.component.scss @@ -1,3 +1,25 @@ +// Hidden until the initial scroll-to-last-read is resolved so there is no +// visible "flash at bottom then jump" on first load. +.chat-hidden { + visibility: hidden; +} + +.offline-banner { + position: sticky; + top: 0; + z-index: 1100; + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + padding: 8px 16px; + background: #ff9f43; + color: #fff; + font-size: 0.9rem; + font-weight: 500; + text-align: center; +} + nb-list-item { border-bottom: 0 !important; padding: 0 !important; diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 5f6200d..12a39be 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -12,13 +12,16 @@ import { NbToastrService } from "@nebular/theme"; import { MessageComponent } from "./message/message.component"; -import { firstValueFrom, interval } from 'rxjs'; +import { MagnetAdSlotComponent } from "./magnet-ad-slot/magnet-ad-slot.component"; +import { firstValueFrom, interval, Subscription } from 'rxjs'; import { ChatMessage, ChatService } from '../../../services/chat.service'; import { AuthService } from '../../../services/auth.service'; import { ActivatedRoute } from '@angular/router'; import { NotificationsService } from '../../../services/notifications.service'; import { User } from '../../../models/user.model'; import { AdminService } from '../../../services/admin.service'; +import { MagnetAdsService } from '../../../services/magnet-ads.service'; +import { SlugService } from '../../../services/slug.service'; type LoadMsgOpt = { scrollDown?: boolean; @@ -45,18 +48,23 @@ type ScrollOpt = { NbButtonModule, NbListModule, NbBadgeModule, - MessageComponent + MessageComponent, + MagnetAdSlotComponent ], templateUrl: './chat.component.html', styleUrl: './chat.component.scss' }) export class ChatComponent implements OnInit, OnDestroy { private eventSource!: EventSource; + private sseEverConnected = false; messages: ChatMessage[] = []; + adSlotsAfter: Set = new Set(); scheduledMessages!: ChatMessage[]; hideScheduledMessages: boolean = false; userInfo?: User; isLoading: boolean = false; + isOffline: boolean = false; + isVisible: boolean = false; // hidden until initial scroll is resolved offset: number = 0; limit: number = 20; hasOldMessages: boolean = true; @@ -64,7 +72,8 @@ export class ChatComponent implements OnInit, OnDestroy { thereNewMessages: boolean = false; showScrollToBottom: boolean = false; private lastHeartbeat: number = Date.now(); - private subLastHeartbeat: any; + private subLastHeartbeat?: Subscription; + private schedulingSub?: Subscription; lastReadMessageId: number = 0; constructor( @@ -73,15 +82,37 @@ export class ChatComponent implements OnInit, OnDestroy { private _adminService: AdminService, private toastrService: NbToastrService, private notificationService: NotificationsService, + private magnetAds: MagnetAdsService, + private slugService: SlugService, private zone: NgZone, private router: ActivatedRoute, - ) { - this._adminService.schedulingBusObservable.subscribe(() => { - //this.scheduledMessages.length = 0; - this.loadScheduledMessages(); + ) { } + + // A role on some other channel grants nothing here — the scheduled-messages + // routes are gated per channel, so only the role on the current slug counts. + private hasWriteRole(): boolean { + const user = this.userInfo; + if (!user) return false; + if (user.globalRole === 'super_admin') return true; + const role = user.channelRoles?.[this.slugService.slug]; + return role === 'owner' || role === 'moderator' || role === 'writer'; + } + + @HostListener('window:online') + onOnline() { + this.zone.run(() => { + this.isOffline = false; + this.toastrService.success('החיבור חודש', '', { duration: 3000 }); + this.initializeMessageListener(); + this.loadMissedMessages(); }); } + @HostListener('window:offline') + onOffline() { + this.zone.run(() => { this.isOffline = true; }); + } + @HostListener('window:scroll', []) onWindowScroll() { this.onListScroll(); @@ -129,30 +160,50 @@ export class ChatComponent implements OnInit, OnDestroy { } ngOnInit() { + this.schedulingSub = this._adminService.schedulingBusObservable.subscribe(() => { + this.loadScheduledMessages(); + }); + this.chatService.getEmojisList(true); + this.magnetAds.loadSettings() + .catch(() => null) + .then(() => this.rebuildItems()); + this.initializeMessageListener(); this.keepAliveSSE(); this._authService.loadUserInfo().then((res) => { this.userInfo = res; - this.userInfo.privileges?.['writer'] && this.loadScheduledMessages(); + this.loadScheduledMessagesIfAllowed(); this.notificationService.init(); + }).catch(() => { + // Anonymous visitor on a public channel — read-only view. + this.userInfo = undefined; }); this.loadMessages().then(() => { const lastReadMsg = Number(localStorage.getItem('lastReadMessage')); - const lastMsgId = this.messages[0].id!; + const lastMsgId = this.messages[0]?.id; + if (!lastMsgId) { this.isVisible = true; return; } + if (lastReadMsg && lastReadMsg < lastMsgId) { + // Set the indicator BEFORE revealing the list so the line renders + // at the right position on first paint — no visible jump. + this.lastReadMessageId = lastReadMsg; + this.scrollToId({ messageId: lastReadMsg, smooth: false, mark: false }); + // Wait one frame for scrollToId to finish (it may need to load more messages), + // then reveal. setLastReadMessage only after the user has actually seen the + // position — so a refresh still brings them back. setTimeout(() => { - this.scrollToId({ messageId: lastReadMsg, smooth: false, mark: false }); - this.lastReadMessageId = lastReadMsg; - }, 200); + this.isVisible = true; + this.setLastReadMessage(lastMsgId.toString()); + }, 350); } else { this.scrollToBottom(false); + this.isVisible = true; + this.setLastReadMessage(lastMsgId.toString()); } - - this.setLastReadMessage(lastMsgId.toString()); }); } @@ -160,27 +211,43 @@ export class ChatComponent implements OnInit, OnDestroy { localStorage.setItem('lastReadMessage', id); } - private async initializeMessageListener() { + private initializeMessageListener() { this.eventSource = this.chatService.sseListener(); - this.eventSource.onmessage = (event) => { + this.eventSource.onopen = () => { + if (this.sseEverConnected) { + // Reconnect after a drop — fetch messages that arrived during the gap + this.zone.run(() => { this.isOffline = false; }); + this.loadMissedMessages(); + } + this.sseEverConnected = true; + this.lastHeartbeat = Date.now(); + }; + + this.eventSource.onerror = () => { + // Browser will auto-retry with Last-Event-ID; we just track offline state + // via window:online/offline and the keepAlive heartbeat. + }; + + this.eventSource.onmessage = (event) => { this.lastHeartbeat = Date.now(); const message = JSON.parse(event.data); switch (message.type) { case 'new-message': if (this.hasNewMessages) break; + if (this.messages.some(m => m.id === message.message.id)) break; // dedup after reconnect this.zone.run(() => { this.messages.unshift(message.message); - this.thereNewMessages = !(message.message.author === this.userInfo?.username); + this.thereNewMessages = !this.isAtBottom() && !(message.message.author === this.userInfo?.username); this.setLastReadMessage(message.message.id!.toString()); - if (this.userInfo?.privileges?.['writer'] && this.scheduledMessages && message.message.author === "Scheduled") { + if (this.hasWriteRole() && this.scheduledMessages && message.message.author === "Scheduled") { this.loadScheduledMessages(true); } }); break; case 'delete-message': - if (this.userInfo?.privileges?.['writer']) { + if (this.hasWriteRole()) { this.zone.run(() => { const index = this.messages.findIndex(m => m.id === message.message.id); if (index !== -1) { @@ -220,18 +287,81 @@ export class ChatComponent implements OnInit, OnDestroy { ngOnDestroy() { this.chatService.sseClose(); - clearInterval(this.subLastHeartbeat); + this.subLastHeartbeat?.unsubscribe(); + this.schedulingSub?.unsubscribe(); + } + + private rebuildItems() { + try { + this.adSlotsAfter = this.magnetAds.computeAdSlots(this.messages); + } catch (e) { + console.error('computeAdSlots failed, ads will not be shown:', e); + this.adSlotsAfter = new Set(); + } } async keepAliveSSE() { - clearInterval(this.subLastHeartbeat); + this.subLastHeartbeat?.unsubscribe(); + // Backend sends heartbeat every 25s; if 35s pass without one the SSE is dead. this.subLastHeartbeat = interval(10000) .subscribe(() => { - if (Date.now() - this.lastHeartbeat > 60000) { + if (Date.now() - this.lastHeartbeat > 35000) { this.lastHeartbeat = Date.now(); this.initializeMessageListener(); - }; + this.loadMissedMessages(); + } + }); + } + + private async loadMissedMessages() { + if (!this.messages.length) return; + const maxId = Math.max(...this.messages.map(m => m.id!)); + try { + const missed = await firstValueFrom(this.chatService.getMessages(maxId, this.limit, 'asc')); + if (!missed?.length) return; + + this.zone.run(() => { + // Deduplicate: SSE may have already delivered some of these via Last-Event-ID. + const existing = new Set(this.messages.map(m => m.id)); + const fresh = missed.filter(m => !existing.has(m.id)); + if (!fresh.length) return; + + // fresh is in ascending order; reverse so newest is at index 0 + this.messages.unshift(...[...fresh].reverse()); + this.hasNewMessages = missed.length >= this.limit; + this.rebuildItems(); + + // The browser's overflow-anchor CSS property handles viewport + // preservation automatically when content is added at the visual + // bottom (flex-column-reverse puts new items there). We only need + // to act when the user is already at the bottom: scroll them to + // see the new messages immediately. + if (this.isAtBottom()) { + this.thereNewMessages = false; + this.scrollToBottom(false); + } else { + this.thereNewMessages = true; + } }); + } catch { + // Best-effort; will retry on the next reconnect + } + } + + private isAtBottom(): boolean { + const distanceFromBottom = + document.documentElement.scrollHeight - window.innerHeight - window.scrollY; + return distanceFromBottom < 80; + } + + private getScrollAnchorElement(): Element | null { + // Pick the topmost fully-visible message as the scroll anchor. + const items = document.querySelectorAll('nb-list-item'); + for (const el of Array.from(items)) { + const rect = el.getBoundingClientRect(); + if (rect.top >= 0 && rect.bottom <= window.innerHeight) return el; + } + return null; } onListScroll() { @@ -253,6 +383,16 @@ export class ChatComponent implements OnInit, OnDestroy { this.thereNewMessages = false; } + // The endpoint answers 403 when the operator switched scheduled messages off, + // and the flag only arrives with the channel info — which is still in flight on + // first paint, so wait for it instead of firing a request that cannot succeed. + private async loadScheduledMessagesIfAllowed(reload: boolean = false) { + if (!this.hasWriteRole()) return; + await this.chatService.ensureChannelInfo().catch(() => null); + if (!this.chatService.scheduledMessagesEnabled) return; + this.loadScheduledMessages(reload); + } + private async loadScheduledMessages(reload: boolean = false) { this._adminService.getScheduledMessages(reload) .then(messages => { @@ -269,7 +409,7 @@ export class ChatComponent implements OnInit, OnDestroy { opt.resetList && (this.offset = 0); - const maxId = Math.max(...this.messages.map(m => m.id!)); + const maxId = this.messages.length ? Math.max(...this.messages.map(m => m.id!)) : 0; if (opt.scrollDown) { direction = "asc"; startId = maxId; @@ -313,6 +453,7 @@ export class ChatComponent implements OnInit, OnDestroy { this.hasOldMessages = response.length >= this.limit; } this.offset = Math.min(...this.messages.map(m => m.id!)); + this.rebuildItems(); setTimeout(() => { opt.messageId && this.scrollToId({ messageId: opt.messageId, smooth: false, mark: opt.mark }); }, 300); diff --git a/frontend/src/app/components/channel/chat/input-form/input-form.component.html b/frontend/src/app/components/channel/chat/input-form/input-form.component.html index 3162557..566e640 100644 --- a/frontend/src/app/components/channel/chat/input-form/input-form.component.html +++ b/frontend/src/app/components/channel/chat/input-form/input-form.component.html @@ -35,16 +35,23 @@ + @if (chatService.fileUploadsEnabled) { + + } - תצוגה מקדימה + + Enter שולח +
@@ -60,6 +67,7 @@ (resized)="inputHeightChanged.emit($event)" [minRows]="1" [maxRows]="6" [maxlength]="maxMessageLength" [disabled]="showMarkdownPreview || isSending" class="bg-transparent border-0 flex-grow-1 fs-5" [(ngModel)]="input" (input)="checkScrollbar()" + (keydown)="onKeydown($event)" (paste)="onPaste($event)" style="resize: none">
@if (message?.id != undefined) { @@ -78,10 +86,12 @@ } @else { + @if (chatService.scheduledMessagesEnabled) { + } diff --git a/frontend/src/app/components/channel/chat/input-form/input-form.component.ts b/frontend/src/app/components/channel/chat/input-form/input-form.component.ts index 7cec8b9..d419ec1 100644 --- a/frontend/src/app/components/channel/chat/input-form/input-form.component.ts +++ b/frontend/src/app/components/channel/chat/input-form/input-form.component.ts @@ -19,11 +19,16 @@ import { } from "@nebular/theme"; import { MarkdownComponent } from "ngx-markdown"; import { NgIconsModule } from "@ng-icons/core"; -import { Attachment, ChatFile, ChatMessage } from '../../../../services/chat.service'; +import { Attachment, ChatFile, ChatMessage, ChatService } from '../../../../services/chat.service'; import { AdminService, EditMsg } from '../../../../services/admin.service'; import { AutosizeModule } from "ngx-autosize"; import { TimePickerComponent } from './time-picker/time-picker.component'; +// Per-user client-side preference: the server never consumed this setting, and +// the channel settings endpoint it used to arrive through is owner-only, so a +// writer or moderator could never receive it. +const ENTER_SENDS_MESSAGE_KEY = 'enterSendsMessage'; + @Component({ selector: 'app-input-form', imports: [ @@ -59,6 +64,7 @@ export class InputFormComponent implements OnInit, OnDestroy { isSending: boolean = false; showMarkdownPreview: boolean = false; hasScrollbar: boolean = false; + enterSendsMessage: boolean = false; private subscription!: Subscription; @ViewChild('inputTextArea') inputTextArea!: ElementRef; @@ -68,22 +74,35 @@ export class InputFormComponent implements OnInit, OnDestroy { constructor( private adminService: AdminService, private toastrService: NbToastrService, - private dialogService: NbDialogService + private dialogService: NbDialogService, + protected chatService: ChatService, ) { } ngOnInit() { + this.enterSendsMessage = localStorage.getItem(ENTER_SENDS_MESSAGE_KEY) === '1'; + if (this.message) { this.input = this.message.text || ''; } this.subscription = this.adminService.messageEditObservable.subscribe((edit?: EditMsg) => { - if (edit?.isScheduling) { + if (!edit) { + // Cleared (e.g. on a channel switch) — drop every leftover of the + // previous compose/edit so nothing targets the wrong channel. + this.message = undefined; + this.input = ''; + this.isAds = false; + this.attachments = []; + this.schedulingMessage = undefined; + return; + } + if (edit.isScheduling) { this.schedulingMessage = edit.message?.timestamp; } - if (edit?.new) { + if (edit.new) { this.input = this.input ? `${this.input}\n${edit.message.text}` : edit.message.text || ''; } else { - this.message = edit?.message; + this.message = edit.message; this.input = this.message?.text || ''; this.isAds = this.message?.is_ads || false; } @@ -199,7 +218,10 @@ export class InputFormComponent implements OnInit, OnDestroy { this.message.text = this.input; this.message.deleted = false; this.message.is_ads = this.isAds; - await firstValueFrom(this.adminService.editMessage(this.message)); + // A rejected save must leave the composer untouched — reporting success here + // would clear the textarea and lose whatever the user just wrote. + const res = await firstValueFrom(this.adminService.editMessage(this.message)); + if (!res?.success) return false; this.cancelUpdateMessage(); return true; } @@ -279,6 +301,50 @@ export class InputFormComponent implements OnInit, OnDestroy { } } + setEnterSendsMessage(checked: boolean) { + this.enterSendsMessage = checked; + localStorage.setItem(ENTER_SENDS_MESSAGE_KEY, checked ? '1' : '0'); + } + + onKeydown(event: KeyboardEvent) { + if (!this.enterSendsMessage) return; + if (event.key !== 'Enter') return; + + if (event.ctrlKey || event.metaKey) { + // Ctrl+Enter → insert newline at cursor + event.preventDefault(); + const ta = this.inputTextArea.nativeElement; + const start = ta.selectionStart; + const end = ta.selectionEnd; + this.input = this.input.substring(0, start) + '\n' + this.input.substring(end); + setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + 1; }); + } else if (!event.shiftKey) { + // Enter alone → send + event.preventDefault(); + this.sendMessage(); + } + } + + onPaste(event: ClipboardEvent) { + const items = event.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith('image/')) { + const file = items[i].getAsFile(); + if (!file) continue; + event.preventDefault(); + const attachment: Attachment = { file }; + const idx = this.attachments.push(attachment) - 1; + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (e) => { + if (e.target) this.attachments[idx].url = e.target.result as string; + }; + this.uploadFile(this.attachments[idx]); + } + } + } + applyFormat(format: 'bold' | 'italic' | 'underline' | 'code') { const textArea = this.inputTextArea.nativeElement; const start = textArea.selectionStart; diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html new file mode 100644 index 0000000..3581101 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+
+ {{ chatService.channelInfo?.name || '' }} + פרסומת +
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss new file mode 100644 index 0000000..30a043e --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss @@ -0,0 +1,25 @@ +:host { + display: block; + width: 100%; +} + +.magnet-ad-message { + &.collapsed { + display: none !important; + } +} + +.magnet-ad-card { + max-width: 80vw; +} + +.magnet-ad-host { + display: block; + width: 100%; + min-height: 1px; + + ::ng-deep .magnet-ad-content { + display: block; + width: 100%; + } +} diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts new file mode 100644 index 0000000..5d6f301 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts @@ -0,0 +1,132 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, +} from '@angular/core'; +import { NbUserModule } from '@nebular/theme'; +import { MagnetAdsService } from '../../../../services/magnet-ads.service'; +import { ChatService } from '../../../../services/chat.service'; + +@Component({ + selector: 'app-magnet-ad-slot', + standalone: true, + imports: [NbUserModule], + templateUrl: './magnet-ad-slot.component.html', + styleUrl: './magnet-ad-slot.component.scss', +}) +export class MagnetAdSlotComponent implements AfterViewInit, OnDestroy { + @Input() slotKey: string = ''; + + @ViewChild('host', { static: true }) hostRef!: ElementRef; + + private observer?: IntersectionObserver; + private rendered = false; + private collapseTimer?: any; + collapsed = false; + + constructor( + private magnet: MagnetAdsService, + public chatService: ChatService, + ) {} + + ngAfterViewInit(): void { + if (typeof IntersectionObserver === 'undefined') { + this.render(); + return; + } + + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !this.rendered) { + this.render(); + this.observer?.disconnect(); + break; + } + } + }, + { rootMargin: '200px 0px' }, + ); + this.observer.observe(this.hostRef.nativeElement); + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + if (this.collapseTimer) clearTimeout(this.collapseTimer); + } + + private render(): void { + if (this.rendered) return; + this.rendered = true; + + const settings = this.magnet.getSettings(); + const snippet = settings?.snippet?.trim(); + if (!snippet) { + this.collapse(); + return; + } + + const host = this.hostRef.nativeElement; + host.innerHTML = ''; + + const container = document.createElement('div'); + container.className = 'magnet-ad-content'; + host.appendChild(container); + + try { + this.injectSnippet(container, snippet); + } catch { + this.collapse(); + return; + } + + this.collapseTimer = setTimeout(() => this.collapseIfEmpty(), 5000); + } + + private injectSnippet(container: HTMLElement, snippet: string): void { + const template = document.createElement('template'); + template.innerHTML = snippet; + + const fragment = template.content; + const scripts: HTMLScriptElement[] = []; + fragment.querySelectorAll('script').forEach((s) => { + scripts.push(s as HTMLScriptElement); + }); + + container.appendChild(fragment); + + for (const oldScript of scripts) { + const newScript = document.createElement('script'); + for (const attr of Array.from(oldScript.attributes)) { + newScript.setAttribute(attr.name, attr.value); + } + if (oldScript.textContent) newScript.textContent = oldScript.textContent; + oldScript.parentNode?.replaceChild(newScript, oldScript); + } + } + + private collapseIfEmpty(): void { + const host = this.hostRef.nativeElement; + const content = host.querySelector('.magnet-ad-content') as HTMLElement | null; + if (!content) { + this.collapse(); + return; + } + const hasMeaningfulContent = + content.children.length > 0 || (content.textContent?.trim().length ?? 0) > 0; + const hasHeight = content.offsetHeight > 0; + + if (!hasMeaningfulContent || !hasHeight) { + this.collapse(); + } + } + + private collapse(): void { + this.collapsed = true; + 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 0f4a7b6..63d0a2c 100644 --- a/frontend/src/app/components/channel/chat/message/message.component.html +++ b/frontend/src/app/components/channel/chat/message/message.component.html @@ -7,7 +7,7 @@
- {{_authService.userInfo?.privileges?.['admin'] ? message.author : + {{ canModerate ? message.author : chatService.channelInfo?.name }} {{ message.timestamp | messageTime }} @@ -22,25 +22,25 @@ (click)="editMessage(message)" status="danger">
} - @if (_authService.userInfo?.privileges?.['writer']) { + @if (canWrite) { } - @if (_authService.userInfo?.privileges?.['writer'] && !message.deleted) { + @if (canWrite && !message.deleted) { } - @if (_authService.userInfo?.privileges?.['writer']) { + @if (canWrite) { } - @if (_authService.userInfo) { + @if (_authService.userInfo && chatService.reportsEnabled) {
-
}
- @if (message.reactions) { + @if (message.reactions && chatService.reactionsEnabled) {
@for (reaction of message.reactions | keyvalue; track reaction) {
{ + if (!res?.success) this.toastrService.danger('', 'שגיאה במחיקת ההודעה'); + }, + error: () => this.toastrService.danger('', 'שגיאה במחיקת ההודעה'), + }); } } @@ -244,6 +271,8 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { 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) { @@ -285,7 +314,7 @@ export class MessageComponent implements OnInit, AfterViewInit, OnDestroy { copyLink(messageId?: number) { if (!messageId || this.isSchedulingMessage) return; - const url = `${window.location.origin}/#${messageId}`; + const url = `${window.location.origin}/channel/${this.slugService.slug}#${messageId}`; navigator.clipboard.writeText(url).then(() => { this.toastrService.success('', 'הקישור הועתק ללוח'); }); 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 0000000..cc47ad7 --- /dev/null +++ b/frontend/src/app/components/landing/landing-page.component.scss @@ -0,0 +1,62 @@ +: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; +} + +.hero { + background: linear-gradient(135deg, #3366ff 0%, #6610f2 100%); + color: white; + padding: 80px 0; + text-align: center; + direction: rtl; +} + +.hero h1 { + font-size: 2.8rem; + font-weight: 700; + margin-bottom: 1rem; +} + +.hero p { + font-size: 1.2rem; + opacity: 0.9; + margin-bottom: 2rem; +} + +.section { + padding: 60px 0; + direction: rtl; +} + +.section-title { + text-align: center; + margin-bottom: 2.5rem; + font-size: 1.8rem; + font-weight: 600; +} + +.step-number { + width: 48px; + height: 48px; + border-radius: 50%; + background: #3366ff; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: 700; + margin: 0 auto 1rem; +} + +.footer { + text-align: center; + padding: 20px; + color: #888; + font-size: 0.85rem; +} 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 0000000..8c741a1 --- /dev/null +++ b/frontend/src/app/components/landing/landing-page.component.ts @@ -0,0 +1,267 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterLink, Router } from '@angular/router'; +import { + NbCardModule, NbButtonModule, NbInputModule, + NbFormFieldModule, NbIconModule, NbAlertModule, NbSpinnerModule, + NbLayoutModule +} from '@nebular/theme'; +import { AuthService } from '../../services/auth.service'; +import { ChannelRequestService } from '../../services/channel-request.service'; + +@Component({ + selector: 'app-landing-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterLink, + NbCardModule, + NbButtonModule, + NbInputModule, + NbFormFieldModule, + NbIconModule, + NbAlertModule, + NbSpinnerModule, + NbLayoutModule, + ], + styleUrl: './landing-page.component.scss', + template: ` + + + + @if (!isChecking) { + + + + +
+
+

פתחו ערוץ חדשות משלכם

+

הגישו בקשה בחינם ונפתח לכם ערוץ פרטי תוך 24 שעות

+ + בקש ערוץ עכשיו + +
+
+ + +
+
+

איך זה עובד?

+
+
+
+
1
+ +
מלא טופס
+
+
+
+
+
2
+ +
קבל אישור
+
+
+
+
+
3
+ +
התחל לשדר
+
+
+
+
+
+ + +
+
+

מה מקבלים?

+
+
+
+ +
+
עיצוב מותאם אישית
+

התאימו את הערוץ שלכם לסגנון שלכם

+
+
+
+
+
+ +
+
ניהול קל ופשוט
+

פאנל ניהול נוח ופשוט לשימוש

+
+
+
+
+
+ +
+
אחסון מדיה
+

העלו תמונות וקבצים ישירות לערוץ

+
+
+
+
+
+ +
+
ממשק בעברית
+

מבנה RTL מלא, תמיכה מושלמת בעברית

+
+
+
+
+
+
+ + +
+
+
+
+ + +

בקש ערוץ

+
+ + @if (submitted) { + + הבקשה נשלחה! נחזור אליך בהקדם + + } @else { + @if (error) { + {{ error }} + } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ } +
+
+
+
+
+
+ + +
+ TheChannel © 2024 +
+ } +
+
+ `, +}) +export class LandingPageComponent implements OnInit { + isChecking = true; + reqName = ''; + reqEmail = ''; + reqSlug = ''; + reqDescription = ''; + submitting = false; + submitted = false; + error = ''; + + constructor( + private authService: AuthService, + private channelRequestService: ChannelRequestService, + private router: Router, + ) {} + + async ngOnInit() { + 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 — show the landing page + } + this.isChecking = false; + } + + scrollToForm(event: Event) { + event.preventDefault(); + const el = document.getElementById('request-form'); + if (el) { + el.scrollIntoView({ behavior: 'smooth' }); + } + } + + async submitRequest() { + this.error = ''; + this.submitting = true; + try { + await this.channelRequestService.submitRequest( + this.reqName, + this.reqEmail, + this.reqSlug, + this.reqDescription, + ); + this.submitted = true; + } catch (err: any) { + this.error = err?.error?.message || 'שגיאה בשליחת הבקשה. נסה שוב.'; + } finally { + this.submitting = false; + } + } +} diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts index 84d6550..c9a6ebe 100644 --- a/frontend/src/app/components/login/login.component.ts +++ b/frontend/src/app/components/login/login.component.ts @@ -6,9 +6,7 @@ import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-login', - imports: [ - FormsModule -], + imports: [FormsModule], templateUrl: './login.component.html', styleUrl: './login.component.scss' }) @@ -29,27 +27,47 @@ export class LoginComponent implements OnInit { 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._route.queryParams.subscribe(params => { + if (params['code'] && params['state'] === localStorage.getItem('google_oauth_state')) { + this.code = params['code']; + this.checkUserInfo = true; + this._authService.login(this.code).then(async () => { + await this._authService.loadUserInfo(); + this.redirectAfterLogin(); + }).catch(() => { + this.code = ''; + this.status = 'failed'; + this.checkUserInfo = false; + alert('התחברות נכשלה, נסה שוב'); + }); + } + }); + } + + private redirectAfterLogin() { + if (this._authService.userInfo?.globalRole === 'super_admin') { + this.router.navigate(['/super-admin']); + return; + } + + const returnUrl = localStorage.getItem('returnUrl'); + localStorage.removeItem('returnUrl'); + if (returnUrl && !returnUrl.startsWith('/login')) { + this.router.navigateByUrl(returnUrl); + return; + } + + this.router.navigate(['/channel']); } login() { diff --git a/frontend/src/app/components/super-admin/channel-requests/channel-requests.component.ts b/frontend/src/app/components/super-admin/channel-requests/channel-requests.component.ts new file mode 100644 index 0000000..7b4527f --- /dev/null +++ b/frontend/src/app/components/super-admin/channel-requests/channel-requests.component.ts @@ -0,0 +1,247 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbCardModule, NbButtonModule, NbInputModule, + NbBadgeModule, NbIconModule, NbToastrService, NbAlertModule, NbFormFieldModule +} from '@nebular/theme'; +import { SuperAdminService, ChannelRequest } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-channel-requests', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbBadgeModule, + NbIconModule, + NbAlertModule, + NbFormFieldModule, + ], + template: ` + + +
בקשות לפתיחת ערוץ
+
+ + @if (loading) { +

טוען...

+ } @else if (requests.length === 0) { +

אין בקשות

+ } @else { +
+ + + + + + + + + + + + + + @for (req of requests; track req.id) { + + + + + + + + + + + + @if (actionId === req.id && approveMode) { + + + + } + + + @if (actionId === req.id && rejectMode) { + + + + } + } + +
שםאימיילSlug מבוקשתיאורתאריךסטטוספעולות
{{ req.name }}{{ req.email }}{{ req.desiredSlug }} + + {{ req.description }} + + {{ req.createdAt | date:'dd/MM/yy HH:mm' }} + @if (req.status === 'pending') { + ממתין + } @else if (req.status === 'approved') { + מאושר + @if (req.approvedSlug) { +
{{ req.approvedSlug }} + } + } @else if (req.status === 'rejected') { + נדחה + } +
+ @if (req.status === 'pending') { +
+ + +
+ } @else { + + } +
+
+
אישור בקשה — {{ req.name }}
+ + @if (lastApproveResult && lastApproveResult.reqId === req.id) { + +

✓ הערוץ נוצר בהצלחה!

+

Slug: {{ lastApproveResult.channelSlug }}

+

אימייל הבעלים: {{ lastApproveResult.ownerEmail }}

+

שלח לו את הקישור: /channel/{{ lastApproveResult.channelSlug }}

+
+ } @else { +
+
+ + +
+
+ + +
+
+
+ + +
+ } +
+
+
+
דחיית בקשה — {{ req.name }}
+
+ + +
+
+ + +
+
+
+
+ } +
+
+ `, +}) +export class ChannelRequestsComponent implements OnInit { + requests: ChannelRequest[] = []; + loading = false; + actionId = ''; + approveSlug = ''; + approveNotes = ''; + rejectNotes = ''; + approveMode = false; + rejectMode = false; + lastApproveResult: { reqId: string; channelSlug: string; ownerEmail: string } | null = null; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.superAdminService.getChannelRequests() + .then(reqs => { this.requests = reqs; }) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת הבקשות')) + .finally(() => { this.loading = false; }); + } + + startApprove(req: ChannelRequest): void { + this.lastApproveResult = null; + this.approveMode = true; + this.rejectMode = false; + this.actionId = req.id; + this.approveSlug = req.desiredSlug.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + this.approveNotes = ''; + } + + startReject(req: ChannelRequest): void { + this.lastApproveResult = null; + this.rejectMode = true; + this.approveMode = false; + this.actionId = req.id; + this.rejectNotes = ''; + } + + confirmApprove(): void { + if (!this.approveSlug) return; + const id = this.actionId; + this.superAdminService.approveChannelRequest(id, this.approveSlug, this.approveNotes) + .then(result => { + this.lastApproveResult = { reqId: id, channelSlug: result.channelSlug, ownerEmail: result.ownerEmail }; + this.toastr.success('', 'הערוץ נוצר בהצלחה'); + this.load(); + }) + .catch((err) => this.toastr.danger(err?.error || '', 'שגיאה באישור הבקשה')); + } + + confirmReject(): void { + const id = this.actionId; + this.superAdminService.rejectChannelRequest(id, this.rejectNotes) + .then(() => { + this.toastr.success('', 'הבקשה נדחתה'); + this.cancel(); + this.load(); + }) + .catch(() => this.toastr.danger('', 'שגיאה בדחיית הבקשה')); + } + + cancel(): void { + this.actionId = ''; + this.approveMode = false; + this.rejectMode = false; + this.approveSlug = ''; + this.approveNotes = ''; + this.rejectNotes = ''; + } +} 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 0000000..477c2da --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-features.component.html @@ -0,0 +1,37 @@ + + + + תכונות ערוץ: {{ slug }} + + + +
+ @for (config of featureConfigs; track config.key) { +
+ @if (config.adminOnly) { + + {{ config.label }} + + מנהל-על + } @else { + + {{ config.label }} + + } +
+ } +
+
+ + + + +
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 0000000..89f3a39 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-features.component.ts @@ -0,0 +1,91 @@ +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'; + +interface FeatureConfig { + key: keyof ChannelFeatures; + label: string; + adminOnly?: boolean; +} + +@Component({ + selector: 'app-channel-features', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbToggleModule, + ], + templateUrl: './channel-features.component.html', +}) +export class ChannelFeaturesComponent implements OnInit { + @Input() slug!: string; + + features: ChannelFeatures = { + reactions: false, + fileUploads: false, + reports: false, + ads: false, + notifications: false, + requireAuth: false, + requireAuthFiles: false, + countViews: false, + scheduledMessages: false, + webhook: false, + magnetLockedByAdmin: false, + adsLockedByAdmin: false, + }; + + saving = false; + + featureConfigs: FeatureConfig[] = [ + { key: 'reactions', label: 'תגובות' }, + { key: 'fileUploads', label: 'העלאת קבצים' }, + { key: 'reports', label: 'דיווחים' }, + { key: 'ads', label: 'פרסומות iframe' }, + { key: 'notifications', label: 'התראות' }, + { key: 'requireAuth', label: 'דרוש כניסה לצפייה' }, + { key: 'requireAuthFiles', label: 'דרוש כניסה לקבצים' }, + { key: 'countViews', label: 'ספירת צפיות' }, + { key: 'scheduledMessages', label: 'הודעות מתוזמנות' }, + { key: 'webhook', label: 'Webhook' }, + { key: 'magnetLockedByAdmin', label: 'מגנט נעול ע"י מנהל-על', adminOnly: true }, + { key: 'adsLockedByAdmin', label: 'פרסומות iframe נעולות ע"י מנהל-על', adminOnly: true }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.loadFeatures(); + } + + loadFeatures() { + this.superAdminService.getChannel(this.slug) + .then(channel => { + this.features = { ...channel.features }; + }) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת תכונות הערוץ')); + } + + save() { + this.saving = true; + this.superAdminService.updateChannelFeatures(this.slug, this.features) + .then(() => this.toastr.success('', 'התכונות נשמרו בהצלחה')) + .catch(() => this.toastr.danger('', 'שגיאה בשמירת התכונות')) + .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 0000000..c60ca40 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.html @@ -0,0 +1,77 @@ + + +
+ + משתמשי ערוץ: {{ slug }} +
+ @if (!addingUser) { + + } +
+ + + @if (addingUser) { + + הוספת משתמש חדש + +
+
+ + +
+
+ + + @for (opt of roleOptions; track opt.value) { + {{ opt.label }} + } + +
+
+ + +
+
+
+
+ } + + @if (users.length === 0) { +

אין משתמשים בערוץ זה

+ } + + @for (user of users; track $index; let i = $index) { + + +
+ {{ user.email }} + + @for (opt of roleOptions; track opt.value) { + {{ opt.label }} + } + + +
+
+
+ } +
+ + + + +
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 0000000..cdbb806 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.ts @@ -0,0 +1,95 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbSelectModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, ChannelUser } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-channel-users', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbSelectModule, + ], + templateUrl: './channel-users.component.html', +}) +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[] = []; + saving = false; + addingUser = false; + newEmail = ''; + newRole: 'owner' | 'moderator' | 'writer' | '' = 'moderator'; + + roleOptions: { value: string; label: string }[] = [ + { value: 'owner', label: 'בעלים' }, + { value: 'moderator', label: 'מנהל' }, + { value: 'writer', label: 'כותב' }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.loadUsers(); + } + + loadUsers() { + this.superAdminService.getChannelUsers(this.slug) + .then(users => { + this.users = [...users]; + this.removedUsers = []; + }) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת משתמשי הערוץ')); + } + + addUser() { + if (!this.newEmail) { + this.toastr.warning('', 'יש להזין כתובת אימייל'); + return; + } + this.users.push({ email: this.newEmail, role: this.newRole as any }); + this.newEmail = ''; + this.newRole = 'moderator'; + this.addingUser = false; + } + + removeUser(index: number) { + const [removed] = this.users.splice(index, 1); + if (removed?.email) this.removedUsers.push({ email: removed.email, role: '' }); + } + + save() { + this.saving = true; + this.superAdminService.setChannelUsers(this.slug, [...this.users, ...this.removedUsers]) + .then(() => { + this.removedUsers = []; + this.toastr.success('', 'המשתמשים נשמרו בהצלחה'); + }) + .catch(() => 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 0000000..16ee3c7 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.html @@ -0,0 +1,96 @@ + + + ניהול ערוצים + @if (!showCreateForm) { + + } + + + + @if (showCreateForm) { + + יצירת ערוץ חדש + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ } + + @if (channels.length === 0) { +

אין ערוצים במערכת

+ } + +
+ + + + + + + + + + + + @for (channel of channels; track channel.slug) { + + + + + + + + } + +
Slugשםבעליםנוצר בתאריךפעולות
{{ channel.slug }}{{ channel.name }}{{ channel.ownerEmail }}{{ channel.createdAt | date:'dd/MM/yyyy' }} +
+ + + + +
+
+
+
+
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 0000000..5765349 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.ts @@ -0,0 +1,88 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, ChannelData } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-channels-list', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + ], + templateUrl: './channels-list.component.html', +}) +export class ChannelsListComponent implements OnInit { + @Output() editFeatures = new EventEmitter(); + @Output() manageUsers = new EventEmitter(); + @Output() manageStorage = new EventEmitter(); + + channels: ChannelData[] = []; + showCreateForm = false; + newSlug = ''; + newName = ''; + newOwnerEmail = ''; + creating = false; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.loadChannels(); + } + + loadChannels() { + this.superAdminService.getChannels() + .then(channels => this.channels = channels) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת ערוצים')); + } + + createChannel() { + if (!this.newSlug || !this.newName || !this.newOwnerEmail) { + this.toastr.warning('', 'יש למלא את כל השדות'); + return; + } + this.creating = true; + this.superAdminService.createChannel(this.newSlug, this.newName, this.newOwnerEmail) + .then(channel => { + this.channels.push(channel); + this.showCreateForm = false; + this.newSlug = ''; + this.newName = ''; + this.newOwnerEmail = ''; + this.toastr.success('', 'הערוץ נוצר בהצלחה'); + }) + .catch((err) => this.toastr.danger(err?.error || '', 'שגיאה ביצירת הערוץ')) + .finally(() => this.creating = false); + } + + deleteChannel(slug: string) { + if (!confirm(`האם אתה בטוח שברצונך למחוק את הערוץ "${slug}"?`)) return; + this.superAdminService.deleteChannel(slug) + .then(() => { + this.channels = this.channels.filter(c => c.slug !== slug); + this.toastr.success('', 'הערוץ נמחק בהצלחה'); + }) + .catch(() => this.toastr.danger('', 'שגיאה במחיקת הערוץ')); + } + + 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 0000000..f06fd57 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-ads/global-ads.component.html @@ -0,0 +1,55 @@ + + + + הגדרות פרסומות iframe גלובליות + + + +
+
+ + +
+ +
+ + +
+ +
+ + נעל פרסומות על כל הערוצים + + כאשר מופעל, הגדרות מנהלי הערוצים מתעלמות +
+ +
+ +
+ + +
+
+ @for (slug of config.lockedChannels; track slug) { + + {{ slug }} + + + } +
+
+
+
+ + + + +
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 0000000..51c1e7f --- /dev/null +++ b/frontend/src/app/components/super-admin/global-ads/global-ads.component.ts @@ -0,0 +1,70 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbToggleModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, GlobalAdsConfig } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-global-ads', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbToggleModule, + ], + templateUrl: './global-ads.component.html', +}) +export class GlobalAdsComponent implements OnInit { + config: GlobalAdsConfig = { + src: '', + width: 300, + lockAll: false, + lockedChannels: [], + }; + + saving = false; + newLockedChannel = ''; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.superAdminService.getAdsConfig() + .then(cfg => this.config = { ...cfg, lockedChannels: [...(cfg.lockedChannels || [])] }) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת הגדרות פרסומות')); + } + + addLockedChannel() { + const slug = this.newLockedChannel.trim(); + if (!slug) return; + if (!this.config.lockedChannels.includes(slug)) { + this.config.lockedChannels.push(slug); + } + this.newLockedChannel = ''; + } + + removeLockedChannel(slug: string) { + this.config.lockedChannels = this.config.lockedChannels.filter(s => s !== slug); + } + + save() { + this.saving = true; + this.superAdminService.setAdsConfig(this.config) + .then(() => this.toastr.success('', 'הגדרות הפרסומות נשמרו בהצלחה')) + .catch(() => this.toastr.danger('', 'שגיאה בשמירת הגדרות הפרסומות')) + .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 0000000..6425c17 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.html @@ -0,0 +1,90 @@ + + + + הגדרות פרסומות מגנט גלובליות + + + +
+
+ + הפעל מגנט + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @for (opt of modeOptions; track opt.value) { + {{ opt.label }} + } + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + נעל מגנט על כל הערוצים + + כאשר מופעל, הגדרות מנהלי הערוצים מתעלמות +
+ +
+ +
+ + +
+
+ @for (slug of config.lockedChannels; track slug) { + + {{ slug }} + + + } +
+
+
+
+ + + + +
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 0000000..fd37e63 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.ts @@ -0,0 +1,95 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbSelectModule, + NbToggleModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, GlobalMagnetConfig } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-global-magnet', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + NbSelectModule, + NbToggleModule, + ], + templateUrl: './global-magnet.component.html', +}) +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: [], + }; + + saving = false; + newLockedChannel = ''; + + // 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: 'לפי מספר הודעות' }, + { value: 'by_time', label: 'לפי זמן (שניות)' }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.superAdminService.getMagnetConfig() + .then(cfg => this.config = { + ...cfg, + mode: this.normalizeMode(cfg.mode), + lockedChannels: [...(cfg.lockedChannels || [])], + }) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת הגדרות מגנט')); + } + + // 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'; + } + + addLockedChannel() { + const slug = this.newLockedChannel.trim(); + if (!slug) return; + if (!this.config.lockedChannels.includes(slug)) { + this.config.lockedChannels.push(slug); + } + this.newLockedChannel = ''; + } + + removeLockedChannel(slug: string) { + this.config.lockedChannels = this.config.lockedChannels.filter(s => s !== slug); + } + + save() { + this.saving = true; + this.superAdminService.setMagnetConfig(this.config) + .then(() => this.toastr.success('', 'הגדרות מגנט נשמרו בהצלחה')) + .catch(() => this.toastr.danger('', 'שגיאה בשמירת הגדרות מגנט')) + .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 0000000..2ef5b2f --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.html @@ -0,0 +1,176 @@ + + +
+ + הגדרות גלובליות +
+
+ + + @if (loading) { +

טוען...

+ } @else { + + + + + + + הגדרות כלליות + + +
+
+ +
כותרת שתשמש לקידום האתר בתוצאות חיפוש. משותפת לכל הערוצים.
+ +
+
+ +
קוד HTML/JS מ-Google Analytics / GTM / Meta Pixel וכדומה. מוזרק לכל עמוד באתר.
+ +
+
+
+
+
+ + + + + + + התראות דחיפה (Push) — Firebase FCM + + +

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

+ +
+ + הפעלת תשתית התראות דחיפה + +
יש להפעיל ולמלא את כל פרטי Firebase שבהמשך.
+
+ + @if (values['on_notification']) { +
+
+ + +
+
+ + +
+
+ +
Firebase SDK Config
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
Service Account (FCM JSON)
+

+ ניתן להדביק את כל קובץ ה-JSON מ-serviceaccounts → Generate new private key לשם מילוי אוטומטי. +

+
+ + @if (fcmJsonError) { +
{{ fcmJsonError }}
+ } + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ } +
+
+
+ } +
+ + + + +
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 0000000..11e190e --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.ts @@ -0,0 +1,130 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, NbCardModule, NbIconModule, NbInputModule, + NbToastrService, NbToggleModule, NbAccordionModule, +} from '@nebular/theme'; +import { SuperAdminService, Setting } from '../../../services/super-admin.service'; + +// FCM JSON key map for autofill +const FCM_JSON_KEY_MAP: Record = { + 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', +}; + +@Component({ + selector: 'app-global-settings', + standalone: true, + imports: [CommonModule, FormsModule, NbCardModule, NbButtonModule, NbInputModule, NbIconModule, NbToggleModule, NbAccordionModule], + templateUrl: './global-settings.component.html', +}) +export class GlobalSettingsComponent implements OnInit { + values: Record = {}; + saving = false; + loading = true; + fcmJsonPaste = ''; + fcmJsonError = ''; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.superAdminService.getGlobalSettings() + .then(settings => this.loadFromSettings(settings || [])) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת הגדרות גלובליות')) + .finally(() => this.loading = false); + } + + private loadFromSettings(settings: Setting[]): void { + // Initialize defaults + this.values = { + custom_title: '', + analytics_head: '', + 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] = this.toBool(s.value); + } else if (s.key in this.values) { + this.values[s.key] = s.value ?? ''; + } + } + } + + private toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; + } + + 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', + '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 = 'לא נמצאו שדות מוכרים ב-JSON'; return; } + this.fcmJsonPaste = ''; + this.toastr.success('', `${count} שדות מולאו אוטומטית`); + } + + save(): void { + this.saving = true; + this.superAdminService.setGlobalSettings(this.buildSettingsArray()) + .then(() => this.toastr.success('', 'ההגדרות נשמרו בהצלחה')) + .catch(() => this.toastr.danger('', 'שגיאה בשמירת ההגדרות')) + .finally(() => this.saving = false); + } +} 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 0000000..20fe3cb --- /dev/null +++ b/frontend/src/app/components/super-admin/global-storage/global-storage.component.ts @@ -0,0 +1,70 @@ +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 + ], + template: ` + + הגדרות אחסון גלובליות + +

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

+
+ + + + + GB לכל ערוץ + +
+
+
+ ` +}) +export class GlobalStorageComponent implements OnInit { + defaultQuotaGb = 5; + saving = false; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService + ) {} + + async ngOnInit() { + try { + const cfg = await this.superAdminService.getGlobalStorageConfig(); + this.defaultQuotaGb = cfg.defaultQuotaGb; + } catch { + this.toastr.danger('שגיאה בטעינת הגדרות אחסון', 'שגיאה'); + } + } + + async save() { + this.saving = true; + try { + await this.superAdminService.setGlobalStorageConfig(this.defaultQuotaGb); + this.toastr.success('הגדרות אחסון נשמרו', 'אחסון'); + } catch { + this.toastr.danger('שגיאה בשמירה', 'שגיאה'); + } 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 0000000..bee1645 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-users/global-users.component.html @@ -0,0 +1,55 @@ + + + + משתמשים גלובליים + + + + @if (loading) { +

טוען...

+ } @else if (users.length === 0) { +

אין משתמשים רשומים

+ } @else { +
+ + + + + + + + + + + + @for (user of users; track user.id) { + + + + + + + + } + +
אימיילשם משתמששם מוצגתפקיד גלובליהרשאות ערוצים
{{ user.email }}{{ user.username }}{{ user.publicName }} + + {{ getRoleLabel(user.globalRole) }} + + +
+ @for (entry of getChannelRolesDisplay(user.channelRoles); track entry) { + {{ entry }} + } + @if (!user.channelRoles || getChannelRolesDisplay(user.channelRoles).length === 0) { + אין + } +
+
+
+ } +
+
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 0000000..ce85b23 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-users/global-users.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, SuperAdminUser } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-global-users', + standalone: true, + imports: [ + CommonModule, + NbCardModule, + NbButtonModule, + NbIconModule, + ], + templateUrl: './global-users.component.html', +}) +export class GlobalUsersComponent implements OnInit { + users: SuperAdminUser[] = []; + loading = true; + + globalRoleLabels: Record = { + super_admin: 'מנהל-על', + admin: 'מנהל', + user: 'משתמש', + '': 'רגיל', + }; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.superAdminService.getGlobalUsers() + .then(users => this.users = users) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת רשימת משתמשים')) + .finally(() => this.loading = false); + } + + getChannelRolesDisplay(channelRoles: Record): string[] { + return Object.entries(channelRoles || {}).map(([slug, role]) => `${slug}: ${role}`); + } + + getRoleLabel(role: string): string { + return this.globalRoleLabels[role] || role; + } +} 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 0000000..e0af10e --- /dev/null +++ b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.html @@ -0,0 +1,56 @@ +
+ + + + סטטיסטיקות מערכת + + + +

+ פעולה זו תאפס את שיא מספר החיבורים הו-זמניים שנרשם בשרת +

+
+
+ + + +
+ + סטטיסטיקות מגנט +
+ +
+ + @if (loadingStats) { +

טוען...

+ } @else if (!magnetStats) { +

אין נתונים זמינים

+ } @else { +
+ + + + + + + + + @for (entry of getStatEntries(); track entry.key) { + + + + + } + +
מפתחערך
{{ entry.key }}{{ entry.value }}
+
+ } +
+
+
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 0000000..b0a68ed --- /dev/null +++ b/frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-super-admin-statistics', + standalone: true, + imports: [ + CommonModule, + NbCardModule, + NbButtonModule, + NbIconModule, + ], + templateUrl: './super-admin-statistics.component.html', +}) +export class SuperAdminStatisticsComponent implements OnInit { + magnetStats: any = null; + loadingStats = true; + resetting = false; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.loadMagnetStats(); + } + + loadMagnetStats() { + this.loadingStats = true; + this.superAdminService.getMagnetStats() + .then(stats => this.magnetStats = stats) + .catch(() => this.toastr.warning('', 'לא ניתן לטעון סטטיסטיקות מגנט')) + .finally(() => this.loadingStats = false); + } + + resetStatistics() { + if (!confirm('האם אתה בטוח שברצונך לאפס את שיא החיבורים?')) return; + this.resetting = true; + this.superAdminService.resetStatistics() + .then(() => this.toastr.success('', 'שיא החיבורים אופס בהצלחה')) + .catch(() => this.toastr.danger('', 'שגיאה באיפוס הסטטיסטיקות')) + .finally(() => this.resetting = false); + } + + getStatEntries(): { key: string; value: any }[] { + if (!this.magnetStats || typeof this.magnetStats !== 'object') return []; + return Object.entries(this.magnetStats).map(([key, value]) => ({ key, value })); + } +} 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 0000000..3a185a0 --- /dev/null +++ b/frontend/src/app/components/super-admin/storage/super-admin-storage.component.ts @@ -0,0 +1,115 @@ +import { Component, Input, OnInit, OnChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbCardModule, NbButtonModule, NbInputModule, + NbFormFieldModule, NbProgressBarModule, NbToastrService, NbAlertModule, 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, NbAlertModule, NbIconModule + ], + template: ` + + אחסון — {{ slug }} + + @if (config) { + +
+
+ {{ formatBytes(config.storageInfo.usedBytes) }} בשימוש + מתוך {{ formatBytes(config.storageInfo.quotaBytes) }} +
+ + + @if (config.storageInfo.autoCleanup) { + ניקוי אוטומטי פעיל + } +
+ + +
+ + + + + GB (0 = ברירת מחדל גלובלית) + +
+ } @else { +

טוען...

+ } +
+
+ ` +}) +export class SuperAdminStorageComponent implements OnInit, OnChanges { + @Input() slug!: string; + + config?: ChannelStorageConfig; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService + ) {} + + ngOnInit() { this.load(); } + ngOnChanges() { this.load(); } + + async load() { + if (!this.slug) return; + try { + this.config = await this.superAdminService.getChannelStorage(this.slug); + } catch { + this.toastr.danger('שגיאה בטעינת מידע אחסון', 'שגיאה'); + } + } + + async save() { + if (!this.config) return; + try { + await this.superAdminService.setChannelStorage(this.slug, this.config.quotaGb); + this.toastr.success('קוטה עודכנה', 'אחסון'); + this.load(); + } catch { + this.toastr.danger('שגיאה בשמירה', 'שגיאה'); + } + } + + progressStatus(level: string): string { + if (level === 'critical') return 'danger'; + if (level === 'warning') return 'warning'; + return 'success'; + } + + 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'; + } +} 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 0000000..ad62d49 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.html @@ -0,0 +1,63 @@ + + +
+ + ניהול מערכת +
+
+ + + + + + + @if (selectedView === VIEW_CHANNEL_FEATURES || selectedView === VIEW_CHANNEL_USERS || selectedView === VIEW_CHANNEL_STORAGE) { +
+ +
+ } + + @switch (selectedView) { + @case (VIEW_REQUESTS) { + + } + @case (VIEW_CHANNELS) { + + + } + @case (VIEW_CHANNEL_FEATURES) { + + } + @case (VIEW_CHANNEL_USERS) { + + } + @case (VIEW_CHANNEL_STORAGE) { + + } + @case (VIEW_GLOBAL_STORAGE) { + + } + @case (VIEW_ADS) { + + } + @case (VIEW_MAGNET) { + + } + @case (VIEW_USERS) { + + } + @case (VIEW_SETTINGS) { + + } + @case (VIEW_STATISTICS) { + + } + } +
+
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 0000000..d4b2347 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.scss @@ -0,0 +1,5 @@ +:host { + display: block; + width: 100%; + height: 100%; +} 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 0000000..46dc243 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.ts @@ -0,0 +1,162 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { filter, Subscription } from 'rxjs'; +import { + NbButtonModule, + NbIconModule, + NbLayoutModule, + NbMenuItem, + NbMenuModule, + NbMenuService, + NbSidebarModule, + NbToastrService, +} from '@nebular/theme'; +import { Router } from '@angular/router'; +import { AuthService } from '../../services/auth.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 { ChannelRequestsComponent } from './channel-requests/channel-requests.component'; + +type ViewName = 'channels' | 'channel-features' | 'channel-users' | 'channel-storage' | 'ads' | 'magnet' | 'users' | 'settings' | 'statistics' | 'global-storage' | 'requests'; + +@Component({ + selector: 'app-super-admin-panel', + standalone: true, + imports: [ + CommonModule, + NbLayoutModule, + NbSidebarModule, + NbMenuModule, + NbButtonModule, + NbIconModule, + ChannelsListComponent, + ChannelFeaturesComponent, + ChannelUsersComponent, + GlobalAdsComponent, + GlobalMagnetComponent, + GlobalUsersComponent, + GlobalSettingsComponent, + SuperAdminStatisticsComponent, + SuperAdminStorageComponent, + GlobalStorageComponent, + ChannelRequestsComponent, + ], + templateUrl: './super-admin-panel.component.html', + styleUrl: './super-admin-panel.component.scss', +}) +export class SuperAdminPanelComponent implements OnInit, OnDestroy { + selectedView: ViewName = 'channels'; + selectedChannelSlug = ''; + private menuSub?: Subscription; + + readonly MENU_TAG = 'super-admin-menu'; + + 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_REQUESTS: ViewName = 'requests'; + + navigationMenu: NbMenuItem[] = [ + { title: 'בקשות לערוצים', icon: 'inbox-outline', selected: false }, + { title: 'ערוצים', icon: 'list-outline', selected: true }, + { title: 'פרסומות iframe', icon: 'film-outline' }, + { title: 'פרסומות מגנט', icon: 'pricetags-outline' }, + { title: 'אחסון גלובלי', icon: 'hard-drive-outline' }, + { title: 'משתמשים', icon: 'people-outline' }, + { title: 'הגדרות גלובליות', icon: 'settings-2-outline' }, + { title: 'סטטיסטיקות', icon: 'bar-chart-outline' }, + ]; + + constructor( + private menuService: NbMenuService, + private authService: AuthService, + private router: Router, + private toastr: NbToastrService, + ) {} + + async logout() { + if (await this.authService.logout()) { + this.router.navigate(['/login']); + } else { + this.toastr.danger('', 'שגיאה בהתנתקות'); + } + } + + ngOnDestroy(): void { + this.menuSub?.unsubscribe(); + } + + ngOnInit(): void { + this.menuSub = this.menuService.onItemClick() + .pipe(filter(({ tag }) => tag === this.MENU_TAG)) + .subscribe(event => { + this.navigationMenu.forEach(item => (item.selected = false)); + event.item.selected = true; + + switch (event.item.icon) { + case 'inbox-outline': + this.selectedView = this.VIEW_REQUESTS; + this.selectedChannelSlug = ''; + break; + case 'list-outline': + this.selectedView = this.VIEW_CHANNELS; + this.selectedChannelSlug = ''; + break; + case 'film-outline': + this.selectedView = this.VIEW_ADS; + break; + case 'pricetags-outline': + this.selectedView = this.VIEW_MAGNET; + break; + case 'people-outline': + this.selectedView = this.VIEW_USERS; + break; + case 'settings-2-outline': + this.selectedView = this.VIEW_SETTINGS; + break; + case 'hard-drive-outline': + this.selectedView = this.VIEW_GLOBAL_STORAGE; + this.selectedChannelSlug = ''; + break; + case 'bar-chart-outline': + this.selectedView = this.VIEW_STATISTICS; + break; + } + }); + } + + onEditFeatures(slug: string) { + this.selectedChannelSlug = slug; + this.selectedView = this.VIEW_CHANNEL_FEATURES; + } + + onManageUsers(slug: string) { + this.selectedChannelSlug = slug; + this.selectedView = this.VIEW_CHANNEL_USERS; + } + + onManageStorage(slug: string) { + this.selectedChannelSlug = slug; + this.selectedView = this.VIEW_CHANNEL_STORAGE; + } + + backToChannels() { + this.selectedView = this.VIEW_CHANNELS; + this.selectedChannelSlug = ''; + } +} 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 0000000..d351bc0 --- /dev/null +++ b/frontend/src/app/guards/super-admin.guard.ts @@ -0,0 +1,20 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +export const SuperAdminGuard: CanActivateFn = async (route, state) => { + const router = inject(Router); + const authService = inject(AuthService); + + try { + const userInfo = await authService.loadUserInfo(); + if (userInfo?.globalRole === 'super_admin') { + return true; + } + router.navigate(['/']); + return false; + } catch { + router.navigate(['/']); + return false; + } +}; diff --git a/frontend/src/app/models/channel.model.ts b/frontend/src/app/models/channel.model.ts index 8efc8e3..88a0a62 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; + slug?: string; name?: string; description?: string; created_at?: string; @@ -7,4 +18,5 @@ export interface Channel { views?: number; require_auth_for_view_files?: boolean; contact_us?: string; -} \ No newline at end of file + features?: ChannelFeatureFlags; +} diff --git a/frontend/src/app/models/user.model.ts b/frontend/src/app/models/user.model.ts index 9375d19..39a5487 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; - email: string; -} \ No newline at end of file + id: string; + username: string; + email: string; + picture: string; + publicName: string; + globalRole: string; + channelRoles: Record; + // Legacy field for backwards compatibility + privileges?: Record; +} diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts index 34aa77d..90aa87c 100644 --- a/frontend/src/app/services/admin.service.ts +++ b/frontend/src/app/services/admin.service.ts @@ -6,13 +6,11 @@ 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'; -export interface PrivilegeUser { - id?: string; - username: string; +export interface ChannelUser { email: string; - publicName: string; - privileges: Record; + role: 'owner' | 'moderator' | 'writer' | ''; } export type EditMsg = { @@ -35,8 +33,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 +55,52 @@ export class AdminService { } getStatistics(): Promise { - return firstValueFrom(this.http.get('/api/admin/statistics')); - } - - resetPeakStatistics(): Promise { - return firstValueFrom(this.http.post('/api/admin/statistics/reset', {})); + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/statistics`)); } addMessage(message: ChatMessage): Observable { - return this.http.post('/api/admin/new', message); + return this.http.post(`/api/channel/${this.slug}/admin/new`, message); } - editMessage(message: ChatMessage): Observable { - return this.http.post(`/api/admin/edit-message`, message); + // Both endpoints answer with the {success} envelope, not the message itself. + editMessage(message: ChatMessage): Observable { + return this.http.post(`/api/channel/${this.slug}/admin/edit-message`, message); } - deleteMessage(id: number | undefined): Observable { - return this.http.get(`/api/admin/delete-message/${id}`); + deleteMessage(id: number | undefined): Observable { + return this.http.get(`/api/channel/${this.slug}/admin/delete-message/${id}`); } uploadFile(formData: FormData) { - return this.http.post('/api/admin/upload', formData, { + return this.http.post(`/api/channel/${this.slug}/admin/upload`, formData, { reportProgress: true, observe: 'events', responseType: 'json' }); } - getPrivilegeUsersList(): Promise { - return firstValueFrom(this.http.get('/api/admin/privilegs-users/get-list')); + getChannelUsers(): Promise { + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/users/get`)); } - setPrivilegeUsers(privilegeUsers: PrivilegeUser[]): Promise { - return firstValueFrom(this.http.post('/api/admin/privilegs-users/set', { list: privilegeUsers })); + setChannelUsers(users: ChannelUser[]): Promise { + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/users/set`, { users })); } setEmojis(emojis: string[] | undefined) { - return firstValueFrom(this.http.post('/api/admin/set-emojis', { emojis })); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/set-emojis`, { emojis })); } getSettings(): Promise { - return firstValueFrom(this.http.get('/api/admin/settings/get')); + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/settings/get`)); } setSettings(settings: Setting[]): Promise { - return firstValueFrom(this.http.post('/api/admin/settings/set', settings)); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/settings/set`, settings)); } getReports(status: string): Promise { - return firstValueFrom(this.http.get('/api/admin/reports/get', { + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/reports/get`, { params: { status: status } @@ -106,7 +108,11 @@ export class AdminService { } setReports(report: Report): Promise { - return firstValueFrom(this.http.post('/api/admin/reports/set', report)); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/reports/set`, report)); + } + + private fetchScheduledMessages(): Promise { + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/scheduled-messages/get`)); } async getScheduledMessages(reload?: boolean): Promise { @@ -115,15 +121,20 @@ export class AdminService { } try { - this.schedulingMessages = await firstValueFrom(this.http.get('/api/admin/scheduled-messages/get')); + this.schedulingMessages = await this.fetchScheduledMessages(); return this.schedulingMessages; } catch { return this.schedulingMessages || []; } } - setScheduledMessage(message: ChatMessage): Promise { - this.schedulingMessages?.unshift(message); + async setScheduledMessage(message: ChatMessage): Promise { + // The update endpoint replaces the whole list, so the cache must be loaded + // first — otherwise an empty/null cache posts an empty body (400) or wipes + // the messages already scheduled. A failed load rejects instead of saving. + const list = this.schedulingMessages ?? await this.fetchScheduledMessages(); + this.schedulingMessages = list; + list.unshift(message); return this.updateSchedulingMessages(); } @@ -140,6 +151,6 @@ export class AdminService { } private updateSchedulingMessages(): Promise { - return firstValueFrom(this.http.post('/api/admin/scheduled-messages/update', this.schedulingMessages)); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/scheduled-messages/update`, this.schedulingMessages ?? [])); } } diff --git a/frontend/src/app/services/ads.service.ts b/frontend/src/app/services/ads.service.ts index eb465ad..874b653 100644 --- a/frontend/src/app/services/ads.service.ts +++ b/frontend/src/app/services/ads.service.ts @@ -1,6 +1,7 @@ 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; @@ -13,10 +14,11 @@ export interface Ad { export class AdsService { constructor( - private http: HttpClient + private http: HttpClient, + private slugService: SlugService, ) { } async getAds(): Promise { - return firstValueFrom(this.http.get('/api/ads/settings')) + return firstValueFrom(this.http.get(`/api/channel/${this.slugService.slug}/ads/settings`)); } } diff --git a/frontend/src/app/services/channel-request.service.ts b/frontend/src/app/services/channel-request.service.ts new file mode 100644 index 0000000..167317d --- /dev/null +++ b/frontend/src/app/services/channel-request.service.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ChannelRequestService { + + constructor(private http: HttpClient) {} + + submitRequest(name: string, email: string, desiredSlug: string, description: string): Promise<{ status: string; id: string }> { + return firstValueFrom( + this.http.post<{ status: string; id: string }>('/api/channel-request', { name, email, desiredSlug, description }) + ); + } + + getChannelRequests(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/channel-requests')); + } + + approveChannelRequest(id: string, slug: string, notes: string): Promise<{ channelSlug: string; ownerEmail: string }> { + return firstValueFrom( + this.http.post<{ channelSlug: string; ownerEmail: string }>( + `/api/super-admin/channel-requests/${id}/approve`, + { slug, notes } + ) + ); + } + + rejectChannelRequest(id: string, notes: string): Promise { + return firstValueFrom( + this.http.post(`/api/super-admin/channel-requests/${id}/reject`, { notes }) + ); + } +} diff --git a/frontend/src/app/services/chat-guard.guard.ts b/frontend/src/app/services/chat-guard.guard.ts index 8ebe0b6..3baf220 100644 --- a/frontend/src/app/services/chat-guard.guard.ts +++ b/frontend/src/app/services/chat-guard.guard.ts @@ -1,16 +1,41 @@ import { inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { CanActivateFn, Router } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; import { AuthService } from './auth.service'; +/** + * 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 (http: HttpClient, slug: string): Promise => { + try { + await firstValueFrom(http.get(`/api/channel/${slug}/info`)); + 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 http = inject(HttpClient); try { let userInfo = await authService.loadUserInfo(); if (userInfo) return true; } catch (err: any) { if (err.status === 401) { + // Anonymous visitor: only send them to the login page when the channel + // actually requires auth. Public channels stay open. + const slug = route.paramMap.get('slug'); + if (slug && !(await channelRequiresAuth(http, slug))) return true; + + localStorage.setItem('returnUrl', state.url); router.navigate(['/login']); return false; } diff --git a/frontend/src/app/services/chat.service.ts b/frontend/src/app/services/chat.service.ts index 74324e0..a26d34d 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 } 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 } @@ -44,20 +45,45 @@ export class ChatService { private eventSource!: EventSource; private emojis: string[] = []; public channelInfo?: Channel; + private channelInfoRequest?: Promise; - constructor(private http: HttpClient) { } + constructor(private http: HttpClient, private slugService: SlugService) { } - async updateChannelInfo() { - this.channelInfo = await firstValueFrom(this.http.get('/api/channel/info')); - return; + private get slug() { return this.slugService.slug; } + + updateChannelInfo(): Promise { + // Deduplicated: several components need the channel info (and the feature + // flags it carries) on the same first paint. + this.channelInfoRequest ??= firstValueFrom(this.http.get(`/api/channel/${this.slug}/info`)) + .then(info => { this.channelInfo = info; }) + .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 { + 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; } + 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'); } + editChannelInfo(name: string, description: string, logoUrl: string): Observable { - return this.http.post('/api/admin/edit-channel-info', { name, description, logoUrl }); + return this.http.post(`/api/channel/${this.slug}/admin/edit-channel-info`, { name, description, logoUrl }); } getMessages(offset: number, limit: number, direction: string): Observable { - return this.http.get('/api/messages', { + return this.http.get(`/api/channel/${this.slug}/messages`, { params: { offset: offset.toString(), limit: limit.toString(), @@ -67,17 +93,23 @@ export class ChatService { } setReact(messageId: number, react: string) { - return firstValueFrom(this.http.post('/api/reactions/set-reactions', { messageId, emoji: react })); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/reactions/set-reactions`, { messageId, emoji: react })); + } + + clearCache() { + this.emojis = []; + this.channelInfo = undefined; + this.channelInfoRequest = undefined; } async getEmojisList(reload: boolean = false): Promise { - if (this.emojis && !reload) return Promise.resolve(this.emojis); // הסרתי את בדיקת האורך, משום שזה יוצר קריאות מיותרות לשרת כאשר לא מוגדר אימוגים - this.emojis = await firstValueFrom(this.http.get('/api/emojis/list')); + if (this.emojis.length && !reload) return this.emojis; + this.emojis = await firstValueFrom(this.http.get(`/api/channel/${this.slug}/emojis/list`)); return this.emojis; } reportMessage(messageId: number, reason: string): Promise { - return firstValueFrom(this.http.post('/api/messages/report', { messageId, reason })); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/messages/report`, { messageId, reason })); } sseListener(): EventSource { @@ -85,7 +117,7 @@ export class ChatService { this.eventSource.close(); } - this.eventSource = new EventSource('/api/events'); + this.eventSource = new EventSource(`/api/channel/${this.slug}/events`); this.eventSource.onopen = () => { console.log('Connection opened'); 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 0000000..13de918 --- /dev/null +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -0,0 +1,130 @@ +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; +} + +@Injectable({ providedIn: 'root' }) +export class MagnetAdsService { + private settings: MagnetSettings | null = null; + private settingsPromise: Promise | null = null; + + constructor(private slugService: SlugService) {} + + clearCache() { + this.settings = null; + this.settingsPromise = null; + } + + loadSettings(force = false): Promise { + 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) => { + this.settings = data; + return data; + }) + .catch(() => null); + + return this.settingsPromise; + } + + getSettings(): MagnetSettings | null { + return this.settings; + } + + /** + * 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 + * 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 { + const result = new Set(); + if (!messages || messages.length === 0) return result; + + const s = this.settings; + if (!s || !s.enabled || !s.snippet?.trim()) return result; + + 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; + } + + private fillByMessages(out: Set, 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++; + if (countSinceLast < per) continue; + + const msgTime = this.toEpoch(m.timestamp); + const enoughTimePassed = !minTime || lastAdTime === null || + (msgTime !== null && (msgTime - lastAdTime) >= minTime * 1000); + + if (enoughTimePassed && m.id !== undefined && m.id !== null) { + out.add(m.id); + countSinceLast = 0; + if (msgTime !== null) lastAdTime = msgTime; + } + } + } + + private fillByTime(out: Set, 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; + + if (lastAdTime === null) { + lastAdTime = msgTime; + continue; + } + + const elapsed = (msgTime - lastAdTime) / 1000; + if (elapsed >= per && msgsSinceLast >= minMsgs && m.id !== undefined && m.id !== null) { + out.add(m.id); + lastAdTime = msgTime; + msgsSinceLast = 0; + } + } + } + + 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/notifications.service.ts b/frontend/src/app/services/notifications.service.ts index ab84817..d016128 100644 --- a/frontend/src/app/services/notifications.service.ts +++ b/frontend/src/app/services/notifications.service.ts @@ -4,6 +4,8 @@ import { NbToastrService } from '@nebular/theme'; import { firstValueFrom } from 'rxjs'; import { FirebaseApp, FirebaseOptions, initializeApp } from 'firebase/app'; import { getMessaging, onMessage, getToken } from 'firebase/messaging'; +import { ResponseResult } from '../models/response-result.model'; +import { SlugService } from './slug.service'; interface NotificationsConfig { @@ -24,12 +26,24 @@ export class NotificationsService { 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('/api/channel/notifications-config')) + await firstValueFrom(this.http.get(`/api/channel/${this.slugService.slug}/notifications-config`)) .then((config) => { this.config = config; }); @@ -91,7 +105,8 @@ export class NotificationsService { async subscribeNotifications(token: string): Promise { if (!token) return false; - return firstValueFrom(this.http.post('/api/channel/notifications-subscribe', { token })) + const res = await firstValueFrom(this.http.post(`/api/channel/${this.slugService.slug}/notifications-subscribe`, { token })); + return res.success; } } \ No newline at end of file diff --git a/frontend/src/app/services/slug.service.ts b/frontend/src/app/services/slug.service.ts new file mode 100644 index 0000000..485f675 --- /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 0000000..00f7734 --- /dev/null +++ b/frontend/src/app/services/super-admin.service.ts @@ -0,0 +1,191 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +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; +} + +export interface ChannelData { + slug: string; + name: string; + description: string; + logoUrl: string; + ownerEmail: string; + createdAt: string; + features: ChannelFeatures; + contactUs: string; +} + +export interface ChannelUser { + email: string; + role: 'owner' | 'moderator' | 'writer' | ''; +} + +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[]; +} + +export interface SuperAdminUser { + id: string; + username: string; + email: string; + publicName: string; + globalRole: string; + channelRoles: Record; +} + +export interface Setting { + key: string; + value: any; +} + +@Injectable({ + providedIn: 'root' +}) +export class SuperAdminService { + + constructor(private http: HttpClient) {} + + getChannels(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/channels')); + } + + createChannel(slug: string, name: string, ownerEmail: string): Promise { + return firstValueFrom(this.http.post('/api/super-admin/channels/create', { slug, name, ownerEmail })); + } + + getChannel(slug: string): Promise { + return firstValueFrom(this.http.get(`/api/super-admin/channels/${slug}`)); + } + + deleteChannel(slug: string): Promise { + return firstValueFrom(this.http.delete(`/api/super-admin/channels/${slug}`)); + } + + updateChannelFeatures(slug: string, features: ChannelFeatures): Promise { + return firstValueFrom(this.http.put(`/api/super-admin/channels/${slug}/features`, features)); + } + + getChannelUsers(slug: string): Promise { + return firstValueFrom(this.http.get(`/api/super-admin/channels/${slug}/users`)); + } + + setChannelUsers(slug: string, users: ChannelUser[]): Promise { + return firstValueFrom(this.http.post(`/api/super-admin/channels/${slug}/users`, { users })); + } + + getGlobalUsers(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/users/list')); + } + + setGlobalUsers(users: SuperAdminUser[]): Promise { + return firstValueFrom(this.http.post('/api/super-admin/users/set', { list: users })); + } + + getGlobalSettings(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/global-settings/get')); + } + + setGlobalSettings(settings: Setting[]): Promise { + return firstValueFrom(this.http.post('/api/super-admin/global-settings/set', settings)); + } + + getAdsConfig(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/ads/config')); + } + + setAdsConfig(cfg: GlobalAdsConfig): Promise { + return firstValueFrom(this.http.post('/api/super-admin/ads/config', cfg)); + } + + getMagnetConfig(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/magnet/config')); + } + + setMagnetConfig(cfg: GlobalMagnetConfig): Promise { + return firstValueFrom(this.http.post('/api/super-admin/magnet/config', cfg)); + } + + getMagnetStats(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/magnet/stats')); + } + + resetStatistics(): Promise { + return firstValueFrom(this.http.post('/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 { + return firstValueFrom(this.http.post('/api/super-admin/storage/config', { defaultQuotaGb })); + } + + getChannelStorage(slug: string): Promise { + return firstValueFrom(this.http.get(`/api/super-admin/channels/${slug}/storage`)); + } + + setChannelStorage(slug: string, quotaGb: number): Promise { + return firstValueFrom(this.http.put(`/api/super-admin/channels/${slug}/storage`, { quotaGb })); + } + + getChannelRequests(): Promise { + return firstValueFrom(this.http.get('/api/super-admin/channel-requests')); + } + + approveChannelRequest(id: string, slug: string, notes: string): Promise<{ channelSlug: string; ownerEmail: string }> { + return firstValueFrom( + this.http.post<{ channelSlug: string; ownerEmail: string }>( + `/api/super-admin/channel-requests/${id}/approve`, + { slug, notes } + ) + ); + } + + rejectChannelRequest(id: string, notes: string): Promise { + return firstValueFrom( + this.http.post(`/api/super-admin/channel-requests/${id}/reject`, { notes }) + ); + } +} + +export interface ChannelRequest { + id: string; + name: string; + email: string; + desiredSlug: string; + description: string; + status: 'pending' | 'approved' | 'rejected'; + notes: string; + createdAt: string; + approvedSlug: string; +} diff --git a/sample.env b/sample.env index 04e4212..1aaa9f5 100644 --- a/sample.env +++ b/sample.env @@ -7,4 +7,11 @@ 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/) +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 +R2_PUBLIC_URL=https://pub-xxxx.r2.dev # optional: public bucket URL for direct redirects \ No newline at end of file