├── 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 | 
3 | 
4 | 
5 | 
6 | [](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 |
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 |
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})`; //``;
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 | 
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 |
--------------------------------------------------------------------------------
/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 # קוד אימות אופציונלי שיישלח עם הוובהוק
--------------------------------------------------------------------------------