├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── api.go ├── auth.go ├── channelInfo.go ├── db.go ├── files.go ├── go.mod ├── go.sum ├── main.go ├── messages.go └── webhook.go ├── docker-compose.yml ├── frontend ├── .dockerignore ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── proxy.conf.js ├── public │ └── favicon.ico ├── src │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── app.routes.ts │ │ ├── components │ │ │ ├── chat │ │ │ │ ├── channel-header │ │ │ │ │ ├── channel-header.component.html │ │ │ │ │ ├── channel-header.component.scss │ │ │ │ │ └── channel-header.component.ts │ │ │ │ ├── channel-info-form │ │ │ │ │ ├── channel-info-form.component.html │ │ │ │ │ ├── channel-info-form.component.scss │ │ │ │ │ ├── channel-info-form.component.spec.ts │ │ │ │ │ └── channel-info-form.component.ts │ │ │ │ ├── chat.component.html │ │ │ │ ├── chat.component.scss │ │ │ │ ├── chat.component.spec.ts │ │ │ │ ├── chat.component.ts │ │ │ │ ├── input-form │ │ │ │ │ ├── input-form.component.html │ │ │ │ │ ├── input-form.component.scss │ │ │ │ │ └── input-form.component.ts │ │ │ │ ├── markdown-help │ │ │ │ │ ├── markdown-help.component.html │ │ │ │ │ ├── markdown-help.component.scss │ │ │ │ │ └── markdown-help.component.ts │ │ │ │ ├── message │ │ │ │ │ ├── message.component.html │ │ │ │ │ ├── message.component.scss │ │ │ │ │ └── message.component.ts │ │ │ │ └── youtube-player │ │ │ │ │ ├── youtube-player.component.html │ │ │ │ │ ├── youtube-player.component.scss │ │ │ │ │ ├── youtube-player.component.spec.ts │ │ │ │ │ └── youtube-player.component.ts │ │ │ └── login │ │ │ │ ├── login.component.html │ │ │ │ ├── login.component.scss │ │ │ │ ├── login.component.spec.ts │ │ │ │ └── login.component.ts │ │ ├── markdown.config.ts │ │ ├── models │ │ │ └── channel.model.ts │ │ ├── pipes │ │ │ └── message-time.pipe.ts │ │ └── services │ │ │ ├── auth.service.ts │ │ │ ├── chat.service.ts │ │ │ └── login-guard.service.ts │ ├── index.html │ ├── main.ts │ └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── kvrocks.conf └── sample.env /Caddyfile: -------------------------------------------------------------------------------- 1 | * { 2 | reverse_proxy backend:3000 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 as build 2 | 3 | WORKDIR /app 4 | COPY ./frontend . 5 | RUN npm install 6 | RUN npm run build 7 | 8 | FROM golang:1.24 AS builder 9 | 10 | WORKDIR /app 11 | 12 | COPY ./backend . 13 | #COPY --from=build /app/dist/channel/browser assets 14 | RUN go mod tidy 15 | RUN go build -o the-channel . 16 | 17 | FROM debian:latest 18 | WORKDIR /app 19 | COPY --from=builder /app/the-channel . 20 | COPY --from=build /app/dist/channel/browser /usr/share/ng 21 | RUN chmod +x the-channel 22 | CMD ["./the-channel"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2025 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see https://www.gnu.org/licenses/. 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # הערוץ 2 | ![Go](https://img.shields.io/badge/Go-1.22-blue?style=flat-square&logo=go) 3 | ![Angular](https://img.shields.io/badge/Angular-DD0031?style=flat&logo=angular&logoColor=white) 4 | ![Caddy](https://img.shields.io/badge/Caddy-00BFB3?style=flat&logo=caddy&logoColor=white) 5 | ![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat&logo=docker&logoColor=white) 6 | [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) 7 | 8 | **פרויקט פשוט וקל להקמת ערוץ עדכונים** 9 | צד שרת מהיר וחזק כתוב ב Go, 10 | מסד נתונים תואם Redis, 11 | צד לקוח עם Angular, 12 | Caddy לניהול דומיין ויצירת תעודה עבור האתר. 13 | הפרויקט מופץ תחת רישיון GNU General Public License v3 (GPLv3). 14 | כל הזכויות שמורות. 15 | 16 | ## הוראות הרצה 17 | ניתן להוריד את הפרויקט עם: 18 | `git clone https://github.com/NetFree-Community/TheChannel` 19 | 20 | יש ליצור קובץ `.env` בהתאם לדוגמא בקובץ `sample.env`. 21 | הפרויקט מגיע עם **Caddy** מובנה. 22 | יש להוסיף את הדומיין בתוך `Caddyfile` כך: 23 | 24 | ```caddy 25 | example.com { 26 | reverse_proxy backend:3000 27 | } 28 | ``` 29 | 30 | מלבד הטיפול בבקשות והפניה ל Container המתאים, **Caddy** מטפל גם בהוספת תעודה לדומיין. 31 | כך שנותר רק להריץ `docker-compose up --build -d` וסיימנו! 32 | 33 | ## הוראות שימוש 34 | לאחר ההרצה הראשונה, יש להכנס למערכת ולהזדהות כמנהל. 35 | יש לגשת לקישור: 36 | https://example.com/login 37 | ולהזדהות באמצעות הפרטים שהגדרתם בקובץ env. 38 | יש להגדיר את שם הערוץ תיאור הערוץ ולהעלות לוגו, שמירה וניתן להתחיל לפרסם הודעות... 39 | 40 | ## יבוא הודעות 41 | ניתן לייבא הודעות באמצעות API כדי להוסיף תכנים מפלטפורמות חיצוניות, כולל אפשרות להגדיר תאריך יצירה מדויק (timestamp) עבור כל הודעה. 42 | 43 | ### כתובת: 44 | POST https://example.com/api/import/post 45 | 46 | ### כותרות (Headers): 47 | | שם הכותרת | ערך | 48 | |----------------|------------------------------------------| 49 | | Content-Type | application/json | 50 | | X-API-Key | *המפתח שהוגדר במשתנה הסביבה `API_SECRET_KEY`* | 51 | 52 | ### גוף הבקשה (Request Body): 53 | 54 | יש לשלוח אובייקט JSON במבנה הבא: 55 | 56 | ```json 57 | { 58 | "text": "Hello from another platform!", 59 | "author": "John Doe", 60 | "timestamp": "2025-04-06T12:34:56Z" 61 | } 62 | ``` 63 | 64 | ## וובהוק (Webhook) 65 | המערכת תומכת בשליחת וובהוק בעת יצירה, עדכון או מחיקה של הודעות. הוובהוק יישלח רק אם הוגדר URL לוובהוק במשתני הסביבה. 66 | 67 | ### הגדרת וובהוק 68 | כדי להפעיל את הוובהוק, יש להגדיר את משתני הסביבה הבאים בקובץ `.env`: 69 | 70 | ``` 71 | WEBHOOK_URL=https://example.com/webhook 72 | WEBHOOK_VERIFY_TOKEN=your-secret-token # Not required 73 | ``` 74 | 75 | ### מבנה הנתונים שנשלחים בוובהוק 76 | הוובהוק נשלח כבקשת POST עם תוכן JSON במבנה הבא: 77 | 78 | ```json 79 | { 80 | "action": "create", // "create", "update", או "delete" 81 | "message": { 82 | "id": 123, 83 | "type": "text", 84 | "text": "message content", 85 | "author": "username", 86 | "timestamp": "2025-04-10T18:30:00Z", 87 | "lastEdit": "2025-04-10T18:35:00Z", 88 | "deleted": false, 89 | "views": 5 90 | }, 91 | "timestamp": "2025-04-10T18:35:05Z", 92 | "verifyToken": "your-secret-token" // If defined 93 | } 94 | ``` 95 | 96 | ### אבטחה 97 | אם הגדרתם `WEBHOOK_VERIFY_TOKEN`, תוכלו להשתמש בו כדי לוודא שהבקשות מגיעות אכן מהמערכת שלכם. בדקו שהערך ב-`verifyToken` תואם לערך שהגדרתם. 98 | -------------------------------------------------------------------------------- /backend/api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "os" 9 | "time" 10 | ) 11 | 12 | var apiSecretKey = os.Getenv("API_SECRET_KEY") 13 | 14 | func addNewPost(w http.ResponseWriter, r *http.Request) { 15 | key := r.Header.Get("X-API-Key") 16 | if key != apiSecretKey { 17 | http.Error(w, "error", http.StatusBadRequest) 18 | return 19 | } 20 | 21 | var message Message 22 | var err error 23 | defer r.Body.Close() 24 | 25 | body := Message{} 26 | if err = json.NewDecoder(r.Body).Decode(&body); err != nil { 27 | log.Printf("Failed to decode message: %v\n", err) 28 | http.Error(w, "error", http.StatusBadRequest) 29 | return 30 | } 31 | 32 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 | defer cancel() 34 | 35 | message.ID = GetMessageNextId(ctx) 36 | message.Type = "md" //body.Type 37 | message.Author = body.Author 38 | message.Timestamp = body.Timestamp 39 | message.Text = body.Text 40 | message.Views = 0 41 | 42 | if err = SetMessage(ctx, message, false); err != nil { 43 | log.Printf("Failed to set new message: %v\n", err) 44 | http.Error(w, "error", http.StatusInternalServerError) 45 | return 46 | } 47 | 48 | w.Header().Set("Content-Type", "application/json") 49 | json.NewEncoder(w).Encode(message) 50 | } 51 | -------------------------------------------------------------------------------- /backend/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/boj/redistore" 9 | ) 10 | 11 | var secretKey string = os.Getenv("SECRET_KEY") 12 | var defaultUser string = os.Getenv("DEFAULT_USER") 13 | var defaultPassword string = os.Getenv("DEFAULT_PASSWORD") 14 | var defaultUserName string = os.Getenv("DEFAULT_USERNAME") 15 | var store = &redistore.RediStore{} 16 | var cookieName = "channel_session" 17 | 18 | type Auth struct { 19 | UserName string 20 | Password string 21 | } 22 | 23 | type Session struct { 24 | ID uint `json:"id"` 25 | Username string `json:"username"` 26 | IsAdmin bool `json:"isAdmin"` 27 | } 28 | 29 | type Response struct { 30 | Success bool `json:"success"` 31 | } 32 | 33 | func login(w http.ResponseWriter, r *http.Request) { 34 | var auth Auth 35 | 36 | if err := json.NewDecoder(r.Body).Decode(&auth); err != nil { 37 | http.Error(w, "error", http.StatusBadRequest) 38 | } 39 | 40 | if auth.UserName != defaultUser || auth.Password != defaultPassword { 41 | http.Error(w, "Invalid credentials", http.StatusUnauthorized) 42 | return 43 | } 44 | 45 | user := Session{ 46 | ID: 1, 47 | Username: defaultUserName, 48 | IsAdmin: true, 49 | } 50 | 51 | session, _ := store.Get(r, cookieName) 52 | session.Values["user"] = user 53 | if err := session.Save(r, w); err != nil { 54 | http.Error(w, "error", http.StatusInternalServerError) 55 | return 56 | } 57 | 58 | w.Header().Set("Content-Type", "application/json") 59 | 60 | response := Response{Success: true} 61 | json.NewEncoder(w).Encode(response) 62 | } 63 | 64 | func logout(w http.ResponseWriter, r *http.Request) { 65 | session, _ := store.Get(r, cookieName) 66 | 67 | session.Values["user"] = nil 68 | session.Options.MaxAge = -1 69 | err := session.Save(r, w) 70 | if err != nil { 71 | http.Error(w, "error", http.StatusInternalServerError) 72 | return 73 | } 74 | 75 | w.Header().Set("Content-Type", "application/json") 76 | response := Response{Success: true} 77 | json.NewEncoder(w).Encode(response) 78 | } 79 | 80 | func checkPrivilege(next http.Handler) http.Handler { 81 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | session, _ := store.Get(r, cookieName) 83 | 84 | _, ok := session.Values["user"].(Session) 85 | if !ok { 86 | http.Error(w, "User not authenticated", http.StatusUnauthorized) 87 | return 88 | } 89 | 90 | next.ServeHTTP(w, r) 91 | }) 92 | } 93 | 94 | func getUserInfo(w http.ResponseWriter, r *http.Request) { 95 | session, _ := store.Get(r, cookieName) 96 | userInfo, ok := session.Values["user"].(Session) 97 | if !ok { 98 | http.Error(w, "User not found", http.StatusNotFound) 99 | return 100 | } 101 | 102 | w.Header().Set("Content-Type", "application/json") 103 | json.NewEncoder(w).Encode(userInfo) 104 | } 105 | -------------------------------------------------------------------------------- /backend/channelInfo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "log" 8 | "net/http" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | type Channel struct { 14 | Id int `json:"id"` 15 | Name string `json:"name"` 16 | Description string `json:"description"` 17 | CreatedAt time.Time `json:"created_at"` 18 | LogoUrl string `json:"logoUrl"` 19 | Views int `json:"views"` 20 | } 21 | 22 | func getChannelInfo(w http.ResponseWriter, r *http.Request) { 23 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 24 | defer cancel() 25 | 26 | c, err := rdb.HGetAll(ctx, "channel:1").Result() 27 | if err != nil { 28 | http.Error(w, "error", http.StatusInternalServerError) 29 | return 30 | } 31 | 32 | var channel Channel 33 | channel.Id, _ = strconv.Atoi(c["id"]) 34 | channel.Name = c["name"] 35 | channel.Description = c["description"] 36 | channel.CreatedAt, _ = time.Parse(time.RFC3339, c["created_at"]) 37 | channel.Views, _ = strconv.Atoi(c["views"]) 38 | channel.LogoUrl = c["logoUrl"] 39 | 40 | w.Header().Set("Content-Type", "application/json") 41 | json.NewEncoder(w).Encode(channel) 42 | } 43 | 44 | func editChannelInfo(w http.ResponseWriter, r *http.Request) { 45 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 46 | defer cancel() 47 | 48 | type Request struct { 49 | Name string `json:"name"` 50 | Description string `json:"description"` 51 | LogoUrl string `json:"logoUrl"` 52 | } 53 | 54 | var req Request 55 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 56 | http.Error(w, "error", http.StatusInternalServerError) 57 | return 58 | } 59 | defer r.Body.Close() 60 | 61 | log.Println("Received request to edit channel info:", req) 62 | 63 | if _, err := rdb.HSet(ctx, "channel:1", "name", req.Name, "description", req.Description, "logoUrl", req.LogoUrl).Result(); err != nil { 64 | http.Error(w, "error", http.StatusInternalServerError) 65 | return 66 | } 67 | 68 | res := Response{Success: true} 69 | json.NewEncoder(w).Encode(res) 70 | } 71 | -------------------------------------------------------------------------------- /backend/db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/redis/go-redis/v9" 13 | ) 14 | 15 | var redisType = os.Getenv("REDIS_PROTOCOL") 16 | var redisAddr = os.Getenv("REDIS_ADDR") 17 | var redisPass = os.Getenv("REDIS_PASSWORD") 18 | var rdb *redis.Client 19 | 20 | type Message struct { 21 | ID int `json:"id" redis:"id"` 22 | Type string `json:"type" redis:"type"` 23 | Text string `json:"text" redis:"text"` 24 | Author string `json:"author" redis:"author"` 25 | Timestamp time.Time `json:"timestamp" redis:"timestamp"` 26 | LastEdit time.Time `json:"lastEdit" redis:"last_edit"` 27 | File FileResponse `json:"file" redis:"-"` 28 | Deleted bool `json:"deleted" redis:"deleted"` 29 | Views int `json:"views" redis:"views"` 30 | } 31 | 32 | type User struct { 33 | ID int `json:"id"` 34 | Username string `json:"username"` 35 | Password string `json:"-"` 36 | IsAdmin bool `json:"isAdmin"` 37 | } 38 | 39 | type PushMessage struct { 40 | Type string `json:"type"` 41 | M Message `json:"message"` 42 | } 43 | 44 | func init() { 45 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 46 | defer cancel() 47 | rdb = redis.NewClient(&redis.Options{ 48 | Network: redisType, 49 | Addr: redisAddr, 50 | Password: redisPass, 51 | DB: 0, 52 | }) 53 | 54 | _, err := rdb.Ping(ctx).Result() 55 | if err != nil { 56 | log.Fatalf("Connection to db failed: %v \n", err) 57 | } 58 | 59 | log.Println("Connection to DB successful!") 60 | } 61 | 62 | func GetMessageNextId(ctx context.Context) int { 63 | id, err := rdb.Incr(ctx, "message:next_id").Result() 64 | if err != nil { 65 | log.Fatalf("Failed to get id: %v\n", err) 66 | } 67 | 68 | return int(id) 69 | } 70 | 71 | func SetMessage(ctx context.Context, m Message, isUpdate bool) error { 72 | messageKey := fmt.Sprintf("messages:%d", m.ID) 73 | 74 | // Set message in hash 75 | if err := rdb.HSet(ctx, messageKey, m).Err(); err != nil { 76 | return err 77 | } 78 | 79 | // Add message timestamp to sorted set 80 | if !isUpdate { 81 | if err := rdb.ZAdd(ctx, "m_times:1", redis.Z{Score: float64(m.Timestamp.Unix()), Member: messageKey}).Err(); err != nil { 82 | return err 83 | } 84 | } 85 | 86 | pushType := "new-message" 87 | if isUpdate { 88 | pushType = "edit-message" 89 | } 90 | 91 | pushMessage := PushMessage{ 92 | Type: pushType, 93 | M: m, 94 | } 95 | 96 | pushMessageData, _ := json.Marshal(pushMessage) 97 | rdb.Publish(ctx, "events", pushMessageData) 98 | 99 | return nil 100 | } 101 | 102 | var getMessageRange = redis.NewScript(` 103 | local time_set_key = KEYS[1] 104 | local offset_key = KEYS[2] 105 | 106 | local required_length = tonumber(ARGV[1]) 107 | local isAdmin = ARGV[2] == 'true' 108 | 109 | local start_index = redis.call('ZREVRANK', time_set_key, offset_key) or 0 110 | if start_index > 0 then 111 | start_index = start_index + 1 112 | end 113 | 114 | local messages = {} 115 | repeat 116 | local batch_size = required_length - #messages 117 | local stop_index = start_index + batch_size 118 | local message_ids = redis.call('ZREVRANGE', time_set_key, start_index, stop_index) 119 | 120 | if #message_ids == 0 then 121 | break 122 | end 123 | 124 | for i, message_key in ipairs(message_ids) do 125 | local message_data = redis.call('HGETALL', message_key) 126 | local message = {} 127 | 128 | for j = 1, #message_data, 2 do 129 | local key = message_data[j] 130 | local value = message_data[j+1] 131 | 132 | if key == 'id' or key == 'views' then 133 | message[key] = tonumber(value) 134 | elseif key == 'deleted' then 135 | message[key] = value == '1' 136 | else 137 | message[key] = value 138 | end 139 | end 140 | 141 | if not message['deleted'] or isAdmin then 142 | table.insert(messages, message) 143 | end 144 | end 145 | 146 | start_index = start_index + batch_size 147 | 148 | until #messages >= required_length 149 | 150 | return cjson.encode(messages) 151 | `) 152 | 153 | func GetMessageRange(ctx context.Context, start, stop int64, isAdmin bool) ([]Message, error) { 154 | offsetKeyName := fmt.Sprintf("messages:%d", start) 155 | res, err := getMessageRange.Run(ctx, rdb, []string{"m_times:1", offsetKeyName}, []string{strconv.FormatInt(stop, 10), strconv.FormatBool(isAdmin)}).Result() 156 | if err != nil { 157 | return []Message{}, err 158 | } 159 | 160 | if res == "{}" { 161 | return []Message{}, nil 162 | } 163 | 164 | var messages []Message 165 | if err := json.Unmarshal([]byte(res.(string)), &messages); err != nil { 166 | return []Message{}, err 167 | } 168 | 169 | return messages, nil 170 | } 171 | 172 | func DeleteMessage(ctx context.Context, id string) error { 173 | msgKey := fmt.Sprintf("messages:%s", id) 174 | rdb.HSet(ctx, msgKey, "deleted", true) 175 | 176 | var m Message 177 | idInt, _ := strconv.Atoi(id) 178 | m.ID = idInt 179 | m.Deleted = true 180 | m.LastEdit = time.Now() 181 | m.Text = "*ההודעה נמחקה*" 182 | m.File = FileResponse{} 183 | 184 | pushMessage := PushMessage{ 185 | Type: "delete-message", 186 | M: m, 187 | } 188 | pushMessageData, _ := json.Marshal(pushMessage) 189 | rdb.Publish(ctx, "events", pushMessageData) 190 | 191 | return nil 192 | } 193 | 194 | func AddViewsToMessages(ctx context.Context, messages []Message) { 195 | for _, m := range messages { 196 | rdb.HIncrBy(ctx, fmt.Sprintf("messages:%d", m.ID), "views", 1) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /backend/files.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "encoding/json" 8 | "errors" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | 16 | "github.com/go-chi/chi" 17 | "github.com/h2non/filetype" 18 | "github.com/subosito/gozaru" 19 | "gopkg.in/yaml.v3" 20 | ) 21 | 22 | var rootUploadPath = "/app/files/" 23 | 24 | type FileResponse struct { 25 | URL string `json:"url"` 26 | Filename string `json:"filename"` 27 | FileType string `json:"filetype"` 28 | } 29 | 30 | var maxBytesReader *http.MaxBytesError 31 | var limitFileSize, _ = strconv.Atoi(os.Getenv("MAX_FILE_SIZE")) 32 | 33 | func serveFile(w http.ResponseWriter, r *http.Request) { 34 | fileId := chi.URLParam(r, "fileid") 35 | 36 | metadataFilePath := filepath.Join(rootUploadPath, fileId[:2], fileId[2:4], fileId+".yaml") 37 | metadataFile, err := os.ReadFile(metadataFilePath) 38 | if err != nil { 39 | http.Error(w, "File not found", http.StatusNotFound) 40 | return 41 | } 42 | 43 | var metaData map[string]any 44 | if err := yaml.Unmarshal(metadataFile, &metaData); err != nil { 45 | http.Error(w, "error", http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | if delete := metaData["delete"].(bool); delete { 50 | http.Error(w, "File not found", http.StatusNotFound) 51 | return 52 | } 53 | 54 | fileHash := metaData["hash"].(string) 55 | filePath := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4], fileHash) 56 | originalFileName := metaData["filename"].(string) 57 | 58 | w.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(originalFileName)) 59 | http.ServeFile(w, r, filePath) 60 | } 61 | 62 | func uploadFile(w http.ResponseWriter, r *http.Request) { 63 | r.Body = http.MaxBytesReader(w, r.Body, int64(limitFileSize)<<20) 64 | 65 | file, handler, err := r.FormFile("file") 66 | if err != nil { 67 | if errors.As(err, &maxBytesReader) { 68 | http.Error(w, "File too large", http.StatusRequestEntityTooLarge) 69 | return 70 | } 71 | http.Error(w, "error", http.StatusBadRequest) 72 | return 73 | } 74 | defer file.Close() 75 | 76 | if err := os.MkdirAll(rootUploadPath, os.ModePerm); err != nil { 77 | http.Error(w, "error", http.StatusInternalServerError) 78 | return 79 | } 80 | 81 | head := make([]byte, 512) 82 | file.Read(head) 83 | 84 | t, _ := filetype.Match(head) 85 | 86 | file.Seek(0, io.SeekStart) 87 | fileHash, err := generatedFileHash(file) 88 | if err != nil { 89 | http.Error(w, "error", http.StatusInternalServerError) 90 | return 91 | } 92 | 93 | hashSubDir := filepath.Join(rootUploadPath, fileHash[:2], fileHash[2:4]) 94 | if err := os.MkdirAll(hashSubDir, os.ModePerm); err != nil { 95 | http.Error(w, "error", http.StatusInternalServerError) 96 | return 97 | } 98 | 99 | var isDuplicateFile bool 100 | testISDuplicateFilePath := filepath.Join(hashSubDir, fileHash) 101 | _, err = os.Stat(testISDuplicateFilePath) 102 | if err == nil { //|| !os.IsNotExist(err) 103 | isDuplicateFile = true 104 | } 105 | 106 | if !isDuplicateFile { 107 | destPath := filepath.Join(hashSubDir, fileHash) 108 | 109 | file.Seek(0, io.SeekStart) 110 | destFile, err := os.Create(destPath) 111 | if err != nil { 112 | http.Error(w, "error", http.StatusInternalServerError) 113 | return 114 | } 115 | defer destFile.Close() 116 | 117 | if _, err := io.Copy(destFile, file); err != nil { 118 | http.Error(w, "error", http.StatusInternalServerError) 119 | return 120 | } 121 | } 122 | 123 | id := generatedRandomID(20) 124 | if id == "" { 125 | http.Error(w, "error", http.StatusInternalServerError) 126 | return 127 | } 128 | 129 | yamlFileDir := filepath.Join(rootUploadPath, id[:2], id[2:4]) 130 | if err := os.MkdirAll(yamlFileDir, os.ModePerm); err != nil { 131 | http.Error(w, "error", http.StatusInternalServerError) 132 | return 133 | } 134 | 135 | safeFilename := gozaru.Sanitize(handler.Filename) 136 | 137 | fileMetadata := map[string]any{ 138 | "id": id, 139 | "filename": safeFilename, 140 | "hash": fileHash, 141 | "type": t.MIME.Type, 142 | "delete": false, 143 | } 144 | metadataFilePath := filepath.Join(rootUploadPath, id[:2], id[2:4], id+".yaml") 145 | metadataFile, err := os.Create(metadataFilePath) 146 | if err != nil { 147 | http.Error(w, "error", http.StatusInternalServerError) 148 | return 149 | } 150 | defer metadataFile.Close() 151 | 152 | yamlData, err := yaml.Marshal(fileMetadata) 153 | if err != nil { 154 | http.Error(w, "error", http.StatusInternalServerError) 155 | return 156 | } 157 | metadataFile.Write(yamlData) 158 | 159 | fileUrl := "/api/files/" + id 160 | 161 | w.Header().Set("Content-Type", "application/json") 162 | json.NewEncoder(w).Encode(FileResponse{ 163 | URL: fileUrl, 164 | Filename: handler.Filename, 165 | FileType: t.MIME.Type, 166 | }) 167 | } 168 | 169 | func generatedFileHash(file io.Reader) (string, error) { 170 | hash := sha256.New() 171 | if _, err := io.Copy(hash, file); err != nil { 172 | return "", err 173 | } 174 | 175 | return hex.EncodeToString(hash.Sum(nil)), nil 176 | } 177 | 178 | func generatedRandomID(len int) string { 179 | b := make([]byte, len) 180 | _, err := rand.Read(b) 181 | if err != nil { 182 | return "" 183 | } 184 | 185 | return hex.EncodeToString(b) 186 | } 187 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module channel 2 | 3 | go 1.24 4 | 5 | require github.com/redis/go-redis/v9 v9.7.0 6 | 7 | require ( 8 | github.com/gomodule/redigo v1.9.2 // indirect 9 | github.com/gorilla/securecookie v1.1.2 // indirect 10 | github.com/gorilla/sessions v1.2.1 // indirect 11 | github.com/kr/pretty v0.1.0 // indirect 12 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 13 | ) 14 | 15 | require ( 16 | github.com/boj/redistore v1.4.0 17 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 19 | github.com/go-chi/chi v1.5.5 20 | github.com/h2non/filetype v1.1.3 21 | github.com/subosito/gozaru v0.0.0-20190625071150-416082cce636 22 | gopkg.in/yaml.v3 v3.0.1 23 | ) 24 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/boj/redistore v1.4.0 h1:PFn5Hcbmj22WGsMKGLaEn33In4+C0lX+txbAOpA3mPA= 2 | github.com/boj/redistore v1.4.0/go.mod h1:JeLqX+qEEBrjytalj9s+3a7o34lNmYncdPj8HeZRvYk= 3 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 4 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 5 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 6 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 7 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 8 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 13 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 14 | github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= 15 | github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= 16 | github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= 17 | github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= 18 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 19 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 20 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 21 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 22 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 23 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 24 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 25 | github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= 26 | github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 27 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 28 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 29 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 30 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 31 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= 35 | github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 38 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 39 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 40 | github.com/subosito/gozaru v0.0.0-20190625071150-416082cce636 h1:LlXBFcxziHIkc7jnbCmUCL5+ujGMky2aJsNvHqtt80Y= 41 | github.com/subosito/gozaru v0.0.0-20190625071150-416082cce636/go.mod h1:LIpwO1yApZNrEQZdu5REqRtRrkaU+52ueA7WGT+CvSw= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 44 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/gob" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/boj/redistore" 10 | "github.com/go-chi/chi" 11 | "github.com/go-chi/chi/middleware" 12 | ) 13 | 14 | var rootStaticFolder = os.Getenv("ROOT_STATIC_FOLDER") 15 | 16 | func main() { 17 | gob.Register(Session{}) 18 | 19 | var err error 20 | store, err = redistore.NewRediStore(10, redisType, redisAddr, "", redisPass, []byte(secretKey)) 21 | if err != nil { 22 | panic(err) 23 | } 24 | store.SetMaxAge(60 * 60 * 24) 25 | store.Options.HttpOnly = true 26 | defer store.Close() 27 | 28 | r := chi.NewRouter() 29 | r.Use(middleware.Logger) 30 | r.Route("/api", func(api chi.Router) { 31 | api.Get("/channel/info", getChannelInfo) 32 | api.Get("/messages", getMessages) 33 | api.Get("/events", getEvents) 34 | api.Post("/auth/login", login) 35 | api.Post("/auth/logout", logout) 36 | api.Get("/files/{fileid}", serveFile) 37 | 38 | api.Post("/import/post", addNewPost) 39 | 40 | api.Route("/auth", func(protected chi.Router) { 41 | protected.Use(checkPrivilege) 42 | protected.Get("/user-info", getUserInfo) 43 | protected.Post("/edit-channel-info", editChannelInfo) 44 | protected.Post("/new", addMessage) 45 | protected.Post("/edit-message", updateMessage) 46 | protected.Get("/delete-message/{id}", deleteMessage) 47 | protected.Post("/upload", uploadFile) 48 | }) 49 | }) 50 | 51 | if rootStaticFolder != "" { 52 | r.Handle("/", http.FileServer(http.Dir("/usr/share/ng"))) 53 | r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.Dir("/usr/share/ng")))) 54 | r.NotFound(func(w http.ResponseWriter, r *http.Request) { 55 | http.ServeFile(w, r, "/usr/share/ng/index.html") 56 | }) 57 | } 58 | 59 | if err := http.ListenAndServe(":"+os.Getenv("SERVER_PORT"), r); err != nil { 60 | log.Fatal(err) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend/messages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/go-chi/chi" 13 | ) 14 | 15 | func getMessages(w http.ResponseWriter, r *http.Request) { 16 | session, _ := store.Get(r, cookieName) 17 | user, _ := session.Values["user"].(Session) 18 | 19 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 20 | defer cancel() 21 | 22 | offsetFromClient := r.URL.Query().Get("offset") 23 | limitFromClient := r.URL.Query().Get("limit") 24 | 25 | offset, err := strconv.Atoi(offsetFromClient) 26 | if err != nil { 27 | offset = 0 28 | } 29 | 30 | limit, err := strconv.Atoi(limitFromClient) 31 | if err != nil { 32 | limit = 20 33 | } 34 | 35 | messages, err := GetMessageRange(ctx, int64(offset), int64(limit), user.IsAdmin) 36 | if err != nil { 37 | log.Printf("Failed to get messages: %v\n", err) 38 | http.Error(w, "error", http.StatusInternalServerError) 39 | return 40 | } 41 | 42 | res := struct { 43 | Messages []Message `json:"messages"` 44 | HasMore bool `json:"hasMore"` 45 | }{ 46 | Messages: messages, 47 | HasMore: len(messages) >= limit, 48 | } 49 | 50 | w.Header().Set("Content-Type", "application/json") 51 | json.NewEncoder(w).Encode(res) 52 | 53 | AddViewsToMessages(ctx, messages) 54 | } 55 | 56 | func addMessage(w http.ResponseWriter, r *http.Request) { 57 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 58 | defer cancel() 59 | 60 | var message Message 61 | var err error 62 | defer r.Body.Close() 63 | 64 | session, _ := store.Get(r, cookieName) 65 | user, _ := session.Values["user"].(Session) 66 | 67 | body := Message{} 68 | if err = json.NewDecoder(r.Body).Decode(&body); err != nil { 69 | log.Printf("Failed to decode message: %v\n", err) 70 | http.Error(w, "error", http.StatusBadRequest) 71 | return 72 | } 73 | 74 | message.ID = GetMessageNextId(ctx) 75 | message.Type = body.Type 76 | message.Author = user.Username 77 | message.Timestamp = time.Now() 78 | message.Text = body.Text 79 | message.File = body.File 80 | message.Views = 0 81 | 82 | if err = SetMessage(ctx, message, false); err != nil { 83 | log.Printf("Failed to set new message: %v\n", err) 84 | http.Error(w, "error", http.StatusInternalServerError) 85 | return 86 | } 87 | 88 | go SendWebhook(context.Background(), "create", message) 89 | 90 | w.Header().Set("Content-Type", "application/json") 91 | json.NewEncoder(w).Encode(message) 92 | } 93 | 94 | func updateMessage(w http.ResponseWriter, r *http.Request) { 95 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 96 | defer cancel() 97 | 98 | var err error 99 | defer r.Body.Close() 100 | 101 | body := Message{} 102 | if err = json.NewDecoder(r.Body).Decode(&body); err != nil { 103 | response := Response{Success: false} 104 | json.NewEncoder(w).Encode(response) 105 | return 106 | } 107 | 108 | body.LastEdit = time.Now() 109 | 110 | if err := SetMessage(ctx, body, true); err != nil { 111 | response := Response{Success: false} 112 | json.NewEncoder(w).Encode(response) 113 | return 114 | } 115 | 116 | go SendWebhook(context.Background(), "update", body) 117 | 118 | response := Response{Success: true} 119 | json.NewEncoder(w).Encode(response) 120 | } 121 | 122 | func deleteMessage(w http.ResponseWriter, r *http.Request) { 123 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 124 | defer cancel() 125 | 126 | id := chi.URLParam(r, "id") 127 | 128 | idInt, _ := strconv.Atoi(id) 129 | message := Message{ID: idInt, Deleted: true} 130 | 131 | if err := DeleteMessage(ctx, id); err != nil { 132 | response := Response{Success: false} 133 | json.NewEncoder(w).Encode(response) 134 | return 135 | } 136 | 137 | go SendWebhook(context.Background(), "delete", message) 138 | 139 | response := Response{Success: true} 140 | json.NewEncoder(w).Encode(response) 141 | } 142 | 143 | func getEvents(w http.ResponseWriter, r *http.Request) { 144 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 145 | defer cancel() 146 | 147 | clientCtx := r.Context() 148 | 149 | w.Header().Set("Content-Type", "text/event-stream") 150 | w.Header().Set("Cache-Control", "no-cache") 151 | w.Header().Set("Connection", "keep-alive") 152 | 153 | pubsub := rdb.Subscribe(ctx, "events") 154 | defer pubsub.Close() 155 | 156 | for { 157 | select { 158 | case <-clientCtx.Done(): 159 | return 160 | case msg, ok := <-pubsub.Channel(): 161 | if !ok { 162 | return 163 | } 164 | fmt.Fprintf(w, "data: %s\n\n", msg.Payload) 165 | w.(http.Flusher).Flush() 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /backend/webhook.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "encoding/json" 8 | "log" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var webhookURL string = os.Getenv("WEBHOOK_URL") 15 | var verifyToken string = os.Getenv("WEBHOOK_VERIFY_TOKEN") 16 | 17 | type WebhookPayload struct { 18 | Action string `json:"action"` 19 | Message Message `json:"message"` 20 | Timestamp time.Time `json:"timestamp"` 21 | VerifyToken string `json:"verifyToken"` 22 | } 23 | 24 | func SendWebhook(ctx context.Context, action string, message Message) { 25 | if webhookURL == "" { 26 | return 27 | } 28 | 29 | payload := WebhookPayload{ 30 | Action: action, 31 | Message: message, 32 | Timestamp: time.Now(), 33 | VerifyToken: verifyToken, 34 | } 35 | 36 | jsonData, err := json.Marshal(payload) 37 | if err != nil { 38 | log.Printf("Error converting webhook data to JSON: %v\n", err) 39 | return 40 | } 41 | 42 | httpCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 43 | defer cancel() 44 | 45 | req, err := http.NewRequestWithContext(httpCtx, "POST", webhookURL, bytes.NewBuffer(jsonData)) 46 | if err != nil { 47 | log.Printf("Error creating webhook request: %v\n", err) 48 | return 49 | } 50 | 51 | req.Header.Set("Content-Type", "application/json") 52 | req.Header.Set("User-Agent", "TheChannel-Webhook") 53 | 54 | // Warning! Default is not secure 55 | client := &http.Client{ 56 | Transport: &http.Transport{ 57 | TLSClientConfig: &tls.Config{ 58 | InsecureSkipVerify: true, 59 | }, 60 | }, 61 | } 62 | resp, err := client.Do(req) 63 | if err != nil { 64 | log.Printf("Error sending webhook: %v\n", err) 65 | return 66 | } 67 | defer resp.Body.Close() 68 | 69 | log.Printf("Sent webhook for action '%s' on message %d. Response code: %d\n", 70 | action, message.ID, resp.StatusCode) 71 | } 72 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: apache/kvrocks 4 | container_name: redis 5 | restart: always 6 | depends_on: 7 | - caddy 8 | user: root 9 | command: -c /kvrocks.conf 10 | volumes: 11 | - ./data:/data 12 | - ./redis_data:/var/lib/kvrocks 13 | - ./kvrocks.conf:/kvrocks.conf 14 | 15 | backend: 16 | build: 17 | context: . 18 | dockerfile: Dockerfile 19 | container_name: backend 20 | depends_on: 21 | - redis 22 | restart: always 23 | env_file: 24 | - .env 25 | networks: 26 | - app-network 27 | volumes: 28 | - ./channel_data:/app/files 29 | - ./data:/app/data 30 | 31 | caddy: 32 | image: caddy:2-alpine 33 | restart: always 34 | ports: 35 | - "80:80" 36 | - "443:443" 37 | volumes: 38 | - ./Caddyfile:/etc/caddy/Caddyfile 39 | - ./caddy_data:/data 40 | - ./caddy_config:/config 41 | networks: 42 | - app-network 43 | 44 | networks: 45 | app-network: 46 | driver: bridge 47 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | /.angular 2 | /dist 3 | /node_modules 4 | /.dockerignore -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetFree-Community/TheChannel/54c39d0a3d253b60958624ca90a04cccee60190b/frontend/README.md -------------------------------------------------------------------------------- /frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "channel": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": "dist/channel", 21 | "deployUrl": "/assets/", 22 | "index": "src/index.html", 23 | "browser": "src/main.ts", 24 | "polyfills": [ 25 | "zone.js", 26 | "@angular/localize/init" 27 | ], 28 | "tsConfig": "tsconfig.app.json", 29 | "assets": [ 30 | { 31 | "glob": "**/*", 32 | "input": "public" 33 | } 34 | ], 35 | "styles": [ 36 | "node_modules/@nebular/theme/styles/prebuilt/default.css", 37 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 38 | "src/styles.scss", 39 | "node_modules/prismjs/themes/prism-okaidia.css", 40 | "node_modules/viewerjs/dist/viewer.css" 41 | ], 42 | "scripts": [ 43 | "node_modules/prismjs/prism.js", 44 | "node_modules/prismjs/components/prism-csharp.min.js", 45 | "node_modules/prismjs/components/prism-css.min.js" 46 | ] 47 | }, 48 | "configurations": { 49 | "production": { 50 | "budgets": [ 51 | { 52 | "type": "initial", 53 | "maximumWarning": "2MB", 54 | "maximumError": "5MB" 55 | }, 56 | { 57 | "type": "anyComponentStyle", 58 | "maximumWarning": "6kB", 59 | "maximumError": "10kB" 60 | } 61 | ], 62 | "outputHashing": "all" 63 | }, 64 | "development": { 65 | "optimization": false, 66 | "extractLicenses": false, 67 | "sourceMap": true 68 | } 69 | }, 70 | "defaultConfiguration": "production" 71 | }, 72 | "serve": { 73 | "builder": "@angular-devkit/build-angular:dev-server", 74 | "configurations": { 75 | "production": { 76 | "buildTarget": "channel:build:production" 77 | }, 78 | "development": { 79 | "buildTarget": "channel:build:development" 80 | } 81 | }, 82 | "defaultConfiguration": "development", 83 | "options": { 84 | "liveReload": true, 85 | "poll": 2000, 86 | "proxyConfig": "proxy.conf.js", 87 | "allowedHosts": [ 88 | "all" 89 | ] 90 | } 91 | }, 92 | "extract-i18n": { 93 | "builder": "@angular-devkit/build-angular:extract-i18n" 94 | }, 95 | "test": { 96 | "builder": "@angular-devkit/build-angular:karma", 97 | "options": { 98 | "polyfills": [ 99 | "zone.js", 100 | "zone.js/testing", 101 | "@angular/localize/init" 102 | ], 103 | "tsConfig": "tsconfig.spec.json", 104 | "assets": [ 105 | { 106 | "glob": "**/*", 107 | "input": "public" 108 | } 109 | ], 110 | "styles": [ 111 | "src/styles.scss", 112 | "node_modules/bootstrap/dist/css/bootstrap.css" 113 | ], 114 | "scripts": [] 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "channel", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --host 0.0.0.0 --disable-host-check", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^19.2.0", 14 | "@angular/cdk": "^19.2.0", 15 | "@angular/common": "^19.2.0", 16 | "@angular/compiler": "^19.2.0", 17 | "@angular/core": "^19.2.0", 18 | "@angular/forms": "^19.2.0", 19 | "@angular/platform-browser": "^19.2.0", 20 | "@angular/platform-browser-dynamic": "^19.2.0", 21 | "@angular/router": "^19.2.0", 22 | "@angular/youtube-player": "^19.2.8", 23 | "@kolkov/angular-editor": "^3.0.0-beta.2", 24 | "@nebular/eva-icons": "^15.0.0", 25 | "@nebular/theme": "^15.0.0", 26 | "@ng-bootstrap/ng-bootstrap": "^18.0.0", 27 | "@ng-icons/core": "^30.3.0", 28 | "@ng-icons/heroicons": "^30.3.0", 29 | "@popperjs/core": "^2.11.8", 30 | "angular-markdown-editor": "^3.1.1", 31 | "bootstrap": "^5.3.3", 32 | "bootstrap-icons": "^1.11.3", 33 | "eva-icons": "^1.1.3", 34 | "marked": "^15.0.0", 35 | "moment": "^2.30.1", 36 | "ngx-markdown": "^19.1.1", 37 | "prismjs": "^1.30.0", 38 | "rxjs": "~7.8.0", 39 | "tslib": "^2.3.0", 40 | "viewerjs": "^1.11.7", 41 | "zone.js": "~0.15.0" 42 | }, 43 | "devDependencies": { 44 | "@angular-devkit/build-angular": "^19.2.0", 45 | "@angular/cli": "^19.2.0", 46 | "@angular/compiler-cli": "^19.2.0", 47 | "@angular/localize": "^19.2.0", 48 | "@types/jasmine": "~5.1.0", 49 | "@types/markdown-it": "^14.1.2", 50 | "@types/markdown-it-link-attributes": "^3.0.5", 51 | "jasmine-core": "~5.5.0", 52 | "karma": "~6.4.0", 53 | "karma-chrome-launcher": "~3.2.0", 54 | "karma-coverage": "~2.2.0", 55 | "karma-jasmine": "~5.1.0", 56 | "karma-jasmine-html-reporter": "~2.1.0", 57 | "typescript": "~5.7.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/proxy.conf.js: -------------------------------------------------------------------------------- 1 | const PROXY_CONFIG = { 2 | "/api/**": { 3 | "target": "http://127.0.0.1:3000", 4 | "changeOrigin": true, 5 | "ws": true, 6 | "cookieDomainRewrite": "" 7 | } 8 | }; 9 | 10 | module.exports = PROXY_CONFIG; -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetFree-Community/TheChannel/54c39d0a3d253b60958624ca90a04cccee60190b/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetFree-Community/TheChannel/54c39d0a3d253b60958624ca90a04cccee60190b/frontend/src/app/app.component.scss -------------------------------------------------------------------------------- /frontend/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(async () => { 6 | await TestBed.configureTestingModule({ 7 | imports: [AppComponent], 8 | }).compileComponents(); 9 | }); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | 17 | it(`should have the 'channel' title`, () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app.title).toEqual('channel'); 21 | }); 22 | 23 | it('should render title', () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | fixture.detectChanges(); 26 | const compiled = fixture.nativeElement as HTMLElement; 27 | expect(compiled.querySelector('h1')?.textContent).toContain('Hello, channel'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | import { NbCardModule, NbLayoutModule } from "@nebular/theme"; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | imports: [RouterOutlet, NbLayoutModule, NbCardModule], 8 | templateUrl: './app.component.html', 9 | styleUrl: './app.component.scss' 10 | }) 11 | export class AppComponent { 12 | title = 'channel'; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | import { provideHttpClient } from '@angular/common/http'; 4 | 5 | import { routes } from './app.routes'; 6 | import { 7 | NbDialogModule, 8 | NbGlobalLogicalPosition, 9 | NbIconModule, 10 | NbLayoutDirection, 11 | NbMenuModule, 12 | NbThemeModule, 13 | NbToastrModule 14 | } from "@nebular/theme"; 15 | import { provideAnimationsAsync } from "@angular/platform-browser/animations/async"; 16 | import { NbEvaIconsModule } from "@nebular/eva-icons"; 17 | import { provideMarkdown } from "ngx-markdown"; 18 | import { MarkdownConfig } from "./markdown.config"; 19 | import { NgIconsModule, provideIcons } from "@ng-icons/core"; // Import NgIconsModule and provideIcons 20 | import { heroBold, heroItalic, heroUnderline, heroCodeBracket, heroPaperClip } from "@ng-icons/heroicons/outline"; 21 | 22 | export const appConfig: ApplicationConfig = { 23 | providers: [ 24 | provideZoneChangeDetection({ eventCoalescing: true }), 25 | provideRouter(routes), 26 | provideHttpClient(), 27 | provideAnimationsAsync(), 28 | provideMarkdown(MarkdownConfig), 29 | provideIcons({ heroBold, heroItalic, heroUnderline, heroCodeBracket, heroPaperClip }), 30 | importProvidersFrom( 31 | NbThemeModule.forRoot(undefined, undefined, undefined, NbLayoutDirection.RTL), 32 | NbIconModule, 33 | NbEvaIconsModule, 34 | NbMenuModule.forRoot(), 35 | NbDialogModule.forRoot(), 36 | NbToastrModule.forRoot({ position: NbGlobalLogicalPosition.TOP_START }), 37 | NgIconsModule 38 | ) 39 | ] 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { LoginComponent } from './components/login/login.component'; 3 | import { ChatComponent } from './components/chat/chat.component'; 4 | import { LoginGuardService } from "./services/login-guard.service"; 5 | 6 | export const routes: Routes = [ 7 | { path: 'login', component: LoginComponent, canActivate: [LoginGuardService] }, 8 | { path: '', component: ChatComponent , pathMatch: 'full'}, 9 | { path: '**', redirectTo: '' } 10 | ]; 11 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-header/channel-header.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

{{ channel?.name }}

6 | {{ channel?.description ?? '' }} 7 |
8 | 9 | @if (userInfo?.isAdmin) { 10 | 13 | 15 | 16 | } 17 |
-------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-header/channel-header.component.scss: -------------------------------------------------------------------------------- 1 | div h1, 2 | div small { 3 | font-family: 'Assistant', sans-serif; 4 | color: white !important; 5 | } 6 | 7 | div user-name { 8 | font-family: 'Assistant', sans-serif; 9 | color: white !important; 10 | } 11 | 12 | div img { 13 | height: 60px; 14 | width: 60px; 15 | cursor: pointer; 16 | } -------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-header/channel-header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { NgIf } from "@angular/common"; 3 | import { AuthService, User } from "../../../services/auth.service"; 4 | import { 5 | NbButtonModule, 6 | NbContextMenuModule, 7 | NbDialogService, 8 | NbIconModule, 9 | NbMenuService, 10 | NbToastrService, 11 | NbUserModule 12 | } from "@nebular/theme"; 13 | import { InputFormComponent } from "../input-form/input-form.component"; 14 | import { Channel } from "../../../models/channel.model"; 15 | import { filter } from "rxjs"; 16 | import { ChatService } from '../../../services/chat.service'; 17 | import { ChannelInfoFormComponent } from '../channel-info-form/channel-info-form.component'; 18 | import Viewer from 'viewerjs'; 19 | 20 | @Component({ 21 | selector: 'app-channel-header', 22 | imports: [ 23 | NgIf, 24 | NbButtonModule, 25 | NbIconModule, 26 | NbUserModule, 27 | NbContextMenuModule 28 | ], 29 | templateUrl: './channel-header.component.html', 30 | styleUrl: './channel-header.component.scss' 31 | }) 32 | export class ChannelHeaderComponent implements OnInit { 33 | 34 | @Input() 35 | userInfo?: User; 36 | 37 | @Output() 38 | userInfoChange: EventEmitter = new EventEmitter(); 39 | 40 | userMenuTag = 'user-menu'; 41 | userMenu = [ 42 | { 43 | title: 'ערוך פרטי ערוץ', 44 | icon: 'edit-2-outline', 45 | }, 46 | { 47 | title: 'התנתק', 48 | icon: 'log-out', 49 | }, 50 | ]; 51 | 52 | constructor(private chatService: ChatService, private _authService: AuthService, private dialogService: NbDialogService, private contextMenuService: NbMenuService, private toastrService: NbToastrService) { 53 | } 54 | 55 | channel?: Channel; 56 | 57 | ngOnInit() { 58 | this.chatService.getChannelInfo().subscribe(channel => { 59 | this.channel = channel; 60 | if (this.channel.logoUrl === "") { 61 | this.channel.logoUrl = "/assets/favicon.ico"; 62 | } 63 | }); 64 | 65 | this.contextMenuService.onItemClick() 66 | .pipe(filter(({ tag }) => tag === this.userMenuTag)) 67 | .subscribe(value => { 68 | switch (value.item.icon) { 69 | case 'log-out': 70 | this.logout(); 71 | break; 72 | case 'edit-2-outline': 73 | this.openChannelEditerDialog(); 74 | break; 75 | } 76 | }); 77 | } 78 | 79 | async logout() { 80 | if (await this._authService.logout()) { 81 | this.userInfo = undefined; 82 | this.userInfoChange.emit(undefined) 83 | } else { 84 | this.toastrService.danger("", "שגיאה בהתנתקות"); 85 | } 86 | } 87 | 88 | openMessageFormDialog() { 89 | this.dialogService.open(InputFormComponent, { closeOnBackdropClick: false }) 90 | } 91 | 92 | openChannelEditerDialog() { 93 | this.dialogService.open(ChannelInfoFormComponent, { closeOnBackdropClick: true, context: { channel: this.channel } }); 94 | } 95 | 96 | private v!: Viewer; 97 | 98 | viewLargeImage(event: MouseEvent) { 99 | const target = event.target as HTMLImageElement; 100 | if (target.tagName === 'IMG') { 101 | if (!this.v) { 102 | this.v = new Viewer(target, { 103 | toolbar: false, 104 | transition: true, 105 | navbar: false, 106 | title: false 107 | }); 108 | } 109 | this.v.show(); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-info-form/channel-info-form.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
כותרת:
8 | 9 |
10 |
תיאור:
11 | 12 |
13 |
14 | 15 |
16 |
17 | 20 |
21 | 24 |
25 |
26 |
-------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-info-form/channel-info-form.component.scss: -------------------------------------------------------------------------------- 1 | div input, 2 | div button, 3 | h6 { 4 | font-family: 'Assistant', sans-serif; 5 | } 6 | 7 | img { 8 | cursor: pointer; 9 | height: 60px; 10 | width: 60px; 11 | } -------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-info-form/channel-info-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChannelInfoFormComponent } from './channel-info-form.component'; 4 | 5 | describe('ChannelInfoFormComponent', () => { 6 | let component: ChannelInfoFormComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ChannelInfoFormComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ChannelInfoFormComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/channel-info-form/channel-info-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Channel } from '../../../models/channel.model'; 3 | import { NbCardModule, NbDialogRef, NbButtonModule, NbSpinnerModule, NbInputModule, NbToastrService } from '@nebular/theme'; 4 | import { Attachment, ChatFile, ChatService } from '../../../services/chat.service'; 5 | import { FormsModule } from '@angular/forms'; 6 | import { HttpEventType } from '@angular/common/http'; 7 | 8 | @Component({ 9 | selector: 'app-channel-info-form', 10 | imports: [ 11 | FormsModule, 12 | NbCardModule, 13 | NbButtonModule, 14 | NbSpinnerModule, 15 | NbInputModule, 16 | ], 17 | templateUrl: './channel-info-form.component.html', 18 | styleUrl: './channel-info-form.component.scss' 19 | }) 20 | export class ChannelInfoFormComponent implements OnInit { 21 | 22 | constructor( 23 | protected dialogRef: NbDialogRef, 24 | private chatService: ChatService, 25 | private taostrService: NbToastrService, 26 | private toastrService: NbToastrService, 27 | ) { } 28 | 29 | ngOnInit(): void { 30 | this.channel = this.dialogRef.componentRef.instance.channel; 31 | this.name = this.channel.name; 32 | this.description = this.channel.description 33 | this.logoUrl = this.channel.logoUrl; 34 | } 35 | 36 | attachment!: Attachment; 37 | channel!: Channel; 38 | isSending: boolean = false; 39 | name!: string; 40 | description!: string; 41 | logoUrl!: string; 42 | 43 | editChannelInfo() { 44 | this.isSending = true; 45 | this.chatService.editChannelInfo(this.name, this.description , this.logoUrl).subscribe({ 46 | next: () => { 47 | console.log("logURL", this.logoUrl); 48 | this.channel.name = this.name; 49 | this.channel.description = this.description; 50 | this.channel.logoUrl = this.logoUrl; 51 | this.isSending = false; 52 | this.taostrService.success("", "עריכת פרטי ערוץ בוצעה בהצלחה"); 53 | this.dialogRef.close(); 54 | }, 55 | error: () => { 56 | this.isSending = false; 57 | this.taostrService.danger("", "עריכת פרטי ערוץ נכשלה"); 58 | } 59 | }); 60 | }; 61 | 62 | onFileSelected(event: Event) { 63 | const input = event.target as HTMLInputElement; 64 | 65 | if (input.files) { 66 | console.log(input.files); 67 | this.attachment = { file: input.files[0] } 68 | const reader = new FileReader(); 69 | reader.readAsDataURL(this.attachment.file); 70 | reader.onload = (event) => { 71 | if (event.target) { 72 | this.logoUrl = event.target.result as string; 73 | } 74 | } 75 | 76 | this.uploadFile(this.attachment); 77 | } 78 | } 79 | 80 | async uploadFile(attachment: Attachment) { 81 | try { 82 | const formData = new FormData(); 83 | if (!attachment.file) return; 84 | formData.append('file', attachment.file); 85 | 86 | attachment.uploading = true; 87 | 88 | this.chatService.uploadFile(formData).subscribe({ 89 | next: (event) => { 90 | if (event.type === HttpEventType.UploadProgress) { 91 | attachment.uploadProgress = Math.round((event.loaded / (event.total || 1)) * 100); 92 | } else if (event.type === HttpEventType.Response) { 93 | const uploadedFile: ChatFile | null = event.body || null; 94 | attachment.uploading = false; 95 | attachment.uploadProgress = 0; 96 | if (!uploadedFile) return; 97 | console.log("uploadedFile", uploadedFile); 98 | this.logoUrl = uploadedFile.url; 99 | } 100 | }, 101 | error: (error) => { 102 | if (error.status === 413) { 103 | this.toastrService.danger("", "קובץ גדול מדי"); 104 | } else { 105 | this.toastrService.danger("", "שגיאה בהעלאת קובץ"); 106 | } 107 | attachment.uploading = false; 108 | }, 109 | }); 110 | 111 | } catch (error) { 112 | this.toastrService.danger("", "שגיאה בהעלאת קובץ"); 113 | } 114 | } 115 | 116 | closeDialog() { 117 | this.dialogRef.close(); 118 | }; 119 | } -------------------------------------------------------------------------------- /frontend/src/app/components/chat/chat.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | אין הודעות 24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 |
32 |
33 |
34 |
-------------------------------------------------------------------------------- /frontend/src/app/components/chat/chat.component.scss: -------------------------------------------------------------------------------- 1 | nb-list-item { 2 | border-bottom: 0 !important; 3 | padding: 0 !important; 4 | } 5 | 6 | .scroll-arrow { 7 | z-index: 999 !important; 8 | } 9 | 10 | nb-list-item:first-child { 11 | border-top: 0 !important; 12 | } 13 | 14 | nb-card-header { 15 | background-color: #444791; 16 | color: white; 17 | } -------------------------------------------------------------------------------- /frontend/src/app/components/chat/chat.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ChatComponent } from './chat.component'; 4 | 5 | describe('ChatComponent', () => { 6 | let component: ChatComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ChatComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(ChatComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/chat.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, OnInit, NgZone, OnDestroy, ViewChild, ElementRef } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { ChatService, ChatMessage } from '../../services/chat.service'; 5 | import { lastValueFrom } from 'rxjs'; 6 | import { AuthService, User } from '../../services/auth.service'; 7 | import { 8 | NbBadgeModule, 9 | NbButtonModule, 10 | NbCardModule, 11 | NbChatModule, 12 | NbIconModule, 13 | NbLayoutModule, 14 | NbListModule 15 | } from "@nebular/theme"; 16 | import { ChannelHeaderComponent } from "./channel-header/channel-header.component"; 17 | import { MessageComponent } from "./message/message.component"; 18 | 19 | @Component({ 20 | selector: 'app-chat', 21 | standalone: true, 22 | imports: [ 23 | CommonModule, 24 | FormsModule, 25 | NbLayoutModule, 26 | NbChatModule, 27 | ChannelHeaderComponent, 28 | NbCardModule, 29 | NbIconModule, 30 | NbButtonModule, 31 | NbListModule, 32 | NbBadgeModule, 33 | MessageComponent, 34 | ], 35 | templateUrl: './chat.component.html', 36 | styleUrl: './chat.component.scss' 37 | }) 38 | 39 | export class ChatComponent implements OnInit, OnDestroy { 40 | private eventSource!: EventSource; 41 | 42 | @ViewChild('messagesList', { static: false, read: ElementRef }) 43 | messagesList?: ElementRef; 44 | 45 | messages: ChatMessage[] = []; 46 | userInfo?: User; 47 | isLoading: boolean = false; 48 | offset: number = 0; 49 | limit: number = 20; 50 | hasMoreMessages: boolean = true; 51 | hasNewMessages: boolean = false; 52 | showScrollToBottom: boolean = false; 53 | 54 | constructor( 55 | private chatService: ChatService, 56 | private _authService: AuthService, 57 | private zone: NgZone, 58 | ) { } 59 | 60 | ngOnInit() { 61 | this.eventSource = this.chatService.sseListener(); 62 | this.eventSource.onmessage = (event) => { 63 | const message = JSON.parse(event.data); 64 | switch (message.type) { 65 | case 'new-message': 66 | this.zone.run(() => { 67 | this.messages.unshift(message.message); 68 | this.hasNewMessages = !(message.message.author === this.userInfo?.username); 69 | }); 70 | break; 71 | case 'delete-message': 72 | if (this.userInfo && this.userInfo.isAdmin) { 73 | this.zone.run(() => { 74 | const index = this.messages.findIndex(m => m.id === message.message.id); 75 | if (index !== -1) { 76 | this.messages[index].deleted = true; 77 | this.messages[index].lastEdit = message.message.lastEdit; 78 | } 79 | }) 80 | break; 81 | }; 82 | this.zone.run(() => { 83 | this.messages = this.messages.filter(m => m.id !== message.message.id); 84 | }); 85 | break; 86 | case 'edit-message': 87 | this.zone.run(() => { 88 | const index = this.messages.findIndex(m => m.id === message.message.id); 89 | if (index !== -1) { 90 | this.messages[index] = message.message; 91 | } else { 92 | // const closestIndex = this.messages.reduce 93 | } 94 | }); 95 | break; 96 | } 97 | }; 98 | 99 | this._authService.loadUserInfo().then(res => this.userInfo = res); 100 | 101 | this.loadMessages().then(() => { 102 | this.scrollToBottom(); 103 | }); 104 | } 105 | 106 | ngOnDestroy() { 107 | this.chatService.sseClose(); 108 | } 109 | 110 | onListScroll() { 111 | let position = this.messagesList?.nativeElement.scrollTop * -1; 112 | this.showScrollToBottom = position > 200; 113 | if (position < 10) { 114 | this.hasNewMessages = false; 115 | } 116 | } 117 | 118 | scrollToBottom() { 119 | this.messagesList?.nativeElement.scrollTo({ behavior: 'smooth', top: 0 }); 120 | this.hasNewMessages = false; 121 | } 122 | 123 | async loadMessages() { 124 | if (this.isLoading || !this.hasMoreMessages) return; 125 | 126 | try { 127 | this.isLoading = true; 128 | const response = await lastValueFrom(this.chatService.getMessages(this.offset, this.limit)) 129 | if (response?.messages) { 130 | this.hasMoreMessages = response.hasMore; 131 | this.messages.push(...response.messages); 132 | this.offset = Math.min(...this.messages.map(m => m.id!)); 133 | } 134 | } catch (error) { 135 | console.error('שגיאה בטעינת הודעות:', error); 136 | } finally { 137 | this.isLoading = false; 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/input-form/input-form.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

{{ message ? 'עריכת פוסט' : 'יצירת פוסט חדש' }}

4 |
5 | 6 |
7 | 10 | 13 | 17 | 21 | 24 | 28 | 29 | 30 | תצוגה מקדימה 31 | 32 |
33 |
34 | @for (attachment of attachments; track attachment) { 35 |
37 | @if (attachment.uploading || !attachment.url) { 38 |
39 | } @else { 40 | 41 | } 42 | {{ attachment.file.name.substring(attachment.file.name.length - 15) }} 43 | 47 |
48 | } 49 |
50 |
51 |
52 | @if (showMarkdownPreview) { 53 | 54 | 55 | 56 | 57 | 58 | } @else { 59 | 62 | } 63 | 64 | 66 | {{ input.length }}/{{ maxMessageLength }} 67 | 68 | 70 |
71 | @if (showMarkdownHelp) { 72 |
73 | 74 |
75 | } 76 |
77 |
78 | 79 | @if (message?.deleted) { 80 |
81 | ההודעה מחוקה ומוסתרת! אישור פרסום ההודעה יפרסם אותה מחדש. 82 |
83 | } 84 |
85 | 88 |
89 | 91 |
92 |
93 |
-------------------------------------------------------------------------------- /frontend/src/app/components/chat/input-form/input-form.component.scss: -------------------------------------------------------------------------------- 1 | div textarea, 2 | div button { 3 | font-family: 'Assistant', sans-serif; 4 | font-weight: 550; 5 | } 6 | 7 | nb-alert { 8 | font-family: 'Assistant', sans-serif; 9 | } 10 | 11 | .toolbar { 12 | display: flex; 13 | background-color: #f5f5f5; 14 | border-radius: 2rem; 15 | padding: 0.25rem; 16 | margin-bottom: 0.5rem; 17 | } 18 | 19 | .toolbar-button { 20 | margin: 0 0.25rem; 21 | min-width: 2.5rem; 22 | min-height: 2.5rem; 23 | border-radius: 50%; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | transition: background-color 0.2s; 28 | } 29 | 30 | .toolbar-button:hover { 31 | background-color: #e0e0e0; 32 | } 33 | 34 | .toolbar-icon { 35 | font-size: 1.25rem; 36 | color: #333333; 37 | } 38 | 39 | .markdown-help-container { 40 | width: 40%; 41 | min-width: 300px; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/input-form/input-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ChatMessage, ChatService, ChatFile , Attachment} from "../../../services/chat.service"; 4 | import { HttpClient, HttpEventType } from "@angular/common/http"; 5 | import { FormsModule } from "@angular/forms"; 6 | import { firstValueFrom } from "rxjs"; 7 | import { 8 | NbAlertModule, 9 | NbButtonModule, 10 | NbCardModule, NbDialogRef, 11 | NbFormFieldModule, 12 | NbIconModule, 13 | NbInputModule, 14 | NbProgressBarModule, NbSpinnerModule, NbTagModule, NbToastrService, NbToggleModule 15 | } from "@nebular/theme"; 16 | import { AngularEditorModule } from "@kolkov/angular-editor"; // Corrected import path 17 | import { MarkdownComponent } from "ngx-markdown"; 18 | import { NgIconsModule } from "@ng-icons/core"; 19 | import { heroBold, heroItalic, heroUnderline, heroCodeBracket, heroPaperClip, heroQuestionMarkCircle } from "@ng-icons/heroicons/outline"; 20 | import { MarkdownHelpComponent } from "../markdown-help/markdown-help.component"; 21 | 22 | @Component({ 23 | selector: 'app-input-form', 24 | imports: [ 25 | CommonModule, 26 | FormsModule, 27 | NbInputModule, 28 | NbIconModule, 29 | NbButtonModule, 30 | NbProgressBarModule, 31 | NbCardModule, 32 | NbFormFieldModule, 33 | AngularEditorModule, // Corrected module name 34 | NbToggleModule, 35 | NbSpinnerModule, 36 | MarkdownComponent, 37 | NbTagModule, 38 | NbAlertModule, 39 | NgIconsModule, // Use NgIconsModule directly, icons are configured in app.config.ts 40 | MarkdownHelpComponent, 41 | ], 42 | templateUrl: './input-form.component.html', 43 | styleUrl: './input-form.component.scss' 44 | }) 45 | export class InputFormComponent implements OnInit { 46 | 47 | protected readonly maxMessageLength: number = 2048; 48 | 49 | message?: ChatMessage; 50 | 51 | attachments: Attachment[] = []; 52 | 53 | input: string = ''; 54 | isSending: boolean = false; 55 | showMarkdownPreview: boolean = false; 56 | showMarkdownHelp: boolean = false; 57 | hasScrollbar: boolean = false; 58 | 59 | @ViewChild('inputTextArea') inputTextArea!: ElementRef; 60 | 61 | constructor(private http: HttpClient, private chatService: ChatService, private toastrService: NbToastrService, protected dialogRef: NbDialogRef) { } 62 | 63 | ngOnInit() { 64 | if (this.message) { 65 | this.input = this.message.text || ''; 66 | } 67 | } 68 | 69 | onFileSelected(event: Event) { 70 | const input = event.target as HTMLInputElement; 71 | if (input.files) { 72 | let newAttachment: Attachment = { file: input.files[0] }; 73 | let i = this.attachments.push(newAttachment) - 1; 74 | 75 | let reader = new FileReader(); 76 | reader.readAsDataURL(newAttachment.file); 77 | reader.onload = (event) => { 78 | if (event.target) { 79 | this.attachments[i].url = event.target.result as string; 80 | } 81 | } 82 | 83 | this.uploadFile(this.attachments[i]); 84 | } 85 | } 86 | 87 | async uploadFile(attachment: Attachment) { 88 | try { 89 | const formData = new FormData(); 90 | if (!attachment.file) return; 91 | formData.append('file', attachment.file); 92 | 93 | attachment.uploading = true; 94 | 95 | this.chatService.uploadFile(formData).subscribe({ 96 | next: (event) => { 97 | if (event.type === HttpEventType.UploadProgress) { 98 | attachment.uploadProgress = Math.round((event.loaded / (event.total || 1)) * 100); 99 | } else if (event.type === HttpEventType.Response) { 100 | const uploadedFile: ChatFile | null = event.body || null; 101 | let embedded = ''; 102 | 103 | if (!uploadedFile) return; 104 | if (uploadedFile?.filetype === 'image') { 105 | embedded = `[image-embedded#](${uploadedFile.url})`; //`![${uploadedFile.filename}](${uploadedFile.url})`; 106 | 107 | } else if (uploadedFile?.filetype === 'video') { 108 | embedded = `[video-embedded#](${uploadedFile.url})`; 109 | 110 | } else if (uploadedFile?.filetype === 'audio') { 111 | embedded = `[audio-embedded#](${uploadedFile.url})`; 112 | 113 | } else { 114 | embedded = `[${uploadedFile.filename}](${uploadedFile.url})`; 115 | } 116 | this.input += (this.input ? '\n' : '') + embedded; 117 | attachment.embedded = embedded; 118 | attachment.uploading = false; 119 | } 120 | }, 121 | error: (error) => { 122 | if (error.status === 413) { 123 | this.toastrService.danger("", "קובץ גדול מדי"); 124 | } else { 125 | this.toastrService.danger("", "שגיאה בהעלאת קובץ"); 126 | } 127 | attachment.uploading = false; 128 | this.removeAttachment(attachment); 129 | } 130 | }); 131 | } catch (error) { 132 | this.toastrService.danger("", "שגיאה בהעלאת קובץ"); 133 | } 134 | } 135 | 136 | async sendMessage() { 137 | try { 138 | this.isSending = true; 139 | 140 | const hasPendingFiles = this.attachments.some((attachment) => attachment.uploading); 141 | if (hasPendingFiles) { 142 | this.toastrService.danger("", "יש קבצים בהעלאה"); 143 | this.isSending = false; 144 | return; 145 | } 146 | 147 | let result = this.message ? await this.updateMessage() : await this.sendNewMessage(); 148 | if (!result) { 149 | throw new Error(); 150 | } 151 | 152 | this.toastrService.success("", "הודעה פורסמה בהצלחה"); 153 | this.dialogRef.close(this.message); 154 | } catch (error) { 155 | this.toastrService.danger("", "שגיאה בפרסום הודעה"); 156 | } finally { 157 | this.isSending = false 158 | } 159 | } 160 | 161 | async updateMessage(): Promise { 162 | if (!this.message) return false; 163 | this.message.text = this.input; 164 | this.message.deleted = false; 165 | await firstValueFrom(this.chatService.editMessage(this.message)); 166 | return true; 167 | } 168 | 169 | async sendNewMessage(): Promise { 170 | if (!this.input.trim() && !this.attachments.length) return false; 171 | 172 | let newMessage: ChatMessage = { 173 | type: 'md', 174 | text: this.input, 175 | file: undefined, 176 | }; 177 | 178 | this.message = await firstValueFrom(this.chatService.addMessage(newMessage)); 179 | 180 | if (!this.message) { 181 | throw new Error(); 182 | } 183 | 184 | return true; 185 | } 186 | 187 | closeDialog() { 188 | this.dialogRef.close(); 189 | } 190 | 191 | removeAttachment(attachment: Attachment) { 192 | this.attachments = this.attachments.filter((file) => file !== attachment); 193 | this.input = this.input.replaceAll(attachment.embedded ?? '', ''); 194 | } 195 | 196 | toggleMarkdownHelp() { 197 | this.showMarkdownHelp = !this.showMarkdownHelp; 198 | } 199 | 200 | checkScrollbar() { 201 | if (this.inputTextArea?.nativeElement) { 202 | const textarea = this.inputTextArea.nativeElement; 203 | this.hasScrollbar = textarea.scrollHeight > textarea.clientHeight; 204 | } 205 | } 206 | 207 | applyFormat(format: 'bold' | 'italic' | 'underline' | 'code') { 208 | const textArea = this.inputTextArea.nativeElement; 209 | const start = textArea.selectionStart; 210 | const end = textArea.selectionEnd; 211 | const selectedText = this.input.substring(start, end); 212 | 213 | let prefix = ''; 214 | let suffix = ''; 215 | let placeholder = ''; 216 | 217 | switch (format) { 218 | case 'bold': 219 | prefix = '**'; 220 | suffix = '**'; 221 | placeholder = 'טקסט מודגש'; 222 | break; 223 | case 'italic': 224 | prefix = '*'; 225 | suffix = '*'; 226 | placeholder = 'טקסט נטוי'; 227 | break; 228 | case 'underline': 229 | prefix = ''; 230 | suffix = ''; 231 | placeholder = 'טקסט עם קו תחתון'; 232 | break; 233 | case 'code': 234 | prefix = '```\n'; 235 | suffix = '\n```'; 236 | placeholder = 'קוד'; 237 | // Add new lines if not already present around the selection/cursor 238 | const before = this.input.substring(0, start); 239 | const after = this.input.substring(end); 240 | if (start > 0 && before.charAt(start - 1) !== '\n') { 241 | prefix = '\n' + prefix; 242 | } 243 | if (end < this.input.length && after.charAt(0) !== '\n') { 244 | suffix = suffix + '\n'; 245 | } 246 | break; 247 | } 248 | 249 | let newText = ''; 250 | let cursorPos = start + prefix.length; 251 | 252 | if (selectedText) { 253 | newText = prefix + selectedText + suffix; 254 | this.input = this.input.substring(0, start) + newText + this.input.substring(end); 255 | // Keep the original selection highlighted 256 | setTimeout(() => { 257 | textArea.selectionStart = start; 258 | textArea.selectionEnd = start + newText.length; 259 | textArea.focus(); 260 | }); 261 | } else { 262 | newText = prefix + placeholder + suffix; 263 | this.input = this.input.substring(0, start) + newText + this.input.substring(end); 264 | // Set cursor position inside the markers or after for code block 265 | setTimeout(() => { 266 | if (format === 'code') { 267 | cursorPos = start + prefix.length; // Cursor at the beginning of the placeholder inside code block 268 | } else { 269 | cursorPos = start + prefix.length; // Cursor at the beginning of the placeholder 270 | } 271 | textArea.selectionStart = cursorPos; 272 | textArea.selectionEnd = cursorPos + placeholder.length; 273 | textArea.focus(); 274 | }); 275 | } 276 | } 277 | 278 | ngAfterViewInit() { 279 | this.checkScrollbar(); 280 | 281 | if (this.inputTextArea?.nativeElement) { 282 | this.inputTextArea.nativeElement.addEventListener('input', () => { 283 | setTimeout(() => this.checkScrollbar(), 0); 284 | }); 285 | 286 | window.addEventListener('resize', () => { 287 | setTimeout(() => this.checkScrollbar(), 0); 288 | }); 289 | } 290 | } 291 | 292 | ngOnDestroy() { 293 | if (this.inputTextArea?.nativeElement) { 294 | this.inputTextArea.nativeElement.removeEventListener('input', this.checkScrollbar); 295 | window.removeEventListener('resize', this.checkScrollbar); 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/markdown-help/markdown-help.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | דוגמאות לשימוש ב-Markdown 4 | 5 | תיעוד רשמי של Markdown 6 | 7 | 8 | 9 |
10 |
11 |
כותרות
12 |
13 | # כותרת רמה 1
14 | ## כותרת רמה 2
15 | ### כותרת רמה 3
16 |
17 |
18 |
עיצוב טקסט
19 |
20 | **טקסט מודגש**
21 | *טקסט נטוי*
22 | ~~טקסט מחוק~~
23 | <u>קו תחתון</u>
24 |
25 |
26 |
רשימות
27 |
28 | - פריט 1
29 | - פריט 2
30 |   - תת פריט
31 | 
32 | 1. פריט ממוספר 1
33 | 2. פריט ממוספר 2
34 |
35 |
36 |
קישורים ותמונות
37 |
38 | [טקסט הקישור](https://example.com)
39 | ![טקסט אלטרנטיבי](https://example.com/image.jpg)
40 |
41 |
42 |
בלוק קוד
43 |
44 | ```javascript
45 | function hello() {{ '{' }}
46 |   console.log("Hello World!");
47 | {{ '}' }}
48 | ```
49 |
50 |
51 |
ציטוט
52 |
53 | > זהו ציטוט
54 | > שממשיך לשורה נוספת
55 |
56 |
57 |
מדיה מוטמעת
58 |
59 | [video-embedded#](url-to-video)
60 | [audio-embedded#](url-to-audio)
61 | [image-embedded#](url-to-image)
62 | https://www.youtube.com/watch?v=IG8BOfLOQnE
63 |
64 |
65 |
66 |
67 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/markdown-help/markdown-help.component.scss: -------------------------------------------------------------------------------- 1 | .markdown-examples { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | 6 | .example { 7 | border: 1px solid #e0e0e0; 8 | border-radius: 0.5rem; 9 | padding: 1rem; 10 | background-color: #f8f9fa; 11 | 12 | h5 { 13 | margin-top: 0; 14 | margin-bottom: 0.5rem; 15 | font-weight: 600; 16 | } 17 | 18 | pre { 19 | margin: 0; 20 | padding: 0.5rem; 21 | background-color: #ffffff; 22 | border: 1px solid #e0e0e0; 23 | border-radius: 0.25rem; 24 | white-space: pre-wrap; 25 | direction: rtl; 26 | text-align: right; 27 | } 28 | } 29 | } 30 | 31 | nb-card-header { 32 | a { 33 | color: #3366ff; 34 | text-decoration: none; 35 | 36 | &:hover { 37 | text-decoration: underline; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/markdown-help/markdown-help.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NbCardModule } from '@nebular/theme'; 3 | 4 | @Component({ 5 | selector: 'app-markdown-help', 6 | standalone: true, 7 | imports: [ 8 | NbCardModule 9 | ], 10 | templateUrl: './markdown-help.component.html', 11 | styleUrl: './markdown-help.component.scss' 12 | }) 13 | export class MarkdownHelpComponent { 14 | // Official Markdown documentation URL 15 | markdownDocsUrl: string = 'https://www.markdownguide.org/basic-syntax/'; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/message/message.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 7 | 8 |
9 |
10 | 13 |
14 |
15 | {{ message.timestamp | messageTime }} 16 |
17 | {{ message.views ?? 0 }} 18 | 19 |
20 |
21 |
22 |
23 |
24 |
25 | 27 |
28 |
-------------------------------------------------------------------------------- /frontend/src/app/components/chat/message/message.component.scss: -------------------------------------------------------------------------------- 1 | .message-card { 2 | position: relative; 3 | z-index: 200; 4 | border-radius: 10px 0 10px 10px; 5 | background-color: #e8ebfa; 6 | } 7 | 8 | .caret { 9 | width: 0; 10 | height: 0; 11 | border: solid; 12 | border-color: #e8ebfa transparent transparent #e8ebfa; 13 | border-width: 0 5px 10px 5px; 14 | } 15 | 16 | .option-mask { 17 | position: absolute; 18 | width: 100%; 19 | height: 100%; 20 | pointer-events: none; 21 | justify-content: end; 22 | border-radius: 10px 0 10px 10px; 23 | box-shadow: inset 2px 2px 7px rgba(0, 0, 0, .3); 24 | padding: 1px; 25 | 26 | opacity: 0; 27 | transition: opacity ease-out 0.1s; 28 | 29 | nb-icon { 30 | pointer-events: all; 31 | color: dimgray; 32 | cursor: pointer; 33 | } 34 | } 35 | 36 | nb-icon { 37 | cursor: pointer; 38 | } 39 | 40 | .message-card:hover .option-mask { 41 | opacity: 1; 42 | transition: opacity ease-in 0.1s; 43 | } 44 | 45 | ::ng-deep { 46 | .menu-item { 47 | .menu-icon { 48 | margin: 0 0 0 0.5rem !important; 49 | } 50 | } 51 | 52 | markdown img { 53 | border-radius: 8px; 54 | cursor: pointer; 55 | } 56 | 57 | i { 58 | cursor: pointer; 59 | } 60 | } -------------------------------------------------------------------------------- /frontend/src/app/components/chat/message/message.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { ChatMessage, ChatService } from "../../../services/chat.service"; 3 | import { NgIf, CommonModule } from "@angular/common"; 4 | import { 5 | NbButtonModule, 6 | NbCardModule, 7 | NbContextMenuModule, NbDialogService, 8 | NbIconModule, NbMenuService, 9 | NbPopoverModule, 10 | NbPosition 11 | } from "@nebular/theme"; 12 | import { MessageTimePipe } from "../../../pipes/message-time.pipe"; 13 | import { filter } from "rxjs"; 14 | import { InputFormComponent } from "../input-form/input-form.component"; 15 | import { MarkdownComponent } from "ngx-markdown"; 16 | import Viewer from 'viewerjs'; 17 | import { YoutubePlayerComponent } from '../youtube-player/youtube-player.component'; 18 | 19 | @Component({ 20 | selector: 'app-message', 21 | imports: [ 22 | NgIf, 23 | CommonModule, 24 | NbCardModule, 25 | NbIconModule, 26 | NbButtonModule, 27 | MessageTimePipe, 28 | NbContextMenuModule, 29 | MarkdownComponent, 30 | NbPopoverModule, 31 | ], 32 | templateUrl: './message.component.html', 33 | styleUrl: './message.component.scss' 34 | }) 35 | 36 | export class MessageComponent implements OnInit { 37 | 38 | private v!: Viewer; 39 | 40 | protected readonly NbPosition = NbPosition; 41 | 42 | @Input() 43 | message: ChatMessage | undefined; 44 | 45 | @Input() 46 | isAdmin: boolean = false; 47 | 48 | optionsMenu = [ // TODO: hide when X time passed 49 | { 50 | title: 'עריכה', 51 | icon: 'edit', 52 | click: (message: ChatMessage) => this.editMessage(message), 53 | hidden: false 54 | }, 55 | { 56 | title: 'מחיקה', 57 | icon: 'trash', 58 | click: (message: ChatMessage) => this.deleteMessage(message), 59 | hidden: false 60 | } 61 | ]; 62 | 63 | constructor( 64 | private _chatService: ChatService, 65 | private menuService: NbMenuService, 66 | private dialogService: NbDialogService, 67 | ) { } 68 | 69 | ngOnInit() { 70 | this.menuService.onItemClick().pipe( 71 | filter(value => value.tag == this.message?.id?.toString()) 72 | ).subscribe((event) => { 73 | let item = this.optionsMenu.find(value => { 74 | return value.title == event.item.title; 75 | }); 76 | if (item && this.message) { 77 | item.click(this.message); 78 | } 79 | }); 80 | } 81 | 82 | editMessage(message: ChatMessage) { 83 | this.dialogService.open(InputFormComponent, { closeOnBackdropClick: false, context: { message: message } }).onClose 84 | .subscribe((result: ChatMessage | undefined) => { 85 | if (result && this.message?.id == result.id) { 86 | this.message = result; 87 | } 88 | }); 89 | } 90 | 91 | deleteMessage(message: ChatMessage) { 92 | const confirm = window.confirm('האם אתה בטוח שברצונך למחוק את ההודעה?'); 93 | if (confirm) 94 | this._chatService.deleteMessage(message.id).subscribe(); 95 | } 96 | 97 | viewLargeImage(event: MouseEvent) { 98 | const target = event.target as HTMLElement; 99 | 100 | if (target.tagName === 'IMG' || target.tagName === 'I') { 101 | const youtubeId = target.getAttribute('youtubeid'); 102 | if (youtubeId) { 103 | this.dialogService.open(YoutubePlayerComponent, { closeOnBackdropClick: true, context: {videoId: youtubeId } }) 104 | return; 105 | } 106 | if (!this.v) { 107 | this.v = new Viewer(target, { 108 | toolbar: false, 109 | transition: true, 110 | navbar: false, 111 | title: false 112 | }); 113 | } 114 | this.v.show(); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/youtube-player/youtube-player.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/youtube-player/youtube-player.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetFree-Community/TheChannel/54c39d0a3d253b60958624ca90a04cccee60190b/frontend/src/app/components/chat/youtube-player/youtube-player.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/chat/youtube-player/youtube-player.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { YoutubePlayerComponent } from './youtube-player.component'; 4 | 5 | describe('YoutubePlayerComponent', () => { 6 | let component: YoutubePlayerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [YoutubePlayerComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(YoutubePlayerComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/app/components/chat/youtube-player/youtube-player.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { NbDialogRef } from '@nebular/theme'; 3 | import { YouTubePlayer } from "@angular/youtube-player"; 4 | import { CommonModule } from '@angular/common'; 5 | 6 | @Component({ 7 | selector: 'app-youtube-player', 8 | imports: [ 9 | YouTubePlayer, 10 | CommonModule 11 | ], 12 | templateUrl: './youtube-player.component.html', 13 | styleUrl: './youtube-player.component.scss' 14 | }) 15 | export class YoutubePlayerComponent implements OnInit { 16 | iframeWidth = window.innerWidth / 100 * 80; 17 | iframeHeight = this.iframeWidth * 9 / 16; 18 | 19 | constructor(private dialogRef: NbDialogRef) { } 20 | 21 | videoId: string = ''; 22 | playerConfig = { 23 | autoplay: 1 24 | } 25 | 26 | ngOnInit(): void { 27 | this.videoId = this.dialogRef.componentRef.instance.videoId; 28 | } 29 | 30 | closeDialog() { 31 | this.dialogRef.close(); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/app/components/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |

כניסה כמנהל

7 |
8 |
9 |
10 |
11 | 12 | 14 |
15 |
16 | 17 | 19 |
20 | 21 |
22 |
23 |
24 |
25 |
26 |
-------------------------------------------------------------------------------- /frontend/src/app/components/login/login.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetFree-Community/TheChannel/54c39d0a3d253b60958624ca90a04cccee60190b/frontend/src/app/components/login/login.component.scss -------------------------------------------------------------------------------- /frontend/src/app/components/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LoginComponent } from './login.component'; 4 | 5 | describe('LoginComponent', () => { 6 | let component: LoginComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [LoginComponent] 12 | }) 13 | .compileComponents(); 14 | 15 | fixture = TestBed.createComponent(LoginComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/app/components/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { AuthService } from '../../services/auth.service'; 4 | import { Router } from '@angular/router'; 5 | 6 | @Component({ 7 | selector: 'app-login', 8 | imports: [ 9 | FormsModule, 10 | ], 11 | templateUrl: './login.component.html', 12 | styleUrl: './login.component.scss' 13 | }) 14 | export class LoginComponent { 15 | username: string = ''; 16 | password: string = ''; 17 | 18 | constructor( 19 | private _authService: AuthService, 20 | private router: Router 21 | ) { } 22 | 23 | async login() { 24 | if (await this._authService.login(this.username, this.password)) { 25 | this.router.navigate(['/chat']); 26 | } else { 27 | alert('שגיאה'); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/app/markdown.config.ts: -------------------------------------------------------------------------------- 1 | import { Parser, Token, Tokens, TokensList } from "marked"; 2 | import { MarkdownModuleConfig, MARKED_OPTIONS, MarkedRenderer } from "ngx-markdown"; 3 | 4 | const matchCustomEmbedRegEx = /^\[(video|audio|image)-embedded#]\((.*?)\)/; 5 | 6 | //https://regexr.com/3dj5t 7 | const matchYoutubeRegEx = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)(?[\w\-]+)(\S+)?$/; 8 | 9 | const customEmbedExtension = { 10 | extensions: [{ 11 | name: 'custom_embed', 12 | level: 'inline', 13 | start: (src: string) => src.match(matchCustomEmbedRegEx)?.index ?? src.match(matchYoutubeRegEx)?.index, 14 | tokenizer: (src: string, tokens: Token[] | TokensList) => { 15 | 16 | let match = src.match(matchCustomEmbedRegEx); 17 | if (match) { 18 | return { 19 | type: 'custom_embed', 20 | raw: match[0], 21 | meta: { type: match[1], url: match[2] }, 22 | }; 23 | } 24 | 25 | match = src.match(matchYoutubeRegEx); 26 | if (match && match.groups?.['id']) { 27 | return { 28 | type: 'custom_embed', 29 | raw: match[0], 30 | meta: { type: 'youtube', id: match.groups['id'] }, 31 | }; 32 | } 33 | 34 | return undefined; 35 | }, 36 | renderer: (token: Tokens.Generic) => { 37 | const { type, url, id } = token['meta']; 38 | switch (type) { 39 | case 'video': 40 | return `
`; 41 | case 'audio': 42 | return `
`; 43 | case 'image': 44 | return `
`; 45 | case 'youtube': 46 | return `
`; 48 | default: 49 | return ''; 50 | } 51 | } 52 | }] 53 | } 54 | 55 | const renderer = new MarkedRenderer(); 56 | renderer.paragraph = ({ tokens }) => Parser.parseInline(tokens); 57 | 58 | export const MarkdownConfig: MarkdownModuleConfig = { 59 | markedExtensions: [customEmbedExtension], 60 | markedOptions: { 61 | provide: MARKED_OPTIONS, 62 | useValue: { 63 | renderer: renderer, 64 | breaks: true, 65 | }, 66 | } 67 | } -------------------------------------------------------------------------------- /frontend/src/app/models/channel.model.ts: -------------------------------------------------------------------------------- 1 | export interface Channel { 2 | id: number; 3 | name: string; 4 | description: string; 5 | created_at: string; 6 | logoUrl: string; 7 | } -------------------------------------------------------------------------------- /frontend/src/app/pipes/message-time.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import moment from 'moment'; 3 | import 'moment/locale/he' 4 | 5 | moment.defineLocale('he', {}); 6 | 7 | @Pipe({ 8 | name: 'messageTime' 9 | }) 10 | export class MessageTimePipe implements PipeTransform { 11 | 12 | transform(value: any | string, ...args: unknown[]): any { 13 | let m = moment(value); 14 | let daysDiff = m.diff(moment(), 'days'); 15 | switch (true) { 16 | case daysDiff > 0: // Future 17 | return m.calendar(); 18 | case daysDiff == 0: // Today 19 | return m.format('LT'); 20 | case daysDiff == -1: // Yesterday 21 | return m.calendar(); 22 | case daysDiff >= -6: // This week 23 | return `${m.format('dddd')} ${m.format('LT')}`; 24 | default: 25 | return m.format('L LT'); 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/app/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { firstValueFrom, lastValueFrom } from 'rxjs'; 4 | 5 | export interface User { 6 | id: number; 7 | username: string; 8 | isAdmin: boolean; 9 | } 10 | 11 | export interface ResponseResult { 12 | success: boolean; 13 | } 14 | 15 | @Injectable({ 16 | providedIn: 'root' 17 | }) 18 | export class AuthService { 19 | private userInfo?: User; 20 | 21 | constructor(private _http: HttpClient) { } 22 | 23 | async login(username: string, password: string) { 24 | let body = { username, password }; 25 | try { 26 | let res = await firstValueFrom(this._http.post('/api/auth/login', body)); 27 | return res.success; 28 | } catch { 29 | this.userInfo = undefined; 30 | return false; 31 | } 32 | } 33 | 34 | async logout() { 35 | let res = await firstValueFrom(this._http.post('/api/auth/logout', {})); 36 | if (res.success) { 37 | this.userInfo = undefined; 38 | } 39 | return res.success; 40 | } 41 | 42 | async loadUserInfo() { 43 | try { 44 | this.userInfo = this.userInfo || await lastValueFrom(this._http.get('/api/auth/user-info')) 45 | } catch { 46 | this.userInfo = undefined; 47 | } 48 | return this.userInfo; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/app/services/chat.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { Channel } from '../models/channel.model'; 5 | import { ResponseResult } from './auth.service'; 6 | 7 | export type MessageType = 'md' | 'text' | 'image' | 'video' | 'audio' | 'document' | 'other'; 8 | export interface ChatMessage { 9 | id?: number; 10 | type?: MessageType; 11 | text?: string; 12 | timestamp?: Date; 13 | userId?: number | null; 14 | author?: string; 15 | lastEdit?: boolean; 16 | deleted?: boolean; 17 | file?: ChatFile; 18 | views?: number; 19 | } 20 | 21 | export interface ChatResponse { 22 | messages: ChatMessage[]; 23 | hasMore: boolean; 24 | } 25 | 26 | export interface ChatFile { 27 | url: string; 28 | filename: string; 29 | filetype: string; 30 | } 31 | 32 | export interface Attachment { 33 | file: File; 34 | url?: string; 35 | uploadProgress?: number; 36 | uploading?: boolean; 37 | embedded?: string; 38 | } 39 | 40 | @Injectable({ 41 | providedIn: 'root' 42 | }) 43 | export class ChatService { 44 | private eventSource!: EventSource; 45 | 46 | constructor(private http: HttpClient) { } 47 | 48 | getChannelInfo() { 49 | return this.http.get('/api/channel/info'); 50 | } 51 | 52 | editChannelInfo(name: string, description: string, logoUrl: string): Observable { 53 | return this.http.post('/api/auth/edit-channel-info', { name, description, logoUrl }); 54 | } 55 | 56 | getMessages(offset: number, limit: number): Observable { 57 | return this.http.get('/api/messages', { 58 | params: { 59 | offset: offset.toString(), 60 | limit: limit.toString() 61 | } 62 | }); 63 | } 64 | 65 | addMessage(message: ChatMessage): Observable { 66 | return this.http.post('/api/auth/new', message); 67 | } 68 | 69 | editMessage(message: ChatMessage): Observable { 70 | return this.http.post(`/api/auth/edit-message`, message); 71 | } 72 | 73 | deleteMessage(id: number | undefined): Observable { 74 | return this.http.get(`/api/auth/delete-message/${id}`); 75 | } 76 | 77 | sseListener(): EventSource { 78 | if (this.eventSource) { 79 | this.eventSource.close(); 80 | } 81 | 82 | this.eventSource = new EventSource('/api/events'); 83 | 84 | this.eventSource.onopen = () => { 85 | console.log('Connection opened'); 86 | }; 87 | 88 | this.eventSource.onerror = (error) => { 89 | console.error('EventSource failed:', error); 90 | }; 91 | 92 | return this.eventSource; 93 | } 94 | 95 | sseClose() { 96 | if (this.eventSource) { 97 | this.eventSource.close(); 98 | } 99 | } 100 | 101 | uploadFile(formData: FormData) { 102 | return this.http.post('/api/auth/upload', formData, { 103 | reportProgress: true, 104 | observe: 'events', 105 | responseType: 'json' 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /frontend/src/app/services/login-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { CanActivate, Router } from "@angular/router"; 3 | import { AuthService } from "./auth.service"; 4 | 5 | @Injectable({providedIn: 'root'}) 6 | export class LoginGuardService implements CanActivate { 7 | 8 | constructor(private authService: AuthService, private router: Router) {} 9 | 10 | async canActivate() { 11 | let isLogged = !!(await this.authService.loadUserInfo()); 12 | if (isLogged) { 13 | this.router.navigate(['/chat']); 14 | return false; 15 | } 16 | 17 | return true; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Channel 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { bootstrapApplication } from '@angular/platform-browser'; 4 | import { appConfig } from './app/app.config'; 5 | import { AppComponent } from './app/app.component'; 6 | 7 | bootstrapApplication(AppComponent, appConfig) 8 | .catch((err) => console.error(err)); 9 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css'); 3 | 4 | html, 5 | body, 6 | markdown, 7 | small, 8 | textarea, 9 | button, 10 | h1, 11 | label, 12 | span, 13 | input { 14 | font-family: 'Assistant', sans-serif; //!important 15 | } 16 | 17 | markdown { 18 | font-weight: 500; 19 | // font-size: 110%; 20 | } -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [ 8 | "@angular/localize" 9 | ] 10 | }, 11 | "files": [ 12 | "src/main.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "outDir": "./dist/out-tsc", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "bundler", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022" 20 | }, 21 | "angularCompilerOptions": { 22 | "enableI18nLegacyMessageIdFormat": false, 23 | "strictInjectionParameters": true, 24 | "strictInputAccessModifiers": true, 25 | "strictTemplates": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine", 9 | "@angular/localize" 10 | ] 11 | }, 12 | "include": [ 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /kvrocks.conf: -------------------------------------------------------------------------------- 1 | unixsocket /data/kvrocks.sock 2 | unixsocketperm 777 3 | dir /var/lib/kvrocks -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # Backend 2 | SERVER_PORT=3000 3 | DEFAULT_USER=admin 4 | DEFAULT_PASSWORD=changeme 5 | DEFAULT_USERNAME=admin 6 | SECRET_KEY=supersecretkey 7 | API_SECRET_KEY=supersecretkey 8 | REDIS_ADDR=/app/data/kvrocks.sock 9 | REDIS_PROTOCOL=unix 10 | MAX_FILE_SIZE=100 11 | ROOT_STATIC_FOLDER=/usr/share/ng 12 | 13 | # Webhook Configuration 14 | # WEBHOOK_URL=https://example.com/webhook # אם מוגדר, וובהוק יישלח בעת יצירה/עדכון/מחיקת פוסט 15 | # WEBHOOK_VERIFY_TOKEN=your-secret-token # קוד אימות אופציונלי שיישלח עם הוובהוק --------------------------------------------------------------------------------