├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app ├── default.go ├── events │ ├── join.go │ ├── message.go │ ├── message_delete.go │ ├── message_like.go │ ├── push_notification.go │ └── send_to_group.go ├── handlers │ ├── auth.go │ ├── auth_test.go │ ├── chat.go │ ├── developers.go │ ├── files.go │ ├── group.go │ ├── group_helpers.go │ ├── group_user.go │ ├── invite.go │ ├── me.go │ ├── mobile.go │ ├── security.go │ ├── theme.go │ ├── users.go │ ├── utils.go │ ├── void.go │ └── ws.go ├── message.go ├── requests │ ├── auth.go │ ├── contacts.go │ ├── dev.go │ ├── group.go │ ├── mobile.go │ ├── request.go │ └── user.go └── services │ ├── storage.go │ └── ws_cache.go ├── database ├── database.go ├── models.go ├── models │ ├── allowed_ip.go │ ├── android_push_notification.go │ ├── ban.go │ ├── base.go │ ├── block.go │ ├── email_verification.go │ ├── file.go │ ├── group.go │ ├── group_user.go │ ├── invite.go │ ├── message.go │ ├── message_likes.go │ ├── relations.go │ ├── reset_password.go │ ├── session.go │ ├── storage.go │ ├── theme.go │ ├── user.go │ ├── user_bot.go │ └── worker.go ├── rdb │ └── redis.go └── suite.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go ├── nginx └── nginx.conf ├── pkg ├── configs │ ├── build.json │ ├── env.go │ └── keys.go ├── encryption │ └── message.go ├── logger │ └── logger.go ├── middlewares │ ├── auth.go │ ├── cors.go │ ├── file.go │ ├── group_access.go │ ├── limiter.go │ ├── role.go │ └── session.go ├── problem │ └── problem.go ├── repository │ ├── block.go │ ├── bot.go │ ├── email_verification.go │ ├── group.go │ ├── group_types.go │ ├── group_user.go │ ├── invite.go │ ├── ips.go │ ├── messge.go │ ├── paginator │ │ ├── paginate.go │ │ └── utils.go │ ├── reset_password.go │ ├── session.go │ ├── theme.go │ ├── user.go │ └── utils.go ├── services │ ├── auth.go │ └── firebase.go ├── utils │ ├── difference.go │ ├── image_url.go │ ├── is_emoji.go │ ├── is_uuid.go │ ├── mail.go │ ├── password.go │ ├── random.go │ ├── remove_from_slice.go │ ├── role │ │ ├── role.go │ │ └── roles.go │ └── validator.go └── ws │ ├── connection.go │ ├── message.go │ └── ws.go ├── routes ├── api.go ├── app.go ├── bot.go ├── dev.go ├── error.go ├── init.go ├── storage.go └── ws.go ├── storage ├── default.webp ├── group.webp └── wizzl.webp ├── templates ├── ip.html ├── register.html └── reset-password.html └── tests └── app.go /.dockerignore: -------------------------------------------------------------------------------- 1 | storage 2 | templates 3 | bin 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FRONTEND_URL="" 2 | 3 | DEBUG=true 4 | SERVER_PORT=":3000" 5 | 6 | DB_HOST=database 7 | DB_PORT=3306 8 | DB_USER=root 9 | DB_PASS=password 10 | DB_NAME=api 11 | 12 | SESSION_LIFESPAN=1296000# 86 400 seconds (24 hour) - 1 296 000 (15 days) 13 | 14 | EMAIL_SENDER_ADDRESS="" 15 | EMAIL_SMTP_HOST="" 16 | EMAIL_SMTP_PORT=0 17 | EMAIL_SMTP_USER="" 18 | EMAIL_SMTP_PASS="" 19 | 20 | MAX_FILE_SIZE=5#mb 21 | FIREBASE_AUTH=""#base64 encoded service account key 22 | 23 | MESSAGE_ENCRYPTION_KEY=""#key for encrypting and decrypting messages (should be exatly 32 byte) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | tmp 4 | mysql-volume 5 | bin 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Install WebP image dependencies for go-webp 6 | # https://github.com/kolesa-team/go-webp 7 | RUN apk add --no-cache gcc musl-dev linux-headers libwebp-dev 8 | RUN rm -rf var/cache/* 9 | 10 | COPY go.mod go.sum .env ./ 11 | RUN go mod download 12 | 13 | COPY . . 14 | 15 | ENV CGO_ENABLED = 1 16 | RUN go build -o wizzl 17 | CMD ["./wizzl", "-"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Martin Mátyás Binder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # env copies the example env to the real place 2 | env: 3 | cp .env.example .env 4 | 5 | buildID: 6 | git show -s --format='{"build": "%h", "date": "%cD", "author": "%an" }' > ./pkg/configs/build.json 7 | 8 | build: buildID 9 | @go build -o ./bin/wizzl 10 | 11 | run: build 12 | @./bin/wizzl 13 | 14 | # handler makes a new handler inside the handlers folder to speed up things 15 | handler: 16 | @if [ -z "$(name)" ]; then \ 17 | echo "Please specify the name of the handler! Usage: make handler name='handler_name'"; \ 18 | elif [ -e "./app/handlers/$(name).go" ]; then \ 19 | echo "The handler already exists"; \ 20 | else \ 21 | pascal=$(call to_pascal, $(name)); \ 22 | camel=$(call to_camel, $(name)); \ 23 | printf "%s\n" \ 24 | "package handlers" \ 25 | "" \ 26 | "import \"github.com/gofiber/fiber/v2\"" \ 27 | "" \ 28 | "type $$camel struct{}" \ 29 | "" \ 30 | "var $$pascal $$camel" \ 31 | "" \ 32 | "func ($$camel) Index(*fiber.Ctx) error {" \ 33 | " return nil" \ 34 | "}" > ./app/handlers/$(name).go; \ 35 | echo "Handler successfully created"; \ 36 | fi 37 | 38 | # some defined methods 39 | define to_pascal 40 | $(shell echo $(1) | sed -e 's/_\([a-zA-Z]\)/\U\1/g' -e 's/^./\U&/') 41 | endef 42 | 43 | define to_camel 44 | $(shell echo $(1) | sed -e 's/_\([a-zA-Z]\)/\U\1/g' -e 's/^./\L&/' -e 's/_\([a-zA-Z]\)/\U\1/g') 45 | endef 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wizzl - Backend 2 | 3 | A high-performance backend for the Wizzl app, built using [Go](https://github.com/golang/go) and [Fiber](https://github.com/gofiber/fiber). Wizzl is a simple messaging platform available as both a website and app. 4 | 5 | ## Project Description 6 | 7 | The core logic of the app resides in the `app` folder. This includes HTTP handlers, request validation structs, services, and WebSocket event handlers. You'll find utility functions and globally accessible structs in the `pkg` folder. The application's routes are organized under the `routes` folder. Additionally, we store email templates in the `templates` folder. 8 | 9 | We also provide a `Makefile` that simplifies handler creation. Using the command `make handler name=custom_handler`, you can generate a new handler file under `app/handlers` with a pre-defined struct and method. 10 | 11 | ### Usage 12 | 13 | Wizzl is live at [wizzl.app](https://wizzl.app), and anyone can use it. If you'd like to run your own instance of Wizzl, follow these steps: 14 | 15 | Ensure you have `docker` and `docker-compose` installed. Then, simply run: 16 | 17 | ```bash 18 | docker compose up 19 | ``` 20 | 21 | This will start the backend service. By default, Wizzl runs on `127.0.0.1:3000`, but you can modify the `docker-compose.yml` file to change the port. 22 | 23 | ### Database 24 | 25 | We use a **MySQL MariaDB** database alongside [GORM](https://gorm.io) as our **ORM**, which is a perfect fit for the app. **Redis** is also integrated to improve performance, cache chat data, and group users, enabling real-time permission-based message delivery. 26 | 27 | All database models are located in the `database/models` folder. To add new models, define them in this folder, and manage relationships in the `relations.go` file for easier maintenance. 28 | 29 | ### Security 30 | 31 | Our backend employs `bcrypt` for password encryption and `AES` for message encryption, ensuring user privacy. The encryption keys are **securely** stored. 32 | 33 | ## Support us 34 | 35 | We truly appreciate any support, whether it’s through contributions, feedback, or spreading the word about **Wizzl**. Every bit helps us improve the platform and continue developing new features. 36 | 37 | If you’d like to support us financially, you can do so by donating through our [Ko-fi](https://ko-fi.com/bndrmrtn) page. Your donations help cover development costs, server expenses, and allow us to focus more on building a better experience for everyone. -------------------------------------------------------------------------------- /app/default.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wizzldev/chat/database/rdb" 6 | "github.com/wizzldev/chat/pkg/configs" 7 | "github.com/wizzldev/chat/pkg/logger" 8 | "github.com/wizzldev/chat/pkg/ws" 9 | "time" 10 | ) 11 | 12 | func WSActionHandler(conn *ws.Connection, userID uint, data []byte) error { 13 | wrapper, err := ws.NewMessage(data, conn) 14 | 15 | if err != nil { 16 | return err 17 | } 18 | 19 | msg := wrapper.Message 20 | 21 | if configs.Env.Debug { 22 | logger.WSNewEvent(wrapper.Resource, msg.Type, userID) 23 | } 24 | 25 | if msg.Type == "ping" { 26 | conn.Send(ws.MessageWrapper{ 27 | Message: &ws.Message{ 28 | Event: "pong", 29 | Data: "pong", 30 | HookID: msg.HookID, 31 | }, 32 | Resource: configs.DefaultWSResource, 33 | }) 34 | return nil 35 | } 36 | 37 | if msg.Type == "close" { 38 | logger.WSDisconnect(wrapper.Resource, userID) 39 | conn.Disconnect() 40 | return nil 41 | } 42 | 43 | _ = rdb.Redis.Set(fmt.Sprintf("user.is-online.%v", userID), []byte("true"), time.Minute*10) 44 | if wrapper.Resource != configs.DefaultWSResource { 45 | return MessageActionHandler(conn, userID, msg, wrapper.Resource) 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /app/events/join.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | "github.com/wizzldev/chat/pkg/logger" 7 | "github.com/wizzldev/chat/pkg/ws" 8 | ) 9 | 10 | func DispatchUserJoin(wsID string, userIDs []uint, user *models.User, groupID uint) error { 11 | m := &models.Message{ 12 | HasGroup: models.HasGroupID(groupID), 13 | HasMessageSender: models.HasMessageSenderID(user.ID), 14 | Content: "", 15 | Type: "join", 16 | DataJSON: "{}", 17 | } 18 | database.DB.Save(m) 19 | 20 | sentTo := ws.WebSocket.BroadcastToUsers(userIDs, wsID, ws.Message{ 21 | Event: "join", 22 | Data: user, 23 | HookID: "#", 24 | }) 25 | 26 | logger.WSSend(wsID, "message.join", user.ID, sentTo) 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /app/events/message.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/wizzldev/chat/database" 7 | "github.com/wizzldev/chat/database/models" 8 | "github.com/wizzldev/chat/pkg/logger" 9 | "github.com/wizzldev/chat/pkg/repository" 10 | "github.com/wizzldev/chat/pkg/utils" 11 | "github.com/wizzldev/chat/pkg/ws" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type ChatMessage struct { 17 | MessageID uint `json:"id"` 18 | Sender models.User `json:"sender"` 19 | Content string `json:"content"` 20 | Type string `json:"type"` 21 | DataJSON string `json:"data_json"` 22 | CreatedAt time.Time `json:"created_at"` 23 | UpdatedAt time.Time `json:"updated_at"` 24 | Reply *models.Message `json:"reply"` 25 | } 26 | 27 | type DataJSON struct { 28 | ReplyID uint `json:"reply_id"` 29 | } 30 | 31 | func DispatchMessage(wsID string, userIDs []uint, gID uint, user *models.User, msg *ws.ClientMessage) error { 32 | var dataJSON DataJSON 33 | err := json.Unmarshal([]byte(msg.DataJSON), &dataJSON) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | message := &models.Message{ 39 | HasGroup: models.HasGroup{ 40 | GroupID: gID, 41 | }, 42 | HasMessageSender: models.HasMessageSender{ 43 | SenderID: user.ID, 44 | }, 45 | Content: strings.TrimSpace(msg.Content), 46 | Type: msg.Type, 47 | DataJSON: msg.DataJSON, 48 | } 49 | if dataJSON.ReplyID > 0 { 50 | message.ReplyID = &dataJSON.ReplyID 51 | message.DataJSON = "{}" 52 | } 53 | 54 | database.DB.Create(message) 55 | message = repository.Message.FindOne(message.ID) 56 | 57 | if user.IsBot { 58 | userIDs = utils.RemoveFromSlice(userIDs, user.ID) 59 | } 60 | 61 | fmt.Println("sending to", userIDs) 62 | sentTo := ws.WebSocket.BroadcastToUsers(userIDs, wsID, ws.Message{ 63 | Event: "message", 64 | Data: ChatMessage{ 65 | MessageID: message.ID, 66 | Sender: *user, 67 | Content: message.Content, 68 | Type: message.Type, 69 | DataJSON: message.DataJSON, 70 | CreatedAt: message.CreatedAt, 71 | UpdatedAt: message.UpdatedAt, 72 | Reply: message.Reply, 73 | }, 74 | HookID: msg.HookID, 75 | }) 76 | logger.WSSend(wsID, "message", user.ID, sentTo) 77 | 78 | return DispatchPushNotification(utils.Difference(userIDs, sentTo), gID, PushNotificationData{ 79 | Title: fmt.Sprintf("%v %v", user.FirstName, user.LastName), 80 | Body: message.Content, 81 | Image: user.ImageURL, 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /app/events/message_delete.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "errors" 5 | "github.com/wizzldev/chat/database" 6 | "github.com/wizzldev/chat/database/models" 7 | "github.com/wizzldev/chat/pkg/logger" 8 | "github.com/wizzldev/chat/pkg/repository" 9 | "github.com/wizzldev/chat/pkg/ws" 10 | "strconv" 11 | ) 12 | 13 | func DispatchMessageDelete(wsID string, userIDs []uint, user *models.User, msg *ws.ClientMessage, deleteOthers bool) error { 14 | id, err := strconv.Atoi(msg.Content) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | m := repository.Message.FindOne(uint(id)) 20 | if m.Sender.ID != user.ID && !deleteOthers { 21 | return errors.New("cannot delete message") 22 | } 23 | m.Type = "deleted" 24 | m.Content = "" 25 | m.DataJSON = "{}" 26 | m.ReplyID = nil 27 | database.DB.Save(m) 28 | 29 | sentTo := ws.WebSocket.BroadcastToUsers(userIDs, wsID, ws.Message{ 30 | Event: "message.unSend", 31 | Data: m.ID, 32 | HookID: msg.HookID, 33 | }) 34 | 35 | logger.WSSend(wsID, "message.unSend", user.ID, sentTo) 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /app/events/message_like.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/wizzldev/chat/database" 6 | "github.com/wizzldev/chat/database/models" 7 | "github.com/wizzldev/chat/pkg/repository" 8 | "github.com/wizzldev/chat/pkg/utils" 9 | "github.com/wizzldev/chat/pkg/ws" 10 | "strings" 11 | ) 12 | 13 | type MessageLike struct { 14 | ID uint `json:"id"` 15 | Emoji string `json:"emoji"` 16 | Sender *models.User `json:"sender"` 17 | MessageID uint `json:"message_id"` 18 | } 19 | 20 | func DispatchMessageLike(wsID string, userIDs []uint, _ uint, user *models.User, msg *ws.ClientMessage) error { 21 | msgID := struct { 22 | MessageID uint `json:"message_id"` 23 | }{} 24 | 25 | err := json.NewDecoder(strings.NewReader(msg.DataJSON)).Decode(&msgID) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if repository.IsExists[models.Message]([]string{"id"}, []any{msgID.MessageID}) { 31 | messageLike := repository.FindModelBy[models.MessageLike]([]string{"message_id", "user_id"}, []any{msgID.MessageID, user.ID}) 32 | var isLiked bool 33 | 34 | if messageLike.ID > 0 { 35 | database.DB.Delete(messageLike) 36 | } else { 37 | messageLike = &models.MessageLike{ 38 | HasMessage: models.HasMessage{ 39 | MessageID: msgID.MessageID, 40 | }, 41 | HasUser: models.HasUser{ 42 | UserID: user.ID, 43 | }, 44 | Emoji: msg.Content, 45 | } 46 | database.DB.Create(messageLike) 47 | isLiked = true 48 | } 49 | 50 | var t = "like" 51 | if !isLiked { 52 | t += ".remove" 53 | } 54 | 55 | if user.IsBot { 56 | userIDs = utils.RemoveFromSlice(userIDs, user.ID) 57 | } 58 | 59 | _ = ws.WebSocket.BroadcastToUsers(userIDs, wsID, ws.Message{ 60 | Event: "message." + t, 61 | Data: &MessageLike{ 62 | ID: messageLike.ID, 63 | Emoji: messageLike.Emoji, 64 | Sender: user, 65 | MessageID: messageLike.MessageID, 66 | }, 67 | HookID: msg.HookID, 68 | }) 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /app/events/push_notification.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/wizzldev/chat/pkg/repository" 5 | "github.com/wizzldev/chat/pkg/services" 6 | "github.com/wizzldev/chat/pkg/utils" 7 | ) 8 | 9 | type PushNotificationData struct { 10 | Title string 11 | Body string 12 | Image string 13 | } 14 | 15 | func DispatchPushNotification(userIDs []uint, gID uint, data PushNotificationData) error { 16 | if len(userIDs) == 0 { 17 | return nil 18 | } 19 | 20 | err := services.PushNotification.Init() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | tokens := repository.User.FindAndroidNotifications(userIDs) 26 | if len(tokens) == 0 { 27 | return nil 28 | } 29 | 30 | return services.PushNotification.Send(tokens, gID, data.Title, data.Body, utils.GetAvatarURL(data.Image, 24)) 31 | } 32 | -------------------------------------------------------------------------------- /app/events/send_to_group.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/wizzldev/chat/pkg/ws" 5 | ) 6 | 7 | func SendToGroup(gID string, userIDs []uint, message ws.Message) { 8 | ws.WebSocket.BroadcastToUsers(userIDs, gID, message) 9 | } 10 | -------------------------------------------------------------------------------- /app/handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wizzldev/chat/pkg/services" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/wizzldev/chat/app/requests" 11 | "github.com/wizzldev/chat/database" 12 | "github.com/wizzldev/chat/database/models" 13 | "github.com/wizzldev/chat/pkg/configs" 14 | "github.com/wizzldev/chat/pkg/middlewares" 15 | "github.com/wizzldev/chat/pkg/repository" 16 | "github.com/wizzldev/chat/pkg/utils" 17 | "golang.org/x/text/cases" 18 | "golang.org/x/text/language" 19 | ) 20 | 21 | type auth struct{} 22 | 23 | var Auth auth 24 | 25 | func (a auth) Login(c *fiber.Ctx) error { 26 | loginRequest := validation[requests.Login](c) 27 | 28 | service := services.NewAuth(c) 29 | data, err := service.Login(&services.AuthRequest{ 30 | Email: loginRequest.Email, 31 | Password: loginRequest.Password, 32 | }) 33 | 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if data.MustVerifyIP { 39 | a.sendIPVerification(data.User, c.IP()) 40 | return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ 41 | "show_ip_modal": true, 42 | }) 43 | } 44 | 45 | return c.JSON(fiber.Map{ 46 | "user": data.User, 47 | "session": "Bearer " + data.Token, 48 | }) 49 | } 50 | 51 | func (a auth) Register(c *fiber.Ctx) error { 52 | registerRequest := validation[requests.Register](c) 53 | 54 | if repository.User.IsEmailExists(registerRequest.Email) { 55 | return fiber.NewError(fiber.StatusBadRequest, "An account already exists with this email address") 56 | } 57 | 58 | password, err := utils.NewPassword(registerRequest.Password).Hash() 59 | 60 | if err != nil { 61 | return err 62 | } 63 | 64 | user := models.User{ 65 | FirstName: registerRequest.FirstName, 66 | LastName: registerRequest.LastName, 67 | Email: registerRequest.Email, 68 | Password: password, 69 | ImageURL: configs.DefaultUserImage, 70 | } 71 | 72 | err = database.DB.Create(&user).Error 73 | if err != nil { 74 | return err 75 | } 76 | 77 | err = a.sendVerificationEmail(&user) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | database.DB.Create(&models.AllowedIP{ 83 | HasUser: models.HasUserID(user.ID), 84 | IP: c.IP(), 85 | Active: true, 86 | }) 87 | 88 | return c.Status(fiber.StatusCreated).JSON(fiber.Map{ 89 | "show_verification_modal": true, 90 | }) 91 | } 92 | 93 | func (auth) Logout(c *fiber.Ctx) error { 94 | sess, err := middlewares.Session(c) 95 | 96 | if err != nil { 97 | return err 98 | } 99 | 100 | database.DB.Where("session_id = ?", sess.ID()).Delete(&models.Session{}) 101 | 102 | return sess.Destroy() 103 | } 104 | 105 | func (a auth) RequestNewEmailVerification(c *fiber.Ctx) error { 106 | email := validation[requests.Email](c) 107 | user := repository.User.FindByEmail(email.Email) 108 | if user.ID < 1 { 109 | return fiber.NewError(fiber.StatusNotFound, "User not found") 110 | } 111 | 112 | if user.EmailVerifiedAt != nil { 113 | return fiber.NewError(fiber.StatusConflict, "Your email address is already verified") 114 | } 115 | 116 | emailVerification := repository.EmailVerification.FindLatestForUser(user.ID) 117 | if emailVerification.ID > 0 { 118 | return fiber.NewError(fiber.StatusConflict, "Email verification request already sent") 119 | } 120 | 121 | err := a.sendVerificationEmail(user) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | return c.JSON(fiber.Map{ 127 | "status": "success", 128 | }) 129 | } 130 | 131 | func (auth) VerifyEmail(c *fiber.Ctx) error { 132 | token := c.Params("token") 133 | user := repository.EmailVerification.FindUserByToken(token) 134 | 135 | if user.ID < 1 { 136 | return fiber.NewError(fiber.StatusNotFound, "User not found") 137 | } 138 | 139 | now := time.Now() 140 | user.EmailVerifiedAt = &now 141 | 142 | err := database.DB.Save(&user).Error 143 | if err != nil { 144 | return err 145 | } 146 | 147 | database.DB.Where("token = ?", token).Delete(&models.EmailVerification{}) 148 | 149 | return c.JSON(fiber.Map{ 150 | "message": "Password has been updated successfully", 151 | }) 152 | } 153 | 154 | func (a auth) RequestNewPassword(c *fiber.Ctx) error { 155 | newPasswordRequest := validation[requests.NewPassword](c) 156 | 157 | user := repository.User.FindByEmail(newPasswordRequest.Email) 158 | if user.ID < 1 { 159 | return fiber.NewError(fiber.StatusNotFound, "User not found") 160 | } 161 | 162 | resetPassword := repository.ResetPassword.FindLatestForUser(user.ID) 163 | if resetPassword.ID > 0 { 164 | return fiber.NewError(fiber.StatusConflict, "Reset password request already sent") 165 | } 166 | 167 | err := a.sendResetPasswordEmail(user) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | return c.JSON(fiber.Map{ 173 | "message": "New password has been sent to your email", 174 | }) 175 | } 176 | 177 | func (auth) SetNewPassword(c *fiber.Ctx) error { 178 | newPasswordRequest := validation[requests.SetNewPassword](c) 179 | token := c.Params("token") 180 | 181 | user := repository.ResetPassword.FindUserByToken(token) 182 | if user.ID < 1 { 183 | return fiber.NewError(fiber.StatusNotFound, "Invalid or expired token") 184 | } 185 | 186 | pass, err := utils.NewPassword(newPasswordRequest.Password).Hash() 187 | 188 | if err != nil { 189 | return err 190 | } 191 | 192 | user.Password = pass 193 | database.DB.Save(&user) 194 | 195 | database.DB.Where("token = ?", token).Delete(&models.ResetPassword{}) 196 | 197 | return c.JSON(fiber.Map{ 198 | "message": "Password has been updated successfully", 199 | }) 200 | } 201 | 202 | func (auth) IsPasswordResetExists(c *fiber.Ctx) error { 203 | token := c.Params("token") 204 | user := repository.ResetPassword.FindUserByToken(token) 205 | 206 | if user.ID < 1 { 207 | return fiber.NewError(fiber.StatusNotFound, "Reset password not found") 208 | } 209 | 210 | return c.JSON(fiber.Map{ 211 | "exists": true, 212 | }) 213 | } 214 | 215 | func (auth) AllowIP(c *fiber.Ctx) error { 216 | token := c.Params("token") 217 | var ip models.AllowedIP 218 | database.DB.Model(&models.AllowedIP{}).Where("verification = ?", token).First(&ip) 219 | if ip.ID < 1 { 220 | return fiber.NewError(fiber.StatusNotFound, "IP not found") 221 | } 222 | ip.Verification = "" 223 | ip.Active = true 224 | database.DB.Save(&ip) 225 | return c.JSON(fiber.Map{ 226 | "allowed": true, 227 | }) 228 | } 229 | 230 | // helpers 231 | func (auth) sendVerificationEmail(user *models.User) error { 232 | token := strconv.Itoa(int(user.ID)) + utils.NewRandom().String(30) 233 | 234 | err := database.DB.Create(&models.EmailVerification{ 235 | HasUser: models.HasUserID(user.ID), 236 | Token: token, 237 | }).Error 238 | 239 | if err != nil { 240 | return err 241 | } 242 | 243 | go func() { 244 | resetPasswordURL := fmt.Sprintf("%s/verify-email/%s", configs.Env.Frontend, token) 245 | mail := utils.NewMail(configs.Env.Email.SenderAddress, user.Email) 246 | mail.Subject("Verify your email address") 247 | mail.TemplateBody("register", map[string]string{ 248 | "firstName": cases.Title(language.English).String(user.FirstName), 249 | "verificationURL": resetPasswordURL, 250 | }, fmt.Sprintf("Click here to verify your email address", resetPasswordURL)) 251 | err := mail.Send() 252 | fmt.Println("Email sent with err:", err) 253 | }() 254 | 255 | return nil 256 | } 257 | 258 | func (auth) sendResetPasswordEmail(user *models.User) error { 259 | token := strconv.Itoa(int(user.ID)) + utils.NewRandom().String(30) 260 | resetPassword := models.ResetPassword{ 261 | HasUser: models.HasUser{ 262 | UserID: user.ID, 263 | }, 264 | Token: token, 265 | } 266 | 267 | err := database.DB.Create(&resetPassword).Error 268 | if err != nil { 269 | return err 270 | } 271 | 272 | go func() { 273 | resetPasswordURL := fmt.Sprintf("%s/reset-password/%s", configs.Env.Frontend, token) 274 | mail := utils.NewMail(configs.Env.Email.SenderAddress, user.Email) 275 | mail.Subject("Reset your password") 276 | mail.TemplateBody("reset-password", map[string]string{ 277 | "firstName": cases.Title(language.English).String(user.FirstName), 278 | "resetPasswordURL": resetPasswordURL, 279 | }, fmt.Sprintf("Click here to reset your password", resetPasswordURL)) 280 | err := mail.Send() 281 | fmt.Println("Email sent with err:", err) 282 | }() 283 | 284 | return nil 285 | } 286 | 287 | func (auth) sendIPVerification(user *models.User, ip string) { 288 | token := strconv.Itoa(int(user.ID)) + utils.NewRandom().String(30) 289 | ipVerification := models.AllowedIP{ 290 | HasUser: models.HasUserID(user.ID), 291 | IP: ip, 292 | Active: false, 293 | Verification: token, 294 | } 295 | database.DB.Create(&ipVerification) 296 | 297 | go func() { 298 | verifyIPURL := fmt.Sprintf("%s/ip-verification/%s", configs.Env.Frontend, token) 299 | mail := utils.NewMail(configs.Env.Email.SenderAddress, user.Email) 300 | mail.Subject("IP Verification") 301 | mail.TemplateBody("ip", map[string]string{ 302 | "firstName": cases.Title(language.English).String(user.FirstName), 303 | "ip": ip, 304 | "verifyIPURL": verifyIPURL, 305 | }, fmt.Sprintf("Click here to verify that IP address", verifyIPURL)) 306 | err := mail.Send() 307 | fmt.Println("Email sent with err:", err) 308 | }() 309 | } 310 | -------------------------------------------------------------------------------- /app/handlers/auth_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/wizzldev/chat/app/requests" 9 | "github.com/wizzldev/chat/database" 10 | "github.com/wizzldev/chat/tests" 11 | "net/http/httptest" 12 | "testing" 13 | ) 14 | 15 | func Test_Register(t *testing.T) { 16 | app := tests.NewApp("../..") 17 | 18 | // Connect to a test db 19 | database.MustConnectTestDB() 20 | 21 | app.Post("/", requests.Use[requests.Register](), Auth.Register) 22 | 23 | data, _ := json.Marshal(fiber.Map{ 24 | "first_name": "John", 25 | "last_name": "Doe", 26 | "email": "john.doe@example.com", 27 | "password": "secret1234", 28 | }) 29 | 30 | req := httptest.NewRequest("POST", "/", bytes.NewReader(data)) 31 | req.Header.Set("Content-Type", "application/json") 32 | 33 | res, err := app.Test(req, -1) 34 | 35 | if err != nil { 36 | t.Fatal("HTTP Request failed:", err) 37 | } 38 | 39 | assert.Equal(t, fiber.StatusCreated, res.StatusCode, "Response should be 201") 40 | 41 | assert.Equal(t, nil, tests.CleanUp(), "Cleanup test") 42 | } 43 | 44 | func Test_Login(t *testing.T) { 45 | app := tests.NewApp("../..") 46 | 47 | // Connect to a test db 48 | database.MustConnectTestDB() 49 | 50 | app.Post("/", requests.Use[requests.Login](), Auth.Login) 51 | 52 | data, _ := json.Marshal(fiber.Map{ 53 | "email": "jane@example.com", 54 | "password": "secret1234", 55 | }) 56 | 57 | req := httptest.NewRequest("POST", "/", bytes.NewReader(data)) 58 | req.Header.Set("Content-Type", "application/json") 59 | 60 | res, err := app.Test(req, -1) 61 | 62 | if err != nil { 63 | t.Fatal("HTTP Request failed:", err) 64 | } 65 | 66 | assert.Equal(t, fiber.StatusForbidden, res.StatusCode, "Response should be 200") 67 | 68 | assert.Equal(t, nil, tests.CleanUp(), "Cleanup test") 69 | } 70 | -------------------------------------------------------------------------------- /app/handlers/chat.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/wizzldev/chat/app/events" 7 | "github.com/wizzldev/chat/app/requests" 8 | "github.com/wizzldev/chat/app/services" 9 | "github.com/wizzldev/chat/database" 10 | "github.com/wizzldev/chat/database/models" 11 | "github.com/wizzldev/chat/pkg/repository" 12 | "github.com/wizzldev/chat/pkg/utils" 13 | "github.com/wizzldev/chat/pkg/utils/role" 14 | "github.com/wizzldev/chat/pkg/ws" 15 | "net/url" 16 | "strconv" 17 | ) 18 | 19 | type chat struct { 20 | *services.Storage 21 | Cache *services.WSCache 22 | } 23 | 24 | var Chat = &chat{} 25 | 26 | func (ch *chat) Init(store *services.Storage, wsCache *services.WSCache) { 27 | ch.Storage = store 28 | ch.Cache = wsCache 29 | } 30 | 31 | func (*chat) Contacts(c *fiber.Ctx) error { 32 | page := c.QueryInt("page", 1) 33 | data := repository.Group.GetContactsForUser(authUserID(c), page, authUser(c)) 34 | return c.JSON(data) 35 | } 36 | 37 | func (*chat) PrivateMessage(c *fiber.Ctx) error { 38 | requestedUserID, err := c.ParamsInt("id", 0) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | userID := uint(requestedUserID) 44 | user := authUser(c) 45 | 46 | if repository.Block.IsBlocked(userID, user.ID) { 47 | return fiber.NewError(fiber.StatusForbidden, "You are blocked") 48 | } 49 | 50 | if gID, ok := repository.Group.IsGroupExists([2]uint{user.ID, userID}); ok { 51 | return c.JSON(fiber.Map{ 52 | "pm_id": gID, 53 | }) 54 | } 55 | 56 | g := models.Group{ 57 | Users: []*models.User{ 58 | { 59 | Base: models.Base{ 60 | ID: userID, 61 | }, 62 | }, 63 | user, 64 | }, 65 | IsPrivateMessage: true, 66 | } 67 | 68 | database.DB.Create(&g) 69 | database.DB.Create(&models.Message{ 70 | HasGroup: models.HasGroup{ 71 | GroupID: g.ID, 72 | }, 73 | HasMessageSender: models.HasMessageSender{ 74 | SenderID: user.ID, 75 | }, 76 | Type: "chat.create", 77 | DataJSON: "{}", 78 | }) 79 | 80 | return c.JSON(fiber.Map{ 81 | "pm_id": g.ID, 82 | }) 83 | } 84 | 85 | func (*chat) Search(c *fiber.Ctx) error { 86 | v := validation[requests.SearchContacts](c) 87 | rawPage := c.Query("page", "1") 88 | page, err := strconv.Atoi(rawPage) 89 | 90 | if err != nil { 91 | return err 92 | } 93 | 94 | users := repository.User.Search(v.FirstName, v.LastName, v.Email, page) 95 | return c.JSON(users) 96 | } 97 | 98 | func (*chat) Find(c *fiber.Ctx) error { 99 | id, err := c.ParamsInt("id", 0) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | user := authUser(c) 105 | 106 | var isYourProfile = false 107 | 108 | g := repository.Group.GetChatUser(uint(id), authUserID(c)) 109 | if g.ImageURL == nil && g.Name == nil { 110 | g.ImageURL = &user.ImageURL 111 | gName := "You#allowTranslation" 112 | g.Name = &gName 113 | isYourProfile = true 114 | } 115 | 116 | roles := role.Roles{} 117 | roles = append(roles, repository.Group.GetUserRoles(g.ID, authUserID(c), *role.NewRoles(g.Roles))...) 118 | 119 | pagination, err := repository.Message.CursorPaginate(uint(id), c.Query("cursor")) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | return c.JSON(fiber.Map{ 125 | "group": g, 126 | "messages": pagination, 127 | "user_roles": roles, 128 | "is_your_profile": isYourProfile, 129 | }) 130 | } 131 | 132 | func (*chat) Messages(c *fiber.Ctx) error { 133 | id, err := c.ParamsInt("id", 0) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | pagination, err := repository.Message.CursorPaginate(uint(id), c.Query("cursor")) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | return c.JSON(pagination) 144 | } 145 | 146 | func (*chat) FindMessage(c *fiber.Ctx) error { 147 | id, err := c.ParamsInt("messageID") 148 | if err != nil { 149 | return err 150 | } 151 | 152 | return c.JSON(repository.Message.FindOne(uint(id))) 153 | } 154 | 155 | func (ch *chat) UploadFile(c *fiber.Ctx) error { 156 | serverID := c.Params("id") 157 | gID, err := strconv.Atoi(serverID) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | fileH, err := c.FormFile("file") 163 | if err != nil { 164 | return err 165 | } 166 | 167 | token := utils.NewRandom().String(50) 168 | file, err := ch.Store(fileH, token) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | user := authUser(c) 174 | 175 | err = events.DispatchMessage(serverID, ch.Cache.GetGroupMemberIDs(serverID), uint(gID), user, &ws.ClientMessage{ 176 | Content: "none", 177 | Type: "file:" + file.Type, 178 | DataJSON: fmt.Sprintf(`{"fetchFrom": "/storage/files/%s/%s", "hasAccessToken": true, "accessToken": "%s"}`, file.Discriminator, url.QueryEscape(file.Name), token), 179 | HookID: c.Query("hook_id"), 180 | }) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | return c.JSON(fiber.Map{ 186 | "status": "success", 187 | }) 188 | } 189 | -------------------------------------------------------------------------------- /app/handlers/developers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/wizzldev/chat/app/requests" 8 | "github.com/wizzldev/chat/database" 9 | "github.com/wizzldev/chat/database/models" 10 | "github.com/wizzldev/chat/pkg/repository" 11 | "github.com/wizzldev/chat/pkg/utils" 12 | ) 13 | 14 | type developers struct{} 15 | 16 | var Developers developers 17 | 18 | func (developers) GetApplications(c *fiber.Ctx) error { 19 | bots := repository.Bot.FindBotsForUserID(authUserID(c)) 20 | return c.JSON(fiber.Map{ 21 | "bots": bots, 22 | }) 23 | } 24 | 25 | func (developers) CreateApplication(c *fiber.Ctx) error { 26 | userID := authUserID(c) 27 | count := repository.Bot.CountForUser(userID) 28 | if count >= 3 { 29 | return fiber.NewError(fiber.StatusTooManyRequests, "A user cannot create more than 3 bots") 30 | } 31 | 32 | request := validation[requests.NewBot](c) 33 | 34 | token := utils.NewRandom().String(200) 35 | 36 | bot := models.User{ 37 | FirstName: request.Name, 38 | Password: token, 39 | ImageURL: "default.webp", 40 | IsBot: true, 41 | } 42 | 43 | err := database.DB.Create(&bot).Error 44 | if err != nil { 45 | return errors.New("Unknown error occurred when creating Bot") 46 | } 47 | 48 | bot.EnableIPCheck = false 49 | database.DB.Save(&bot) 50 | 51 | botUser := &models.UserBot{ 52 | HasUser: models.HasUserID(userID), 53 | HasBot: models.HasBotID(bot.ID), 54 | } 55 | database.DB.Create(&botUser) 56 | 57 | return c.JSON(fiber.Map{ 58 | "application_id": bot.ID, 59 | "token": token, 60 | }) 61 | } 62 | 63 | func (developers) RegenerateApplicationToken(c *fiber.Ctx) error { 64 | rawID, err := c.ParamsInt("id") 65 | if err != nil { 66 | return err 67 | } 68 | 69 | userID := authUserID(c) 70 | botID := uint(rawID) 71 | 72 | bot := repository.Bot.FindUserBot(userID, botID) 73 | if !bot.Exists() { 74 | return fiber.ErrNotFound 75 | } 76 | 77 | token := utils.NewRandom().String(200) 78 | bot.Password = token 79 | 80 | return c.JSON(fiber.Map{ 81 | "application_id": bot.ID, 82 | "token": token, 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /app/handlers/files.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/app/services" 6 | "net/http" 7 | "os" 8 | "time" 9 | ) 10 | 11 | type files struct { 12 | *services.Storage 13 | } 14 | 15 | var Files = &files{} 16 | 17 | func (s *files) Init(store *services.Storage) { 18 | s.Storage = store 19 | } 20 | 21 | func (s *files) Get(c *fiber.Ctx) error { 22 | return c.SendFile(s.LocalFile(c).Path) 23 | } 24 | 25 | func (s *files) GetInfo(c *fiber.Ctx) error { 26 | return c.JSON(s.LocalFile(c)) 27 | } 28 | 29 | func (s *files) GetAvatar(c *fiber.Ctx) error { 30 | fileModel := s.LocalFile(c) 31 | if fileModel.Type != "avatar" { 32 | return fiber.ErrBadRequest 33 | } 34 | 35 | size, _ := c.ParamsInt("size") 36 | if size <= 0 { 37 | size = 256 38 | } 39 | 40 | file, err := os.Open(fileModel.Path) 41 | if err != nil { 42 | return fiber.NewError(fiber.StatusInternalServerError, "Failed to open file") 43 | } 44 | 45 | stream, err := s.WebPStream(file, uint(size)) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | c.Set("Content-Type", "image/webp") 51 | c.Set("Content-Disposition", "inline; filename=\"avatar.webp\"") 52 | c.Set("Cache-Control", "public, max-age=3600") 53 | c.Set("Last-Modified", fileModel.UpdatedAt.Format(http.TimeFormat)) 54 | c.Set("Expires", time.Now().Add(24*time.Hour).Format(http.TimeFormat)) 55 | return c.SendStream(stream) 56 | } 57 | -------------------------------------------------------------------------------- /app/handlers/group.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/lib/pq" 11 | "github.com/wizzldev/chat/app/events" 12 | "github.com/wizzldev/chat/app/requests" 13 | "github.com/wizzldev/chat/app/services" 14 | "github.com/wizzldev/chat/database" 15 | "github.com/wizzldev/chat/database/models" 16 | "github.com/wizzldev/chat/pkg/configs" 17 | "github.com/wizzldev/chat/pkg/repository" 18 | "github.com/wizzldev/chat/pkg/utils/role" 19 | "github.com/wizzldev/chat/pkg/ws" 20 | ) 21 | 22 | type group struct { 23 | groupHelpers 24 | *services.Storage 25 | Cache *services.WSCache 26 | } 27 | 28 | var Group = &group{} 29 | 30 | func (g *group) Init(store *services.Storage, cache *services.WSCache) { 31 | g.Storage = store 32 | g.Cache = cache 33 | } 34 | 35 | func (*group) New(c *fiber.Ctx) error { 36 | data := validation[requests.NewGroup](c) 37 | 38 | userIDs := repository.IDsExists[models.User](data.UserIDs) 39 | var users []*models.User 40 | 41 | users = append(users, &models.User{ 42 | Base: models.Base{ID: authUserID(c)}, 43 | }) 44 | for _, id := range userIDs { 45 | users = append(users, &models.User{Base: models.Base{ID: id}}) 46 | } 47 | 48 | var roles pq.StringArray 49 | for _, r := range data.Roles { 50 | roles = append(roles, r) 51 | } 52 | 53 | userID := authUserID(c) 54 | 55 | var ( 56 | img = configs.DefaultGroupImage 57 | name = data.Name 58 | ) 59 | g := models.Group{ 60 | ImageURL: &img, 61 | Name: &name, 62 | Roles: roles, 63 | IsPrivateMessage: false, 64 | Users: users, 65 | HasUser: models.HasUserID(userID), 66 | } 67 | 68 | database.DB.Create(&g) 69 | 70 | message := models.Message{ 71 | HasGroup: models.HasGroupID(g.ID), 72 | Type: "chat.create", 73 | HasMessageSender: models.HasMessageSenderID(userID), 74 | } 75 | database.DB.Create(&message) 76 | 77 | database.DB.Where("group_id = ? and user_id = ?", g.ID, userID).Save(&models.GroupUser{ 78 | HasGroup: models.HasGroupID(g.ID), 79 | HasUser: models.HasUserID(userID), 80 | Roles: []string{string(role.Creator)}, 81 | }) 82 | 83 | return c.JSON(fiber.Map{ 84 | "group_id": g.ID, 85 | }) 86 | } 87 | 88 | func (*group) GetInfo(c *fiber.Ctx) error { 89 | id, err := c.ParamsInt("id") 90 | if err != nil { 91 | return err 92 | } 93 | 94 | userID := authUserID(c) 95 | g := repository.Group.GetChatUser(uint(id), userID) 96 | 97 | return c.JSON(fiber.Map{ 98 | "id": g.ID, 99 | "created_at": g.CreatedAt, 100 | "updated_at": g.UpdatedAt, 101 | "image_url": g.ImageURL, 102 | "name": g.Name, 103 | "roles": g.Roles, 104 | "is_private_message": g.IsPrivateMessage, 105 | "is_verified": g.Verified, 106 | "custom_invite": g.CustomInvite, 107 | "emoji": g.Emoji, 108 | "your_roles": repository.Group.GetUserRoles(g.ID, userID, *role.NewRoles(g.Roles)), 109 | "theme_id": g.ThemeID, 110 | }) 111 | } 112 | 113 | func (g *group) UploadGroupImage(c *fiber.Ctx) error { 114 | gr, err := g.group(c.Params("id")) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | if gr.IsPrivateMessage { 120 | return fiber.ErrBadRequest 121 | } 122 | 123 | fileH, err := c.FormFile("image") 124 | if err != nil { 125 | return err 126 | } 127 | 128 | file, err := g.Storage.StoreAvatar(fileH) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | // FIX: firstly delete the image then save the new 134 | img := *gr.ImageURL 135 | if img != configs.DefaultGroupImage { 136 | _ = g.Storage.RemoveByDisc(strings.SplitN(img, ".", 2)[0]) 137 | } 138 | 139 | img = file.Discriminator + ".webp" 140 | gr.ImageURL = &img 141 | database.DB.Save(gr) 142 | 143 | g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 144 | Type: "update.image", 145 | HookID: c.Query("hook_id"), 146 | DataJSON: "{}", 147 | }) 148 | 149 | return c.JSON(gr) 150 | } 151 | 152 | func (g *group) ModifyRoles(c *fiber.Ctx) error { 153 | serverID := c.Params("id") 154 | gr, err := g.group(serverID) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | if gr.IsPrivateMessage { 160 | return fiber.ErrBadRequest 161 | } 162 | 163 | roles := validation[requests.ModifyRoles](c) 164 | 165 | userRoles := repository.Group.GetUserRoles(gr.ID, authUserID(c), *role.NewRoles(gr.Roles)) 166 | if !userRoles.Can(role.Creator) { 167 | if slices.Contains(gr.Roles, string(role.Creator)) != slices.Contains(roles.Roles, string(role.Creator)) { 168 | return fiber.ErrForbidden 169 | } 170 | } 171 | 172 | gr.Roles = roles.Roles 173 | 174 | database.DB.Save(gr) 175 | 176 | userIDs := g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 177 | Type: "update.roles", 178 | DataJSON: "{}", 179 | }) 180 | 181 | events.SendToGroup(serverID, userIDs, ws.Message{ 182 | Event: "reload", 183 | Data: nil, 184 | }) 185 | 186 | return c.JSON(fiber.Map{ 187 | "status": "ok", 188 | }) 189 | } 190 | 191 | func (g *group) EditName(c *fiber.Ctx) error { 192 | gr, err := g.group(c.Params("id")) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | if gr.IsPrivateMessage { 198 | return fiber.ErrBadRequest 199 | } 200 | 201 | data := validation[requests.EditGroupName](c) 202 | gr.Name = &data.Name 203 | database.DB.Save(gr) 204 | 205 | g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 206 | Type: "update.name", 207 | DataJSON: "{}", 208 | }) 209 | 210 | return c.JSON(fiber.Map{ 211 | "status": "ok", 212 | }) 213 | } 214 | 215 | func (g *group) CustomInvite(c *fiber.Ctx) error { 216 | gr, err := g.group(c.Params("id")) 217 | if err != nil { 218 | return err 219 | } 220 | 221 | data := validation[requests.CustomInvite](c) 222 | 223 | if gr.IsPrivateMessage { 224 | return fiber.ErrBadRequest 225 | } 226 | 227 | if repository.Group.CustomInviteExists(data.Invite) { 228 | return c.Status(fiber.StatusConflict).JSON(fiber.Map{ 229 | "status": "already-exists", 230 | }) 231 | } 232 | 233 | gr.CustomInvite = &data.Invite 234 | database.DB.Save(gr) 235 | 236 | return c.JSON(fiber.Map{ 237 | "status": "ok", 238 | }) 239 | } 240 | 241 | func (g *group) Leave(c *fiber.Ctx) error { 242 | gr, err := g.group(c.Params("id")) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | userID := authUserID(c) 248 | 249 | if gr.IsPrivateMessage || gr.UserID == userID { 250 | return fiber.ErrBadRequest 251 | } 252 | 253 | repository.GroupUser.Delete(gr.ID, userID) 254 | 255 | g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 256 | Type: "leave", 257 | }) 258 | 259 | return c.JSON(fiber.Map{ 260 | "status": "ok", 261 | }) 262 | } 263 | 264 | func (g *group) Delete(c *fiber.Ctx) error { 265 | gr, err := g.group(c.Params("id")) 266 | if err != nil { 267 | return err 268 | } 269 | 270 | img := *gr.ImageURL 271 | if img != configs.DefaultGroupImage { 272 | _ = g.Storage.RemoveByDisc(strings.SplitN(img, ".", 2)[0]) 273 | } 274 | 275 | g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 276 | Type: "delete", 277 | Content: strconv.Itoa(int(gr.ID)), 278 | }) 279 | 280 | worker := &models.Worker{ 281 | Command: "cleanup.group", 282 | Data: strconv.Itoa(int(gr.ID)), 283 | } 284 | database.DB.Create(&worker) 285 | 286 | return c.JSON(fiber.Map{ 287 | "status": "ok", 288 | }) 289 | } 290 | 291 | func (g *group) Emoji(c *fiber.Ctx) error { 292 | serverID := c.Params("id") 293 | 294 | gr, err := g.group(serverID) 295 | if err != nil { 296 | return err 297 | } 298 | 299 | data := validation[requests.Emoji](c) 300 | gr.Emoji = &data.Emoji 301 | database.DB.Save(gr) 302 | 303 | userIDs := g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 304 | Type: "emoji.update", 305 | DataJSON: "{}", 306 | }) 307 | 308 | events.SendToGroup(serverID, userIDs, ws.Message{ 309 | Event: "reload", 310 | Data: nil, 311 | }) 312 | 313 | return c.JSON(fiber.Map{ 314 | "status": "ok", 315 | }) 316 | } 317 | 318 | func (g *group) Users(c *fiber.Ctx) error { 319 | gID, err := c.ParamsInt("id") 320 | if err != nil { 321 | return err 322 | } 323 | 324 | data, err := repository.Group.Users(uint(gID), c.Query("cursor")) 325 | if err != nil { 326 | return err 327 | } 328 | 329 | return c.JSON(data) 330 | } 331 | 332 | func (g *group) UserCount(c *fiber.Ctx) error { 333 | gID, err := c.ParamsInt("id") 334 | if err != nil { 335 | return err 336 | } 337 | 338 | return c.JSON(fiber.Map{ 339 | "count": repository.Group.UserCount(uint(gID)), 340 | }) 341 | } 342 | 343 | func (g *group) SetTheme(c *fiber.Ctx) error { 344 | serverID := c.Params("id") 345 | gr, err := g.group(serverID) 346 | if err != nil { 347 | return err 348 | } 349 | 350 | themeID, err := c.ParamsInt("themeID") 351 | if err != nil { 352 | return err 353 | } 354 | 355 | th := repository.Theme.Find(uint(themeID)) 356 | 357 | if th.ID < 1 { 358 | return fiber.ErrNotFound 359 | } 360 | 361 | gr.ThemeID = &th.ID 362 | database.DB.Save(&gr) 363 | 364 | userIDs := g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 365 | Type: "theme.update", 366 | DataJSON: "{}", 367 | }) 368 | 369 | events.SendToGroup(serverID, userIDs, ws.Message{ 370 | Event: "reload", 371 | Data: nil, 372 | }) 373 | 374 | return c.JSON(gr) 375 | } 376 | 377 | func (g *group) RemoveTheme(c *fiber.Ctx) error { 378 | serverID := c.Params("id") 379 | gr, err := g.group(serverID) 380 | if err != nil { 381 | return err 382 | } 383 | 384 | gr.ThemeID = nil 385 | gr.Theme = nil 386 | database.DB.Save(&gr) 387 | 388 | userIDs := g.sendMessage(g.Cache, gr.ID, authUser(c), &ws.ClientMessage{ 389 | Type: "theme.update", 390 | DataJSON: "{}", 391 | }) 392 | 393 | events.SendToGroup(serverID, userIDs, ws.Message{ 394 | Event: "reload", 395 | Data: nil, 396 | }) 397 | 398 | return c.JSON(gr) 399 | } 400 | 401 | func (g *group) InviteApplication(c *fiber.Ctx) error { 402 | request := validation[requests.ApplicationInvite](c) 403 | bot := repository.Bot.FindByID(request.BotID) 404 | if !bot.Exists() { 405 | return fiber.NewError(fiber.StatusBadRequest, "No bot exists with that id") 406 | } 407 | 408 | gr := repository.Group.Find(request.GroupID) 409 | if !gr.Exists() { 410 | return fiber.ErrNotFound 411 | } 412 | 413 | userID := authUserID(c) 414 | 415 | var roles role.Roles 416 | if gr.UserID == userID { 417 | roles = *role.All() 418 | } else { 419 | roles = repository.Group.GetUserRoles(uint(request.GroupID), userID, *role.NewRoles(gr.Roles)) 420 | } 421 | 422 | if !roles.Can(role.CreateIntegration) { 423 | return fiber.NewError(fiber.StatusForbidden, "You are not allowed to access this resource") 424 | } 425 | 426 | if !repository.Group.IsGroupUserExists(gr.ID, bot.ID) { 427 | database.DB.Create(&models.GroupUser{ 428 | HasUser: models.HasUserID(bot.ID), 429 | HasGroup: models.HasGroupID(gr.ID), 430 | }) 431 | 432 | g.Cache.DisposeGroupMemberIDs(fmt.Sprint(gr.ID)) 433 | } 434 | 435 | return c.JSON(fiber.Map{ 436 | "status": "success", 437 | }) 438 | } 439 | -------------------------------------------------------------------------------- /app/handlers/group_helpers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/wizzldev/chat/app/events" 7 | "github.com/wizzldev/chat/app/services" 8 | "github.com/wizzldev/chat/database/models" 9 | "github.com/wizzldev/chat/pkg/repository" 10 | "github.com/wizzldev/chat/pkg/utils/role" 11 | "github.com/wizzldev/chat/pkg/ws" 12 | "strconv" 13 | ) 14 | 15 | type groupHelpers struct{} 16 | 17 | func (groupHelpers) GetAllRoles(c *fiber.Ctx) error { 18 | return c.JSON(fiber.Map{ 19 | "roles": role.All(), 20 | "recommended": []role.Role{ 21 | role.EditGroupImage, 22 | role.EditGroupName, 23 | role.EditGroupTheme, 24 | role.SendMessage, 25 | role.AttachFile, 26 | role.DeleteMessage, 27 | role.CreateIntegration, 28 | role.KickUser, 29 | role.InviteUser, 30 | }, 31 | }) 32 | } 33 | 34 | func (groupHelpers) sendMessage(cache *services.WSCache, gID uint, user *models.User, message *ws.ClientMessage) []uint { 35 | serverID := strconv.Itoa(int(gID)) 36 | userIDs := cache.GetGroupMemberIDs(serverID) 37 | _ = events.DispatchMessage(serverID, userIDs, gID, user, message) 38 | return userIDs 39 | } 40 | 41 | func (groupHelpers) group(id string) (*models.Group, error) { 42 | idInt, err := strconv.Atoi(id) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | g := repository.Group.Find(uint(idInt)) 48 | if g.ID < 1 { 49 | return nil, errors.New("group does not exits") 50 | } 51 | return g, nil 52 | } 53 | -------------------------------------------------------------------------------- /app/handlers/group_user.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/app/requests" 6 | "github.com/wizzldev/chat/database/models" 7 | "github.com/wizzldev/chat/pkg/repository" 8 | ) 9 | 10 | type groupUser struct{} 11 | 12 | var GroupUser groupUser 13 | 14 | func (g groupUser) EditNickName(c *fiber.Ctx) error { 15 | gu, err := g.helpData(c) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | data := validation[requests.Nickname](c) 21 | gu.NickName = &data.Nickname 22 | 23 | err = repository.GroupUser.Update(gu) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return c.JSON(fiber.Map{ 29 | "status": "ok", 30 | }) 31 | } 32 | 33 | func (g groupUser) RemoveNickName(c *fiber.Ctx) error { 34 | gu, err := g.helpData(c) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | gu.NickName = nil 40 | err = repository.GroupUser.Update(gu) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return c.JSON(fiber.Map{ 46 | "status": "ok", 47 | }) 48 | } 49 | 50 | func (groupUser) helpData(c *fiber.Ctx) (*models.GroupUser, error) { 51 | gID, err := c.ParamsInt("id") 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | userID, err := c.ParamsInt("userID") 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | gu, err := repository.GroupUser.Find(uint(gID), uint(userID)) 62 | 63 | return gu, err 64 | } 65 | -------------------------------------------------------------------------------- /app/handlers/invite.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/wizzldev/chat/app/events" 7 | "github.com/wizzldev/chat/app/requests" 8 | "github.com/wizzldev/chat/app/services" 9 | "github.com/wizzldev/chat/database" 10 | "github.com/wizzldev/chat/database/models" 11 | "github.com/wizzldev/chat/pkg/repository" 12 | "strconv" 13 | ) 14 | 15 | type invite struct { 16 | cache *services.WSCache 17 | } 18 | 19 | var Invite invite 20 | 21 | func (i *invite) Init(cache *services.WSCache) { 22 | i.cache = cache 23 | } 24 | 25 | func (*invite) Create(c *fiber.Ctx) error { 26 | id, err := c.ParamsInt("id") 27 | if err != nil { 28 | return err 29 | } 30 | 31 | data := validation[requests.NewInvite](c) 32 | 33 | i := &models.Invite{ 34 | HasUser: models.HasUserID(authUserID(c)), 35 | HasGroup: models.HasGroupID(uint(id)), 36 | Key: repository.Invite.CreateCode(), 37 | Expiration: data.Expiration, 38 | } 39 | 40 | if data.MaxUsage > 0 { 41 | i.MaxUsage = &data.MaxUsage 42 | } 43 | 44 | database.DB.Create(i) 45 | 46 | return c.JSON(i) 47 | } 48 | 49 | func (i *invite) Describe(c *fiber.Ctx) error { 50 | userID := authUserID(c) 51 | groupID, err := i.getGroupID(c.Params("code", ""), userID) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | gr := repository.Group.Find(groupID) 57 | 58 | if gr.ID == 0 { 59 | return fiber.ErrNotFound 60 | } 61 | 62 | return c.JSON(gr) 63 | } 64 | 65 | func (i *invite) Use(c *fiber.Ctx) error { 66 | userID := authUserID(c) 67 | 68 | groupID, err := i.getGroupID(c.Params("code", ""), userID) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if !repository.Group.IsGroupUserExists(groupID, userID) { 74 | gu := &models.GroupUser{ 75 | HasGroup: models.HasGroupID(groupID), 76 | HasUser: models.HasUserID(userID), 77 | } 78 | database.DB.Create(gu) 79 | 80 | serverID := strconv.Itoa(int(groupID)) 81 | _ = events.DispatchUserJoin(serverID, i.cache.GetGroupMemberIDs(serverID), authUser(c), groupID) 82 | } 83 | 84 | return c.JSON(fiber.Map{ 85 | "status": "success", 86 | "group_id": groupID, 87 | }) 88 | } 89 | 90 | func (*invite) getGroupID(code string, userID uint) (uint, error) { 91 | inv := repository.Invite.FindInviteByCode(code) 92 | fmt.Println("code", code, inv) 93 | if inv.IsValid() { 94 | if repository.Group.IsBanned(inv.GroupID, userID) { 95 | return 0, fiber.ErrForbidden 96 | } 97 | 98 | inv.Decrement() 99 | database.DB.Save(inv) 100 | return inv.GroupID, nil 101 | } 102 | 103 | g := repository.Invite.FindGroupInviteByCode(code) 104 | if !g.Exists() { 105 | return 0, fiber.ErrNotFound 106 | } 107 | 108 | if repository.Group.IsBanned(g.ID, userID) { 109 | return 0, fiber.ErrForbidden 110 | } 111 | 112 | return g.ID, nil 113 | } 114 | -------------------------------------------------------------------------------- /app/handlers/me.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/wizzldev/chat/app/requests" 7 | "github.com/wizzldev/chat/app/services" 8 | "github.com/wizzldev/chat/database" 9 | "github.com/wizzldev/chat/pkg/configs" 10 | "strings" 11 | ) 12 | 13 | type me struct { 14 | *services.Storage 15 | } 16 | 17 | var Me = &me{} 18 | 19 | func (m *me) Init(store *services.Storage) { 20 | m.Storage = store 21 | } 22 | 23 | func (*me) Hello(c *fiber.Ctx) error { 24 | user := authUser(c) 25 | return c.JSON(fiber.Map{ 26 | "message": fmt.Sprintf("Hello %s", user.FirstName), 27 | "user": user, 28 | }) 29 | } 30 | 31 | func (*me) Update(c *fiber.Ctx) error { 32 | user := authUser(c) 33 | valid := validation[requests.UpdateMe](c) 34 | user.FirstName = valid.FirstName 35 | user.LastName = valid.LastName 36 | database.DB.Save(user) 37 | return c.JSON(user) 38 | } 39 | 40 | func (m *me) UploadProfileImage(c *fiber.Ctx) error { 41 | fileH, err := c.FormFile("image") 42 | if err != nil { 43 | return err 44 | } 45 | 46 | file, err := m.Storage.StoreAvatar(fileH) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | user := authUser(c) 52 | if user.ImageURL != configs.DefaultUserImage { 53 | _ = m.Storage.RemoveByDisc(strings.SplitN(user.ImageURL, ".", 2)[0]) 54 | } 55 | 56 | user.ImageURL = file.Discriminator + ".webp" 57 | database.DB.Save(user) 58 | 59 | return c.JSON(user) 60 | } 61 | 62 | func (m *me) Delete(c *fiber.Ctx) error { 63 | user := authUser(c) 64 | 65 | if user.ImageURL != configs.DefaultUserImage { 66 | _ = m.Storage.RemoveByDisc(strings.SplitN(user.ImageURL, ".", 2)[0]) 67 | } 68 | 69 | database.DB.Delete(user) 70 | 71 | return c.JSON(fiber.Map{ 72 | "status": "ok", 73 | }) 74 | } 75 | 76 | func (m *me) SwitchIPCheck(c *fiber.Ctx) error { 77 | user := authUser(c) 78 | user.EnableIPCheck = !user.EnableIPCheck 79 | database.DB.Save(&user) 80 | 81 | return c.JSON(fiber.Map{ 82 | "enabled": user.EnableIPCheck, 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /app/handlers/mobile.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/app/requests" 6 | "github.com/wizzldev/chat/database" 7 | "github.com/wizzldev/chat/database/models" 8 | "github.com/wizzldev/chat/pkg/repository" 9 | ) 10 | 11 | type mobile struct{} 12 | 13 | var Mobile mobile 14 | 15 | func (mobile) RegisterPushNotification(c *fiber.Ctx) error { 16 | userID := authUserID(c) 17 | data := validation[requests.PushToken](c) 18 | 19 | if !repository.User.IsAndroidNotificationTokenExists(userID, data.Token) { 20 | database.DB.Create(&models.AndroidPushNotification{ 21 | HasUser: models.HasUserID(userID), 22 | Token: data.Token, 23 | }) 24 | } 25 | 26 | return c.JSON(fiber.Map{ 27 | "status": "success", 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /app/handlers/security.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/database" 6 | "github.com/wizzldev/chat/database/models" 7 | "github.com/wizzldev/chat/pkg/middlewares" 8 | "github.com/wizzldev/chat/pkg/repository" 9 | ) 10 | 11 | type security struct{} 12 | 13 | var Security security 14 | 15 | func (security) Sessions(c *fiber.Ctx) error { 16 | sess, err := middlewares.Session(c) 17 | if err != nil { 18 | return err 19 | } 20 | sessID := sess.ID() 21 | 22 | sessions := repository.Session.AllForUser(authUserID(c)) 23 | 24 | for _, s := range sessions { 25 | if s.SessionID == sessID { 26 | s.Current = true 27 | break 28 | } 29 | } 30 | 31 | return c.JSON(sessions) 32 | } 33 | 34 | func (security) DestroySessions(c *fiber.Ctx) error { 35 | user := authUser(c) 36 | sessions := repository.Session.AllForUser(user.ID) 37 | 38 | var del []models.Session 39 | for _, session := range sessions { 40 | err := middlewares.Store.Delete(session.SessionID) 41 | if err == nil { 42 | del = append(del, *session) 43 | } 44 | } 45 | 46 | database.DB.Delete(&del) 47 | 48 | return c.JSON(fiber.Map{ 49 | "status": "ok", 50 | }) 51 | } 52 | 53 | func (security) DestroySession(c *fiber.Ctx) error { 54 | id, err := c.ParamsInt("id") 55 | if err != nil { 56 | return err 57 | } 58 | 59 | userID := authUserID(c) 60 | sess := repository.Session.FindForUser(userID, uint(id)) 61 | 62 | err = middlewares.Store.Delete(sess.SessionID) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | database.DB.Delete(sess) 68 | return c.JSON(fiber.Map{ 69 | "status": "ok", 70 | }) 71 | } 72 | 73 | func (security) IPs(c *fiber.Ctx) error { 74 | return c.JSON(repository.IPs.AllForUser(authUserID(c))) 75 | } 76 | 77 | func (security) DestroyIP(c *fiber.Ctx) error { 78 | id, err := c.ParamsInt("id") 79 | if err != nil { 80 | return err 81 | } 82 | 83 | userID := authUserID(c) 84 | ip := repository.IPs.FindForUser(userID, uint(id)) 85 | sessions := repository.Session.FindForUserByIP(userID, ip.IP) 86 | database.DB.Delete(ip) 87 | 88 | for _, session := range sessions { 89 | _ = middlewares.Store.Delete(session.SessionID) 90 | } 91 | database.DB.Delete(sessions) 92 | 93 | return c.JSON(fiber.Map{ 94 | "status": "ok", 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /app/handlers/theme.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/pkg/repository" 6 | ) 7 | 8 | type theme struct{} 9 | 10 | var Theme theme 11 | 12 | func (theme) Paginate(c *fiber.Ctx) error { 13 | data, err := repository.Theme.Paginate(c.Query("cursor")) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | return c.JSON(data) 19 | } 20 | -------------------------------------------------------------------------------- /app/handlers/users.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/app/requests" 6 | "github.com/wizzldev/chat/pkg/repository" 7 | ) 8 | 9 | type users struct{} 10 | 11 | var Users users 12 | 13 | func (users) FindByEmail(c *fiber.Ctx) error { 14 | data := validation[requests.Email](c) 15 | 16 | user := repository.User.FindByEmail(data.Email) 17 | 18 | if repository.User.IsBlocked(user.ID, authUserID(c)) || user.ID < 1 { 19 | return fiber.ErrNotFound 20 | } 21 | 22 | return c.JSON(user) 23 | } 24 | -------------------------------------------------------------------------------- /app/handlers/utils.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/database/models" 6 | "github.com/wizzldev/chat/pkg/configs" 7 | ) 8 | 9 | func validation[T any](c *fiber.Ctx) *T { 10 | return c.Locals(configs.RequestValidation).(*T) 11 | } 12 | 13 | func authUser(c *fiber.Ctx) *models.User { 14 | return c.Locals(configs.LocalAuthUser).(*models.User) 15 | } 16 | 17 | func authUserID(c *fiber.Ctx) uint { 18 | return c.Locals(configs.LocalAuthUserID).(uint) 19 | } 20 | -------------------------------------------------------------------------------- /app/handlers/void.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | func Void(c *fiber.Ctx) error { 6 | return nil 7 | } 8 | -------------------------------------------------------------------------------- /app/handlers/ws.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gofiber/contrib/websocket" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/wizzldev/chat/pkg/ws" 7 | ) 8 | 9 | type wsHandler struct{} 10 | 11 | var WS wsHandler 12 | 13 | func (wsHandler) Connect(c *fiber.Ctx) error { 14 | return websocket.New(ws.Init().AddConnection)(c) 15 | } 16 | -------------------------------------------------------------------------------- /app/message.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/wizzldev/chat/app/events" 7 | "github.com/wizzldev/chat/app/services" 8 | "github.com/wizzldev/chat/pkg/utils/role" 9 | "github.com/wizzldev/chat/pkg/ws" 10 | "slices" 11 | "strconv" 12 | ) 13 | 14 | var cache = services.NewWSCache() 15 | 16 | func MessageActionHandler(conn *ws.Connection, userID uint, msg *ws.ClientMessage, id string) error { 17 | user, err := cache.GetUser(userID) 18 | 19 | if err != nil { 20 | go conn.Send(ws.MessageWrapper{ 21 | Message: &ws.Message{ 22 | Event: "error", 23 | Data: err.Error(), 24 | }, 25 | Resource: id, 26 | }) 27 | return err 28 | } 29 | 30 | gIDInt, err := strconv.Atoi(id) 31 | if err != nil { 32 | return err 33 | } 34 | gID := uint(gIDInt) 35 | 36 | members := cache.GetGroupMemberIDs(id) 37 | if !slices.Contains(members, userID) { 38 | return fmt.Errorf("user %d not in group members", userID) 39 | } 40 | 41 | isPM := cache.IsPM(gID) 42 | roles := cache.GetRoles(userID, gID) 43 | roleErr := errors.New("you do not have a permit") 44 | 45 | switch msg.Type { 46 | case "message": 47 | if !roles.Can(role.SendMessage) && !isPM { 48 | return roleErr 49 | } 50 | return events.DispatchMessage(id, members, gID, user, msg) 51 | case "message.like": 52 | return events.DispatchMessageLike(id, members, gID, user, msg) 53 | case "message.delete": 54 | if !roles.Can(role.DeleteMessage) && !isPM { 55 | return roleErr 56 | } 57 | return events.DispatchMessageDelete(id, members, user, msg, roles.Can(role.DeleteOtherMemberMessage) && !isPM) 58 | default: 59 | conn.Send(ws.MessageWrapper{ 60 | Message: &ws.Message{ 61 | Event: "error", 62 | Data: fmt.Sprintf("Unknown message type: %s", msg.Type), 63 | }, 64 | Resource: id, 65 | }) 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /app/requests/auth.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type Register struct { 4 | FirstName string `json:"first_name" validate:"required,min=3,max=55"` 5 | LastName string `json:"last_name" validate:"required,min=3,max=55"` 6 | Email string `json:"email" validate:"required,email"` 7 | Password string `json:"password" validate:"required,min=8,max=250"` 8 | } 9 | 10 | type Login struct { 11 | Email string `json:"email" validate:"required,email"` 12 | Password string `json:"password" validate:"required,max=250"` 13 | } 14 | 15 | type Email struct { 16 | Email string `json:"email" validate:"required,email"` 17 | } 18 | 19 | type NewPassword struct { 20 | Email string `json:"email" validate:"required,email"` 21 | } 22 | 23 | type SetNewPassword struct { 24 | Password string `json:"password" validate:"required"` 25 | } 26 | -------------------------------------------------------------------------------- /app/requests/contacts.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type SearchContacts struct { 4 | FirstName string `json:"first_name" validator:"max:55"` 5 | LastName string `json:"last_name" validator:"max:55"` 6 | Email string `json:"email" validator:"email,max:255"` 7 | } 8 | -------------------------------------------------------------------------------- /app/requests/dev.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type NewBot struct { 4 | Name string `json:"name" validate:"required,min=3,max=55"` 5 | } 6 | 7 | type ApplicationInvite struct { 8 | GroupID uint `json:"group_id"` 9 | BotID uint `json:"bot_id"` 10 | } 11 | -------------------------------------------------------------------------------- /app/requests/group.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "time" 4 | 5 | type NewGroup struct { 6 | Name string `json:"name" validate:"required,min=3,max=55"` 7 | UserIDs []uint `json:"user_ids" validator:"required,min:3,number"` 8 | Roles []string `json:"roles" validate:"required,dive,is_role"` 9 | } 10 | 11 | type ModifyRoles struct { 12 | Roles []string `json:"roles" validate:"required,dive,is_role"` 13 | } 14 | 15 | type EditGroupName struct { 16 | Name string `json:"name" validate:"required,min=3,max=55"` 17 | } 18 | 19 | type NewInvite struct { 20 | MaxUsage int `json:"max_usage" validate:"number,min=0,max=50"` 21 | Expiration *time.Time `json:"expiration" validate:"omitempty,invite_date"` 22 | } 23 | 24 | type CustomInvite struct { 25 | Invite string `json:"invite" validate:"omitempty,min=3,max=15,alphanumunicode"` 26 | } 27 | 28 | type Emoji struct { 29 | Emoji string `json:"emoji" validate:"required,is_emoji"` 30 | } 31 | 32 | type Nickname struct { 33 | Nickname string `json:"nickname" validate:"required,min=3,max=50"` 34 | } 35 | -------------------------------------------------------------------------------- /app/requests/mobile.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type PushToken struct { 4 | Token string `json:"token" validate:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /app/requests/request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/pkg/utils" 6 | ) 7 | 8 | func Use[T any]() fiber.Handler { 9 | return func(c *fiber.Ctx) error { 10 | return utils.Validate[T](c) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/requests/user.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type UpdateMe struct { 4 | FirstName string `json:"first_name" validate:"required,min=3,max=55"` 5 | LastName string `json:"last_name" validate:"required,min=3,max=55"` 6 | } 7 | -------------------------------------------------------------------------------- /app/services/storage.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/kolesa-team/go-webp/decoder" 9 | "github.com/kolesa-team/go-webp/encoder" 10 | "github.com/kolesa-team/go-webp/webp" 11 | "github.com/nfnt/resize" 12 | "github.com/wizzldev/chat/database" 13 | "github.com/wizzldev/chat/database/models" 14 | "github.com/wizzldev/chat/pkg/configs" 15 | "github.com/wizzldev/chat/pkg/utils" 16 | "image" 17 | "image/gif" 18 | "image/jpeg" 19 | "image/png" 20 | "io" 21 | "mime" 22 | "mime/multipart" 23 | "os" 24 | "path/filepath" 25 | ) 26 | 27 | type Storage struct { 28 | BasePath string 29 | } 30 | 31 | func NewStorage() (*Storage, error) { 32 | base, err := os.Getwd() 33 | if err != nil { 34 | return nil, err 35 | } 36 | base = filepath.Join(base, "./storage") 37 | s := &Storage{ 38 | BasePath: base, 39 | } 40 | return s, nil 41 | } 42 | 43 | func (*Storage) WebPFromFormFile(file io.Reader, dest *os.File, contentType string) error { 44 | var ( 45 | img image.Image 46 | err error 47 | ) 48 | 49 | switch contentType { 50 | case "image/png": 51 | img, err = png.Decode(file) 52 | case "image/jpeg": 53 | img, err = jpeg.Decode(file) 54 | case "image/gif": 55 | img, err = gif.Decode(file) 56 | case "image/webp": 57 | img, err = webp.Decode(file, &decoder.Options{}) 58 | default: 59 | img, err = nil, errors.New("unsupported image type") 60 | } 61 | 62 | if err != nil { 63 | return err 64 | } 65 | 66 | img = resize.Resize(512, 512, img, resize.Lanczos3) 67 | 68 | options, err := encoder.NewLossyEncoderOptions(encoder.PresetDefault, 75) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | return webp.Encode(dest, img, options) 74 | } 75 | 76 | func (*Storage) getFileName(key, mimeType string) string { 77 | var t string 78 | types, err := mime.ExtensionsByType(mimeType) 79 | if err == nil { 80 | if len(types) > 0 { 81 | t = types[0] 82 | } 83 | } 84 | return key + t 85 | } 86 | 87 | func (*Storage) WebPStream(file io.Reader, size uint) (io.Reader, error) { 88 | img, err := webp.Decode(file, &decoder.Options{}) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if size >= 1 && size <= 1024 { 94 | img = resize.Resize(size, size, img, resize.Lanczos3) 95 | } 96 | 97 | buf := new(bytes.Buffer) 98 | err = webp.Encode(buf, img, nil) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return bytes.NewReader(buf.Bytes()), nil 104 | } 105 | 106 | func (s *Storage) LocalFile(c *fiber.Ctx) *models.File { 107 | file := c.Locals(configs.LocalFileModel).(*models.File) 108 | file.Path = filepath.Join(s.BasePath, file.Path) 109 | return file 110 | } 111 | 112 | func (*Storage) SaveWebP(source io.Reader, dest *os.File) error { 113 | img, err := png.Decode(source) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | options, err := encoder.NewLossyEncoderOptions(encoder.PresetDefault, 100) 119 | 120 | if err != nil { 121 | return err 122 | } 123 | 124 | return webp.Encode(dest, img, options) 125 | } 126 | 127 | func (*Storage) NewDiscriminator() string { 128 | rand := utils.NewRandom() 129 | key := rand.String(25) 130 | for { 131 | var count int64 132 | database.DB.Model(&models.File{}).Where("discriminator = ?", key).Count(&count) 133 | if count == 0 { 134 | break 135 | } 136 | key = rand.String(35) 137 | } 138 | return key 139 | } 140 | 141 | func (s *Storage) StoreAvatar(fileH *multipart.FileHeader) (*models.File, error) { 142 | file, err := fileH.Open() 143 | if err != nil { 144 | return nil, err 145 | } 146 | defer file.Close() 147 | 148 | disc := s.NewDiscriminator() 149 | cType := fileH.Header.Get("Content-Type") 150 | path := s.getFileName(disc, "image/webp") 151 | dest, err := os.Create(filepath.Join(s.BasePath, path)) 152 | if err != nil { 153 | return nil, err 154 | } 155 | defer dest.Close() 156 | 157 | fileInfo, err := dest.Stat() 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | err = s.WebPFromFormFile(file, dest, cType) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | fileModel := &models.File{ 168 | Path: path, 169 | Name: "", 170 | Type: "avatar", 171 | Discriminator: disc, 172 | ContentType: "image/webp", 173 | Size: fileInfo.Size(), 174 | } 175 | err = database.DB.Create(fileModel).Error 176 | 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | return fileModel, nil 182 | } 183 | 184 | func (s *Storage) Store(fileH *multipart.FileHeader, token ...string) (*models.File, error) { 185 | if fileH.Size > configs.Env.MaxFileSize { 186 | return nil, fiber.NewError(fiber.StatusRequestEntityTooLarge, "file too large") 187 | } 188 | 189 | file, err := fileH.Open() 190 | if err != nil { 191 | fmt.Println("file header open error", err) 192 | return nil, err 193 | } 194 | 195 | disc := s.NewDiscriminator() 196 | path := s.getFileName(disc, fileH.Header.Get("Content-Type")) 197 | 198 | dest, err := os.Create(filepath.Join(s.BasePath, path)) 199 | if err != nil { 200 | fmt.Println("failed to open new file", err) 201 | return nil, err 202 | } 203 | defer dest.Close() 204 | 205 | data, err := io.ReadAll(file) 206 | if err != nil { 207 | fmt.Println("failed to read file", err) 208 | return nil, err 209 | } 210 | 211 | _, err = dest.Write(data) 212 | if err != nil { 213 | fmt.Println("failed to write file", err) 214 | return nil, err 215 | } 216 | 217 | fileModel := models.File{ 218 | Path: path, 219 | Name: fileH.Filename, 220 | Discriminator: disc, 221 | Type: "file", 222 | ContentType: fileH.Header.Get("Content-Type"), 223 | Size: fileH.Size, 224 | } 225 | 226 | if len(token) > 0 { 227 | t := &token[0] 228 | fileModel.AccessToken = t 229 | } 230 | 231 | err = database.DB.Create(&fileModel).Error 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | return &fileModel, nil 237 | } 238 | 239 | func (s *Storage) RemoveByDisc(disc string) error { 240 | var file models.File 241 | database.DB.Model(&models.File{}).Where("discriminator = ?", disc).First(&file) 242 | 243 | if file.ID < 1 { 244 | return errors.New("file not found") 245 | } 246 | 247 | err := os.Remove(filepath.Join(s.BasePath, file.Path)) 248 | if err != nil { 249 | return err 250 | } 251 | 252 | database.DB.Delete(&file) 253 | 254 | return nil 255 | } 256 | 257 | func (s *Storage) OpenFile(path string) (*os.File, error) { 258 | return os.Open(filepath.Join(s.BasePath, path)) 259 | } 260 | -------------------------------------------------------------------------------- /app/services/ws_cache.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/wizzldev/chat/database/models" 11 | "github.com/wizzldev/chat/database/rdb" 12 | "github.com/wizzldev/chat/pkg/repository" 13 | "github.com/wizzldev/chat/pkg/utils/role" 14 | ) 15 | 16 | var ctx = context.Background() 17 | 18 | type WSCache struct{} 19 | 20 | func NewWSCache() *WSCache { 21 | return new(WSCache) 22 | } 23 | 24 | func (w WSCache) GetUser(userID uint) (*models.User, error) { 25 | key := w.key(fmt.Sprintf("user:%d", userID)) 26 | 27 | userByte, err := rdb.Redis.Get(key) 28 | if err != nil { 29 | return w.getAndSaveUser(userID, key) 30 | } 31 | 32 | var user models.User 33 | 34 | if err := json.Unmarshal(userByte, &user); err != nil { 35 | return w.getAndSaveUser(userID, key) 36 | } 37 | 38 | return &user, nil 39 | } 40 | 41 | func (WSCache) getAndSaveUser(userID uint, key string) (*models.User, error) { 42 | user := repository.User.FindById(userID) 43 | data, err := json.Marshal(user) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | _ = rdb.Redis.Set(key, data, time.Minute*20) 49 | return user, nil 50 | } 51 | 52 | func (w WSCache) GetGroupMemberIDs(groupID string) []uint { 53 | key := w.key(fmt.Sprintf("group.%v.userIds", groupID)) 54 | 55 | gIDsByte, err := rdb.Redis.Get(key) 56 | if err != nil { 57 | return w.getAndSaveGroupIDs(groupID, key) 58 | } 59 | 60 | var gIDs []uint 61 | if err := json.Unmarshal(gIDsByte, &gIDs); err != nil { 62 | return w.getAndSaveGroupIDs(groupID, key) 63 | } 64 | 65 | return gIDs 66 | } 67 | 68 | func (w WSCache) DisposeGroupMemberIDs(groupID string) error { 69 | key := w.key(fmt.Sprintf("group.%v.userIds", groupID)) 70 | return rdb.Redis.Delete(key) 71 | } 72 | 73 | func (WSCache) getAndSaveGroupIDs(groupID string, key string) []uint { 74 | var uIDs []uint 75 | 76 | gID, err := strconv.Atoi(groupID) 77 | if err != nil { 78 | return uIDs 79 | } 80 | 81 | uIDs = repository.Group.GetUserIDs(uint(gID)) 82 | data, err := json.Marshal(uIDs) 83 | if err != nil { 84 | return uIDs 85 | } 86 | 87 | _ = rdb.Redis.Set(key, data, time.Minute*20) 88 | return uIDs 89 | } 90 | 91 | func (w WSCache) GetRoles(userID uint, groupID uint) role.Roles { 92 | key := w.key(fmt.Sprintf("roles.user:%d.%d", userID, groupID)) 93 | 94 | roleByte, err := rdb.Redis.Get(key) 95 | if err != nil { 96 | return w.getAndSaveUserRoles(userID, groupID, key) 97 | } 98 | 99 | var roles []string 100 | if err = json.Unmarshal(roleByte, &roles); err != nil { 101 | return w.getAndSaveUserRoles(userID, groupID, key) 102 | } 103 | 104 | return *role.NewRoles(roles) 105 | } 106 | 107 | func (w WSCache) getAndSaveUserRoles(userID uint, groupID uint, key string) role.Roles { 108 | roles := repository.Group.GetUserRoles(groupID, userID, w.GetGroupRoles(groupID)) 109 | _ = rdb.Redis.Set(key, []byte(roles.String()), time.Minute*20) 110 | return roles 111 | } 112 | 113 | func (w WSCache) GetGroupRoles(groupID uint) role.Roles { 114 | key := w.key(fmt.Sprintf("roles.group:%d", groupID)) 115 | 116 | gIDsByte, err := rdb.Redis.Get(key) 117 | if err != nil { 118 | return *w.getAndSaveGroupRoles(groupID, key) 119 | } 120 | 121 | var roles []string 122 | if err := json.Unmarshal(gIDsByte, &roles); err != nil { 123 | return *w.getAndSaveGroupRoles(groupID, key) 124 | } 125 | 126 | return *role.NewRoles(roles) 127 | } 128 | 129 | func (WSCache) getAndSaveGroupRoles(groupID uint, key string) *role.Roles { 130 | group := repository.Group.Find(groupID) 131 | if group.ID < 1 { 132 | return new(role.Roles) 133 | } 134 | 135 | roles := role.NewRoles(group.Roles) 136 | 137 | _ = rdb.Redis.Set(key, []byte(roles.String()), time.Minute*20) 138 | 139 | return roles 140 | } 141 | 142 | func (w WSCache) IsPM(groupID uint) bool { 143 | // TODO: make it cacheable 144 | return repository.Group.Find(groupID).IsPrivateMessage 145 | } 146 | 147 | func (WSCache) key(s string) string { 148 | return "ws-" + s 149 | } 150 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "gorm.io/driver/mysql" 9 | "gorm.io/gorm" 10 | "gorm.io/gorm/logger" 11 | ) 12 | 13 | var DB *gorm.DB 14 | 15 | func MustConnect() { 16 | dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=utf8mb4&parseTime=True&loc=Local", configs.Env.Database.Username, configs.Env.Database.Password, configs.Env.Database.Host, configs.Env.Database.Port, configs.Env.Database.Database) 17 | db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ 18 | DisableForeignKeyConstraintWhenMigrating: true, 19 | }) 20 | 21 | if err != nil { 22 | log.Fatalf("Failed to connect to the database: %v\n", err.Error()) 23 | } 24 | 25 | log.Println("successfully connected to the database!") 26 | if configs.Env.Debug { 27 | db.Logger = logger.Default.LogMode(logger.Info) 28 | } else { 29 | db.Logger = logger.Default.LogMode(logger.Error) 30 | } 31 | 32 | log.Println("Running migrations") 33 | err = db.AutoMigrate(getModels()...) 34 | 35 | if err != nil { 36 | log.Fatal("Failed to migrate: " + err.Error()) 37 | } 38 | 39 | DB = db 40 | } 41 | -------------------------------------------------------------------------------- /database/models.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import "github.com/wizzldev/chat/database/models" 4 | 5 | func getModels() []interface{} { 6 | return []interface{}{ 7 | &models.Message{}, 8 | &models.MessageLike{}, 9 | &models.Worker{}, 10 | &models.AndroidPushNotification{}, 11 | &models.Group{}, 12 | &models.Ban{}, 13 | &models.Invite{}, 14 | &models.Block{}, 15 | &models.EmailVerification{}, 16 | &models.ResetPassword{}, 17 | &models.Theme{}, 18 | &models.GroupUser{}, 19 | &models.AllowedIP{}, 20 | &models.Session{}, 21 | &models.UserBot{}, 22 | &models.User{}, 23 | &models.File{}, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /database/models/allowed_ip.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type AllowedIP struct { 4 | Base 5 | HasUser 6 | IP string `json:"ip"` 7 | Active bool `json:"-"` 8 | Verification string `json:"-"` 9 | } 10 | -------------------------------------------------------------------------------- /database/models/android_push_notification.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type AndroidPushNotification struct { 4 | Base 5 | HasUser 6 | Token string `json:"token"` 7 | } 8 | -------------------------------------------------------------------------------- /database/models/ban.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type Ban struct { 6 | Base 7 | HasGroup 8 | HasUser 9 | 10 | BlockedUserID uint `json:"-"` 11 | BlockedUser User `json:"sender" gorm:"constraint:OnDelete:CASCADE;foreignKey:BlockedUserID"` 12 | 13 | Duration *time.Time `json:"duration" gorm:"default:null"` 14 | 15 | Reason string `json:"reason"` 16 | } 17 | -------------------------------------------------------------------------------- /database/models/base.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Base struct { 8 | ID uint `json:"id" gorm:"primaryKey;type:bigint(255)"` 9 | CreatedAt time.Time `json:"created_at"` 10 | UpdatedAt time.Time `json:"updated_at"` 11 | } 12 | 13 | func (b *Base) Exists() bool { 14 | return b.ID > 0 15 | } 16 | -------------------------------------------------------------------------------- /database/models/block.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Block struct { 4 | Base 5 | HasUser 6 | BlockedUserID uint `json:"-"` 7 | BlockedUser User `json:"blocked" gorm:"constraint:OnDelete:CASCADE;foreignKey:BlockedUserID"` 8 | 9 | Reason string `json:"reason"` 10 | } 11 | -------------------------------------------------------------------------------- /database/models/email_verification.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type EmailVerification struct { 4 | Base 5 | HasUser 6 | Token string `json:"token" gorm:"token"` 7 | } 8 | -------------------------------------------------------------------------------- /database/models/file.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type File struct { 4 | Base 5 | Path string `json:"-"` 6 | Name string `json:"name"` 7 | ContentType string `json:"content_type"` 8 | AccessToken *string `json:"access_token"` 9 | Type string `json:"type"` 10 | Discriminator string `json:"discriminator"` 11 | Size int64 `json:"size" gorm:"default:0"` 12 | } 13 | -------------------------------------------------------------------------------- /database/models/group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Group struct { 4 | Base 5 | HasTheme 6 | HasUser 7 | IsPrivateMessage bool `json:"is_private_message"` 8 | Users []*User `json:"members,omitempty" gorm:"constraint:OnDelete:CASCADE;many2many:group_user"` 9 | ImageURL *string `json:"image_url,omitempty" gorm:"default:null"` 10 | Name *string `json:"name,omitempty" gorm:"default:null"` 11 | Roles []string `json:"roles,omitempty" gorm:"default:'[]';serializer:json"` 12 | Verified bool `json:"is_verified" gorm:"default:false"` 13 | Emoji *string `json:"emoji,omitempty" gorm:"default:null"` 14 | CustomInvite *string `json:"custom_invite,omitempty" gorm:"default:null"` 15 | } 16 | -------------------------------------------------------------------------------- /database/models/group_user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type GroupUser struct { 4 | HasGroup 5 | HasUser 6 | NickName *string `json:"nick_name,omitempty" gorm:"default:NULL"` 7 | Roles []string `json:"roles,omitempty" gorm:"default:null;serializer:json"` 8 | } 9 | 10 | func (GroupUser) TableName() string { 11 | return "group_user" 12 | } 13 | -------------------------------------------------------------------------------- /database/models/invite.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Invite struct { 8 | Base 9 | HasUser 10 | HasGroup 11 | MaxUsage *int `json:"max_usage" gorm:"default:null"` 12 | Key string `json:"key" gorm:"index:unique"` // join.wizzl.app/invite_id -> wizzl.app/invite/invite_id 13 | Expiration *time.Time `json:"expiration"` 14 | } 15 | 16 | // join.wizzl.app/releases 17 | // join.wizzl.app/wizzl 18 | // join.wizzl.app/support 19 | 20 | func (i *Invite) IsValid() bool { 21 | // check if the invite is valid or not 22 | return i.ID > 0 && 23 | (i.MaxUsage == nil || *i.MaxUsage > 0) && 24 | (i.Expiration == nil || time.Now().Before(*i.Expiration)) 25 | } 26 | 27 | func (i *Invite) Decrement() { 28 | if i.MaxUsage == nil { 29 | return 30 | } 31 | 32 | usage := *i.MaxUsage - 1 33 | i.MaxUsage = &usage 34 | } 35 | -------------------------------------------------------------------------------- /database/models/message.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/wizzldev/chat/pkg/encryption" 5 | "gorm.io/gorm" 6 | "log" 7 | ) 8 | 9 | type Message struct { 10 | Base 11 | HasGroup 12 | HasMessageSender 13 | HasMessageReply 14 | HasMessageLikes 15 | Content string `json:"content"` 16 | Type string `json:"type"` 17 | DataJSON string `json:"data_json"` 18 | Encrypted bool `json:"-"` 19 | } 20 | 21 | func (m *Message) AfterFind(*gorm.DB) error { 22 | if !m.Encrypted { 23 | return nil 24 | } 25 | var err error 26 | m.Content, err = encryption.DecryptMessage(m.Content) 27 | if err != nil { 28 | m.Content = "#fail.decrypt" 29 | } 30 | return nil 31 | } 32 | 33 | func (m *Message) BeforeCreate(*gorm.DB) error { 34 | content, err := encryption.EncryptMessage(m.Content) 35 | if err != nil { 36 | log.Fatal(err) 37 | return nil 38 | } 39 | m.Encrypted = true 40 | m.Content = content 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /database/models/message_likes.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type MessageLike struct { 4 | Base 5 | HasMessage 6 | HasUser 7 | Emoji string `json:"emoji"` 8 | } 9 | -------------------------------------------------------------------------------- /database/models/relations.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type HasMessageSender struct { 4 | SenderID uint `json:"-"` 5 | Sender User `json:"sender" gorm:"constraint:OnDelete:CASCADE;foreignKey:SenderID"` 6 | } 7 | 8 | func HasMessageSenderID(id uint) HasMessageSender { 9 | return HasMessageSender{SenderID: id} 10 | } 11 | 12 | type HasGroup struct { 13 | GroupID uint `json:"-"` 14 | Group *Group `json:"receiver,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:GroupID"` 15 | } 16 | 17 | func HasGroupID(id uint) HasGroup { 18 | return HasGroup{GroupID: id} 19 | } 20 | 21 | type HasUser struct { 22 | UserID uint `json:"-"` 23 | User *User `json:"user,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:UserID"` 24 | } 25 | 26 | func HasUserID(id uint) HasUser { 27 | return HasUser{UserID: id} 28 | } 29 | 30 | type HasBot struct { 31 | BotID uint `json:"-"` 32 | Bot *User `json:"bot,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:BotID"` 33 | } 34 | 35 | func HasBotID(id uint) HasBot { 36 | return HasBot{BotID: id} 37 | } 38 | 39 | type HasMessageReply struct { 40 | ReplyID *uint `json:"-" gorm:"message_id;default:NULL"` 41 | Reply *Message `json:"reply,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:ReplyID"` 42 | } 43 | 44 | type HasMessage struct { 45 | MessageID uint `json:"-"` 46 | Message *Message `json:"message,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:MessageID"` 47 | } 48 | 49 | type HasMessageLikes struct { 50 | MessageLikes []MessageLike `json:"likes,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:MessageID"` 51 | } 52 | 53 | type HasTheme struct { 54 | ThemeID *uint `json:"-" gorm:"theme_id;default:NULL"` 55 | Theme *Theme `json:"theme,omitempty" gorm:"constraint:OnDelete:CASCADE;foreignKey:ThemeID"` 56 | } 57 | -------------------------------------------------------------------------------- /database/models/reset_password.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ResetPassword struct { 4 | Base 5 | HasUser 6 | Token string `json:"token" gorm:"token"` 7 | } 8 | -------------------------------------------------------------------------------- /database/models/session.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Session struct { 4 | Base 5 | HasUser 6 | IP string `json:"ip_address"` 7 | SessionID string `json:"-"` 8 | Agent string `json:"user_agent"` 9 | Current bool `json:"current" gorm:"-:all"` 10 | } 11 | -------------------------------------------------------------------------------- /database/models/storage.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Storage struct { 4 | Base 5 | HasUser 6 | FilePath string `json:"file_path"` 7 | IsPublic bool `json:"-"` 8 | } 9 | -------------------------------------------------------------------------------- /database/models/theme.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Theme struct { 4 | Base 5 | HasUser 6 | Name string `json:"name"` 7 | Data string `json:"data" gorm:"type:json"` 8 | } 9 | -------------------------------------------------------------------------------- /database/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/wizzldev/chat/database/rdb" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type User struct { 14 | Base 15 | FirstName string `json:"first_name" gorm:"type:varchar(100)"` 16 | LastName string `json:"last_name,omitempty" gorm:"type:varchar(100)"` 17 | Email string `json:"email,omitempty" gorm:"type:varchar(100)"` 18 | Password string `json:"-" gorm:"type:varchar(255)"` 19 | ImageURL string `json:"image_url" gorm:"type:varchar(255)"` 20 | EmailVerifiedAt *time.Time `json:"-"` 21 | EnableIPCheck bool `json:"enable_ip_check" gorm:"default:true"` 22 | IsOnline bool `json:"is_online" gorm:"-:all"` 23 | IsBot bool `json:"is_bot" gorm:"default:false"` 24 | GroupUser *GroupUser `json:"group_user,omitempty"` 25 | } 26 | 27 | var ctx = context.Background() 28 | 29 | func (u *User) PublicData() fiber.Map { 30 | return fiber.Map{ 31 | "first_name": u.FirstName, 32 | "last_name": u.LastName, 33 | "image_url": u.ImageURL, 34 | "is_online": u.IsOnline, 35 | } 36 | } 37 | 38 | func (u *User) AfterFind(*gorm.DB) error { 39 | _, err := rdb.Redis.Get(fmt.Sprintf("user.is-online.%v", u.ID)) 40 | u.IsOnline = err != nil 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /database/models/user_bot.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type UserBot struct { 4 | Base 5 | HasUser 6 | HasBot 7 | } 8 | -------------------------------------------------------------------------------- /database/models/worker.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Worker struct { 4 | Base 5 | Command string `json:"command" gorm:"type:varchar(100)"` 6 | Data string `json:"data" gorm:"type:longtext"` 7 | } 8 | -------------------------------------------------------------------------------- /database/rdb/redis.go: -------------------------------------------------------------------------------- 1 | package rdb 2 | 3 | import ( 4 | "github.com/gofiber/storage/redis/v3" 5 | "os" 6 | ) 7 | 8 | var Redis = redis.New(redis.Config{ 9 | URL: getURL(), 10 | }) 11 | 12 | func getURL() string { 13 | url := os.Getenv("REDIS_URL") 14 | if url == "" { 15 | return "redis://:@127.0.0.1:6379/0" 16 | } 17 | return url 18 | } 19 | -------------------------------------------------------------------------------- /database/suite.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wizzldev/chat/database/models" 6 | "github.com/wizzldev/chat/pkg/configs" 7 | "github.com/wizzldev/chat/pkg/utils" 8 | "gorm.io/driver/mysql" 9 | "gorm.io/gorm" 10 | "gorm.io/gorm/logger" 11 | "log" 12 | "time" 13 | ) 14 | 15 | func MustConnectTestDB() { 16 | dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=utf8mb4&parseTime=True&loc=Local", configs.Env.Database.Username, configs.Env.Database.Password, configs.Env.Database.Host, configs.Env.Database.Port, configs.Env.Database.Database) 17 | db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ 18 | DisableForeignKeyConstraintWhenMigrating: true, 19 | }) 20 | 21 | if err != nil { 22 | log.Fatalf("Failed to connect to the database: %v\n", err.Error()) 23 | } 24 | 25 | log.Println("successfully connected to the database!") 26 | if configs.Env.Debug { 27 | db.Logger = logger.Default.LogMode(logger.Warn) 28 | } else { 29 | db.Logger = logger.Default.LogMode(logger.Error) 30 | } 31 | 32 | log.Println("Running migrations") 33 | err = db.AutoMigrate(getModels()...) 34 | 35 | if err != nil { 36 | log.Fatal("Failed to migrate: " + err.Error()) 37 | } 38 | 39 | now := time.Now() 40 | pass, _ := utils.NewPassword("secret1234").Hash() 41 | // Creating main test user 42 | db.Create(&models.User{ 43 | FirstName: "Jane", 44 | LastName: "Roe", 45 | Email: "jane@example.com", 46 | Password: pass, // password 47 | ImageURL: configs.DefaultUserImage, 48 | EmailVerifiedAt: &now, 49 | }) 50 | db.Create(&models.User{ 51 | FirstName: "Sam", 52 | LastName: "Doe", 53 | Email: "sam@example.com", 54 | Password: pass, // password 55 | ImageURL: configs.DefaultUserImage, 56 | EmailVerifiedAt: &now, 57 | }) 58 | 59 | DB = db 60 | } 61 | 62 | func CleanUpTestDB() error { 63 | err := DB.Migrator().DropTable(getModels()...) 64 | if err != nil { 65 | return fmt.Errorf("failed to drop tables: " + err.Error()) 66 | } 67 | 68 | DB.Rollback() 69 | 70 | db, err := DB.DB() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | err = db.Close() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | database: 3 | image: "mariadb:10.4.32" 4 | restart: always 5 | environment: 6 | MYSQL_DATABASE: "api" 7 | MYSQL_ROOT_PASSWORD: "password" 8 | volumes: 9 | - ./mysql-volume:/var/lib/mysql 10 | redis: 11 | image: "redis:latest" 12 | healthcheck: 13 | test: ["CMD", "redis-cli", "ping"] 14 | interval: 1s 15 | timeout: 2s 16 | retries: 10 17 | environment: 18 | - ALLOW_EMPTY_PASSWORD=yes 19 | api: 20 | build: . 21 | restart: always 22 | depends_on: 23 | redis: 24 | condition: service_healthy 25 | volumes: 26 | - ./storage:/app/storage 27 | - ./templates/:/app/templates 28 | ports: 29 | - "3000:3000" 30 | environment: 31 | - REDIS_URL=redis://redis:6379/0 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wizzldev/chat 2 | 3 | go 1.21.7 4 | 5 | require ( 6 | firebase.google.com/go v3.13.0+incompatible 7 | github.com/fatih/color v1.17.0 8 | github.com/go-playground/validator/v10 v10.19.0 9 | github.com/gofiber/contrib/websocket v1.3.0 10 | github.com/gofiber/fiber/v2 v2.52.5 11 | github.com/gofiber/storage/redis/v3 v3.1.1 12 | github.com/golobby/dotenv v1.3.2 13 | github.com/kolesa-team/go-webp v1.0.4 14 | github.com/lib/pq v1.10.9 15 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 16 | github.com/stretchr/testify v1.9.0 17 | github.com/tmdvs/Go-Emoji-Utils v1.1.0 18 | golang.org/x/crypto v0.25.0 19 | golang.org/x/net v0.27.0 20 | golang.org/x/text v0.16.0 21 | google.golang.org/api v0.189.0 22 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 23 | gorm.io/driver/mysql v1.5.6 24 | gorm.io/gorm v1.25.9 25 | ) 26 | 27 | require ( 28 | cloud.google.com/go v0.115.0 // indirect 29 | cloud.google.com/go/auth v0.7.2 // indirect 30 | cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect 31 | cloud.google.com/go/compute/metadata v0.5.0 // indirect 32 | cloud.google.com/go/firestore v1.16.0 // indirect 33 | cloud.google.com/go/iam v1.1.10 // indirect 34 | cloud.google.com/go/longrunning v0.5.9 // indirect 35 | cloud.google.com/go/storage v1.43.0 // indirect 36 | github.com/andybalholm/brotli v1.1.0 // indirect 37 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 38 | github.com/davecgh/go-spew v1.1.1 // indirect 39 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 40 | github.com/fasthttp/websocket v1.5.7 // indirect 41 | github.com/felixge/httpsnoop v1.0.4 // indirect 42 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 43 | github.com/go-logr/logr v1.4.2 // indirect 44 | github.com/go-logr/stdr v1.2.2 // indirect 45 | github.com/go-playground/locales v0.14.1 // indirect 46 | github.com/go-playground/universal-translator v0.18.1 // indirect 47 | github.com/go-sql-driver/mysql v1.7.0 // indirect 48 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 49 | github.com/golang/protobuf v1.5.4 // indirect 50 | github.com/golobby/cast v1.3.3 // indirect 51 | github.com/google/s2a-go v0.1.7 // indirect 52 | github.com/google/uuid v1.6.0 // indirect 53 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 54 | github.com/googleapis/gax-go/v2 v2.13.0 // indirect 55 | github.com/jinzhu/inflection v1.0.0 // indirect 56 | github.com/jinzhu/now v1.1.5 // indirect 57 | github.com/klauspost/compress v1.17.6 // indirect 58 | github.com/leodido/go-urn v1.4.0 // indirect 59 | github.com/mattn/go-colorable v0.1.13 // indirect 60 | github.com/mattn/go-isatty v0.0.20 // indirect 61 | github.com/mattn/go-runewidth v0.0.15 // indirect 62 | github.com/philhofer/fwd v1.1.2 // indirect 63 | github.com/pmezard/go-difflib v1.0.0 // indirect 64 | github.com/redis/go-redis/v9 v9.5.1 // indirect 65 | github.com/rivo/uniseg v0.2.0 // indirect 66 | github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect 67 | github.com/tinylib/msgp v1.1.8 // indirect 68 | github.com/valyala/bytebufferpool v1.0.0 // indirect 69 | github.com/valyala/fasthttp v1.52.0 // indirect 70 | github.com/valyala/tcplisten v1.0.0 // indirect 71 | go.opencensus.io v0.24.0 // indirect 72 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect 73 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 74 | go.opentelemetry.io/otel v1.24.0 // indirect 75 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 76 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 77 | golang.org/x/oauth2 v0.21.0 // indirect 78 | golang.org/x/sync v0.7.0 // indirect 79 | golang.org/x/sys v0.22.0 // indirect 80 | golang.org/x/time v0.5.0 // indirect 81 | google.golang.org/appengine v1.6.8 // indirect 82 | google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect 83 | google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect 84 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect 85 | google.golang.org/grpc v1.64.1 // indirect 86 | google.golang.org/protobuf v1.34.2 // indirect 87 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 88 | gopkg.in/yaml.v3 v3.0.1 // indirect 89 | ) 90 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/wizzldev/chat/database" 8 | "github.com/wizzldev/chat/pkg/configs" 9 | "github.com/wizzldev/chat/routes" 10 | ) 11 | 12 | func main() { 13 | envFile := flag.String("env", ".env", "dotenv file to load") 14 | flag.Parse() 15 | 16 | err := configs.LoadEnv(*envFile) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | database.MustConnect() 22 | 23 | app := routes.NewApp() 24 | 25 | log.Fatal(app.Listen(configs.Env.ServerPort)) 26 | } 27 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream api { 2 | server 127.0.0.1:3000; 3 | } 4 | 5 | server { 6 | server_name api.wizzl.app; 7 | client_max_body_size 5M; 8 | proxy_set_header Host $host; 9 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 10 | 11 | location / { 12 | proxy_pass http://api; 13 | } 14 | 15 | location /ws { 16 | rewrite ^ /404 break; # Move all api ws routes to 404 17 | proxy_pass http://api; # Proxy to the original backend 18 | } 19 | } 20 | 21 | server { 22 | server_name gateway.wizzl.app; 23 | 24 | location / { 25 | rewrite ^ /ws break; # Rewrite everything to /ws 26 | proxy_pass http://api; # Move all requests to api /ws 27 | proxy_http_version 1.1; 28 | proxy_set_header Upgrade $http_upgrade; 29 | proxy_set_header Connection "Upgrade"; 30 | proxy_set_header Host $host; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/configs/build.json: -------------------------------------------------------------------------------- 1 | {"build": "9df9a4b", "date": "Mon, 30 Sep 2024 21:04:33 +0200", "author": "Martin Binder" } 2 | -------------------------------------------------------------------------------- /pkg/configs/env.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/golobby/dotenv" 8 | ) 9 | 10 | type session struct { 11 | LifespanSeconds int `env:"SESSION_LIFESPAN"` 12 | } 13 | 14 | type redis struct { 15 | Host string `env:"REDIS_HOST"` 16 | Port int `env:"REDIS_PORT"` 17 | User string `env:"REDIS_USER"` 18 | Password string `env:"REDIS_PASS"` 19 | DB int `env:"REDIS_DB"` 20 | } 21 | 22 | type databaseEnv struct { 23 | Host string `env:"DB_HOST"` 24 | Port int `env:"DB_PORT"` 25 | Username string `env:"DB_USER"` 26 | Password string `env:"DB_PASS"` 27 | Database string `env:"DB_NAME"` 28 | } 29 | 30 | type email struct { 31 | SMTPHost string `env:"EMAIL_SMTP_HOST"` 32 | SMTPPort int `env:"EMAIL_SMTP_PORT"` 33 | Username string `env:"EMAIL_SMTP_USER"` 34 | Password string `env:"EMAIL_SMTP_PASS"` 35 | SenderAddress string `env:"EMAIL_SENDER_ADDRESS"` 36 | } 37 | 38 | type env struct { 39 | Frontend string `env:"FRONTEND_URL"` 40 | Debug bool `env:"DEBUG"` 41 | ServerPort string `env:"SERVER_PORT"` 42 | MaxFileSize int64 `env:"MAX_FILE_SIZE"` 43 | Database databaseEnv 44 | Session session 45 | Redis redis 46 | Email email 47 | FirebaseAuthKey string `env:"FIREBASE_AUTH_KEY"` 48 | MessageEncryptionKey string `env:"MESSAGE_ENCRYPTION_KEY"` 49 | } 50 | 51 | var Env env 52 | 53 | func LoadEnv(path ...string) error { 54 | p := "./.env" 55 | if len(path) > 0 { 56 | p = path[0] 57 | } 58 | 59 | file, err := os.Open(p) 60 | if err != nil { 61 | return fmt.Errorf("failed to load environment variables: %w", err) 62 | } 63 | 64 | err = dotenv.NewDecoder(file).Decode(&Env) 65 | if err != nil { 66 | return fmt.Errorf("failed to parse environment variables: %w", err) 67 | } 68 | 69 | Env.MaxFileSize = Env.MaxFileSize * 1_000_000 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/configs/keys.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | const ( 4 | RequestValidation = "requestValidation" 5 | 6 | SessionAuthUserID = "auth.userId" 7 | 8 | LocalAuthUser = "authUser" 9 | LocalAuthUserID = "authUserId" 10 | LocalIsBot = "auth.isBot" 11 | LocalFileModel = "storage.file" 12 | 13 | DefaultWSResource = "ws.default" 14 | 15 | DefaultUserImage = "default.webp" 16 | DefaultGroupImage = "group.webp" 17 | ) 18 | -------------------------------------------------------------------------------- /pkg/encryption/message.go: -------------------------------------------------------------------------------- 1 | package encryption 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "errors" 9 | "github.com/wizzldev/chat/pkg/configs" 10 | "io" 11 | ) 12 | 13 | func EncryptMessage(m string) (string, error) { 14 | block, err := aes.NewCipher([]byte(configs.Env.MessageEncryptionKey)) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | aesGCM, err := cipher.NewGCM(block) 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | nonce := make([]byte, aesGCM.NonceSize()) 25 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 26 | return "", err 27 | } 28 | 29 | cipherText := aesGCM.Seal(nonce, nonce, []byte(m), nil) 30 | return base64.StdEncoding.EncodeToString(cipherText), nil 31 | } 32 | 33 | func DecryptMessage(cipherText string) (string, error) { 34 | if cipherText == "" { 35 | return "", errors.New("cipherText is empty") 36 | } 37 | 38 | data, err := base64.StdEncoding.DecodeString(cipherText) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | block, err := aes.NewCipher([]byte(configs.Env.MessageEncryptionKey)) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | aesGCM, err := cipher.NewGCM(block) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | nonceSize := aesGCM.NonceSize() 54 | nonce, cipherTextBytes := data[:nonceSize], data[nonceSize:] 55 | 56 | plainText, err := aesGCM.Open(nil, nonce, cipherTextBytes, nil) 57 | if err != nil { 58 | return "", err 59 | } 60 | 61 | return string(plainText), nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "github.com/fatih/color" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func now() string { 11 | c := color.New(color.FgHiBlack) 12 | return c.Sprint(time.Now().Format("15:04:05")) 13 | } 14 | 15 | func number(n any) string { 16 | c := color.New(color.FgYellow, color.Bold) 17 | return c.Sprintf("%v", n) 18 | } 19 | 20 | func numberList(n []uint) string { 21 | var s string 22 | for _, v := range n { 23 | s += number(v) + ", " 24 | } 25 | return strings.TrimSuffix(s, ", ") 26 | } 27 | 28 | func ip(ip string) string { 29 | var str string 30 | 31 | c := color.New(color.FgGreen) 32 | data := strings.Split(ip, ".") 33 | for _, v := range data { 34 | str += c.Sprintf("%s", v) + "." 35 | } 36 | return strings.TrimSuffix(str, ".") 37 | } 38 | 39 | func user(userID any) string { 40 | return fmt.Sprintf(" : %s", number(userID)) 41 | } 42 | 43 | func ws(wsID string, userID uint) string { 44 | c := color.New(color.BgHiMagenta, color.FgHiWhite, color.Bold) 45 | id := color.New(color.FgHiBlue, color.Italic) 46 | var u string 47 | if userID > 0 { 48 | u = user(userID) 49 | } 50 | return fmt.Sprintf("%s ~ %s [%s%s] -", c.Sprintf(" WS "), now(), id.Sprintf(wsID), u) 51 | } 52 | 53 | func WSNewConnection(wsID string, ipAddr string, userID uint) { 54 | fmt.Printf("%s New connection (%s)\n", ws(wsID, userID), ip(ipAddr)) 55 | } 56 | 57 | func WSNewEvent(wsID, event string, userID uint) { 58 | fmt.Printf("%s New event (%s)\n", ws(wsID, userID), event) 59 | } 60 | 61 | func WSDisconnect(wsID string, userID uint) { 62 | c := color.New(color.FgRed) 63 | fmt.Printf("%s %s\n", ws(wsID, userID), c.Sprint("Connection closed")) 64 | } 65 | 66 | func WSSend(wsID string, event string, userID uint, userIDs []uint) { 67 | fmt.Printf("%s Message (%s) Sent to: [%s]\n", ws(wsID, userID), event, numberList(userIDs)) 68 | } 69 | 70 | func WSPoolSize(wsID string, size int, ids []uint) { 71 | fmt.Printf("%s Pool size: %s: [%s]\n", ws(wsID, 0), number(size), numberList(ids)) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/middlewares/auth.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/pkg/configs" 6 | "github.com/wizzldev/chat/pkg/repository" 7 | "strings" 8 | ) 9 | 10 | func Auth(c *fiber.Ctx) error { 11 | sess, err := AuthSession(c) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | userId := sess.Get(configs.SessionAuthUserID) 17 | if userId == nil { 18 | return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") 19 | } 20 | 21 | user := repository.User.FindById(userId.(uint)) 22 | 23 | if user.ID < 1 { 24 | return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") 25 | } 26 | 27 | if user.IsBot { 28 | return fiber.NewError(fiber.StatusBadRequest, "Cannot use bot as a user") 29 | } 30 | 31 | c.Locals(configs.LocalAuthUser, user) 32 | c.Locals(configs.LocalAuthUserID, user.ID) 33 | c.Locals(configs.LocalIsBot, user.IsBot) 34 | return c.Next() 35 | } 36 | 37 | func WSAuth(c *fiber.Ctx) error { 38 | q := c.Query("authorization", "none") 39 | 40 | if q != "none" { 41 | c.Request().Header.Set("Authorization", "bearer "+q) 42 | } 43 | 44 | return AnyAuth(c) 45 | } 46 | 47 | func BotAuth(c *fiber.Ctx) error { 48 | token, err := BotToken(c) 49 | if err != nil { 50 | return err 51 | } 52 | if token == "" { 53 | return fiber.NewError(fiber.StatusUnauthorized, "Invalid token") 54 | } 55 | 56 | bot := repository.Bot.FindByToken(token) 57 | 58 | if bot.ID < 1 { 59 | return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") 60 | } 61 | 62 | c.Locals(configs.LocalAuthUser, bot) 63 | c.Locals(configs.LocalAuthUserID, bot.ID) 64 | c.Locals(configs.LocalIsBot, bot.IsBot) 65 | return c.Next() 66 | } 67 | 68 | func AnyAuth(c *fiber.Ctx) error { 69 | if strings.Contains(strings.ToLower(string(c.Request().Header.Peek("Authorization"))), " bot ") { 70 | return BotAuth(c) 71 | } 72 | return Auth(c) 73 | } 74 | 75 | func NoBots(c *fiber.Ctx) error { 76 | if c.Locals(configs.LocalIsBot).(bool) { 77 | return fiber.NewError(fiber.StatusForbidden, "Bots not allowed") 78 | } 79 | return c.Next() 80 | } 81 | -------------------------------------------------------------------------------- /pkg/middlewares/cors.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/url" 5 | "slices" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/gofiber/fiber/v2/middleware/cors" 9 | ) 10 | 11 | var corsOriginDomains = []string{"wizzl.app", "dev.wizzl.app", "localhost"} 12 | 13 | func CORS() fiber.Handler { 14 | return cors.New(cors.Config{ 15 | AllowHeaders: "Authorization,Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Access-Control-Allow-Origin,X-Frontend-Client,X-File-Access-Token", 16 | AllowCredentials: true, 17 | AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS", 18 | MaxAge: 60 * 5, // 5 minutes 19 | AllowOriginsFunc: func(origin string) bool { 20 | u, err := url.Parse(origin) 21 | if err != nil { 22 | return false 23 | } 24 | return slices.Contains(corsOriginDomains, u.Hostname()) 25 | }, 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/middlewares/file.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/database" 6 | "github.com/wizzldev/chat/database/models" 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | func StorageFileToLocal() fiber.Handler { 13 | return func(c *fiber.Ctx) error { 14 | disc := c.Params("disc") 15 | if strings.Contains(disc, "-") { 16 | s := strings.SplitN(disc, "-", 2) 17 | if len(s) == 2 { 18 | disc = s[0] 19 | } 20 | } 21 | 22 | var file models.File 23 | database.DB.Model(&models.File{}). 24 | Where("discriminator = ?", disc). 25 | Find(&file) 26 | 27 | if file.ID < 1 { 28 | return fiber.ErrNotFound 29 | } 30 | 31 | rawName := c.Params("filename") 32 | name, err := url.QueryUnescape(rawName) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if name != "" && name != file.Name { 38 | return fiber.ErrNotFound 39 | } 40 | 41 | c.Locals(configs.LocalFileModel, &file) 42 | return c.Next() 43 | } 44 | } 45 | 46 | func StorageFilePermission() fiber.Handler { 47 | return func(c *fiber.Ctx) error { 48 | file := c.Locals(configs.LocalFileModel).(*models.File) 49 | 50 | if file.AccessToken != nil && !canAccessFile(c, *file.AccessToken) { 51 | return fiber.ErrForbidden 52 | } 53 | 54 | return c.Next() 55 | } 56 | } 57 | 58 | func canAccessFile(c *fiber.Ctx, token string) bool { 59 | rawCode := c.Request().Header.Peek("X-File-Access-Token") 60 | return string(rawCode) == token || c.Query("access_token") == token 61 | } 62 | -------------------------------------------------------------------------------- /pkg/middlewares/group_access.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/database" 6 | "github.com/wizzldev/chat/pkg/configs" 7 | ) 8 | 9 | func GroupAccess(IDLookup string) fiber.Handler { 10 | return func(c *fiber.Ctx) error { 11 | authUserID := c.Locals(configs.LocalAuthUserID).(uint) 12 | groupID, err := c.ParamsInt(IDLookup) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | var can int64 18 | err = database.DB.Table("group_user"). 19 | Where("group_id = ? and user_id = ?", groupID, authUserID). 20 | Limit(1). 21 | Count(&can).Error 22 | 23 | if err != nil || can < 1 { 24 | return fiber.ErrForbidden 25 | } 26 | 27 | return c.Next() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/middlewares/limiter.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/gofiber/fiber/v2/middleware/limiter" 6 | "github.com/wizzldev/chat/app/requests" 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "time" 9 | ) 10 | 11 | func NewSimpleLimiter(max int, expiration time.Duration, message string, keyGenerator ...func(*fiber.Ctx) string) fiber.Handler { 12 | var gen = func(c *fiber.Ctx) string { 13 | return c.IP() 14 | } 15 | 16 | if len(keyGenerator) == 1 { 17 | gen = keyGenerator[0] 18 | } 19 | 20 | return limiter.New(limiter.Config{ 21 | Max: max, 22 | Expiration: expiration, 23 | LimitReached: func(c *fiber.Ctx) error { 24 | return fiber.NewError(fiber.StatusTooManyRequests, message) 25 | }, 26 | KeyGenerator: gen, 27 | }) 28 | } 29 | 30 | func NewAuthLimiter() fiber.Handler { 31 | return NewSimpleLimiter(10, 10*time.Minute, "Too many attempts, try again later", func(c *fiber.Ctx) string { 32 | req := c.Locals(configs.RequestValidation).(*requests.Login) 33 | return req.Email 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/middlewares/role.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/pkg/configs" 6 | "github.com/wizzldev/chat/pkg/repository" 7 | "github.com/wizzldev/chat/pkg/utils/role" 8 | ) 9 | 10 | func NewRoleMiddleware(r role.Role) fiber.Handler { 11 | return func(c *fiber.Ctx) error { 12 | gID, err := c.ParamsInt("id") 13 | if err != nil { 14 | return err 15 | } 16 | 17 | userID := c.Locals(configs.LocalAuthUserID).(uint) 18 | g := repository.Group.Find(uint(gID)) 19 | if g.ID < 1 { 20 | return fiber.ErrNotFound 21 | } 22 | 23 | if g.IsPrivateMessage { 24 | return c.Next() 25 | } 26 | 27 | var roles role.Roles 28 | if g.UserID == userID { 29 | roles = *role.All() 30 | } else { 31 | roles = repository.Group.GetUserRoles(uint(gID), userID, *role.NewRoles(g.Roles)) 32 | } 33 | 34 | if !roles.Can(r) { 35 | return fiber.NewError(fiber.StatusForbidden, "You are not allowed to access this resource") 36 | } 37 | 38 | return c.Next() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/middlewares/session.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/gofiber/fiber/v2/middleware/session" 6 | "github.com/gofiber/fiber/v2/utils" 7 | "github.com/wizzldev/chat/database/rdb" 8 | "github.com/wizzldev/chat/pkg/configs" 9 | utils2 "github.com/wizzldev/chat/pkg/utils" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var Store = session.New(session.Config{ 15 | Expiration: time.Duration(configs.Env.Session.LifespanSeconds) * time.Second, 16 | KeyGenerator: func() string { 17 | return "w_" + utils.UUIDv4() + "__" + strings.ToLower(utils2.NewRandom().String(50)) 18 | }, 19 | KeyLookup: "header:Authorization", 20 | Storage: rdb.Redis, 21 | }) 22 | 23 | func AuthSession(c *fiber.Ctx) (*session.Session, error) { 24 | token, err := getToken(c.Request().Header.Peek("Authorization")) 25 | if err != nil { 26 | return nil, err 27 | } 28 | c.Request().Header.Set("Authorization", token) 29 | return Store.Get(c) 30 | } 31 | 32 | func Session(c *fiber.Ctx) (*session.Session, error) { 33 | return Store.Get(c) 34 | } 35 | 36 | func BotToken(c *fiber.Ctx) (string, error) { 37 | token, err := getToken(c.Request().Header.Peek("Authorization")) 38 | if err != nil { 39 | return "", err 40 | } 41 | if !strings.HasPrefix(token, "bot ") { 42 | return "", fiber.NewError(fiber.StatusBadRequest, "Authorization header does not contain bot token") 43 | } 44 | token = strings.TrimPrefix(token, "bot ") 45 | c.Request().Header.Set("Authorization", token) 46 | return token, nil 47 | } 48 | 49 | func getToken(raw []byte) (string, error) { 50 | authHeader := strings.ToLower(string(raw)) 51 | if !strings.HasPrefix(authHeader, "bearer ") && authHeader != "" { 52 | return "", fiber.NewError(fiber.StatusUnauthorized, "Invalid authorization header") 53 | } 54 | authHeaderTrimmed := strings.TrimPrefix(authHeader, "bearer ") 55 | if !strings.HasPrefix(authHeader, "bearer ") { 56 | return "", fiber.NewError(fiber.StatusForbidden, "Authorization header is not bearer token") 57 | } 58 | return authHeaderTrimmed, nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/problem/problem.go: -------------------------------------------------------------------------------- 1 | package problem 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | type Problem struct { 6 | Type string 7 | Title string 8 | Detail string 9 | Instance string 10 | custom map[string]any 11 | } 12 | 13 | func New(c *fiber.Ctx, statusCode int, Type string, title string, detail string, instance string) error { 14 | return NewProblem(Type, title, detail, instance).Response(c, statusCode) 15 | } 16 | 17 | func NewProblem(Type string, title string, detail string, instance string) *Problem { 18 | return &Problem{ 19 | Type: Type, 20 | Title: title, 21 | Detail: detail, 22 | Instance: instance, 23 | custom: make(map[string]any), 24 | } 25 | } 26 | 27 | func (p *Problem) AddCustomFields(fields map[string]any) { 28 | for k, v := range fields { 29 | p.custom[k] = v 30 | } 31 | } 32 | 33 | func (p *Problem) Response(c *fiber.Ctx, statusCode ...int) error { 34 | status := fiber.StatusInternalServerError 35 | 36 | if len(statusCode) > 0 { 37 | status = statusCode[0] 38 | } 39 | 40 | m := map[string]any{ 41 | "type": p.Type, 42 | "title": p.Title, 43 | "detail": p.Detail, 44 | "instance": p.Instance, 45 | } 46 | 47 | for k, v := range p.custom { 48 | m[k] = v 49 | } 50 | 51 | return c.Status(status).Type("application/problem+json").JSON(m) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/repository/block.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "github.com/wizzldev/chat/database/models" 4 | 5 | type block struct{} 6 | 7 | var Block block 8 | 9 | func (block) IsBlocked(userID, blockedUserID uint) bool { 10 | return IsExists[models.Block]([]string{"user_id", "blocked_user_id"}, []any{userID, blockedUserID}) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/repository/bot.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type bot struct{} 9 | 10 | var Bot bot 11 | 12 | func (bot) FindByToken(t string) *models.User { 13 | var bot models.User 14 | database.DB.Model(&models.User{}).Where("password = ? and is_bot = ?", t, true).First(&bot) 15 | return &bot 16 | } 17 | 18 | func (bot) FindByID(id uint) *models.User { 19 | return FindModelBy[models.User]([]string{"id", "is_bot"}, []any{id, true}) 20 | } 21 | 22 | func (bot) FindBotsForUserID(userID uint) *[]models.User { 23 | var bots []models.User 24 | database.DB.Raw(` 25 | select users.* from users 26 | inner join user_bots on user_bots.bot_id = users.id 27 | where users.is_bot = 1 and user_bots.user_id = ? 28 | `, userID).Find(&bots) 29 | return &bots 30 | } 31 | 32 | func (bot) CountForUser(userID uint) int { 33 | var count int64 34 | database.DB.Model(&models.UserBot{}).Where("user_id = ?", userID).Count(&count) 35 | return int(count) 36 | } 37 | 38 | func (bot) FindUserBot(userID, botID uint) *models.User { 39 | var bot models.User 40 | database.DB.Raw(` 41 | select users.* from users 42 | inner join user_bots on user_bots.user_id = ? and user_bots.bot_id = ? 43 | limit 1 44 | `, 45 | userID, botID).First(&bot) 46 | return &bot 47 | } 48 | -------------------------------------------------------------------------------- /pkg/repository/email_verification.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type emailVerification struct{} 9 | 10 | var EmailVerification emailVerification 11 | 12 | func (emailVerification) FindUserByToken(token string) *models.User { 13 | var user models.User 14 | database.DB.Raw(` 15 | select users.* from users 16 | inner join email_verifications on users.id = email_verifications.user_id 17 | where email_verifications.token = ? 18 | and email_verifications.created_at > DATE_SUB(NOW(), INTERVAL 1 DAY) 19 | limit 1 20 | `, token).Scan(&user) 21 | return &user 22 | } 23 | 24 | func (emailVerification) FindLatestForUser(uid uint) *models.EmailVerification { 25 | var model models.EmailVerification 26 | database.DB.Model(&models.EmailVerification{}).Where("user_id = ? and created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)", uid).Order("created_at DESC").First(&model) 27 | return &model 28 | } 29 | -------------------------------------------------------------------------------- /pkg/repository/group.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gofiber/fiber/v2/log" 6 | "github.com/wizzldev/chat/database" 7 | "github.com/wizzldev/chat/database/models" 8 | "github.com/wizzldev/chat/pkg/encryption" 9 | "github.com/wizzldev/chat/pkg/repository/paginator" 10 | "github.com/wizzldev/chat/pkg/utils/role" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | type group struct{} 16 | 17 | var Group group 18 | 19 | func (group) FindPM(ID uint) *models.Group { 20 | return FindModelBy[models.Group]([]string{"id", "is_private_message"}, []any{ID, true}) 21 | } 22 | 23 | func (group) Find(ID uint) *models.Group { 24 | return FindModelBy[models.Group]([]string{"id"}, []any{ID}) 25 | } 26 | 27 | func (group) CanUserAccess(groupID uint, u *models.User) bool { 28 | var count int64 29 | err := database.DB.Raw(` 30 | select count(*) from groups 31 | inner join group_user on group_user.group_id = groups.id 32 | inner join users on users.id = group_user.user_id 33 | where groups.id = ? and users.id = ? 34 | limit 1 35 | `, groupID, u.ID). 36 | Count(&count).Error 37 | 38 | if err != nil { 39 | log.Warn("Failed to execute query:", err) 40 | return false 41 | } 42 | 43 | return count > 0 44 | } 45 | 46 | func (group) GetUserIDs(groupID uint) []uint { 47 | var gIDs []uint 48 | err := database.DB.Raw(` 49 | select distinct users.id from groups 50 | inner join group_user on group_user.group_id = groups.id 51 | inner join users on users.id = group_user.user_id 52 | where groups.id = ? 53 | `, groupID).Find(&gIDs).Error 54 | 55 | if err != nil { 56 | log.Warn("Failed to execute query:", err) 57 | } 58 | 59 | return gIDs 60 | } 61 | 62 | func (group) IsGroupExists(userIDs [2]uint) (uint, bool) { 63 | var data struct { 64 | GroupID uint 65 | } 66 | 67 | database.DB.Raw(` 68 | select gu.group_id as group_id from group_user gu 69 | inner join users on gu.user_id = users.id 70 | inner join groups on groups.id = gu.group_id 71 | group by gu.group_id 72 | having sum(gu.user_id = ?) > 0 73 | and sum(gu.user_id = ?) > 0 74 | and count(*) = 2 75 | `, userIDs[0], userIDs[1]). 76 | Scan(&data) 77 | 78 | return data.GroupID, data.GroupID != 0 79 | } 80 | 81 | func (group) GetContactsForUser(userID uint, page int, authUser *models.User) *[]Contact { 82 | var perPage = 15 83 | var offset = perPage * (page - 1) 84 | 85 | var data []struct { 86 | IsMessageEncrypted bool 87 | MessageContent *string 88 | MessageType string 89 | MessageCreatedAt time.Time 90 | SenderID uint 91 | SenderName string 92 | GroupID uint 93 | IsPrivateMessage bool 94 | GroupName *string 95 | ImageURL *string 96 | Verified bool 97 | CustomInvite *string 98 | UserID uint 99 | SenderNickName *string 100 | } 101 | _ = database.DB.Raw(` 102 | select 103 | messages.encrypted as is_message_encrypted, 104 | messages.content as message_content, 105 | messages.type as message_type, 106 | messages.created_at as message_created_at, 107 | users.id as sender_id, 108 | users.first_name as sender_name, 109 | groups.id as group_id, 110 | groups.is_private_message, 111 | groups.name as group_name, 112 | groups.image_url, 113 | groups.verified, 114 | groups.custom_invite, 115 | groups.user_id, 116 | group_user.nick_name as sender_nick_name 117 | from messages 118 | join ( 119 | select group_id, max(created_at) as max_created_at from messages 120 | group by group_id order by created_at desc 121 | ) as latest_messages 122 | on messages.group_id = latest_messages.group_id 123 | and messages.created_at = latest_messages.max_created_at 124 | left join users on messages.sender_id = users.id and users.is_bot = 0 125 | join groups on messages.group_id = groups.id 126 | left join group_user on group_user.user_id = messages.sender_id 127 | and group_user.group_id = groups.id 128 | where groups.id in ( 129 | select distinct group_user.group_id 130 | from group_user 131 | where group_user.user_id = ? 132 | ) 133 | and users.id is not null 134 | order by message_created_at desc 135 | limit `+strconv.Itoa(perPage)+` offset `+strconv.Itoa(offset)+` 136 | `, userID).Find(&data).Error 137 | 138 | var privateMessageIDs []uint 139 | for _, v := range data { 140 | if v.IsPrivateMessage { 141 | privateMessageIDs = append(privateMessageIDs, v.GroupID) 142 | } 143 | } 144 | 145 | var userGroupMap []struct { 146 | GroupID uint 147 | UserFirstName string 148 | UserLastName string 149 | UserImageUrl string 150 | } 151 | _ = database.DB.Raw(` 152 | select 153 | groups.id as group_id, 154 | users.first_name as user_first_name, 155 | users.last_name as user_last_name, 156 | users.image_url as user_image_url 157 | from group_user 158 | inner join groups on groups.id = group_user.group_id 159 | inner join users on users.id = group_user.user_id 160 | where group_user.group_id in (?) and users.id != ? 161 | `, privateMessageIDs, userID).Find(&userGroupMap).Error 162 | 163 | var contacts []Contact 164 | 165 | for _, v := range data { 166 | groupName := "" 167 | imageURL := "" 168 | 169 | if v.GroupName != nil { 170 | groupName = *v.GroupName 171 | } else { 172 | for _, u := range userGroupMap { 173 | if u.GroupID == v.GroupID { 174 | groupName = fmt.Sprintf("%s %s", u.UserFirstName, u.UserLastName) 175 | break 176 | } 177 | } 178 | if groupName == "" { 179 | groupName = "You#allowTranslation" 180 | } 181 | } 182 | 183 | if v.ImageURL != nil { 184 | imageURL = *v.ImageURL 185 | } else { 186 | for _, u := range userGroupMap { 187 | if u.GroupID == v.GroupID { 188 | imageURL = u.UserImageUrl 189 | } 190 | } 191 | if imageURL == "" { 192 | imageURL = authUser.ImageURL 193 | } 194 | } 195 | 196 | var msgContent = *v.MessageContent 197 | 198 | if v.IsMessageEncrypted { 199 | s, err := encryption.DecryptMessage(msgContent) 200 | if err != nil { 201 | msgContent = "#fail.decrypt" 202 | } else { 203 | msgContent = s 204 | } 205 | } 206 | 207 | contact := Contact{ 208 | ID: v.GroupID, 209 | Name: groupName, 210 | ImageURL: imageURL, 211 | Verified: v.Verified, 212 | IsPrivateMessage: v.IsPrivateMessage, 213 | CustomInvite: v.CustomInvite, 214 | CreatorID: v.UserID, 215 | LastMessage: LastMessage{ 216 | SenderID: v.SenderID, 217 | SenderName: v.SenderName, 218 | NickName: v.SenderNickName, 219 | Content: &msgContent, 220 | Type: v.MessageType, 221 | Date: v.MessageCreatedAt, 222 | }, 223 | } 224 | contacts = append(contacts, contact) 225 | } 226 | 227 | return &contacts 228 | } 229 | 230 | func (group) GetChatUser(chatID uint, userID uint) *models.Group { 231 | var data models.Group 232 | _ = database.DB.Model(&models.Group{}).Preload("Theme"). 233 | Where("id = ?", chatID).Find(&data).Error 234 | 235 | if data.IsPrivateMessage { 236 | var user models.User 237 | _ = database.DB.Raw(` 238 | select users.* from group_user 239 | inner join groups on groups.id = group_user.group_id 240 | inner join users on users.id = group_user.user_id 241 | where group_id = ? and users.id != ? 242 | limit 1 243 | `, data.ID, userID).Scan(&user).Error 244 | data.ImageURL = &user.ImageURL 245 | if user.ID > 0 { 246 | name := fmt.Sprintf("%s %s", user.FirstName, user.LastName) 247 | data.Name = &name 248 | } 249 | } 250 | 251 | return &data 252 | } 253 | 254 | func (group) GetUserRoles(gID uint, uID uint, roles role.Roles) role.Roles { 255 | var gUser models.GroupUser 256 | database.DB.Model(&models.GroupUser{}).Where("user_id = ? and group_id = ?", uID, gID).First(&gUser) 257 | 258 | for _, r := range gUser.Roles { 259 | realRole, err := role.New(r) 260 | if err != nil { 261 | continue 262 | } 263 | if realRole == role.Creator { 264 | return *role.All() 265 | } 266 | if realRole == role.Admin { 267 | roles = *role.All() 268 | roles.Revoke(role.Creator) 269 | break 270 | } 271 | roles = append(roles, realRole) 272 | } 273 | 274 | return roles 275 | } 276 | 277 | func (group) FindGroupUser(gID uint, uID uint) *models.GroupUser { 278 | var gUser models.GroupUser 279 | err := database.DB.Model(&models.GroupUser{}).Where("group_id = ? and user_id = ?", gID, uID).Find(&gUser) 280 | fmt.Println(err) 281 | return &gUser 282 | } 283 | 284 | func (group) IsBanned(groupID, userID uint) bool { 285 | var ban models.Ban 286 | database.DB.Model(&models.Ban{}).Where("user_id = ? and group_id = ?", userID, groupID).First(&ban) 287 | return ban.Exists() 288 | } 289 | 290 | func (group) IsGroupUserExists(groupID, userID uint) bool { 291 | var count int64 292 | database.DB.Model(&models.GroupUser{}).Where("group_id = ? and user_id = ?", groupID, userID). 293 | Limit(1). 294 | Count(&count) 295 | return count > 0 296 | } 297 | 298 | func (group) CustomInviteExists(s string) bool { 299 | var count int64 300 | database.DB.Model(&models.Group{}).Where("custom_invite not null and lower(custom_invite) = lower(?)", s). 301 | Limit(1). 302 | Count(&count) 303 | return count > 0 304 | } 305 | 306 | func (group) Users(gID uint, cursor string) (Pagination[models.User], error) { 307 | query := database.DB.Model(&models.User{}). 308 | Preload("GroupUser"). 309 | Where("users.id in (select user_id from group_user where group_id = ?)", gID) 310 | 311 | data, next, prev, err := paginator.Paginate[models.User](query, &paginator.Config{ 312 | Cursor: cursor, 313 | Order: "desc", 314 | Limit: 30, 315 | PointsNext: false, 316 | }) 317 | 318 | return Pagination[models.User]{ 319 | Data: data, 320 | NextCursor: next, 321 | Previous: prev, 322 | }, err 323 | } 324 | 325 | func (group) UserCount(gID uint) int { 326 | var count int64 327 | database.DB.Model(&models.User{}). 328 | Where("users.is_bot = 0 and users.id in (select user_id from group_user where group_id = ?)", gID). 329 | Count(&count) 330 | 331 | return int(count) 332 | } 333 | -------------------------------------------------------------------------------- /pkg/repository/group_types.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "time" 4 | 5 | type LastMessage struct { 6 | SenderID uint `json:"sender_id"` 7 | SenderName string `json:"sender_name"` 8 | NickName *string `json:"nick_name"` 9 | Content *string `json:"content"` 10 | Type string `json:"type"` 11 | Date time.Time `json:"date"` 12 | } 13 | 14 | type Contact struct { 15 | ID uint `json:"id"` 16 | Name string `json:"name"` 17 | ImageURL string `json:"image"` 18 | Verified bool `json:"is_verified"` 19 | IsPrivateMessage bool `json:"is_private_message"` 20 | CustomInvite *string `json:"custom_invite"` 21 | CreatorID uint `json:"creator_id"` 22 | LastMessage LastMessage `json:"last_message"` 23 | } 24 | -------------------------------------------------------------------------------- /pkg/repository/group_user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type groupUser struct{} 9 | 10 | var GroupUser groupUser 11 | 12 | func (groupUser) Delete(groupID uint, userID uint) { 13 | database.DB.Where("group_id = ? and user_id = ?", groupID, userID).Delete(&models.GroupUser{}) 14 | } 15 | 16 | func (groupUser) Find(groupID uint, userID uint) (*models.GroupUser, error) { 17 | var gu models.GroupUser 18 | 19 | err := database.DB.Model(&models.GroupUser{}).Where("user_id = ? and group_id = ?", userID, groupID).First(&gu).Error 20 | 21 | return &gu, err 22 | } 23 | 24 | func (groupUser) Update(gu *models.GroupUser) error { 25 | return database.DB.Where("group_id = ? and user_id = ?", gu.GroupID, gu.UserID).First(&gu).Error 26 | } 27 | -------------------------------------------------------------------------------- /pkg/repository/invite.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database/models" 5 | "github.com/wizzldev/chat/pkg/utils" 6 | "math" 7 | "sync" 8 | ) 9 | 10 | type invite struct { 11 | mu *sync.Mutex 12 | } 13 | 14 | var Invite = &invite{mu: &sync.Mutex{}} 15 | 16 | func (i *invite) CreateCode() string { 17 | rand := utils.NewRandom() 18 | 19 | var ( 20 | key = rand.String(6) 21 | trials = 0.0 22 | ) 23 | 24 | i.mu.Lock() 25 | if IsExists[models.Invite]([]string{"invites.key"}, []any{key}) || IsExists[models.Group]([]string{"custom_invite"}, []any{key}) { 26 | trials += 0.3 27 | times := int(math.Trunc(trials)) 28 | if times == 1 { 29 | times = 2 30 | } 31 | 32 | key = rand.String(6 * times) 33 | } 34 | i.mu.Unlock() 35 | 36 | return key 37 | } 38 | 39 | func (i *invite) FindInviteByCode(id string) *models.Invite { 40 | return FindModelBy[models.Invite]([]string{"invites.key"}, []any{id}) 41 | 42 | } 43 | 44 | func (i *invite) FindGroupInviteByCode(id string) *models.Group { 45 | return FindModelBy[models.Group]([]string{"custom_invite"}, []any{id}) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/repository/ips.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type ips struct{} 9 | 10 | var IPs ips 11 | 12 | func (ips) AllForUser(uID uint) []*models.AllowedIP { 13 | var ip []*models.AllowedIP 14 | database.DB.Model(&models.AllowedIP{}).Where("user_id = ? and active = ?", uID, true).Order("created_at desc").Limit(30).Find(&ip) 15 | return ip 16 | } 17 | 18 | func (ips) FindForUser(uID uint, id uint) *models.AllowedIP { 19 | var s *models.AllowedIP 20 | database.DB.Model(&models.AllowedIP{}).Where("user_id = ? and id = ? and active = ?", uID, id, true).First(&s) 21 | return s 22 | } 23 | -------------------------------------------------------------------------------- /pkg/repository/messge.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | "github.com/wizzldev/chat/pkg/repository/paginator" 7 | ) 8 | 9 | type message struct{} 10 | 11 | var Message message 12 | 13 | func (message) FindOne(messageID uint) *models.Message { 14 | var msg models.Message 15 | 16 | _ = database.DB.Model(&models.Message{}). 17 | Preload("Sender"). 18 | Preload("Reply.Sender"). 19 | Preload("MessageLikes.User"). 20 | Where("id = ?", messageID). 21 | Order("created_at desc"). 22 | First(&msg).Error 23 | 24 | return &msg 25 | } 26 | 27 | func (message) Latest(gID uint) (*[]models.Message, string, string) { 28 | var messages []models.Message 29 | 30 | _ = database.DB.Model(&models.Message{}). 31 | Preload("Sender"). 32 | Preload("Reply.Sender"). 33 | Preload("MessageLikes.User"). 34 | Where("group_id = ?", gID). 35 | Order("created_at desc"). 36 | Limit(30).Find(&messages).Error 37 | 38 | return &messages, "", "" 39 | } 40 | 41 | func (message) LatestOne(gID uint) *models.Message { 42 | var m models.Message 43 | 44 | database.DB.Model(&models.Message{}). 45 | Preload("Sender"). 46 | Preload("Reply.Sender"). 47 | Preload("MessageLikes.User"). 48 | Where("group_id = ?", gID). 49 | Order("created_at desc"). 50 | First(&m) 51 | 52 | return &m 53 | } 54 | 55 | func (message) CursorPaginate(gID uint, cursor string) (Pagination[models.Message], error) { 56 | query := database.DB.Model(&models.Message{}).Preload("Sender"). 57 | Preload("Reply.Sender"). 58 | Preload("MessageLikes.User"). 59 | Where("group_id = ?", gID) 60 | 61 | data, next, prev, err := paginator.Paginate[models.Message](query, &paginator.Config{ 62 | Cursor: cursor, 63 | Order: "desc", 64 | Limit: 30, 65 | PointsNext: false, 66 | }) 67 | 68 | return Pagination[models.Message]{ 69 | Data: data, 70 | NextCursor: next, 71 | Previous: prev, 72 | }, err 73 | } 74 | -------------------------------------------------------------------------------- /pkg/repository/paginator/paginate.go: -------------------------------------------------------------------------------- 1 | package paginator 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | type Config struct { 9 | Cursor string 10 | Order string 11 | PointsNext bool 12 | Limit int 13 | } 14 | 15 | func Paginate[M interface{}](query *gorm.DB, config *Config) (*[]M, string, string, error) { 16 | var models []M 17 | 18 | isFirstPage := config.Cursor == "" 19 | pointsNext := false 20 | 21 | if config.Cursor != "" { 22 | decodedCursor, err := decodeCursor(config.Cursor) 23 | if err != nil { 24 | return nil, "", "", err 25 | } 26 | 27 | pointsNext = decodedCursor["points_next"] == true 28 | operator, order := getPaginationOperator(pointsNext, config.Order) 29 | whereStr := fmt.Sprintf("(created_at %s ? or (created_at = ? and id %s ?))", operator, operator) 30 | query = query.Where(whereStr, decodedCursor["created_at"], decodedCursor["created_at"], decodedCursor["id"]) 31 | if order != "" { 32 | config.Order = order 33 | } 34 | } 35 | 36 | query.Order("created_at " + config.Order).Limit(config.Limit + 1).Find(&models) 37 | hasPagination := len(models) > config.Limit 38 | 39 | if hasPagination { 40 | models = models[:config.Limit] 41 | } 42 | 43 | if !isFirstPage && !pointsNext { 44 | models = reverse(models) 45 | } 46 | 47 | next, prev := calculatePagination[M](isFirstPage, hasPagination, config.Limit, models, pointsNext) 48 | 49 | return &models, next, prev, nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/repository/paginator/utils.go: -------------------------------------------------------------------------------- 1 | package paginator 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "reflect" 7 | "time" 8 | ) 9 | 10 | func reverse[T any](s []T) []T { 11 | for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { 12 | s[i], s[j] = s[j], s[i] 13 | } 14 | return s 15 | } 16 | 17 | type Cursor map[string]interface{} 18 | 19 | func createCursor(id uint, createdAt time.Time, pointsNext bool) Cursor { 20 | return Cursor{ 21 | "id": id, 22 | "created_at": createdAt, 23 | "points_next": pointsNext, 24 | } 25 | } 26 | 27 | func encodeCursor(cursor Cursor) string { 28 | if len(cursor) == 0 { 29 | return "" 30 | } 31 | serializedCursor, err := json.Marshal(cursor) 32 | if err != nil { 33 | return "" 34 | } 35 | encodedCursor := base64.StdEncoding.EncodeToString(serializedCursor) 36 | return encodedCursor 37 | } 38 | 39 | func decodeCursor(cursor string) (Cursor, error) { 40 | decodedCursor, err := base64.StdEncoding.DecodeString(cursor) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | var cur Cursor 46 | if err := json.Unmarshal(decodedCursor, &cur); err != nil { 47 | return nil, err 48 | } 49 | return cur, nil 50 | } 51 | 52 | func getPaginationOperator(pointsNext bool, sortOrder string) (string, string) { 53 | if pointsNext && sortOrder == "asc" { 54 | return ">", "" 55 | } 56 | if pointsNext && sortOrder == "desc" { 57 | return "<", "" 58 | } 59 | if !pointsNext && sortOrder == "asc" { 60 | return "<", "desc" 61 | } 62 | if !pointsNext && sortOrder == "desc" { 63 | return ">", "asc" 64 | } 65 | 66 | return "", "" 67 | } 68 | 69 | func calculatePagination[M interface{}](isFirstPage bool, hasPagination bool, limit int, messages []M, pointsNext bool) (string, string) { 70 | nextCur := Cursor{} 71 | prevCur := Cursor{} 72 | if isFirstPage { 73 | if hasPagination { 74 | nextCur = createCursor(getAttr(messages[limit-1], "ID").(uint), getAttr(messages[limit-1], "CreatedAt").(time.Time), true) 75 | } 76 | } else { 77 | if pointsNext { 78 | // if pointing next, it always has prev, but it might not have next 79 | if hasPagination { 80 | nextCur = createCursor(getAttr(messages[limit-1], "ID").(uint), getAttr(messages[limit-1], "CreatedAt").(time.Time), true) 81 | } 82 | prevCur = createCursor(getAttr(messages[0], "ID").(uint), getAttr(messages[0], "CreatedAt").(time.Time), false) 83 | } else { 84 | // this is case of prev, there will always be, nest, but prev needs to be calculated 85 | nextCur = createCursor(getAttr(messages[limit-1], "ID").(uint), getAttr(messages[limit-1], "CreatedAt").(time.Time), true) 86 | if hasPagination { 87 | prevCur = createCursor(getAttr(messages[0], "ID").(uint), getAttr(messages[limit-1], "CreatedAt").(time.Time), false) 88 | } 89 | } 90 | } 91 | 92 | return encodeCursor(nextCur), encodeCursor(prevCur) 93 | } 94 | 95 | func getAttr(v interface{}, field string) interface{} { 96 | r := reflect.ValueOf(v) 97 | f := reflect.Indirect(r).FieldByName(field) 98 | return f.Interface() 99 | } 100 | -------------------------------------------------------------------------------- /pkg/repository/reset_password.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type resetPassword struct{} 9 | 10 | var ResetPassword resetPassword 11 | 12 | func (resetPassword) FindUserByToken(token string) *models.User { 13 | var user models.User 14 | database.DB.Raw(` 15 | select users.* from users 16 | inner join reset_passwords on users.id = reset_passwords.user_id 17 | where reset_passwords.token = ? 18 | and reset_passwords.created_at > DATE_SUB(NOW(), INTERVAL 1 DAY) 19 | limit 1 20 | `, token).Scan(&user) 21 | return &user 22 | } 23 | 24 | func (resetPassword) FindLatestForUser(uid uint) *models.ResetPassword { 25 | var model models.ResetPassword 26 | database.DB.Model(&models.ResetPassword{}).Where("user_id = ? and created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)", uid).Order("created_at DESC").First(&model) 27 | return &model 28 | } 29 | -------------------------------------------------------------------------------- /pkg/repository/session.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type session struct{} 9 | 10 | var Session session 11 | 12 | func (session) AllForUser(uID uint) []*models.Session { 13 | var sessions []*models.Session 14 | database.DB.Model(&models.Session{}).Where("user_id = ?", uID).Order("created_at desc").Limit(30).Find(&sessions) 15 | return sessions 16 | } 17 | 18 | func (session) FindForUser(uID uint, id uint) *models.Session { 19 | var s *models.Session 20 | database.DB.Model(&models.Session{}).Where("user_id = ? and id = ?", uID, id).First(&s) 21 | return s 22 | } 23 | 24 | func (session) FindForUserByIP(uID uint, ip string) []*models.Session { 25 | var s []*models.Session 26 | database.DB.Model(&models.Session{}).Where("user_id = ? and ip = ?", uID, ip).Find(&s) 27 | return s 28 | } 29 | -------------------------------------------------------------------------------- /pkg/repository/theme.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | "github.com/wizzldev/chat/pkg/repository/paginator" 7 | ) 8 | 9 | type theme struct{} 10 | 11 | var Theme theme 12 | 13 | func (theme) Find(id uint) *models.Theme { 14 | return FindModelBy[models.Theme]([]string{"id"}, []any{id}) 15 | } 16 | 17 | func (theme) Paginate(cursor string) (Pagination[models.Theme], error) { 18 | query := database.DB.Model(&models.Theme{}) 19 | 20 | data, next, prev, err := paginator.Paginate[models.Theme](query, &paginator.Config{ 21 | Cursor: cursor, 22 | Order: "desc", 23 | Limit: 30, 24 | PointsNext: false, 25 | }) 26 | 27 | return Pagination[models.Theme]{ 28 | Data: data, 29 | NextCursor: next, 30 | Previous: prev, 31 | }, err 32 | } 33 | -------------------------------------------------------------------------------- /pkg/repository/user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "github.com/wizzldev/chat/database/models" 6 | ) 7 | 8 | type user struct{} 9 | 10 | var User user 11 | 12 | // FindById returns the user with the given id or an error 13 | func (u user) FindById(id uint) *models.User { 14 | return u.FindBy([]string{"id"}, id) 15 | } 16 | 17 | func (u user) FindByEmail(email string) *models.User { 18 | return u.FindBy([]string{"email"}, email) 19 | } 20 | 21 | func (user) FindBy(fields []string, values ...interface{}) *models.User { 22 | return FindModelBy[models.User](fields, values) 23 | } 24 | 25 | func (user) IsEmailExists(email string) bool { 26 | return IsExists[models.User]([]string{"email"}, []any{email}) 27 | } 28 | 29 | func (user) IsIPAllowed(uID uint, ip string) bool { 30 | var count int64 31 | database.DB.Model(&models.AllowedIP{}).Where("user_id = ? and ip = ? and active = ?", uID, ip, true).Count(&count) 32 | return count > 0 33 | } 34 | 35 | func (user) IsBlocked(blockerID uint, blockedID uint) bool { 36 | return IsExists[models.Block]([]string{"user_id", "blocked_user_id"}, []any{blockerID, blockedID}) 37 | } 38 | 39 | func (user) Search(f string, l string, e string, page int) []*models.User { 40 | var users []*models.User 41 | q := database.DB.Model(models.User{}) 42 | where := "" 43 | var whereData []any 44 | if f != "" { 45 | where += `first_name like ?` 46 | whereData = append(whereData, "%"+f+"%") 47 | } 48 | if l != "" { 49 | if where != "" { 50 | where += " and " 51 | } 52 | where += `last_name like ?` 53 | whereData = append(whereData, "%"+l+"%") 54 | } 55 | 56 | if e != "" { 57 | if where != "" { 58 | where += " and " 59 | } 60 | where += `email like ?` 61 | whereData = append(whereData, "%"+e+"%") 62 | } 63 | 64 | if where != "" { 65 | q.Where(where, whereData...) 66 | } 67 | 68 | err := q.Order("created_at desc"). 69 | Limit(10). 70 | Offset(10 * (page - 1)). 71 | Find(&users).Error 72 | 73 | if err != nil { 74 | return users 75 | } 76 | 77 | return users 78 | } 79 | 80 | func (user) FindAndroidNotifications(userIDs []uint) []string { 81 | var notifications []models.AndroidPushNotification 82 | database.DB.Model(&models.AndroidPushNotification{}).Where("user_id in ?", userIDs).Find(¬ifications) 83 | var tokens []string 84 | for _, notification := range notifications { 85 | tokens = append(tokens, notification.Token) 86 | } 87 | return tokens 88 | } 89 | 90 | func (user) IsAndroidNotificationTokenExists(userID uint, token string) bool { 91 | var count int64 92 | database.DB.Model(&models.AndroidPushNotification{}).Where("user_id = ? and token = ?", userID, token).Limit(1).Count(&count) 93 | return count > 0 94 | } 95 | -------------------------------------------------------------------------------- /pkg/repository/utils.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/wizzldev/chat/database" 5 | "strings" 6 | ) 7 | 8 | type Pagination[M interface{}] struct { 9 | Data *[]M `json:"data"` 10 | NextCursor string `json:"next_cursor"` 11 | Previous string `json:"previous_cursor"` 12 | } 13 | 14 | func FindModelBy[M any](fields []string, values []any) *M { 15 | var model M 16 | 17 | _ = database.DB.Model(model). 18 | Where(buildWhereQuery(fields), values...). 19 | Limit(1). 20 | Find(&model) 21 | 22 | return &model 23 | } 24 | 25 | func All[M any]() []*M { 26 | var models []*M 27 | _ = database.DB.Find(&models) 28 | return models 29 | } 30 | 31 | func buildWhereQuery(fields []string) string { 32 | var query string 33 | for _, f := range fields { 34 | query += " " + f + " = ? and" 35 | } 36 | return strings.TrimSuffix(query, " and") 37 | } 38 | 39 | func IsExists[M any](fields []string, values []any) bool { 40 | var model M 41 | var count int64 42 | 43 | database.DB.Model(model). 44 | Where(buildWhereQuery(fields), values...). 45 | Limit(1). 46 | Count(&count) 47 | 48 | return count > 0 49 | } 50 | 51 | func IDsExists[M any](IDs []uint) []uint { 52 | var model M 53 | var existing []uint 54 | database.DB.Model(model).Select("id").Where("id in (?)", IDs).Find(&existing) 55 | return existing 56 | } 57 | -------------------------------------------------------------------------------- /pkg/services/auth.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/database" 6 | "github.com/wizzldev/chat/database/models" 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "github.com/wizzldev/chat/pkg/middlewares" 9 | "github.com/wizzldev/chat/pkg/repository" 10 | "github.com/wizzldev/chat/pkg/utils" 11 | "net" 12 | ) 13 | 14 | type Auth struct { 15 | ctx *fiber.Ctx 16 | } 17 | 18 | type AuthRequest struct { 19 | Email string `json:"email"` 20 | Password string `json:"-"` 21 | } 22 | 23 | type AuthResponse struct { 24 | MustVerifyIP bool 25 | Token string 26 | User *models.User 27 | } 28 | 29 | func NewAuth(c *fiber.Ctx) *Auth { 30 | return &Auth{ctx: c} 31 | } 32 | 33 | func (a *Auth) Login(request *AuthRequest) (*AuthResponse, error) { 34 | sess, err := middlewares.Session(a.ctx) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | user := repository.User.FindByEmail(request.Email) 40 | if user.ID < 1 { 41 | return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid email or password") 42 | } 43 | 44 | if user.IsBot { 45 | return nil, fiber.NewError(fiber.StatusBadRequest, "Cannot use bot as user") 46 | } 47 | 48 | if !utils.NewPassword(request.Password).Compare(user.Password) { 49 | return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid email or password") 50 | } 51 | 52 | if user.EmailVerifiedAt == nil { 53 | return nil, fiber.NewError(fiber.StatusUnauthorized, "Please verify your email before login") 54 | } 55 | 56 | ip := a.ctx.IP() 57 | if user.EnableIPCheck && !repository.User.IsIPAllowed(user.ID, ip) && !net.ParseIP(ip).IsPrivate() { 58 | return &AuthResponse{ 59 | MustVerifyIP: true, 60 | User: user, 61 | }, nil 62 | } 63 | 64 | sess.Set(configs.SessionAuthUserID, user.ID) 65 | sessID := sess.ID() 66 | err = sess.Save() 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | database.DB.Create(&models.Session{ 72 | HasUser: models.HasUserID(user.ID), 73 | IP: ip, 74 | SessionID: sessID, 75 | Agent: string(a.ctx.Request().Header.Peek("User-Agent")), 76 | }) 77 | 78 | return &AuthResponse{ 79 | MustVerifyIP: false, 80 | Token: sessID, 81 | User: user, 82 | }, nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/services/firebase.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "encoding/base64" 5 | firebase "firebase.google.com/go" 6 | "firebase.google.com/go/messaging" 7 | "fmt" 8 | "github.com/wizzldev/chat/pkg/configs" 9 | "golang.org/x/net/context" 10 | "google.golang.org/api/option" 11 | "strconv" 12 | ) 13 | 14 | type pushNotification struct { 15 | init bool 16 | client *messaging.Client 17 | } 18 | 19 | var PushNotification = &pushNotification{ 20 | init: false, 21 | client: nil, 22 | } 23 | 24 | func (p *pushNotification) Init() error { 25 | if p.init { 26 | return nil 27 | } 28 | 29 | p.init = true 30 | 31 | decodedKey, err := p.getKey() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | opts := []option.ClientOption{option.WithCredentialsJSON(decodedKey)} 37 | 38 | app, err := firebase.NewApp(context.Background(), nil, opts...) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | fcmClient, err := app.Messaging(context.Background()) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | p.client = fcmClient 49 | 50 | return nil 51 | } 52 | 53 | func (p *pushNotification) Send(tokens []string, gID uint, title string, body string, imageURL string) error { 54 | if !p.init { 55 | return nil 56 | } 57 | _, err := p.client.SendMulticast(context.Background(), &messaging.MulticastMessage{ 58 | Notification: &messaging.Notification{ 59 | Title: title, 60 | Body: body, 61 | }, 62 | Android: &messaging.AndroidConfig{ 63 | CollapseKey: fmt.Sprintf("group_%d", gID), 64 | Notification: &messaging.AndroidNotification{ 65 | Title: title, 66 | Body: body, 67 | Icon: imageURL, 68 | Color: "#B26AF4", 69 | Tag: fmt.Sprintf("group_%d", gID), 70 | }, 71 | }, 72 | Tokens: tokens, 73 | Data: map[string]string{ 74 | "chat_id": strconv.Itoa(int(gID)), 75 | }, 76 | }) 77 | return err 78 | } 79 | 80 | func (*pushNotification) getKey() ([]byte, error) { 81 | return base64.StdEncoding.DecodeString(configs.Env.FirebaseAuthKey) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/utils/difference.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Difference(a, b []uint) []uint { 4 | mb := make(map[uint]struct{}, len(b)) 5 | for _, x := range b { 6 | mb[x] = struct{}{} 7 | } 8 | var diff []uint 9 | for _, x := range a { 10 | if _, found := mb[x]; !found { 11 | diff = append(diff, x) 12 | } 13 | } 14 | return diff 15 | } 16 | -------------------------------------------------------------------------------- /pkg/utils/image_url.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func GetAvatarURL(image string, size ...int) string { 10 | var img string 11 | if len(size) == 0 { 12 | img = image 13 | } else { 14 | data := strings.Split(image, ".") 15 | img = data[0] + "-s" + strconv.Itoa(size[0]) + "." + data[1] 16 | } 17 | return fmt.Sprintf("https://api.wizzl.app/storage/avatars/%s", img) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/utils/is_emoji.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | emoji "github.com/tmdvs/Go-Emoji-Utils" 5 | ) 6 | 7 | func IsEmoji(s string) bool { 8 | _, err := emoji.LookupEmoji(s) 9 | return err == nil 10 | } 11 | -------------------------------------------------------------------------------- /pkg/utils/is_uuid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "regexp" 4 | 5 | func IsValidUUID(uuid string) bool { 6 | r := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") 7 | return r.MatchString(uuid) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/utils/mail.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/wizzldev/chat/pkg/configs" 10 | "gopkg.in/gomail.v2" 11 | ) 12 | 13 | type Mail struct { 14 | fromAddress string 15 | toAddresses []string 16 | subject string 17 | body string 18 | isHTML bool 19 | } 20 | 21 | func NewMail(from string, to ...string) *Mail { 22 | return &Mail{ 23 | fromAddress: from, 24 | toAddresses: to, 25 | } 26 | } 27 | 28 | func (m *Mail) Subject(s string) *Mail { 29 | m.subject = s 30 | return m 31 | } 32 | 33 | func (m *Mail) Body(b string, html bool) *Mail { 34 | m.body = b 35 | m.isHTML = html 36 | return m 37 | } 38 | 39 | func (m *Mail) TemplateBody(t string, props map[string]string, otherwise string) *Mail { 40 | m.isHTML = true 41 | pwd, err := os.Getwd() 42 | 43 | if err != nil { 44 | fmt.Println("Failed to get current working directory:", err) 45 | m.body = otherwise 46 | return m 47 | } 48 | 49 | path := pwd + "/templates/" + t + ".html" 50 | data, err := os.ReadFile(path) 51 | 52 | if err != nil { 53 | fmt.Println("Failed to open template file:", err) 54 | m.body = otherwise 55 | return m 56 | } 57 | 58 | dataStr := string(data) 59 | 60 | for k, v := range props { 61 | dataStr = strings.Replace(dataStr, "@"+k, v, 1) 62 | } 63 | 64 | m.body = dataStr 65 | return m 66 | } 67 | 68 | func (m *Mail) Send() error { 69 | gm := gomail.NewMessage() 70 | gm.SetHeader("From", m.fromAddress) 71 | gm.SetHeader("To", m.toAddresses...) 72 | gm.SetHeader("Subject", m.subject) 73 | 74 | contentType := "text/html" 75 | if !m.isHTML { 76 | contentType = "text/plain" 77 | } 78 | 79 | gm.SetBody(contentType, m.body) 80 | 81 | d := gomail.NewDialer(configs.Env.Email.SMTPHost, configs.Env.Email.SMTPPort, configs.Env.Email.Username, configs.Env.Email.Password) 82 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true} 83 | 84 | if err := d.DialAndSend(gm); err != nil { 85 | return err 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/utils/password.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | type Password struct { 6 | password string 7 | } 8 | 9 | func NewPassword(p string) *Password { 10 | return &Password{ 11 | password: p, 12 | } 13 | } 14 | 15 | func (p *Password) Hash() (string, error) { 16 | bytes, err := bcrypt.GenerateFromPassword([]byte(p.password), 14) 17 | 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | return string(bytes), nil 23 | } 24 | 25 | func (p *Password) Compare(h string) bool { 26 | return bcrypt.CompareHashAndPassword([]byte(h), []byte(p.password)) == nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/utils/random.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | type Random struct { 9 | random *rand.Rand 10 | } 11 | 12 | func NewRandom() Random { 13 | source := rand.NewSource(time.Now().UnixNano()) 14 | return Random{ 15 | random: rand.New(source), 16 | } 17 | } 18 | 19 | func (r Random) String(n int) string { 20 | letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 21 | b := make([]rune, n) 22 | for i := range b { 23 | b[i] = letterRunes[r.random.Intn(len(letterRunes))] 24 | } 25 | return string(b) 26 | } 27 | 28 | func (r Random) Number(low int, hi int) int { 29 | return low + r.random.Intn(hi-low) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/utils/remove_from_slice.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func RemoveFromSlice[S []E, E comparable](slice S, elem E) S { 4 | for i := 0; i < len(slice); i++ { 5 | if slice[i] == elem { 6 | // Remove the element by slicing the slice to exclude it 7 | slice = append(slice[:i], slice[i+1:]...) 8 | // Decrement i to account for the removed element 9 | i-- 10 | } 11 | } 12 | return slice 13 | } 14 | -------------------------------------------------------------------------------- /pkg/utils/role/role.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import "errors" 4 | 5 | type Role string 6 | 7 | const ( 8 | Creator Role = "CREATOR" // The user who created the chat, no one can do anything with him 9 | Admin Role = "ADMIN" // He can do anything with anyone except the Creator 10 | EditGroupImage Role = "EDIT_GROUP_IMAGE" 11 | EditGroupName Role = "EDIT_GROUP_NAME" 12 | EditGroupTheme Role = "EDIT_GROUP_THEME" 13 | InviteUser Role = "INVITE_USER" 14 | KickUser Role = "KICK_USER" 15 | SendMessage Role = "SEND_MESSAGE" 16 | AttachFile Role = "ATTACH_FILE" 17 | DeleteMessage Role = "DELETE_MESSAGE" 18 | DeleteOtherMemberMessage Role = "DELETE_OTHER_MEMBER_MESSAGE" 19 | CreateIntegration Role = "CREATE_INTEGRATION" 20 | ) 21 | 22 | func New(s string) (Role, error) { 23 | switch Role(s) { 24 | case Creator: 25 | return Creator, nil 26 | case Admin: 27 | return Admin, nil 28 | case AttachFile: 29 | return AttachFile, nil 30 | case EditGroupImage: 31 | return EditGroupImage, nil 32 | case EditGroupName: 33 | return EditGroupName, nil 34 | case EditGroupTheme: 35 | return EditGroupTheme, nil 36 | case InviteUser: 37 | return InviteUser, nil 38 | case KickUser: 39 | return KickUser, nil 40 | case SendMessage: 41 | return SendMessage, nil 42 | case DeleteMessage: 43 | return DeleteMessage, nil 44 | case DeleteOtherMemberMessage: 45 | return DeleteOtherMemberMessage, nil 46 | case CreateIntegration: 47 | return CreateIntegration, nil 48 | } 49 | 50 | return "", errors.New("this role does not exist") 51 | } 52 | 53 | func All() *Roles { 54 | var roles Roles 55 | roles = append(roles, Creator, Admin, EditGroupImage, EditGroupName, EditGroupTheme, InviteUser, KickUser, SendMessage, AttachFile, DeleteMessage, DeleteOtherMemberMessage, CreateIntegration) 56 | return &roles 57 | } 58 | -------------------------------------------------------------------------------- /pkg/utils/role/roles.go: -------------------------------------------------------------------------------- 1 | package role 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Roles []Role 8 | 9 | func NewRoles(s []string) *Roles { 10 | var roles Roles 11 | 12 | for _, role := range s { 13 | role, err := New(role) 14 | if err != nil { 15 | continue 16 | } 17 | roles = append(roles, role) 18 | } 19 | 20 | return &roles 21 | } 22 | 23 | func (r *Roles) Can(rl Role) bool { 24 | for _, role := range *r { 25 | if role == rl { 26 | return true 27 | } 28 | } 29 | return false 30 | } 31 | 32 | func (r *Roles) String() string { 33 | b, err := json.Marshal(r) 34 | if err != nil { 35 | return "[]" 36 | } 37 | return string(b) 38 | } 39 | 40 | func (r *Roles) Grant(rl Role) { 41 | *r = append(*r, rl) 42 | } 43 | 44 | func (r *Roles) Revoke(rl Role) { 45 | for i, revoke := range *r { 46 | if revoke == rl { 47 | *r = append((*r)[:i], (*r)[i+1:]...) 48 | break 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/utils/validator.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/go-playground/validator/v10" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/gofiber/fiber/v2/log" 10 | "github.com/wizzldev/chat/pkg/utils/role" 11 | "reflect" 12 | "regexp" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type IError struct { 18 | Field string `json:"field,omitempty"` 19 | Tag string `json:"tag,omitempty"` 20 | Value string `json:"value,omitempty"` 21 | } 22 | 23 | var ( 24 | Validator = validator.New() 25 | IsRegistered = false 26 | ) 27 | 28 | func Validate[T any](c *fiber.Ctx) error { 29 | fmt.Println("validating") 30 | if !IsRegistered { 31 | RegisterCustomValidations() 32 | } 33 | var s T 34 | var errs []*IError 35 | 36 | err := json.Unmarshal(c.Body(), &s) 37 | if err != nil { 38 | return fmt.Errorf("failed to decode body: %w", err) 39 | } 40 | 41 | err = Validator.Struct(s) 42 | 43 | if err != nil { 44 | var validatorError validator.ValidationErrors 45 | if !errors.As(err, &validatorError) { 46 | return err 47 | } 48 | 49 | for _, fieldError := range validatorError { 50 | var el IError 51 | pattern := regexp.MustCompile(`([a-z0-9])([A-Z])`) 52 | p := pattern.ReplaceAllString(fieldError.Field(), "${1}_${2}") 53 | el.Field = strings.ToLower(p) 54 | el.Tag = fieldError.Tag() 55 | el.Value = fieldError.Param() 56 | errs = append(errs, &el) 57 | } 58 | 59 | return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ 60 | "type": "error:validator", 61 | "errors": errs, 62 | }) 63 | } 64 | 65 | c.Locals("requestValidation", &s) 66 | return c.Next() 67 | } 68 | 69 | func RegisterCustomValidations() { 70 | IsRegistered = true 71 | err := Validator.RegisterValidation("is_role", func(level validator.FieldLevel) bool { 72 | value := level.Field().String() 73 | _, err := role.New(strings.ToUpper(value)) 74 | return err == nil 75 | }) 76 | 77 | if err != nil { 78 | log.Fatal("Failed to register validator (is_role):", err) 79 | } 80 | 81 | err = Validator.RegisterValidation("invite_date", func(fl validator.FieldLevel) bool { 82 | date, ok := fl.Field().Interface().(time.Time) 83 | if !ok { 84 | return false 85 | } 86 | if date.IsZero() { 87 | return true 88 | } 89 | now := time.Now() 90 | return date.After(now.AddDate(0, 0, 1)) && date.Before(now.AddDate(0, 6, 0)) 91 | }) 92 | 93 | if err != nil { 94 | log.Fatal("Failed to register validator (invite_date):", err) 95 | } 96 | 97 | err = Validator.RegisterValidation("is_pointer", func(fl validator.FieldLevel) bool { 98 | return fl.Field().Kind() == reflect.Ptr 99 | }) 100 | 101 | if err != nil { 102 | log.Fatal("Failed to register validator (is_pointer):", err) 103 | } 104 | 105 | err = Validator.RegisterValidation("is_emoji", func(fl validator.FieldLevel) bool { 106 | fmt.Println("validating is emoji") 107 | s := fl.Field().String() 108 | return IsEmoji(s) 109 | }) 110 | 111 | if err != nil { 112 | log.Fatal("Failed to register validator (is_emoji):", err) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/ws/connection.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gofiber/contrib/websocket" 6 | "github.com/gofiber/fiber/v2/log" 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "io" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type Connection struct { 14 | *websocket.Conn 15 | Connected bool 16 | UserID uint 17 | serverID string 18 | mu *sync.RWMutex 19 | } 20 | 21 | func NewConnection(serverID string, ws *websocket.Conn, userId uint) *Connection { 22 | return &Connection{ 23 | Conn: ws, 24 | Connected: true, 25 | UserID: userId, 26 | serverID: serverID, 27 | mu: &sync.RWMutex{}, 28 | } 29 | } 30 | 31 | func (c *Connection) Disconnect(msg ...string) { 32 | if !c.Connected { 33 | return 34 | } 35 | 36 | closeMessage := "closed by client" 37 | if len(msg) > 0 { 38 | closeMessage = msg[0] 39 | } 40 | 41 | WebSocket.mu.Lock() 42 | _ = c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, closeMessage), time.Now().Add(time.Second)) 43 | c.Connected = false 44 | err := c.Conn.Close() 45 | if err != nil { 46 | fmt.Println("Error closing connection", err) 47 | } 48 | WebSocket.Remove(c) 49 | fmt.Printf("Disconnected from server %s: %s \n", c.serverID, c.IP()) 50 | WebSocket.mu.Unlock() 51 | } 52 | 53 | func (c *Connection) ReadLoop() { 54 | var ( 55 | mt int 56 | msg []byte 57 | err error 58 | ) 59 | for c.Connected { 60 | if mt, msg, err = c.ReadMessage(); err != nil { 61 | break 62 | } 63 | if mt != websocket.TextMessage { 64 | continue 65 | } 66 | 67 | if configs.Env.Debug { 68 | c.Send(MessageWrapper{ 69 | Message: &Message{ 70 | Event: "echo", 71 | Data: string(msg), 72 | HookID: "#", 73 | }, 74 | Resource: configs.DefaultWSResource, 75 | }) 76 | } 77 | 78 | err := MessageHandler(c, c.UserID, msg) 79 | 80 | if err != nil { 81 | if configs.Env.Debug { 82 | log.Warn("WS Read error:", err) 83 | } 84 | 85 | if err != io.EOF { 86 | c.Send(MessageWrapper{ 87 | Message: &Message{ 88 | Event: "error", 89 | Data: err.Error(), 90 | HookID: "#", 91 | }, 92 | Resource: "#", 93 | }) 94 | } 95 | 96 | continue 97 | } 98 | } 99 | c.Disconnect() 100 | } 101 | 102 | func (c *Connection) Send(m MessageWrapper) { 103 | if !c.Connected { 104 | return 105 | } 106 | c.mu.Lock() 107 | err := c.Conn.WriteJSON(m) 108 | c.mu.Unlock() 109 | if err != nil { 110 | c.Disconnect() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /pkg/ws/message.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/wizzldev/chat/pkg/configs" 7 | "github.com/wizzldev/chat/pkg/utils" 8 | ) 9 | 10 | type Map map[string]interface{} 11 | 12 | type Message struct { 13 | Event string `json:"event"` 14 | Data interface{} `json:"data"` 15 | HookID string `json:"hook_id"` 16 | } 17 | 18 | type MessageWrapper struct { 19 | Message *Message `json:"message"` 20 | Resource string `json:"resource"` 21 | } 22 | 23 | type ClientMessage struct { 24 | Content string `json:"content" validate:"required,min=1,max=500"` 25 | Type string `json:"type" validate:"required,max=55"` 26 | DataJSON string `json:"data_json" validate:"required,json,max=200"` 27 | HookID string `json:"hook_id"` 28 | } 29 | 30 | type ClientMessageWrapper struct { 31 | Message *ClientMessage `json:"message"` 32 | Resource string `json:"resource"` 33 | } 34 | 35 | func NewMessage(data []byte, conn *Connection) (*ClientMessageWrapper, error) { 36 | var c ClientMessageWrapper 37 | err := json.Unmarshal(data, &c) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to decode body: %w", err) 40 | } 41 | 42 | if err := utils.Validator.Struct(c.Message); err != nil { 43 | go conn.Send(MessageWrapper{ 44 | Message: &Message{ 45 | Event: "error", 46 | Data: err.Error(), 47 | }, 48 | Resource: configs.DefaultWSResource, 49 | }) 50 | return nil, err 51 | } 52 | return &c, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/ws/ws.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gofiber/contrib/websocket" 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "github.com/wizzldev/chat/pkg/logger" 9 | "github.com/wizzldev/chat/pkg/utils" 10 | ) 11 | 12 | var MessageHandler func(conn *Connection, userID uint, message []byte) error 13 | 14 | var WebSocket *Server 15 | 16 | type Server struct { 17 | Pool []*Connection 18 | mu sync.RWMutex 19 | } 20 | 21 | type BroadcastFunc func(*Connection) bool 22 | 23 | func Init() *Server { 24 | if WebSocket == nil { 25 | WebSocket = NewServer() 26 | } 27 | 28 | return WebSocket 29 | } 30 | 31 | func NewServer() *Server { 32 | return &Server{ 33 | Pool: make([]*Connection, 0, 100), 34 | } 35 | } 36 | 37 | func (s *Server) AddConnection(ws *websocket.Conn) { 38 | conn := NewConnection("", ws, ws.Locals(configs.LocalAuthUserID).(uint)) 39 | defer conn.Disconnect() 40 | 41 | conn.Send(MessageWrapper{ 42 | Message: &Message{ 43 | Event: "connection", 44 | Data: "established", 45 | HookID: "#", 46 | }, 47 | Resource: configs.DefaultWSResource, 48 | }) 49 | 50 | s.mu.Lock() 51 | s.Pool = append(s.Pool, conn) 52 | s.mu.Unlock() 53 | 54 | if configs.Env.Debug { 55 | logger.WSNewConnection("", conn.IP(), conn.UserID) 56 | } 57 | 58 | s.LogPoolSize() 59 | 60 | conn.ReadLoop() 61 | } 62 | 63 | func (s *Server) Broadcast(m MessageWrapper) { 64 | s.mu.RLock() // Read lock since we aren't modifying the pool 65 | defer s.mu.RUnlock() 66 | 67 | var wg sync.WaitGroup 68 | for _, conn := range s.Pool { 69 | if conn.Connected { 70 | wg.Add(1) 71 | go func(c *Connection) { 72 | defer wg.Done() 73 | c.Send(m) 74 | }(conn) 75 | } 76 | } 77 | wg.Wait() // Wait for all goroutines to finish sending 78 | } 79 | 80 | func (s *Server) BroadcastFunc(f BroadcastFunc, m MessageWrapper) { 81 | s.mu.RLock() // Read lock 82 | defer s.mu.RUnlock() 83 | 84 | var wg sync.WaitGroup 85 | for _, conn := range s.Pool { 86 | if conn.Connected && f(conn) { 87 | wg.Add(1) 88 | go func(c *Connection) { 89 | defer wg.Done() 90 | c.Send(m) 91 | }(conn) 92 | } 93 | } 94 | wg.Wait() // Wait for all goroutines to finish 95 | } 96 | 97 | func (s *Server) BroadcastToUsers(userIDs []uint, id string, m Message) []uint { 98 | userIDMap := make(map[uint]struct{}, len(userIDs)) 99 | for _, id := range userIDs { 100 | userIDMap[id] = struct{}{} 101 | } 102 | 103 | var sentTo []uint 104 | s.mu.RLock() // Read lock 105 | defer s.mu.RUnlock() 106 | 107 | var wg sync.WaitGroup 108 | mu := sync.Mutex{} // Mutex to safely append to sentTo 109 | for _, conn := range s.Pool { 110 | if _, exists := userIDMap[conn.UserID]; exists && conn.Connected { 111 | wg.Add(1) 112 | go func(c *Connection) { 113 | defer wg.Done() 114 | c.Send(MessageWrapper{ 115 | Message: &m, 116 | Resource: id, 117 | }) 118 | mu.Lock() 119 | sentTo = append(sentTo, c.UserID) 120 | mu.Unlock() 121 | }(conn) 122 | } 123 | } 124 | wg.Wait() 125 | return sentTo 126 | } 127 | 128 | func (s *Server) Remove(c *Connection) { 129 | s.mu.Lock() 130 | s.Pool = utils.RemoveFromSlice(s.Pool, c) 131 | s.mu.Unlock() 132 | 133 | s.LogPoolSize() 134 | } 135 | 136 | func (s *Server) GetUserIDs() []uint { 137 | s.mu.RLock() 138 | defer s.mu.RUnlock() 139 | 140 | userIDs := make([]uint, 0, len(s.Pool)) 141 | for _, conn := range s.Pool { 142 | userIDs = append(userIDs, conn.UserID) 143 | } 144 | return userIDs 145 | } 146 | 147 | func (s *Server) LogPoolSize() { 148 | userIDs := s.GetUserIDs() 149 | logger.WSPoolSize("", len(s.Pool), userIDs) 150 | } 151 | -------------------------------------------------------------------------------- /routes/api.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/wizzldev/chat/app/handlers" 8 | "github.com/wizzldev/chat/app/requests" 9 | "github.com/wizzldev/chat/pkg/middlewares" 10 | "github.com/wizzldev/chat/pkg/utils/role" 11 | ) 12 | 13 | func RegisterAPI(r fiber.Router) { 14 | r.Get("/", func(c *fiber.Ctx) error { 15 | return c.SendFile("./pkg/configs/build.json") 16 | }) 17 | 18 | { 19 | r.Post("/login", requests.Use[requests.Login](), middlewares.NewAuthLimiter(), handlers.Auth.Login).Name("auth.login") 20 | r.Get("/allow-ip/:token", handlers.Auth.AllowIP).Name("auth.allow-ip") 21 | r.Post("/register", requests.Use[requests.Register](), handlers.Auth.Register).Name("auth.register") 22 | r.Get("/logout", handlers.Auth.Logout).Name("auth.logout") 23 | r.Post("/request-new-password", requests.Use[requests.NewPassword](), handlers.Auth.RequestNewPassword).Name("auth.request-password") 24 | r.Get("/set-new-password/:token", handlers.Auth.IsPasswordResetExists).Name("auth.is-set-password-exists") 25 | r.Post("/set-new-password/:token", requests.Use[requests.SetNewPassword](), handlers.Auth.SetNewPassword).Name("auth.set-password") 26 | r.Get("/verify-email/:token", handlers.Auth.VerifyEmail).Name("auth.verify-email") 27 | r.Post("/request-new-email-verification", requests.Use[requests.Email](), handlers.Auth.RequestNewEmailVerification).Name("auth.request-email-verification") 28 | } 29 | 30 | auth := r.Group("/", middlewares.AnyAuth).Name("main.") 31 | { 32 | auth.Get("/me", handlers.Me.Hello).Name("me") 33 | auth.Put("/me", middlewares.NoBots, requests.Use[requests.UpdateMe](), middlewares.NewSimpleLimiter(3, 10*time.Minute, "Too many modifications, try again later"), handlers.Me.Update).Name("me") 34 | auth.Get("/me/ip-check", handlers.Me.SwitchIPCheck).Name("ip-switch") 35 | auth.Post("/me/profile-image", middlewares.NoBots, handlers.Me.UploadProfileImage).Name("profile-image") 36 | // wait a week before deletion 37 | // auth.Delete("/me", middlewares.NoBots, handlers.Me.Delete) 38 | } 39 | 40 | mobile := r.Group("/mobile", middlewares.NoBots).Name("mobile.") 41 | { 42 | mobile.Post("/register-push-notification", requests.Use[requests.PushToken](), handlers.Mobile.RegisterPushNotification).Name("register-push") 43 | } 44 | 45 | security := auth.Group("/security", middlewares.NoBots).Name("security.") 46 | { 47 | security.Get("/sessions", handlers.Security.Sessions).Name("sessions") 48 | security.Delete("/sessions", handlers.Security.DestroySessions).Name("sessions") 49 | security.Delete("/sessions/:id", handlers.Security.DestroySession).Name("sessions.single") 50 | security.Get("/ips", handlers.Security.IPs).Name("ips") 51 | security.Delete("/ips/:id", handlers.Security.DestroyIP).Name("ips.single") 52 | security.Use(HandleNotFoundError) 53 | } 54 | 55 | users := auth.Group("/users", middlewares.NoBots).Name("users.") 56 | { 57 | users.Post("/findByEmail", requests.Use[requests.Email](), handlers.Users.FindByEmail).Name("find:email") 58 | users.Use(HandleNotFoundError) 59 | } 60 | 61 | auth.Get("/themes", handlers.Theme.Paginate).Name("themes") 62 | auth.Get("/chat/contacts", handlers.Chat.Contacts).Name("contacts") 63 | auth.Get("/chat/user/:id", middlewares.GroupAccess("id"), handlers.Group.GetInfo).Name("chat.user") 64 | auth.Get("/chat/private/:id", handlers.Chat.PrivateMessage).Name("chat.user:private") 65 | auth.Post("/chat/search", requests.Use[requests.SearchContacts](), handlers.Chat.Search).Name("chat.search") 66 | 67 | auth.Post("/chat/group", requests.Use[requests.NewGroup](), handlers.Group.New).Name("chat.group") 68 | auth.Get("/chat/roles", handlers.Group.GetAllRoles).Name("chat.roles") 69 | 70 | chat := auth.Group("/chat/:id", middlewares.GroupAccess("id")).Name("chat.") 71 | { 72 | chat.Get("/", handlers.Chat.Find).Name("find") 73 | chat.Put("/", middlewares.NewRoleMiddleware(role.EditGroupName), requests.Use[requests.EditGroupName](), handlers.Group.EditName).Name("name") 74 | chat.Get("/paginate", handlers.Chat.Messages).Name("paginate") 75 | chat.Put("/invite", middlewares.NewRoleMiddleware(role.Creator), requests.Use[requests.CustomInvite](), middlewares.NewSimpleLimiter(3, 15*time.Minute, "too many requests"), handlers.Group.CustomInvite).Name("invite") 76 | chat.Put("/emoji", middlewares.NewRoleMiddleware(role.Admin), requests.Use[requests.Emoji](), handlers.Group.Emoji).Name("emoji") 77 | chat.Get("/leave", handlers.Group.Leave).Name("leave") 78 | chat.Delete("/", middlewares.NewRoleMiddleware(role.Creator), handlers.Group.Delete).Name("delete") 79 | chat.Put("/roles", middlewares.NewRoleMiddleware(role.Admin), requests.Use[requests.ModifyRoles](), handlers.Group.ModifyRoles).Name("roles") 80 | chat.Get("/message/:messageID", handlers.Chat.FindMessage) 81 | chat.Post("/new-invite", middlewares.NewRoleMiddleware(role.InviteUser), requests.Use[requests.NewInvite](), middlewares.NewSimpleLimiter(3, 10*time.Minute, "Try again later before creating another"), handlers.Invite.Create).Name("invite") 82 | 83 | chat.Post("/file", middlewares.NewRoleMiddleware(role.AttachFile), handlers.Chat.UploadFile).Name("upload-file") 84 | chat.Post("/group-image", middlewares.NewRoleMiddleware(role.EditGroupImage), handlers.Group.UploadGroupImage).Name("image") 85 | 86 | chat.Get("/users", handlers.Group.Users).Name("users") 87 | chat.Get("/user_count", handlers.Group.UserCount).Name("user-count") 88 | 89 | chat.Put("/theme/:themeID", middlewares.NewRoleMiddleware(role.EditGroupTheme), handlers.Group.SetTheme).Name("theme") 90 | chat.Delete("/theme", middlewares.NewRoleMiddleware(role.EditGroupTheme), handlers.Group.RemoveTheme).Name("theme") 91 | 92 | chat.Post("/nickname/:userID", middlewares.NewRoleMiddleware(role.Admin), requests.Use[requests.Nickname](), handlers.GroupUser.EditNickName).Name("nickname") 93 | chat.Delete("/nickname/:userID", middlewares.NewRoleMiddleware(role.Admin), handlers.GroupUser.RemoveNickName).Name("nickname") 94 | 95 | chat.Use(HandleNotFoundError) 96 | } 97 | 98 | auth.Get("/invite/:code", handlers.Invite.Describe).Name("invite") 99 | auth.Get("/invite/:code/use", handlers.Invite.Use).Name("invite:use") 100 | 101 | // bot := r.Group("/bots", middlewares.Auth) 102 | { 103 | // bot.Get("/") 104 | // bot.Post("/") 105 | // bot.Post("/image") 106 | // bot.Put("/") 107 | // bot.Delete("/") 108 | // bot.Use(HandleNotFoundError) 109 | } 110 | 111 | auth.Use(HandleNotFoundError) 112 | r.Use(HandleNotFoundError) 113 | } 114 | -------------------------------------------------------------------------------- /routes/app.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/gofiber/fiber/v2/middleware/logger" 8 | "github.com/gofiber/fiber/v2/middleware/recover" 9 | "github.com/wizzldev/chat/pkg/configs" 10 | "github.com/wizzldev/chat/pkg/middlewares" 11 | ) 12 | 13 | func NewApp() *fiber.App { 14 | app := fiber.New(fiber.Config{ 15 | ErrorHandler: ErrorHandler, 16 | Prefork: !configs.Env.Debug, 17 | ServerHeader: "Wizzl", 18 | AppName: "Wizzl v1.0.0", 19 | ProxyHeader: fiber.HeaderXForwardedFor, 20 | EnableIPValidation: true, 21 | }) 22 | 23 | if !configs.Env.Debug { 24 | app.Use(recover.New()) 25 | } else { 26 | app.Use(logger.New()) 27 | } 28 | 29 | app.Use(middlewares.CORS()) 30 | 31 | MustInitApplication() 32 | RegisterRouteGetter(app) 33 | RegisterStorage(app.Group("/storage").Name("storage.")) 34 | WS(app.Group("/ws")) 35 | RegisterBot(app.Group("/bot").Name("bot.")) 36 | RegisterDev(app.Group("/developers").Name("devs.")) 37 | RegisterAPI(app) 38 | app.Use(HandleNotFoundError) 39 | 40 | return app 41 | } 42 | 43 | func RegisterRouteGetter(r fiber.Router) { 44 | r.Get("/app/api-routes", func(c *fiber.Ctx) error { 45 | allRoute := c.App().GetRoutes() 46 | var routes []fiber.Route 47 | 48 | for _, route := range allRoute { 49 | if !strings.HasSuffix(route.Path, "/") && route.Name != "" { 50 | routes = append(routes, route) 51 | } 52 | } 53 | 54 | return c.JSON(routes) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /routes/bot.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/pkg/middlewares" 6 | ) 7 | 8 | func RegisterBot(r fiber.Router) { 9 | r.Get("/auth", middlewares.BotAuth, func(c *fiber.Ctx) error { 10 | return c.JSON(fiber.Map{ 11 | "success": true, 12 | }) 13 | }).Name("auth") 14 | } 15 | -------------------------------------------------------------------------------- /routes/dev.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/app/handlers" 6 | "github.com/wizzldev/chat/app/requests" 7 | "github.com/wizzldev/chat/pkg/middlewares" 8 | ) 9 | 10 | func RegisterDev(r fiber.Router) { 11 | auth := r.Group("/", middlewares.Auth) 12 | 13 | auth.Get("/applications", handlers.Developers.GetApplications).Name("apps") 14 | auth.Post("/applications", requests.Use[requests.NewBot](), handlers.Developers.CreateApplication).Name("apps") 15 | auth.Patch("/applications/:id", handlers.Developers.RegenerateApplicationToken).Name("apps") 16 | 17 | auth.Post("/invite", requests.Use[requests.ApplicationInvite](), handlers.Group.InviteApplication).Name("apps.invite") 18 | } 19 | -------------------------------------------------------------------------------- /routes/error.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "errors" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | func HandleNotFoundError(c *fiber.Ctx) error { 9 | return fiber.NewError(fiber.StatusNotFound, "This resource could not be found") 10 | } 11 | 12 | func ErrorHandler(c *fiber.Ctx, err error) error { 13 | code := fiber.StatusInternalServerError 14 | 15 | var e *fiber.Error 16 | if errors.As(err, &e) { 17 | code = e.Code 18 | } 19 | 20 | return c.Status(code).JSON(fiber.Map{ 21 | "error": err.Error(), 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /routes/init.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/wizzldev/chat/app/handlers" 5 | "github.com/wizzldev/chat/app/services" 6 | ) 7 | 8 | func MustInitApplication() { 9 | store, err := services.NewStorage() 10 | if err != nil { 11 | panic(err) 12 | } 13 | wsCache := services.NewWSCache() 14 | 15 | handlers.Chat.Init(store, wsCache) 16 | handlers.Files.Init(store) 17 | handlers.Group.Init(store, services.NewWSCache()) 18 | handlers.Me.Init(store) 19 | handlers.Invite.Init(wsCache) 20 | } 21 | -------------------------------------------------------------------------------- /routes/storage.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/wizzldev/chat/app/handlers" 6 | "github.com/wizzldev/chat/pkg/middlewares" 7 | ) 8 | 9 | func RegisterStorage(r fiber.Router) { 10 | file := r.Group("/files").Name("files.") 11 | file.Get("/:disc/:filename", middlewares.StorageFileToLocal(), middlewares.StorageFilePermission(), handlers.Files.Get).Name("file") 12 | file.Get("/:disc/:filename/info", middlewares.StorageFileToLocal(), middlewares.StorageFilePermission(), handlers.Files.GetInfo).Name("info") 13 | file.Use(HandleNotFoundError) 14 | 15 | avatar := r.Group("/avatars").Name("avatars.") 16 | avatar.Post("/upload", func(c *fiber.Ctx) error { 17 | fh, err := c.FormFile("image") 18 | if err != nil { 19 | return err 20 | } 21 | f, err := handlers.Files.StoreAvatar(fh) 22 | if err != nil { 23 | return err 24 | } 25 | return c.SendString(f.Discriminator) 26 | }).Name("upload") 27 | avatar.Get("/:disc-s:size.webp", middlewares.StorageFileToLocal(), handlers.Files.GetAvatar).Name("with-size") 28 | avatar.Get("/:disc.webp", middlewares.StorageFileToLocal(), handlers.Files.GetAvatar).Name("simple") 29 | avatar.Use(HandleNotFoundError) 30 | 31 | r.Use(HandleNotFoundError) 32 | } 33 | -------------------------------------------------------------------------------- /routes/ws.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gofiber/contrib/websocket" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/wizzldev/chat/app" 7 | "github.com/wizzldev/chat/app/handlers" 8 | "github.com/wizzldev/chat/pkg/middlewares" 9 | "github.com/wizzldev/chat/pkg/ws" 10 | ) 11 | 12 | func WS(r fiber.Router) { 13 | ws.MessageHandler = app.WSActionHandler 14 | ws.Init() 15 | 16 | r.Use(middlewares.WSAuth) 17 | r.Use(func(c *fiber.Ctx) error { 18 | if websocket.IsWebSocketUpgrade(c) { 19 | c.Locals("allowed", true) 20 | return c.Next() 21 | } 22 | return fiber.ErrUpgradeRequired 23 | }) 24 | r.Get("/", handlers.WS.Connect) 25 | r.Use(HandleNotFoundError) 26 | } 27 | -------------------------------------------------------------------------------- /storage/default.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzldev/backend/37b8d0ce93d28c4e532e49057d0cdf9e0932bbd5/storage/default.webp -------------------------------------------------------------------------------- /storage/group.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzldev/backend/37b8d0ce93d28c4e532e49057d0cdf9e0932bbd5/storage/group.webp -------------------------------------------------------------------------------- /storage/wizzl.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzldev/backend/37b8d0ce93d28c4e532e49057d0cdf9e0932bbd5/storage/wizzl.webp -------------------------------------------------------------------------------- /templates/ip.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 35 | Verify your email address 36 | 58 | 59 | 60 |
61 |
62 | 63 | 64 | 118 | 119 |
65 |
66 | 67 | Wizzl Logo 68 | 69 |
70 | 71 | 72 | 98 | 99 | 100 | 101 | 102 | 103 | 115 | 116 |
73 |

Hi @firstName,

74 |

75 | 76 | IP: @ip 77 | 78 |
79 |
80 | Someone is trying to access your account from an unknown ip address. 81 |
82 |
83 | If it wasn't you, you can safely ignore this email. 84 |

85 |
86 | 97 |
104 |

Wizzl - Fast, secure and reliable chat

105 |

106 | Github 107 | • 108 | Discord 109 | • 110 | Instagram 111 | • 112 | Support us 113 |

114 |
117 |
120 |
121 |
122 | 123 | -------------------------------------------------------------------------------- /templates/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 35 | Verify your email address 36 | 58 | 59 | 60 |
61 |
62 | 63 | 64 | 115 | 116 |
65 |
66 | 67 | Wizzl Logo 68 | 69 |
70 | 71 | 72 | 95 | 96 | 97 | 98 | 99 | 100 | 112 | 113 |
73 |

Hi @firstName,

74 |

75 | Thanks for your registration. 76 |
77 |
78 | Please verify your email address by clicking the button below. 79 |
80 | If you didn't register, you can safely disregard this message. 81 |

82 |
83 | 94 |
101 |

Wizzl - Fast, secure and reliable chat

102 |

103 | Github 104 | • 105 | Discord 106 | • 107 | Instagram 108 | • 109 | Support us 110 |

111 |
114 |
117 |
118 |
119 | 120 | -------------------------------------------------------------------------------- /templates/reset-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 35 | Reset your password 36 | 58 | 59 | 60 |
61 |
62 | 63 | 64 | 112 | 113 |
65 |
66 | 67 | Wizzl Logo 68 | 69 |
70 | 71 | 72 | 92 | 93 | 94 | 95 | 96 | 97 | 109 | 110 |
73 |

Hi @firstName,

74 |

75 | Seems like you've forgotten your password. No worries! Click the button below to reset it: 76 |
77 | If you didn't register, you can safely disregard this message. 78 |

79 |
80 | 91 |
98 |

Wizzl - Fast, secure and reliable chat

99 |

100 | Github 101 | • 102 | Discord 103 | • 104 | Instagram 105 | • 106 | Support us 107 |

108 |
111 |
114 |
115 |
116 | 117 | -------------------------------------------------------------------------------- /tests/app.go: -------------------------------------------------------------------------------- 1 | package tests //nolint:typecheck 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/gofiber/fiber/v2/log" 6 | "github.com/wizzldev/chat/database" 7 | "github.com/wizzldev/chat/pkg/configs" 8 | "path/filepath" 9 | ) 10 | 11 | func NewApp(envPath string) *fiber.App { 12 | err := configs.LoadEnv(filepath.Join(envPath, ".env.test")) 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | return fiber.New(fiber.Config{ 18 | Prefork: !configs.Env.Debug, 19 | ServerHeader: "Wizzl", 20 | AppName: "Wizzl v1.0.0", 21 | ProxyHeader: fiber.HeaderXForwardedFor, 22 | EnableIPValidation: true, 23 | }) 24 | } 25 | 26 | func CleanUp() error { 27 | return database.CleanUpTestDB() 28 | } 29 | --------------------------------------------------------------------------------