chat.go (13091B)
1 package handlers 2 3 import ( 4 "dkforest/pkg/captcha" 5 "dkforest/pkg/config" 6 "dkforest/pkg/database" 7 dutils "dkforest/pkg/database/utils" 8 "dkforest/pkg/hashset" 9 "dkforest/pkg/managers" 10 "dkforest/pkg/utils" 11 hutils "dkforest/pkg/web/handlers/utils" 12 "github.com/PuerkitoBio/goquery" 13 "github.com/asaskevich/govalidator" 14 "github.com/labstack/echo" 15 "github.com/sirupsen/logrus" 16 "gorm.io/gorm" 17 "net/http" 18 "strconv" 19 "strings" 20 "time" 21 ) 22 23 func RedRoomHandler(c echo.Context) error { 24 return chatHandler(c, true, false) 25 } 26 27 func ChatHandler(c echo.Context) error { 28 return chatHandler(c, false, false) 29 } 30 31 func ChatStreamHandler(c echo.Context) error { 32 return chatHandler(c, false, true) 33 } 34 35 func chatHandler(c echo.Context, redRoom, stream bool) error { 36 const chatPasswordTmplName = "standalone.chat-password" 37 38 // WARNING: in this handler, "authUser" can be null. 39 authUser := c.Get("authUser").(*database.User) 40 41 db := c.Get("database").(*database.DkfDB) 42 43 if !stream && authUser != nil { 44 stream = authUser.UseStream 45 } 46 47 var data chatData 48 data.PowEnabled = config.PowEnabled.Load() 49 data.RedRoom = redRoom 50 preventRefresh := utils.DoParseBool(c.QueryParam("r")) 51 52 v := c.QueryParams() 53 if preventRefresh { 54 v.Set("r", "1") 55 } 56 if _, found := c.QueryParams()["ml"]; found { 57 v.Set("ml", "1") 58 data.Multiline = true 59 } 60 data.ChatQueryParams = "?" + v.Encode() 61 62 if authUser == nil { 63 if config.SignupEnabled.IsFalse() { 64 return c.Render(http.StatusOK, "flash", FlashResponse{Message: "New signup are temporarily disabled", Redirect: "/", Type: "alert-danger"}) 65 } 66 67 data.CaptchaID, data.CaptchaImg = captcha.New() 68 } 69 70 room, err := db.GetChatRoomByName(getRoomName(c)) 71 if err != nil { 72 return c.Redirect(http.StatusFound, "/") 73 } 74 data.Room = room 75 76 if authUser != nil { 77 managers.ActiveUsers.UpdateUserInRoom(room, managers.NewUserInfo(authUser)) 78 79 // We display tutorial on official or public rooms 80 data.DisplayTutorial = (room.IsOfficialRoom() || (room.IsListed && !room.IsProtected())) && !authUser.TutorialCompleted() 81 82 if data.DisplayTutorial { 83 data.TutoSecs = getTutorialStepDuration() 84 data.TutoFrames = generateCssFrames(data.TutoSecs, nil, true) 85 if c.Request().Method == http.MethodGet { 86 authUser.SetChatTutorialTime(db, time.Now()) 87 } 88 } 89 } 90 91 if c.Request().Method == http.MethodPost { 92 return handlePost(db, c, data, authUser) 93 } 94 95 // If you don't have access to the room (room is protected and user is nil or no cookie with the password) 96 // We display the page to enter room password. 97 if hasAccess, _ := room.HasAccess(c); !hasAccess { 98 if !room.IsProtected() && room.Mode == database.UserWhitelistRoomMode { 99 return c.Render(http.StatusOK, "standalone.chat-whitelist", data) 100 } 101 return c.Render(http.StatusOK, chatPasswordTmplName, data) 102 } 103 104 data.IsSubscribed = db.IsUserSubscribedToRoom(authUser.ID, room.ID) 105 data.IsOfficialRoom = room.IsOfficialRoom() 106 data.IsStream = stream 107 return c.Render(http.StatusOK, "chat", data) 108 } 109 110 func getRoomName(c echo.Context) string { 111 roomName := c.Param("roomName") 112 if roomName == "" { 113 roomName = "general" 114 } 115 return roomName 116 } 117 118 func handlePost(db *database.DkfDB, c echo.Context, data chatData, authUser *database.User) error { 119 formName := c.Request().PostFormValue("formName") 120 switch formName { 121 case "logout": 122 return handleLogoutPost(c, data.Room) 123 case "tutorialP1", "tutorialP2", "tutorialP3": 124 return handleTutorialPost(db, c, data, authUser) 125 case "chat-password": 126 return handleChatPasswordPost(db, c, data, authUser) 127 } 128 return hutils.RedirectReferer(c) 129 } 130 131 // Logout of a protected room (delete room cookies) 132 func handleLogoutPost(c echo.Context, room database.ChatRoom) error { 133 hutils.DeleteRoomCookie(c, int64(room.ID)) 134 return c.Redirect(http.StatusFound, "/chat") 135 } 136 137 func handleTutorialPost(db *database.DkfDB, c echo.Context, data chatData, authUser *database.User) error { 138 if authUser.ChatTutorial < 3 && time.Since(authUser.ChatTutorialTime) >= time.Duration(data.TutoSecs)*time.Second { 139 authUser.IncrChatTutorial(db) 140 } 141 return hutils.RedirectReferer(c) 142 } 143 144 // Handle POST requests for chat-password, when someone tries to authenticate in a protected room providing a password. 145 func handleChatPasswordPost(db *database.DkfDB, c echo.Context, data chatData, authUser *database.User) error { 146 const chatPasswordTmplName = "standalone.chat-password" 147 data.RoomPassword = c.Request().PostFormValue("password") 148 149 // If no user set, we verify the captcha and username for the guest account 150 if authUser == nil { 151 data.GuestUsername = c.Request().PostFormValue("guest_username") 152 data.Pow = c.Request().PostFormValue("pow") 153 captchaID := c.Request().PostFormValue("captcha_id") 154 captchaInput := c.Request().PostFormValue("captcha") 155 if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { 156 data.ErrCaptcha = err.Error() 157 return c.Render(http.StatusOK, chatPasswordTmplName, data) 158 } 159 160 if err := db.CanUseUsername(database.Username(data.GuestUsername), false); err != nil { 161 data.ErrGuestUsername = err.Error() 162 return c.Render(http.StatusOK, chatPasswordTmplName, data) 163 } 164 165 // verify POW 166 if config.PowEnabled.IsTrue() { 167 if !hutils.VerifyPow(data.GuestUsername, data.Pow, config.PowDifficulty) { 168 data.ErrPow = "invalid proof of work" 169 return c.Render(http.StatusOK, chatPasswordTmplName, data) 170 } 171 } 172 } 173 174 // Verify room password is correct 175 key := database.GetRoomDecryptionKey(data.RoomPassword) 176 hashedPassword := database.GetRoomPasswordHash(data.RoomPassword) 177 if !data.Room.VerifyPasswordHash(hashedPassword) { 178 data.Error = "Invalid room password" 179 return c.Render(http.StatusOK, chatPasswordTmplName, data) 180 } 181 182 // If no user set, create the guest account + session 183 // TODO: maybe add "_guest" suffix to guest accounts? 184 if authUser == nil { 185 password := utils.GenerateToken32() 186 newUser, errs := db.CreateGuestUser(data.GuestUsername, password) 187 if errs.HasError() { 188 data.ErrGuestUsername = errs.Username 189 return c.Render(http.StatusOK, chatPasswordTmplName, data) 190 } 191 192 session := db.DoCreateSession(newUser.ID, c.Request().UserAgent(), time.Hour*24) 193 c.SetCookie(createSessionCookie(session.Token, time.Hour*24)) 194 } 195 196 hutils.CreateRoomCookie(c, int64(data.Room.ID), hashedPassword, key) 197 return c.Redirect(http.StatusFound, "/chat/"+data.Room.Name) 198 } 199 200 func ChatArchiveHandler(c echo.Context) error { 201 authUser := c.Get("authUser").(*database.User) 202 db := c.Get("database").(*database.DkfDB) 203 var data chatArchiveData 204 data.DateFormat = authUser.GetDateFormat() 205 roomName := c.Param("roomName") 206 207 room, roomKey, err := dutils.GetRoomAndKey(db, c, roomName) 208 if err != nil { 209 return c.Redirect(http.StatusFound, "/chat") 210 } 211 212 data.UUID = c.QueryParam("uuid") 213 data.Room = room 214 215 if data.UUID != "" { 216 msg, err := db.GetRoomChatMessageByUUID(room.ID, data.UUID) 217 if err != nil { 218 return c.Redirect(http.StatusFound, "/") 219 } 220 nbMsg := 150 221 args := []any{room.ID, authUser.ID, authUser.ID} 222 whereClause := `room_id = ? AND group_id IS NULL AND (to_user_id is null OR to_user_id = ? OR user_id = ?)` 223 if !authUser.DisplayIgnored { 224 args = append(args, authUser.ID) 225 whereClause += ` AND user_id NOT IN (SELECT ignored_user_id FROM ignored_users WHERE user_id = ?)` 226 } 227 raw := ` 228 SELECT * FROM ( 229 SELECT * 230 FROM chat_messages 231 WHERE ` + whereClause + ` 232 AND id >= ? 233 ORDER BY id ASC 234 LIMIT ? 235 ) 236 UNION 237 SELECT * FROM ( 238 SELECT * 239 FROM chat_messages 240 WHERE ` + whereClause + ` 241 AND id < ? 242 ORDER BY id DESC 243 LIMIT ? 244 ) ORDER BY id DESC` 245 args = append(args, msg.ID, nbMsg) 246 args = append(args, args...) 247 db.DB().Raw(raw, args...).Scan(&data.Messages) 248 249 // Manually do Preload("Room") 250 for _, m := range data.Messages { 251 m.Room = data.Room 252 } 253 254 //--- < Manually do a Preload("User") Preload("ToUser") > --- 255 usersIDs := hashset.New[database.UserID]() 256 for _, m := range data.Messages { 257 usersIDs.Insert(m.UserID) 258 if m.ToUserID != nil { 259 usersIDs.Insert(*m.ToUserID) 260 } 261 } 262 users, _ := db.GetUsersByID(usersIDs.ToArray()) 263 usersMap := make(map[database.UserID]database.User) 264 for _, u := range users { 265 usersMap[u.ID] = u 266 } 267 for i, m := range data.Messages { 268 if u, ok := usersMap[m.UserID]; ok { 269 data.Messages[i].User = u 270 } 271 if m.ToUserID != nil { 272 if u, ok := usersMap[*m.ToUserID]; ok { 273 data.Messages[i].ToUser = &u 274 } 275 } 276 } 277 //--- </ Manually do a Preload("User") Preload("ToUser") > --- 278 279 } else { 280 if err := db.DB().Table("chat_messages"). 281 Where("room_id = ? AND group_id IS NULL AND (to_user_id is null OR to_user_id = ? OR user_id = ?)", room.ID, authUser.ID, authUser.ID). 282 Scopes(func(query *gorm.DB) *gorm.DB { 283 if !authUser.DisplayIgnored { 284 query = query.Where(`user_id NOT IN (SELECT ignored_user_id FROM ignored_users WHERE user_id = ?)`, authUser.ID) 285 } 286 data.CurrentPage, data.MaxPage, data.MessagesCount, query = NewPaginator().SetResultPerPage(300).Paginate(c, query) 287 return query 288 }). 289 Order("id DESC"). 290 Preload("Room"). 291 Preload("User"). 292 Preload("ToUser"). 293 Find(&data.Messages).Error; err != nil { 294 logrus.Error(err) 295 } 296 } 297 298 if roomKey != "" { 299 if err := data.Messages.DecryptAll(roomKey); err != nil { 300 return c.NoContent(http.StatusInternalServerError) 301 } 302 } 303 304 return c.Render(http.StatusOK, "chat-archive", data) 305 } 306 307 func ChatDeleteHandler(c echo.Context) error { 308 authUser := c.Get("authUser").(*database.User) 309 db := c.Get("database").(*database.DkfDB) 310 var data chatDeleteData 311 roomName := c.Param("roomName") 312 room, err := db.GetChatRoomByName(roomName) 313 if err != nil { 314 return c.Redirect(http.StatusFound, "/") 315 } 316 if !room.IsRoomOwner(authUser.ID) { 317 return c.Redirect(http.StatusFound, "/") 318 } 319 data.Room = room 320 321 if c.Request().Method == http.MethodPost { 322 if room.IsProtected() { 323 hutils.DeleteRoomCookie(c, int64(room.ID)) 324 } 325 db.DeleteChatRoomByID(room.ID) 326 return c.Redirect(http.StatusFound, "/chat") 327 } 328 329 return c.Render(http.StatusOK, "chat-delete", data) 330 } 331 332 func RoomChatSettingsHandler(c echo.Context) error { 333 authUser := c.Get("authUser").(*database.User) 334 db := c.Get("database").(*database.DkfDB) 335 var data roomChatSettingsData 336 roomName := c.Param("roomName") 337 room, err := db.GetChatRoomByName(roomName) 338 if err != nil { 339 return c.Redirect(http.StatusFound, "/") 340 } 341 if !room.IsRoomOwner(authUser.ID) { 342 return c.Redirect(http.StatusFound, "/") 343 } 344 data.Room = room 345 346 if c.Request().Method == http.MethodPost { 347 return c.Redirect(http.StatusFound, "/chat") 348 } 349 350 return c.Render(http.StatusOK, "chat-room-settings", data) 351 } 352 353 func ChatCreateRoomHandler(c echo.Context) error { 354 authUser := c.Get("authUser").(*database.User) 355 db := c.Get("database").(*database.DkfDB) 356 var data chatCreateRoomData 357 data.CaptchaID, data.CaptchaImg = captcha.New() 358 data.IsEphemeral = true 359 if c.Request().Method == http.MethodPost { 360 data.RoomName = c.Request().PostFormValue("room_name") 361 data.Password = c.Request().PostFormValue("password") 362 data.IsListed = utils.DoParseBool(c.Request().PostFormValue("is_listed")) 363 data.IsEphemeral = utils.DoParseBool(c.Request().PostFormValue("is_ephemeral")) 364 if !govalidator.Matches(data.RoomName, "^[a-zA-Z0-9_]{3,50}$") { 365 data.ErrorRoomName = "invalid room name" 366 return c.Render(http.StatusOK, "chat-create-room", data) 367 } 368 captchaID := c.Request().PostFormValue("captcha_id") 369 captchaInput := c.Request().PostFormValue("captcha") 370 if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { 371 data.ErrCaptcha = err.Error() 372 return c.Render(http.StatusOK, "chat-create-room", data) 373 } 374 passwordHash := "" 375 if data.Password != "" { 376 passwordHash = database.GetRoomPasswordHash(data.Password) 377 } 378 if _, err := db.CreateRoom(data.RoomName, passwordHash, authUser.ID, data.IsListed); err != nil { 379 data.Error = err.Error() 380 return c.Render(http.StatusOK, "chat-create-room", data) 381 } 382 return c.Redirect(http.StatusFound, "/chat/"+data.RoomName) 383 } 384 return c.Render(http.StatusOK, "chat-create-room", data) 385 } 386 387 func ChatCodeHandler(c echo.Context) error { 388 messageUUID := c.Param("messageUUID") 389 idx, err := strconv.Atoi(c.Param("idx")) 390 if err != nil { 391 return c.Redirect(http.StatusFound, "/") 392 } 393 394 authUser := c.Get("authUser").(*database.User) 395 db := c.Get("database").(*database.DkfDB) 396 msg, err := db.GetChatMessageByUUID(messageUUID) 397 if err != nil { 398 return c.Redirect(http.StatusFound, "/") 399 } 400 401 if !dutils.VerifyMsgAuth(db, &msg, authUser.ID, authUser.IsModerator()) { 402 return c.Redirect(http.StatusFound, "/") 403 } 404 405 doc, err := goquery.NewDocumentFromReader(strings.NewReader(msg.Message)) 406 if err != nil { 407 return c.Redirect(http.StatusFound, "/") 408 } 409 n := doc.Find("pre").Eq(idx) 410 if n == nil { 411 return c.Redirect(http.StatusFound, "/") 412 } 413 414 var data chatCodepData 415 data.Code, err = n.Html() 416 if err != nil { 417 return c.Redirect(http.StatusFound, "/") 418 } 419 return c.Render(http.StatusOK, "chat-code", data) 420 } 421 422 func ChatHelpHandler(c echo.Context) error { 423 var data chatHelpData 424 return c.Render(http.StatusOK, "chat-help", data) 425 }