From 28e2fc40773f33a7c24b676bc8b8decbec651576 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 04:56:40 +0300 Subject: [PATCH 01/60] Add files via upload --- captain-definition | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 captain-definition 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 From e5f106239df185232b792b1bd25a8f0d0ad642e2 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 02:29:30 +0000 Subject: [PATCH 02/60] feat: redesign admin settings UI with categorized visual layout Replace the generic key/value table with a structured form: settings are grouped by category (general, auth, ads, webhook, notifications, FCM), each with Hebrew labels and descriptions. Booleans are toggles, regex rules have separate pattern/replace fields, and a paste-JSON helper auto-fills FCM service account fields. A collapsible "advanced" section preserves manual key/value entry for unknown settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/settings/settings.component.html | 216 ++++++++++-- .../admin/settings/settings.component.scss | 191 +++++++++++ .../admin/settings/settings.component.ts | 207 +++++++++++- .../admin/settings/settings.schema.ts | 308 ++++++++++++++++++ 4 files changed, 881 insertions(+), 41 deletions(-) create mode 100644 frontend/src/app/components/admin/settings/settings.schema.ts 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..4bf219f 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,161 @@ export class SettingsComponent implements OnInit { ngOnInit(): void { this.adminService.getSettings() - .then(settings => this.settings = settings || []); + .then(settings => this.loadFromSettings(settings || [])); } - saveSettings() { - this.setInProgress = true; - this.adminService.setSettings(this.settings) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); - this.setInProgress = false; + private loadFromSettings(settings: Setting[]) { + const known = getAllKnownKeys(); + this.values = {}; + this.regexRules = []; + this.extraSettings = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + if (f.type === 'boolean') this.values[f.key] = false; + else this.values[f.key] = ''; + } + } + + for (const s of settings) { + if (s.key === 'regex-replace') { + const raw = String(s.value ?? ''); + if (raw.includes('#')) { + const idx = raw.indexOf('#'); + this.regexRules.push({ + pattern: raw.substring(0, idx), + replace: raw.substring(idx + 1), + }); + } else if (raw) { + this.regexRules.push({ pattern: raw, replace: '' }); + } + continue; + } + + if (known.has(s.key)) { + const field = this.findField(s.key); + if (field?.type === 'boolean') { + this.values[s.key] = this.toBool(s.value); + } else { + this.values[s.key] = s.value === undefined || s.value === null ? '' : String(s.value); + } + } else { + this.extraSettings.push({ + key: s.key, + value: s.value === undefined || s.value === null ? '' : String(s.value), + }); + } + } } - removeSetting(index: number) { - // if (!confirm('האם אתה בטוח שברצונך למחוק את ההגדרה הזו?')) return; - this.settings.splice(index, 1); + private findField(key: string): SettingFieldSchema | undefined { + for (const cat of this.schema) { + const f = cat.fields.find(x => x.key === key); + if (f) return f; + } + return undefined; + } + + private toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v !== 0; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; + } + + isFieldVisible(field: SettingFieldSchema): boolean { + if (!field.hideWhen) return true; + return this.values[field.hideWhen.key] !== field.hideWhen.equals; + } + + addRegexRule() { + this.regexRules.push({ pattern: '', replace: '' }); + } + + removeRegexRule(i: number) { + this.regexRules.splice(i, 1); + } + + addExtraSetting() { + this.extraSettings.push({ key: '', value: '' }); + } + + removeExtraSetting(i: number) { + this.extraSettings.splice(i, 1); + } + + applyFcmJsonPaste() { + this.fcmJsonError = ''; + if (!this.fcmJsonPaste.trim()) return; + let parsed: any; + try { + parsed = JSON.parse(this.fcmJsonPaste); + } catch (e) { + this.fcmJsonError = 'JSON לא תקין'; + return; + } + + let count = 0; + for (const [jsonKey, settingKey] of Object.entries(FCM_JSON_KEY_MAP)) { + if (parsed[jsonKey] !== undefined) { + this.values[settingKey] = String(parsed[jsonKey]); + count++; + } + } + + if (count === 0) { + this.fcmJsonError = 'לא נמצאו שדות מוכרים בקובץ ה-JSON'; + return; + } + + this.fcmJsonPaste = ''; + this.tostService.success('', `${count} שדות מולאו אוטומטית מה-JSON`); + } + + private buildSettingsArray(): Setting[] { + const out: Setting[] = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + const v = this.values[f.key]; + if (f.type === 'boolean') { + if (v === true) out.push({ key: f.key, value: '1' as any }); + } else if (f.type === 'number') { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } else { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } + } + } + + for (const r of this.regexRules) { + const p = (r.pattern || '').trim(); + if (!p) continue; + out.push({ key: 'regex-replace', value: `${p}#${r.replace ?? ''}` as any }); + } + + for (const e of this.extraSettings) { + const k = (e.key || '').trim(); + if (!k) continue; + out.push({ key: k, value: e.value ?? '' as any }); + } + + return out; + } + + saveSettings() { + this.setInProgress = true; + const payload = this.buildSettingsArray(); + this.adminService.setSettings(payload) + .then(() => this.tostService.success('', 'השינויים נשמרו בהצלחה!')) + .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינויים')) + .finally(() => this.setInProgress = false); } } diff --git a/frontend/src/app/components/admin/settings/settings.schema.ts b/frontend/src/app/components/admin/settings/settings.schema.ts new file mode 100644 index 0000000..56b7897 --- /dev/null +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -0,0 +1,308 @@ +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, + }, + ], + }, + { + 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: '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: 'מבוסס על שירות Firebase Cloud Messaging (FCM) של גוגל.', + fields: [ + { + key: 'on_notification', + label: 'הפעלת התראות דחיפה', + description: 'יש להפעיל ולהזין את כל הפרטים מ-Firebase שבהמשך.', + type: 'boolean', + }, + { + key: 'project_domain', + label: 'דומיין הפרויקט (להפניית לחיצה על התראה)', + description: 'כתובת ה-URL שאליה ינותב המשתמש כשילחץ על התראה.', + type: 'url', + placeholder: 'https://example.com', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'vapid', + label: 'מפתח VAPID', + description: 'Cloud Messaging > Web Push certificates > Key pair', + type: 'password', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_api_key', + label: 'apiKey', + description: 'General > SDK setup and configuration > apiKey', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_auth_domain', + label: 'authDomain', + description: 'General > SDK setup and configuration > authDomain', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_project_id', + label: 'projectId', + description: 'General > SDK setup and configuration > projectId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_storage_bucket', + label: 'storageBucket', + description: 'General > SDK setup and configuration > storageBucket', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_messaging_sender_id', + label: 'messagingSenderId', + description: 'General > SDK setup and configuration > messagingSenderId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_app_id', + label: 'appId', + description: 'General > SDK setup and configuration > appId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_measurement_id', + label: 'measurementId', + description: 'General > SDK setup and configuration > measurementId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + ], + }, + { + id: 'fcm_json', + title: 'חשבון שירות FCM (Service Account)', + icon: 'file-text-outline', + description: 'שדות אלה מגיעים מקובץ ה-JSON שנוצר תחת serviceaccounts > Generate new private key. ניתן להדביק את כל קובץ ה-JSON בשדה הראשון לשם מילוי אוטומטי של כל השדות.', + fields: [ + { + key: 'fcm_json_type', + label: 'type', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_project_id', + label: 'project_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_private_key_id', + label: 'private_key_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_private_key', + label: 'private_key', + type: 'textarea', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_email', + label: 'client_email', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_id', + label: 'client_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_auth_uri', + label: 'auth_uri', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_token_uri', + label: 'token_uri', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_auth_provider_x509_cert_url', + label: 'auth_provider_x509_cert_url', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_x509_cert_url', + label: 'client_x509_cert_url', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_universe_domain', + label: 'universe_domain', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + ], + }, +]; + +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; +} From a8d3a1ef7e5d03c354c7eea569ae927dd07bbc4a Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 02:39:07 +0000 Subject: [PATCH 03/60] chore: trigger redeploy Co-Authored-By: Claude Opus 4.7 (1M context) From 72da5a4b8c8301e0405569e36641fc9dc74f43ab Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 03:14:18 +0000 Subject: [PATCH 04/60] feat: integrate Magnet ad platform with frequency rules and lazy loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated admin tab "שילוב פרסומות ממגנט" where admins paste an HTML/JS embed snippet from the Magnet ad platform. Ads render between chat messages according to configurable rules: either every N messages (with optional minimum time gap) or every N seconds (with optional minimum new-messages gap). Each ad slot is lazy-loaded with IntersectionObserver — the external embed only fires when the user scrolls near it, and re-fires on every re-mount so each viewer (and each scroll) gets a live ad. If the embed produces no DOM content within 5 seconds, the slot collapses silently. Backend exposes a new public GET /api/ads/magnet endpoint and persists the seven magnet_* keys in the existing settings:list store. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/ads.go | 28 ++++ backend/main.go | 1 + backend/settings.go | 28 ++++ .../admin/admin-panel.component.html | 3 + .../components/admin/admin-panel.component.ts | 12 +- .../magnet-ads/magnet-ads.component.html | 116 +++++++++++++++ .../magnet-ads/magnet-ads.component.scss | 89 +++++++++++ .../admin/magnet-ads/magnet-ads.component.ts | 137 +++++++++++++++++ .../channel/chat/chat.component.html | 28 ++-- .../components/channel/chat/chat.component.ts | 18 ++- .../magnet-ad-slot.component.html | 1 + .../magnet-ad-slot.component.scss | 19 +++ .../magnet-ad-slot.component.ts | 126 ++++++++++++++++ .../src/app/services/magnet-ads.service.ts | 138 ++++++++++++++++++ 14 files changed, 731 insertions(+), 13 deletions(-) create mode 100644 frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html create mode 100644 frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss create mode 100644 frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts create mode 100644 frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html create mode 100644 frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss create mode 100644 frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts create mode 100644 frontend/src/app/services/magnet-ads.service.ts diff --git a/backend/ads.go b/backend/ads.go index b868cb4..7d544d0 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -19,3 +19,31 @@ func getAdsSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } + +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"` +} + +func getMagnetAdsSettings(w http.ResponseWriter, r *http.Request) { + settings := MagnetAdsSettings{ + Enabled: settingConfig.MagnetEnabled, + Mode: settingConfig.MagnetMode, + PerMessages: settingConfig.MagnetPerMessages, + MinTimeSeconds: settingConfig.MagnetMinTimeSeconds, + PerSeconds: settingConfig.MagnetPerSeconds, + MinMessagesSinceLast: settingConfig.MagnetMinMessagesSince, + } + + if settings.Enabled { + settings.Snippet = settingConfig.MagnetSnippet + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(settings) +} diff --git a/backend/main.go b/backend/main.go index a33d03c..1bbeac0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -74,6 +74,7 @@ func main() { r.Get("/firebase-messaging-sw.js", getFirebaseMessagingSW) r.Route("/api", func(api chi.Router) { api.Get("/ads/settings", getAdsSettings) + api.Get("/ads/magnet", getMagnetAdsSettings) api.Get("/emojis/list", getEmojisList) api.Get("/channel/notifications-config", getNotificationsConfig) api.Post("/channel/notifications-subscribe", subscribeNotifications) diff --git a/backend/settings.go b/backend/settings.go index c9286a0..4aa3d21 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -41,6 +41,13 @@ type SettingConfig struct { MaxFileSize int64 CustomTitle string ContactUs string + MagnetEnabled bool + MagnetSnippet string + MagnetMode string + MagnetPerMessages int64 + MagnetMinTimeSeconds int64 + MagnetPerSeconds int64 + MagnetMinMessagesSince int64 } type Setting struct { @@ -186,6 +193,27 @@ 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() } } diff --git a/frontend/src/app/components/admin/admin-panel.component.html b/frontend/src/app/components/admin/admin-panel.component.html index 240f739..f4bfbe8 100644 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ b/frontend/src/app/components/admin/admin-panel.component.html @@ -31,6 +31,9 @@ @case (statistics) { } + @case (magnetAds) { + + } } diff --git a/frontend/src/app/components/admin/admin-panel.component.ts b/frontend/src/app/components/admin/admin-panel.component.ts index 9db0b9d..8305815 100644 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ b/frontend/src/app/components/admin/admin-panel.component.ts @@ -6,6 +6,7 @@ import { PrivilegDashboardComponent } from "./privileg-dashboard/privileg-dashbo 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"; @Component({ selector: 'admin-dashboard', @@ -19,7 +20,8 @@ import { StatisticsComponent } from "./statistics/statistics.component"; PrivilegDashboardComponent, ChannelInfoFormComponent, ReportsComponent, - StatisticsComponent + StatisticsComponent, + MagnetAdsComponent ], templateUrl: './admin-panel.component.html', styleUrls: ['./admin-panel.component.scss'] @@ -34,6 +36,7 @@ export class AdminPanelComponent implements OnInit { readonly closedReports = "closed-reports"; readonly allReports = "all-reports"; readonly statistics = "statistics"; + readonly magnetAds = "magnet-ads"; selectedMenuItem = this.info; @@ -59,6 +62,10 @@ export class AdminPanelComponent implements OnInit { title: "אימוג'ים", icon: 'smiling-face-outline', }, + { + title: 'שילוב פרסומות ממגנט', + icon: 'pricetags-outline', + }, { title: 'דיווחים', icon: 'alert-triangle-outline', @@ -117,6 +124,9 @@ export class AdminPanelComponent implements OnInit { case 'bar-chart-outline': this.selectedMenuItem = this.statistics; break; + case 'pricetags-outline': + this.selectedMenuItem = this.magnetAds; + 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..8c7b03a --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -0,0 +1,116 @@ +
+ + + + + שילוב פרסומות ממגנט + + + +

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

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

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

+
+ +
+ +

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

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

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

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

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

+ +
+ +
+ +

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

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

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

+ +
+ +
+ +

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

+ +
+
+ } +
+
+ +
+ +
+ +
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..565068d --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss @@ -0,0 +1,89 @@ +: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; +} + +.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..21de06c --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -0,0 +1,137 @@ +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'; + +const MAGNET_KEYS = [ + 'magnet_enabled', + 'magnet_snippet', + 'magnet_mode', + 'magnet_per_messages', + 'magnet_min_time_seconds', + 'magnet_per_seconds', + 'magnet_min_messages_since', +] 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; + + otherSettings: Setting[] = []; + inProgress = false; + + 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; + } + } + } + + 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 }); + } + + this.adminService.setSettings(out) + .then(() => this.toast.success('', 'הגדרות מגנט נשמרו בהצלחה')) + .catch(() => this.toast.danger('', 'שגיאה בשמירת ההגדרות')) + .finally(() => this.inProgress = false); + } +} diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 5ed82b7..18a9f89 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -23,18 +23,24 @@ } - @for (message of messages; track message) { - - - @if (message.id === lastReadMessageId + 1) { -
-
-
לא נקראו
-
-
+ @for (item of items; track item.trackKey) { + @if (item.kind === 'message') { + + + @if (item.message.id === lastReadMessageId + 1) { +
+
+
לא נקראו
+
+
+ } + +
+ } @else { + + + } - -
} @if (messages.length === 0) { diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 5f6200d..99721c5 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 { ChatItem, MagnetAdsService } from '../../../services/magnet-ads.service'; type LoadMsgOpt = { scrollDown?: boolean; @@ -45,7 +47,8 @@ type ScrollOpt = { NbButtonModule, NbListModule, NbBadgeModule, - MessageComponent + MessageComponent, + MagnetAdSlotComponent ], templateUrl: './chat.component.html', styleUrl: './chat.component.scss' @@ -53,6 +56,7 @@ type ScrollOpt = { export class ChatComponent implements OnInit, OnDestroy { private eventSource!: EventSource; messages: ChatMessage[] = []; + items: ChatItem[] = []; scheduledMessages!: ChatMessage[]; hideScheduledMessages: boolean = false; userInfo?: User; @@ -73,6 +77,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, ) { @@ -131,6 +136,8 @@ export class ChatComponent implements OnInit, OnDestroy { ngOnInit() { this.chatService.getEmojisList(true); + this.magnetAds.loadSettings().then(() => this.rebuildItems()); + this.initializeMessageListener(); this.keepAliveSSE(); @@ -172,6 +179,7 @@ export class ChatComponent implements OnInit, OnDestroy { if (this.hasNewMessages) break; this.zone.run(() => { this.messages.unshift(message.message); + this.rebuildItems(); this.thereNewMessages = !(message.message.author === this.userInfo?.username); this.setLastReadMessage(message.message.id!.toString()); if (this.userInfo?.privileges?.['writer'] && this.scheduledMessages && message.message.author === "Scheduled") { @@ -187,11 +195,13 @@ export class ChatComponent implements OnInit, OnDestroy { this.messages[index].deleted = true; this.messages[index].last_edit = message.message.last_edit; } + this.rebuildItems(); }); break; }; this.zone.run(() => { this.messages = this.messages.filter(m => m.id !== message.message.id); + this.rebuildItems(); }); break; case 'edit-message': @@ -203,6 +213,7 @@ export class ChatComponent implements OnInit, OnDestroy { // TOTO: Find the closest message to attach the retrieved message to // const closestIndex = this.messages.reduce } + this.rebuildItems(); }); break; case 'reaction': @@ -223,6 +234,10 @@ export class ChatComponent implements OnInit, OnDestroy { clearInterval(this.subLastHeartbeat); } + private rebuildItems() { + this.items = this.magnetAds.buildItems(this.messages); + } + async keepAliveSSE() { clearInterval(this.subLastHeartbeat); this.subLastHeartbeat = interval(10000) @@ -313,6 +328,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/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..7372252 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html @@ -0,0 +1 @@ +
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..90da96a --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss @@ -0,0 +1,19 @@ +:host { + display: block; + width: 100%; +} + +.magnet-ad-host { + display: block; + width: 100%; + min-height: 1px; + + &.collapsed { + display: none; + } + + ::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..4a76045 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts @@ -0,0 +1,126 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, +} from '@angular/core'; +import { MagnetAdsService } from '../../../../services/magnet-ads.service'; + +@Component({ + selector: 'app-magnet-ad-slot', + standalone: true, + 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) {} + + 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/services/magnet-ads.service.ts b/frontend/src/app/services/magnet-ads.service.ts new file mode 100644 index 0000000..e49eba8 --- /dev/null +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@angular/core'; +import { ChatMessage } from './chat.service'; + +export interface MagnetSettings { + enabled: boolean; + snippet: string; + mode: 'by_messages' | 'by_time' | string; + perMessages: number; + minTimeSeconds: number; + perSeconds: number; + minMessagesSinceLast: number; +} + +export type ChatItem = + | { kind: 'message'; message: ChatMessage; trackKey: string } + | { kind: 'ad'; trackKey: string }; + +@Injectable({ providedIn: 'root' }) +export class MagnetAdsService { + private settings: MagnetSettings | null = null; + private settingsPromise: Promise | null = 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/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; + } + + /** + * Build a list of chat items (messages + ad slots) for the chat component. + * Input: messages array as held in chat.component (newest at index 0). + * Output: items in the same order (newest first), with ad slots interleaved + * according to the configured rules. The list is rendered by a flex-column-reverse + * container, so ad slots appear visually between messages in chronological order. + */ + buildItems(messages: ChatMessage[]): ChatItem[] { + if (!messages || messages.length === 0) return []; + + const s = this.settings; + if (!s || !s.enabled || !s.snippet?.trim()) { + return messages.map(m => ({ + kind: 'message', + message: m, + trackKey: `m-${m.id}`, + } as ChatItem)); + } + + const chrono = [...messages].reverse(); + const out: ChatItem[] = []; + + if (s.mode === 'by_time') { + out.push(...this.buildByTime(chrono, s)); + } else { + out.push(...this.buildByMessages(chrono, s)); + } + + return out.reverse(); + } + + private buildByMessages(chrono: ChatMessage[], s: MagnetSettings): ChatItem[] { + const per = Math.max(1, s.perMessages || 5); + const minTime = Math.max(0, s.minTimeSeconds || 0); + const out: ChatItem[] = []; + + let countSinceLast = 0; + let lastAdTime: number | null = null; + + for (const m of chrono) { + out.push({ kind: 'message', message: m, trackKey: `m-${m.id}` }); + countSinceLast++; + + if (countSinceLast >= per) { + const msgTime = this.toEpoch(m.timestamp); + const enoughTimePassed = !minTime || lastAdTime === null || + (msgTime !== null && (msgTime - lastAdTime) >= minTime * 1000); + + if (enoughTimePassed) { + out.push({ kind: 'ad', trackKey: `ad-after-${m.id}` }); + countSinceLast = 0; + if (msgTime !== null) lastAdTime = msgTime; + } + } + } + + return out; + } + + private buildByTime(chrono: ChatMessage[], s: MagnetSettings): ChatItem[] { + const per = Math.max(1, s.perSeconds || 60); + const minMsgs = Math.max(0, s.minMessagesSinceLast || 0); + const out: ChatItem[] = []; + + let lastAdTime: number | null = null; + let msgsSinceLast = 0; + + for (const m of chrono) { + out.push({ kind: 'message', message: m, trackKey: `m-${m.id}` }); + 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) { + out.push({ kind: 'ad', trackKey: `ad-after-${m.id}` }); + lastAdTime = msgTime; + msgsSinceLast = 0; + } + } + + return out; + } + + 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; + } +} From 925d900e627f2cf9cc9b7a5ec3fa7c573a789b95 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Thu, 7 May 2026 03:24:26 +0000 Subject: [PATCH 05/60] feat: add Magnet click & earnings stats panel in admin Add a publisher API key field and a stats viewer in the Magnet admin tab. A new admin-protected endpoint GET /api/admin/magnet/stats proxies the request to Magnet's publisher-stats function so the API key never reaches the browser. The stats panel shows clicks and earnings (today / week / month) in two columns with currency formatting, displays the site domain, and has a refresh button with a last-updated timestamp. Errors from Magnet (400 invalid key, 404 unapproved site) are surfaced in Hebrew. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/ads.go | 42 +++++++ backend/main.go | 1 + backend/settings.go | 4 + .../magnet-ads/magnet-ads.component.html | 114 ++++++++++++++++++ .../magnet-ads/magnet-ads.component.scss | 107 ++++++++++++++++ .../admin/magnet-ads/magnet-ads.component.ts | 72 +++++++++++ 6 files changed, 340 insertions(+) diff --git a/backend/ads.go b/backend/ads.go index 7d544d0..bbda9e3 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "io" "net/http" + "net/url" + "time" ) type AdsSettings struct { @@ -47,3 +50,42 @@ func getMagnetAdsSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } + +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) { + apiKey := settingConfig.MagnetApiKey + if apiKey == "" { + http.Error(w, `{"error":"missing_api_key","message":"Magnet API key is not configured"}`, http.StatusBadRequest) + return + } + + q := url.Values{} + q.Set("k", 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/main.go b/backend/main.go index 1bbeac0..956364a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -104,6 +104,7 @@ func main() { protected.Post("/privilegs-users/set", protectedWithPrivilege(Admin, setPrivilegeUsers)) protected.Get("/settings/get", protectedWithPrivilege(Admin, getSettings)) protected.Post("/settings/set", protectedWithPrivilege(Admin, setSettings)) + protected.Get("/magnet/stats", protectedWithPrivilege(Admin, getMagnetStats)) protected.Get("/reports/get", protectedWithPrivilege(Admin, getReports)) protected.Post("/reports/set", protectedWithPrivilege(Admin, setReports)) }) diff --git a/backend/settings.go b/backend/settings.go index 4aa3d21..aae2e46 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -48,6 +48,7 @@ type SettingConfig struct { MagnetMinTimeSeconds int64 MagnetPerSeconds int64 MagnetMinMessagesSince int64 + MagnetApiKey string } type Setting struct { @@ -214,6 +215,9 @@ func (s *Settings) ToConfig() *SettingConfig { case "magnet_min_messages_since": config.MagnetMinMessagesSince = setting.GetInt() + + case "magnet_api_key": + config.MagnetApiKey = setting.GetString() } } 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 index 8c7b03a..cdce902 100644 --- a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -40,6 +40,31 @@ + + + + מפתח API למגנט + + +
+ +

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

+ +
+
+
+ @@ -106,6 +131,95 @@ + + + + נתוני הקלקות ותגמולים + + + +

+ נתונים אלו מתקבלים ישירות ממגנט בזמן אמת לפי המפתח שמוגדר למעלה. + רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ, בש"ח. + "היום" מתחיל מ-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/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..22a8407 --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.html @@ -0,0 +1,75 @@ + + +
+ + משתמשי ערוץ: {{ slug }} +
+ +
+ + + @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..dc4c1af --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.html @@ -0,0 +1,89 @@ + + + ניהול ערוצים + + + + + @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..3c2451b --- /dev/null +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.ts @@ -0,0 +1,87 @@ +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(); + + 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(() => this.toastr.danger('', 'שגיאה ביצירת הערוץ')) + .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.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/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/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/super-admin.service.ts b/frontend/src/app/services/super-admin.service.ts new file mode 100644 index 0000000..b73b9b8 --- /dev/null +++ b/frontend/src/app/services/super-admin.service.ts @@ -0,0 +1,144 @@ +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', {})); + } +} From 2825add3530fa32fffb6c364dd660c6367e98b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 18:07:45 +0000 Subject: [PATCH 24/60] Complete super admin panel frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - super-admin-panel.component: main shell with sidebar menu (ערוצים, פרסומות iframe, פרסומות מגנט, משתמשים, הגדרות גלובליות, סטטיסטיקות) - channels-list: table with create/edit-features/manage-users/delete actions - channel-features: all 12 feature toggles with Hebrew labels (incl. lock indicators) - channel-users: add/remove/change-role per channel, save button - global-ads: src, width, lockAll toggle, locked channels tag input - global-magnet: full magnet config + frequency settings + lock rules - global-users: read-only overview of all users with role chips - global-settings: key-value FCM/VAPID settings editor - statistics: reset peak connections + magnet stats display - app.routes.ts: add /super-admin route with SuperAdminGuard - channel-header: add "פאנל מנהל-על" link for super admins - channel.component: show input footer for super_admin + channel role holders https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- frontend/src/app/app.routes.ts | 7 ++ .../channel-header.component.ts | 7 ++ .../components/channel/channel.component.html | 2 +- .../components/channel/channel.component.ts | 5 + .../channels/channel-users.component.html | 10 +- .../channels/channels-list.component.html | 10 +- .../global-ads/global-ads.component.html | 55 +++++++++ .../global-magnet.component.html | 90 ++++++++++++++ .../global-magnet/global-magnet.component.ts | 83 +++++++++++++ .../global-settings.component.html | 71 +++++++++++ .../global-settings.component.ts | 66 +++++++++++ .../global-users/global-users.component.html | 55 +++++++++ .../global-users/global-users.component.ts | 52 ++++++++ .../super-admin-statistics.component.html | 56 +++++++++ .../super-admin-statistics.component.ts | 57 +++++++++ .../super-admin-panel.component.html | 48 ++++++++ .../super-admin-panel.component.scss | 11 ++ .../super-admin-panel.component.ts | 112 ++++++++++++++++++ 18 files changed, 788 insertions(+), 9 deletions(-) create mode 100644 frontend/src/app/components/super-admin/global-ads/global-ads.component.html create mode 100644 frontend/src/app/components/super-admin/global-magnet/global-magnet.component.html create mode 100644 frontend/src/app/components/super-admin/global-magnet/global-magnet.component.ts create mode 100644 frontend/src/app/components/super-admin/global-settings/global-settings.component.html create mode 100644 frontend/src/app/components/super-admin/global-settings/global-settings.component.ts create mode 100644 frontend/src/app/components/super-admin/global-users/global-users.component.html create mode 100644 frontend/src/app/components/super-admin/global-users/global-users.component.ts create mode 100644 frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.html create mode 100644 frontend/src/app/components/super-admin/statistics/super-admin-statistics.component.ts create mode 100644 frontend/src/app/components/super-admin/super-admin-panel.component.html create mode 100644 frontend/src/app/components/super-admin/super-admin-panel.component.scss create mode 100644 frontend/src/app/components/super-admin/super-admin-panel.component.ts diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 9bb3141..c2e28ab 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,6 +2,8 @@ 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'; export const routes: Routes = [ { path: 'login', component: LoginComponent }, @@ -10,6 +12,11 @@ export const routes: Routes = [ component: ChannelComponent, canActivate: [AuthGuard], }, + { + path: 'super-admin', + component: SuperAdminPanelComponent, + canActivate: [AuthGuard, SuperAdminGuard], + }, { path: '**', redirectTo: '' 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..a9cd620 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 @@ -40,6 +40,10 @@ export class ChannelHeaderComponent implements OnInit { title: 'ניהול ערוץ', icon: 'people-outline', }] : []), + ...(user?.globalRole === 'super_admin' ? [{ + title: 'פאנל מנהל-על', + icon: 'shield-outline', + }] : []), { title: 'התנתק', icon: 'log-out', @@ -91,6 +95,9 @@ export class ChannelHeaderComponent implements OnInit { case 'people-outline': this.dialogService.open(AdminPanelComponent, { closeOnBackdropClick: true }); break; + case 'shield-outline': + this.router.navigate(['/super-admin']); + break; } }); diff --git a/frontend/src/app/components/channel/channel.component.html b/frontend/src/app/components/channel/channel.component.html index c1c4bc5..a6adcf1 100644 --- a/frontend/src/app/components/channel/channel.component.html +++ b/frontend/src/app/components/channel/channel.component.html @@ -13,7 +13,7 @@ } - @if (userInfo?.privileges?.['writer']) { + @if (userInfo?.globalRole === 'super_admin' || userInfo?.privileges?.['writer'] || hasAnyRole(userInfo)) { diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index 68a83f0..ed4dd14 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -63,6 +63,11 @@ export class ChannelComponent implements OnInit { }); } + 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/super-admin/channels/channel-users.component.html b/frontend/src/app/components/super-admin/channels/channel-users.component.html index 22a8407..c60ca40 100644 --- a/frontend/src/app/components/super-admin/channels/channel-users.component.html +++ b/frontend/src/app/components/super-admin/channels/channel-users.component.html @@ -4,10 +4,12 @@ משתמשי ערוץ: {{ slug }}
- + @if (!addingUser) { + + } 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 index dc4c1af..60f0531 100644 --- a/frontend/src/app/components/super-admin/channels/channels-list.component.html +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.html @@ -1,10 +1,12 @@ ניהול ערוצים - + @if (!showCreateForm) { + + } 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-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..ea1f4e4 --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.html @@ -0,0 +1,71 @@ + + +
+ + הגדרות גלובליות (FCM / VAPID) +
+ @if (!addingNew) { + + } +
+ + + @if (loading) { +

טוען...

+ } @else { + @if (addingNew) { + + הגדרה חדשה + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ } + + @if (settings.length === 0) { +

אין הגדרות גלובליות

+ } + + @for (setting of settings; track $index; let i = $index) { +
+ + + +
+ } + } +
+ + + + +
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..8aa2dfc --- /dev/null +++ b/frontend/src/app/components/super-admin/global-settings/global-settings.component.ts @@ -0,0 +1,66 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbToastrService, +} from '@nebular/theme'; +import { SuperAdminService, Setting } from '../../../services/super-admin.service'; + +@Component({ + selector: 'app-global-settings', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbInputModule, + NbIconModule, + ], + templateUrl: './global-settings.component.html', +}) +export class GlobalSettingsComponent implements OnInit { + settings: Setting[] = []; + saving = false; + loading = true; + newKey = ''; + newValue = ''; + addingNew = false; + + constructor( + private superAdminService: SuperAdminService, + private toastr: NbToastrService, + ) {} + + ngOnInit(): void { + this.superAdminService.getGlobalSettings() + .then(settings => this.settings = [...(settings || [])]) + .catch(() => this.toastr.danger('', 'שגיאה בטעינת הגדרות גלובליות')) + .finally(() => this.loading = false); + } + + removeSetting(index: number) { + this.settings.splice(index, 1); + } + + addSetting() { + const key = this.newKey.trim(); + if (!key) return; + this.settings.push({ key, value: this.newValue }); + this.newKey = ''; + this.newValue = ''; + this.addingNew = false; + } + + save() { + this.saving = true; + this.superAdminService.setGlobalSettings(this.settings) + .then(() => 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/super-admin-panel.component.html b/frontend/src/app/components/super-admin/super-admin-panel.component.html new file mode 100644 index 0000000..e109133 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.html @@ -0,0 +1,48 @@ + + + + + +
+ +
+ @if (selectedView === VIEW_CHANNEL_FEATURES || selectedView === VIEW_CHANNEL_USERS) { +
+ +
+ } + + @switch (selectedView) { + @case (VIEW_CHANNELS) { + + + } + @case (VIEW_CHANNEL_FEATURES) { + + } + @case (VIEW_CHANNEL_USERS) { + + } + @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..9e80539 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.super-admin-panel-card { + height: 100%; + width: 100%; + margin: 0; +} 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..786e159 --- /dev/null +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + NbCardModule, + NbLayoutModule, + NbMenuItem, + NbMenuModule, + NbMenuService, + NbSidebarModule, +} from '@nebular/theme'; +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'; + +type ViewName = 'channels' | 'channel-features' | 'channel-users' | 'ads' | 'magnet' | 'users' | 'settings' | 'statistics'; + +@Component({ + selector: 'app-super-admin-panel', + standalone: true, + imports: [ + CommonModule, + NbLayoutModule, + NbSidebarModule, + NbMenuModule, + NbCardModule, + ChannelsListComponent, + ChannelFeaturesComponent, + ChannelUsersComponent, + GlobalAdsComponent, + GlobalMagnetComponent, + GlobalUsersComponent, + GlobalSettingsComponent, + SuperAdminStatisticsComponent, + ], + 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_ADS: ViewName = 'ads'; + readonly VIEW_MAGNET: ViewName = 'magnet'; + readonly VIEW_USERS: ViewName = 'users'; + readonly VIEW_SETTINGS: ViewName = 'settings'; + readonly VIEW_STATISTICS: ViewName = 'statistics'; + + navigationMenu: NbMenuItem[] = [ + { title: 'ערוצים', icon: 'list-outline', selected: true }, + { title: 'פרסומות iframe', icon: 'film-outline' }, + { title: 'פרסומות מגנט', icon: 'pricetags-outline' }, + { title: 'משתמשים', icon: 'people-outline' }, + { title: 'הגדרות גלובליות', icon: 'settings-2-outline' }, + { title: 'סטטיסטיקות', icon: 'bar-chart-outline' }, + ]; + + constructor(private menuService: NbMenuService) {} + + ngOnInit(): void { + this.menuService.onItemClick().subscribe(event => { + this.navigationMenu.forEach(item => (item.selected = false)); + event.item.selected = true; + + switch (event.item.icon) { + 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 '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; + } + + backToChannels() { + this.selectedView = this.VIEW_CHANNELS; + this.selectedChannelSlug = ''; + } +} From 581a20f55fceeb672e6c6135a85b43359980ed72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 18:11:49 +0000 Subject: [PATCH 25/60] Add Cloudflare R2 storage with local fallback - storage.go: R2 client init (aws-sdk-go-v2/s3), upload, exists-check, download helpers Reads env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL (optional) - files.go: file metadata moved to Redis (key: file:{id}), YAML fallback for legacy files If R2 configured: uploads to R2, serves via redirect (public URL) or proxy If R2 not configured: local disk at /app/files/ (backward compatible) Deduplication by SHA-256 hash preserved for both R2 and local - main.go: call initR2() on startup - sample.env: document R2 env vars - go.mod/go.sum: add aws-sdk-go-v2 dependencies https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/files.go | 247 +++++++++++++++++++++++++++------------------ backend/go.mod | 18 ++++ backend/go.sum | 36 +++++++ backend/main.go | 1 + backend/storage.go | 87 ++++++++++++++++ sample.env | 9 +- 6 files changed, 301 insertions(+), 97 deletions(-) create mode 100644 backend/storage.go diff --git a/backend/files.go b/backend/files.go index 3d320c2..6b557dc 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" @@ -30,39 +32,105 @@ 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"` +} + 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() +} + +// 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 + } - metadataFilePath := filepath.Join(rootUploadPath, fileId[:2], fileId[2:4], fileId+".yaml") - metadataFile, err := os.ReadFile(metadataFilePath) + // 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 { - http.Error(w, "File not found", http.StatusNotFound) - return + 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 +} - var metaData map[string]any - if err := yaml.Unmarshal(metadataFile, &metaData); err != nil { - http.Error(w, "error", http.StatusInternalServerError) +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 } - if delete := metaData["delete"].(bool); delete { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + meta, err := dbGetFileMetadata(ctx, fileId) + if err != nil || meta.Delete { http.Error(w, "File not found", http.StatusNotFound) return } - fileHash, _ := dyno.GetString(metaData["hash"]) - filePath := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4], fileHash) - originalFileName, _ := dyno.GetString(metaData["filename"]) + w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(meta.Filename)) - w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(originalFileName)) + if r2Enabled { + key := r2ObjectKey(meta.Hash) + if r2PublicURL != "" { + // Redirect to public R2 URL + http.Redirect(w, r, r2PublicURL+"/"+key, http.StatusFound) + return + } + // Proxy through backend + body, contentType, err := r2Download(ctx, key) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer body.Close() + if contentType != nil { + w.Header().Set("Content-Type", *contentType) + } + io.Copy(w, body) + return + } + + // Local storage fallback + filePath := filepath.Join(rootUploadPath, meta.Hash[:2], meta.Hash[2:4], meta.Hash) http.ServeFile(w, r, filePath) } func uploadFile(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() slug := channelSlugFromCtx(r) @@ -81,88 +149,70 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() - if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { + // Read file into memory for hashing + type detection + upload + 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(head) + t, _ := filetype.Match(fileBytes[:min(512, len(fileBytes))]) - file.Seek(0, io.SeekStart) - fileHash, err := generatedFileHash(file) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } + // Compute SHA-256 hash + hashBytes := sha256.Sum256(fileBytes) + fileHash := hex.EncodeToString(hashBytes[:]) - 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 { - 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 { + if r2Enabled { + key := r2ObjectKey(fileHash) + if !r2Exists(ctx, key) { + if err := r2Upload(ctx, key, bytes.NewReader(fileBytes), contentType); err != nil { + http.Error(w, "error uploading file", http.StatusInternalServerError) + return + } + } + } else { + // Local storage + 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 _, err := os.Stat(destPath); os.IsNotExist(err) { + if err := os.WriteFile(destPath, fileBytes, 0644); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } } - id := generatedRandomID(20) - if id == "" { - http.Error(w, "error", http.StatusInternalServerError) - return - } - - yamlFileDir := filepath.Join(rootUploadPath, id[:2], id[2:4]) - if err := os.MkdirAll(yamlFileDir, os.ModePerm); err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - - safeFilename := gozaru.Sanitize(handler.Filename) - - fileMetadata := map[string]any{ - "id": id, - "filename": safeFilename, - "hash": fileHash, - "type": t.MIME.Type, - "delete": false, + meta := &FileMetadata{ + ID: id, + Filename: safeFilename, + Hash: fileHash, + Type: t.MIME.Type, + Delete: false, } - metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") - metadataFile, err := os.Create(metadataFilePath) - if err != nil { + if err := dbSaveFileMetadata(ctx, meta); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } - defer metadataFile.Close() - - yamlData, err := yaml.Marshal(fileMetadata) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - metadataFile.Write(yamlData) fileUrl := "/api/channel/" + slug + "/files/" + id @@ -179,26 +229,21 @@ 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() - // Try to get slug from query param or use a default slug := r.URL.Query().Get("slug") if slug == "" { http.ServeFile(w, r, "assets/favicon.ico") @@ -213,35 +258,45 @@ func getFavicon(w http.ResponseWriter, r *http.Request) { logoUrl := c["logoUrl"] if logoUrl == "" { - logoUrl = "assets/favicon.ico" - } - fileId := path.Base(logoUrl) - - if len(fileId) < 4 { http.ServeFile(w, r, "assets/favicon.ico") return } - metadataFilePath := filepath.Join(rootUploadPath, fileId[:2], fileId[2:4], fileId+".yaml") - metadataFile, err := os.ReadFile(metadataFilePath) - if err != nil { + fileId := path.Base(logoUrl) + if len(fileId) < 4 { http.ServeFile(w, r, "assets/favicon.ico") return } - var metaData map[string]any - if err := yaml.Unmarshal(metadataFile, &metaData); err != nil { + meta, err := dbGetFileMetadata(ctx, fileId) + if err != nil || meta.Delete { http.ServeFile(w, r, "assets/favicon.ico") return } - if delete := metaData["delete"].(bool); delete { - http.ServeFile(w, r, "assets/favicon.ico") + 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 } - fileHash, _ := dyno.GetString(metaData["hash"]) - filePath := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4], fileHash) - + 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..eb6caa7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 7bfddbc..7317c3a 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= diff --git a/backend/main.go b/backend/main.go index 042ce8f..43d8b23 100644 --- a/backend/main.go +++ b/backend/main.go @@ -18,6 +18,7 @@ var rootStaticFolder = os.Getenv("ROOT_STATIC_FOLDER") func main() { gob.Register(Session{}) + initR2() initializePrivilegeUsers() go statLogger() diff --git a/backend/storage.go b/backend/storage.go new file mode 100644 index 0000000..fa52b66 --- /dev/null +++ b/backend/storage.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "io" + "log" + "os" + + "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. +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 +} 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 From 21042ed209c02c0688406c9e23c28194bf0ce0a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 18:23:27 +0000 Subject: [PATCH 26/60] Add storage quota system, super admin storage panel, and TinyPNG compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: storage quota tracking with per-channel and global defaults - Backend: auto-cleanup oldest files when quota is near full - Backend: TinyPNG API integration — compresses PNG/JPEG/WebP before upload - Frontend (admin): channel storage panel with usage bar, warnings, auto-cleanup toggle - Frontend (admin): TinyPNG API key field in settings (storage category) - Frontend (super-admin): global storage quota config panel - Frontend (super-admin): per-channel storage view with quota override and usage bar - Frontend (super-admin): "אחסון" button in channels list to open per-channel storage - Super admin service: added getGlobalStorageConfig/setGlobalStorageConfig/getChannelStorage/setChannelStorage https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/db.go | 143 +++++++++++++ backend/files.go | 170 +++++++++++++-- backend/main.go | 6 + backend/settings.go | 4 + backend/storage.go | 9 + backend/storage_handlers.go | 202 ++++++++++++++++++ .../admin/admin-panel.component.html | 3 + .../components/admin/admin-panel.component.ts | 20 +- .../admin/settings/settings.schema.ts | 14 ++ .../admin/storage/storage.component.ts | 122 +++++++++++ .../channels/channels-list.component.html | 5 + .../channels/channels-list.component.ts | 1 + .../global-storage.component.ts | 70 ++++++ .../storage/super-admin-storage.component.ts | 115 ++++++++++ .../super-admin-panel.component.html | 11 +- .../super-admin-panel.component.ts | 18 +- frontend/src/app/models/channel.model.ts | 1 + .../src/app/services/super-admin.service.ts | 16 ++ 18 files changed, 910 insertions(+), 20 deletions(-) create mode 100644 backend/storage_handlers.go create mode 100644 frontend/src/app/components/admin/storage/storage.component.ts create mode 100644 frontend/src/app/components/super-admin/global-storage/global-storage.component.ts create mode 100644 frontend/src/app/components/super-admin/storage/super-admin-storage.component.ts diff --git a/backend/db.go b/backend/db.go index a390bc4..b9e1f5e 100644 --- a/backend/db.go +++ b/backend/db.go @@ -1014,3 +1014,146 @@ func dbSetGlobalAdsConfig(ctx context.Context, cfg *GlobalAdsConfig) error { } 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 6b557dc..bda68ca 100644 --- a/backend/files.go +++ b/backend/files.go @@ -24,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 { @@ -33,11 +84,13 @@ type FileResponse struct { } type FileMetadata struct { - ID string `json:"id"` - Filename string `json:"filename"` - Hash string `json:"hash"` - Type string `json:"type"` - Delete bool `json:"delete"` + 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 @@ -149,7 +202,6 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { } defer file.Close() - // Read file into memory for hashing + type detection + upload fileBytes, err := io.ReadAll(file) if err != nil { http.Error(w, "error", http.StatusInternalServerError) @@ -158,10 +210,21 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { t, _ := filetype.Match(fileBytes[:min(512, len(fileBytes))]) - // Compute SHA-256 hash + // Compress images with TinyPNG if the channel has an API key configured + if cfg.TinyPngApiKey != "" { + fileBytes = compressWithTinyPng(ctx, cfg.TinyPngApiKey, fileBytes, t.MIME.Value) + } + + 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 + } + id := generatedRandomID(20) if id == "" { http.Error(w, "error", http.StatusInternalServerError) @@ -174,16 +237,18 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { contentType = "application/octet-stream" } + isNewHash := true if r2Enabled { key := r2ObjectKey(fileHash) - if !r2Exists(ctx, key) { + 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 { - // Local storage if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { http.Error(w, "error", http.StatusInternalServerError) return @@ -194,26 +259,36 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { return } destPath := filepath.Join(hashSubDir, fileHash) - if _, err := os.Stat(destPath); os.IsNotExist(err) { + 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 } } + _ = isNewHash + + dbIncrFileHashRefs(ctx, fileHash) meta := &FileMetadata{ - ID: id, - Filename: safeFilename, - Hash: fileHash, - Type: t.MIME.Type, - Delete: false, + ID: id, + Filename: safeFilename, + Hash: fileHash, + Type: t.MIME.Type, + Delete: false, + Size: fileSize, + ChannelSlug: slug, } if err := dbSaveFileMetadata(ctx, meta); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } + 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") @@ -224,6 +299,71 @@ func uploadFile(w http.ResponseWriter, r *http.Request) { }) } +// 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 + } + + used, err := dbGetChannelStorageUsed(ctx, slug) + if err != nil { + 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) + } + + // 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 { + return fmt.Errorf("storage quota exceeded") + } + + for _, f := range files { + if needToFree <= 0 { + break + } + deleteFileByID(ctx, slug, f.ID) + needToFree -= f.Size + } + + 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) { hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { diff --git a/backend/main.go b/backend/main.go index 43d8b23..f4d0258 100644 --- a/backend/main.go +++ b/backend/main.go @@ -71,6 +71,10 @@ func main() { 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) }) @@ -119,6 +123,8 @@ func main() { 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)) }) }) }) diff --git a/backend/settings.go b/backend/settings.go index 3ebc3d7..0158d10 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -50,6 +50,7 @@ type SettingConfig struct { MagnetMinMessagesSince int64 MagnetApiKey string AnalyticsHead string + TinyPngApiKey string } type Setting struct { @@ -223,6 +224,9 @@ func (s *Settings) ToConfig() *SettingConfig { case "analytics_head": config.AnalyticsHead = setting.GetString() + + case "tinypng_api_key": + config.TinyPngApiKey = setting.GetString() } } diff --git a/backend/storage.go b/backend/storage.go index fa52b66..e16cdd2 100644 --- a/backend/storage.go +++ b/backend/storage.go @@ -85,3 +85,12 @@ func r2Download(ctx context.Context, key string) (io.ReadCloser, *string, error) } return result.Body, result.ContentType, 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/frontend/src/app/components/admin/admin-panel.component.html b/frontend/src/app/components/admin/admin-panel.component.html index f4bfbe8..21f5abc 100644 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ b/frontend/src/app/components/admin/admin-panel.component.html @@ -34,6 +34,9 @@ @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 8305815..b64988f 100644 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ b/frontend/src/app/components/admin/admin-panel.component.ts @@ -1,5 +1,6 @@ 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"; @@ -7,6 +8,7 @@ import { ChannelInfoFormComponent } from "../channel/channel-info-form/channel-i 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', @@ -21,7 +23,8 @@ import { MagnetAdsComponent } from "./magnet-ads/magnet-ads.component"; ChannelInfoFormComponent, ReportsComponent, StatisticsComponent, - MagnetAdsComponent + MagnetAdsComponent, + StorageComponent ], templateUrl: './admin-panel.component.html', styleUrls: ['./admin-panel.component.scss'] @@ -37,6 +40,7 @@ export class AdminPanelComponent implements OnInit { readonly allReports = "all-reports"; readonly statistics = "statistics"; readonly magnetAds = "magnet-ads"; + readonly storage = "storage"; selectedMenuItem = this.info; @@ -66,6 +70,10 @@ export class AdminPanelComponent implements OnInit { title: 'שילוב פרסומות ממגנט', icon: 'pricetags-outline', }, + { + title: 'אחסון', + icon: 'hard-drive-outline', + }, { title: 'דיווחים', icon: 'alert-triangle-outline', @@ -86,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 { @@ -127,6 +140,9 @@ export class AdminPanelComponent implements OnInit { 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/settings/settings.schema.ts b/frontend/src/app/components/admin/settings/settings.schema.ts index c2e2c82..27ebc00 100644 --- a/frontend/src/app/components/admin/settings/settings.schema.ts +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -123,6 +123,20 @@ export const SETTINGS_SCHEMA: SettingsCategorySchema[] = [ }, ], }, + { + 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)', 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/super-admin/channels/channels-list.component.html b/frontend/src/app/components/super-admin/channels/channels-list.component.html index 60f0531..a94e472 100644 --- a/frontend/src/app/components/super-admin/channels/channels-list.component.html +++ b/frontend/src/app/components/super-admin/channels/channels-list.component.html @@ -76,6 +76,11 @@ משתמשים + + +
+ + ` +}) +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/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 index e109133..d5289af 100644 --- a/frontend/src/app/components/super-admin/super-admin-panel.component.html +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.html @@ -6,7 +6,7 @@
- @if (selectedView === VIEW_CHANNEL_FEATURES || selectedView === VIEW_CHANNEL_USERS) { + @if (selectedView === VIEW_CHANNEL_FEATURES || selectedView === VIEW_CHANNEL_USERS || selectedView === VIEW_CHANNEL_STORAGE) {
+ + } + + +
+
+ + + + +
+ TheChannel © 2024 +
+ `, +}) +export class LandingPageComponent implements OnInit { + 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']); + } + } + } catch { + // Not logged in — show the landing page + } + } + + 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..90bd776 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,7 +27,7 @@ export class LoginComponent implements OnInit { try { await this._authService.loadUserInfo(); if (this._authService.userInfo) { - this.router.navigate(['/']); + this.redirectAfterLogin(); return; } } catch { @@ -37,8 +35,9 @@ export class LoginComponent implements OnInit { 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(['/']); + this._authService.login(this.code).then(async () => { + await this._authService.loadUserInfo(); + this.redirectAfterLogin(); }).catch(() => { this.code = ''; this.status = 'failed'; @@ -52,6 +51,14 @@ export class LoginComponent implements OnInit { } } + private redirectAfterLogin() { + if (this._authService.userInfo?.globalRole === 'super_admin') { + this.router.navigate(['/super-admin']); + } else { + this.router.navigate(['/channel']); + } + } + login() { this._authService.loginWithGoogle(); } 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..e5a83f7 --- /dev/null +++ b/frontend/src/app/components/super-admin/channel-requests/channel-requests.component.ts @@ -0,0 +1,246 @@ +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; + 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(() => this.toastr.danger('', 'שגיאה באישור הבקשה')); + } + + 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/super-admin-panel.component.html b/frontend/src/app/components/super-admin/super-admin-panel.component.html index d5289af..8f6b14c 100644 --- a/frontend/src/app/components/super-admin/super-admin-panel.component.html +++ b/frontend/src/app/components/super-admin/super-admin-panel.component.html @@ -15,6 +15,9 @@ } @switch (selectedView) { + @case (VIEW_REQUESTS) { + + } @case (VIEW_CHANNELS) { { + 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/super-admin.service.ts b/frontend/src/app/services/super-admin.service.ts index fa4abbe..00f7734 100644 --- a/frontend/src/app/services/super-admin.service.ts +++ b/frontend/src/app/services/super-admin.service.ts @@ -157,4 +157,35 @@ export class SuperAdminService { 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; } From f360334ea592ab92134aea63467abae0ce3c8c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 04:44:22 +0000 Subject: [PATCH 29/60] Fix multi-tenant isolation, performance, and rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (data isolation): - serveFile: verify meta.ChannelSlug matches URL slug — blocks cross-channel file access - addNewPost (/import): validate slug against slugRegex to prevent key injection Performance (50+ concurrent channels): - Redis pool: PoolSize=100, MinIdleConns=10, retries, timeouts (was default 10) - dbDeleteChannel: replace O(keyspace) SCAN with targeted pipeline using known key names + message IDs from sorted set — O(messages) instead of O(total keys) - Scheduled messages: maintain sorted set "scheduled:due_channels" (score=next due time) so the goroutine only wakes channels with pending messages, not all channels every minute - Lua getMessageRange: add max_scan_rounds=20 guard to prevent unbounded loops on sparse data Rate limiting: - File uploads: 30/min per channel (burst=10) via golang.org/x/time/rate returns HTTP 429 when exceeded https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/api.go | 4 +- backend/db.go | 104 +++++++++++++++++++++++++++++++------------ backend/files.go | 8 ++++ backend/go.mod | 4 +- backend/go.sum | 2 + backend/main.go | 2 +- backend/ratelimit.go | 42 +++++++++++++++++ backend/scheduled.go | 96 ++++++++++++++++++++++----------------- 8 files changed, 189 insertions(+), 73 deletions(-) create mode 100644 backend/ratelimit.go diff --git a/backend/api.go b/backend/api.go index 3519c07..ccc13bb 100644 --- a/backend/api.go +++ b/backend/api.go @@ -12,8 +12,8 @@ import ( func addNewPost(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") - if slug == "" { - http.Error(w, "missing slug", http.StatusBadRequest) + if !slugRegex.MatchString(slug) { + http.Error(w, "invalid slug", http.StatusBadRequest) return } diff --git a/backend/db.go b/backend/db.go index b9e1f5e..9011a93 100644 --- a/backend/db.go +++ b/backend/db.go @@ -52,10 +52,16 @@ 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, + Network: redisType, + Addr: redisAddr, + Password: redisPass, + DB: 0, + PoolSize: 100, // one per active SSE connection + room for writes + MinIdleConns: 10, + MaxRetries: 3, + DialTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 5 * time.Second, }) _, err := rdb.Ping(ctx).Result() @@ -186,7 +192,11 @@ 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 @@ -748,6 +758,21 @@ func dbSaveScheduledMessages(ctx context.Context, slug string, messages *[]Messa 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 } @@ -873,32 +898,55 @@ func dbListChannels(ctx context.Context) ([]*ChannelData, error) { } func dbDeleteChannel(ctx context.Context, slug string) error { - // Remove from channels:list - if err := rdb.ZRem(ctx, "channels:list", slug).Err(); err != nil { - return err - } - - // Scan and delete all channel:{slug}:* keys - pattern := fmt.Sprintf("channel:%s:*", slug) - var cursor uint64 - for { - keys, nextCursor, err := rdb.Scan(ctx, cursor, pattern, 100).Result() - if err != nil { - return err - } - if len(keys) > 0 { - if err := rdb.Del(ctx, keys...).Err(); err != nil { - return err - } - } - cursor = nextCursor - if cursor == 0 { - break + 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), + } + 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]...) } - - // Delete the main channel hash - if err := rdb.Del(ctx, fmt.Sprintf("channel:%s", slug)).Err(); err != nil { + pipe.ZRem(ctx, "channels:list", slug) + pipe.ZRem(ctx, "scheduled:due_channels", slug) + if _, err := pipe.Exec(ctx); err != nil { return err } diff --git a/backend/files.go b/backend/files.go index bda68ca..0ec8bcd 100644 --- a/backend/files.go +++ b/backend/files.go @@ -148,11 +148,19 @@ func serveFile(w http.ResponseWriter, r *http.Request) { 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 } + // 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 + } w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(meta.Filename)) diff --git a/backend/go.mod b/backend/go.mod index eb6caa7..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 @@ -72,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 7317c3a..db68322 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -211,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 08ab2d8..9a25728 100644 --- a/backend/main.go +++ b/backend/main.go @@ -119,7 +119,7 @@ func main() { 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, uploadFile)) + r.Post("/upload", protectedWithChannelRole(RoleWriter, uploadRateLimit(uploadFile))) r.Get("/scheduled-messages/get", protectedWithChannelRole(RoleWriter, getScheduledMessages)) r.Post("/scheduled-messages/update", protectedWithChannelRole(RoleWriter, updateScheduledMessages)) diff --git a/backend/ratelimit.go b/backend/ratelimit.go new file mode 100644 index 0000000..248216e --- /dev/null +++ b/backend/ratelimit.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// uploadLimiters stores a per-channel rate limiter for file uploads. +// Each channel allows up to 30 uploads per minute with a burst of 10. +var ( + uploadLimiters sync.Map + uploadLimiterMu sync.Mutex +) + +func getUploadLimiter(slug string) *rate.Limiter { + if v, ok := uploadLimiters.Load(slug); ok { + return v.(*rate.Limiter) + } + uploadLimiterMu.Lock() + defer uploadLimiterMu.Unlock() + // Double-check after lock + if v, ok := uploadLimiters.Load(slug); ok { + return v.(*rate.Limiter) + } + l := rate.NewLimiter(rate.Every(2*time.Second), 10) // 30/min, burst 10 + uploadLimiters.Store(slug, l) + return l +} + +func uploadRateLimit(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slug := channelSlugFromCtx(r) + if !getUploadLimiter(slug).Allow() { + http.Error(w, "too many uploads — slow down", http.StatusTooManyRequests) + return + } + next(w, r) + } +} diff --git a/backend/scheduled.go b/backend/scheduled.go index ff44709..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,49 +15,62 @@ func init() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for range ticker.C { - ctxList, cancelList := context.WithTimeout(context.Background(), 5*time.Second) - channels, err := dbListChannels(ctxList) - cancelList() - if err != nil { - continue - } + runScheduledMessages() + } + }() +} + +// runScheduledMessages only processes channels that have at least one message +// due before now, using the "scheduled:due_channels" sorted set (score = earliest +// due timestamp). This avoids querying every channel every minute. +func runScheduledMessages() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + now := float64(time.Now().Unix()) - for _, ch := range channels { - slug := ch.Slug - ctxGet, cancelGet := context.WithTimeout(context.Background(), 5*time.Second) - list, err := dbGetScheduledMessages(ctxGet, slug) - cancelGet() - if err != nil { - continue - } - - now := time.Now() - newList := make([]Message, 0) - for _, msg := range *list { - if msg.Timestamp.Before(now) { - go func(m *Message, s string) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - m.ID = getMessageNextId(ctx, s) - m.Timestamp = time.Now() - m.Author = "Scheduled" - m.AuthorId = "0" - setMessage(ctx, 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) - 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) { From ccf083f121edf8d0a874602680a5527a4b5ff06b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 04:52:16 +0000 Subject: [PATCH 30/60] Implement all scalability and performance recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redis Streams (horizontal scaling): - Replace pub/sub with Redis Streams for SSE events * publishEvent() uses XADD (MaxLen~1000, auto-trim) * getEvents() uses XREAD BLOCK with 5s timeout loop * Supports Last-Event-ID header: clients reconnect without missing events * Multiple backend instances can all serve SSE from the same stream - Stream key: channel:{slug}:events (consistent naming convention) - Stream keys added to dbDeleteChannel cleanup list Redis Universal Client (Cluster/Sentinel support): - rdb changed from *redis.Client to redis.UniversalClient - NewUniversalClient: single addr = normal, multiple REDIS_ADDRS = cluster, REDIS_MASTER set = Sentinel — zero code changes needed to scale up Pre-signed R2 URLs (backend off the file-serving path): - r2PresignURL() generates 1-hour signed URLs via s3.NewPresignClient - serveFile: public bucket → CDN redirect, private bucket → pre-signed URL, only falls back to backend proxy if presigning fails HTTP ETags for message caching: - touchLastModified() stores nano-timestamp on every message write/edit/delete - getMessages returns ETag header; returns 304 Not Modified when client is current - Eliminates redundant Lua script executions for unchanged channels Per-user upload rate limiting: - Rate limiter key is now userEmail:channelSlug instead of just slug - Prevents one user from exhausting the channel's upload budget - 30 uploads/min per user per channel (burst 10) https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/db.go | 46 +++++++++++++++++++---- backend/files.go | 11 +++++- backend/messages.go | 88 +++++++++++++++++++++++++++++++------------- backend/ratelimit.go | 32 ++++++++++------ backend/storage.go | 16 ++++++++ 5 files changed, 145 insertions(+), 48 deletions(-) diff --git a/backend/db.go b/backend/db.go index 9011a93..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"` @@ -51,12 +51,16 @@ 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, + // 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, - PoolSize: 100, // one per active SSE connection + room for writes + MasterName: os.Getenv("REDIS_MASTER"), + PoolSize: 100, MinIdleConns: 10, MaxRetries: 3, DialTimeout: 5 * time.Second, @@ -72,6 +76,28 @@ func init() { log.Println("Connection to DB successful!") } +// 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 { @@ -119,7 +145,8 @@ func setMessage(ctx context.Context, slug string, m *Message, isUpdate bool) err } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, fmt.Sprintf("events:%s", slug), pushMessageData) + publishEvent(ctx, slug, pushMessageData) + touchLastModified(ctx, slug) return nil } @@ -165,7 +192,7 @@ func setReaction(ctx context.Context, slug string, messageId int, emoji string, } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, fmt.Sprintf("events:%s", slug), pushMessageData) + publishEvent(ctx, slug, pushMessageData) return nil } @@ -363,7 +390,8 @@ func funcDeleteMessage(ctx context.Context, slug string, id string) error { M: m, } pushMessageData, _ := json.Marshal(pushMessage) - rdb.Publish(ctx, fmt.Sprintf("events:%s", slug), pushMessageData) + publishEvent(ctx, slug, pushMessageData) + touchLastModified(ctx, slug) return nil } @@ -931,6 +959,8 @@ func dbDeleteChannel(ctx context.Context, slug string) error { 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...) diff --git a/backend/files.go b/backend/files.go index 0ec8bcd..2f8d9bf 100644 --- a/backend/files.go +++ b/backend/files.go @@ -167,11 +167,18 @@ func serveFile(w http.ResponseWriter, r *http.Request) { if r2Enabled { key := r2ObjectKey(meta.Hash) if r2PublicURL != "" { - // Redirect to public R2 URL + // Public bucket: redirect to CDN URL (fastest, no auth needed) http.Redirect(w, r, r2PublicURL+"/"+key, http.StatusFound) return } - // Proxy through backend + // 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) diff --git a/backend/messages.go b/backend/messages.go index 41b6121..58ed1d4 100644 --- a/backend/messages.go +++ b/backend/messages.go @@ -10,6 +10,7 @@ import ( "time" "github.com/go-chi/chi" + "github.com/redis/go-redis/v9" ) func getMessages(w http.ResponseWriter, r *http.Request) { @@ -36,6 +37,16 @@ func getMessages(w http.ResponseWriter, r *http.Request) { 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) @@ -144,6 +155,12 @@ func deleteMessage(w http.ResponseWriter, r *http.Request) { 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 { @@ -152,18 +169,21 @@ func getEvents(w http.ResponseWriter, r *http.Request) { } slug := channelSlugFromCtx(r) + streamKey := fmt.Sprintf("channel:%s:events", slug) - clientCtx := r.Context() - heartbeat := time.NewTicker(25 * time.Second) - defer heartbeat.Stop() + // 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() @@ -171,37 +191,53 @@ func getEvents(w http.ResponseWriter, r *http.Request) { go increaseCounterSSE(slug) defer decreaseCounterSSE(slug) - eventChannel := fmt.Sprintf("events:%s", slug) - pubsub := rdb.Subscribe(r.Context(), eventChannel) - defer pubsub.Close() - - 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/ratelimit.go b/backend/ratelimit.go index 248216e..f098c31 100644 --- a/backend/ratelimit.go +++ b/backend/ratelimit.go @@ -8,33 +8,41 @@ import ( "golang.org/x/time/rate" ) -// uploadLimiters stores a per-channel rate limiter for file uploads. -// Each channel allows up to 30 uploads per minute with a burst of 10. +// 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 + uploadLimiters sync.Map + uploadLimiterMu sync.Mutex ) -func getUploadLimiter(slug string) *rate.Limiter { - if v, ok := uploadLimiters.Load(slug); ok { +func getUploadLimiter(key string) *rate.Limiter { + if v, ok := uploadLimiters.Load(key); ok { return v.(*rate.Limiter) } uploadLimiterMu.Lock() defer uploadLimiterMu.Unlock() - // Double-check after lock - if v, ok := uploadLimiters.Load(slug); ok { + if v, ok := uploadLimiters.Load(key); ok { return v.(*rate.Limiter) } - l := rate.NewLimiter(rate.Every(2*time.Second), 10) // 30/min, burst 10 - uploadLimiters.Store(slug, l) + 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) - if !getUploadLimiter(slug).Allow() { - http.Error(w, "too many uploads — slow down", http.StatusTooManyRequests) + + // 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/storage.go b/backend/storage.go index e16cdd2..29bb9fb 100644 --- a/backend/storage.go +++ b/backend/storage.go @@ -5,6 +5,7 @@ import ( "io" "log" "os" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -75,6 +76,7 @@ func r2Exists(ctx context.Context, key string) bool { } // 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), @@ -86,6 +88,20 @@ func r2Download(ctx context.Context, key string) (io.ReadCloser, *string, error) 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{ From c238eba4465c89ab437317aec3f4322494550615 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 05:10:00 +0000 Subject: [PATCH 31/60] Fix per-channel URL routing, OAuth returnUrl, and all API slug paths - Add SlugService: central store for the active channel slug - Add /channel/:slug route; ChannelComponent resolves slug from URL param or redirects to /channel/ from user's channelRoles - AuthGuard saves state.url to localStorage before redirecting to /login - LoginComponent: load userInfo after OAuth callback, restore returnUrl on redirect; super_admin goes to /super-admin, others go to / - Fix all frontend API calls to include /channel/{slug}/: chat.service: info, messages, events, emojis, reactions, report admin.service: new, edit, delete, upload, users, settings, emojis, reports, scheduled-messages, statistics ads.service: ads/settings notifications.service: notifications-config, notifications-subscribe magnet-ads.service: ads/magnet - Fix ChannelComponent to guard child render behind slugReady flag - Fix channel-header: show admin menu via channelRoles instead of old privileges field - Fix chat.component: scheduled messages + delete visibility use channelRoles instead of legacy privileges - Fix magnet-ads.component: stats call uses correct /super-admin route - AdminService.clearCache() resets schedulingMessages on channel switch https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- frontend/src/app/app.routes.ts | 5 +++ .../admin/magnet-ads/magnet-ads.component.ts | 2 +- .../channel-header.component.ts | 2 +- .../components/channel/channel.component.html | 38 ++++++++++--------- .../components/channel/channel.component.ts | 33 ++++++++++++++-- .../components/channel/chat/chat.component.ts | 6 +-- .../app/components/login/login.component.ts | 23 +++++++++-- frontend/src/app/services/admin.service.ts | 38 +++++++++++-------- frontend/src/app/services/ads.service.ts | 6 ++- frontend/src/app/services/chat-guard.guard.ts | 1 + frontend/src/app/services/chat.service.ts | 21 +++++----- .../src/app/services/magnet-ads.service.ts | 5 ++- .../src/app/services/notifications.service.ts | 6 ++- frontend/src/app/services/slug.service.ts | 6 +++ 14 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 frontend/src/app/services/slug.service.ts diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c2e28ab..8c37f1d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -12,6 +12,11 @@ export const routes: Routes = [ component: ChannelComponent, canActivate: [AuthGuard], }, + { + path: 'channel/:slug', + component: ChannelComponent, + canActivate: [AuthGuard], + }, { path: 'super-admin', component: SuperAdminPanelComponent, 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 index e0de604..07b24b1 100644 --- a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -165,7 +165,7 @@ export class MagnetAdsComponent implements OnInit { this.statsError = ''; try { - const res = await fetch('/api/admin/magnet/stats', { credentials: 'include' }); + 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 {} 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 a9cd620..665286b 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 @@ -36,7 +36,7 @@ export class ChannelHeaderComponent implements OnInit { 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', }] : []), diff --git a/frontend/src/app/components/channel/channel.component.html b/frontend/src/app/components/channel/channel.component.html index a6adcf1..7cad061 100644 --- a/frontend/src/app/components/channel/channel.component.html +++ b/frontend/src/app/components/channel/channel.component.html @@ -1,21 +1,23 @@ - - - - +@if (slugReady) { + + + + - - - - - @if (ad) { - - + + - } - @if (userInfo?.globalRole === 'super_admin' || userInfo?.privileges?.['writer'] || hasAnyRole(userInfo)) { - - - - } - + @if (ad) { + + + + } + + @if (userInfo?.globalRole === 'super_admin' || hasAnyRole(userInfo)) { + + + + } + +} diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index ed4dd14..40ab4b5 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -15,6 +15,10 @@ 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'; @Component({ selector: 'app-channel', @@ -48,18 +52,41 @@ 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, ) { } ad: Ad = { src: '', width: 0 }; userInfo?: User; + slugReady = false; + + async ngOnInit(): Promise { + let slug = this.route.snapshot.paramMap.get('slug'); + + 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 }); + } + return; + } + + this.slugService.slug = slug; + this.chatService.channelInfo = undefined; + this.adminService.clearCache(); + this.slugReady = true; - ngOnInit(): void { this.adsService.getAds().then(ad => { this.ad = ad; }); this._authService.loadUserInfo().then(res => { - this.userInfo = res + this.userInfo = res; }); } diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 32f391b..2a28dbb 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -145,7 +145,7 @@ export class ChatComponent implements OnInit, OnDestroy { 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(); }); @@ -183,13 +183,13 @@ export class ChatComponent implements OnInit, OnDestroy { this.messages.unshift(message.message); this.thereNewMessages = !(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) { diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts index 84d6550..d825f77 100644 --- a/frontend/src/app/components/login/login.component.ts +++ b/frontend/src/app/components/login/login.component.ts @@ -29,7 +29,7 @@ export class LoginComponent implements OnInit { try { await this._authService.loadUserInfo(); if (this._authService.userInfo) { - this.router.navigate(['/']); + this.redirectAfterLogin(); return; } } catch { @@ -37,8 +37,9 @@ export class LoginComponent implements OnInit { 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(['/']); + this._authService.login(this.code).then(async () => { + await this._authService.loadUserInfo(); + this.redirectAfterLogin(); }).catch(() => { this.code = ''; this.status = 'failed'; @@ -52,6 +53,22 @@ export class LoginComponent implements OnInit { } } + 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(['/']); + } + login() { this._authService.loginWithGoogle(); } diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts index 34aa77d..4a434ae 100644 --- a/frontend/src/app/services/admin.service.ts +++ b/frontend/src/app/services/admin.service.ts @@ -6,6 +6,7 @@ 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; @@ -35,8 +36,15 @@ export class AdminService { constructor( private http: HttpClient, + private slugService: SlugService, ) { } + private get slug() { return this.slugService.slug; } + + clearCache() { + this.schedulingMessages = null; + } + reloadSchedulingMessage() { this.schedulingBus.next(); } @@ -50,27 +58,27 @@ 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' @@ -78,27 +86,27 @@ export class AdminService { } getPrivilegeUsersList(): Promise { - return firstValueFrom(this.http.get('/api/admin/privilegs-users/get-list')); + 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 })); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/users/set`, { list: privilegeUsers })); } 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 +114,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 +123,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 +148,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/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 index 8fabdc2..34d7a2a 100644 --- a/frontend/src/app/services/magnet-ads.service.ts +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { ChatMessage } from './chat.service'; +import { SlugService } from './slug.service'; export interface MagnetSettings { enabled: boolean; @@ -16,11 +17,13 @@ export class MagnetAdsService { private settings: MagnetSettings | null = null; private settingsPromise: Promise | null = null; + constructor(private slugService: SlugService) {} + loadSettings(force = false): Promise { if (this.settings && !force) return Promise.resolve(this.settings); if (this.settingsPromise && !force) return this.settingsPromise; - this.settingsPromise = fetch('/api/ads/magnet') + this.settingsPromise = fetch(`/api/channel/${this.slugService.slug}/ads/magnet`) .then(r => r.ok ? r.json() : null) .then((data: MagnetSettings | null) => { this.settings = data; 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 = ''; +} From e3202af1cb644f489ab4e3e996bafb2d0441e711 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 05:23:06 +0000 Subject: [PATCH 32/60] Fix three logic bugs found in flow review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Channel param subscription: replace snapshot with paramMap.subscribe so navigating /channel/foo → /channel/bar re-initializes the component without destroying it (Angular reuses the instance for same-route nav). Also implement OnDestroy to unsubscribe and avoid memory leaks. 2. No-channel state: when the user has no channelRoles the component now sets noChannel=true and renders a Hebrew error message instead of a blank screen. 3. Login checkUserInfo timing: remove the try/finally pattern that reset checkUserInfo synchronously before the async login().then() chain completed. Now the flag is managed explicitly at each code path so the loading indicator reflects actual in-flight state. https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../components/channel/channel.component.html | 6 +++ .../components/channel/channel.component.ts | 27 ++++++++++++-- .../app/components/login/login.component.ts | 37 ++++++++++--------- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/components/channel/channel.component.html b/frontend/src/app/components/channel/channel.component.html index 7cad061..033251b 100644 --- a/frontend/src/app/components/channel/channel.component.html +++ b/frontend/src/app/components/channel/channel.component.html @@ -1,3 +1,9 @@ +@if (noChannel) { +
+ אין ערוץ מוגדר עבור חשבון זה. פנה למנהל המערכת. +
+} + @if (slugReady) { diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index 40ab4b5..b859c52 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; import { AdvertisingComponent } from "./advertising/advertising.component"; import { Ad, AdsService } from '../../services/ads.service'; @@ -19,6 +19,7 @@ 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 { Subscription } from 'rxjs'; @Component({ selector: 'app-channel', @@ -37,7 +38,7 @@ import { ChatService } from '../../services/chat.service'; 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) { @@ -63,9 +64,25 @@ export class ChannelComponent implements OnInit { ad: Ad = { src: '', width: 0 }; userInfo?: User; slugReady = false; + noChannel = false; + private paramSub?: Subscription; - async ngOnInit(): Promise { - let slug = this.route.snapshot.paramMap.get('slug'); + ngOnInit(): void { + // Subscribe to paramMap so navigation between /channel/foo → /channel/bar + // triggers a full re-initialization without needing to destroy the component. + 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; if (!slug) { const user = await this._authService.loadUserInfo(); @@ -73,6 +90,8 @@ export class ChannelComponent implements OnInit { const firstSlug = roles ? Object.keys(roles)[0] : ''; if (firstSlug) { this.router.navigate(['/channel', firstSlug], { replaceUrl: true }); + } else { + this.noChannel = true; } return; } diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts index d825f77..08ec0c0 100644 --- a/frontend/src/app/components/login/login.component.ts +++ b/frontend/src/app/components/login/login.component.ts @@ -29,28 +29,31 @@ export class LoginComponent implements OnInit { try { await this._authService.loadUserInfo(); if (this._authService.userInfo) { + 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(async () => { - await this._authService.loadUserInfo(); - this.redirectAfterLogin(); - }).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() { From fae823e3c7a4872e23b6d0a5c393788bd6af8edb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 06:31:13 +0000 Subject: [PATCH 33/60] Fix offline handling: banner, reconnect, missed-message sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problems fixed: 1. No user feedback on disconnect — added a sticky orange Hebrew banner "אין חיבור לאינטרנט" that appears immediately on window:offline and disappears on window:online. 2. keepAlive forced-reconnect lost messages — the old code created a new EventSource (no Last-Event-ID) after 60 s; missed messages were gone. Now: (a) threshold reduced 60 s → 35 s (backend heartbeat is 25 s so one missed beat triggers reconnect), (b) loadMissedMessages() is called after every forced reconnect to HTTP-fetch messages newer than maxId. 3. SSE onopen now detects reconnect (sseEverConnected flag) and calls loadMissedMessages() so the browser's natural auto-reconnect also syncs the gap, not just the keepAlive path. 4. window:online listener: hides banner, shows success toast "החיבור חודש", re-initialises SSE, and calls loadMissedMessages(). 5. window:offline listener: shows the banner immediately without waiting for the 35 s heartbeat timeout. 6. onerror handler cleaned up — browser handles SSE retry automatically with Last-Event-ID; logging the raw error was the only previous action. https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../channel/chat/chat.component.html | 7 +++ .../channel/chat/chat.component.scss | 16 +++++ .../components/channel/chat/chat.component.ts | 60 +++++++++++++++++-- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 4024d1c..8d3febf 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -1,3 +1,10 @@ +@if (isOffline) { +
+ + אין חיבור לאינטרנט — ההודעות יתעדכנו אוטומטית עם חזרת החיבור +
+} +
diff --git a/frontend/src/app/components/channel/chat/chat.component.scss b/frontend/src/app/components/channel/chat/chat.component.scss index 0fb83bb..4a36ae1 100644 --- a/frontend/src/app/components/channel/chat/chat.component.scss +++ b/frontend/src/app/components/channel/chat/chat.component.scss @@ -1,3 +1,19 @@ +.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 2a28dbb..845eb0b 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -55,12 +55,14 @@ type ScrollOpt = { }) 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; offset: number = 0; limit: number = 20; hasOldMessages: boolean = true; @@ -87,6 +89,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(); @@ -169,10 +186,25 @@ 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); @@ -243,15 +275,35 @@ export class ChatComponent implements OnInit, OnDestroy { 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) { + this.zone.run(() => { + this.messages.unshift(...missed.reverse()); + this.hasNewMessages = missed.length >= this.limit; + this.thereNewMessages = true; + this.rebuildItems(); + }); + } + } catch { + // Best-effort; will retry on the next reconnect + } + } + onListScroll() { const distanceFromBottom = document.documentElement.scrollHeight - window.innerHeight - window.scrollY; this.showScrollToBottom = distanceFromBottom > 100; From 94987641c6f557fb4f7cef9cc0b12cc73794e4c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 06:40:30 +0000 Subject: [PATCH 34/60] Fix scroll jumps and message duplicates on reconnect Problems fixed: 1. Initial-load flash-then-jump: the list was visible during the 200ms between first render (showing newest messages) and scrollToId (jumping to last-unread). Fixed with isVisible / .chat-hidden: content stays invisible until scroll position is resolved, then appears already in the right place. setLastReadMessage is now called AFTER the jump so a page refresh still brings the user back to the unread position. 2. Duplicate messages after reconnect: loadMissedMessages (HTTP) and the browser SSE auto-reconnect (Last-Event-ID) could both deliver the same messages. Fixed with an existing-ID Set filter in loadMissedMessages and a same-ID guard in the SSE new-message case. 3. Scroll anchor in loadMissedMessages: adding many messages at the visual bottom could shift the viewport. Fixed by capturing the topmost visible element's bounding rect before the unshift and calling window.scrollBy to compensate the delta. 4. thereNewMessages badge shown unnecessarily: if the user was already at the bottom when missed messages arrived the badge now stays hidden and the view scrolls down automatically instead. 5. SSE new-message thereNewMessages: only shows badge when user is not at bottom (consistent with loadMissedMessages behaviour). https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../channel/chat/chat.component.html | 3 +- .../channel/chat/chat.component.scss | 6 ++ .../components/channel/chat/chat.component.ts | 79 +++++++++++++++---- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 8d3febf..8e78ae4 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -6,7 +6,8 @@ }
+ (bottomThreshold)="loadMessages({ scrollDown: true })" [listenWindowScroll]="true" + [class.chat-hidden]="!isVisible"> @for (msg of scheduledMessages; track $index) { diff --git a/frontend/src/app/components/channel/chat/chat.component.scss b/frontend/src/app/components/channel/chat/chat.component.scss index 4a36ae1..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,9 @@ +// 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; diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 845eb0b..9f4e9b4 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -63,6 +63,7 @@ export class ChatComponent implements OnInit, OnDestroy { 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; @@ -168,17 +169,26 @@ export class ChatComponent implements OnInit, OnDestroy { 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()); }); } @@ -211,9 +221,10 @@ export class ChatComponent implements OnInit, OnDestroy { 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?.channelRoles && Object.keys(this.userInfo.channelRoles).length > 0 && this.scheduledMessages && message.message.author === "Scheduled") { this.loadScheduledMessages(true); @@ -291,19 +302,59 @@ export class ChatComponent implements OnInit, OnDestroy { 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) { - this.zone.run(() => { - this.messages.unshift(...missed.reverse()); - this.hasNewMessages = missed.length >= this.limit; + 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; + + // Anchor the current scroll position so adding messages at the + // visual bottom (newest) does not shift the viewport. + const anchorEl = this.getScrollAnchorElement(); + const anchorTop = anchorEl?.getBoundingClientRect().top ?? 0; + + // fresh is in ascending order; reverse to prepend newest-first + this.messages.unshift(...[...fresh].reverse()); + this.hasNewMessages = missed.length >= this.limit; + this.rebuildItems(); + + // Restore position relative to anchor so the user's view doesn't jump. + if (anchorEl) { + const delta = anchorEl.getBoundingClientRect().top - anchorTop; + window.scrollBy({ top: delta, behavior: 'instant' }); + } + + // Only flag badge if user is not already at the bottom. + if (this.isAtBottom()) { + this.thereNewMessages = false; + this.scrollToBottom(false); + } else { this.thereNewMessages = true; - this.rebuildItems(); - }); - } + } + }); } 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; From 66006b84de433c4818740565ca0e48ac8ed2077e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 06:59:54 +0000 Subject: [PATCH 35/60] Fix three regressions: channel switch stale state, empty-messages crash, scroll anchor jump - channel.component.ts: add await Promise.resolve() between slugReady=false and slugReady=true so Angular CD actually destroys ChatComponent on channel switch; without it the same-tick toggle is collapsed and child state (isVisible, messages, lastReadMessageId) persists across slugs - chat.component.ts: guard Math.max() with messages.length check to avoid -Infinity startId when loadMessages() is called with an empty array - chat.component.ts: remove manual scroll-anchor/delta compensation in loadMissedMessages(); the browser's overflow-anchor CSS handles viewport preservation automatically for flex-column-reverse, and the manual scrollBy could cause visible jumps https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../components/channel/channel.component.ts | 5 +++++ .../components/channel/chat/chat.component.ts | 21 +++++++------------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index b859c52..d8198a0 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -83,6 +83,11 @@ export class ChannelComponent implements OnInit, OnDestroy { 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(); diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 9f4e9b4..2220403 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -310,23 +310,16 @@ export class ChatComponent implements OnInit, OnDestroy { const fresh = missed.filter(m => !existing.has(m.id)); if (!fresh.length) return; - // Anchor the current scroll position so adding messages at the - // visual bottom (newest) does not shift the viewport. - const anchorEl = this.getScrollAnchorElement(); - const anchorTop = anchorEl?.getBoundingClientRect().top ?? 0; - - // fresh is in ascending order; reverse to prepend newest-first + // 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(); - // Restore position relative to anchor so the user's view doesn't jump. - if (anchorEl) { - const delta = anchorEl.getBoundingClientRect().top - anchorTop; - window.scrollBy({ top: delta, behavior: 'instant' }); - } - - // Only flag badge if user is not already at the bottom. + // 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); @@ -390,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; From 541c999cf666ec452cabad49c485478b09bc3a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 07:15:29 +0000 Subject: [PATCH 36/60] Fix 9 bugs across channel management, admin panel, and backend reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - admin.service: replace PrivilegeUser (old boolean flags) with ChannelUser {email, role} to match the backend's actual channel-user API shape; fix setChannelUsers payload key from { list } to { users } (backend was silently ignoring all updates) - privileg-dashboard: rewrite to use role selector (moderator/writer) instead of admin/moderator/writer checkboxes; fix isOwner guard using channelRoles instead of privileges (buttons were permanently disabled); fix nullUser reference bug (spread into new object instead of aliasing) - channel-header: store contextMenuService subscription and unsubscribe in ngOnDestroy — each channel switch was adding another persistent listener; fix logout() double-navigation where router.navigate(['/']) ran after the 401 catch already navigated to /login - channel.component: call magnetAds.clearCache() on channel switch so the next channel loads its own ad settings - magnet-ads.service: add clearCache() method Backend: - auth.go: check idtoken.NewValidator error before using the validator — a nil validator would panic on the next line - privileges.go / channels.go / main.go: convert initializePrivilegeUsers() from void+panic to error return; request handlers now log and continue instead of crashing the server on a transient Redis error; startup still panics as before https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/auth.go | 5 + backend/channels.go | 13 ++- backend/main.go | 4 +- backend/privileges.go | 16 ++- .../privileg-dashboard.component.html | 98 ++++++------------- .../privileg-dashboard.component.ts | 76 +++++++------- .../channel-header.component.ts | 20 ++-- .../components/channel/channel.component.ts | 3 + frontend/src/app/services/admin.service.ts | 15 ++- .../src/app/services/magnet-ads.service.ts | 5 + 10 files changed, 121 insertions(+), 134 deletions(-) diff --git a/backend/auth.go b/backend/auth.go index c3edfa6..6fe11e3 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -102,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) diff --git a/backend/channels.go b/backend/channels.go index b92b8d4..94bf20c 100644 --- a/backend/channels.go +++ b/backend/channels.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "log" "net/http" "regexp" "time" @@ -157,7 +158,9 @@ func createChannel(w http.ResponseWriter, r *http.Request) { if req.OwnerEmail != "" { dbAssignChannelRole(ctx, req.OwnerEmail, req.Slug, RoleOwner) - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after createChannel: %v", err) + } } w.Header().Set("Content-Type", "application/json") @@ -271,7 +274,9 @@ func superAdminSetChannelUsers(w http.ResponseWriter, r *http.Request) { http.Error(w, "error", http.StatusInternalServerError) return } - initializePrivilegeUsers() + 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}) @@ -394,7 +399,9 @@ func setChannelUsers(w http.ResponseWriter, r *http.Request) { http.Error(w, "error", http.StatusInternalServerError) return } - initializePrivilegeUsers() + 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/main.go b/backend/main.go index f4d0258..22686a2 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,7 +19,9 @@ var rootStaticFolder = os.Getenv("ROOT_STATIC_FOLDER") func main() { gob.Register(Session{}) initR2() - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + panic(err) + } go statLogger() var err error diff --git a/backend/privileges.go b/backend/privileges.go index 4d50e85..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" @@ -35,7 +36,11 @@ var channelRoleLevels = map[ChannelRole]int{ var privilegesUsers sync.Map var superAdminEmails []string -func initializePrivilegeUsers() { +// 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) @@ -44,7 +49,7 @@ func initializePrivilegeUsers() { privilegesUsers.Clear() users, err := dbGetUsersList(ctx) if err != nil && err != redis.Nil { - panic("Failed to get users list: " + err.Error()) + return fmt.Errorf("get users list: %w", err) } emailToIdx := make(map[string]int) @@ -72,8 +77,9 @@ func initializePrivilegeUsers() { } if err := dbSetUsersList(ctx, users); err != nil { - panic("Failed to set users list: " + err.Error()) + return fmt.Errorf("set users list: %w", err) } + return nil } func isSuperAdmin(r *http.Request) bool { @@ -158,7 +164,9 @@ func setPrivilegeUsers(w http.ResponseWriter, r *http.Request) { return } - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after setPrivilegeUsers: %v", err) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(Response{Success: true}) 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/channel/channel-header/channel-header.component.ts b/frontend/src/app/components/channel/channel-header/channel-header.component.ts index 665286b..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,7 +31,8 @@ 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) { @@ -85,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) { @@ -104,23 +106,23 @@ export class ChannelHeaderComponent implements OnInit { 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.ts b/frontend/src/app/components/channel/channel.component.ts index d8198a0..aaa8e92 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -19,6 +19,7 @@ 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 { Subscription } from 'rxjs'; @Component({ @@ -59,6 +60,7 @@ export class ChannelComponent implements OnInit, OnDestroy { private slugService: SlugService, private adminService: AdminService, private chatService: ChatService, + private magnetAds: MagnetAdsService, ) { } ad: Ad = { src: '', width: 0 }; @@ -104,6 +106,7 @@ export class ChannelComponent implements OnInit, OnDestroy { this.slugService.slug = slug; this.chatService.channelInfo = undefined; this.adminService.clearCache(); + this.magnetAds.clearCache(); this.slugReady = true; this.adsService.getAds().then(ad => { diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts index 4a434ae..30db514 100644 --- a/frontend/src/app/services/admin.service.ts +++ b/frontend/src/app/services/admin.service.ts @@ -8,12 +8,9 @@ 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 = { @@ -85,12 +82,12 @@ export class AdminService { }); } - getPrivilegeUsersList(): Promise { - return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/users/get`)); + getChannelUsers(): Promise { + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/users/get`)); } - setPrivilegeUsers(privilegeUsers: PrivilegeUser[]): Promise { - return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/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) { diff --git a/frontend/src/app/services/magnet-ads.service.ts b/frontend/src/app/services/magnet-ads.service.ts index 34d7a2a..40c031c 100644 --- a/frontend/src/app/services/magnet-ads.service.ts +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -19,6 +19,11 @@ export class MagnetAdsService { 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; From 6698a9eb7da0b36aa13ffd1005d7355c92f553f1 Mon Sep 17 00:00:00 2001 From: 27180781 <37129224+27180781@users.noreply.github.com> Date: Mon, 18 May 2026 12:17:35 +0300 Subject: [PATCH 37/60] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9e0a4bb..b089d5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ COPY ./frontend . RUN npm install \ && npm run build -FROM golang:1.24 AS builder2 +FROM golang:1.25 AS builder2 WORKDIR /app From 3d23f2c8b4c93909e5d3ae9decde08600bd350aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:36:35 +0000 Subject: [PATCH 38/60] Fix Docker build: upgrade to Go 1.25, add dep caching, remove go mod tidy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golang:1.24 → golang:1.25 (transitive dep now requires >=1.25) - Remove go mod tidy from Dockerfile — go.sum is committed and correct, running tidy at build time downloads everything unnecessarily - Split backend COPY into two steps: go.mod/go.sum first + go mod download, then source files. Docker caches the download layer when only .go files change, so repeated builds skip the multi-minute dependency download https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9e0a4bb..99f7ce6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,13 +5,16 @@ 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 From 696959c5d4034ea06850063e81a356866741e59b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:39:36 +0000 Subject: [PATCH 39/60] chore: normalize Dockerfile AS keyword casing https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 99f7ce6..24f9d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20 as builder1 +FROM node:20 AS builder1 WORKDIR /app COPY ./frontend . From 83f49be7b8ae46fddf866160aa23c5c5cd8d982e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 05:10:00 +0000 Subject: [PATCH 40/60] Fix per-channel URL routing, OAuth returnUrl, and all API slug paths - Add SlugService: central store for the active channel slug - Add /channel/:slug route; ChannelComponent resolves slug from URL param or redirects to /channel/ from user's channelRoles - AuthGuard saves state.url to localStorage before redirecting to /login - LoginComponent: load userInfo after OAuth callback, restore returnUrl on redirect; super_admin goes to /super-admin, others go to / - Fix all frontend API calls to include /channel/{slug}/: chat.service: info, messages, events, emojis, reactions, report admin.service: new, edit, delete, upload, users, settings, emojis, reports, scheduled-messages, statistics ads.service: ads/settings notifications.service: notifications-config, notifications-subscribe magnet-ads.service: ads/magnet - Fix ChannelComponent to guard child render behind slugReady flag - Fix channel-header: show admin menu via channelRoles instead of old privileges field - Fix chat.component: scheduled messages + delete visibility use channelRoles instead of legacy privileges - Fix magnet-ads.component: stats call uses correct /super-admin route - AdminService.clearCache() resets schedulingMessages on channel switch https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- frontend/src/app/app.routes.ts | 5 ++ .../admin/magnet-ads/magnet-ads.component.ts | 2 +- .../channel-header.component.ts | 2 +- .../components/channel/channel.component.html | 38 +++++++------- .../components/channel/channel.component.ts | 33 +++++++++++-- .../components/channel/chat/chat.component.ts | 6 +-- .../app/components/login/login.component.ts | 49 ++++++++++++------- frontend/src/app/services/admin.service.ts | 38 ++++++++------ frontend/src/app/services/ads.service.ts | 6 ++- frontend/src/app/services/chat-guard.guard.ts | 1 + frontend/src/app/services/chat.service.ts | 21 ++++---- .../src/app/services/magnet-ads.service.ts | 5 +- .../src/app/services/notifications.service.ts | 6 ++- frontend/src/app/services/slug.service.ts | 6 +++ 14 files changed, 144 insertions(+), 74 deletions(-) create mode 100644 frontend/src/app/services/slug.service.ts diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 16a1b60..6d4f9e6 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -14,6 +14,11 @@ export const routes: Routes = [ component: ChannelComponent, canActivate: [AuthGuard], }, + { + path: 'channel/:slug', + component: ChannelComponent, + canActivate: [AuthGuard], + }, { path: 'super-admin', component: SuperAdminPanelComponent, 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 index e0de604..07b24b1 100644 --- a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -165,7 +165,7 @@ export class MagnetAdsComponent implements OnInit { this.statsError = ''; try { - const res = await fetch('/api/admin/magnet/stats', { credentials: 'include' }); + 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 {} 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 66d6183..1bf89bd 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 @@ -36,7 +36,7 @@ export class ChannelHeaderComponent implements OnInit { 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', }] : []), diff --git a/frontend/src/app/components/channel/channel.component.html b/frontend/src/app/components/channel/channel.component.html index a6adcf1..7cad061 100644 --- a/frontend/src/app/components/channel/channel.component.html +++ b/frontend/src/app/components/channel/channel.component.html @@ -1,21 +1,23 @@ - - - - +@if (slugReady) { + + + + - - - - - @if (ad) { - - + + - } - @if (userInfo?.globalRole === 'super_admin' || userInfo?.privileges?.['writer'] || hasAnyRole(userInfo)) { - - - - } - + @if (ad) { + + + + } + + @if (userInfo?.globalRole === 'super_admin' || hasAnyRole(userInfo)) { + + + + } + +} diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index ed4dd14..40ab4b5 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -15,6 +15,10 @@ 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'; @Component({ selector: 'app-channel', @@ -48,18 +52,41 @@ 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, ) { } ad: Ad = { src: '', width: 0 }; userInfo?: User; + slugReady = false; + + async ngOnInit(): Promise { + let slug = this.route.snapshot.paramMap.get('slug'); + + 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 }); + } + return; + } + + this.slugService.slug = slug; + this.chatService.channelInfo = undefined; + this.adminService.clearCache(); + this.slugReady = true; - ngOnInit(): void { this.adsService.getAds().then(ad => { this.ad = ad; }); this._authService.loadUserInfo().then(res => { - this.userInfo = res + this.userInfo = res; }); } diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 32f391b..2a28dbb 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -145,7 +145,7 @@ export class ChatComponent implements OnInit, OnDestroy { 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(); }); @@ -183,13 +183,13 @@ export class ChatComponent implements OnInit, OnDestroy { this.messages.unshift(message.message); this.thereNewMessages = !(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) { diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts index 90bd776..c7795a4 100644 --- a/frontend/src/app/components/login/login.component.ts +++ b/frontend/src/app/components/login/login.component.ts @@ -27,36 +27,47 @@ export class LoginComponent implements OnInit { try { await this._authService.loadUserInfo(); if (this._authService.userInfo) { + 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(async () => { - await this._authService.loadUserInfo(); - this.redirectAfterLogin(); - }).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']); - } else { - this.router.navigate(['/channel']); + return; } + + const returnUrl = localStorage.getItem('returnUrl'); + localStorage.removeItem('returnUrl'); + if (returnUrl && !returnUrl.startsWith('/login')) { + this.router.navigateByUrl(returnUrl); + return; + } + + this.router.navigate(['/']); } login() { diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts index 34aa77d..4a434ae 100644 --- a/frontend/src/app/services/admin.service.ts +++ b/frontend/src/app/services/admin.service.ts @@ -6,6 +6,7 @@ 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; @@ -35,8 +36,15 @@ export class AdminService { constructor( private http: HttpClient, + private slugService: SlugService, ) { } + private get slug() { return this.slugService.slug; } + + clearCache() { + this.schedulingMessages = null; + } + reloadSchedulingMessage() { this.schedulingBus.next(); } @@ -50,27 +58,27 @@ 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' @@ -78,27 +86,27 @@ export class AdminService { } getPrivilegeUsersList(): Promise { - return firstValueFrom(this.http.get('/api/admin/privilegs-users/get-list')); + 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 })); + return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/users/set`, { list: privilegeUsers })); } 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 +114,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 +123,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 +148,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/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 index 8fabdc2..34d7a2a 100644 --- a/frontend/src/app/services/magnet-ads.service.ts +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { ChatMessage } from './chat.service'; +import { SlugService } from './slug.service'; export interface MagnetSettings { enabled: boolean; @@ -16,11 +17,13 @@ export class MagnetAdsService { private settings: MagnetSettings | null = null; private settingsPromise: Promise | null = null; + constructor(private slugService: SlugService) {} + loadSettings(force = false): Promise { if (this.settings && !force) return Promise.resolve(this.settings); if (this.settingsPromise && !force) return this.settingsPromise; - this.settingsPromise = fetch('/api/ads/magnet') + this.settingsPromise = fetch(`/api/channel/${this.slugService.slug}/ads/magnet`) .then(r => r.ok ? r.json() : null) .then((data: MagnetSettings | null) => { this.settings = data; 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 = ''; +} From 8ff774e14dc38af4b8d8661b266363883269c29b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 05:23:06 +0000 Subject: [PATCH 41/60] Fix three logic bugs found in flow review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Channel param subscription: replace snapshot with paramMap.subscribe so navigating /channel/foo → /channel/bar re-initializes the component without destroying it (Angular reuses the instance for same-route nav). Also implement OnDestroy to unsubscribe and avoid memory leaks. 2. No-channel state: when the user has no channelRoles the component now sets noChannel=true and renders a Hebrew error message instead of a blank screen. 3. Login checkUserInfo timing: remove the try/finally pattern that reset checkUserInfo synchronously before the async login().then() chain completed. Now the flag is managed explicitly at each code path so the loading indicator reflects actual in-flight state. https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../components/channel/channel.component.html | 6 +++++ .../components/channel/channel.component.ts | 27 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/channel/channel.component.html b/frontend/src/app/components/channel/channel.component.html index 7cad061..033251b 100644 --- a/frontend/src/app/components/channel/channel.component.html +++ b/frontend/src/app/components/channel/channel.component.html @@ -1,3 +1,9 @@ +@if (noChannel) { +
+ אין ערוץ מוגדר עבור חשבון זה. פנה למנהל המערכת. +
+} + @if (slugReady) { diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index 40ab4b5..b859c52 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; import { AdvertisingComponent } from "./advertising/advertising.component"; import { Ad, AdsService } from '../../services/ads.service'; @@ -19,6 +19,7 @@ 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 { Subscription } from 'rxjs'; @Component({ selector: 'app-channel', @@ -37,7 +38,7 @@ import { ChatService } from '../../services/chat.service'; 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) { @@ -63,9 +64,25 @@ export class ChannelComponent implements OnInit { ad: Ad = { src: '', width: 0 }; userInfo?: User; slugReady = false; + noChannel = false; + private paramSub?: Subscription; - async ngOnInit(): Promise { - let slug = this.route.snapshot.paramMap.get('slug'); + ngOnInit(): void { + // Subscribe to paramMap so navigation between /channel/foo → /channel/bar + // triggers a full re-initialization without needing to destroy the component. + 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; if (!slug) { const user = await this._authService.loadUserInfo(); @@ -73,6 +90,8 @@ export class ChannelComponent implements OnInit { const firstSlug = roles ? Object.keys(roles)[0] : ''; if (firstSlug) { this.router.navigate(['/channel', firstSlug], { replaceUrl: true }); + } else { + this.noChannel = true; } return; } From 680ebaa4f25b47a9e9b10d3a7c2d6c9c9ac43a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 06:31:13 +0000 Subject: [PATCH 42/60] Fix offline handling: banner, reconnect, missed-message sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problems fixed: 1. No user feedback on disconnect — added a sticky orange Hebrew banner "אין חיבור לאינטרנט" that appears immediately on window:offline and disappears on window:online. 2. keepAlive forced-reconnect lost messages — the old code created a new EventSource (no Last-Event-ID) after 60 s; missed messages were gone. Now: (a) threshold reduced 60 s → 35 s (backend heartbeat is 25 s so one missed beat triggers reconnect), (b) loadMissedMessages() is called after every forced reconnect to HTTP-fetch messages newer than maxId. 3. SSE onopen now detects reconnect (sseEverConnected flag) and calls loadMissedMessages() so the browser's natural auto-reconnect also syncs the gap, not just the keepAlive path. 4. window:online listener: hides banner, shows success toast "החיבור חודש", re-initialises SSE, and calls loadMissedMessages(). 5. window:offline listener: shows the banner immediately without waiting for the 35 s heartbeat timeout. 6. onerror handler cleaned up — browser handles SSE retry automatically with Last-Event-ID; logging the raw error was the only previous action. https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../channel/chat/chat.component.html | 7 +++ .../channel/chat/chat.component.scss | 16 +++++ .../components/channel/chat/chat.component.ts | 60 +++++++++++++++++-- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 4024d1c..8d3febf 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -1,3 +1,10 @@ +@if (isOffline) { +
+ + אין חיבור לאינטרנט — ההודעות יתעדכנו אוטומטית עם חזרת החיבור +
+} +
diff --git a/frontend/src/app/components/channel/chat/chat.component.scss b/frontend/src/app/components/channel/chat/chat.component.scss index 0fb83bb..4a36ae1 100644 --- a/frontend/src/app/components/channel/chat/chat.component.scss +++ b/frontend/src/app/components/channel/chat/chat.component.scss @@ -1,3 +1,19 @@ +.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 2a28dbb..845eb0b 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -55,12 +55,14 @@ type ScrollOpt = { }) 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; offset: number = 0; limit: number = 20; hasOldMessages: boolean = true; @@ -87,6 +89,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(); @@ -169,10 +186,25 @@ 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); @@ -243,15 +275,35 @@ export class ChatComponent implements OnInit, OnDestroy { 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) { + this.zone.run(() => { + this.messages.unshift(...missed.reverse()); + this.hasNewMessages = missed.length >= this.limit; + this.thereNewMessages = true; + this.rebuildItems(); + }); + } + } catch { + // Best-effort; will retry on the next reconnect + } + } + onListScroll() { const distanceFromBottom = document.documentElement.scrollHeight - window.innerHeight - window.scrollY; this.showScrollToBottom = distanceFromBottom > 100; From e4f66ff43b883f8bc6c036ce55a5c606cf01267b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 06:40:30 +0000 Subject: [PATCH 43/60] Fix scroll jumps and message duplicates on reconnect Problems fixed: 1. Initial-load flash-then-jump: the list was visible during the 200ms between first render (showing newest messages) and scrollToId (jumping to last-unread). Fixed with isVisible / .chat-hidden: content stays invisible until scroll position is resolved, then appears already in the right place. setLastReadMessage is now called AFTER the jump so a page refresh still brings the user back to the unread position. 2. Duplicate messages after reconnect: loadMissedMessages (HTTP) and the browser SSE auto-reconnect (Last-Event-ID) could both deliver the same messages. Fixed with an existing-ID Set filter in loadMissedMessages and a same-ID guard in the SSE new-message case. 3. Scroll anchor in loadMissedMessages: adding many messages at the visual bottom could shift the viewport. Fixed by capturing the topmost visible element's bounding rect before the unshift and calling window.scrollBy to compensate the delta. 4. thereNewMessages badge shown unnecessarily: if the user was already at the bottom when missed messages arrived the badge now stays hidden and the view scrolls down automatically instead. 5. SSE new-message thereNewMessages: only shows badge when user is not at bottom (consistent with loadMissedMessages behaviour). https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../channel/chat/chat.component.html | 3 +- .../channel/chat/chat.component.scss | 6 ++ .../components/channel/chat/chat.component.ts | 79 +++++++++++++++---- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 8d3febf..8e78ae4 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -6,7 +6,8 @@ }
+ (bottomThreshold)="loadMessages({ scrollDown: true })" [listenWindowScroll]="true" + [class.chat-hidden]="!isVisible"> @for (msg of scheduledMessages; track $index) { diff --git a/frontend/src/app/components/channel/chat/chat.component.scss b/frontend/src/app/components/channel/chat/chat.component.scss index 4a36ae1..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,9 @@ +// 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; diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 845eb0b..9f4e9b4 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -63,6 +63,7 @@ export class ChatComponent implements OnInit, OnDestroy { 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; @@ -168,17 +169,26 @@ export class ChatComponent implements OnInit, OnDestroy { 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()); }); } @@ -211,9 +221,10 @@ export class ChatComponent implements OnInit, OnDestroy { 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?.channelRoles && Object.keys(this.userInfo.channelRoles).length > 0 && this.scheduledMessages && message.message.author === "Scheduled") { this.loadScheduledMessages(true); @@ -291,19 +302,59 @@ export class ChatComponent implements OnInit, OnDestroy { 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) { - this.zone.run(() => { - this.messages.unshift(...missed.reverse()); - this.hasNewMessages = missed.length >= this.limit; + 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; + + // Anchor the current scroll position so adding messages at the + // visual bottom (newest) does not shift the viewport. + const anchorEl = this.getScrollAnchorElement(); + const anchorTop = anchorEl?.getBoundingClientRect().top ?? 0; + + // fresh is in ascending order; reverse to prepend newest-first + this.messages.unshift(...[...fresh].reverse()); + this.hasNewMessages = missed.length >= this.limit; + this.rebuildItems(); + + // Restore position relative to anchor so the user's view doesn't jump. + if (anchorEl) { + const delta = anchorEl.getBoundingClientRect().top - anchorTop; + window.scrollBy({ top: delta, behavior: 'instant' }); + } + + // Only flag badge if user is not already at the bottom. + if (this.isAtBottom()) { + this.thereNewMessages = false; + this.scrollToBottom(false); + } else { this.thereNewMessages = true; - this.rebuildItems(); - }); - } + } + }); } 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; From 4f13ea7d78379065246a64fea9d529ec4a6227e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 06:59:54 +0000 Subject: [PATCH 44/60] Fix three regressions: channel switch stale state, empty-messages crash, scroll anchor jump - channel.component.ts: add await Promise.resolve() between slugReady=false and slugReady=true so Angular CD actually destroys ChatComponent on channel switch; without it the same-tick toggle is collapsed and child state (isVisible, messages, lastReadMessageId) persists across slugs - chat.component.ts: guard Math.max() with messages.length check to avoid -Infinity startId when loadMessages() is called with an empty array - chat.component.ts: remove manual scroll-anchor/delta compensation in loadMissedMessages(); the browser's overflow-anchor CSS handles viewport preservation automatically for flex-column-reverse, and the manual scrollBy could cause visible jumps https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../components/channel/channel.component.ts | 5 +++++ .../components/channel/chat/chat.component.ts | 21 +++++++------------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index b859c52..d8198a0 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -83,6 +83,11 @@ export class ChannelComponent implements OnInit, OnDestroy { 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(); diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 9f4e9b4..2220403 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -310,23 +310,16 @@ export class ChatComponent implements OnInit, OnDestroy { const fresh = missed.filter(m => !existing.has(m.id)); if (!fresh.length) return; - // Anchor the current scroll position so adding messages at the - // visual bottom (newest) does not shift the viewport. - const anchorEl = this.getScrollAnchorElement(); - const anchorTop = anchorEl?.getBoundingClientRect().top ?? 0; - - // fresh is in ascending order; reverse to prepend newest-first + // 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(); - // Restore position relative to anchor so the user's view doesn't jump. - if (anchorEl) { - const delta = anchorEl.getBoundingClientRect().top - anchorTop; - window.scrollBy({ top: delta, behavior: 'instant' }); - } - - // Only flag badge if user is not already at the bottom. + // 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); @@ -390,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; From 18ac6d3ec502af6873ebee451134ad495f9f1b57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 07:15:29 +0000 Subject: [PATCH 45/60] Fix 9 bugs across channel management, admin panel, and backend reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - admin.service: replace PrivilegeUser (old boolean flags) with ChannelUser {email, role} to match the backend's actual channel-user API shape; fix setChannelUsers payload key from { list } to { users } (backend was silently ignoring all updates) - privileg-dashboard: rewrite to use role selector (moderator/writer) instead of admin/moderator/writer checkboxes; fix isOwner guard using channelRoles instead of privileges (buttons were permanently disabled); fix nullUser reference bug (spread into new object instead of aliasing) - channel-header: store contextMenuService subscription and unsubscribe in ngOnDestroy — each channel switch was adding another persistent listener; fix logout() double-navigation where router.navigate(['/']) ran after the 401 catch already navigated to /login - channel.component: call magnetAds.clearCache() on channel switch so the next channel loads its own ad settings - magnet-ads.service: add clearCache() method Backend: - auth.go: check idtoken.NewValidator error before using the validator — a nil validator would panic on the next line - privileges.go / channels.go / main.go: convert initializePrivilegeUsers() from void+panic to error return; request handlers now log and continue instead of crashing the server on a transient Redis error; startup still panics as before https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/auth.go | 5 + backend/channels.go | 13 ++- backend/main.go | 4 +- backend/privileges.go | 16 ++- .../privileg-dashboard.component.html | 98 ++++++------------- .../privileg-dashboard.component.ts | 76 +++++++------- .../channel-header.component.ts | 22 ++++- .../components/channel/channel.component.ts | 3 + frontend/src/app/services/admin.service.ts | 15 ++- .../src/app/services/magnet-ads.service.ts | 5 + 10 files changed, 128 insertions(+), 129 deletions(-) diff --git a/backend/auth.go b/backend/auth.go index c3edfa6..6fe11e3 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -102,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) diff --git a/backend/channels.go b/backend/channels.go index b92b8d4..94bf20c 100644 --- a/backend/channels.go +++ b/backend/channels.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "log" "net/http" "regexp" "time" @@ -157,7 +158,9 @@ func createChannel(w http.ResponseWriter, r *http.Request) { if req.OwnerEmail != "" { dbAssignChannelRole(ctx, req.OwnerEmail, req.Slug, RoleOwner) - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after createChannel: %v", err) + } } w.Header().Set("Content-Type", "application/json") @@ -271,7 +274,9 @@ func superAdminSetChannelUsers(w http.ResponseWriter, r *http.Request) { http.Error(w, "error", http.StatusInternalServerError) return } - initializePrivilegeUsers() + 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}) @@ -394,7 +399,9 @@ func setChannelUsers(w http.ResponseWriter, r *http.Request) { http.Error(w, "error", http.StatusInternalServerError) return } - initializePrivilegeUsers() + 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/main.go b/backend/main.go index 9a25728..5f1b76f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,7 +19,9 @@ var rootStaticFolder = os.Getenv("ROOT_STATIC_FOLDER") func main() { gob.Register(Session{}) initR2() - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + panic(err) + } go statLogger() var err error diff --git a/backend/privileges.go b/backend/privileges.go index 4d50e85..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" @@ -35,7 +36,11 @@ var channelRoleLevels = map[ChannelRole]int{ var privilegesUsers sync.Map var superAdminEmails []string -func initializePrivilegeUsers() { +// 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) @@ -44,7 +49,7 @@ func initializePrivilegeUsers() { privilegesUsers.Clear() users, err := dbGetUsersList(ctx) if err != nil && err != redis.Nil { - panic("Failed to get users list: " + err.Error()) + return fmt.Errorf("get users list: %w", err) } emailToIdx := make(map[string]int) @@ -72,8 +77,9 @@ func initializePrivilegeUsers() { } if err := dbSetUsersList(ctx, users); err != nil { - panic("Failed to set users list: " + err.Error()) + return fmt.Errorf("set users list: %w", err) } + return nil } func isSuperAdmin(r *http.Request) bool { @@ -158,7 +164,9 @@ func setPrivilegeUsers(w http.ResponseWriter, r *http.Request) { return } - initializePrivilegeUsers() + if err := initializePrivilegeUsers(); err != nil { + log.Printf("initializePrivilegeUsers after setPrivilegeUsers: %v", err) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(Response{Success: true}) 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/channel/channel-header/channel-header.component.ts b/frontend/src/app/components/channel/channel-header/channel-header.component.ts index 1bf89bd..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,7 +31,8 @@ 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) { @@ -85,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) { @@ -104,11 +106,23 @@ export class ChannelHeaderComponent implements OnInit { this.updateScreenSize(); } + ngOnDestroy() { + this.menuSub?.unsubscribe(); + } + async logout() { if (await this._authService.logout()) { this.userInfo = undefined; this.userInfoChange.emit(undefined); - this.router.navigate(['/']); + 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']); + } + } } else { this.toastrService.danger("", "שגיאה בהתנתקות"); } diff --git a/frontend/src/app/components/channel/channel.component.ts b/frontend/src/app/components/channel/channel.component.ts index d8198a0..aaa8e92 100644 --- a/frontend/src/app/components/channel/channel.component.ts +++ b/frontend/src/app/components/channel/channel.component.ts @@ -19,6 +19,7 @@ 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 { Subscription } from 'rxjs'; @Component({ @@ -59,6 +60,7 @@ export class ChannelComponent implements OnInit, OnDestroy { private slugService: SlugService, private adminService: AdminService, private chatService: ChatService, + private magnetAds: MagnetAdsService, ) { } ad: Ad = { src: '', width: 0 }; @@ -104,6 +106,7 @@ export class ChannelComponent implements OnInit, OnDestroy { this.slugService.slug = slug; this.chatService.channelInfo = undefined; this.adminService.clearCache(); + this.magnetAds.clearCache(); this.slugReady = true; this.adsService.getAds().then(ad => { diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts index 4a434ae..30db514 100644 --- a/frontend/src/app/services/admin.service.ts +++ b/frontend/src/app/services/admin.service.ts @@ -8,12 +8,9 @@ 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 = { @@ -85,12 +82,12 @@ export class AdminService { }); } - getPrivilegeUsersList(): Promise { - return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/users/get`)); + getChannelUsers(): Promise { + return firstValueFrom(this.http.get(`/api/channel/${this.slug}/admin/users/get`)); } - setPrivilegeUsers(privilegeUsers: PrivilegeUser[]): Promise { - return firstValueFrom(this.http.post(`/api/channel/${this.slug}/admin/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) { diff --git a/frontend/src/app/services/magnet-ads.service.ts b/frontend/src/app/services/magnet-ads.service.ts index 34d7a2a..40c031c 100644 --- a/frontend/src/app/services/magnet-ads.service.ts +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -19,6 +19,11 @@ export class MagnetAdsService { 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; From b83526b92f7f9f58d5bf9ebf1d836b81dc48b9e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:36:35 +0000 Subject: [PATCH 46/60] Fix Docker build: upgrade to Go 1.25, add dep caching, remove go mod tidy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golang:1.24 → golang:1.25 (transitive dep now requires >=1.25) - Remove go mod tidy from Dockerfile — go.sum is committed and correct, running tidy at build time downloads everything unnecessarily - Split backend COPY into two steps: go.mod/go.sum first + go mod download, then source files. Docker caches the download layer when only .go files change, so repeated builds skip the multi-minute dependency download https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b089d5c..99f7ce6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,12 @@ 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 From f2adf81da24359c9f41ef832ff85fb903151ee34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:39:36 +0000 Subject: [PATCH 47/60] chore: normalize Dockerfile AS keyword casing https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 99f7ce6..24f9d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20 as builder1 +FROM node:20 AS builder1 WORKDIR /app COPY ./frontend . From 6a1f669eda37284a7371d80906d661fb4fc05da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 10:33:17 +0000 Subject: [PATCH 48/60] Fix duplicate /api/super-admin route causing startup panic Merge conflict left two r.Route("/api/super-admin") blocks; chi panics on duplicate path registration. Moved channel-request admin routes into the existing super-admin block. https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- backend/main.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/backend/main.go b/backend/main.go index 5f1b76f..0425438 100644 --- a/backend/main.go +++ b/backend/main.go @@ -78,20 +78,14 @@ func main() { r.Get("/channels/{slug}/storage", getSuperAdminChannelStorage) r.Put("/channels/{slug}/storage", setSuperAdminChannelStorage) r.Post("/statistics/reset", resetStatistics) - }) - - // Public: submit channel request (no auth required) - r.Post("/api/channel-request", submitChannelRequest) - - // Super admin: manage channel requests - r.Route("/api/super-admin", func(r chi.Router) { - r.Use(checkLogin) - r.Use(requireSuperAdmin) 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) From 8efa495646d26d858b0fe2099ad2708cbcc73005 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 11:48:31 +0000 Subject: [PATCH 49/60] Fix styling flash: suppress landing page until auth check completes After Google login, redirect to /channel directly instead of / to avoid rendering the landing page. Also suppress the landing page DOM until the auth check resolves so logged-in users navigating to / see nothing before the redirect fires. https://claude.ai/code/session_015yWNYaVjk7ozyguKLA8vvx --- .../src/app/components/landing/landing-page.component.ts | 5 +++++ frontend/src/app/components/login/login.component.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/landing/landing-page.component.ts b/frontend/src/app/components/landing/landing-page.component.ts index 8b828d3..43e33a5 100644 --- a/frontend/src/app/components/landing/landing-page.component.ts +++ b/frontend/src/app/components/landing/landing-page.component.ts @@ -26,6 +26,7 @@ import { ChannelRequestService } from '../../services/channel-request.service'; ], styleUrl: './landing-page.component.scss', template: ` + @if (!isChecking) {