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..24f9d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,20 @@ -FROM node:20 as builder1 +FROM node:20 AS builder1 WORKDIR /app COPY ./frontend . RUN npm install \ && 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..c1a4d05 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,263 @@ 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 + if isChannelAdsLocked(globalAds, ch) { + settings = AdsSettings{Src: globalAds.Src, Width: globalAds.Width} + } else { + 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}) +} + +// syncAdsLockFlags updates AdsLockedByAdmin on all channels based on the new global config. +func syncAdsLockFlags(globalAds *GlobalAdsConfig) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + return + } + + for _, ch := range channels { + shouldLock := globalAds.LockAll || slices.Contains(globalAds.LockedChannels, ch.Slug) + if ch.Features.AdsLockedByAdmin != shouldLock { + ch.Features.AdsLockedByAdmin = shouldLock + dbSetChannelFeatures(ctx, ch.Slug, &ch.Features) + } + } +} + +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) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + channels, err := dbListChannels(ctx) + if err != nil { + return + } + + for _, ch := range channels { + shouldLock := globalMagnet.LockAll || slices.Contains(globalMagnet.LockedChannels, ch.Slug) + if ch.Features.MagnetLockedByAdmin != shouldLock { + ch.Features.MagnetLockedByAdmin = shouldLock + dbSetChannelFeatures(ctx, ch.Slug, &ch.Features) + } + } +} + +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..ccc13bb 100644 --- a/backend/api.go +++ b/backend/api.go @@ -6,11 +6,23 @@ import ( "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) key := r.Header.Get("X-API-Key") - if key != settingConfig.ApiSecretKey { + if key != cfg.ApiSecretKey || cfg.ApiSecretKey == "" { http.Error(w, "error", http.StatusBadRequest) return } @@ -26,10 +38,7 @@ func addNewPost(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - message.ID = getMessageNextId(ctx) + message.ID = getMessageNextId(ctx, slug) message.Type = "md" //body.Type message.Author = body.Author message.Timestamp = body.Timestamp @@ -37,7 +46,7 @@ func addNewPost(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 diff --git a/backend/auth.go b/backend/auth.go index 0fa5069..6fe11e3 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -37,12 +37,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 { @@ -101,6 +102,11 @@ func login(w http.ResponseWriter, r *http.Request) { 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) @@ -108,7 +114,7 @@ func login(w http.ResponseWriter, r *http.Request) { } email, _ := dyno.GetString(payload.Claims["email"]) - go registeringEmail(email) + go registeringEmail("", email) u, err := getUser(ctx, payload.Claims) if err != nil { @@ -118,12 +124,13 @@ 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) @@ -171,17 +178,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) @@ -244,3 +240,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..f003ba5 100644 --- a/backend/channelInfo.go +++ b/backend/channelInfo.go @@ -4,14 +4,11 @@ import ( "context" "encoding/json" "net/http" - "strconv" "time" - - "github.com/icza/dyno" ) type Channel struct { - Id int `json:"id"` + Id string `json:"id"` Name string `json:"name"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` @@ -25,29 +22,26 @@ 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.Views = amount w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(channel) @@ -57,10 +51,13 @@ func editChannelInfo(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + slug := channelSlugFromCtx(r) + type Request struct { Name string `json:"name"` Description string `json:"description"` LogoUrl string `json:"logoUrl"` + ContactUs string `json:"contactUs"` } var req Request @@ -70,17 +67,13 @@ 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 + if _, err := rdb.HSet(ctx, hashKey, "name", req.Name, "description", req.Description, "logoUrl", req.LogoUrl, "contactUs", req.ContactUs).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_requests.go b/backend/channel_requests.go new file mode 100644 index 0000000..f1c8533 --- /dev/null +++ b/backend/channel_requests.go @@ -0,0 +1,229 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" +) + +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 + } + if err := rdb.Set(ctx, "channel_request:"+req.ID, data, 0).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) { + ids, err := rdb.ZRevRange(ctx, "channel_requests:list", 0, -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() + + 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 + } + + 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 + } + + 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: ChannelFeatures{ + Reactions: true, + FileUploads: true, + Reports: true, + ScheduledMessages: true, + }, + } + + if err := dbCreateChannel(ctx, channel); err != nil { + 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 + } + + 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..94bf20c --- /dev/null +++ b/backend/channels.go @@ -0,0 +1,408 @@ +package main + +import ( + "context" + "encoding/json" + "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 +} + +// 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) + } + }) +} + +// 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, + }, + } + + if err := dbCreateChannel(ctx, channel); err != nil { + 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) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + slug := chi.URLParam(r, "slug") + + 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() + + users, err := dbGetUsersList(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + 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 != "" { + users = append(users, User{ + Email: ru.Email, + ChannelRoles: map[string]ChannelRole{slug: ru.Role}, + }) + } + } + + if err := dbSetUsersList(ctx, 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"` + } + + var channelUsers []ChannelUser + 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"` + } + + var channelUsers []ChannelUser + 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() + + users, err := dbGetUsersList(ctx) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + 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 != "" { + users = append(users, User{ + Email: ru.Email, + ChannelRoles: map[string]ChannelRole{slug: ru.Role}, + }) + } + } + + if err := dbSetUsersList(ctx, 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..88bb44b 100644 --- a/backend/db.go +++ b/backend/db.go @@ -17,7 +17,7 @@ import ( var redisType = os.Getenv("REDIS_PROTOCOL") 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 +35,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 +51,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,8 +76,30 @@ 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 { + 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) } @@ -74,13 +107,18 @@ func getMessageNextId(ctx context.Context) int { return int(id) } -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 +129,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 +145,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 +174,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 +192,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 } @@ -180,11 +219,15 @@ var getMessageRange = redis.NewScript(` 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 local message_ids - + if direction == 'asc' then message_ids = redis.call('ZRANGE', time_set_key, start_index, stop_index) else @@ -198,18 +241,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 +281,7 @@ var getMessageRange = redis.NewScript(` message[key] = value end end - + if not message['deleted'] or isAdmin then table.insert(messages, message) end @@ -251,9 +294,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 +329,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 +350,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 +373,8 @@ 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) +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 +390,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 +422,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 +438,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 +503,23 @@ func dbGetUsersList(ctx context.Context) ([]User, error) { return usersList, nil } -func dbSetSettings(ctx context.Context, settings *Settings) error { +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 +535,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,8 +655,12 @@ 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 } @@ -578,21 +682,24 @@ func dbGetReports(ctx context.Context, status ReportStatus) (Reports, error) { 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 +711,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 +735,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 +775,38 @@ 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. + var earliest float64 + for _, m := range *messages { + ts := float64(m.Timestamp.Unix()) + 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 +821,417 @@ 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 +} + +func dbCreateChannel(ctx context.Context, channel *ChannelData) error { + hashKey := fmt.Sprintf("channel:%s", channel.Slug) + + if err := rdb.HSet(ctx, hashKey, + "slug", channel.Slug, + "name", channel.Name, + "description", channel.Description, + "logoUrl", channel.LogoUrl, + "ownerEmail", channel.OwnerEmail, + "createdAt", channel.CreatedAt.Format(time.RFC3339), + "contactUs", channel.ContactUs, + ).Err(); err != nil { + return err + } + + featuresJSON, err := json.Marshal(channel.Features) + if err != nil { + 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 { + return err + } + + if err := rdb.ZAdd(ctx, "channels:list", redis.Z{ + Score: float64(channel.CreatedAt.Unix()), + Member: channel.Slug, + }).Err(); err != nil { + 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 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:sse_statistics:total", 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...) + + pipe := rdb.Pipeline() + // Delete in batches of 200 to avoid huge single commands + for i := 0; i < len(allKeys); i += 200 { + end := i + 200 + if end > len(allKeys) { + end = len(allKeys) + } + pipe.Del(ctx, allKeys[i:end]...) + } + pipe.ZRem(ctx, "channels:list", slug) + pipe.ZRem(ctx, "scheduled:due_channels", slug) + if _, err := pipe.Exec(ctx); err != nil { + return err + } + + return nil +} + +func dbSetChannelFeatures(ctx context.Context, slug string, features *ChannelFeatures) error { + featuresJSON, err := json.Marshal(features) + if err != nil { + return fmt.Errorf("failed to marshal features: %v", err) + } + + featuresKey := fmt.Sprintf("channel:%s:features", slug) + if err := rdb.Set(ctx, featuresKey, featuresJSON, 0).Err(); err != nil { + return err + } + + return nil +} + +func dbAssignChannelRole(ctx context.Context, email, slug string, role ChannelRole) error { + users, err := dbGetUsersList(ctx) + if err != nil { + return err + } + + found := false + 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 + found = true + break + } + } + + if !found { + users = append(users, User{ + Email: email, + ChannelRoles: map[string]ChannelRole{slug: role}, + }) + } + + return dbSetUsersList(ctx, users) +} + +// 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() + if err != nil { + return false, nil + } + return v == "true", 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() +} + +// 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..2f8d9bf 100644 --- a/backend/files.go +++ b/backend/files.go @@ -1,12 +1,14 @@ package main import ( + "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -22,6 +24,57 @@ 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 || resp.StatusCode != http.StatusCreated { + return data + } + defer resp.Body.Close() + + 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 || dlResp.StatusCode != http.StatusOK { + return data + } + defer dlResp.Body.Close() + + compressed, err := io.ReadAll(dlResp.Body) + if err != nil || len(compressed) == 0 { + return data + } + return compressed +} + var rootUploadPath = "/app/files/" type FileResponse struct { @@ -30,39 +83,128 @@ type FileResponse struct { FileType string `json:"filetype"` } +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 +} + var maxBytesReader *http.MaxBytesError -func serveFile(w http.ResponseWriter, r *http.Request) { - fileId := chi.URLParam(r, "fileid") +// 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 file belongs to a different channel. + // Legacy files with empty ChannelSlug are allowed through for backward compatibility. + if meta.ChannelSlug != "" && meta.ChannelSlug != 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)) + + 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 + } - w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(originalFileName)) + // 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) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + slug := channelSlugFromCtx(r) + cfg := getChannelConfig(ctx, slug) + + r.Body = http.MaxBytesReader(w, r.Body, int64(cfg.MaxFileSize)<<20) file, handler, err := r.FormFile("file") if err != nil { @@ -75,97 +217,166 @@ 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 != "" { + fileBytes = compressWithTinyPng(ctx, cfg.TinyPngApiKey, fileBytes, t.MIME.Value) + } - 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 { + if err := r2Upload(ctx, key, bytes.NewReader(fileBytes), contentType); 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 == "" { - http.Error(w, "error", http.StatusInternalServerError) - return + _ = isNewHash + + dbIncrFileHashRefs(ctx, fileHash) + + meta := &FileMetadata{ + ID: id, + Filename: safeFilename, + Hash: fileHash, + Type: t.MIME.Type, + Delete: false, + Size: fileSize, + ChannelSlug: slug, } - - yamlFileDir := filepath.Join(rootUploadPath, id[:2], id[2:4]) - if err := os.MkdirAll(yamlFileDir, os.ModePerm); err != nil { + if err := dbSaveFileMetadata(ctx, meta); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } - safeFilename := gozaru.Sanitize(handler.Filename) + 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, + }) +} - fileMetadata := map[string]any{ - "id": id, - "filename": safeFilename, - "hash": fileHash, - "type": t.MIME.Type, - "delete": false, +// 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 } - metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") - metadataFile, err := os.Create(metadataFilePath) + + used, err := dbGetChannelStorageUsed(ctx, slug) if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return + return nil + } + + if used+newFileSize <= quota { + return nil // within quota + } + + autoCleanup, _ := dbGetChannelAutoCleanup(ctx, slug) + if !autoCleanup { + return fmt.Errorf("storage quota exceeded (%d/%d bytes)", used, quota) } - defer metadataFile.Close() - yamlData, err := yaml.Marshal(fileMetadata) + // Auto-cleanup: delete oldest files until we have enough space (target: 80% of quota) + target := int64(float64(quota) * 0.80) + needToFree := (used + newFileSize) - target + + 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, - }) + 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)) + } + } } func generatedFileHash(file io.Reader) (string, error) { @@ -173,57 +384,74 @@ 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) + if err != nil || meta.Delete { 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/main.go b/backend/main.go index a33d03c..0425438 100644 --- a/backend/main.go +++ b/backend/main.go @@ -16,30 +16,12 @@ 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() + initR2() + if err := initializePrivilegeUsers(); err != nil { + panic(err) + } go statLogger() var err error @@ -54,62 +36,108 @@ func main() { 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.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", setReactions) + r.Post("/messages/report", 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)) + r.Get("/delete-message/{id}", protectedWithChannelRole(RoleWriter, deleteMessage)) + r.Post("/upload", protectedWithChannelRole(RoleWriter, uploadRateLimit(uploadFile))) + r.Get("/scheduled-messages/get", protectedWithChannelRole(RoleWriter, getScheduledMessages)) + r.Post("/scheduled-messages/update", protectedWithChannelRole(RoleWriter, 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 != "" { + if settingConfig != nil && settingConfig.RootStaticFolder != "" { r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.Dir(settingConfig.RootStaticFolder)))) r.NotFound(serveSpaFile) } @@ -124,6 +152,10 @@ func main() { } func serveSpaFile(w http.ResponseWriter, r *http.Request) { + if settingConfig == nil || settingConfig.RootStaticFolder == "" { + http.Error(w, "File not found", http.StatusNotFound) + return + } htmlPath := filepath.Join(settingConfig.RootStaticFolder, "index.html") content, err := os.ReadFile(htmlPath) if err != nil { @@ -135,6 +167,10 @@ func serveSpaFile(w http.ResponseWriter, r *http.Request) { content = bytes.ReplaceAll(content, []byte(""), []byte(settingConfig.CustomTitle)) } + if settingConfig.AnalyticsHead != "" { + content = bytes.Replace(content, []byte(""), []byte(settingConfig.AnalyticsHead+""), 1) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(content) } diff --git a/backend/messages.go b/backend/messages.go index 4f56b7e..58ed1d4 100644 --- a/backend/messages.go +++ b/backend/messages.go @@ -10,12 +10,16 @@ import ( "time" "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" ) 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") @@ -30,7 +34,20 @@ func getMessages(w http.ResponseWriter, r *http.Request) { 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 + etag := `"` + getLastModified(ctx, slug) + `"` + if etag != `""` { + 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 +57,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 +80,7 @@ func addMessage(w http.ResponseWriter, r *http.Request) { return } - message.ID = getMessageNextId(ctx) + message.ID = getMessageNextId(ctx, slug) message.Type = body.Type message.Author = user.PublicName message.AuthorId = user.ID @@ -71,14 +90,14 @@ 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) @@ -88,6 +107,8 @@ 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() @@ -100,13 +121,13 @@ func updateMessage(w http.ResponseWriter, r *http.Request) { body.LastEdit = time.Now() - if err := setMessage(ctx, &body, true); err != nil { + if err := setMessage(ctx, slug, &body, true); err != nil { response := Response{Success: false} json.NewEncoder(w).Encode(response) 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 +137,30 @@ 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") idInt, _ := strconv.Atoi(id) message := Message{ID: idInt, Deleted: true} - if err := funcDeleteMessage(ctx, id); err != nil { + if err := funcDeleteMessage(ctx, slug, id); err != nil { response := Response{Success: false} json.NewEncoder(w).Encode(response) 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 +168,76 @@ 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". + lastID := r.Header.Get("Last-Event-ID") + if 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() + go 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() + 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 + if err != redis.Nil { + time.Sleep(500 * time.Millisecond) } - _, err := fmt.Fprintf(w, "data: %s\n\n", msg.Payload) - if err != nil { - return + continue + } + + 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/notifications.go b/backend/notifications.go index 045c691..15a2eb8 100644 --- a/backend/notifications.go +++ b/backend/notifications.go @@ -43,8 +43,14 @@ 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) + response := NotificationsConfig{ - EnableNotifications: settingConfig.OnNotification, + EnableNotifications: settingConfig.OnNotification && channelCfg.OnNotification, VAPID: settingConfig.VAPID, FirebaseConfig: FirebaseConfig{ ApiKey: settingConfig.FcmApiKey, @@ -121,6 +127,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 +143,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 +155,20 @@ func subscribeNotifications(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -func pushFcmMessage(m *Message) { +func pushFcmMessage(slug string, m *Message) { if !settingConfig.OnNotification { - return } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - list, err := getSubcriptionsList() + 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,7 +179,7 @@ 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 @@ -221,8 +233,5 @@ func pushFcmMessage(m *Message) { } 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..02949ea 100644 --- a/backend/privileges.go +++ b/backend/privileges.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "log" "net/http" "os" @@ -13,40 +14,60 @@ import ( "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 +var superAdminEmails []string + +// 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()) + return fmt.Errorf("get users list: %w", err) } - existsEmails := make(map[string]bool) - for _, user := range users { - existsEmails[user.Email] = true + + emailToIdx := make(map[string]int) + for i, u := range users { + emailToIdx[u.Email] = i } - for _, admin := range adminUsers { - if !existsEmails[admin] { + + for _, email := range superAdminEmails { + email = strings.TrimSpace(email) + if email == "" { + continue + } + if i, exists := emailToIdx[email]; exists { + users[i].GlobalRole = RoleSuperAdmin + } else { users = append(users, User{ - Username: "", - Email: admin, - Privileges: Privileges{ - Admin: true, - Moderator: true, - Writer: true, - }, + Email: email, + GlobalRole: RoleSuperAdmin, }) } } @@ -56,16 +77,58 @@ func initializePrivilegeUsers() { } if err := dbSetUsersList(ctx, users); err != nil { - panic("Failed to set users list in database: " + err.Error()) + return fmt.Errorf("set users list: %w", err) } + return nil } -func (p Privileges) MarshalBinary() ([]byte, error) { - return json.Marshal(p) +func isSuperAdmin(r *http.Request) bool { + session, _ := store.Get(r, cookieName) + s, ok := session.Values["user"].(Session) + if !ok { + return false + } + return s.GlobalRole == RoleSuperAdmin } -func (p *Privileges) UnmarshalBinary(data []byte) error { - return json.Unmarshal(data, p) +func hasChannelRole(r *http.Request, slug string, minRole ChannelRole) bool { + if isSuperAdmin(r) { + return true + } + session, _ := store.Get(r, cookieName) + s, ok := session.Values["user"].(Session) + if !ok { + return false + } + if s.ChannelRoles == nil { + return false + } + role, exists := s.ChannelRoles[slug] + if !exists { + return false + } + return channelRoleLevels[role] >= channelRoleLevels[minRole] +} + +func requireSuperAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isSuperAdmin(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func protectedWithChannelRole(minRole ChannelRole, handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + if !hasChannelRole(r, slug, minRole) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + handler(w, r) + } } func getPrivilegeUsersList(w http.ResponseWriter, r *http.Request) { @@ -101,9 +164,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..f098c31 --- /dev/null +++ b/backend/ratelimit.go @@ -0,0 +1,50 @@ +package main + +import ( + "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 +} + +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..6fa2b15 100644 --- a/backend/report.go +++ b/backend/report.go @@ -39,6 +39,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) @@ -56,7 +58,7 @@ func reportMessage(w http.ResponseWriter, r *http.Request) { 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 +74,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 +97,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 +108,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..14b765e 100644 --- a/backend/scheduled.go +++ b/backend/scheduled.go @@ -3,8 +3,11 @@ package main import ( "context" "encoding/json" + "fmt" "net/http" "time" + + "github.com/redis/go-redis/v9" ) func init() { @@ -12,47 +15,71 @@ 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 { - continue - } + runScheduledMessages() + } + }() +} - 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) - } - } +// 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()) - ctxSave, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) - dbSaveScheduledMessages(ctxSave, &newList) - cancelSave() + // 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 + ctxGet, cancelGet := context.WithTimeout(context.Background(), 5*time.Second) + list, err := dbGetScheduledMessages(ctxGet, slug) + cancelGet() + if err != nil { + continue } - }() + + nowTime := time.Now() + newList := make([]Message, 0) + for _, msg := range *list { + if msg.Timestamp.Before(nowTime) { + go func(m *Message, s string) { + postCtx, postCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer postCancel() + m.ID = getMessageNextId(postCtx, s) + m.Timestamp = time.Now() + m.Author = "Scheduled" + m.AuthorId = "0" + setMessage(postCtx, s, m, false) + go SendWebhook(context.Background(), s, "create", m) + go pushFcmMessage(s, m) + }(&msg, slug) + } else { + newList = append(newList, msg) + } + } + + ctxSave, cancelSave := context.WithTimeout(context.Background(), 5*time.Second) + dbSaveScheduledMessages(ctxSave, slug, &newList) // also updates the sorted set + cancelSave() + } } 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 +94,15 @@ 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 { + 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..0158d10 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -41,6 +41,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,6 +58,7 @@ type Setting struct { Value any `json:"value"` } +// settingConfig is the global config (FCM/VAPID/global notifications) var settingConfig *SettingConfig type Settings []Setting @@ -56,7 +67,7 @@ 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()) } @@ -186,6 +197,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 +248,33 @@ 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 { + return &SettingConfig{FcmJson: &FcmJsonConfing{}} + } + 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 +286,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 +297,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 + } + + settingConfig = 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..58ed51a 100644 --- a/backend/statistics.go +++ b/backend/statistics.go @@ -13,6 +13,11 @@ 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"` Timestamp time.Time `json:"timestamp" redis:"timestamp"` @@ -22,14 +27,38 @@ type Statistics struct { Labels []string `json:"labels"` } +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) + + peak, loaded := channelPeakSSE.LoadOrStore(slug, &PeakSSEConnections{}) + if !loaded { + // Try to load from DB + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if p, err := dbGetPeakSSEConnections(ctx, slug); err == nil && p != nil { + peak = p + channelPeakSSE.Store(slug, p) + } + } + return peak.(*PeakSSEConnections), m +} + func init() { + // Global peak initialization (legacy - for backward compat) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - p, _ := dbGetPeakSSEConnections(ctx) + // Try to load from a default channel or just use zero values + _ = ctx peakMu.Lock() defer peakMu.Unlock() - peakSSEConnections = p + // peakSSEConnections remains zero-valued if no default slug } func statLogger() { @@ -37,48 +66,65 @@ 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) + 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 +133,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, } @@ -101,8 +147,6 @@ func resetStatistics(w http.ResponseWriter, r *http.Request) { defer peakMu.Unlock() peakSSEConnections.Value = 0 peakSSEConnections.Timestamp = time.Time{} - p := *peakSSEConnections - go dbSavePeakSSEConnections(&p) var response Response response.Success = true 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..7055185 --- /dev/null +++ b/backend/storage_handlers.go @@ -0,0 +1,202 @@ +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, _ := dbGetChannelAutoCleanup(ctx, slug) + + 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..def48f5 100644 --- a/backend/webhook.go +++ b/backend/webhook.go @@ -10,9 +10,6 @@ import ( "time" ) -// var webhookURL string = os.Getenv("WEBHOOK_URL") -// var verifyToken string = os.Getenv("WEBHOOK_VERIFY_TOKEN") - type WebhookPayload struct { Action string `json:"action"` Message Message `json:"message"` @@ -20,8 +17,13 @@ type WebhookPayload struct { VerifyToken string `json:"verifyToken"` } -func SendWebhook(ctx context.Context, action string, message *Message) { - if settingConfig.WebhookURL == "" { +func SendWebhook(ctx context.Context, slug string, action string, message *Message) { + // Load per-channel config for webhook settings + chCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + cfg := getChannelConfig(chCtx, slug) + if cfg.WebhookURL == "" { return } @@ -29,7 +31,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 +40,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 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.routes.ts b/frontend/src/app/app.routes.ts index 9bb3141..b7440e1 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,16 +2,35 @@ 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: '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..21f5abc 100644 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ b/frontend/src/app/components/admin/admin-panel.component.html @@ -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..b64988f 100644 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ b/frontend/src/app/components/admin/admin-panel.component.ts @@ -1,11 +1,14 @@ import { Component, OnInit } from "@angular/core"; import { NbCardModule, NbLayoutModule, NbMenuItem, NbMenuModule, NbMenuService, NbSidebarModule } from "@nebular/theme"; +import { ChatService } from "../../services/chat.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"; @Component({ selector: 'admin-dashboard', @@ -19,7 +22,9 @@ import { StatisticsComponent } from "./statistics/statistics.component"; PrivilegDashboardComponent, ChannelInfoFormComponent, ReportsComponent, - StatisticsComponent + StatisticsComponent, + MagnetAdsComponent, + StorageComponent ], templateUrl: './admin-panel.component.html', styleUrls: ['./admin-panel.component.scss'] @@ -34,6 +39,8 @@ 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; @@ -59,6 +66,14 @@ export class AdminPanelComponent implements OnInit { title: "אימוג'ים", icon: 'smiling-face-outline', }, + { + title: 'שילוב פרסומות ממגנט', + icon: 'pricetags-outline', + }, + { + title: 'אחסון', + icon: 'hard-drive-outline', + }, { title: 'דיווחים', icon: 'alert-triangle-outline', @@ -79,8 +94,13 @@ export class AdminPanelComponent implements OnInit { } ]; + get channelSlug(): string { + return this.chatService.channelInfo?.slug ?? ''; + } + constructor( - private menuService: NbMenuService + private menuService: NbMenuService, + private chatService: ChatService ) { } ngOnInit(): void { @@ -117,6 +137,12 @@ export class AdminPanelComponent implements OnInit { 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; } }); } 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..cdce902 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -0,0 +1,230 @@ +
+ + + + + שילוב פרסומות ממגנט + + + +

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

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

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

+
+ +
+ +

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

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

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

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

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

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

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

+ +
+ +
+ +

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

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

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

+ +
+ +
+ +

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

+ +
+
+ } +
+
+ + + + + נתוני הקלקות ותגמולים + + + +

+ נתונים אלו מתקבלים ישירות ממגנט בזמן אמת לפי המפתח שמוגדר למעלה. + רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ, בש"ח. + "היום" מתחיל מ-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..07b24b1 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -0,0 +1,209 @@ +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 { 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 toast: NbToastrService, + ) {} + + ngOnInit(): void { + this.adminService.getSettings().then(settings => this.load(settings || [])); + } + + 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..44b97e8 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 }} + + @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..de66d4f 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,59 @@ 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[] = []; + 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) + .catch(() => this.toastService.danger('', 'שגיאה בטעינת המשתמשים')); } saveChanges() { - this.adminService.setPrivilegeUsers(this.privilegeUsersList) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); + this.adminService.setChannelUsers(this.usersList) + .then(() => this.toastService.success('', 'השינויים נשמרו בהצלחה!')) + .catch(() => this.toastService.danger('', 'שגיאה בשמירת השינויים')); } deleteUser(index: number) { if (!confirm('האם אתה בטוח שברצונך למחוק את המשתמש הזה?')) return; - this.privilegeUsersList.splice(index, 1); + this.usersList.splice(index, 1); } 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/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..5b26f36 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,167 @@ export class SettingsComponent implements OnInit { ngOnInit(): void { this.adminService.getSettings() - .then(settings => this.settings = settings || []); + .then(settings => { + this.loadFromSettings(settings || []); + this.adminService.enterSendsMessage = this.toBool(this.values['enter_sends_message']); + }); } - 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.adminService.enterSendsMessage = this.toBool(this.values['enter_sends_message']); + 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..d7d6da0 --- /dev/null +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -0,0 +1,206 @@ +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: 'custom_title', + label: 'כותרת מותאמת אישית', + description: 'כותרת שתשמש לקידום האתר בתוצאות חיפוש (SEO).', + type: 'text', + placeholder: 'לדוגמא: הערוץ החדשותי שלי', + }, + { + key: 'contact_us', + label: 'קישור ליצירת קשר', + description: 'הזנת קישור תפעיל כפתור "צור קשר" שיפנה לקישור זה.', + type: 'url', + placeholder: 'https://example.com/contact', + }, + { + key: 'max_file_size', + label: 'הגבלת גודל קובץ להעלאה (MB)', + description: 'גודל מקסימלי בקבצים שניתן להעלות לערוץ. ברירת מחדל: 100 MB.', + type: 'number', + placeholder: '100', + default: 100, + }, + { + key: 'enter_sends_message', + label: 'Enter שולח הודעה', + description: 'לחיצה על Enter תשלח את ההודעה. לחיצה על Ctrl+Enter תוריד שורה.', + type: 'boolean', + default: false, + }, + ], + }, + { + id: 'analytics', + title: 'אנליטיקס וטראקינג', + icon: 'activity-outline', + description: 'קוד שיוטמע בתוך תגית בכל עמוד באתר. מתאים לתגיות Google Analytics, Google Tag Manager, Meta Pixel וכדומה.', + fields: [ + { + key: 'analytics_head', + label: 'קוד אנליטיקס להטמעה ב-', + description: 'הדביקו כאן את כל קטע ה-HTML/JS שקיבלתם משירות האנליטיקס (כולל תגיות \n', + }, + ], + }, + { + id: 'auth', + title: 'הזדהות ואבטחה', + icon: 'shield-outline', + fields: [ + { + key: 'require_auth', + label: 'חיוב הזדהות לכניסה לערוץ', + description: 'משתמשים יחויבו להתחבר לפני שיוכלו לצפות בערוץ.', + type: 'boolean', + }, + { + key: 'require_auth_for_view_files', + label: 'חיוב הזדהות לצפייה בתמונות וסרטונים', + description: 'גם אם הערוץ פתוח לצפייה, ניתן לחייב הזדהות לפני צפייה בקבצים.', + type: 'boolean', + }, + { + key: 'api_secret_key', + label: 'מפתח API ליבוא הודעות', + description: 'מפתח סודי שיש לכלול בכותרת X-API-Key בעת קריאות יבוא הודעות.', + type: 'password', + placeholder: 'מפתח סודי חזק', + }, + ], + }, + { + id: 'views', + title: 'מונה צפיות', + icon: 'eye-outline', + fields: [ + { + key: 'count_views', + label: 'הפעלת ספירת צפיות בהודעות', + description: 'מציג ליד כל הודעה את מספר הצפיות שנספרו עבורה.', + type: 'boolean', + }, + ], + }, + { + 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: '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: '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', +}; + +export function getAllKnownKeys(): Set { + const keys = new Set(); + for (const cat of SETTINGS_SCHEMA) { + for (const f of cat.fields) keys.add(f.key); + } + keys.add('regex-replace'); + return keys; +} 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..a7cb292 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, @@ -30,16 +31,21 @@ 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']) ? [{ + ...(user?.channelRoles && Object.keys(user.channelRoles).length > 0 ? [{ title: 'ניהול ערוץ', icon: 'people-outline', }] : []), + ...(user?.globalRole === 'super_admin' ? [{ + title: 'פאנל מנהל-על', + icon: 'shield-outline', + }] : []), { title: 'התנתק', icon: 'log-out', @@ -81,7 +87,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 +97,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..ae7fe6d 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -1,26 +1,42 @@ -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 { Subscription } from 'rxjs'; @Component({ selector: 'app-channel', imports: [ + FormsModule, AdvertisingComponent, NbLayoutModule, + NbCardModule, + NbInputModule, + NbSpinnerModule, InputFormComponent, ChannelHeaderComponent, NbButtonModule, @@ -29,11 +45,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 +64,109 @@ 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 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.channelInfo = undefined; + this.adminService.clearCache(); + 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; }); } + 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('', 'שגיאה בהתנתקות'); + } + } + + hasAnyRole(user: User | undefined): boolean { + if (!user?.channelRoles) return false; + return Object.keys(user.channelRoles).length > 0; + } + 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..2220403 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -12,6 +12,7 @@ import { NbToastrService } from "@nebular/theme"; import { MessageComponent } from "./message/message.component"; +import { MagnetAdSlotComponent } from "./magnet-ad-slot/magnet-ad-slot.component"; import { firstValueFrom, interval } from 'rxjs'; import { ChatMessage, ChatService } from '../../../services/chat.service'; import { AuthService } from '../../../services/auth.service'; @@ -19,6 +20,7 @@ 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'; type LoadMsgOpt = { scrollDown?: boolean; @@ -45,18 +47,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; @@ -73,6 +80,7 @@ export class ChatComponent implements OnInit, OnDestroy { private _adminService: AdminService, private toastrService: NbToastrService, private notificationService: NotificationsService, + private magnetAds: MagnetAdsService, private zone: NgZone, private router: ActivatedRoute, ) { @@ -82,6 +90,21 @@ export class ChatComponent implements OnInit, OnDestroy { }); } + @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(); @@ -131,28 +154,41 @@ export class ChatComponent implements OnInit, OnDestroy { ngOnInit() { 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.userInfo.channelRoles && Object.keys(this.userInfo.channelRoles).length > 0 && this.loadScheduledMessages(); this.notificationService.init(); }); 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 +196,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.userInfo?.channelRoles && Object.keys(this.userInfo.channelRoles).length > 0 && this.scheduledMessages && message.message.author === "Scheduled") { this.loadScheduledMessages(true); } }); break; case 'delete-message': - if (this.userInfo?.privileges?.['writer']) { + if (this.userInfo?.channelRoles && Object.keys(this.userInfo.channelRoles).length > 0) { this.zone.run(() => { const index = this.messages.findIndex(m => m.id === message.message.id); if (index !== -1) { @@ -223,17 +275,79 @@ export class ChatComponent implements OnInit, OnDestroy { clearInterval(this.subLastHeartbeat); } + 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); + // 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() { const distanceFromBottom = document.documentElement.scrollHeight - window.innerHeight - window.scrollY; this.showScrollToBottom = distanceFromBottom > 100; @@ -269,7 +383,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 +427,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..da0633f 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 @@ -60,6 +60,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) { 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..01a90e6 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 @@ -279,6 +279,45 @@ export class InputFormComponent implements OnInit, OnDestroy { } } + onKeydown(event: KeyboardEvent) { + if (!this.adminService.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/landing/landing-page.component.scss b/frontend/src/app/components/landing/landing-page.component.scss new file mode 100644 index 0000000..42293a9 --- /dev/null +++ b/frontend/src/app/components/landing/landing-page.component.scss @@ -0,0 +1,56 @@ +:host { + display: block; +} + +.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..43e33a5 --- /dev/null +++ b/frontend/src/app/components/landing/landing-page.component.ts @@ -0,0 +1,258 @@ +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 +} 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, + ], + 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..37e68d3 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.ts @@ -0,0 +1,85 @@ +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[] = []; + 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]) + .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) { + this.users.splice(index, 1); + } + + save() { + this.saving = true; + this.superAdminService.setChannelUsers(this.slug, this.users) + .then(() => 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..6ec0004 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-magnet/global-magnet.component.ts @@ -0,0 +1,83 @@ +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: 'per_messages', + perMessages: 10, + minTimeSeconds: 60, + perSeconds: 300, + minMessagesSinceLast: 5, + apiKey: '', + lockAll: false, + lockedChannels: [], + }; + + saving = false; + newLockedChannel = ''; + + modeOptions = [ + { value: 'per_messages', label: 'לפי מספר הודעות' }, + { value: 'per_seconds', label: 'לפי זמן (שניות)' }, + ]; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.superAdminService.getMagnetConfig() + .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.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..6636c25 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.html @@ -0,0 +1,151 @@ + + +
+ + הגדרות גלובליות +
+
+ + + @if (loading) { +

טוען...

+ } @else { + + + + + + + התראות דחיפה (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..7943424 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.ts @@ -0,0 +1,127 @@ +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 = { + 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 = [ + '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..9be3d0b --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.ts @@ -0,0 +1,154 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +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 { + selectedView: ViewName = 'channels'; + selectedChannelSlug = ''; + + 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('', 'שגיאה בהתנתקות'); + } + } + + ngOnInit(): void { + this.menuService.onItemClick().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..a21cddc 100644 --- a/frontend/src/app/models/channel.model.ts +++ b/frontend/src/app/models/channel.model.ts @@ -1,5 +1,6 @@ export interface Channel { id?: number; + slug?: string; name?: string; description?: string; created_at?: string; 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..69e0356 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 = { @@ -33,10 +31,19 @@ export class AdminService { private schedulingMessages: ChatMessage[] | null = null; + enterSendsMessage: boolean = false; + constructor( private http: HttpClient, + private slugService: SlugService, ) { } + private get slug() { return this.slugService.slug; } + + clearCache() { + this.schedulingMessages = null; + } + reloadSchedulingMessage() { this.schedulingBus.next(); } @@ -50,55 +57,55 @@ export class AdminService { } getStatistics(): Promise { - return firstValueFrom(this.http.get('/api/admin/statistics')); + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/statistics`)); } resetPeakStatistics(): Promise { - return firstValueFrom(this.http.post('/api/admin/statistics/reset', {})); + return firstValueFrom(this.http.post('/api/super-admin/statistics/reset', {})); } 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); + 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}`); + 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 +113,7 @@ 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)); } async getScheduledMessages(reload?: boolean): Promise { @@ -115,7 +122,7 @@ export class AdminService { } try { - this.schedulingMessages = await firstValueFrom(this.http.get('/api/admin/scheduled-messages/get')); + this.schedulingMessages = await firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/scheduled-messages/get`)); return this.schedulingMessages; } catch { return this.schedulingMessages || []; @@ -140,6 +147,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..45a9772 100644 --- a/frontend/src/app/services/chat-guard.guard.ts +++ b/frontend/src/app/services/chat-guard.guard.ts @@ -11,6 +11,7 @@ export const AuthGuard: CanActivateFn = async (route, state) => { if (userInfo) return true; } catch (err: any) { if (err.status === 401) { + 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..a114175 100644 --- a/frontend/src/app/services/chat.service.ts +++ b/frontend/src/app/services/chat.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { firstValueFrom, Observable } from 'rxjs'; import { Channel } 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 } @@ -45,19 +46,21 @@ export class ChatService { private emojis: string[] = []; public channelInfo?: Channel; - constructor(private http: HttpClient) { } + constructor(private http: HttpClient, private slugService: SlugService) { } + + private get slug() { return this.slugService.slug; } async updateChannelInfo() { - this.channelInfo = await firstValueFrom(this.http.get('/api/channel/info')); + this.channelInfo = await firstValueFrom(this.http.get(`/api/channel/${this.slug}/info`)); return; } 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 +70,17 @@ 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 })); } 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 && !reload) return Promise.resolve(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 +88,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..40c031c --- /dev/null +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -0,0 +1,129 @@ +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(); + + if (s.mode === 'by_time') { + 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..d34c967 100644 --- a/frontend/src/app/services/notifications.service.ts +++ b/frontend/src/app/services/notifications.service.ts @@ -4,6 +4,7 @@ import { NbToastrService } from '@nebular/theme'; import { firstValueFrom } from 'rxjs'; import { FirebaseApp, FirebaseOptions, initializeApp } from 'firebase/app'; import { getMessaging, onMessage, getToken } from 'firebase/messaging'; +import { SlugService } from './slug.service'; interface NotificationsConfig { @@ -24,12 +25,13 @@ export class NotificationsService { constructor( private http: HttpClient, private tostrService: NbToastrService, + private slugService: SlugService, ) { } async init() { 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 +93,7 @@ export class NotificationsService { async subscribeNotifications(token: string): Promise { if (!token) return false; - return firstValueFrom(this.http.post('/api/channel/notifications-subscribe', { token })) + return firstValueFrom(this.http.post(`/api/channel/${this.slugService.slug}/notifications-subscribe`, { token })) } } \ 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