dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

chat.go (15961B)


      1 package v1
      2 
      3 import (
      4 	"dkforest/pkg/config"
      5 	"dkforest/pkg/database"
      6 	dutils "dkforest/pkg/database/utils"
      7 	"dkforest/pkg/hashset"
      8 	"dkforest/pkg/managers"
      9 	"dkforest/pkg/pubsub"
     10 	"dkforest/pkg/utils"
     11 	"dkforest/pkg/web/handlers/interceptors/command"
     12 	"dkforest/pkg/web/handlers/poker"
     13 	"dkforest/pkg/web/handlers/streamModals"
     14 	hutils "dkforest/pkg/web/handlers/utils"
     15 	"dkforest/pkg/web/handlers/utils/stream"
     16 	"errors"
     17 	"fmt"
     18 	"github.com/labstack/echo"
     19 	"net/http"
     20 	"strings"
     21 	"time"
     22 )
     23 
     24 func manualPreload(db *database.DkfDB, msg *database.ChatMessage, room database.ChatRoom) {
     25 	if msg.GroupID != nil {
     26 		if msg.Group == nil {
     27 			group, _ := db.GetRoomGroupByID(msg.RoomID, *msg.GroupID)
     28 			msg.Group = &group
     29 		}
     30 	}
     31 	if msg.ToUserID != nil {
     32 		if msg.ToUser == nil {
     33 			toUser, _ := db.GetUserByID(*msg.ToUserID)
     34 			msg.ToUser = &toUser
     35 		}
     36 	}
     37 	if msg.User.ID == 0 {
     38 		msg.User, _ = db.GetUserByID(msg.UserID)
     39 	}
     40 	msg.Room = room
     41 }
     42 
     43 // Return true if the message passes all the user's filter.
     44 // false if the message does not and should be discarded.
     45 func applyUserFilters(db *database.DkfDB, authUser database.IUserRenderMessage, msg *database.ChatMessage,
     46 	pmUserID *database.UserID, pmOnlyQuery database.PmDisplayMode, displayHellbanned, mentionsOnlyQuery bool) bool {
     47 	if pmUserID != nil {
     48 		if msg.ToUserID == nil {
     49 			return false
     50 		}
     51 		if *msg.ToUserID == *pmUserID || msg.UserID == *pmUserID {
     52 			return true
     53 		}
     54 	}
     55 	if (pmOnlyQuery == database.PmOnly && msg.ToUser == nil) ||
     56 		(pmOnlyQuery == database.PmNone && msg.ToUser != nil) ||
     57 		!authUser.GetDisplayModerators() && msg.Moderators ||
     58 		!displayHellbanned && msg.IsHellbanned {
     59 		return false
     60 	}
     61 
     62 	if !authUser.GetDisplayIgnored() {
     63 		ignoredUsersIDs, _ := db.GetIgnoredUsersIDs(authUser.GetID())
     64 		if utils.InArr(msg.UserID, ignoredUsersIDs) {
     65 			return false
     66 		}
     67 	}
     68 
     69 	if mentionsOnlyQuery && !strings.Contains(msg.Message, authUser.GetUsername().AtStr()) {
     70 		return false
     71 	}
     72 	return true
     73 }
     74 
     75 func soundNotifications(msg *database.ChatMessage, authUser database.IUserRenderMessage, renderedMsg *string) (out string) {
     76 	var newMessageSound, taggedSound, pmSound bool
     77 	if msg.User.ID != authUser.GetID() && !msg.SkipNotify {
     78 		newMessageSound = true
     79 		if strings.Contains(*renderedMsg, authUser.GetUsername().AtStr()) {
     80 			taggedSound = true
     81 		}
     82 		if msg.IsPmRecipient(authUser.GetID()) {
     83 			pmSound = true
     84 		}
     85 	}
     86 	if (authUser.GetNotifyTagged() && taggedSound) || (authUser.GetNotifyPmmed() && pmSound) {
     87 		out = `<audio src="/public/mp3/sound5.mp3" autoplay></audio>`
     88 	} else if authUser.GetNotifyNewMessage() && newMessageSound {
     89 		out = `<audio src="/public/mp3/sound6.mp3" autoplay></audio>`
     90 	}
     91 	return
     92 }
     93 
     94 type Alternator struct {
     95 	state          bool
     96 	fmt, animation string
     97 }
     98 
     99 func newAlternator(fmt, animation string) *Alternator {
    100 	return &Alternator{fmt: fmt, animation: animation}
    101 }
    102 
    103 func (a *Alternator) alternate() string {
    104 	a.state = !a.state
    105 	return fmt.Sprintf(a.fmt, a.animation+utils.Ternary(a.state, "1", "2"))
    106 }
    107 
    108 func ChatStreamMessagesHandler(c echo.Context) error {
    109 	db := c.Get("database").(*database.DkfDB)
    110 	authUser := c.Get("authUser").(*database.User)
    111 	csrf, _ := c.Get("csrf").(string)
    112 
    113 	queryParams := c.QueryParams()
    114 	_, mlFound := queryParams["ml"]
    115 	_, hrmFound := queryParams["hrm"]
    116 	_, hideTsFound := queryParams["hide_ts"]
    117 
    118 	roomName := c.Param("roomName")
    119 	room, roomKey, err := dutils.GetRoomAndKey(db, c, roomName)
    120 	if err != nil {
    121 		return c.Redirect(http.StatusFound, "/")
    122 	}
    123 
    124 	streamItem, err := stream.SetStreaming(c, authUser.ID, "")
    125 	if err != nil {
    126 		return nil
    127 	}
    128 	defer streamItem.Cleanup()
    129 
    130 	// Keep track of how many bytes we sent on the http request, so we can auto-refresh when passing a threshold
    131 	bytesSent := 0
    132 	send := func(s string) {
    133 		n, _ := c.Response().Write([]byte(s))
    134 		bytesSent += n
    135 	}
    136 
    137 	data := ChatMessagesData{}
    138 	data.TopBarQueryParams = utils.TernaryOrZero(mlFound, "&ml=1")
    139 	data.HideRightColumn = authUser.HideRightColumn || hrmFound
    140 	data.HideTimestamps = authUser.GetDateFormat() == "" || hideTsFound
    141 
    142 	// Register modals and send the css for them
    143 	modalsManager := streamModals.NewModalsManager()
    144 	modalsManager.Register(streamModals.NewCodeModal(authUser.ID, room))
    145 	if authUser.IsAdmin {
    146 		modalsManager.Register(streamModals.NewPurgeModal(authUser.ID, room))
    147 	}
    148 	send(modalsManager.Css())
    149 
    150 	data.ReadMarker, _ = db.GetUserReadMarker(authUser.ID, room.ID)
    151 	data.ChatMenuData.RoomName = room.Name
    152 	data.ManualRefreshTimeout = 0
    153 	send(GenerateStyle(authUser, data))
    154 	if authUser.DisplayAliveIndicator {
    155 		send(`<div id="i"></div>`) // http alive indicator; green/red dot
    156 	}
    157 	send(fmt.Sprintf(`<div style="display:flex;flex-direction:column-reverse;" id="msgs">`))
    158 
    159 	// Get initial messages for the user
    160 	pmOnlyQuery := dutils.DoParsePmDisplayMode(c.QueryParam("pmonly"))
    161 	mentionsOnlyQuery := utils.DoParseBool(c.QueryParam("mentionsOnly"))
    162 	pmUserID := dutils.GetUserIDFromUsername(db, c.QueryParam(command.RedirectPmUsernameQP))
    163 	displayHellbanned := authUser.DisplayHellbanned || authUser.IsHellbanned
    164 	displayIgnoredMessages := utils.False()
    165 	msgs, err := db.GetChatMessages(room.ID, roomKey, authUser.Username, authUser.ID, pmUserID, pmOnlyQuery, mentionsOnlyQuery,
    166 		displayHellbanned, authUser.DisplayIgnored, authUser.DisplayModerators, displayIgnoredMessages, 150, 0)
    167 	if err != nil {
    168 		return c.Redirect(http.StatusFound, "/")
    169 	}
    170 
    171 	// Render the messages as html
    172 	data.Messages = msgs
    173 	data.NbButtons = authUser.CountUIButtons()
    174 	nullUsername := config.NullUsername
    175 	hasNoMsgs := len(data.Messages) == 0
    176 	send("<div>" + RenderMessages(authUser, data, csrf, nullUsername, nil, false) + "</div>")
    177 	c.Response().Flush()
    178 
    179 	// Create a subscriber and topics to listen to
    180 	selfRefreshLoadingIconTopic := "refresh_loading_icon_" + string(authUser.Username)
    181 	selfRefreshTopic := "refresh_" + string(authUser.Username)
    182 	selfWizzTopic := "wizz_" + string(authUser.Username)
    183 	readMarkerTopic := "readmarker_" + authUser.ID.String()
    184 	authorizedTopics := []string{
    185 		database.RefreshTopic,
    186 		selfRefreshTopic,
    187 		selfRefreshLoadingIconTopic,
    188 		selfWizzTopic,
    189 		readMarkerTopic,
    190 		"room_" + room.ID.String()}
    191 	authorizedTopics = append(authorizedTopics, modalsManager.Topics()...)
    192 	sub := database.MsgPubSub.Subscribe(authorizedTopics)
    193 	defer sub.Close()
    194 
    195 	// Keep track of messages that are after the read-marker (unread).
    196 	// When we receive a "delete msg", and this map is empty, we should hide the read-marker
    197 	// as it means the read marker is now at the very top.
    198 	msgsMap := hashset.New[int64]()
    199 	for _, msg := range msgs {
    200 		if msg.CreatedAt.After(data.ReadMarker.ReadAt) {
    201 			msgsMap.Set(msg.ID)
    202 		}
    203 	}
    204 
    205 	// If the read-marker is at the very top, it will be hidden and need to be displayed when we receive a new message.
    206 	// If it is not at the top, it will already be visible and does not need to be displayed again.
    207 	var displayReadMarker bool
    208 	if len(msgs) > 0 {
    209 		fstMsgTsRound := msgs[0].CreatedAt.Round(time.Second)
    210 		readMarkerTsRound := data.ReadMarker.ReadAt.Round(time.Second)
    211 		displayReadMarker = !fstMsgTsRound.After(readMarkerTsRound)
    212 	}
    213 
    214 	// Keep track of current read-marker revision
    215 	readMarkerRev := 0
    216 	// Function to hide current rev of read marker and insert an invisible one at the top.
    217 	updateReadMarker := func() {
    218 		if authUser.ChatReadMarkerEnabled {
    219 			send(fmt.Sprintf(`<style>.read-marker-%d{display:none !important;}</style>`, readMarkerRev))
    220 			send(fmt.Sprintf(`<div class="read-marker read-marker-%d" style="display:none;"></div>`, readMarkerRev+1))
    221 		}
    222 		readMarkerRev++
    223 		displayReadMarker = true
    224 	}
    225 	// Function to show the invisible read-marker which used to be at the top.
    226 	showReadMarker := func() {
    227 		if displayReadMarker {
    228 			if authUser.ChatReadMarkerEnabled {
    229 				send(fmt.Sprintf(`<style>.read-marker-%d{display:block !important;}</style>`, readMarkerRev))
    230 			}
    231 			displayReadMarker = false
    232 		}
    233 	}
    234 
    235 	// Toggle between true/false every 5sec. This bool keep track of which class to send for our "online indicator"
    236 	// We need to change the css class in order for the css to never actually complete the animation and stay "green".
    237 	indicatorAlt := newAlternator(`<style>#i{animation: %s 30s forwards}</style>`, "i")
    238 	wizzAlt := newAlternator(`<style>#msgs{animation: %s 0.25s linear 7;}</style>`, "horizontal-shaking")
    239 
    240 Loop:
    241 	for {
    242 		select {
    243 		case <-streamItem.Quit:
    244 			break Loop
    245 		default:
    246 		}
    247 
    248 		// Refresh the page to prevent having it growing infinitely bigger
    249 		if bytesSent > 10<<20 { // 10 MB
    250 			send(hutils.MetaRefreshNow())
    251 			return nil
    252 		}
    253 
    254 		authUser1, err := db.GetUserRenderMessageByID(authUser.ID)
    255 		if err != nil {
    256 			break Loop
    257 		}
    258 
    259 		managers.ActiveUsers.UpdateUserInRoom(room, managers.NewUserInfo(authUser1))
    260 
    261 		// Update read record
    262 		db.UpdateChatReadRecord(authUser1.GetID(), room.ID)
    263 
    264 		// Toggle the "http alive indicator" class to keep the dot green
    265 		if authUser1.GetDisplayAliveIndicator() {
    266 			send(indicatorAlt.alternate())
    267 		}
    268 
    269 		topic, msgTyp, err := sub.ReceiveTimeout2(5*time.Second, streamItem.Quit)
    270 		if err != nil {
    271 			if errors.Is(err, pubsub.ErrCancelled) {
    272 				break Loop
    273 			}
    274 			c.Response().Flush()
    275 			continue
    276 		}
    277 
    278 		// We receive this event when the "update read-marker" button is clicked.
    279 		// This means the user is saying that all messages are read, and read-marker should be at the very top.
    280 		if topic == readMarkerTopic {
    281 			msgsMap.Clear() // read-marker at the top, so no unread message
    282 			updateReadMarker()
    283 			c.Response().Flush()
    284 			continue
    285 		}
    286 
    287 		if topic == selfRefreshLoadingIconTopic {
    288 			send(hutils.MetaRefresh(1))
    289 			return nil
    290 		}
    291 
    292 		if topic == selfRefreshTopic && msgTyp.Typ == database.Close {
    293 			return nil
    294 		}
    295 
    296 		if topic == selfRefreshTopic && msgTyp.Typ == database.Redirect {
    297 			send(hutils.MetaRedirectNow(msgTyp.NewURL))
    298 			return nil
    299 		}
    300 
    301 		if topic == selfRefreshTopic || msgTyp.Typ == database.ForceRefresh {
    302 			send(hutils.MetaRefreshNow())
    303 			return nil
    304 		}
    305 
    306 		if topic == selfWizzTopic || msgTyp.Typ == database.Wizz {
    307 			send(wizzAlt.alternate())
    308 			c.Response().Flush()
    309 			continue
    310 		}
    311 
    312 		if modalsManager.Handle(db, authUser1, topic, csrf, msgTyp, send) {
    313 			c.Response().Flush()
    314 			continue
    315 		}
    316 
    317 		if msgTyp.Typ == database.DeleteMsg {
    318 			// Delete msg from the map that keep track of unread messages.
    319 			// If the map is now empty, we hide the read-marker.
    320 			msgsMap.Delete(msgTyp.Msg.ID)
    321 			if msgsMap.Empty() {
    322 				updateReadMarker()
    323 			}
    324 
    325 			send(fmt.Sprintf(`<style>.msgidc-%s-%d{display:none;}</style>`, msgTyp.Msg.UUID, msgTyp.Msg.Rev))
    326 			c.Response().Flush()
    327 			continue
    328 		}
    329 
    330 		if msgTyp.Typ == database.EditMsg {
    331 			// Get all messages for the user that were created after the edited one (included)
    332 			msgs, err := db.GetChatMessages(room.ID, roomKey, authUser1.GetUsername(), authUser1.GetID(), pmUserID, pmOnlyQuery,
    333 				mentionsOnlyQuery, displayHellbanned, authUser1.GetDisplayIgnored(), authUser1.GetDisplayModerators(),
    334 				displayIgnoredMessages, 150, msgTyp.Msg.ID)
    335 			if err != nil {
    336 				return c.Redirect(http.StatusFound, "/")
    337 			}
    338 
    339 			// If no messages, continue. This might happen if the user has ignored the user making the edit.
    340 			if len(msgs) == 0 {
    341 				c.Response().Flush()
    342 				continue
    343 			}
    344 
    345 			// Generate css to hide the previous revision of these messages
    346 			toHide := make([]string, len(msgs))
    347 			for i, msg := range msgs {
    348 				toHide[i] = fmt.Sprintf(".msgidc-%s-%d", msg.UUID, msg.Rev-1)
    349 			}
    350 			send(fmt.Sprintf(`<style>%s{display:none;}</style>`, strings.Join(toHide, ",")))
    351 
    352 			// Render the new revision of the messages in html
    353 			data.Messages = msgs
    354 			data.NbButtons = authUser1.CountUIButtons()
    355 			data.ReadMarker, _ = db.GetUserReadMarker(authUser1.GetID(), room.ID)
    356 
    357 			// Only try to redraw the read-marker if the first message
    358 			// that we redraw is older than our read-marker position.
    359 			var readMarkerRevRef *int
    360 			fstMsgTsRound := msgs[0].CreatedAt.Round(time.Second)
    361 			readMarkerTsRound := data.ReadMarker.ReadAt.Round(time.Second)
    362 			if !fstMsgTsRound.After(readMarkerTsRound) {
    363 				readMarkerRevRef = &readMarkerRev
    364 			}
    365 
    366 			send(RenderMessages(authUser1, data, csrf, nullUsername, readMarkerRevRef, true))
    367 
    368 			c.Response().Flush()
    369 			continue
    370 		}
    371 
    372 		msg := &msgTyp.Msg
    373 		if room.IsProtected() {
    374 			if err := msg.Decrypt(roomKey); err != nil {
    375 				return c.Redirect(http.StatusFound, "/")
    376 			}
    377 		}
    378 
    379 		if !dutils.VerifyMsgAuth(db, msg, authUser1.GetID(), authUser1.IsModerator()) ||
    380 			!applyUserFilters(db, authUser1, msg, pmUserID, pmOnlyQuery, displayHellbanned, mentionsOnlyQuery) {
    381 			continue
    382 		}
    383 
    384 		manualPreload(db, msg, room)
    385 
    386 		baseTopBarURL := "/api/v1/chat/top-bar/" + room.Name
    387 		if hasNoMsgs {
    388 			send(`<style>#no-msg{display:none}</style>`)
    389 			hasNoMsgs = false
    390 		}
    391 		readMarkerRendered := true
    392 		isFirstMsg := false
    393 		isEdit := utils.False()
    394 		renderedMsg := RenderMessage(1, *msg, authUser1, data, baseTopBarURL, &readMarkerRendered, &isFirstMsg, csrf, nullUsername, &readMarkerRev, isEdit)
    395 
    396 		// Keep track of unread messages
    397 		msgsMap.Set(msg.ID)
    398 
    399 		send(renderedMsg)
    400 		showReadMarker()
    401 
    402 		// Sound notifications
    403 		send(soundNotifications(msg, authUser1, &renderedMsg))
    404 
    405 		c.Response().Flush()
    406 	} // end of infinite loop (LOOP)
    407 
    408 	// Display a big banner stating the connection is closed.
    409 	send(`<div class="connection-closed">Connection closed</div>`)
    410 	// Auto refresh the page after 5sec so that the client reconnect after the app has restarted
    411 	send(hutils.MetaRefresh(5))
    412 	c.Response().Flush()
    413 	return nil
    414 }
    415 
    416 // ChatStreamMenuHandler return the html for the "stream" chat right-manu.
    417 func ChatStreamMenuHandler(c echo.Context) error {
    418 	db := c.Get("database").(*database.DkfDB)
    419 	authUser := c.Get("authUser").(*database.User)
    420 	roomName := c.Param("roomName")
    421 
    422 	room, _, err := dutils.GetRoomAndKey(db, c, roomName)
    423 	if err != nil {
    424 		return c.NoContent(http.StatusForbidden)
    425 	}
    426 
    427 	if !authUser.UseStreamMenu {
    428 		data := GetChatMenuData(c, room)
    429 		s := utils.TernaryOrZero(!data.PreventRefresh, hutils.MetaRefresh(5))
    430 		s += GenerateStyle(authUser, ChatMessagesData{})
    431 		s += RenderRightColumn(authUser, data)
    432 		return c.HTML(http.StatusOK, s)
    433 	}
    434 
    435 	streamItem, err := stream.SetStreaming(c, authUser.ID, "")
    436 	if err != nil {
    437 		return nil
    438 	}
    439 	defer streamItem.Cleanup()
    440 
    441 	send := func(s string) { _, _ = c.Response().Write([]byte(s)) }
    442 	var prevHash uint32
    443 	var menuID int
    444 	var once utils.Once
    445 
    446 	selfRefreshLoadingIconTopic := "refresh_loading_icon_" + string(authUser.Username)
    447 	selfRefreshTopic := "refresh_" + string(authUser.Username)
    448 	sub := database.MsgPubSub.Subscribe([]string{
    449 		database.RefreshTopic,
    450 		selfRefreshTopic,
    451 		selfRefreshLoadingIconTopic})
    452 	defer sub.Close()
    453 
    454 	send(GenerateStyle(authUser, ChatMessagesData{}))
    455 
    456 Loop:
    457 	for {
    458 		select {
    459 		case <-once.Now():
    460 		case <-time.After(5 * time.Second):
    461 		case p := <-sub.ReceiveCh():
    462 			if p.Topic == selfRefreshLoadingIconTopic {
    463 				send(hutils.MetaRefresh(1))
    464 				return nil
    465 			}
    466 			if p.Msg.Typ == database.ForceRefresh || p.Topic == selfRefreshTopic {
    467 				send(hutils.MetaRefreshNow())
    468 				return nil
    469 			}
    470 			if p.Msg.Typ == database.CloseMenu {
    471 				return nil
    472 			}
    473 			send(hutils.MetaRefresh(1))
    474 			return nil
    475 		case <-streamItem.Quit:
    476 			break Loop
    477 		}
    478 
    479 		data := GetChatMenuData(c, room)
    480 		rightColumn := RenderRightColumn(authUser, data)
    481 		newHash := utils.Crc32([]byte(rightColumn))
    482 		if newHash != prevHash {
    483 			send(fmt.Sprintf(`<style>#menu_%d{display:none}</style><div id="menu_%d">%s</div>`, menuID, menuID+1, rightColumn))
    484 			c.Response().Flush()
    485 			prevHash = newHash
    486 			menuID++
    487 		}
    488 	}
    489 	send(hutils.MetaRefresh(5))
    490 	c.Response().Flush()
    491 	return nil
    492 }
    493 
    494 func ChatStreamMessagesRefreshHandler(c echo.Context) error {
    495 	authUser := c.Get("authUser").(*database.User)
    496 	database.MsgPubSub.Pub("refresh_loading_icon_"+string(authUser.Username), database.ChatMessageType{Typ: database.ForceRefresh})
    497 	poker.PubSub.Pub("refresh_loading_icon_"+string(authUser.Username), poker.RefreshLoadingIconEvent{})
    498 	return hutils.RedirectReferer(c)
    499 }