dkforest

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

commit 6e04d49934d8683ff468676ec97eaa0ae377fc4e
parent 96745a8e63122a5445b4be746379a014f5187868
Author: n0tr1v <n0tr1v@protonmail.com>
Date:   Thu,  8 Jun 2023 01:05:11 -0700

separate interceptors in own package

Diffstat:
Mpkg/actions/actions.go | 10+++++-----
Mpkg/web/handlers/admin.go | 6+++---
Dpkg/web/handlers/api/v1/bangInterceptor.go | 32--------------------------------
Dpkg/web/handlers/api/v1/battleship.go | 607-------------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/chess.go | 578------------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/codeModalInterceptor.go | 51---------------------------------------------------
Mpkg/web/handlers/api/v1/handlers.go | 76+++++++---------------------------------------------------------------------
Apkg/web/handlers/api/v1/interceptors/bangInterceptor.go | 32++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/battleship.go | 607+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/chess.go | 578++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/codeModalInterceptor.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/interceptor.go | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/msgInterceptor.go | 1109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/slashInterceptor.go | 1817+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/snippetInterceptor.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/spamInterceptor.go | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/spamInterceptor_test.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/uploadInterceptor.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/api/v1/interceptors/werewolf.go | 701+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpkg/web/handlers/api/v1/msgInterceptor.go | 196-------------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/slashInterceptor.go | 1805-------------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/snippetInterceptor.go | 57---------------------------------------------------------
Dpkg/web/handlers/api/v1/spamInterceptor.go | 265-------------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/spamInterceptor_test.go | 69---------------------------------------------------------------------
Mpkg/web/handlers/api/v1/topBarHandler.go | 1059+++----------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/uploadInterceptor.go | 77-----------------------------------------------------------------------------
Dpkg/web/handlers/api/v1/werewolf.go | 701-------------------------------------------------------------------------------
Mpkg/web/handlers/data.go | 4++--
Mpkg/web/handlers/handlers.go | 13+++++++------
29 files changed, 5573 insertions(+), 5546 deletions(-)

diff --git a/pkg/actions/actions.go b/pkg/actions/actions.go @@ -10,7 +10,7 @@ import ( "dkforest/pkg/managers" "dkforest/pkg/utils" "dkforest/pkg/web" - "dkforest/pkg/web/handlers/api/v1" + "dkforest/pkg/web/handlers/api/v1/interceptors" "errors" "fmt" "github.com/mattn/go-colorable" @@ -82,10 +82,10 @@ func Start(c *cli.Context) error { utils.SGo(func() { xmrWatch(db) }) utils.SGo(func() { openBrowser(noBrowser, int64(port)) }) - v1.LoadFilters(db) - v1.ChessInstance = v1.NewChess(db) - v1.BattleshipInstance = v1.NewBattleship(db) - v1.WWInstance = v1.NewWerewolf(db) + interceptors.LoadFilters(db) + interceptors.ChessInstance = interceptors.NewChess(db) + interceptors.BattleshipInstance = interceptors.NewBattleship(db) + interceptors.WWInstance = interceptors.NewWerewolf(db) web.Start(db, host, port) return nil diff --git a/pkg/web/handlers/admin.go b/pkg/web/handlers/admin.go @@ -3,7 +3,7 @@ package handlers import ( dutils "dkforest/pkg/database/utils" "dkforest/pkg/managers" - v1 "dkforest/pkg/web/handlers/api/v1" + "dkforest/pkg/web/handlers/api/v1/interceptors" "github.com/jinzhu/gorm" "net/http" "regexp" @@ -40,12 +40,12 @@ func AdminSpamFiltersHandler(c echo.Context) error { if _, err := db.CreateOrEditSpamFilter(data.ID, data.Filter, data.IsRegex, data.Action); err != nil { logrus.Error(err) } - v1.LoadFilters(db) + interceptors.LoadFilters(db) } else if btnSubmit == "delete" { if err := db.DeleteSpamFilterByID(data.ID); err != nil { logrus.Error(err) } - v1.LoadFilters(db) + interceptors.LoadFilters(db) } return c.Redirect(http.StatusFound, "/admin/spam-filters") } diff --git a/pkg/web/handlers/api/v1/bangInterceptor.go b/pkg/web/handlers/api/v1/bangInterceptor.go @@ -1,32 +0,0 @@ -package v1 - -import "dkforest/pkg/config" - -type BangInterceptor struct{} - -func (i BangInterceptor) InterceptMsg(cmd *Command) { - switch cmd.message { - case "!links": - handleLinksBangCmd(cmd) - case "!rtuto": - handleRtutoBangCmd(cmd) - } - return -} - -func handleLinksBangCmd(cmd *Command) { - message := ` -Chats: -Black Hat Chat: ` + config.BhcOnion + ` -Forums: -CryptBB: ` + config.CryptbbOnion - msg, _, _ := ProcessRawMessage(cmd.db, message, "", cmd.authUser.ID, cmd.room.ID, nil, true) - cmd.zeroMsg(msg) - cmd.err = ErrRedirect -} - -func handleRtutoBangCmd(cmd *Command) { - cmd.authUser.ChatTutorial = 0 - cmd.authUser.DoSave(cmd.db) - cmd.err = ErrRedirect -} diff --git a/pkg/web/handlers/api/v1/battleship.go b/pkg/web/handlers/api/v1/battleship.go @@ -1,607 +0,0 @@ -package v1 - -import ( - "bytes" - "dkforest/pkg/config" - "dkforest/pkg/database" - "dkforest/pkg/utils" - "encoding/base64" - "errors" - "fmt" - "github.com/fogleman/gg" - "github.com/sirupsen/logrus" - "html/template" - "image/color" - "strconv" - "sync" - "time" -) - -// Carrier 5 -// Battleship 4 -// Cruiser 3 -// Submarine 3 -// Destroyer 2 - -/** -◀■■▶ -▲ -█ -▼ -● -*/ - -var BattleshipInstance *Battleship - -type BSCoordinate struct { - x, y int -} - -type BSPlayer struct { - id database.UserID - username database.Username - userStyle string - card *BSCard - shots map[int]struct{} -} - -func newPlayer(player database.User) *BSPlayer { - p := new(BSPlayer) - p.id = player.ID - p.username = player.Username - p.userStyle = player.GenerateChatStyle() - p.card = generateCard() - p.shots = make(map[int]struct{}) - return p -} - -type Direction int - -const ( - vertical Direction = iota + 1 - horizontal -) - -type BSShip struct { - name string - x, y, size int - direction Direction - reversed bool - health int -} - -func newShip(name string, x, y, size int, dir Direction, reversed bool) BSShip { - return BSShip{name: name, x: x, y: y, size: size, direction: dir, health: size, reversed: reversed} -} - -func (s BSShip) contains(pos int) bool { - for _, el := range s.getPos() { - if pos == el { - return true - } - } - return false -} - -func (s BSShip) getPos() (out []int) { - for i := 0; i < s.size; i++ { - incr := i - if s.direction == vertical { - incr *= 10 - } - out = append(out, s.y*10+s.x+incr) - } - return -} - -type BSCard struct { - carrier BSShip - battleShip BSShip - cruiser BSShip - submarine BSShip - destroyer BSShip -} - -func (c *BSCard) collide(newShip BSShip) bool { - for _, p := range newShip.getPos() { - if c.carrier.contains(p) || - c.battleShip.contains(p) || - c.cruiser.contains(p) || - c.submarine.contains(p) || - c.destroyer.contains(p) { - return true - } - } - return false -} - -func (c *BSCard) shot(pos int) { - if c.carrier.contains(pos) { - c.carrier.health -= 1 - } else if c.battleShip.contains(pos) { - c.battleShip.health -= 1 - } else if c.cruiser.contains(pos) { - c.cruiser.health -= 1 - } else if c.submarine.contains(pos) { - c.submarine.health -= 1 - } else if c.destroyer.contains(pos) { - c.destroyer.health -= 1 - } -} - -func (c BSCard) allShipsDead() bool { - return c.carrier.health == 0 && - c.battleShip.health == 0 && - c.cruiser.health == 0 && - c.submarine.health == 0 && - c.destroyer.health == 0 -} - -func (c BSCard) shipAt(pos int) (string, bool) { - if c.carrier.contains(pos) { - return "carrier", c.carrier.health == 0 - } else if c.battleShip.contains(pos) { - return "battleShip", c.battleShip.health == 0 - } else if c.cruiser.contains(pos) { - return "cruiser", c.cruiser.health == 0 - } else if c.submarine.contains(pos) { - return "submarine", c.submarine.health == 0 - } else if c.destroyer.contains(pos) { - return "destroyer", c.destroyer.health == 0 - } - return "", false -} - -func (c BSCard) hasShipAt(pos int) bool { - var allPos []int - allPos = append(allPos, c.carrier.getPos()...) - allPos = append(allPos, c.battleShip.getPos()...) - allPos = append(allPos, c.cruiser.getPos()...) - allPos = append(allPos, c.submarine.getPos()...) - allPos = append(allPos, c.destroyer.getPos()...) - for _, el := range allPos { - if pos == el { - return true - } - } - return false -} - -type BSGame struct { - lastUpdated time.Time - turn int - player1 *BSPlayer - player2 *BSPlayer -} - -func newGame(player1, player2 database.User) *BSGame { - g := new(BSGame) - g.lastUpdated = time.Now() - g.player1 = newPlayer(player1) - g.player2 = newPlayer(player2) - return g -} - -func (g BSGame) IsPlayerTurn(playerID database.UserID) bool { - return g.turn == 0 && g.player1.id == playerID || - g.turn == 1 && g.player2.id == playerID -} - -func (g *BSGame) Shot(pos string) (shipStr string, shipDead, gameEnded bool, err error) { - g.lastUpdated = time.Now() - rowStr := pos[0] - row := int(rowStr - 'A') - col, _ := strconv.Atoi(string(pos[1])) - p := row*10 + col - - ent1 := g.player1 - ent2 := g.player2 - if g.turn == 1 { - ent1, ent2 = ent2, ent1 - } - if _, ok := ent1.shots[p]; ok { - return "", false, false, errors.New("position already hit") - } - ent1.shots[p] = struct{}{} - ent2.card.shot(p) - shipStr, shipDead = ent2.card.shipAt(p) - gameEnded = ent2.card.allShipsDead() - - g.turn = (g.turn + 1) % 2 - return -} - -type Battleship struct { - sync.Mutex - db *database.DkfDB - zeroID database.UserID - games map[string]*BSGame -} - -func NewBattleship(db *database.DkfDB) *Battleship { - zeroUser, _ := db.GetUserByUsername(config.NullUsername) - b := &Battleship{db: db, zeroID: zeroUser.ID} - b.games = make(map[string]*BSGame) - - // Thread that cleanup inactive games - go func() { - for { - time.Sleep(time.Minute) - b.Lock() - for k, g := range b.games { - if time.Since(g.lastUpdated) > 5*time.Minute { - delete(b.games, k) - } - } - b.Unlock() - } - }() - - return b -} - -func generateCard() *BSCard { - c := new(BSCard) - genTmpShip := func(name string, size int) (out BSShip) { - reversed := utils.RandBool() - dir := utils.RandChoice([]Direction{horizontal, vertical}) - val1 := utils.RandInt(0, 9) - val2 := utils.RandInt(0, 9-size) - if dir == horizontal { - val1, val2 = val2, val1 - } - out = newShip(name, val1, val2, size, dir, reversed) - return - } - for _, i := range []int{0, 1, 2, 3, 4} { // iterate 5 times (for each boat) - names := []string{"carrier", "battleship", "cruiser", "submarine", "destroyer"} - sizes := []int{5, 4, 3, 3, 2} // respective boat size - for { - tmpShip := genTmpShip(names[i], sizes[i]) - // If boat collide with another boat, we need to generate a new position for that boat - if c.collide(tmpShip) { - continue - } - // boat position is valid, assign it - switch i { - case 0: - c.carrier = tmpShip - case 1: - c.battleShip = tmpShip - case 2: - c.cruiser = tmpShip - case 3: - c.submarine = tmpShip - case 4: - c.destroyer = tmpShip - } - break - } - } - return c -} - -func (g *BSGame) drawCardFor(tmp int, roomName string, isNewGame, shipDead, gameEnded bool, shipStr, pos string) (out string) { - you := g.player1 - enemy := g.player2 - if tmp == 1 { - you = g.player2 - enemy = g.player1 - } - - imgB64Fn := func(myCard bool) string { - ent1 := enemy - ent2 := you - if myCard { - ent1 = you - ent2 = enemy - } - - c := gg.NewContext(177, 177) - - c.Push() - c.SetColor(color.White) - c.DrawRectangle(0, 0, 177, 177) - c.Fill() - c.Pop() - - c.Push() - c.SetColor(color.Black) - x := 22.0 - y := 13.0 - c.DrawString("0", x, y) - c.DrawString("1", x+16, y) - c.DrawString("2", x+16+16, y) - c.DrawString("3", x+16+16+16, y) - c.DrawString("4", x+16+16+16+16, y) - c.DrawString("5", x+16+16+16+16+16, y) - c.DrawString("6", x+16+16+16+16+16+16, y) - c.DrawString("7", x+16+16+16+16+16+16+16, y) - c.DrawString("8", x+16+16+16+16+16+16+16+16, y) - c.DrawString("9", x+16+16+16+16+16+16+16+16+16, y) - x = 6 - y = 29.0 - c.DrawString("A", x, y) - c.DrawString("B", x, y+16) - c.DrawString("C", x, y+16+16) - c.DrawString("D", x, y+16+16+16) - c.DrawString("E", x, y+16+16+16+16) - c.DrawString("F", x, y+16+16+16+16+16) - c.DrawString("G", x, y+16+16+16+16+16+16) - c.DrawString("H", x, y+16+16+16+16+16+16+16) - c.DrawString("I", x, y+16+16+16+16+16+16+16+16) - c.DrawString("J", x, y+16+16+16+16+16+16+16+16+16) - c.Pop() - - c.Push() - c.SetLineWidth(1) - c.SetColor(color.RGBA{R: 90, G: 90, B: 90, A: 255}) - for col := 0.0; col < 12; col++ { - c.MoveTo(0.5+col*16, 0) - c.LineTo(0.5+col*16, 176) - c.Stroke() - } - for row := 0.0; row < 12; row++ { - c.MoveTo(0, 0.5+row*16) - c.LineTo(176, 0.5+row*16) - c.Stroke() - } - c.Pop() - - drawShip := func(s BSShip) { - if !myCard && s.health != 0 && !gameEnded { - return - } - //fmt.Println(s.name, s.x, s.y, s.direction, s.reversed) - c.Push() - c.Translate(0.5, 0.5) - c.Translate(16, 16) - c.Translate(float64(s.x)*16, float64(s.y)*16) - if s.direction == horizontal { - if s.reversed { - c.Translate(float64(s.size)*16, 0) - c.Rotate(gg.Radians(90)) - } else { - c.Translate(0, 16) - c.Rotate(gg.Radians(-90)) - } - } else { - if s.reversed { - c.Translate(16, float64(s.size)*16) - c.Rotate(gg.Radians(180)) - } - } - // Front of the ship - c.MoveTo(1, 11) - c.QuadraticTo(8, -10, 15, 11) - // Length of the ship - c.Translate(0, float64(s.size-1)*16) - // back of the ship - c.LineTo(15, 11) - c.QuadraticTo(8, 17, 1, 11) - c.ClosePath() - if s.health == 0 { - c.SetColor(color.RGBA{R: 100, G: 100, B: 100, A: 200}) - c.Fill() - } else if !myCard && gameEnded { - c.SetColor(color.RGBA{R: 100, G: 130, B: 100, A: 200}) - c.Fill() - } else { - c.SetColor(color.RGBA{R: 100, G: 100, B: 100, A: 255}) - c.Fill() - } - c.Pop() - } - drawShip(ent1.card.carrier) - drawShip(ent1.card.battleShip) - drawShip(ent1.card.cruiser) - drawShip(ent1.card.submarine) - drawShip(ent1.card.destroyer) - - c.Push() - c.Translate(0.5, 0.5) - c.Translate(16, 16) - for shot := range ent2.shots { - shotRow := shot / 10 - shotCol := shot % 10 - c.Push() - c.Translate(float64(shotCol)*16, float64(shotRow)*16) - - if ent1.card.hasShipAt(shot) { - c.DrawCircle(8, 8, 4) - c.SetColor(color.RGBA{R: 255, G: 200, B: 0, A: 255}) - c.Fill() - - c.DrawCircle(8, 8, 3) - c.SetColor(color.RGBA{R: 255, G: 0, B: 0, A: 255}) - c.Fill() - } else { - c.DrawCircle(8, 8, 3) - c.SetColor(color.RGBA{R: 60, G: 200, B: 0, A: 255}) - c.Fill() - - c.DrawCircle(8, 8, 2) - c.SetColor(color.RGBA{R: 100, G: 0, B: 0, A: 255}) - c.Fill() - } - - c.Pop() - } - c.Pop() - - var buf bytes.Buffer - _ = c.EncodePNG(&buf) - imgB64 := base64.StdEncoding.EncodeToString(buf.Bytes()) - return imgB64 - } - - imgB64 := imgB64Fn(true) - img1B64 := imgB64Fn(false) - - htmlTmpl := ` -Against <span {{ .EnemyUserStyle | HTMLAttr }}>@{{ .EnemyUsername }}</span><br /> -{{ if not .IsNewGame }} - {{ if .YourTurn }} - <span {{ .EnemyUserStyle | HTMLAttr }}>@{{ .EnemyUsername }}</span> played {{ .Pos }} - {{ else }} - you played {{ .Pos }} - {{ end }} - ; - {{ if .ShipStr }} - {{ .ShipStr }} hit - {{ if .ShipDead }} - and sunk - {{ end }} - {{ else }} - miss - {{ end }} - ; -{{ end }} -{{ if .GameEnded }} - {{ if .YourTurn }} - You lost!<br /> - {{ else }} - You win!<br /> - {{ end }} -{{ else }} - {{ if .YourTurn }} - now is your turn<br /> - {{ else }} - waiting for opponent<br /> - {{ end }} -{{ end }} -<table> - <tr> - <td><img src="data:image/png;base64,{{ .ImgB64 }}" alt="" /></td> - <td style="vertical-align: top;"> - <form method="post" style="margin-left: 10px;" action="/api/v1/battleship"> - <input type="hidden" name="room" value="{{ .RoomName }}" /> - <input type="hidden" name="enemyUsername" value="{{ .EnemyUsername }}" /> - <table style="width: 177px; height: 177px; background-image: url(data:image/png;base64,{{ .Img1B64 }})"> - <tr style="height: 16px;"><td colspan="11">&nbsp;</td></tr> - {{- range $row := .Rows -}} - <tr style="height: 16px;"> - <td style="width: 16px;"></td> - {{- range $col := $.Cols -}} - {{- if NotShot $row $col -}} - {{- if and $.YourTurn (not $.GameEnded) -}} - <td style="width: 16px;"> - <button style="height: 15px; width: 15px;" name="move" value="{{ GetRune $row }}{{ $col }}"></button> - </td> - {{- else -}} - <td style="width: 16px;"></td> - {{- end -}} - {{- else -}} - <td style="width: 16px;"></td> - {{- end -}} - {{- end -}} - </tr> - {{- end -}} - </table> - </form> - </td> - </tr> -</table> -` - data := map[string]any{ - "RoomName": roomName, - "EnemyUserStyle": enemy.userStyle, - "EnemyUsername": enemy.username, - "IsNewGame": isNewGame, - "YourTurn": g.turn == tmp, - "Pos": pos, - "ShipStr": shipStr, - "ShipDead": shipDead, - "GameEnded": gameEnded, - "ImgB64": imgB64, - "Img1B64": img1B64, - "Rows": []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, - "Cols": []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, - } - fns := template.FuncMap{ - "GetRune": func(i int) string { - return string(rune('A' + i)) - }, - "NotShot": func(i, j int) bool { - _, ok := you.shots[i*10+j] - return !ok - }, - "HTMLAttr": func(in string) template.HTMLAttr { - return template.HTMLAttr(in) - }, - } - var buf bytes.Buffer - _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data) - return buf.String() -} - -func (b *Battleship) InterceptMsg(cmd *Command) { - m := bsRgx.FindStringSubmatch(cmd.message) - if len(m) != 3 { - return - } - enemyUsername := database.Username(m[1]) - pos := m[2] - if err := b.playMove(cmd.room.Name, cmd.room.ID, cmd.roomKey, *cmd.authUser, enemyUsername, pos); err != nil { - cmd.err = err - return - } - cmd.err = ErrStop - return -} - -func (b *Battleship) playMove(roomName string, roomID database.RoomID, roomKey string, authUser database.User, enemyUsername database.Username, pos string) error { - b.Lock() - defer b.Unlock() - - user, err := b.db.GetUserByUsername(enemyUsername) - if err != nil { - return errors.New("invalid username") - } - - var gameKey string - if authUser.ID < user.ID { - gameKey = fmt.Sprintf("%d_%d", authUser.ID, user.ID) - } else { - gameKey = fmt.Sprintf("%d_%d", user.ID, authUser.ID) - } - - var shipStr string - var isNewGame, shipDead, gameEnded bool - g, ok := b.games[gameKey] - if ok { - if !g.IsPlayerTurn(authUser.ID) { - return errors.New("not your turn") - } - shipStr, shipDead, gameEnded, err = g.Shot(pos) - if err != nil { - return err - } - } else { - if pos != "" { - return errors.New("no Game ongoing") - } - g = newGame(user, authUser) - b.games[gameKey] = g - isNewGame = true - } - - // Delete old messages sent by "0" to the players - if err := b.db.DB(). - Where("room_id = ? AND user_id = ? AND (to_user_id = ? OR to_user_id = ?)", roomID, b.zeroID, g.player1.id, g.player2.id). - Delete(&database.ChatMessage{}).Error; err != nil { - logrus.Error(err) - } - - card1 := g.drawCardFor(0, roomName, isNewGame, shipDead, gameEnded, shipStr, pos) - _, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.player1.id) - - card2 := g.drawCardFor(1, roomName, isNewGame, shipDead, gameEnded, shipStr, pos) - _, _ = b.db.CreateMsg(card2, card2, roomKey, roomID, b.zeroID, &g.player2.id) - - if gameEnded { - delete(b.games, gameKey) - } - - return nil -} diff --git a/pkg/web/handlers/api/v1/chess.go b/pkg/web/handlers/api/v1/chess.go @@ -1,578 +0,0 @@ -package v1 - -import ( - "bytes" - "dkforest/bindata" - "dkforest/pkg/config" - "dkforest/pkg/database" - dutils "dkforest/pkg/database/utils" - "dkforest/pkg/pubsub" - "dkforest/pkg/utils" - "encoding/base64" - "errors" - "fmt" - "github.com/fogleman/gg" - "github.com/google/uuid" - "github.com/labstack/echo" - "github.com/notnil/chess" - "github.com/sirupsen/logrus" - "html/template" - "image" - "image/color" - "image/png" - "sort" - "strconv" - "sync" - "time" -) - -type ChessPlayer struct { - ID database.UserID - Username database.Username - UserStyle string - NotifyChessMove bool -} - -type ChessGame struct { - Key string - Game *chess.Game - lastUpdated time.Time - Player1 *ChessPlayer - Player2 *ChessPlayer - CreatedAt time.Time -} - -func newChessPlayer(player database.User) *ChessPlayer { - p := new(ChessPlayer) - p.ID = player.ID - p.Username = player.Username - p.UserStyle = player.GenerateChatStyle() - p.NotifyChessMove = player.NotifyChessMove - return p -} - -func newChessGame(gameKey string, player1, player2 database.User) *ChessGame { - g := new(ChessGame) - g.CreatedAt = time.Now() - g.Key = gameKey - g.Game = chess.NewGame() - g.lastUpdated = time.Now() - g.Player1 = newChessPlayer(player1) - g.Player2 = newChessPlayer(player2) - return g -} - -type Chess struct { - sync.Mutex - db *database.DkfDB - zeroID database.UserID - games map[string]*ChessGame -} - -func NewChess(db *database.DkfDB) *Chess { - zeroUser, _ := db.GetUserByUsername(config.NullUsername) - c := &Chess{db: db, zeroID: zeroUser.ID} - c.games = make(map[string]*ChessGame) - - // Thread that cleanup inactive games - go func() { - for { - time.Sleep(15 * time.Minute) - c.Lock() - for k, g := range c.games { - if time.Since(g.lastUpdated) > 3*time.Hour { - delete(c.games, k) - } - } - c.Unlock() - } - }() - - return c -} - -var ChessInstance *Chess - -const ( - sqSize = 45 - boardSize = 8 * sqSize -) - -func renderBoardPng(last *chess.Move, position *chess.Position, isFlipped bool) image.Image { - boardMap := position.Board().SquareMap() - ctx := gg.NewContext(boardSize, boardSize) - for i := 0; i < 64; i++ { - sq := chess.Square(i) - sqPiece := boardMap[sq] - renderSquare(ctx, sq, last, position.Turn(), sqPiece, isFlipped) - } - return ctx.Image() -} - -func XyForSquare(isFlipped bool, sq chess.Square) (x, y int) { - fileIndex := int(sq.File()) - rankIndex := 7 - int(sq.Rank()) - x = fileIndex * sqSize - y = rankIndex * sqSize - if isFlipped { - x = boardSize - x - sqSize - y = boardSize - y - sqSize - } - return -} - -func colorForSquare(sq chess.Square) color.RGBA { - sqSum := int(sq.File()) + int(sq.Rank()) - if sqSum%2 == 0 { - return color.RGBA{R: 165, G: 117, B: 81, A: 255} - } - return color.RGBA{R: 235, G: 209, B: 166, A: 255} -} - -func renderSquare(ctx *gg.Context, sq chess.Square, last *chess.Move, turn chess.Color, sqPiece chess.Piece, isFlipped bool) { - x, y := XyForSquare(isFlipped, sq) - // draw square - ctx.Push() - ctx.SetColor(colorForSquare(sq)) - ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize) - ctx.Fill() - ctx.Pop() - // Draw previous move - if last != nil { - if last.S1() == sq || last.S2() == sq { - ctx.Push() - ctx.SetRGBA(0, 1, 0, 0.1) - ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize) - ctx.Fill() - ctx.Pop() - } - // Draw check - p := sqPiece - if p != chess.NoPiece { - if p.Type() == chess.King && p.Color() == turn && last.HasTag(chess.Check) { - ctx.Push() - ctx.SetRGBA(1, 0, 0, 0.4) - ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize) - ctx.Fill() - ctx.Pop() - } - } - } - - ctx.Push() - ctx.SetColor(color.RGBA{R: 0, G: 0, B: 0, A: 180}) - if (!isFlipped && sq.Rank() == chess.Rank1) || (isFlipped && sq.Rank() == chess.Rank8) { - ctx.DrawString(sq.File().String(), float64(x+sqSize-7), float64(y+sqSize-1)) - } - if (!isFlipped && sq.File() == chess.FileA) || (isFlipped && sq.File() == chess.FileH) { - ctx.DrawString(sq.Rank().String(), float64(x+1), float64(y+11)) - } - ctx.Pop() - - // draw piece - p := sqPiece - if p != chess.NoPiece { - img := getFile("img/chess/" + p.Color().String() + pieceTypeMap[p.Type()] + ".png") - ctx.Push() - ctx.DrawImage(img, x, y) - ctx.Pop() - } -} - -var pieceTypeMap = map[chess.PieceType]string{ - chess.King: "K", - chess.Queen: "Q", - chess.Rook: "R", - chess.Bishop: "B", - chess.Knight: "N", - chess.Pawn: "P", -} - -var cache = make(map[string]image.Image) - -func getFile(fileName string) image.Image { - if img, ok := cache[fileName]; ok { - return img - } - fileBy := bindata.MustAsset(fileName) - img, _ := png.Decode(bytes.NewReader(fileBy)) - cache[fileName] = img - return img -} - -func renderTable(imgB64 string, isBlack bool) string { - htmlTmpl := ` -<style> -input[type=checkbox] { - display:none; -} -input[type=checkbox] + label { - display: inline-block; - padding: 0 0 0 0; - margin: 0 0 0 0; - height: 39px; - width: 39px; - background-size: 100%; - border: 3px solid transparent; -} -input[type=checkbox]:checked + label { - display: inline-block; - background-size: 100%; - border: 3px solid red; -} -</style> - -<table style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"> - {{ range $row := .Rows }} - <tr> - {{ range $col := $.Cols }} - {{ $id := GetID $row $col }} - <td> - <input name="sq_{{ $id }}" ID="sq_{{ $id }}" type="checkbox" value="1" /> - <label for="sq_{{ $id }}"></label> - </td> - {{ end }} - </tr> - {{ end }} -</table> -` - data := map[string]any{ - "ImgB64": imgB64, - "Rows": []int{0, 1, 2, 3, 4, 5, 6, 7}, - "Cols": []int{0, 1, 2, 3, 4, 5, 6, 7}, - } - - fns := template.FuncMap{ - "GetID": func(row, col int) int { - var id int - if isBlack { - id = row*8 + (7 - col) - } else { - id = (7-row)*8 + col - } - return id - }, - } - - var buf bytes.Buffer - _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data) - return buf.String() -} - -func (g *ChessGame) renderBoardB64(isFlipped bool) string { - position := g.Game.Position() - moves := g.Game.Moves() - var last *chess.Move - if len(moves) > 0 { - last = moves[len(moves)-1] - } - var buf bytes.Buffer - img := renderBoardPng(last, position, isFlipped) - _ = png.Encode(&buf, img) - imgB64 := base64.StdEncoding.EncodeToString(buf.Bytes()) - return imgB64 -} - -func (g *ChessGame) DrawPlayerCard(isBlack, isYourTurn bool) string { - return g.drawPlayerCard("", isBlack, isYourTurn) -} - -func (g *ChessGame) drawPlayerCard(roomName string, isBlack, isYourTurn bool) string { - enemy := utils.Ternary(isBlack, g.Player1, g.Player2) - - imgB64 := g.renderBoardB64(isBlack) - - htmlTmpl := ` -<div style="margin: 0 auto; width: 360px;"> - <div style="color: #eee;"> - <span {{ .White.UserStyle | attr }}>@{{ .White.Username }}</span> (white) VS - <span {{ .Black.UserStyle | attr }}>@{{ .Black.Username }}</span> (black) - </div> - - {{ if .GameOver }} - <div style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"></div> - {{ else }} - <form method="post"> - <input type="hidden" name="message" value="resign" /> - <button type="submit" style="background-color: #aaa; margin: 5px 0;">Resign</button> - </form> - {{ if .IsYourTurn }} - <form method="post"{{ if .InChat }} action="/api/v1/chess"{{ end }}> - {{ .Table }} - {{ if .InChat }} - <input type="hidden" name="room" value="{{ .RoomName }}" /> - <input type="hidden" name="enemyUsername" value="{{ .Username }}" /> - <input type="hidden" name="move" value="move" /> - {{ else }} - <input type="hidden" name="message" value="/pm {{ .Username }} /c move" /> - {{ end }} - <div style="width: 100%; display: flex; margin: 5px 0;"> - <div> - <button type="submit" style="background-color: #aaa;">Move</button> - </div> - <div style="margin-left: auto;"> - <span style="color: #aaa; margin-left: 20px;">Promo:</span> - <select name="promotion" style="background-color: #aaa;"> - <option value="queen">Queen</option> - <option value="rook">Rook</option> - <option value="knight">Knight</option> - <option value="bishop">Bishop</option> - </select> - </div> - </div> - </form> - {{ else }} - <div style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"></div> - {{ end }} - {{ end }} - <div style="color: #eee;">Outcome: {{ .Outcome }}</div> - - {{ if .GameOver }} - <div><textarea>{{ .PGN }}</textarea></div> - {{ end }} -</div> -` - - data := map[string]any{ - "RoomName": roomName, - "IsYourTurn": isYourTurn, - "InChat": roomName != "", - "White": g.Player1, - "Black": g.Player2, - "Username": enemy.Username, - "Table": template.HTML(renderTable(imgB64, isBlack)), - "ImgB64": imgB64, - "Outcome": g.Game.Outcome().String(), - "GameOver": g.Game.Outcome() != chess.NoOutcome, - "PGN": g.Game.String(), - } - - fns := template.FuncMap{ - "attr": func(s string) template.HTMLAttr { - return template.HTMLAttr(s) - }, - } - - var buf1 bytes.Buffer - _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data) - return buf1.String() -} - -func (g *ChessGame) DrawSpectatorCard(isFlipped bool) string { - imgB64 := g.renderBoardB64(isFlipped) - - htmlTmpl := ` -<div style="margin: 0 auto; width: 360px;"> - <div style="color: #eee;"> - <span {{ .White.UserStyle | attr }}>@{{ .White.Username }}</span> (white) VS - <span {{ .Black.UserStyle | attr }}>@{{ .Black.Username }}</span> (black) - </div> - <div style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"></div> - <div style="color: #eee;">Outcome: {{ .Outcome }}</div> - {{ if .GameOver }} - <div><textarea>{{ .PGN }}</textarea></div> - {{ end }} -</div> -` - - data := map[string]any{ - "White": g.Player1, - "Black": g.Player2, - "ImgB64": imgB64, - "Outcome": g.Game.Outcome().String(), - "GameOver": g.Game.Outcome() != chess.NoOutcome, - "PGN": g.Game.String(), - } - - fns := template.FuncMap{ - "attr": func(s string) template.HTMLAttr { - return template.HTMLAttr(s) - }, - } - - var buf1 bytes.Buffer - _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data) - return buf1.String() -} - -func (b *Chess) GetGame(key string) *ChessGame { - b.Lock() - defer b.Unlock() - if g, ok := b.games[key]; ok { - return g - } - return nil -} - -func (b *Chess) GetGames() (out []ChessGame) { - b.Lock() - defer b.Unlock() - for _, v := range b.games { - out = append(out, *v) - } - sort.Slice(out, func(i, j int) bool { - return out[i].CreatedAt.After(out[j].CreatedAt) - }) - return -} - -func (b *Chess) NewGame1(roomKey string, roomID database.RoomID, player1, player2 database.User) (*ChessGame, error) { - if player1.ID == player2.ID { - return nil, errors.New("can't play yourself") - } - - key := uuid.New().String() - g := b.NewGame(key, player1, player2) - - zeroUser := dutils.GetZeroUser(b.db) - dutils.SendNewChessGameMessages(b.db, key, roomKey, roomID, zeroUser, player1, player2) - return g, nil -} - -func (b *Chess) NewGame(gameKey string, user1, user2 database.User) *ChessGame { - g := newChessGame(gameKey, user1, user2) - b.Lock() - b.games[gameKey] = g - b.Unlock() - return g -} - -func (b *Chess) newGame(gameKey string, user1, user2 database.User) *ChessGame { - g := newChessGame(gameKey, user1, user2) - b.games[gameKey] = g - return g -} - -func (b *Chess) SendMove(gameKey string, userID database.UserID, g *ChessGame, c echo.Context) error { - if (g.Game.Position().Turn() == chess.White && userID != g.Player1.ID) || - (g.Game.Position().Turn() == chess.Black && userID != g.Player2.ID) { - return errors.New("not your turn") - } - - you := g.Player2 - opponent := g.Player1 - if g.Game.Position().Turn() == chess.White { - you = g.Player1 - opponent = g.Player2 - } - - selectedSquares := make([]chess.Square, 0) - for i := 0; i < 64; i++ { - if utils.DoParseBool(c.Request().PostFormValue("sq_" + strconv.Itoa(i))) { - selectedSquares = append(selectedSquares, chess.Square(i)) - } - } - - if len(selectedSquares) != 2 { - return errors.New("must select 2 squares") - } - - promo := chess.Queen - switch c.Request().PostFormValue("promotion") { - case "queen": - promo = chess.Queen - case "rook": - promo = chess.Rook - case "knight": - promo = chess.Knight - case "bishop": - promo = chess.Bishop - } - - var moveStr string - validMoves := g.Game.Position().ValidMoves() - var found bool - for _, move := range validMoves { - if (move.S1() == selectedSquares[0] && move.S2() == selectedSquares[1] && (move.Promo() == chess.NoPieceType || move.Promo() == promo)) || - (move.S1() == selectedSquares[1] && move.S2() == selectedSquares[0] && (move.Promo() == chess.NoPieceType || move.Promo() == promo)) { - moveStr = chess.AlgebraicNotation{}.Encode(g.Game.Position(), move) - found = true - break - } - } - - if !found { - return fmt.Errorf("invalid move %s %s", selectedSquares[0], selectedSquares[1]) - } - - _ = g.Game.MoveStr(moveStr) - g.lastUpdated = time.Now() - if g.Game.Outcome() != chess.NoOutcome { - //delete(b.games, gameKey) - } - - ChessPubSub.Pub(gameKey, true) - - // Notify (pm) the opponent that you made a move - if opponent.NotifyChessMove { - msg := fmt.Sprintf("@%s played %s", you.Username, moveStr) - msg, _ = colorifyTaggedUsers(msg, b.db.GetUsersByUsername) - chatMsg, _ := b.db.CreateMsg(msg, msg, "", config.GeneralRoomID, b.zeroID, &opponent.ID) - go func() { - time.Sleep(30 * time.Second) - _ = chatMsg.Delete(b.db) - }() - } - - return nil -} - -var ChessPubSub = pubsub.NewPubSub[bool]() - -func (b *Chess) InterceptMsg(cmd *Command) { - m := cRgx.FindStringSubmatch(cmd.message) - if len(m) != 3 { - return - } - enemyUsername := database.Username(m[1]) - pos := m[2] - if err := b.playMove(enemyUsername, pos, *cmd.authUser, cmd.c, cmd.room.Name, cmd.roomKey, cmd.room.ID); err != nil { - cmd.err = err - return - } - cmd.err = ErrStop -} - -func (b *Chess) playMove(enemyUsername database.Username, pos string, authUser database.User, c echo.Context, roomName, roomKey string, roomID database.RoomID) error { - b.Lock() - defer b.Unlock() - - user, err := b.db.GetUserByUsername(enemyUsername) - if err != nil { - return errors.New("invalid username") - } - - var gameKey string - if authUser.ID < user.ID { - gameKey = fmt.Sprintf("%d_%d", authUser.ID, user.ID) - } else { - gameKey = fmt.Sprintf("%d_%d", user.ID, authUser.ID) - } - - g, ok := b.games[gameKey] - if ok { - if err := b.SendMove(gameKey, authUser.ID, g, c); err != nil { - return err - } - } else { - if pos != "" { - return errors.New("no Game ongoing") - } - g = b.newGame(gameKey, user, authUser) - } - - // Delete old messages sent by "0" to the players - if err := b.db.DB(). - Where("room_id = ? AND user_id = ? AND (to_user_id = ? OR to_user_id = ?)", roomID, b.zeroID, g.Player1.ID, g.Player2.ID). - Delete(&database.ChatMessage{}).Error; err != nil { - logrus.Error(err) - } - - card1 := g.drawPlayerCard(roomName, false, true) - _, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.Player1.ID) - - card1 = g.drawPlayerCard(roomName, true, true) - _, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.Player2.ID) - - return nil -} diff --git a/pkg/web/handlers/api/v1/codeModalInterceptor.go b/pkg/web/handlers/api/v1/codeModalInterceptor.go @@ -1,51 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/database" - "dkforest/pkg/utils" -) - -type CodeModalInterceptor struct{} - -func (i CodeModalInterceptor) InterceptMsg(cmd *Command) { - sender := cmd.c.Request().PostFormValue("sender") - lang := cmd.c.Request().PostFormValue("lang") - isMod := utils.DoParseBool(cmd.c.Request().PostFormValue("isMod")) - pm := cmd.c.Request().PostFormValue("pm") - - if !cmd.authUser.CanUseMultiline || sender != "codeModal" { - return - } - - database.MsgPubSub.Pub("modal_code_hide_"+cmd.authUser.ID.String()+"_"+cmd.room.ID.String(), database.ChatMessageType{}) - - cmd.modMsg = isMod - if pm != "" { - toUser, err := cmd.db.GetUserByUsername(database.Username(pm)) - if err != nil { - cmd.err = ErrRedirect - return - } - cmd.skipInboxes, cmd.err = canUserPmOther(cmd.db, *cmd.authUser, toUser, cmd.room.IsOwned()) - if cmd.err != nil { - return - } - cmd.toUser = &toUser - cmd.redirectQP.Set(redirectPmQP, string(toUser.Username)) - } - - if cmd.origMessage == "" { - cmd.err = ErrRedirect - return - } - - if !utils.InArr(lang, []string{"go", "c", "cpp", "py", "js", "php", "css", "sql", "rs", "c#", "rb", "html", "bash"}) { - lang = "" - } - cmd.origMessage = codeFenceWrap(lang, cmd.origMessage) - cmd.message = codeFenceWrap(lang, cmd.message) -} - -func codeFenceWrap(lang, msg string) string { - return "\n```" + lang + "\n" + msg + "\n```\n" -} diff --git a/pkg/web/handlers/api/v1/handlers.go b/pkg/web/handlers/api/v1/handlers.go @@ -10,6 +10,7 @@ import ( "dkforest/pkg/hashset" "dkforest/pkg/managers" "dkforest/pkg/utils" + "dkforest/pkg/web/handlers/api/v1/interceptors" hutils "dkforest/pkg/web/handlers/utils" "encoding/json" "errors" @@ -20,73 +21,10 @@ import ( "golang.org/x/net/websocket" "io" "net/http" - "regexp" "strings" "time" ) -var usernameF = `\w{3,20}` // username (regex Fragment) -var userOr0 = usernameF + `|0` -var groupName = `\w{3,20}` -var roomNameF = `\w{3,50}` -var chatTs = `\d{2}:\d{2}:\d{2}` -var optAtGUser = `@?(` + usernameF + `)` // Optional @, Grouped, Username -var optAtGUserOr0 = `@?(` + userOr0 + `)` // Optional @, Grouped, Username or 0 -var onionV2Rgx = regexp.MustCompile(`[a-z2-7]{16}\.onion`) -var onionV3Rgx = regexp.MustCompile(`[a-z2-7]{56}\.onion`) -var deleteMsgRgx = regexp.MustCompile(`^/d (\d{2}:\d{2}:\d{2})(?:\s` + optAtGUserOr0 + `)?$`) -var ignoreRgx = regexp.MustCompile(`^/(?:ignore|i) ` + optAtGUser) -var pmToggleWhitelistUserRgx = regexp.MustCompile(`^/pmw ` + optAtGUser) -var pmToggleBlacklistUserRgx = regexp.MustCompile(`^/pmb ` + optAtGUser) -var whitelistUserRgx = regexp.MustCompile(`^/(?:whitelist|wl) ` + optAtGUser) -var unIgnoreRgx = regexp.MustCompile(`^/(?:unignore|ui) ` + optAtGUser) -var groupRgx = regexp.MustCompile(`^/g (` + groupName + `)\s(?s:(.*))`) -var pmRgx = regexp.MustCompile(`^/pm ` + optAtGUserOr0 + `(?:\s(?s:(.*)))?`) -var editRgx = regexp.MustCompile(`^/e (` + chatTs + `)\s(?s:(.*))`) -var hbmtRgx = regexp.MustCompile(`^/hbmt (` + chatTs + `)$`) -var chessRgx = regexp.MustCompile(`^/chess ` + optAtGUser + `(?:\s(w|b|r))?`) -var inboxRgx = regexp.MustCompile(`^/inbox ` + optAtGUser + `(\s-e)?\s(?s:(.*))`) -var purgeRgx = regexp.MustCompile(`^/purge(\s-hb)? ` + optAtGUserOr0) -var renameRgx = regexp.MustCompile(`^/rename ` + optAtGUser + ` ` + optAtGUser) -var profileRgx = regexp.MustCompile(`^/p ` + optAtGUserOr0) -var kickRgx = regexp.MustCompile(`^/(?:kick|k) ` + optAtGUser) -var setUrlRgx = regexp.MustCompile(`^/seturl (.+)`) -var kickKeepRgx = regexp.MustCompile(`^/(?:kk) ` + optAtGUser) -var kickSilentRgx = regexp.MustCompile(`^/(?:ks) ` + optAtGUser) -var kickKeepSilentRgx = regexp.MustCompile(`^/(?:kks) ` + optAtGUser) -var rtutoRgx = regexp.MustCompile(`^/(?:rtuto) ` + optAtGUser) -var logoutRgx = regexp.MustCompile(`^/(?:logout) ` + optAtGUser) -var forceCaptchaRgx = regexp.MustCompile(`^/(?:captcha) ` + optAtGUser) -var unkickRgx = regexp.MustCompile(`^/(?:unkick|uk) ` + optAtGUser) -var hellbanRgx = regexp.MustCompile(`^/(?:hellban|hb) ` + optAtGUser) -var unhellbanRgx = regexp.MustCompile(`^/(?:unhellban|uhb) ` + optAtGUser) -var randRgx = regexp.MustCompile(`^/rand (-?\d+) (-?\d+)$`) -var tokenRgx = regexp.MustCompile(`^/token (\d{1,2})$`) -var snippetRgx = regexp.MustCompile(`!\w{1,20}`) -var tagRgx = regexp.MustCompile(`@(` + userOr0 + `)`) -var autoTagRgx = regexp.MustCompile(`@(\w+)\*`) -var roomTagRgx = regexp.MustCompile(`#(` + roomNameF + `)`) -var tzRgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2} at \d{1,2}\.\d{1,2}\.\d{1,2} (?i)[A|P]M)`) // Screen Shot 2022-02-04 at 11.58.58 PM -var tz1Rgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2} \d{1,2}-\d{1,2}-\d{1,2})`) // Screenshot from 2022-02-04 11-58-58.png -var tz3Rgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2} \d{1,6})`) // Screenshot 2023-05-20 202351.png -var tz4Rgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2}_\d{1,2}_\d{1,2}_\d{1,2})`) // Screenshot_2023-05-20_11_13_14.png -var addGroupRgx = regexp.MustCompile(`^/addgroup (` + groupName + `)$`) -var rmGroupRgx = regexp.MustCompile(`^/rmgroup (` + groupName + `)$`) -var lockGroupRgx = regexp.MustCompile(`^/glock (` + groupName + `)$`) -var unlockGroupRgx = regexp.MustCompile(`^/gunlock (` + groupName + `)$`) -var groupUsersRgx = regexp.MustCompile(`^/gusers (` + groupName + `)$`) -var groupAddUserRgx = regexp.MustCompile(`^/gadduser (` + groupName + `) ` + optAtGUser + `$`) -var groupRmUserRgx = regexp.MustCompile(`^/grmuser (` + groupName + `) ` + optAtGUser + `$`) -var unsubscribeRgx = regexp.MustCompile(`^/unsubscribe (` + roomNameF + `)$`) -var bsRgx = regexp.MustCompile(`^/pm ` + optAtGUser + ` /bs\s?([A-J]\d)?$`) -var cRgx = regexp.MustCompile(`^/pm ` + optAtGUser + ` /c\s?(move)?$`) -var hideRgx = regexp.MustCompile(`^/hide (?:“\[)?(\d{2}:\d{2}:\d{2})`) -var unhideRgx = regexp.MustCompile(`^/unhide (\d{2}:\d{2}:\d{2})$`) -var memeRgx = regexp.MustCompile(`^/meme ([a-zA-Z0-9_-]{3,50})$`) -var memeRenameRgx = regexp.MustCompile(`^/meme ([a-zA-Z0-9_-]{3,50}) ([a-zA-Z0-9_-]{3,50})$`) -var memeRemoveRgx = regexp.MustCompile(`^/memerm ([a-zA-Z0-9_-]{3,50})$`) -var memesRgx = regexp.MustCompile(`^/memes$`) - // GetChatMenuData gets the data needed to render the "right-menu" in a chat room. // We have it separate because we have one endpoint that only render the right menu (for "stream" chat). // and one endpoint to render both messages and menu ("non-stream" chat). @@ -613,7 +551,7 @@ func ChessHandler(c echo.Context) error { if err != nil { return c.Redirect(http.StatusFound, redirectURL+"?error="+err.Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) } - if err = ChessInstance.playMove(enemyUsername, pos, *authUser, c, roomName, roomKey, room.ID); err != nil { + if err = interceptors.ChessInstance.PlayMove(enemyUsername, pos, *authUser, c, roomName, roomKey, room.ID); err != nil { return c.Redirect(http.StatusFound, redirectURL+"?error="+err.Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) } return c.Redirect(http.StatusFound, redirectURL) @@ -628,10 +566,10 @@ func WerewolfHandler(c echo.Context) error { if err != nil { return c.Redirect(http.StatusFound, redirectURL+"?error="+err.Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) } - cmd := NewCommand(c, origMessage, room, roomKey) - WWInstance.InterceptMsg(cmd) - if cmd.err != nil { - return c.Redirect(http.StatusFound, redirectURL+"?error="+cmd.err.Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) + cmd := interceptors.NewCommand(c, origMessage, room, roomKey) + interceptors.WWInstance.InterceptMsg(cmd) + if cmd.Err() != nil { + return c.Redirect(http.StatusFound, redirectURL+"?error="+cmd.Err().Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) } return c.Redirect(http.StatusFound, redirectURL) } @@ -647,7 +585,7 @@ func BattleshipHandler(c echo.Context) error { if err != nil { return c.Redirect(http.StatusFound, redirectURL+"?error="+err.Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) } - if err = BattleshipInstance.playMove(roomName, room.ID, roomKey, *authUser, enemyUsername, pos); err != nil { + if err = interceptors.BattleshipInstance.PlayMove(roomName, room.ID, roomKey, *authUser, enemyUsername, pos); err != nil { return c.Redirect(http.StatusFound, redirectURL+"?error="+err.Error()+"&errorTs="+utils.FormatInt64(time.Now().Unix())) } return c.Redirect(http.StatusFound, redirectURL) diff --git a/pkg/web/handlers/api/v1/interceptors/bangInterceptor.go b/pkg/web/handlers/api/v1/interceptors/bangInterceptor.go @@ -0,0 +1,32 @@ +package interceptors + +import "dkforest/pkg/config" + +type BangInterceptor struct{} + +func (i BangInterceptor) InterceptMsg(cmd *Command) { + switch cmd.message { + case "!links": + handleLinksBangCmd(cmd) + case "!rtuto": + handleRtutoBangCmd(cmd) + } + return +} + +func handleLinksBangCmd(cmd *Command) { + message := ` +Chats: +Black Hat Chat: ` + config.BhcOnion + ` +Forums: +CryptBB: ` + config.CryptbbOnion + msg, _, _ := ProcessRawMessage(cmd.db, message, "", cmd.authUser.ID, cmd.room.ID, nil, true) + cmd.zeroMsg(msg) + cmd.err = ErrRedirect +} + +func handleRtutoBangCmd(cmd *Command) { + cmd.authUser.ChatTutorial = 0 + cmd.authUser.DoSave(cmd.db) + cmd.err = ErrRedirect +} diff --git a/pkg/web/handlers/api/v1/interceptors/battleship.go b/pkg/web/handlers/api/v1/interceptors/battleship.go @@ -0,0 +1,607 @@ +package interceptors + +import ( + "bytes" + "dkforest/pkg/config" + "dkforest/pkg/database" + "dkforest/pkg/utils" + "encoding/base64" + "errors" + "fmt" + "github.com/fogleman/gg" + "github.com/sirupsen/logrus" + "html/template" + "image/color" + "strconv" + "sync" + "time" +) + +// Carrier 5 +// Battleship 4 +// Cruiser 3 +// Submarine 3 +// Destroyer 2 + +/** +◀■■▶ +▲ +█ +▼ +● +*/ + +var BattleshipInstance *Battleship + +type BSCoordinate struct { + x, y int +} + +type BSPlayer struct { + id database.UserID + username database.Username + userStyle string + card *BSCard + shots map[int]struct{} +} + +func newPlayer(player database.User) *BSPlayer { + p := new(BSPlayer) + p.id = player.ID + p.username = player.Username + p.userStyle = player.GenerateChatStyle() + p.card = generateCard() + p.shots = make(map[int]struct{}) + return p +} + +type Direction int + +const ( + vertical Direction = iota + 1 + horizontal +) + +type BSShip struct { + name string + x, y, size int + direction Direction + reversed bool + health int +} + +func newShip(name string, x, y, size int, dir Direction, reversed bool) BSShip { + return BSShip{name: name, x: x, y: y, size: size, direction: dir, health: size, reversed: reversed} +} + +func (s BSShip) contains(pos int) bool { + for _, el := range s.getPos() { + if pos == el { + return true + } + } + return false +} + +func (s BSShip) getPos() (out []int) { + for i := 0; i < s.size; i++ { + incr := i + if s.direction == vertical { + incr *= 10 + } + out = append(out, s.y*10+s.x+incr) + } + return +} + +type BSCard struct { + carrier BSShip + battleShip BSShip + cruiser BSShip + submarine BSShip + destroyer BSShip +} + +func (c *BSCard) collide(newShip BSShip) bool { + for _, p := range newShip.getPos() { + if c.carrier.contains(p) || + c.battleShip.contains(p) || + c.cruiser.contains(p) || + c.submarine.contains(p) || + c.destroyer.contains(p) { + return true + } + } + return false +} + +func (c *BSCard) shot(pos int) { + if c.carrier.contains(pos) { + c.carrier.health -= 1 + } else if c.battleShip.contains(pos) { + c.battleShip.health -= 1 + } else if c.cruiser.contains(pos) { + c.cruiser.health -= 1 + } else if c.submarine.contains(pos) { + c.submarine.health -= 1 + } else if c.destroyer.contains(pos) { + c.destroyer.health -= 1 + } +} + +func (c BSCard) allShipsDead() bool { + return c.carrier.health == 0 && + c.battleShip.health == 0 && + c.cruiser.health == 0 && + c.submarine.health == 0 && + c.destroyer.health == 0 +} + +func (c BSCard) shipAt(pos int) (string, bool) { + if c.carrier.contains(pos) { + return "carrier", c.carrier.health == 0 + } else if c.battleShip.contains(pos) { + return "battleShip", c.battleShip.health == 0 + } else if c.cruiser.contains(pos) { + return "cruiser", c.cruiser.health == 0 + } else if c.submarine.contains(pos) { + return "submarine", c.submarine.health == 0 + } else if c.destroyer.contains(pos) { + return "destroyer", c.destroyer.health == 0 + } + return "", false +} + +func (c BSCard) hasShipAt(pos int) bool { + var allPos []int + allPos = append(allPos, c.carrier.getPos()...) + allPos = append(allPos, c.battleShip.getPos()...) + allPos = append(allPos, c.cruiser.getPos()...) + allPos = append(allPos, c.submarine.getPos()...) + allPos = append(allPos, c.destroyer.getPos()...) + for _, el := range allPos { + if pos == el { + return true + } + } + return false +} + +type BSGame struct { + lastUpdated time.Time + turn int + player1 *BSPlayer + player2 *BSPlayer +} + +func newGame(player1, player2 database.User) *BSGame { + g := new(BSGame) + g.lastUpdated = time.Now() + g.player1 = newPlayer(player1) + g.player2 = newPlayer(player2) + return g +} + +func (g BSGame) IsPlayerTurn(playerID database.UserID) bool { + return g.turn == 0 && g.player1.id == playerID || + g.turn == 1 && g.player2.id == playerID +} + +func (g *BSGame) Shot(pos string) (shipStr string, shipDead, gameEnded bool, err error) { + g.lastUpdated = time.Now() + rowStr := pos[0] + row := int(rowStr - 'A') + col, _ := strconv.Atoi(string(pos[1])) + p := row*10 + col + + ent1 := g.player1 + ent2 := g.player2 + if g.turn == 1 { + ent1, ent2 = ent2, ent1 + } + if _, ok := ent1.shots[p]; ok { + return "", false, false, errors.New("position already hit") + } + ent1.shots[p] = struct{}{} + ent2.card.shot(p) + shipStr, shipDead = ent2.card.shipAt(p) + gameEnded = ent2.card.allShipsDead() + + g.turn = (g.turn + 1) % 2 + return +} + +type Battleship struct { + sync.Mutex + db *database.DkfDB + zeroID database.UserID + games map[string]*BSGame +} + +func NewBattleship(db *database.DkfDB) *Battleship { + zeroUser, _ := db.GetUserByUsername(config.NullUsername) + b := &Battleship{db: db, zeroID: zeroUser.ID} + b.games = make(map[string]*BSGame) + + // Thread that cleanup inactive games + go func() { + for { + time.Sleep(time.Minute) + b.Lock() + for k, g := range b.games { + if time.Since(g.lastUpdated) > 5*time.Minute { + delete(b.games, k) + } + } + b.Unlock() + } + }() + + return b +} + +func generateCard() *BSCard { + c := new(BSCard) + genTmpShip := func(name string, size int) (out BSShip) { + reversed := utils.RandBool() + dir := utils.RandChoice([]Direction{horizontal, vertical}) + val1 := utils.RandInt(0, 9) + val2 := utils.RandInt(0, 9-size) + if dir == horizontal { + val1, val2 = val2, val1 + } + out = newShip(name, val1, val2, size, dir, reversed) + return + } + for _, i := range []int{0, 1, 2, 3, 4} { // iterate 5 times (for each boat) + names := []string{"carrier", "battleship", "cruiser", "submarine", "destroyer"} + sizes := []int{5, 4, 3, 3, 2} // respective boat size + for { + tmpShip := genTmpShip(names[i], sizes[i]) + // If boat collide with another boat, we need to generate a new position for that boat + if c.collide(tmpShip) { + continue + } + // boat position is valid, assign it + switch i { + case 0: + c.carrier = tmpShip + case 1: + c.battleShip = tmpShip + case 2: + c.cruiser = tmpShip + case 3: + c.submarine = tmpShip + case 4: + c.destroyer = tmpShip + } + break + } + } + return c +} + +func (g *BSGame) drawCardFor(tmp int, roomName string, isNewGame, shipDead, gameEnded bool, shipStr, pos string) (out string) { + you := g.player1 + enemy := g.player2 + if tmp == 1 { + you = g.player2 + enemy = g.player1 + } + + imgB64Fn := func(myCard bool) string { + ent1 := enemy + ent2 := you + if myCard { + ent1 = you + ent2 = enemy + } + + c := gg.NewContext(177, 177) + + c.Push() + c.SetColor(color.White) + c.DrawRectangle(0, 0, 177, 177) + c.Fill() + c.Pop() + + c.Push() + c.SetColor(color.Black) + x := 22.0 + y := 13.0 + c.DrawString("0", x, y) + c.DrawString("1", x+16, y) + c.DrawString("2", x+16+16, y) + c.DrawString("3", x+16+16+16, y) + c.DrawString("4", x+16+16+16+16, y) + c.DrawString("5", x+16+16+16+16+16, y) + c.DrawString("6", x+16+16+16+16+16+16, y) + c.DrawString("7", x+16+16+16+16+16+16+16, y) + c.DrawString("8", x+16+16+16+16+16+16+16+16, y) + c.DrawString("9", x+16+16+16+16+16+16+16+16+16, y) + x = 6 + y = 29.0 + c.DrawString("A", x, y) + c.DrawString("B", x, y+16) + c.DrawString("C", x, y+16+16) + c.DrawString("D", x, y+16+16+16) + c.DrawString("E", x, y+16+16+16+16) + c.DrawString("F", x, y+16+16+16+16+16) + c.DrawString("G", x, y+16+16+16+16+16+16) + c.DrawString("H", x, y+16+16+16+16+16+16+16) + c.DrawString("I", x, y+16+16+16+16+16+16+16+16) + c.DrawString("J", x, y+16+16+16+16+16+16+16+16+16) + c.Pop() + + c.Push() + c.SetLineWidth(1) + c.SetColor(color.RGBA{R: 90, G: 90, B: 90, A: 255}) + for col := 0.0; col < 12; col++ { + c.MoveTo(0.5+col*16, 0) + c.LineTo(0.5+col*16, 176) + c.Stroke() + } + for row := 0.0; row < 12; row++ { + c.MoveTo(0, 0.5+row*16) + c.LineTo(176, 0.5+row*16) + c.Stroke() + } + c.Pop() + + drawShip := func(s BSShip) { + if !myCard && s.health != 0 && !gameEnded { + return + } + //fmt.Println(s.name, s.x, s.y, s.direction, s.reversed) + c.Push() + c.Translate(0.5, 0.5) + c.Translate(16, 16) + c.Translate(float64(s.x)*16, float64(s.y)*16) + if s.direction == horizontal { + if s.reversed { + c.Translate(float64(s.size)*16, 0) + c.Rotate(gg.Radians(90)) + } else { + c.Translate(0, 16) + c.Rotate(gg.Radians(-90)) + } + } else { + if s.reversed { + c.Translate(16, float64(s.size)*16) + c.Rotate(gg.Radians(180)) + } + } + // Front of the ship + c.MoveTo(1, 11) + c.QuadraticTo(8, -10, 15, 11) + // Length of the ship + c.Translate(0, float64(s.size-1)*16) + // back of the ship + c.LineTo(15, 11) + c.QuadraticTo(8, 17, 1, 11) + c.ClosePath() + if s.health == 0 { + c.SetColor(color.RGBA{R: 100, G: 100, B: 100, A: 200}) + c.Fill() + } else if !myCard && gameEnded { + c.SetColor(color.RGBA{R: 100, G: 130, B: 100, A: 200}) + c.Fill() + } else { + c.SetColor(color.RGBA{R: 100, G: 100, B: 100, A: 255}) + c.Fill() + } + c.Pop() + } + drawShip(ent1.card.carrier) + drawShip(ent1.card.battleShip) + drawShip(ent1.card.cruiser) + drawShip(ent1.card.submarine) + drawShip(ent1.card.destroyer) + + c.Push() + c.Translate(0.5, 0.5) + c.Translate(16, 16) + for shot := range ent2.shots { + shotRow := shot / 10 + shotCol := shot % 10 + c.Push() + c.Translate(float64(shotCol)*16, float64(shotRow)*16) + + if ent1.card.hasShipAt(shot) { + c.DrawCircle(8, 8, 4) + c.SetColor(color.RGBA{R: 255, G: 200, B: 0, A: 255}) + c.Fill() + + c.DrawCircle(8, 8, 3) + c.SetColor(color.RGBA{R: 255, G: 0, B: 0, A: 255}) + c.Fill() + } else { + c.DrawCircle(8, 8, 3) + c.SetColor(color.RGBA{R: 60, G: 200, B: 0, A: 255}) + c.Fill() + + c.DrawCircle(8, 8, 2) + c.SetColor(color.RGBA{R: 100, G: 0, B: 0, A: 255}) + c.Fill() + } + + c.Pop() + } + c.Pop() + + var buf bytes.Buffer + _ = c.EncodePNG(&buf) + imgB64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + return imgB64 + } + + imgB64 := imgB64Fn(true) + img1B64 := imgB64Fn(false) + + htmlTmpl := ` +Against <span {{ .EnemyUserStyle | HTMLAttr }}>@{{ .EnemyUsername }}</span><br /> +{{ if not .IsNewGame }} + {{ if .YourTurn }} + <span {{ .EnemyUserStyle | HTMLAttr }}>@{{ .EnemyUsername }}</span> played {{ .Pos }} + {{ else }} + you played {{ .Pos }} + {{ end }} + ; + {{ if .ShipStr }} + {{ .ShipStr }} hit + {{ if .ShipDead }} + and sunk + {{ end }} + {{ else }} + miss + {{ end }} + ; +{{ end }} +{{ if .GameEnded }} + {{ if .YourTurn }} + You lost!<br /> + {{ else }} + You win!<br /> + {{ end }} +{{ else }} + {{ if .YourTurn }} + now is your turn<br /> + {{ else }} + waiting for opponent<br /> + {{ end }} +{{ end }} +<table> + <tr> + <td><img src="data:image/png;base64,{{ .ImgB64 }}" alt="" /></td> + <td style="vertical-align: top;"> + <form method="post" style="margin-left: 10px;" action="/api/v1/battleship"> + <input type="hidden" name="room" value="{{ .RoomName }}" /> + <input type="hidden" name="enemyUsername" value="{{ .EnemyUsername }}" /> + <table style="width: 177px; height: 177px; background-image: url(data:image/png;base64,{{ .Img1B64 }})"> + <tr style="height: 16px;"><td colspan="11">&nbsp;</td></tr> + {{- range $row := .Rows -}} + <tr style="height: 16px;"> + <td style="width: 16px;"></td> + {{- range $col := $.Cols -}} + {{- if NotShot $row $col -}} + {{- if and $.YourTurn (not $.GameEnded) -}} + <td style="width: 16px;"> + <button style="height: 15px; width: 15px;" name="move" value="{{ GetRune $row }}{{ $col }}"></button> + </td> + {{- else -}} + <td style="width: 16px;"></td> + {{- end -}} + {{- else -}} + <td style="width: 16px;"></td> + {{- end -}} + {{- end -}} + </tr> + {{- end -}} + </table> + </form> + </td> + </tr> +</table> +` + data := map[string]any{ + "RoomName": roomName, + "EnemyUserStyle": enemy.userStyle, + "EnemyUsername": enemy.username, + "IsNewGame": isNewGame, + "YourTurn": g.turn == tmp, + "Pos": pos, + "ShipStr": shipStr, + "ShipDead": shipDead, + "GameEnded": gameEnded, + "ImgB64": imgB64, + "Img1B64": img1B64, + "Rows": []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + "Cols": []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + } + fns := template.FuncMap{ + "GetRune": func(i int) string { + return string(rune('A' + i)) + }, + "NotShot": func(i, j int) bool { + _, ok := you.shots[i*10+j] + return !ok + }, + "HTMLAttr": func(in string) template.HTMLAttr { + return template.HTMLAttr(in) + }, + } + var buf bytes.Buffer + _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data) + return buf.String() +} + +func (b *Battleship) InterceptMsg(cmd *Command) { + m := bsRgx.FindStringSubmatch(cmd.message) + if len(m) != 3 { + return + } + enemyUsername := database.Username(m[1]) + pos := m[2] + if err := b.PlayMove(cmd.room.Name, cmd.room.ID, cmd.roomKey, *cmd.authUser, enemyUsername, pos); err != nil { + cmd.err = err + return + } + cmd.err = ErrStop + return +} + +func (b *Battleship) PlayMove(roomName string, roomID database.RoomID, roomKey string, authUser database.User, enemyUsername database.Username, pos string) error { + b.Lock() + defer b.Unlock() + + user, err := b.db.GetUserByUsername(enemyUsername) + if err != nil { + return errors.New("invalid username") + } + + var gameKey string + if authUser.ID < user.ID { + gameKey = fmt.Sprintf("%d_%d", authUser.ID, user.ID) + } else { + gameKey = fmt.Sprintf("%d_%d", user.ID, authUser.ID) + } + + var shipStr string + var isNewGame, shipDead, gameEnded bool + g, ok := b.games[gameKey] + if ok { + if !g.IsPlayerTurn(authUser.ID) { + return errors.New("not your turn") + } + shipStr, shipDead, gameEnded, err = g.Shot(pos) + if err != nil { + return err + } + } else { + if pos != "" { + return errors.New("no Game ongoing") + } + g = newGame(user, authUser) + b.games[gameKey] = g + isNewGame = true + } + + // Delete old messages sent by "0" to the players + if err := b.db.DB(). + Where("room_id = ? AND user_id = ? AND (to_user_id = ? OR to_user_id = ?)", roomID, b.zeroID, g.player1.id, g.player2.id). + Delete(&database.ChatMessage{}).Error; err != nil { + logrus.Error(err) + } + + card1 := g.drawCardFor(0, roomName, isNewGame, shipDead, gameEnded, shipStr, pos) + _, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.player1.id) + + card2 := g.drawCardFor(1, roomName, isNewGame, shipDead, gameEnded, shipStr, pos) + _, _ = b.db.CreateMsg(card2, card2, roomKey, roomID, b.zeroID, &g.player2.id) + + if gameEnded { + delete(b.games, gameKey) + } + + return nil +} diff --git a/pkg/web/handlers/api/v1/interceptors/chess.go b/pkg/web/handlers/api/v1/interceptors/chess.go @@ -0,0 +1,578 @@ +package interceptors + +import ( + "bytes" + "dkforest/bindata" + "dkforest/pkg/config" + "dkforest/pkg/database" + dutils "dkforest/pkg/database/utils" + "dkforest/pkg/pubsub" + "dkforest/pkg/utils" + "encoding/base64" + "errors" + "fmt" + "github.com/fogleman/gg" + "github.com/google/uuid" + "github.com/labstack/echo" + "github.com/notnil/chess" + "github.com/sirupsen/logrus" + "html/template" + "image" + "image/color" + "image/png" + "sort" + "strconv" + "sync" + "time" +) + +type ChessPlayer struct { + ID database.UserID + Username database.Username + UserStyle string + NotifyChessMove bool +} + +type ChessGame struct { + Key string + Game *chess.Game + lastUpdated time.Time + Player1 *ChessPlayer + Player2 *ChessPlayer + CreatedAt time.Time +} + +func newChessPlayer(player database.User) *ChessPlayer { + p := new(ChessPlayer) + p.ID = player.ID + p.Username = player.Username + p.UserStyle = player.GenerateChatStyle() + p.NotifyChessMove = player.NotifyChessMove + return p +} + +func newChessGame(gameKey string, player1, player2 database.User) *ChessGame { + g := new(ChessGame) + g.CreatedAt = time.Now() + g.Key = gameKey + g.Game = chess.NewGame() + g.lastUpdated = time.Now() + g.Player1 = newChessPlayer(player1) + g.Player2 = newChessPlayer(player2) + return g +} + +type Chess struct { + sync.Mutex + db *database.DkfDB + zeroID database.UserID + games map[string]*ChessGame +} + +func NewChess(db *database.DkfDB) *Chess { + zeroUser, _ := db.GetUserByUsername(config.NullUsername) + c := &Chess{db: db, zeroID: zeroUser.ID} + c.games = make(map[string]*ChessGame) + + // Thread that cleanup inactive games + go func() { + for { + time.Sleep(15 * time.Minute) + c.Lock() + for k, g := range c.games { + if time.Since(g.lastUpdated) > 3*time.Hour { + delete(c.games, k) + } + } + c.Unlock() + } + }() + + return c +} + +var ChessInstance *Chess + +const ( + sqSize = 45 + boardSize = 8 * sqSize +) + +func renderBoardPng(last *chess.Move, position *chess.Position, isFlipped bool) image.Image { + boardMap := position.Board().SquareMap() + ctx := gg.NewContext(boardSize, boardSize) + for i := 0; i < 64; i++ { + sq := chess.Square(i) + sqPiece := boardMap[sq] + renderSquare(ctx, sq, last, position.Turn(), sqPiece, isFlipped) + } + return ctx.Image() +} + +func XyForSquare(isFlipped bool, sq chess.Square) (x, y int) { + fileIndex := int(sq.File()) + rankIndex := 7 - int(sq.Rank()) + x = fileIndex * sqSize + y = rankIndex * sqSize + if isFlipped { + x = boardSize - x - sqSize + y = boardSize - y - sqSize + } + return +} + +func colorForSquare(sq chess.Square) color.RGBA { + sqSum := int(sq.File()) + int(sq.Rank()) + if sqSum%2 == 0 { + return color.RGBA{R: 165, G: 117, B: 81, A: 255} + } + return color.RGBA{R: 235, G: 209, B: 166, A: 255} +} + +func renderSquare(ctx *gg.Context, sq chess.Square, last *chess.Move, turn chess.Color, sqPiece chess.Piece, isFlipped bool) { + x, y := XyForSquare(isFlipped, sq) + // draw square + ctx.Push() + ctx.SetColor(colorForSquare(sq)) + ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize) + ctx.Fill() + ctx.Pop() + // Draw previous move + if last != nil { + if last.S1() == sq || last.S2() == sq { + ctx.Push() + ctx.SetRGBA(0, 1, 0, 0.1) + ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize) + ctx.Fill() + ctx.Pop() + } + // Draw check + p := sqPiece + if p != chess.NoPiece { + if p.Type() == chess.King && p.Color() == turn && last.HasTag(chess.Check) { + ctx.Push() + ctx.SetRGBA(1, 0, 0, 0.4) + ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize) + ctx.Fill() + ctx.Pop() + } + } + } + + ctx.Push() + ctx.SetColor(color.RGBA{R: 0, G: 0, B: 0, A: 180}) + if (!isFlipped && sq.Rank() == chess.Rank1) || (isFlipped && sq.Rank() == chess.Rank8) { + ctx.DrawString(sq.File().String(), float64(x+sqSize-7), float64(y+sqSize-1)) + } + if (!isFlipped && sq.File() == chess.FileA) || (isFlipped && sq.File() == chess.FileH) { + ctx.DrawString(sq.Rank().String(), float64(x+1), float64(y+11)) + } + ctx.Pop() + + // draw piece + p := sqPiece + if p != chess.NoPiece { + img := getFile("img/chess/" + p.Color().String() + pieceTypeMap[p.Type()] + ".png") + ctx.Push() + ctx.DrawImage(img, x, y) + ctx.Pop() + } +} + +var pieceTypeMap = map[chess.PieceType]string{ + chess.King: "K", + chess.Queen: "Q", + chess.Rook: "R", + chess.Bishop: "B", + chess.Knight: "N", + chess.Pawn: "P", +} + +var cache = make(map[string]image.Image) + +func getFile(fileName string) image.Image { + if img, ok := cache[fileName]; ok { + return img + } + fileBy := bindata.MustAsset(fileName) + img, _ := png.Decode(bytes.NewReader(fileBy)) + cache[fileName] = img + return img +} + +func renderTable(imgB64 string, isBlack bool) string { + htmlTmpl := ` +<style> +input[type=checkbox] { + display:none; +} +input[type=checkbox] + label { + display: inline-block; + padding: 0 0 0 0; + margin: 0 0 0 0; + height: 39px; + width: 39px; + background-size: 100%; + border: 3px solid transparent; +} +input[type=checkbox]:checked + label { + display: inline-block; + background-size: 100%; + border: 3px solid red; +} +</style> + +<table style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"> + {{ range $row := .Rows }} + <tr> + {{ range $col := $.Cols }} + {{ $id := GetID $row $col }} + <td> + <input name="sq_{{ $id }}" ID="sq_{{ $id }}" type="checkbox" value="1" /> + <label for="sq_{{ $id }}"></label> + </td> + {{ end }} + </tr> + {{ end }} +</table> +` + data := map[string]any{ + "ImgB64": imgB64, + "Rows": []int{0, 1, 2, 3, 4, 5, 6, 7}, + "Cols": []int{0, 1, 2, 3, 4, 5, 6, 7}, + } + + fns := template.FuncMap{ + "GetID": func(row, col int) int { + var id int + if isBlack { + id = row*8 + (7 - col) + } else { + id = (7-row)*8 + col + } + return id + }, + } + + var buf bytes.Buffer + _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data) + return buf.String() +} + +func (g *ChessGame) renderBoardB64(isFlipped bool) string { + position := g.Game.Position() + moves := g.Game.Moves() + var last *chess.Move + if len(moves) > 0 { + last = moves[len(moves)-1] + } + var buf bytes.Buffer + img := renderBoardPng(last, position, isFlipped) + _ = png.Encode(&buf, img) + imgB64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + return imgB64 +} + +func (g *ChessGame) DrawPlayerCard(isBlack, isYourTurn bool) string { + return g.drawPlayerCard("", isBlack, isYourTurn) +} + +func (g *ChessGame) drawPlayerCard(roomName string, isBlack, isYourTurn bool) string { + enemy := utils.Ternary(isBlack, g.Player1, g.Player2) + + imgB64 := g.renderBoardB64(isBlack) + + htmlTmpl := ` +<div style="margin: 0 auto; width: 360px;"> + <div style="color: #eee;"> + <span {{ .White.UserStyle | attr }}>@{{ .White.Username }}</span> (white) VS + <span {{ .Black.UserStyle | attr }}>@{{ .Black.Username }}</span> (black) + </div> + + {{ if .GameOver }} + <div style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"></div> + {{ else }} + <form method="post"> + <input type="hidden" name="message" value="resign" /> + <button type="submit" style="background-color: #aaa; margin: 5px 0;">Resign</button> + </form> + {{ if .IsYourTurn }} + <form method="post"{{ if .InChat }} action="/api/v1/chess"{{ end }}> + {{ .Table }} + {{ if .InChat }} + <input type="hidden" name="room" value="{{ .RoomName }}" /> + <input type="hidden" name="enemyUsername" value="{{ .Username }}" /> + <input type="hidden" name="move" value="move" /> + {{ else }} + <input type="hidden" name="message" value="/pm {{ .Username }} /c move" /> + {{ end }} + <div style="width: 100%; display: flex; margin: 5px 0;"> + <div> + <button type="submit" style="background-color: #aaa;">Move</button> + </div> + <div style="margin-left: auto;"> + <span style="color: #aaa; margin-left: 20px;">Promo:</span> + <select name="promotion" style="background-color: #aaa;"> + <option value="queen">Queen</option> + <option value="rook">Rook</option> + <option value="knight">Knight</option> + <option value="bishop">Bishop</option> + </select> + </div> + </div> + </form> + {{ else }} + <div style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"></div> + {{ end }} + {{ end }} + <div style="color: #eee;">Outcome: {{ .Outcome }}</div> + + {{ if .GameOver }} + <div><textarea>{{ .PGN }}</textarea></div> + {{ end }} +</div> +` + + data := map[string]any{ + "RoomName": roomName, + "IsYourTurn": isYourTurn, + "InChat": roomName != "", + "White": g.Player1, + "Black": g.Player2, + "Username": enemy.Username, + "Table": template.HTML(renderTable(imgB64, isBlack)), + "ImgB64": imgB64, + "Outcome": g.Game.Outcome().String(), + "GameOver": g.Game.Outcome() != chess.NoOutcome, + "PGN": g.Game.String(), + } + + fns := template.FuncMap{ + "attr": func(s string) template.HTMLAttr { + return template.HTMLAttr(s) + }, + } + + var buf1 bytes.Buffer + _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data) + return buf1.String() +} + +func (g *ChessGame) DrawSpectatorCard(isFlipped bool) string { + imgB64 := g.renderBoardB64(isFlipped) + + htmlTmpl := ` +<div style="margin: 0 auto; width: 360px;"> + <div style="color: #eee;"> + <span {{ .White.UserStyle | attr }}>@{{ .White.Username }}</span> (white) VS + <span {{ .Black.UserStyle | attr }}>@{{ .Black.Username }}</span> (black) + </div> + <div style="width: 360px; height: 360px; background-image: url(data:image/png;base64,{{ .ImgB64 }})"></div> + <div style="color: #eee;">Outcome: {{ .Outcome }}</div> + {{ if .GameOver }} + <div><textarea>{{ .PGN }}</textarea></div> + {{ end }} +</div> +` + + data := map[string]any{ + "White": g.Player1, + "Black": g.Player2, + "ImgB64": imgB64, + "Outcome": g.Game.Outcome().String(), + "GameOver": g.Game.Outcome() != chess.NoOutcome, + "PGN": g.Game.String(), + } + + fns := template.FuncMap{ + "attr": func(s string) template.HTMLAttr { + return template.HTMLAttr(s) + }, + } + + var buf1 bytes.Buffer + _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data) + return buf1.String() +} + +func (b *Chess) GetGame(key string) *ChessGame { + b.Lock() + defer b.Unlock() + if g, ok := b.games[key]; ok { + return g + } + return nil +} + +func (b *Chess) GetGames() (out []ChessGame) { + b.Lock() + defer b.Unlock() + for _, v := range b.games { + out = append(out, *v) + } + sort.Slice(out, func(i, j int) bool { + return out[i].CreatedAt.After(out[j].CreatedAt) + }) + return +} + +func (b *Chess) NewGame1(roomKey string, roomID database.RoomID, player1, player2 database.User) (*ChessGame, error) { + if player1.ID == player2.ID { + return nil, errors.New("can't play yourself") + } + + key := uuid.New().String() + g := b.NewGame(key, player1, player2) + + zeroUser := dutils.GetZeroUser(b.db) + dutils.SendNewChessGameMessages(b.db, key, roomKey, roomID, zeroUser, player1, player2) + return g, nil +} + +func (b *Chess) NewGame(gameKey string, user1, user2 database.User) *ChessGame { + g := newChessGame(gameKey, user1, user2) + b.Lock() + b.games[gameKey] = g + b.Unlock() + return g +} + +func (b *Chess) newGame(gameKey string, user1, user2 database.User) *ChessGame { + g := newChessGame(gameKey, user1, user2) + b.games[gameKey] = g + return g +} + +func (b *Chess) SendMove(gameKey string, userID database.UserID, g *ChessGame, c echo.Context) error { + if (g.Game.Position().Turn() == chess.White && userID != g.Player1.ID) || + (g.Game.Position().Turn() == chess.Black && userID != g.Player2.ID) { + return errors.New("not your turn") + } + + you := g.Player2 + opponent := g.Player1 + if g.Game.Position().Turn() == chess.White { + you = g.Player1 + opponent = g.Player2 + } + + selectedSquares := make([]chess.Square, 0) + for i := 0; i < 64; i++ { + if utils.DoParseBool(c.Request().PostFormValue("sq_" + strconv.Itoa(i))) { + selectedSquares = append(selectedSquares, chess.Square(i)) + } + } + + if len(selectedSquares) != 2 { + return errors.New("must select 2 squares") + } + + promo := chess.Queen + switch c.Request().PostFormValue("promotion") { + case "queen": + promo = chess.Queen + case "rook": + promo = chess.Rook + case "knight": + promo = chess.Knight + case "bishop": + promo = chess.Bishop + } + + var moveStr string + validMoves := g.Game.Position().ValidMoves() + var found bool + for _, move := range validMoves { + if (move.S1() == selectedSquares[0] && move.S2() == selectedSquares[1] && (move.Promo() == chess.NoPieceType || move.Promo() == promo)) || + (move.S1() == selectedSquares[1] && move.S2() == selectedSquares[0] && (move.Promo() == chess.NoPieceType || move.Promo() == promo)) { + moveStr = chess.AlgebraicNotation{}.Encode(g.Game.Position(), move) + found = true + break + } + } + + if !found { + return fmt.Errorf("invalid move %s %s", selectedSquares[0], selectedSquares[1]) + } + + _ = g.Game.MoveStr(moveStr) + g.lastUpdated = time.Now() + if g.Game.Outcome() != chess.NoOutcome { + //delete(b.games, gameKey) + } + + ChessPubSub.Pub(gameKey, true) + + // Notify (pm) the opponent that you made a move + if opponent.NotifyChessMove { + msg := fmt.Sprintf("@%s played %s", you.Username, moveStr) + msg, _ = colorifyTaggedUsers(msg, b.db.GetUsersByUsername) + chatMsg, _ := b.db.CreateMsg(msg, msg, "", config.GeneralRoomID, b.zeroID, &opponent.ID) + go func() { + time.Sleep(30 * time.Second) + _ = chatMsg.Delete(b.db) + }() + } + + return nil +} + +var ChessPubSub = pubsub.NewPubSub[bool]() + +func (b *Chess) InterceptMsg(cmd *Command) { + m := cRgx.FindStringSubmatch(cmd.message) + if len(m) != 3 { + return + } + enemyUsername := database.Username(m[1]) + pos := m[2] + if err := b.PlayMove(enemyUsername, pos, *cmd.authUser, cmd.c, cmd.room.Name, cmd.roomKey, cmd.room.ID); err != nil { + cmd.err = err + return + } + cmd.err = ErrStop +} + +func (b *Chess) PlayMove(enemyUsername database.Username, pos string, authUser database.User, c echo.Context, roomName, roomKey string, roomID database.RoomID) error { + b.Lock() + defer b.Unlock() + + user, err := b.db.GetUserByUsername(enemyUsername) + if err != nil { + return errors.New("invalid username") + } + + var gameKey string + if authUser.ID < user.ID { + gameKey = fmt.Sprintf("%d_%d", authUser.ID, user.ID) + } else { + gameKey = fmt.Sprintf("%d_%d", user.ID, authUser.ID) + } + + g, ok := b.games[gameKey] + if ok { + if err := b.SendMove(gameKey, authUser.ID, g, c); err != nil { + return err + } + } else { + if pos != "" { + return errors.New("no Game ongoing") + } + g = b.newGame(gameKey, user, authUser) + } + + // Delete old messages sent by "0" to the players + if err := b.db.DB(). + Where("room_id = ? AND user_id = ? AND (to_user_id = ? OR to_user_id = ?)", roomID, b.zeroID, g.Player1.ID, g.Player2.ID). + Delete(&database.ChatMessage{}).Error; err != nil { + logrus.Error(err) + } + + card1 := g.drawPlayerCard(roomName, false, true) + _, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.Player1.ID) + + card1 = g.drawPlayerCard(roomName, true, true) + _, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.Player2.ID) + + return nil +} diff --git a/pkg/web/handlers/api/v1/interceptors/codeModalInterceptor.go b/pkg/web/handlers/api/v1/interceptors/codeModalInterceptor.go @@ -0,0 +1,51 @@ +package interceptors + +import ( + "dkforest/pkg/database" + "dkforest/pkg/utils" +) + +type CodeModalInterceptor struct{} + +func (i CodeModalInterceptor) InterceptMsg(cmd *Command) { + sender := cmd.c.Request().PostFormValue("sender") + lang := cmd.c.Request().PostFormValue("lang") + isMod := utils.DoParseBool(cmd.c.Request().PostFormValue("isMod")) + pm := cmd.c.Request().PostFormValue("pm") + + if !cmd.authUser.CanUseMultiline || sender != "codeModal" { + return + } + + database.MsgPubSub.Pub("modal_code_hide_"+cmd.authUser.ID.String()+"_"+cmd.room.ID.String(), database.ChatMessageType{}) + + cmd.modMsg = isMod + if pm != "" { + toUser, err := cmd.db.GetUserByUsername(database.Username(pm)) + if err != nil { + cmd.err = ErrRedirect + return + } + cmd.skipInboxes, cmd.err = canUserPmOther(cmd.db, *cmd.authUser, toUser, cmd.room.IsOwned()) + if cmd.err != nil { + return + } + cmd.toUser = &toUser + cmd.redirectQP.Set(RedirectPmQP, string(toUser.Username)) + } + + if cmd.origMessage == "" { + cmd.err = ErrRedirect + return + } + + if !utils.InArr(lang, []string{"go", "c", "cpp", "py", "js", "php", "css", "sql", "rs", "c#", "rb", "html", "bash"}) { + lang = "" + } + cmd.origMessage = codeFenceWrap(lang, cmd.origMessage) + cmd.message = codeFenceWrap(lang, cmd.message) +} + +func codeFenceWrap(lang, msg string) string { + return "\n```" + lang + "\n" + msg + "\n```\n" +} diff --git a/pkg/web/handlers/api/v1/interceptors/interceptor.go b/pkg/web/handlers/api/v1/interceptors/interceptor.go @@ -0,0 +1,150 @@ +package interceptors + +import ( + "dkforest/pkg/database" + dutils "dkforest/pkg/database/utils" + "errors" + "fmt" + "github.com/labstack/echo" + "net/url" +) + +var ErrRedirect = errors.New("redirect") +var ErrStop = errors.New("stop") + +const ( + RedirectPmQP = "pm" + RedirectEditQP = "e" + RedirectGroupQP = "g" + RedirectModQP = "m" + RedirectHbmQP = "hbm" + RedirectTagQP = "tag" + RedirectHTagQP = "htag" + RedirectMTagQP = "mtag" + RedirectQuoteQP = "quote" + RedirectMultilineQP = "ml" +) + +type Interceptor interface { + InterceptMsg(*Command) +} + +type Command struct { + err error + + // Data that can be mutated + redirectQP url.Values // RedirectURL Query Parameters + origMessage string // This is the original text that the user input (can be changed by /e) + dataMessage string // This is what the user will have in his input box + message string // Un-sanitized message received from the user + room database.ChatRoom // Room the user is in + roomKey string // Room password (if any) + authUser *database.User // Authenticated user (sender of the message) + db *database.DkfDB // Database instance + toUser *database.User // If not nil, will be a PM + upload *database.Upload // If the message contains an uploaded file + editMsg *database.ChatMessage // If we're editing a message + groupID *database.GroupID // If the message is for a subgroup + hellbanMsg bool // Is the message will be marked HB + systemMsg bool // Is the message system + modMsg bool // Is the message part of the "moderators" group + c echo.Context + zeroUser *database.User // Cache the zero (@0) user + skipInboxes bool +} + +func (c *Command) Err() error { + return c.err +} + +func (c *Command) OrigMessage() string { + return c.origMessage +} + +func (c *Command) DataMessage() string { + return c.dataMessage +} + +func (c *Command) SetRedirectQP(v url.Values) { + c.redirectQP = v +} + +func NewCommand(c echo.Context, origMessage string, room database.ChatRoom, roomKey string) *Command { + authUser := c.Get("authUser").(*database.User) + db := c.Get("database").(*database.DkfDB) + return &Command{ + c: c, + authUser: authUser, + db: db, + hellbanMsg: authUser.IsHellbanned, + redirectQP: url.Values{}, + origMessage: origMessage, + message: origMessage, + room: room, + roomKey: roomKey, + } +} + +func (c *Command) RedirectURL() string { + return fmt.Sprintf("/api/v1/chat/top-bar/%s?%s", c.room.Name, c.redirectQP.Encode()) +} + +// Lazy loading and cache of the zero user +func (c *Command) getZeroUser() database.User { + if c.zeroUser == nil { + zeroUser := dutils.GetZeroUser(c.db) + c.zeroUser = &zeroUser + } + return *c.zeroUser +} + +// Have the "zero user" send a processed message to the authUser +func (c *Command) zeroProcMsg(rawMsg string) { + c.zeroProcMsgRoom(rawMsg, c.roomKey, c.room.ID) +} + +// Have the "zero user" send a processed message in the specified room +func (c *Command) zeroPublicProcMsgRoom(rawMsg, roomKey string, roomID database.RoomID) { + c.zeroProcMsgRoomToUser(rawMsg, roomKey, roomID, nil) +} + +// Have the "zero user" send a processed message to the authUser in the specified room +func (c *Command) zeroProcMsgRoom(rawMsg, roomKey string, roomID database.RoomID) { + c.zeroProcMsgRoomToUser(rawMsg, roomKey, roomID, c.authUser) +} + +// Have the "zero user" send a "processed message" PM to a user in a specific room. +func (c *Command) zeroProcMsgRoomToUser(rawMsg, roomKey string, roomID database.RoomID, toUser *database.User) { + procMsg, _, _ := ProcessRawMessage(c.db, rawMsg, roomKey, c.authUser.ID, roomID, nil, true) + c.zeroRawMsg(toUser, rawMsg, procMsg) +} + +// Have the "zero usser" send an unprocessed private message to the authUser +func (c *Command) zeroMsg(msg string) { + c.zeroRawMsg(c.authUser, msg, msg) +} + +// Have the "zero usser" send an unprocessed message in the current room +func (c *Command) zeroPublicMsg(raw, msg string) { + c.zeroRawMsg(nil, raw, msg) +} + +func (c *Command) zeroRawMsg(user2 *database.User, raw, msg string) { + zeroUser := c.getZeroUser() + c.rawMsg(zeroUser, user2, raw, msg) +} + +func (c *Command) rawMsg(user1 database.User, user2 *database.User, raw, msg string) { + if c.room.ReadOnly { + return + } + rawMsgRoom(c.db, user1, user2, raw, msg, c.roomKey, c.room.ID) +} + +func rawMsgRoom(db *database.DkfDB, user1 database.User, user2 *database.User, raw, msg, roomKey string, roomID database.RoomID) { + var toUserID *database.UserID + if user2 != nil { + toUserID = &user2.ID + } + _, _ = db.CreateMsg(raw, msg, roomKey, roomID, user1.ID, toUserID) +} diff --git a/pkg/web/handlers/api/v1/interceptors/msgInterceptor.go b/pkg/web/handlers/api/v1/interceptors/msgInterceptor.go @@ -0,0 +1,1109 @@ +package interceptors + +import ( + bf "dkforest/pkg/blackfriday/v2" + "dkforest/pkg/clockwork" + "dkforest/pkg/config" + "dkforest/pkg/database" + "dkforest/pkg/hashset" + "dkforest/pkg/levenshtein" + "dkforest/pkg/managers" + "dkforest/pkg/utils" + "errors" + "fmt" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/microcosm-cc/bluemonday" + html2 "html" + "math" + "regexp" + "strings" + "time" +) + +const minMsgLen = 1 +const maxMsgLen = 10000 + +const ( + agePrefix = "-----BEGIN AGE ENCRYPTED FILE-----" + ageSuffix = "-----END AGE ENCRYPTED FILE-----" + pgpPrefix = "-----BEGIN PGP MESSAGE-----" + pgpSuffix = "-----END PGP MESSAGE-----" + pgpPKeyPrefix = "-----BEGIN PGP PUBLIC KEY BLOCK-----" + pgpPKeySuffix = "-----END PGP PUBLIC KEY BLOCK-----" + pgpSignedPrefix = "-----BEGIN PGP SIGNED MESSAGE-----" + pgpSignedSuffix = "-----END PGP SIGNATURE-----" +) + +var emojiReplacer = strings.NewReplacer( + ":):", `<span class="emoji" title=":):">☺</span>`, + ":smile:", `<span class="emoji" title=":smile:">☺</span>`, + ":happy:", `<span class="emoji" title=":happy:">😃</span>`, + ":see-no-evil:", `<span class="emoji" title=":see-no-evil:">🙈</span>`, + ":hear-no-evil:", `<span class="emoji" title=":hear-no-evil:">🙉</span>`, + ":speak-no-evil:", `<span class="emoji" title=":speak-no-evil:">🙊</span>`, + ":poop:", `<span class="emoji" title=":poop:">💩</span>`, + ":+1:", `<span class="emoji" title=":+1:">👍</span>`, + ":evil:", `<span class="emoji" title=":evil:">😈</span>`, + ":cat-happy:", `<span class="emoji" title=":cat-happy:">😸</span>`, + ":eyes:", `<span class="emoji" title=":eyes:">👀</span>`, + ":wave:", `<span class="emoji" title=":wave:">👋</span>`, + ":clap:", `<span class="emoji" title=":clap:">👏</span>`, + ":fire:", `<span class="emoji" title=":fire:">🔥</span>`, + ":sparkles:", `<span class="emoji" title=":sparkles:">✨</span>`, + ":sweat:", `<span class="emoji" title=":sweat:">💦</span>`, + ":heart:", `<span class="emoji" title=":heart:">❤</span>`, + ":broken-heart:", `<span class="emoji" title=":broken-heart:">💔</span>`, + ":zzz:", `<span class="emoji" title=":zzz:">💤</span>`, + ":praise:", `<span class="emoji" title=":praise:">🙌</span>`, + ":joy:", `<span class="emoji" title=":joy:">😂</span>`, + ":sob:", `<span class="emoji" title=":sob:">😭</span>`, + ":scream:", `<span class="emoji" title=":scream:">😱</span>`, + ":heart-eyes:", `<span class="emoji" title=":heart-eyes:">😍</span>`, + ":blush:", `<span class="emoji" title=":blush:">☺</span>`, + ":crazy:", `<span class="emoji" title=":crazy:">😜</span>`, + ":angry:", `<span class="emoji" title=":angry:">😡</span>`, + ":triumph:", `<span class="emoji" title=":triumph:">😤</span>`, + ":vomit:", `<span class="emoji" title=":vomit:">🤮</span>`, + ":skull:", `<span class="emoji" title=":skull:">💀</span>`, + ":alien:", `<span class="emoji" title=":alien:">👽</span>`, + ":sleeping:", `<span class="emoji" title=":sleeping:">😴</span>`, + ":tongue:", `<span class="emoji" title=":tongue:">😛</span>`, + ":cool:", `<span class="emoji" title=":cool:">😎</span>`, + ":wink:", `<span class="emoji" title=":wink:">😉</span>`, + ":thinking:", `<span class="emoji" title=":thinking:">🤔</span>`, + ":happy-sweat:", `<span class="emoji" title=":happy-sweat:">😅</span>`, + ":nerd:", `<span class="emoji" title=":nerd:">🤓</span>`, + ":fox:", `<span class="emoji" title=":fox:">🦊</span>`, + ":popcorn:", `<span class="emoji" title=":popcorn:">🍿</span>`, + ":shrug:", `¯\_(ツ)_/¯`, + ":flip:", `(╯°□°)╯︵ ┻━┻`, + ":flip-all:", `┻━┻︵ \(°□°)/ ︵ ┻━┻`, + ":fix-table:", `(ヘ・_・)ヘ┳━┳`, + ":disap:", `ಠ_ಠ`, +) + +var usernameF = `\w{3,20}` // username (regex Fragment) +var userOr0 = usernameF + `|0` +var groupName = `\w{3,20}` +var roomNameF = `\w{3,50}` +var chatTs = `\d{2}:\d{2}:\d{2}` +var optAtGUser = `@?(` + usernameF + `)` // Optional @, Grouped, Username +var optAtGUserOr0 = `@?(` + userOr0 + `)` // Optional @, Grouped, Username or 0 +var onionV2Rgx = regexp.MustCompile(`[a-z2-7]{16}\.onion`) +var onionV3Rgx = regexp.MustCompile(`[a-z2-7]{56}\.onion`) +var deleteMsgRgx = regexp.MustCompile(`^/d (\d{2}:\d{2}:\d{2})(?:\s` + optAtGUserOr0 + `)?$`) +var ignoreRgx = regexp.MustCompile(`^/(?:ignore|i) ` + optAtGUser) +var pmToggleWhitelistUserRgx = regexp.MustCompile(`^/pmw ` + optAtGUser) +var pmToggleBlacklistUserRgx = regexp.MustCompile(`^/pmb ` + optAtGUser) +var whitelistUserRgx = regexp.MustCompile(`^/(?:whitelist|wl) ` + optAtGUser) +var unIgnoreRgx = regexp.MustCompile(`^/(?:unignore|ui) ` + optAtGUser) +var groupRgx = regexp.MustCompile(`^/g (` + groupName + `)\s(?s:(.*))`) +var pmRgx = regexp.MustCompile(`^/pm ` + optAtGUserOr0 + `(?:\s(?s:(.*)))?`) +var editRgx = regexp.MustCompile(`^/e (` + chatTs + `)\s(?s:(.*))`) +var hbmtRgx = regexp.MustCompile(`^/hbmt (` + chatTs + `)$`) +var chessRgx = regexp.MustCompile(`^/chess ` + optAtGUser + `(?:\s(w|b|r))?`) +var inboxRgx = regexp.MustCompile(`^/inbox ` + optAtGUser + `(\s-e)?\s(?s:(.*))`) +var purgeRgx = regexp.MustCompile(`^/purge(\s-hb)? ` + optAtGUserOr0) +var renameRgx = regexp.MustCompile(`^/rename ` + optAtGUser + ` ` + optAtGUser) +var profileRgx = regexp.MustCompile(`^/p ` + optAtGUserOr0) +var kickRgx = regexp.MustCompile(`^/(?:kick|k) ` + optAtGUser) +var setUrlRgx = regexp.MustCompile(`^/seturl (.+)`) +var kickKeepRgx = regexp.MustCompile(`^/(?:kk) ` + optAtGUser) +var kickSilentRgx = regexp.MustCompile(`^/(?:ks) ` + optAtGUser) +var kickKeepSilentRgx = regexp.MustCompile(`^/(?:kks) ` + optAtGUser) +var rtutoRgx = regexp.MustCompile(`^/(?:rtuto) ` + optAtGUser) +var logoutRgx = regexp.MustCompile(`^/(?:logout) ` + optAtGUser) +var forceCaptchaRgx = regexp.MustCompile(`^/(?:captcha) ` + optAtGUser) +var unkickRgx = regexp.MustCompile(`^/(?:unkick|uk) ` + optAtGUser) +var hellbanRgx = regexp.MustCompile(`^/(?:hellban|hb) ` + optAtGUser) +var unhellbanRgx = regexp.MustCompile(`^/(?:unhellban|uhb) ` + optAtGUser) +var randRgx = regexp.MustCompile(`^/rand (-?\d+) (-?\d+)$`) +var tokenRgx = regexp.MustCompile(`^/token (\d{1,2})$`) +var snippetRgx = regexp.MustCompile(`!\w{1,20}`) +var tagRgx = regexp.MustCompile(`@(` + userOr0 + `)`) +var autoTagRgx = regexp.MustCompile(`@(\w+)\*`) +var roomTagRgx = regexp.MustCompile(`#(` + roomNameF + `)`) +var tzRgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2} at \d{1,2}\.\d{1,2}\.\d{1,2} (?i)[A|P]M)`) // Screen Shot 2022-02-04 at 11.58.58 PM +var tz1Rgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2} \d{1,2}-\d{1,2}-\d{1,2})`) // Screenshot from 2022-02-04 11-58-58.png +var tz3Rgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2} \d{1,6})`) // Screenshot 2023-05-20 202351.png +var tz4Rgx = regexp.MustCompile(`(\d{4}-\d{1,2}-\d{1,2}_\d{1,2}_\d{1,2}_\d{1,2})`) // Screenshot_2023-05-20_11_13_14.png +var addGroupRgx = regexp.MustCompile(`^/addgroup (` + groupName + `)$`) +var rmGroupRgx = regexp.MustCompile(`^/rmgroup (` + groupName + `)$`) +var lockGroupRgx = regexp.MustCompile(`^/glock (` + groupName + `)$`) +var unlockGroupRgx = regexp.MustCompile(`^/gunlock (` + groupName + `)$`) +var groupUsersRgx = regexp.MustCompile(`^/gusers (` + groupName + `)$`) +var groupAddUserRgx = regexp.MustCompile(`^/gadduser (` + groupName + `) ` + optAtGUser + `$`) +var groupRmUserRgx = regexp.MustCompile(`^/grmuser (` + groupName + `) ` + optAtGUser + `$`) +var unsubscribeRgx = regexp.MustCompile(`^/unsubscribe (` + roomNameF + `)$`) +var bsRgx = regexp.MustCompile(`^/pm ` + optAtGUser + ` /bs\s?([A-J]\d)?$`) +var cRgx = regexp.MustCompile(`^/pm ` + optAtGUser + ` /c\s?(move)?$`) +var hideRgx = regexp.MustCompile(`^/hide (?:“\[)?(\d{2}:\d{2}:\d{2})`) +var unhideRgx = regexp.MustCompile(`^/unhide (\d{2}:\d{2}:\d{2})$`) +var memeRgx = regexp.MustCompile(`^/meme ([a-zA-Z0-9_-]{3,50})$`) +var memeRenameRgx = regexp.MustCompile(`^/meme ([a-zA-Z0-9_-]{3,50}) ([a-zA-Z0-9_-]{3,50})$`) +var memeRemoveRgx = regexp.MustCompile(`^/memerm ([a-zA-Z0-9_-]{3,50})$`) +var memesRgx = regexp.MustCompile(`^/memes$`) + +type MsgInterceptor struct{} + +func (i MsgInterceptor) InterceptMsg(cmd *Command) { + if cmd.room.ReadOnly { + if cmd.room.OwnerUserID != nil && *cmd.room.OwnerUserID != cmd.authUser.ID { + cmd.err = fmt.Errorf("room is read-only") + return + } + } + + // Only check maximum length of message if we are uploading a file + // Trim whitespaces and ensure minimum length + minLen := utils.Ternary(cmd.upload != nil, 0, minMsgLen) + if !utils.ValidateRuneLength(strings.TrimSpace(cmd.message), minLen, maxMsgLen) { + cmd.dataMessage = cmd.origMessage + cmd.err = fmt.Errorf("%d - %d characters", minLen, maxMsgLen) + return + } + + html, taggedUsersIDsMap, err := ProcessRawMessage(cmd.db, cmd.message, cmd.roomKey, cmd.authUser.ID, cmd.room.ID, cmd.upload, cmd.authUser.CanUseMultiline) + if err != nil { + cmd.dataMessage = cmd.origMessage + cmd.err = err + return + } + + if len(strings.TrimSpace(html)) <= len("<p></p>") { + cmd.dataMessage = cmd.origMessage + cmd.err = errors.New("empty message") + return + } + + toUserID := database.UserPtrID(cmd.toUser) + + msgID, _ := cmd.db.CreateOrEditMessage(cmd.editMsg, html, cmd.origMessage, cmd.roomKey, cmd.room.ID, cmd.authUser.ID, toUserID, cmd.upload, cmd.groupID, cmd.hellbanMsg, cmd.modMsg, cmd.systemMsg) + + if !cmd.skipInboxes { + sendInboxes(cmd.db, cmd.room, cmd.authUser, cmd.toUser, msgID, cmd.groupID, html, cmd.modMsg, taggedUsersIDsMap) + } + + // Count public messages in #general room + if cmd.room.ID == config.GeneralRoomID && cmd.toUser == nil { + cmd.authUser.GeneralMessagesCount++ + generalRoomKarma(cmd.db, cmd.authUser) + cmd.authUser.DoSave(cmd.db) + } + + // Update chat read marker + cmd.db.UpdateChatReadMarker(cmd.authUser.ID, cmd.room.ID) + + // Update user activity + isPM := cmd.toUser != nil + updateUserActivity(isPM, cmd.modMsg, cmd.room, cmd.authUser) +} + +func generalRoomKarma(db *database.DkfDB, authUser *database.User) { + // Hellban users ain't getting karma + if authUser.IsHellbanned { + return + } + messagesCount := authUser.GeneralMessagesCount + if messagesCount%100 == 0 { + description := fmt.Sprintf("sent %d messages", messagesCount) + authUser.IncrKarma(db, 1, description) + } else if messagesCount == 20 { + authUser.IncrKarma(db, 1, "first 20 messages sent") + } +} + +var msgPolicy = bluemonday.NewPolicy(). + AllowElements("a", "p", "span", "strong", "del", "code", "pre", "em", "ul", "li", "br", "small", "i"). + AllowAttrs("href", "rel", "target").OnElements("a"). + AllowAttrs("tabindex", "style").OnElements("pre"). + AllowAttrs("style", "class", "title").OnElements("span"). + AllowAttrs("style").OnElements("small") + +// ProcessRawMessage return the new html, and a map of tagged users used for notifications +// This function takes an "unsafe" user input "in", and return html which will be safe to render. +func ProcessRawMessage(db *database.DkfDB, in, roomKey string, authUserID database.UserID, roomID database.RoomID, + upload *database.Upload, canUseMultiline bool) (string, map[database.UserID]database.User, error) { + html, quoted := convertQuote(db, in, roomKey, roomID) // Get raw quote text which is not safe to render + html = convertNewLines(html, canUseMultiline) + html = html2.EscapeString(html) // Makes user input safe to render + // All html generated from this point on shall be safe to render. + html = convertPGPClearsignToFile(db, html, authUserID) + html = convertPGPMessageToFile(db, html, authUserID) + html = convertPGPPublicKeyToFile(db, html, authUserID) + html = convertAgeMessageToFile(db, html, authUserID) + html = convertLinksWithoutScheme(html) + html = convertMarkdown(html) + html = convertBangShortcuts(html) + html = convertArchiveLinks(db, html, roomID, authUserID) + html = convertLinks(html, roomID, db.GetUserByUsername, db.GetLinkByShorthand, db.GetChatMessageByUUID) + html = linkDefaultRooms(html) + html, taggedUsersIDsMap := colorifyTaggedUsers(html, db.GetUsersByUsername) + html = linkRoomTags(db, html) + html = emojiReplacer.Replace(html) + html = styleQuote(html, quoted) + html = appendUploadLink(html, upload) + if quoted != nil { // Add quoted message owner for inboxes + taggedUsersIDsMap[quoted.UserID] = quoted.User + } + html = msgPolicy.Sanitize(html) + return html, taggedUsersIDsMap, nil +} + +func sendInboxes(db *database.DkfDB, room database.ChatRoom, authUser, toUser *database.User, msgID int64, groupID *database.GroupID, html string, modMsg bool, + taggedUsersIDsMap map[database.UserID]database.User) { + // Only have chat inbox for unencrypted messages + if room.IsProtected() { + return + } + // If user is hellbanned, do not send inboxes + if authUser.IsHellbanned { + return + } + // Early return if we don't need to send inboxes + if toUser == nil && len(taggedUsersIDsMap) == 0 { + return + } + + blacklistedBy, _ := db.GetPmBlacklistedByUsers(authUser.ID) + blacklistedByMap := make(map[database.UserID]struct{}) + for _, b := range blacklistedBy { + blacklistedByMap[b.UserID] = struct{}{} + } + + ignoredBy, _ := db.GetIgnoredByUsers(authUser.ID) + ignoredByMap := make(map[database.UserID]struct{}) + for _, b := range ignoredBy { + ignoredByMap[b.UserID] = struct{}{} + } + + sendInbox := func(user database.User, isPM, modCh bool) { + if !managers.ActiveUsers.IsUserActiveInRoom(user.ID, room) || user.AFK { + // Do not send notification if receiver is blacklisting you + if _, ok := blacklistedByMap[user.ID]; ok { + return + } + // Do not send notification if receiver is ignoring you + if _, ok := ignoredByMap[user.ID]; ok { + return + } + db.CreateInboxMessage(html, room.ID, authUser.ID, user.ID, isPM, modCh, &msgID) + } + } + + // If the message is a PM, only notify the receiver, not the tagged people in it. + if toUser != nil { + sendInbox(*toUser, true, false) + } else if room.Name == "moderators" { // Only tags other moderators on "moderators" room + for _, user := range taggedUsersIDsMap { + if user.IsModerator() { + sendInbox(user, false, false) + } + } + } else if modMsg { // Only tags other moderators on /m messages + for _, user := range taggedUsersIDsMap { + if user.IsModerator() { + sendInbox(user, false, true) + } + } + } else if groupID != nil { // Only tags other people in the group + for _, user := range taggedUsersIDsMap { + if db.IsUserInGroupByID(user.ID, *groupID) { + sendInbox(user, false, false) + } + } + } else { // Otherwise, notify tagged people + for _, user := range taggedUsersIDsMap { + sendInbox(user, false, false) + } + } +} + +func updateUserActivity(isPM, modMsg bool, room database.ChatRoom, authUser *database.User) { + // We do not update user presence when they send private messages or moderators group message + if isPM || modMsg { + return + } + managers.ActiveUsers.UpdateUserInRoom(room, managers.NewUserInfoUpdateActivity(authUser)) +} + +func appendUploadLink(html string, upload *database.Upload) string { + if upload != nil { + if html != "" { + html += " " + } + html += `[` + upload.GetHTMLLink() + `]` + } + return html +} + +func checkCPLinks(db *database.DkfDB, html string) bool { + m1 := onionV3Rgx.FindAllStringSubmatch(html, -1) + m2 := onionV2Rgx.FindAllStringSubmatch(html, -1) + for _, m := range append(m1, m2...) { + hash := utils.MD5([]byte(m[0])) + if _, err := db.GetOnionBlacklist(hash); err == nil { + return true + } + } + return false +} + +type getUsersByUsernameFn func(usernames []string) ([]database.User, error) + +// Update the given html to add user style for tags. +// Return the new html, and a map[userID]User of tagged users. +func colorifyTaggedUsers(html string, getUsersByUsername getUsersByUsernameFn) (string, map[database.UserID]database.User) { + usernameMatches := tagRgx.FindAllStringSubmatch(html, -1) + usernames := hashset.New[string]() + for _, usernameMatch := range usernameMatches { + usernames.Insert(usernameMatch[1]) + } + taggedUsers, _ := getUsersByUsername(usernames.ToArray()) + + taggedUsersMap := make(map[string]database.User) + taggedUsersIDsMap := make(map[database.UserID]database.User) + for _, taggedUser := range taggedUsers { + taggedUsersMap[strings.ToLower(taggedUser.Username.AtStr())] = taggedUser + if taggedUser.Username != config.NullUsername { + taggedUsersIDsMap[taggedUser.ID] = taggedUser + } + } + + if len(usernameMatches) > 0 { + html = tagRgx.ReplaceAllStringFunc(html, func(s string) string { + lowerS := strings.ToLower(s) + if user, ok := taggedUsersMap[lowerS]; ok { + return fmt.Sprintf("<span %s>@%s</span>", user.GenerateChatStyle1(), user.Username) + } + + // Not found, try to fix typos using levenshtein + activeUsers := managers.ActiveUsers.GetActiveUsers() + if len(activeUsers) > 0 { + minDist := math.MaxInt + minAu := activeUsers[0] + for _, au := range activeUsers { + lowerAu := strings.ToLower(string(au.Username)) + d := levenshtein.ComputeDistance(lowerS, lowerAu) + if d < minDist { + minDist = d + minAu = au + } + } + if minDist <= 3 { + if users, _ := getUsersByUsername([]string{minAu.Username.String()}); len(users) > 0 { + user := users[0] + return fmt.Sprintf("<span %s>@%s</span>", user.GenerateChatStyle1(), user.Username) + } + } + } + + return s + }) + } + return html, taggedUsersIDsMap +} + +func linkRoomTags(db *database.DkfDB, html string) string { + if roomTagRgx.MatchString(html) { + html = roomTagRgx.ReplaceAllStringFunc(html, func(s string) string { + if room, err := db.GetChatRoomByName(strings.TrimPrefix(s, "#")); err == nil { + return `<a href="/chat/` + room.Name + `" target="_top">` + s + `</a>` + } + return s + }) + } + return html +} + +func linkDefaultRooms(html string) string { + r := strings.NewReplacer( + "#general", `<a href="/chat/general" target="_top">#general</a>`, + "#programming", `<a href="/chat/programming" target="_top">#programming</a>`, + "#hacking", `<a href="/chat/hacking" target="_top">#hacking</a>`, + "#suggestions", `<a href="/chat/suggestions" target="_top">#suggestions</a>`, + "#announcements", `<a href="/chat/announcements" target="_top">#announcements</a>`, + ) + return r.Replace(html) +} + +// Convert timestamps such as 01:23:45 to an archive link if a message with that timestamp exists. +// eg: "Some text 14:31:46 some more text" +func convertArchiveLinks(db *database.DkfDB, html string, roomID database.RoomID, authUserID database.UserID) string { + start, rest := "", html + + // Do not replace timestamps that are inside a quote text + const quoteSuffix = `”` + endOfQuoteIdx := strings.LastIndex(html, quoteSuffix) + if endOfQuoteIdx != -1 { + start, rest = html[:endOfQuoteIdx], html[endOfQuoteIdx:] + } + + archiveRgx := regexp.MustCompile(`(\d{2}-\d{2} )?\d{2}:\d{2}:\d{2}`) + if archiveRgx.MatchString(rest) { + rest = archiveRgx.ReplaceAllStringFunc(rest, func(s string) string { + var dt time.Time + var err error + if len(s) == 8 { // HH:MM:SS + dt, err = utils.ParsePrevDatetimeAt(s, clockwork.NewRealClock()) + } else if len(s) == 14 { // mm-dd HH:MM:SS + dt, err = utils.ParsePrevDatetimeAt2(s, clockwork.NewRealClock()) + } + if err != nil { + return s + } + if msgs, err := db.GetRoomChatMessagesByDate(roomID, dt.UTC()); err == nil && len(msgs) > 0 { + msg := msgs[0] + if len(msgs) > 1 { + for _, msgTmp := range msgs { + if msgTmp.User.ID == authUserID || (msgTmp.ToUserID != nil && *msgTmp.ToUserID == authUserID) { + msg = msgTmp + break + } + } + } + return fmt.Sprintf(`<a href="/chat/%s/archive#%s" target="_blank" rel="noopener noreferrer">%s</a>`, msg.Room.Name, msg.UUID, s) + } + return s + }) + } + return start + rest +} + +func convertBangShortcuts(html string) string { + r := strings.NewReplacer( + "!bhc", config.BhcOnion, + "!cryptbb", config.CryptbbOnion, + "!dread", config.DreadOnion, + "!dkf", config.DkfOnion, + "!rroom", config.DkfOnion+`/red-room`, + "!dnmx", config.DnmxOnion, + "!whonix", config.WhonixOnion, + "!age", config.AgeUrl, + "!chattor", config.ChattorOnion, + "!lulbins", config.LulbinsOnion, + ) + return r.Replace(html) +} + +func convertMarkdown(in string) string { + out := strings.Replace(in, "\r", "", -1) + resBytes := bf.Run([]byte(out), bf.WithRenderer(utils.MyRenderer(false, false)), bf.WithExtensions( + bf.NoIntraEmphasis|bf.Tables|bf.FencedCode| + bf.Strikethrough|bf.SpaceHeadings| + bf.DefinitionLists|bf.HardLineBreak|bf.NoLink)) + out = string(resBytes) + return out +} + +// This function will get the raw user input message which is not safe to directly render. +// +// To prevent people from altering the text of the quote, +// we retrieve the original quoted message using the timestamp and username, +// and we use the original message text. +// +// eg: we received altered quote, and return original quote -> +// “[01:23:45] username - Some maliciously altered quote” Some text +// “[01:23:45] username - The original text” Some text +func convertQuote(db *database.DkfDB, origHtml string, roomKey string, roomID database.RoomID) (html string, quoted *database.ChatMessage) { + const quotePrefix = `“[` + const quoteSuffix = `”` + html = origHtml + idx := strings.Index(origHtml, quoteSuffix) + if strings.HasPrefix(origHtml, quotePrefix) && idx > -1 { + prefixLen := len(quotePrefix) + suffixLen := len(quoteSuffix) + if len(origHtml) > prefixLen+9 { + hourMinSec := origHtml[prefixLen : prefixLen+8] + username := database.Username(origHtml[prefixLen+10 : strings.Index(origHtml[prefixLen+10:], " ")+prefixLen+10]) + if quoted = getQuotedChatMessage(db, hourMinSec, username, roomID); quoted != nil { + html = GetQuoteTxt(db, roomKey, *quoted) + html += origHtml[idx+suffixLen:] + } + } + } + return html, quoted +} + +func styleQuote(origHtml string, quoted *database.ChatMessage) (html string) { + const quoteSuffix = `”` + html = origHtml + if quoted != nil { + idx := strings.Index(origHtml, quoteSuffix) + prefixLen := len(`<p>“[`) + suffixLen := len(quoteSuffix) + dateLen := 8 // 01:23:45 --> 8 + + // <p>“[01:23:45] username - quoted text” user text</p> + date := origHtml[prefixLen : prefixLen+dateLen] // `01:23:45` + quoteTxt := origHtml[prefixLen+dateLen+1 : idx] // ` username - quoted text` + userTxt := origHtml[idx+suffixLen:] // ` user text</p>` + + sb := strings.Builder{} + sb.WriteString(`<p>“<small style="opacity: 0.8;"><i>[`) + + // Date link + sb.WriteString(`<a href="/chat/`) + sb.WriteString(quoted.Room.Name) + sb.WriteString(`/archive#`) + sb.WriteString(quoted.UUID) + sb.WriteString(`" target="_blank" rel="noopener noreferrer">`) + sb.WriteString(date) + sb.WriteString(`</a>`) + + sb.WriteString(`]<span `) + sb.WriteString(quoted.User.GenerateChatStyle1()) + sb.WriteString(`>`) + sb.WriteString(quoteTxt) + sb.WriteString(`</span></i></small>”`) + sb.WriteString(userTxt) + html = sb.String() + } + return html +} + +// Given a roomID and hourMinSec (01:23:45) and a username, retrieve the message from database that fits the predicates. +func getQuotedChatMessage(db *database.DkfDB, hourMinSec string, username database.Username, roomID database.RoomID) (quoted *database.ChatMessage) { + if dt, err := utils.ParsePrevDatetimeAt(hourMinSec, clockwork.NewRealClock()); err == nil { + if msgs, err := db.GetRoomChatMessagesByDate(roomID, dt.UTC()); err == nil && len(msgs) > 0 { + msg := msgs[0] + if len(msgs) > 1 { + for _, msgTmp := range msgs { + if msgTmp.User.Username == username { + msg = msgTmp + break + } + } + } + quoted = &msg + } + } + return +} + +// GetQuoteTxt given a chat message, return the text to be used as a quote. +func GetQuoteTxt(db *database.DkfDB, roomKey string, quoted database.ChatMessage) (out string) { + var err error + decrypted, err := quoted.GetRawMessage(roomKey) + if err != nil { + return + } + if quoted.ToUserID != nil { + if m := pmRgx.FindStringSubmatch(decrypted); len(m) == 3 { + decrypted = m[2] + } + } else if quoted.Moderators { + decrypted = strings.TrimPrefix(decrypted, "/m ") + } else if quoted.IsHellbanned { + decrypted = strings.TrimPrefix(decrypted, "/hbm ") + } + isMe := false + if strings.HasPrefix(decrypted, "/me") { + isMe = true + decrypted = strings.TrimPrefix(decrypted, "/me ") + } + + startIdx := 0 + if strings.HasPrefix(decrypted, `“[`) { + startIdx = strings.Index(decrypted, `” `) + if startIdx == -1 { + startIdx = 0 + } else { + startIdx += len(`” `) + } + } + + decrypted = replTextPrefixSuffix(decrypted, agePrefix, ageSuffix, "[age.txt]") + decrypted = replTextPrefixSuffix(decrypted, pgpPrefix, pgpSuffix, "[pgp.txt]") + decrypted = replTextPrefixSuffix(decrypted, pgpPKeyPrefix, pgpPKeySuffix, "[pgp_pkey.txt]") + + remaining := " " + if !quoted.System { + remaining += fmt.Sprintf(`%s `, quoted.User.Username) + } + if quoted.UploadID != nil { + if upload, err := db.GetUploadByID(*quoted.UploadID); err == nil { + if decrypted != "" { + decrypted += " " + } + decrypted += `[` + upload.OrigFileName + `]` + } + } + if !isMe { + remaining += "- " + } + + toBeQuoted := decrypted[startIdx:] + toBeQuoted = strings.ReplaceAll(toBeQuoted, "\n", ` `) + toBeQuoted = strings.ReplaceAll(toBeQuoted, `“`, `"`) + toBeQuoted = strings.ReplaceAll(toBeQuoted, `”`, `"`) + + remaining += utils.TruncStr2(toBeQuoted, 70, "…") + return `“[` + quoted.CreatedAt.Format("15:04:05") + "]" + remaining + `”` +} + +func replTextPrefixSuffix(msg, prefix, suffix, repl string) (out string) { + out = msg + pgpPIdx := strings.Index(msg, prefix) + pgpSIdx := strings.Index(msg, suffix) + if pgpPIdx != -1 && pgpSIdx != -1 { + newMsg := msg[:pgpPIdx] + newMsg += repl + newMsg += msg[pgpSIdx+len(suffix):] + out = newMsg + } + return +} + +var noSchemeOnionLinkRgx = regexp.MustCompile(`\s[a-z2-7]{56}\.onion`) + +// Fix up onion links that are missing the http scheme. This often happen when copy/pasting a link. +func convertLinksWithoutScheme(in string) string { + html := noSchemeOnionLinkRgx.ReplaceAllStringFunc(in, func(s string) string { + return " http://" + strings.TrimSpace(s) + }) + return html +} + +var linkRgxStr = `(http|ftp|https):\/\/([\w\-_]+(?:(?:\.[\w\-_]+)+))([\w\-\.,@?^=%&amp;:/~\+#\(\)]*[\w\-\@?^=%&amp;/~\+#\(\)])?` +var profileRgxStr = `/u/\w{3,20}` +var linkShorthandRgxStr = `/l/\w{3,20}` +var dkfArchiveRgx = regexp.MustCompile(`/chat/([\w_]{3,50})/archive\?uuid=([\w-]{36})#[\w-]{36}`) +var linkOrProfileRgx = regexp.MustCompile(`(` + linkRgxStr + `|` + profileRgxStr + `|` + linkShorthandRgxStr + `)`) +var userProfileLinkRgx = regexp.MustCompile(`^` + profileRgxStr + `$`) +var linkShorthandPageLinkRgx = regexp.MustCompile(`^` + linkShorthandRgxStr + `$`) +var youtubeComIDRgx = regexp.MustCompile(`watch\?v=([\w-]+)`) +var youtubeComShortsIDRgx = regexp.MustCompile(`/shorts/([\w-]+)`) +var youtuBeIDRgx = regexp.MustCompile(`https://youtu\.be/([\w-]+)`) +var yewtubeBeIDRgx = youtubeComIDRgx +var invidiousIDRgx = youtubeComIDRgx + +func makeHtmlLink(label, link string) string { + // We replace @ to prevent colorifyTaggedUsers from trying to generate html inside the links. + r := strings.NewReplacer("@", "&#64;", "#", "&#35;") + label = r.Replace(label) + link = r.Replace(link) + return fmt.Sprintf(`<a href="%s" rel="noopener noreferrer" target="_blank">%s</a>`, link, label) +} + +func splitQuote(in string) (string, string) { + const quotePrefix = `<p>“[` + const quoteSuffix = `”` + idx := strings.Index(in, quoteSuffix) + if idx == -1 || !strings.HasPrefix(in, quotePrefix) { + return "", in + } + return in[:idx], in[idx:] +} + +func convertLinks(in string, + roomID database.RoomID, + getUserByUsername func(database.Username) (database.User, error), + getLinkByShorthand func(string) (database.Link, error), + getChatMessageByUUID func(string) (database.ChatMessage, error)) string { + quote, rest := splitQuote(in) + + libredditURLs := []string{ + "http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion", + "http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion", + "http://kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion", + "http://inytumdgnri7xsqtvpntjevaelxtgbjqkuqhtf6txxhwbll2fwqtakqd.onion", + "http://liredejj74h5xjqr2dylnl5howb2bpikfowqoveub55ru27x43357iid.onion", + "http://kzhfp3nvb4qp575vy23ccbrgfocezjtl5dx66uthgrhu7nscu6rcwjyd.onion", + "http://ecue64ybzvn6vjzl37kcsnwt4ycmbsyf74nbttyg7rkc3t3qwnj7mcyd.onion", + "http://ledditqo2mxfvlgobxnlhrkq4dh34jss6evfkdkb2thlvy6dn4f4gpyd.onion", + "http://ol5begilptoou34emq2sshf3may3hlblvipdjtybbovpb7c7zodxmtqd.onion", + "http://lbrdtjaj7567ptdd4rv74lv27qhxfkraabnyphgcvptl64ijx2tijwid.onion", + } + + invidiousURLs := []string{ + "http://c7hqkpkpemu6e7emz5b4vyz7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion", + "http://kbjggqkzv65ivcqj6bumvp337z6264huv5kpkwuv6gu5yjiskvan7fad.onion", + "http://grwp24hodrefzvjjuccrkw3mjq4tzhaaq32amf33dzpmuxe7ilepcmad.onion"} + + wikilessURLs := []string{ + "http://c2pesewpalbi6lbfc5hf53q4g3ovnxe4s7tfa6k2aqkf7jd7a7dlz5ad.onion", + "http://dj2tbh2nqfxyfmvq33cjmhuw7nb6am7thzd3zsjvizeqf374fixbrxyd.onion"} + + nitterURLs := []string{ + "http://nitraeju2mipeziu2wtcrqsxg7h62v5y4eqgwi75uprynkj74gevvuqd.onion"} + + rimgoURLs := []string{ + "http://be7udfhmnzqyt7cxysg6c4pbawarvaofjjywp35nhd5qamewdfxl6sid.onion"} + + knownOnions := [][]string{ + {"http://dkfgit.onion", config.DkfGitOnion}, + {"http://dread.onion", config.DreadOnion}, + {"http://cryptbb.onion", config.CryptbbOnion}, + {"http://blkhat.onion", config.BhcOnion}, + {"http://dnmx.onion", config.DnmxOnion}, + {"http://whonix.onion", config.WhonixOnion}, + {"http://chattor.onion", config.ChattorOnion}, + {"http://lulbins.onion", config.LulbinsOnion}, + } + + newRest := linkOrProfileRgx.ReplaceAllStringFunc(rest, func(link string) string { + // Convert all occurrences of "/u/username" to a link to user profile page if the user exists + if userProfileLinkRgx.MatchString(link) { + user, err := getUserByUsername(database.Username(strings.TrimPrefix(link, "/u/"))) + if err != nil { + return link + } + href := "/u/" + string(user.Username) + return makeHtmlLink(href, href) + } + + // Convert all occurrences of "/l/shorthand" to a link to link page if the shorthand exists + if linkShorthandPageLinkRgx.MatchString(link) { + l, err := getLinkByShorthand(strings.TrimPrefix(link, "/l/")) + if err != nil { + return link + } + href := "/l/" + *l.Shorthand + return makeHtmlLink(href, href) + } + + // Handle reddit links + if strings.HasPrefix(link, "https://www.reddit.com/") { + old := strings.Replace(link, "https://www.reddit.com/", "https://old.reddit.com/", 1) + libredditLink := utils.RandChoice(libredditURLs) + libredditLink = strings.Replace(link, "https://www.reddit.com", libredditLink, 1) + oldHtmlLink := makeHtmlLink("old", old) + libredditHtmlLink := makeHtmlLink("libredditLink", libredditLink) + htmlLink := makeHtmlLink(link, link) + return htmlLink + ` (` + oldHtmlLink + ` | ` + libredditHtmlLink + `)` + } else if strings.HasPrefix(link, "https://old.reddit.com/") { + libredditLink := utils.RandChoice(libredditURLs) + libredditLink = strings.Replace(link, "https://old.reddit.com", libredditLink, 1) + libredditHtmlLink := makeHtmlLink("libredditLink", libredditLink) + htmlLink := makeHtmlLink(link, link) + return htmlLink + ` (` + libredditHtmlLink + `)` + } + for _, libredditURL := range libredditURLs { + if strings.HasPrefix(link, libredditURL) { + newPrefix := strings.Replace(link, libredditURL, "http://reddit.onion", 1) + old := strings.Replace(link, libredditURL, "https://old.reddit.com", 1) + oldHtmlLink := makeHtmlLink("old", old) + htmlLink := makeHtmlLink(newPrefix, link) + return htmlLink + ` (` + oldHtmlLink + `)` + } + } + + // Append YouTube link to invidious link + for _, invidiousURL := range invidiousURLs { + if strings.HasPrefix(link, invidiousURL) { + if strings.Contains(link, ".onion/watch?v=") { + newPrefix := strings.Replace(link, invidiousURL, "http://invidious.onion", 1) + m := invidiousIDRgx.FindStringSubmatch(link) + if len(m) == 2 { + videoID := m[1] + youtubeLink := "https://www.youtube.com/watch?v=" + videoID + youtubeHtmlLink := makeHtmlLink("Youtube", youtubeLink) + htmlLink := makeHtmlLink(newPrefix, link) + return htmlLink + ` (` + youtubeHtmlLink + `)` + } + } + } + } + // Unknown invidious links + if strings.Contains(link, ".onion/watch?v=") { + m := invidiousIDRgx.FindStringSubmatch(link) + if len(m) == 2 { + videoID := m[1] + youtubeLink := "https://www.youtube.com/watch?v=" + videoID + youtubeHtmlLink := makeHtmlLink("Youtube", youtubeLink) + htmlLink := makeHtmlLink(link, link) + return htmlLink + ` (` + youtubeHtmlLink + `)` + } + } + + // Append wikiless link to wikipedia link + if strings.HasPrefix(link, "https://en.wikipedia.org/") { + wikilessLink := utils.RandChoice(wikilessURLs) + wikilessLink = strings.Replace(link, "https://en.wikipedia.org", wikilessLink, 1) + wikilessHtmlLink := makeHtmlLink("Wikiless", wikilessLink) + htmlLink := makeHtmlLink(link, link) + return htmlLink + ` (` + wikilessHtmlLink + `)` + } + for _, wikilessURL := range wikilessURLs { + if strings.HasPrefix(link, wikilessURL) { + newPrefix := strings.Replace(link, wikilessURL, "http://wikiless.onion", 1) + wikipediaPrefix := strings.Replace(link, wikilessURL, "https://en.wikipedia.org", 1) + wikipediaHtmlLink := makeHtmlLink("Wikipedia", wikipediaPrefix) + htmlLink := makeHtmlLink(newPrefix, link) + return htmlLink + ` (` + wikipediaHtmlLink + `)` + } + } + + // Append nitter link to twitter link + if strings.HasPrefix(link, "https://twitter.com/") { + nitterLink := utils.RandChoice(nitterURLs) + nitterLink = strings.Replace(link, "https://twitter.com", nitterLink, 1) + nitterHtmlLink := makeHtmlLink("Nitter", nitterLink) + htmlLink := makeHtmlLink(link, link) + return htmlLink + ` (` + nitterHtmlLink + `)` + } + for _, nitterURL := range nitterURLs { + if strings.HasPrefix(link, nitterURL) { + newPrefix := strings.Replace(link, nitterURL, "http://nitter.onion", 1) + twitterPrefix := strings.Replace(link, nitterURL, "https://twitter.com", 1) + twitterHtmlLink := makeHtmlLink("Twitter", twitterPrefix) + htmlLink := makeHtmlLink(newPrefix, link) + return htmlLink + ` (` + twitterHtmlLink + `)` + } + } + + // Append rimgo link to imgur link + if strings.HasPrefix(link, "https://imgur.com/") { + rimgoLink := utils.RandChoice(rimgoURLs) + rimgoLink = strings.Replace(link, "https://imgur.com", rimgoLink, 1) + rimgoHtmlLink := makeHtmlLink("Rimgo", rimgoLink) + htmlLink := makeHtmlLink(link, link) + return htmlLink + ` (` + rimgoHtmlLink + `)` + } + for _, rimgoURL := range rimgoURLs { + if strings.HasPrefix(link, rimgoURL) { + newPrefix := strings.Replace(link, rimgoURL, "http://rimgo.onion", 1) + imgurPrefix := strings.Replace(link, rimgoURL, "https://imgur.com", 1) + imgurHtmlLink := makeHtmlLink("Imgur", imgurPrefix) + htmlLink := makeHtmlLink(newPrefix, link) + return htmlLink + ` (` + imgurHtmlLink + `)` + } + } + + // Append invidious link to YouTube/yewtube link + var videoID string + var m []string + var isShortUrl, isYewtube bool + if strings.HasPrefix(link, "https://youtu.be/") { + m = youtuBeIDRgx.FindStringSubmatch(link) + } else if strings.HasPrefix(link, "https://www.youtube.com/watch?v=") { + m = youtubeComIDRgx.FindStringSubmatch(link) + } else if strings.HasPrefix(link, "https://yewtu.be/") || strings.HasPrefix(link, "https://www.yewtu.be/") { + m = yewtubeBeIDRgx.FindStringSubmatch(link) + isYewtube = true + } else if strings.HasPrefix(link, "https://www.youtube.com/shorts/") { + m = youtubeComShortsIDRgx.FindStringSubmatch(link) + isShortUrl = true + } + if len(m) == 2 { + videoID = m[1] + } + if videoID != "" { + invidiousLink := utils.RandChoice(invidiousURLs) + "/watch?v=" + videoID + "&local=true" + invidiousHtmlLink := makeHtmlLink("Invidious", invidiousLink) + htmlLink := makeHtmlLink(link, link) + youtubeLink := "https://www.youtube.com/watch?v=" + videoID + youtubeHtmlLink := makeHtmlLink("YT", youtubeLink) + out := htmlLink + ` (` + invidiousHtmlLink + `)` + if isShortUrl || isYewtube { + out = htmlLink + ` (` + youtubeHtmlLink + ` | ` + invidiousHtmlLink + `)` + } + return out + } + + // Special case for dkf links. + { + dkfLocalPrefix := "http://127.0.0.1:8080" + dkfShortPrefix := "http://dkf.onion" + dkfLongPrefix := config.DkfOnion + hasLocalPrefix := strings.HasPrefix(link, dkfLocalPrefix) + hasDkfShortPrefix := strings.HasPrefix(link, dkfShortPrefix) + hasDkfLongPrefix := strings.HasPrefix(link, dkfLongPrefix) + if hasLocalPrefix || hasDkfLongPrefix || hasDkfShortPrefix { + var trimmed string + if hasLocalPrefix { + trimmed = strings.TrimPrefix(link, dkfLocalPrefix) + } else if hasDkfLongPrefix { + trimmed = strings.TrimPrefix(link, dkfLongPrefix) + } else if hasDkfShortPrefix { + trimmed = strings.TrimPrefix(link, dkfShortPrefix) + } + label := dkfShortPrefix + trimmed + href := trimmed + // Shorten archive links + if m := dkfArchiveRgx.FindStringSubmatch(label); len(m) == 3 { + if msg, err := getChatMessageByUUID(m[2]); err == nil { + if roomID == msg.RoomID { + label = msg.CreatedAt.Format("[Jan 02 03:04:05]") + } else { + label = msg.CreatedAt.Format("[#" + m[1] + " Jan 02 03:04:05]") + } + } + } + // Allows to have messages such as: "my profile is /u/username :)" + if userProfileLinkRgx.MatchString(trimmed) { + if user, err := getUserByUsername(database.Username(strings.TrimPrefix(trimmed, "/u/"))); err == nil { + label = "/u/" + string(user.Username) + href = "/u/" + string(user.Username) + } + } else if linkShorthandPageLinkRgx.MatchString(trimmed) { + // Convert all occurrences of "/l/shorthand" to a link to link page if the shorthand exists + if l, err := getLinkByShorthand(strings.TrimPrefix(trimmed, "/l/")); err == nil { + label = "/l/" + *l.Shorthand + href = "/l/" + *l.Shorthand + } + } + return makeHtmlLink(label, href) + } + } + + for _, el := range knownOnions { + shortPrefix := el[0] + longPrefix := el[1] + if strings.HasPrefix(link, longPrefix) { + return makeHtmlLink(shortPrefix+strings.TrimPrefix(link, longPrefix), link) + } else if strings.HasPrefix(link, shortPrefix) { + return makeHtmlLink(link, longPrefix+strings.TrimPrefix(link, shortPrefix)) + } + } + return makeHtmlLink(link, link) + }) + + return quote + newRest +} + +func convertNewLines(html string, canUseMultiline bool) string { + if !canUseMultiline { + html = strings.ReplaceAll(html, "\n", "") + } + return html +} + +func extractPGPMessage(html string) (out string) { + pgpPrefixL := pgpPrefix + pgpSuffixL := pgpSuffix + startIdx := strings.Index(html, pgpPrefixL) + endIdx := strings.Index(html, pgpSuffixL) + if startIdx != -1 && endIdx != -1 { + out = html[startIdx : endIdx+len(pgpSuffixL)] + out = strings.TrimSpace(out) + out = strings.TrimPrefix(out, pgpPrefixL) + out = strings.TrimSuffix(out, pgpSuffixL) + out = strings.Join(strings.Split(out, " "), "\n") + out = pgpPrefixL + out + out += pgpSuffixL + } + return out +} + +// Auto convert pasted pgp public key into uploaded file +func convertPGPPublicKeyToFile(db *database.DkfDB, html string, authUserID database.UserID) string { + pgpPKeyPrefixL := pgpPKeyPrefix + pgpPKeySuffixL := pgpPKeySuffix + startIdx := strings.Index(html, pgpPKeyPrefixL) + endIdx := strings.Index(html, pgpPKeySuffixL) + if startIdx != -1 && endIdx != -1 { + pkeySubSlice := html[startIdx : endIdx+len(pgpPKeySuffixL)] + unescapedPkey := html2.UnescapeString(pkeySubSlice) + tmp := convertInlinePGPPublicKey(unescapedPkey) + upload, _ := db.CreateUpload("pgp_pkey.txt", []byte(tmp), authUserID) + msgBefore := html[0:startIdx] + msgAfter := html[endIdx+len(pgpPKeySuffixL):] + html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter + html = strings.TrimSpace(html) + } + return html +} + +func convertPGPClearsignToFile(db *database.DkfDB, html string, authUserID database.UserID) string { + if b, _ := clearsign.Decode([]byte(html)); b != nil { + pgpSignedPrefixL := pgpSignedPrefix + pgpSignedSuffixL := pgpSignedSuffix + startIdx := strings.Index(html, pgpSignedPrefixL) + endIdx := strings.Index(html, pgpSignedSuffixL) + tmp := html[startIdx : endIdx+len(pgpSignedSuffixL)] + upload, _ := db.CreateUpload("pgp_clearsign.txt", []byte(tmp), authUserID) + msgBefore := html[0:startIdx] + msgAfter := html[endIdx+len(pgpSignedSuffixL):] + html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter + html = strings.TrimSpace(html) + } + return html +} + +// Auto convert pasted pgp message into uploaded file +func convertPGPMessageToFile(db *database.DkfDB, html string, authUserID database.UserID) string { + pgpPrefixL := pgpPrefix + pgpSuffixL := pgpSuffix + startIdx := strings.Index(html, pgpPrefixL) + endIdx := strings.Index(html, pgpSuffixL) + if startIdx != -1 && endIdx != -1 { + tmp := html[startIdx : endIdx+len(pgpSuffixL)] + tmp = strings.TrimSpace(tmp) + tmp = strings.TrimPrefix(tmp, pgpPrefixL) + tmp = strings.TrimSuffix(tmp, pgpSuffixL) + tmp = strings.Join(strings.Split(tmp, " "), "\n") + tmp = pgpPrefixL + tmp + tmp += pgpSuffixL + upload, _ := db.CreateUpload("pgp.txt", []byte(tmp), authUserID) + msgBefore := html[0:startIdx] + msgAfter := html[endIdx+len(pgpSuffixL):] + html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter + html = strings.TrimSpace(html) + } + return html +} + +// Auto convert pasted age message into uploaded file +func convertAgeMessageToFile(db *database.DkfDB, html string, authUserID database.UserID) string { + agePrefixL := agePrefix + ageSuffixL := ageSuffix + startIdx := strings.Index(html, agePrefixL) + endIdx := strings.Index(html, ageSuffixL) + if startIdx != -1 && endIdx != -1 { + tmp := html[startIdx : endIdx+len(ageSuffixL)] + tmp = strings.TrimSpace(tmp) + tmp = strings.TrimPrefix(tmp, agePrefixL) + tmp = strings.TrimSuffix(tmp, ageSuffixL) + tmp = strings.Join(strings.Split(tmp, " "), "\n") + tmp = agePrefixL + tmp + tmp += ageSuffixL + upload, _ := db.CreateUpload("age.txt", []byte(tmp), authUserID) + msgBefore := html[0:startIdx] + msgAfter := html[endIdx+len(ageSuffixL):] + html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter + html = strings.TrimSpace(html) + } + return html +} + +func convertInlinePGPPublicKey(inlinePKey string) string { + pgpPKeyPrefixL := pgpPKeyPrefix + pgpPKeySuffixL := pgpPKeySuffix + // If it contains new lines, it was probably pasted using multi-line text box + if strings.Contains(inlinePKey, "\n") { + return inlinePKey + } + inlinePKey = strings.TrimSpace(inlinePKey) + inlinePKey = strings.TrimPrefix(inlinePKey, pgpPKeyPrefixL) + inlinePKey = strings.TrimSuffix(inlinePKey, pgpPKeySuffixL) + inlinePKey = strings.TrimSpace(inlinePKey) + commentsParts := strings.Split(inlinePKey, "Comment: ") + commentsParts, lastCommentPart := commentsParts[:len(commentsParts)-1], commentsParts[len(commentsParts)-1] + newCommentsParts := make([]string, 0) + for idx := range commentsParts { + if commentsParts[idx] != "" { + commentsParts[idx] = "Comment: " + commentsParts[idx] + commentsParts[idx] = strings.TrimSpace(commentsParts[idx]) + newCommentsParts = append(newCommentsParts, commentsParts[idx]) + } + } + + rgx := regexp.MustCompile(`\s\s(\w|\+|/){64}`) + m := rgx.FindStringIndex(lastCommentPart) + commentsStr := "" + key := "" + if len(m) == 2 { + idx := m[0] + lastCommentP1 := lastCommentPart[:idx] + lastCommentP2 := lastCommentPart[idx+2:] + key = strings.Join(strings.Split(lastCommentP2, " "), "\n") + commentsStr = strings.Join(newCommentsParts, "\n") + commentsStr += "\nComment: " + lastCommentP1 + "\n\n" + } else { + key = "\n" + strings.Join(strings.Split(lastCommentPart, " "), "\n") + } + inlinePKey = pgpPKeyPrefixL + "\n" + commentsStr + key + "\n" + pgpPKeySuffixL + return inlinePKey +} diff --git a/pkg/web/handlers/api/v1/interceptors/slashInterceptor.go b/pkg/web/handlers/api/v1/interceptors/slashInterceptor.go @@ -0,0 +1,1817 @@ +package interceptors + +import ( + "dkforest/pkg/clockwork" + "dkforest/pkg/config" + "dkforest/pkg/database" + dutils "dkforest/pkg/database/utils" + "dkforest/pkg/managers" + "dkforest/pkg/utils" + "errors" + "fmt" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/asaskevich/govalidator" + "github.com/dustin/go-humanize" + "github.com/sirupsen/logrus" + "html" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type ErrSuccess struct { + msg string +} + +func NewErrSuccess(msg string) *ErrSuccess { + return &ErrSuccess{msg: msg} +} + +func (e ErrSuccess) Error() string { + return e.msg +} + +// SlashInterceptor handle all forward slash commands. +// +// If by the end of this function, the c.err is set, it will trigger +// different behavior according to the type of error it holds. +// if c.err is set to ErrRedirect, the chat-bar iframe will refresh completely. +// if c.err is set to ErrStop, no further processing of the user input will be done, +// and the chat iframe will be rendered instead of redirected. +// This is useful to keep a prefix in the text box (eg: /pm user ) +// if c.err is set to an instance of ErrSuccess, +// a green message will appear beside the text box. +// otherwise if c.err is set to a different error, +// text box is retested to original message, +// and a red message will appear beside the text box. +type SlashInterceptor struct{} + +func (i SlashInterceptor) InterceptMsg(c *Command) { + if !strings.HasPrefix(c.message, "/") { + return + } + handled := handleUserCmd(c) || + handlePrivateRoomCmd(c) || + handlePrivateRoomOwnerCmd(c) || + handleModeratorCmd(c) || + handleAdminCmd(c) + if !handled { + c.err = errors.New("invalid slash command") + } +} + +func handleUserCmd(c *Command) (handled bool) { + return handleIgnoreCmd(c) || + handleUnIgnoreCmd(c) || + handleToggleAutocomplete(c) || + handleTutorialCmd(c) || + handleDeleteMsgCmd(c) || + handleHideMsgCmd(c) || + handleUnHideMsgCmd(c) || + handleListIgnoredCmd(c) || + handleListPmWhitelistCmd(c) || + handleSetPmModeWhitelistCmd(c) || + handleSetPmModeStandardCmd(c) || + handleTogglePmBlacklistedUser(c) || + handleTogglePmWhitelistedUser(c) || + handleGroupChatCmd(c) || + handleMeCmd(c) || + handleEditCmd(c) || + handlePMCmd(c) || + handleEditLastCmd(c) || + handleSubscribeCmd(c) || + handleUnsubscribeCmd(c) || + handleProfileCmd(c) || + handleInboxCmd(c) || + handleChessCmd(c) || + handleHbmCmd(c) || + handleHbmtCmd(c) || + handleTokenCmd(c) || + handleMd5Cmd(c) || + handleSha1Cmd(c) || + handleSha256Cmd(c) || + handleSha512Cmd(c) || + handleDiceCmd(c) || + handleRandCmd(c) || + handleChoiceCmd(c) || + handleListMemes(c) || + handleSuccessCmd(c) || + handleAfkCmd(c) || + handleDateCmd(c) || + handleUpdateReadMarkerCmd(c) || + handleCodeCmd(c) || + handleErrorCmd(c) +} + +func handlePrivateRoomCmd(c *Command) (handled bool) { + return handleGetModeCmd(c) || + handleWhitelistCmd(c) +} + +func handlePrivateRoomOwnerCmd(c *Command) (handled bool) { + if (c.room.OwnerUserID != nil && *c.room.OwnerUserID == c.authUser.ID) || c.authUser.IsAdmin { + return handleAddGroupCmd(c) || + handleRmGroupCmd(c) || + handleLockGroupCmd(c) || + handleUnlockGroupCmd(c) || + handleGroupUsersCmd(c) || + handleListGroupsCmd(c) || + handleGroupAddUserCmd(c) || + handleGroupRmUserCmd(c) || + handleSetModeWhitelistCmd(c) || + handleSetModeStandardCmd(c) || + handleGetRoomWhitelistCmd(c) || + handleToggleReadOnlyCmd(c) + } + return false +} + +func handleModeratorCmd(c *Command) (handled bool) { + if c.authUser.IsModerator() { + return handleModeratorGroupCmd(c) || + handleListModeratorsCmd(c) || + handleKickCmd(c) || + handleKickKeepCmd(c) || + handleKickSilentCmd(c) || + handleKickKeepSilentCmd(c) || + handleUnkickCmd(c) || + handleLogoutCmd(c) || + handleForceCaptchaCmd(c) || + handleResetTutorialCmd(c) || + handleHellbanCmd(c) || + handleUnhellbanCmd(c) + } + return false +} + +func handleAdminCmd(c *Command) (handled bool) { + if c.authUser.IsAdmin { + return handleSystemCmd(c) || + handleSetChatRoomExternalLink(c) || + handlePurge(c) || + handleRename(c) || + handleNewMeme(c) || + handleRenameMeme(c) || + handleRemoveMeme(c) || + handleRefreshCmd(c) + } + return false +} + +func handleModeratorGroupCmd(c *Command) (handled bool) { + if strings.HasPrefix(c.message, "/m ") || strings.HasPrefix(c.message, "/n ") { + if strings.HasPrefix(c.message, "/n ") { + c.message = strings.Replace(c.message, "/n ", "/m ", 1) + } + c.message = strings.TrimPrefix(c.message, "/m ") + c.redirectQP.Set(RedirectModQP, "1") + c.modMsg = true + if handleMeCmd(c) { + return true + } else if handleCodeCmd(c) { + return true + } + return true + } + return false +} + +func handleListModeratorsCmd(c *Command) (handled bool) { + if c.message == "/moderators" || c.message == "/mods" { + mods, err := c.db.GetModeratorsUsers() + if err != nil { + c.err = err + return true + } + msg := "Moderators:\n" + if len(mods) > 0 { + msg += "\n" + for _, mod := range mods { + msg += mod.Username.AtStr() + "\n" + } + } else { + msg += "no moderators" + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleKickCmd(c *Command) (handled bool) { + if m := kickRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + if err := kickCmd(c, username, true, false); err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } + return +} + +// Kick a user but keep the messages +func handleKickKeepCmd(c *Command) (handled bool) { + if m := kickKeepRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + if err := kickCmd(c, username, false, false); err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } + return +} + +// Kick a user, no system message in chat +func handleKickSilentCmd(c *Command) (handled bool) { + if m := kickSilentRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + if err := kickCmd(c, username, true, true); err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } + return +} + +// Kick a user, keep the messages, no system message in chat +func handleKickKeepSilentCmd(c *Command) (handled bool) { + if m := kickKeepSilentRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + if err := kickCmd(c, username, false, true); err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } + return +} + +func kickCmd(c *Command, username database.Username, purge, silent bool) error { + user, err := c.db.GetUserByUsername(username) + if err != nil { + return ErrUsernameNotFound + } + return dutils.Kick(c.db, user, *c.authUser, purge, silent) +} + +var ErrUsernameNotFound = errors.New("username not found") +var ErrUnauthorized = errors.New("unauthorized") + +func handleUnkickCmd(c *Command) (handled bool) { + if m := unkickRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + if user.Verified { + c.err = errors.New("user already not kicked") + return true + } + c.db.NewAudit(*c.authUser, fmt.Sprintf("unkick %s #%d", user.Username, user.ID)) + user.Verified = true + user.DoSave(c.db) + + // Display unkick message + c.db.CreateUnkickMsg(user, *c.authUser) + + c.err = ErrRedirect + return true + } + return +} + +func handleForceCaptchaCmd(c *Command) (handled bool) { + if m := forceCaptchaRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + if c.authUser.IsAdmin || !user.IsModerator() || c.authUser.Username == username { + c.db.NewAudit(*c.authUser, fmt.Sprintf("force captcha %s #%d", user.Username, user.ID)) + user.CaptchaRequired = true + user.DoSave(c.db) + } + c.err = ErrRedirect + return true + } + return +} + +func handleLogoutCmd(c *Command) (handled bool) { + if m := logoutRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + if !c.authUser.IsAdmin && user.Vetted { + c.err = ErrUnauthorized + return true + } + if c.authUser.IsAdmin || !user.IsModerator() { + c.db.NewAudit(*c.authUser, fmt.Sprintf("logout %s #%d", user.Username, user.ID)) + + _ = c.db.DeleteUserSessions(user.ID) + + // Remove user from the user cache + managers.ActiveUsers.RemoveUser(user.ID) + } + + c.err = ErrRedirect + return true + } + return +} + +func handleResetTutorialCmd(c *Command) (handled bool) { + if m := rtutoRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + if !c.authUser.IsAdmin && user.Vetted { + c.err = ErrUnauthorized + return true + } + if c.authUser.IsAdmin || !user.IsModerator() { + c.db.NewAudit(*c.authUser, fmt.Sprintf("rtuto %s #%d", user.Username, user.ID)) + user.ChatTutorial = 0 + user.DoSave(c.db) + } + c.err = ErrRedirect + return true + } + return +} + +func handleHellbanCmd(c *Command) (handled bool) { + if m := hellbanRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + if !c.authUser.IsAdmin && (user.Vetted || user.IsModerator()) { + c.err = ErrUnauthorized + return true + } + c.db.NewAudit(*c.authUser, fmt.Sprintf("hellban %s #%d", user.Username, user.ID)) + user.HellBan(c.db) + managers.ActiveUsers.UpdateUserHBInRooms(managers.NewUserInfo(&user)) + + c.err = ErrRedirect + return true + } + return +} + +func handleUnhellbanCmd(c *Command) (handled bool) { + if m := unhellbanRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + if !c.authUser.IsAdmin && (user.Vetted || user.IsModerator()) { + c.err = ErrUnauthorized + return true + } + c.db.NewAudit(*c.authUser, fmt.Sprintf("unhellban %s #%d", user.Username, user.ID)) + user.UnHellBan(c.db) + managers.ActiveUsers.UpdateUserHBInRooms(managers.NewUserInfo(&user)) + + c.err = ErrRedirect + return true + } + return false +} + +func handleHbmCmd(c *Command) (handled bool) { + if !c.authUser.CanSeeHB() { + return + } + if strings.HasPrefix(c.message, "/hbm ") { + c.message = strings.TrimPrefix(c.message, "/hbm ") + c.hellbanMsg = true + c.redirectQP.Set(RedirectHbmQP, "1") + return true + } + return +} + +func handleHbmtCmd(c *Command) (handled bool) { + if !c.authUser.CanSeeHB() { + return + } + if m := hbmtRgx.FindStringSubmatch(c.message); len(m) == 2 { + date := m[1] + if dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()); err == nil { + if msg, err := c.db.GetRoomChatMessageByDate(c.room.ID, c.authUser.ID, dt.UTC()); err == nil { + msg.IsHellbanned = !msg.IsHellbanned + msg.DoSave(c.db) + } else { + c.err = errors.New("no message found at this timestamp") + return true + } + } + c.err = ErrRedirect + return true + } + return +} + +func handleDiceCmd(c *Command) (handled bool) { + if strings.HasPrefix(c.message, "/dice") { + dice := utils.RandInt(1, 6) + raw := fmt.Sprintf(`rolling dice for @%s ... "%d"`, c.authUser.Username, dice) + msg := fmt.Sprintf(`rolling dice for @%s ... "<span style="color: white;">%d</span>"`, c.authUser.Username, dice) + msg, _ = colorifyTaggedUsers(msg, c.db.GetUsersByUsername) + go func() { + time.Sleep(time.Second) + c.zeroPublicMsg(raw, msg) + }() + return true + } + return +} + +func handleRandCmd(c *Command) (handled bool) { + if strings.HasPrefix(c.message, "/rand") { + min := 1 + max := 6 + var dice int + if m := randRgx.FindStringSubmatch(c.message); len(m) == 3 { + var err error + min, err = strconv.Atoi(m[1]) + if err != nil { + c.err = err + return true + } + max, err = strconv.Atoi(m[2]) + if err != nil { + c.err = err + return true + } + if max <= min { + c.err = errors.New("max must be greater than min") + return true + } + } else if c.message != "/rand" { + c.err = errors.New("invalid /rand command") + return true + } + dice = utils.RandInt(min, max) + raw := fmt.Sprintf(`rolling dice for @%s ... "%d"`, c.authUser.Username, dice) + msg := fmt.Sprintf(`rolling dice for @%s ... "<span style="color: white;">%d</span>"`, c.authUser.Username, dice) + msg, _ = colorifyTaggedUsers(msg, c.db.GetUsersByUsername) + go func() { + time.Sleep(time.Second) + c.zeroPublicMsg(raw, msg) + }() + return true + } + return +} + +func handleChoiceCmd(c *Command) (handled bool) { + if strings.HasPrefix(c.message, "/choice ") { + tmp := html.EscapeString(strings.TrimPrefix(c.message, "/choice ")) + words := strings.Fields(tmp) + answer := utils.RandChoice(words) + raw := fmt.Sprintf(`@%s choice %s ... "%s"`, c.authUser.Username, words, answer) + msg := fmt.Sprintf(`@%s choice %s ... "<span style="color: white;">%s</span>"`, c.authUser.Username, words, answer) + msg, _ = colorifyTaggedUsers(msg, c.db.GetUsersByUsername) + go func() { + time.Sleep(time.Second) + c.zeroPublicMsg(raw, msg) + }() + c.skipInboxes = true + return true + } + return +} + +func handleTokenCmd(c *Command) (handled bool) { + if c.message == "/token" { + c.zeroMsg(utils.GenerateToken10()) + c.err = ErrRedirect + return true + } else if m := tokenRgx.FindStringSubmatch(c.message); len(m) == 2 { + n, _ := strconv.Atoi(m[1]) + if n < 1 || n > 32 { + c.err = errors.New("value must be [1;32]") + return true + } + n = utils.Clamp(n, 1, 32) + c.zeroMsg(utils.GenerateTokenN(n)) + c.err = ErrRedirect + return true + } + return +} + +func handleMd5Cmd(c *Command) (handled bool) { + return handleHasherCmd(c, "/md5 ", utils.MD5) +} + +func handleSha1Cmd(c *Command) (handled bool) { + return handleHasherCmd(c, "/sha1 ", utils.Sha1) +} + +func handleSha256Cmd(c *Command) (handled bool) { + return handleHasherCmd(c, "/sha256 ", utils.Sha256) +} + +func handleSha512Cmd(c *Command) (handled bool) { + return handleHasherCmd(c, "/sha512 ", utils.Sha512) +} + +func handleHasherCmd(c *Command, prefix string, fn func([]byte) string) (handled bool) { + if strings.HasPrefix(c.message, prefix) { + c.message = strings.TrimPrefix(c.message, prefix) + c.dataMessage = prefix + c.zeroMsg(fn([]byte(c.message))) + c.err = ErrStop + return true + } + return +} + +func handleRmGroupCmd(c *Command) (handled bool) { + if m := rmGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { + groupName := m[1] + if err := c.db.DeleteChatRoomGroup(c.room.ID, groupName); err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } + return false +} + +func handleLockGroupCmd(c *Command) (handled bool) { + if m := lockGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { + groupName := m[1] + group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) + if err != nil { + c.err = err + return true + } + group.Locked = true + group.DoSave(c.db) + c.err = ErrRedirect + return true + } + return false +} + +func handleUnlockGroupCmd(c *Command) (handled bool) { + if m := unlockGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { + groupName := m[1] + group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) + if err != nil { + c.err = err + return true + } + group.Locked = false + group.DoSave(c.db) + c.err = ErrRedirect + return true + } + return false +} + +func handleGroupUsersCmd(c *Command) (handled bool) { + if m := groupUsersRgx.FindStringSubmatch(c.message); len(m) == 2 { + groupName := m[1] + group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) + if err != nil { + c.err = err + return true + } + users, err := c.db.GetRoomGroupUsers(c.room.ID, group.ID) + sort.Slice(users, func(i, j int) bool { + return users[i].User.Username < users[j].User.Username + }) + msg := "" + if len(users) > 0 { + msg += "\n" + for _, user := range users { + msg += user.User.Username.AtStr() + "\n" + } + } else { + msg += "no user in th group: " + groupName + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleListGroupsCmd(c *Command) (handled bool) { + if c.message == "/groups" { + groups, err := c.db.GetRoomGroups(c.room.ID) + if err != nil { + c.err = err + return true + } + msg := "" + if len(groups) > 0 { + msg += "\n" + for _, group := range groups { + msg += group.Name + " (" + group.Color + ")\n" + } + } else { + msg += "no groups" + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleGroupAddUserCmd(c *Command) (handled bool) { + if m := groupAddUserRgx.FindStringSubmatch(c.message); len(m) == 3 { + groupName := m[1] + username := database.Username(m[2]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = err + return true + } + group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) + if err != nil { + c.err = err + return true + } + _, err = c.db.AddUserToRoomGroup(c.room.ID, group.ID, user.ID) + if err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } else if strings.HasPrefix(c.message, "/gadduser ") { + c.err = errors.New("invalid /gadduser command") + } + return false +} + +func handleGroupRmUserCmd(c *Command) (handled bool) { + if m := groupRmUserRgx.FindStringSubmatch(c.message); len(m) == 3 { + groupName := m[1] + username := database.Username(m[2]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = err + return true + } + group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) + if err != nil { + c.err = err + return true + } + err = c.db.RmUserFromRoomGroup(c.room.ID, group.ID, user.ID) + if err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } else if strings.HasPrefix(c.message, "/grmuser ") { + c.err = errors.New("invalid /grmuser command") + } + return false +} + +func handleSetModeWhitelistCmd(c *Command) (handled bool) { + if c.message == "/mode user-whitelist" { + c.room.Mode = database.UserWhitelistRoomMode + c.room.DoSave(c.db) + msg := `room mode set to "user whitelist"` + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleSetModeStandardCmd(c *Command) (handled bool) { + if c.message == "/mode standard" { + c.room.Mode = database.NormalRoomMode + c.room.DoSave(c.db) + msg := `room mode set to "standard"` + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleGetRoomWhitelistCmd(c *Command) (handled bool) { + if m := whitelistUserRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + var msg string + if err != nil { + msg = fmt.Sprintf(`username "%s" not found`, username) + } else { + if _, err := c.db.WhitelistUser(c.room.ID, user.ID); err != nil { + if err := c.db.DeWhitelistUser(c.room.ID, user.ID); err != nil { + msg = fmt.Sprintf("failed to toggle @%s in whitelist", user.Username) + } else { + msg = fmt.Sprintf("@%s removed from whitelist", user.Username) + } + } else { + msg = fmt.Sprintf("@%s added to whitelist", user.Username) + } + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleToggleReadOnlyCmd(c *Command) (handled bool) { + if c.message == "/ro" { + c.room.ReadOnly = !c.room.ReadOnly + c.room.DoSave(c.db) + if c.room.ReadOnly { + c.err = NewErrSuccess("room is now read-only") + } else { + c.err = NewErrSuccess("room is no longer read-only") + } + return true + } + return +} + +func handleAddGroupCmd(c *Command) (handled bool) { + if m := addGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { + name := m[1] + _, err := c.db.CreateChatRoomGroup(c.room.ID, name, "#fff") + if err != nil { + c.err = err + return true + } + c.err = ErrRedirect + return true + } + return false +} + +func handleWhitelistCmd(c *Command) (handled bool) { + if c.message == "/whitelist" || c.message == "/wl" { + whitelistedUsers, _ := c.db.GetWhitelistedUsers(c.room.ID) + var msg string + if len(whitelistedUsers) > 0 { + usernames := make([]string, 0) + for _, whitelistedUser := range whitelistedUsers { + usernames = append(usernames, whitelistedUser.User.Username.AtStr()) + } + sort.Slice(usernames, func(i, j int) bool { return usernames[i] < usernames[j] }) + msg = "whitelisted users: " + strings.Join(usernames, ", ") + } else { + msg = "no whitelisted user" + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleGetModeCmd(c *Command) (handled bool) { + if c.message == "/mode" { + var msg string + if c.room.Mode == database.NormalRoomMode { + msg = `room is in "standard" mode` + } else if c.room.Mode == database.UserWhitelistRoomMode { + msg = `room is in "user whitelist" mode` + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleMeCmd(c *Command) (handled bool) { + if c.message == "/me " { + c.err = errors.New("invalid /me command") + return true + } + if strings.HasPrefix(c.message, "/me ") { + return true + } + return +} + +func handleEditCmd(c *Command) (handled bool) { + if m := editRgx.FindStringSubmatch(c.message); len(m) == 3 { + date := m[1] + newMsg := m[2] + if dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()); err == nil { + if time.Since(dt) <= config.EditMessageTimeLimit { + if msg, err := c.db.GetRoomChatMessageByDate(c.room.ID, c.authUser.ID, dt.UTC()); err == nil { + c.editMsg = &msg + c.origMessage = newMsg + c.message = newMsg + + // If we're editing a message which contains a link to an uploaded file, + // we need to re-add the link to the html. + if msg.UploadID != nil { + if newUpload, err := c.db.GetUploadByID(*msg.UploadID); err == nil { + c.upload = &newUpload + } + } + + if pmRgx.MatchString(c.message) { + handlePMCmd(c) + } else if c.authUser.IsModerator() && strings.HasPrefix(c.message, "/m ") { + handleModeratorGroupCmd(c) + } else if strings.HasPrefix(c.message, "/hbm ") { + handleHbmCmd(c) + } else if strings.HasPrefix(c.message, "/g ") { + handleGroupChatCmd(c) + } else if strings.HasPrefix(c.message, "/system ") || strings.HasPrefix(c.message, "/sys ") { + handleSystemCmd(c) + } + } + } + } + return true + } + return +} + +func handleEditLastCmd(c *Command) (handled bool) { + if c.message == "/e" { + msg, err := c.db.GetUserLastChatMessageInRoom(c.authUser.ID, c.room.ID) + if err != nil { + return true + } + c.redirectQP.Set(RedirectEditQP, msg.CreatedAt.Format("15:04:05")) + c.err = ErrRedirect + return true + } + return +} + +var ErrPMDenied = errors.New("you cannot pm/inbox this user") +var Err20Msgs = errors.New("you need 20 public messages to unlock PMs/Inbox; or be whitelisted") +var ErrOther20Msgs = errors.New("dest user must be whitelisted or have 20 public messages") + +func canUserInboxOther(db *database.DkfDB, user, other database.User) error { + doesNotMatter := false + _, err := canUserPmOther(db, user, other, doesNotMatter) + return err +} + +func canUserPmOther(db *database.DkfDB, user, other database.User, roomIsPrivate bool) (skipInbox bool, err error) { + errPMDenied := ErrPMDenied + + if user.ID == other.ID { + return false, errors.New("cannot /pm yourself") + } + + if db.IsUserPmWhitelisted(user.ID, other.ID) { + return false, nil + } + + switch other.PmMode { + case database.PmModeWhitelist: + // We are in whitelist mode, and user is not whitelisted + return false, errPMDenied + + case database.PmModeStandard: + if !user.CanSendPM() { + // In private rooms, can send PM but inboxes will be skipped if not enough public messages + if roomIsPrivate { + return true, nil + } + // Need at least 20 public messages to send PM in a public room + return false, Err20Msgs + } + + // User on blacklist cannot PM/Inbox + if db.IsUserPmBlacklisted(user.ID, other.ID) { + return false, errPMDenied + } + // Other doesn't want PM from new users + if !user.AccountOldEnough() && other.BlockNewUsersPm { + return false, errPMDenied + } + + if !other.CanSendPM() { + if db.IsUserPmWhitelisted(other.ID, user.ID) { + return true, nil + } + // In private rooms, can send PM but inboxes will be skipped if not enough public messages + if roomIsPrivate { + return true, nil + } + return false, ErrOther20Msgs + } + + return false, nil + } + + // Should never go here + return false, nil +} + +func handlePMCmd(c *Command) (handled bool) { + if m := pmRgx.FindStringSubmatch(c.message); len(m) == 3 { + username := database.Username(m[1]) + newMsg := m[2] + + // Chat helpers + if username == config.NullUsername { + return handlePm0(c, newMsg) + } + + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = errors.New("invalid username") + return true + } + + c.skipInboxes, c.err = canUserPmOther(c.db, *c.authUser, user, c.room.IsOwned()) + if c.err != nil { + return true + } + + c.toUser = &user + c.message = newMsg + c.redirectQP.Set(RedirectPmQP, string(user.Username)) + + if newMsg == "/d" || strings.HasPrefix(newMsg, "/d ") { + handled = handleDeleteMsgCmd(c) + if c.err != nil && c.err != ErrRedirect { + return handled + } + c.err = ErrRedirect + return handled + } + + if handleCodeCmd(c) { + return true + } + + return true + } else if strings.HasPrefix(c.message, "/pm ") { + c.err = errors.New("invalid /pm command") + return true + } + return false +} + +// Handle PMs sent to user 0 (/pm 0 msg) +func handlePm0(c *Command, msg string) (handled bool) { + c.redirectQP.Set(RedirectPmQP, "0") + if msg == "ping" { + c.zeroMsg("pong") + c.err = ErrRedirect + return true + + } else if msg == "talk" { + c.zeroMsg("talking") + c.err = ErrRedirect + return true + + } else if msg == "pgp" || msg == "gpg" { + pkey := c.authUser.GPGPublicKey + if pkey == "" { + c.message = "I could not find a public pgp key in your profile." + } else { + msg := "This is a sample text" + if encrypted, err := utils.GeneratePgpEncryptedMessage(pkey, msg); err != nil { + c.message = err.Error() + } else { + c.message = strings.Join(strings.Split(encrypted, "\n"), " ") + } + } + c.zeroProcMsg(c.message) + c.err = ErrRedirect + return true + + } else if pgpMsg := extractPGPMessage(msg); pgpMsg != "" { + decrypted, err := utils.PgpDecryptMessage(config.NullUserPrivateKey, pgpMsg) + if err != nil { + c.message = err.Error() + } else { + c.message = "Decrypted message: " + decrypted + } + c.zeroProcMsg(c.message) + c.err = ErrRedirect + return true + + } else if b, _ := clearsign.Decode([]byte(msg)); b != nil { + if p, err := packet.Read(b.ArmoredSignature.Body); err == nil { + if sig, ok := p.(*packet.Signature); ok { + zero := c.getZeroUser() + msg := fmt.Sprintf("<br />"+ + "<table %s>"+ + "<tr><td align=\"right\">Signature made:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ + "<tr><td align=\"right\">Fingerprint:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ + "<tr><td align=\"right\">Issuer:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ + "</table>", + zero.GenerateChatStyle(), + sig.CreationTime.Format(time.RFC1123), + utils.FormatPgPFingerprint(sig.IssuerFingerprint), + utils.Ternary(sig.SignerUserId != nil, *sig.SignerUserId, "n/a")) + c.zeroMsg(msg) + c.err = ErrRedirect + return true + } + } + + } else if c.upload != nil { + + // If we sent a clearsign file to @0, the bot will reply with information about the signature + if c.upload.FileSize < config.MaxFileSizeBeforeDownload { + if file, err := c.db.GetUploadByFileName(c.upload.FileName); err == nil { + if _, by, err := file.GetContent(); err == nil { + if b, _ := clearsign.Decode(by); b != nil { + if p, err := packet.Read(b.ArmoredSignature.Body); err == nil { + if sig, ok := p.(*packet.Signature); ok { + zero := c.getZeroUser() + msg := fmt.Sprintf("<br />"+ + "<table %s>"+ + "<tr><td align=\"right\">File:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span> (<span style=\"color: #82e17f;\">%s</span>)</td></tr>"+ + "<tr><td align=\"right\">Signature made:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ + "<tr><td align=\"right\">Fingerprint:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ + "<tr><td align=\"right\">Issuer:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ + "</table>", + zero.GenerateChatStyle(), + c.upload.OrigFileName, + humanize.Bytes(uint64(c.upload.FileSize)), + sig.CreationTime.Format(time.RFC1123), + utils.FormatPgPFingerprint(sig.IssuerFingerprint), + utils.Ternary(sig.SignerUserId != nil, *sig.SignerUserId, "n/a")) + c.zeroMsg(msg) + c.err = ErrRedirect + return true + } + } + } + } + } + } + } + + zeroUser := c.getZeroUser() + c.toUser = &zeroUser + c.message = msg + + return true +} + +func handleSubscribeCmd(c *Command) (handled bool) { + if c.message == "/subscribe" { + _ = c.db.SubscribeToRoom(c.authUser.ID, c.room.ID) + c.err = ErrRedirect + return true + } + return +} + +func handleUnsubscribeCmd(c *Command) (handled bool) { + if m := unsubscribeRgx.FindStringSubmatch(c.message); len(m) == 2 { + room, err := c.db.GetChatRoomByName(m[1]) + if err != nil { + c.err = err + return true + } + _ = c.db.UnsubscribeFromRoom(c.authUser.ID, room.ID) + c.err = ErrRedirect + return true + + } else if c.message == "/unsubscribe" { + _ = c.db.UnsubscribeFromRoom(c.authUser.ID, c.room.ID) + c.err = ErrRedirect + return true + } + return +} + +func handleGroupChatCmd(c *Command) (handled bool) { + if m := groupRgx.FindStringSubmatch(c.message); len(m) == 3 { + groupName := m[1] + c.message = m[2] + group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) + if err != nil { + c.err = err + return true + } + if group.Locked { + c.err = errors.New("group is locked") + return true + } + c.redirectQP.Set(RedirectGroupQP, group.Name) + c.groupID = &group.ID + return true + } else if strings.HasPrefix(c.message, "/g ") { + c.err = errors.New("invalid /g command") + return true + } + return false +} + +func handleListIgnoredCmd(c *Command) (handled bool) { + if c.message == "/i" || c.message == "/ignore" { + ignoredUsers, _ := c.db.GetIgnoredUsers(c.authUser.ID) + sort.Slice(ignoredUsers, func(i, j int) bool { + return ignoredUsers[i].IgnoredUser.Username < ignoredUsers[j].IgnoredUser.Username + }) + msg := "" + if len(ignoredUsers) > 0 { + msg += "\n" + for _, ignoredUser := range ignoredUsers { + msg += ignoredUser.IgnoredUser.Username.AtStr() + "\n" + } + } else { + msg += "no ignored users" + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleListPmWhitelistCmd(c *Command) (handled bool) { + if c.message == "/pmwhitelist" { + pmWhitelistUsers, _ := c.db.GetPmWhitelistedUsers(c.authUser.ID) + sort.Slice(pmWhitelistUsers, func(i, j int) bool { + return pmWhitelistUsers[i].WhitelistedUser.Username < pmWhitelistUsers[j].WhitelistedUser.Username + }) + msg := "" + if len(pmWhitelistUsers) > 0 { + msg += "\n" + for _, ignoredUser := range pmWhitelistUsers { + msg += ignoredUser.WhitelistedUser.Username.AtStr() + "\n" + } + } else { + msg += "no PM whitelisted users" + } + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleSetPmModeWhitelistCmd(c *Command) (handled bool) { + if c.message == "/setpmmode whitelist" { + c.authUser.PmMode = database.PmModeWhitelist + c.authUser.DoSave(c.db) + msg := `pm mode set to "whitelist"` + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleSetPmModeStandardCmd(c *Command) (handled bool) { + if c.message == "/setpmmode standard" { + c.authUser.PmMode = database.PmModeStandard + c.authUser.DoSave(c.db) + msg := `pm mode set to "standard"` + c.zeroProcMsg(msg) + c.err = ErrRedirect + return true + } + return false +} + +func handleTogglePmBlacklistedUser(c *Command) (handled bool) { + if m := pmToggleBlacklistUserRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrRedirect + return true + } + if c.db.ToggleBlacklistedUser(c.authUser.ID, user.ID) { + c.err = NewErrSuccess("added to blacklist") + } else { + c.err = NewErrSuccess("removed from blacklist") + } + return true + } + return false +} + +func handleTogglePmWhitelistedUser(c *Command) (handled bool) { + if m := pmToggleWhitelistUserRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrRedirect + return true + } + if c.db.ToggleWhitelistedUser(c.authUser.ID, user.ID) { + c.err = NewErrSuccess("added to whitelist") + } else { + c.err = NewErrSuccess("removed from whitelist") + } + return true + } + return false +} + +func handleChessCmd(c *Command) (handled bool) { + if m := chessRgx.FindStringSubmatch(c.message); len(m) == 3 { + username := database.Username(m[1]) + color := m[2] + player1 := *c.authUser + player2, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = errors.New("invalid username") + return true + } + if color == "r" { + color = utils.RandChoice([]string{"w", "b"}) + } + if color == "b" { + player1, player2 = player2, player1 + } + if _, err := ChessInstance.NewGame1(c.roomKey, c.room.ID, player1, player2); err != nil { + c.err = err + return true + } + c.err = NewErrSuccess("chess game created") + return true + } + return +} + +func handleInboxCmd(c *Command) (handled bool) { + if m := inboxRgx.FindStringSubmatch(c.message); len(m) == 4 { + username := database.Username(m[1]) + encryptRaw := m[2] + message := m[3] + tryEncrypt := false + if encryptRaw == " -e" { + tryEncrypt = true + } + toUser, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = errors.New("invalid username") + return true + } + + if err := canUserInboxOther(c.db, *c.authUser, toUser); err != nil { + c.err = err + return true + } + + html := message + if tryEncrypt { + if toUser.GPGPublicKey == "" { + c.err = errors.New("user has no pgp public key") + return true + } + html, err = utils.GeneratePgpEncryptedMessage(toUser.GPGPublicKey, message) + if err != nil { + c.err = errors.New("failed to encrypt") + return true + } + html = strings.Join(strings.Split(html, "\n"), " ") + } + + html, _, _ = ProcessRawMessage(c.db, html, c.roomKey, c.authUser.ID, c.room.ID, nil, c.authUser.CanUseMultiline) + c.db.CreateInboxMessage(html, c.room.ID, c.authUser.ID, toUser.ID, true, false, nil) + + c.dataMessage = "/inbox " + string(username) + " " + c.err = NewErrSuccess("inbox sent") + return true + + } else if strings.HasPrefix(c.message, "/inbox ") { + c.err = errors.New("invalid /inbox command") + return true + } + return +} + +func handleProfileCmd(c *Command) (handled bool) { + if m := profileRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrUsernameNotFound + return true + } + profile := `/u/` + user.Username + c.zeroMsg(fmt.Sprintf(`[<a href="%s" rel="noopener noreferrer" target="_blank">profile of %s</a>]`, profile, user.Username)) + c.err = ErrRedirect + return true + } else if strings.HasPrefix(c.message, "/p ") { + c.err = errors.New("invalid profile command") + return true + } + return +} + +type tutorialSteps struct { + SendMessage bool + EditMessage bool + SendPM bool + EditPM bool + SendQuote bool + TagSomeone bool + VisitProfile bool +} + +func handleTutorialCmd(c *Command) (handled bool) { + if c.message == "/tuto" && false { + name := "tuto_" + utils.GenerateToken10() + room, _ := c.db.CreateRoom(name, "", c.authUser.ID, false) + c.err = ErrRedirect + c.zeroProcMsg("Tutorial here -> #" + room.Name) + c.zeroPublicProcMsgRoom("Welcome to the tutorial", "", room.ID) + return true + } + return +} + +func handleDeleteMsgCmd(c *Command) (handled bool) { + delMsgFn := func(msg database.ChatMessage) { + if msg.RoomID == config.GeneralRoomID && msg.ToUserID == nil { + msg.User.GeneralMessagesCount-- + msg.User.DoSave(c.db) + } + _ = msg.Delete(c.db) + } + if c.message == "/d" { + if msg, err := c.db.GetUserLastChatMessageInRoom(c.authUser.ID, c.room.ID); err != nil { + c.err = errors.New("unable to find last message") + return true + } else if msg.TooOldToDelete() { + c.err = errors.New("message is to old to be deleted") + return true + } else { + delMsgFn(msg) + } + c.err = ErrRedirect + return true + + } else if m := deleteMsgRgx.FindStringSubmatch(c.message); len(m) >= 3 { + if len(m) == 3 { + date := m[1] + matchUsername := m[2] + dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()) + if err != nil { + logrus.Error(err) + c.err = err + return true + } + msgs, err := c.db.GetRoomChatMessagesByDate(c.room.ID, dt.UTC()) + if err != nil { + c.err = err + return true + } + if len(msgs) == 0 { + c.err = errors.New("failed to find msg") + return true + + } else if len(msgs) == 1 { + msg := msgs[0] + if !c.authUser.IsModerator() { + if msg.User.Username != c.authUser.Username { + c.err = errors.New("failed to find msg") + return true + } + if msg.TooOldToDelete() { + c.err = errors.New("message is to old to be deleted") + return true + } + delMsgFn(msg) + c.err = ErrRedirect + return true + } + // Moderator + _ = msg.Delete(c.db) + c.err = ErrRedirect + return true + + } else if len(msgs) > 1 { + if !c.authUser.IsModerator() { + var msg database.ChatMessage + for _, msgTmp := range msgs { + if msgTmp.User.Username == c.authUser.Username { + msg = msgTmp + break + } + } + if msg.UUID == "" { + c.err = errors.New("failed to find msg") + return true + } + if msg.TooOldToDelete() { + c.err = errors.New("message is to old to be deleted") + return true + } + delMsgFn(msg) + c.err = ErrRedirect + return true + + } + + // Moderator + if matchUsername == "" { + c.err = errors.New("more the 1 msg with this timestamp") + return true + } + var msg database.ChatMessage + for _, msgTmp := range msgs { + if string(msgTmp.User.Username) == matchUsername { + msg = msgTmp + break + } + } + if msg.UUID == "" { + c.err = errors.New("failed to find msg") + return true + } + _ = msg.Delete(c.db) + c.err = ErrRedirect + return true + } + } + return true + + } else if strings.HasPrefix(c.message, "/d ") { + c.err = errors.New("invalid /d command") + return true + } + return +} + +func handleHideMsgCmd(c *Command) (handled bool) { + if m := hideRgx.FindStringSubmatch(c.message); len(m) == 2 { + date := m[1] + dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()) + if err != nil { + logrus.Error(err) + c.err = err + return true + } + msgs, err := c.db.GetRoomChatMessagesByDate(c.room.ID, dt.UTC()) + if err != nil { + c.err = err + return true + } + if len(msgs) == 1 { + c.db.IgnoreMessage(c.authUser.ID, msgs[0].ID) + c.err = ErrRedirect + } else { + c.err = errors.New("more than 1 message") + } + return true + } + return +} + +func handleUnHideMsgCmd(c *Command) (handled bool) { + if m := unhideRgx.FindStringSubmatch(c.message); len(m) == 2 { + date := m[1] + dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()) + if err != nil { + logrus.Error(err) + c.err = err + return true + } + msgs, err := c.db.GetRoomChatMessagesByDate(c.room.ID, dt.UTC()) + if err != nil { + c.err = err + return true + } + if len(msgs) == 1 { + c.db.UnIgnoreMessage(c.authUser.ID, msgs[0].ID) + c.err = ErrRedirect + } else { + c.err = errors.New("more than 1 message") + } + return true + } + return +} + +func handleIgnoreCmd(c *Command) (handled bool) { + if m := ignoreRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrRedirect + return true + } + c.db.IgnoreUser(c.authUser.ID, user.ID) + database.MsgPubSub.Pub("refresh_"+string(c.authUser.Username), database.ChatMessageType{Typ: database.ForceRefresh}) + c.err = ErrRedirect + return true + } else if strings.HasPrefix(c.message, "/ignore ") || strings.HasPrefix(c.message, "/i ") { + c.err = errors.New("invalid ignore command") + return true + } + return +} + +func handleUnIgnoreCmd(c *Command) (handled bool) { + if m := unIgnoreRgx.FindStringSubmatch(c.message); len(m) == 2 { + username := database.Username(m[1]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = ErrRedirect + return true + } + c.db.UnIgnoreUser(c.authUser.ID, user.ID) + database.MsgPubSub.Pub("refresh_"+string(c.authUser.Username), database.ChatMessageType{Typ: database.ForceRefresh}) + c.err = ErrRedirect + return true + } else if strings.HasPrefix(c.message, "/unignore ") || strings.HasPrefix(c.message, "/ui ") { + c.err = errors.New("invalid unignore command") + return true + } + return +} + +func handleToggleAutocomplete(c *Command) (handled bool) { + if c.message == "/toggle-autocomplete" { + c.authUser.AutocompleteCommandsEnabled = !c.authUser.AutocompleteCommandsEnabled + c.authUser.DoSave(c.db) + c.err = ErrRedirect + return true + } + return +} + +func handleAfkCmd(c *Command) (handled bool) { + if c.message == "/afk" { + c.authUser.AFK = !c.authUser.AFK + c.authUser.DoSave(c.db) + c.err = ErrRedirect + return true + } + return +} + +func handleDateCmd(c *Command) (handled bool) { + if c.message == "/date" { + c.zeroMsg(time.Now().Format(time.RFC1123)) + c.err = ErrRedirect + return true + } + return +} + +func handleSuccessCmd(c *Command) (handled bool) { + if c.message == "/success" { + c.err = NewErrSuccess("success message") + return true + } + return +} + +func handleErrorCmd(c *Command) (handled bool) { + if c.message == "/error" { + c.err = errors.New("error message") + return true + } + return +} + +func handleSystemCmd(c *Command) (handled bool) { + if strings.HasPrefix(c.message, "/sys ") { + c.message = strings.Replace(c.message, "/sys ", "/system ", 1) + } + if strings.HasPrefix(c.message, "/system ") { + c.message = strings.TrimPrefix(c.message, "/system ") + c.systemMsg = true + return true + } + return false +} + +func handleSetChatRoomExternalLink(c *Command) (handled bool) { + if m := setUrlRgx.FindStringSubmatch(c.message); len(m) == 2 { + externalURL := m[1] + if !govalidator.IsURL(externalURL) { + externalURL = "" + } + room, err := c.db.GetChatRoomByID(c.room.ID) + if err != nil { + c.err = err + return true + } + room.ExternalLink = externalURL + room.DoSave(c.db) + c.err = ErrRedirect + return true + } + return +} + +func handlePurge(c *Command) (handled bool) { + if m := purgeRgx.FindStringSubmatch(c.message); len(m) == 3 { + isHB := m[1] == " -hb" + username := database.Username(m[2]) + user, err := c.db.GetUserByUsername(username) + if err != nil { + c.err = err + return true + } + c.db.NewAudit(*c.authUser, fmt.Sprintf("purge %s #%d", user.Username, user.ID)) + if isHB { + _ = c.db.DeleteUserHbChatMessages(user.ID) + } else { + _ = c.db.DeleteUserChatMessages(user.ID) + } + database.MsgPubSub.Pub(database.RefreshTopic, database.ChatMessageType{Typ: database.ForceRefresh}) + c.err = ErrRedirect + return true + } + return +} + +func handleRename(c *Command) (handled bool) { + if m := renameRgx.FindStringSubmatch(c.message); len(m) == 3 { + oldUsername := database.Username(m[1]) + newUsername := database.Username(m[2]) + user, err := c.db.GetUserByUsername(oldUsername) + if err != nil { + c.err = err + return true + } + c.db.NewAudit(*c.authUser, fmt.Sprintf("rename %s -> %s #%d", user.Username, newUsername, user.ID)) + + if err := c.db.CanRenameTo(oldUsername, newUsername); err != nil { + c.err = err + return true + } + + managers.ActiveUsers.RemoveUser(user.ID) + user.Username = newUsername + user.DoSave(c.db) + + c.err = ErrRedirect + return true + } + return +} + +func handleNewMeme(c *Command) (handled bool) { + if m := memeRgx.FindStringSubmatch(c.message); len(m) == 2 { + if c.upload == nil { + c.err = errors.New("no file uploaded") + return true + } + slug := m[1] + oldPath := filepath.Join(config.Global.ProjectUploadsPath(), c.upload.FileName) + newPath := filepath.Join(config.Global.ProjectMemesPath(), c.upload.FileName) + _ = os.Rename(oldPath, newPath) + + if err := c.db.DB().Delete(&c.upload).Error; err != nil { + logrus.Error(err) + } + + meme := database.Meme{ + Slug: slug, + FileName: c.upload.FileName, + OrigFileName: c.upload.OrigFileName, + FileSize: c.upload.FileSize, + } + if err := c.db.DB().Create(&meme).Error; err != nil { + _ = os.Remove(newPath) + logrus.Error(err) + } + + c.err = ErrRedirect + return true + } + return +} + +func handleRemoveMeme(c *Command) (handled bool) { + if m := memeRemoveRgx.FindStringSubmatch(c.message); len(m) == 2 { + slug := m[1] + meme, err := c.db.GetMemeBySlug(slug) + if err != nil { + c.err = errors.New("meme not found") + return true + } + if err := meme.Delete(c.db); err != nil { + c.err = err + return true + } + c.err = NewErrSuccess("meme removed") + return true + } + return +} + +func handleRenameMeme(c *Command) (handled bool) { + if m := memeRenameRgx.FindStringSubmatch(c.message); len(m) == 3 { + slug := m[1] + newSlug := m[2] + meme, err := c.db.GetMemeBySlug(slug) + if err != nil { + c.err = errors.New("meme not found") + return true + } + meme.Slug = newSlug + meme.DoSave(c.db) + c.err = NewErrSuccess("meme renamed") + return true + } + return +} + +func handleListMemes(c *Command) (handled bool) { + if m := memesRgx.FindStringSubmatch(c.message); len(m) == 1 { + memes, _ := c.db.GetMemes() + msg := "" + for _, m := range memes { + msg += fmt.Sprintf(`<a href="/memes/%s" rel="noopener noreferrer" target="_blank">%s</a>`, m.Slug, m.Slug) + if c.authUser.IsAdmin { + msg += fmt.Sprintf(` (%s)`, humanize.Bytes(uint64(m.FileSize))) + } + msg += "<br />" + } + c.zeroMsg(msg) + c.err = ErrRedirect + return true + } + return +} + +func handleRefreshCmd(c *Command) (handled bool) { + if c.message == "/refresh" { + c.err = ErrRedirect + database.MsgPubSub.Pub(database.RefreshTopic, database.ChatMessageType{Typ: database.ForceRefresh}) + return true + } + return +} + +func handleCodeCmd(c *Command) (handled bool) { + if c.message == "/code" { + c.err = ErrRedirect + if !c.authUser.CanUseMultiline { + c.err = errors.New("multiline is disabled for your account") + return true + } else if !c.authUser.UseStream { + c.err = errors.New("only work on stream version of this chat") + return true + } + payload := database.ChatMessageType{} + if c.modMsg { + payload.IsMod = true + } + if c.toUser != nil { + toUserUsername := c.toUser.Username + payload.ToUserUsername = &toUserUsername + } + // ModalManager.Pub("code", payload) + database.MsgPubSub.Pub("modal_code_show_"+c.authUser.ID.String()+"_"+c.room.ID.String(), payload) + return true + } + return +} + +func handleUpdateReadMarkerCmd(c *Command) (handled bool) { + if c.message == "/r" { + c.db.UpdateChatReadMarker(c.authUser.ID, c.room.ID) + c.err = ErrRedirect + return true + } + return +} diff --git a/pkg/web/handlers/api/v1/interceptors/snippetInterceptor.go b/pkg/web/handlers/api/v1/interceptors/snippetInterceptor.go @@ -0,0 +1,57 @@ +package interceptors + +import ( + "dkforest/pkg/database" + "dkforest/pkg/managers" + "strings" +) + +type SnippetInterceptor struct{} + +func (i SnippetInterceptor) InterceptMsg(cmd *Command) { + // Snippets actually mutate the original message, + // to simulate that the user actually typed the text + cmd.origMessage = snippets(cmd.db, cmd.authUser.ID, cmd.origMessage) + + cmd.origMessage = autocompleteTags(cmd.origMessage) + + cmd.message = cmd.origMessage +} + +func snippets(db *database.DkfDB, authUserID database.UserID, html string) string { + if snippetRgx.MatchString(html) { + userSnippets, _ := db.GetUserSnippets(authUserID) + if len(userSnippets) > 0 { + // Build hashmap for fast lookup + m := make(map[string]string) + for _, snippet := range userSnippets { + m["!"+snippet.Name] = snippet.Text + } + html = snippetRgx.ReplaceAllStringFunc(html, func(s string) string { + // If snippet name exists, use the mapped value + if v, ok := m[s]; ok { + return v + } + return s + }) + } + } + return html +} + +func autocompleteTags(html string) string { + activeUsers := managers.ActiveUsers.GetActiveUsers() + html = autoTagRgx.ReplaceAllStringFunc(html, func(s string) string { + s1 := strings.TrimPrefix(s, "@") + s1 = strings.TrimSuffix(s1, "*") + s1 = strings.ToLower(s1) + for _, au := range activeUsers { + l := strings.ToLower(string(au.Username)) + if strings.HasPrefix(l, s1) { + return au.Username.AtStr() + } + } + return s + }) + return html +} diff --git a/pkg/web/handlers/api/v1/interceptors/spamInterceptor.go b/pkg/web/handlers/api/v1/interceptors/spamInterceptor.go @@ -0,0 +1,265 @@ +package interceptors + +import ( + "dkforest/pkg/config" + "dkforest/pkg/database" + dutils "dkforest/pkg/database/utils" + "dkforest/pkg/utils" + "errors" + "github.com/sirupsen/logrus" + "regexp" + "strings" + "sync" + "time" +) + +type SpamInterceptor struct{} + +type Filter struct { + IsRegex bool + Term string + Rgx *regexp.Regexp + Kick bool + Hb bool +} + +var filters []Filter +var filtersMtx sync.RWMutex + +func LoadFilters(db *database.DkfDB) { + filtersMtx.Lock() + defer filtersMtx.Unlock() + filters = make([]Filter, 0) + dbFilters, _ := db.GetSpamFilters() + for _, dbFilter := range dbFilters { + f := Filter{IsRegex: dbFilter.IsRegex} + if dbFilter.Action == 1 { + f.Kick = true + } else if dbFilter.Action == 2 { + f.Hb = true + } + if dbFilter.IsRegex { + f.Rgx = regexp.MustCompile(dbFilter.Filter) + } else { + f.Term = dbFilter.Filter + } + filters = append(filters, f) + } +} + +// Check the filters that we have in the database. +func checkDynamicFilters(c *Command, lowerCaseMessage string, silentSelfKick bool) error { + filtersMtx.RLock() + defer filtersMtx.RUnlock() + for _, f := range filters { + isMatch := (f.IsRegex && f.Rgx.MatchString(c.message)) || + (!f.IsRegex && strings.Contains(lowerCaseMessage, f.Term)) + if isMatch { + if f.Hb { + dutils.SelfHellBan(c.db, c.authUser) + return ErrSilent + } + if f.Kick { + _ = dutils.SelfKick(c.db, *c.authUser, silentSelfKick) + } + return ErrSpamFilterTriggered + } + } + return nil +} + +func (i SpamInterceptor) InterceptMsg(c *Command) { + lowerCaseMessage := strings.ToLower(c.message) + silentSelfKick := config.SilentSelfKick.Load() + + if err := checkDynamicFilters(c, lowerCaseMessage, silentSelfKick); err != nil { + if !errors.Is(err, ErrSilent) { + c.err = err + } + return + } + + if c.room.IsOfficialRoom() { + if err := checkSpam(c.db, c.origMessage, lowerCaseMessage, c.authUser); err != nil { + c.err = err + return + } + } + + // Check CP links + if checkCPLinks(c.db, c.message) { + c.err = errors.New("forbidden url") + return + } + + if !c.authUser.CanUseUppercase { + c.message = strings.ToLower(c.message) + } +} + +var ErrSilent = errors.New("") +var ErrSpamFilterTriggered = errors.New("spam filter triggered") + +func checkSpam(db *database.DkfDB, origMessage, lowerCaseMessage string, authUser *database.User) error { + silentSelfKick := config.SilentSelfKick.Load() + + // Kick retard new users + if time.Since(authUser.CreatedAt) < 5*time.Hour { + if strings.Contains(lowerCaseMessage, "fucked up links") || + strings.Contains(lowerCaseMessage, "i wanna see gore") || + strings.Contains(lowerCaseMessage, "how can i make money") || + strings.Contains(lowerCaseMessage, "any links for scary stuff") { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + if authUser.GeneralMessagesCount < 20 || time.Since(authUser.CreatedAt) < 5*time.Hour { + if strings.Contains(lowerCaseMessage, "cp link") { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + + if strings.Contains(lowerCaseMessage, "#dorkforest") { + if authUser.IsModerator() { + return ErrSpamFilterTriggered + } + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + + // Auto kick upper case typing retards + if authUser.GeneralMessagesCount <= 5 { + count, total := utils.CountUppercase(origMessage) + pct := float64(count) / float64(total) + if total > 5 && pct > 0.8 { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + + // Auto HB "new here"/"legit market" retards + if autoHellbanCheck(authUser, lowerCaseMessage) { + dutils.SelfHellBan(db, authUser) + return nil + } + + if autoKickSpammers(authUser, lowerCaseMessage) { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + + tot, wordsMap := utils.WordCount(lowerCaseMessage) + if tot >= 5 { + totalUniqueWords := len(wordsMap) + uniqueRatio := float64(totalUniqueWords) / float64(tot) + repeatedWordsCount := 0 + for word, count := range wordsMap { + if len(word) >= 5 && count > 10 { + repeatedWordsCount++ + } + } + retardRatio := float64(repeatedWordsCount) / float64(totalUniqueWords) + //fmt.Println(tot, totalUniqueWords, uniqueRatio, repeatedWordsCount, retardRatio, wordsMap) + if uniqueRatio < 0.2 { + logrus.Error("failed unique ratio: " + origMessage) + return errors.New("failed unique ratio") + } + if retardRatio > 0.1 { + logrus.Error("failed retard ratio: " + origMessage) + return errors.New("failed retard ratio") + } + } + + if authUser.GeneralMessagesCount < 10 { + if autoKickProfanity(tot, wordsMap) { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + + if authUser.GeneralMessagesCount < 4 { + if (wordsMap["need"] > 0 && wordsMap["help"] > 0) || + (wordsMap["help"] > 0 && wordsMap["me"] > 0) || + (wordsMap["make"] > 0 && wordsMap["money"] > 0) || + wordsMap["porn"] > 0 || + wordsMap["murder"] > 0 { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + + if authUser.GeneralMessagesCount < 10 { + if ((wordsMap["learn"] > 0 || wordsMap["teach"] > 0) && (wordsMap["hacking"] > 0 || wordsMap["hack"] > 0)) || + (wordsMap["cook"] > 0 && wordsMap["meth"] > 0) || + (wordsMap["creepy"] > 0 && (wordsMap["site"] > 0 || wordsMap["sites"] > 0)) || + (wordsMap["porn"] > 0 && (wordsMap["link"] > 0 || wordsMap["links"] > 0)) || + (wordsMap["topic"] > 0 && wordsMap["link"] > 0) { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + + if authUser.GeneralMessagesCount < 20 || time.Since(authUser.CreatedAt) < 5*time.Hour { + if wordsMap["cp"] > 0 && (wordsMap["link"] > 0 || wordsMap["links"] > 0) { + _ = dutils.SelfKick(db, *authUser, silentSelfKick) + return ErrSpamFilterTriggered + } + } + + return nil +} + +func autoKickProfanityTmp(orig string) bool { + tot, m := utils.WordCount(strings.ToLower(orig)) + return autoKickProfanity(tot, m) +} + +func autoKickProfanity(tot int, wordsMap map[string]int) bool { + if tot > 4 && countProfanity(wordsMap) >= 4 { + return true + } + return false +} + +func countProfanity(wordsMap map[string]int) int { + profanityWords := []string{"anus", "asshole", "cock", "dick", "nigger", "niggers", "nigga", "niggas", "sex", "rape", "porn", + "cunt", "murder", "fuck", "blood", "corpse", "hole", "slut", "bitch", "shit", "poop", "butt", "faggot", + "submissive", "slurping", "suck", "nuts", "gore", "stupid", "dumb", "jerking", "rotten", "rotted", "stinky"} + profanity := 0 + for _, w := range profanityWords { + if n, ok := wordsMap[w]; ok { + profanity += n + } + } + return profanity +} + +var spamCharsRgx = regexp.MustCompile("[^a-z0-9]+") + +func autoKickSpammers(authUser *database.User, lowerCaseMessage string) bool { + if authUser.GeneralMessagesCount <= 10 { + processedString := spamCharsRgx.ReplaceAllString(lowerCaseMessage, "") + return strings.Contains(processedString, "lemybeauty") || + strings.Contains(processedString, "blacktorcc") || + strings.Contains(processedString, "profjerry") || + strings.Contains(processedString, "shopdarkse") + } + return false +} + +func autoHellbanCheck(authUser *database.User, lowerCaseMessage string) bool { + checks := []string{ + "new here", + "legit market", + "help me", + } + if authUser.GeneralMessagesCount <= 5 { + for _, check := range checks { + if strings.Contains(lowerCaseMessage, check) { + return true + } + } + } + return false +} diff --git a/pkg/web/handlers/api/v1/interceptors/spamInterceptor_test.go b/pkg/web/handlers/api/v1/interceptors/spamInterceptor_test.go @@ -0,0 +1,69 @@ +package interceptors + +import ( + "dkforest/pkg/database" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_autoHellbanCheck(t *testing.T) { + type args struct { + authUser *database.User + lowerCaseMessage string + } + tests := []struct { + name string + args args + want bool + }{ + {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "hi new here"}, want: true}, + {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "hello anybody know of any legit market places ? its getting tough on here to find any that actually do what they supposed to "}, want: true}, + {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "Hello Guys and Ladys someone can help me? I Have a Little problem.."}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, autoHellbanCheck(tt.args.authUser, tt.args.lowerCaseMessage), "autoHellbanCheck(%v, %v)", tt.args.authUser, tt.args.lowerCaseMessage) + }) + } +} + +func Test_autoKickSpammers(t *testing.T) { + type args struct { + authUser *database.User + lowerCaseMessage string + } + tests := []struct { + name string + args args + want bool + }{ + {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "blablabla l e m y _ b e a u t y on "}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, autoKickSpammers(tt.args.authUser, tt.args.lowerCaseMessage), "autoKickSpammers(%v, %v)", tt.args.authUser, tt.args.lowerCaseMessage) + }) + } +} + +func Test_autoKickProfanityTmp(t *testing.T) { + type args struct { + orig string + } + tests := []struct { + name string + args args + want bool + }{ + {"", args{orig: "biden is dumb fuck can suck my dick stupid nigger"}, true}, + {"", args{orig: "u can suck his nuts like the submissive faggot u are. slurping eye contact for deep man love with dirty butthole sniffing."}, true}, + {"", args{orig: "how to tear a human slut bitch from the cunt to the part in her hairline then shit into the chest cavity for happy dumpling poop soup"}, true}, + {"", args{orig: "lets murder a nun and fuck the blood scabs into her corpse pussy hole"}, true}, + {"", args{orig: "quick question, whats the best method to plant a grenaed in old ladys stinky rotted cunt hole to blast her bloods on a hotel walls"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, autoKickProfanityTmp(tt.args.orig), "autoKickProfanityTmp(%v)", tt.args.orig) + }) + } +} diff --git a/pkg/web/handlers/api/v1/interceptors/uploadInterceptor.go b/pkg/web/handlers/api/v1/interceptors/uploadInterceptor.go @@ -0,0 +1,77 @@ +package interceptors + +import ( + "dkforest/pkg/config" + "dkforest/pkg/database" + "dkforest/pkg/utils" + hutils "dkforest/pkg/web/handlers/utils" + "errors" + "fmt" + "github.com/asaskevich/govalidator" + "github.com/dustin/go-humanize" + "github.com/sirupsen/logrus" + "io/ioutil" + "mime/multipart" +) + +type UploadInterceptor struct{} + +func (i UploadInterceptor) InterceptMsg(cmd *Command) { + if file, handler, uploadErr := cmd.c.Request().FormFile("file"); uploadErr == nil { + // Save file on disk & database & append file link to html + var err error + cmd.upload, err = handleUploadedFile(cmd.db, file, handler, cmd.authUser) + if err != nil { + cmd.err = err + return + } + } +} + +func handleUploadedFile(db *database.DkfDB, file multipart.File, handler *multipart.FileHeader, authUser *database.User) (*database.Upload, error) { + defer file.Close() + if !authUser.CanUpload() { + return nil, hutils.AccountTooYoungErr + } + userSizeUploaded := db.GetUserTotalUploadSize(authUser.ID) + if handler.Size+userSizeUploaded > config.MaxUserTotalUploadSize { + return nil, fmt.Errorf("user upload limit reached (%s)", humanize.Bytes(config.MaxUserTotalUploadSize)) + } + origFileName := handler.Filename + if handler.Size > config.MaxUserFileUploadSize { + return nil, fmt.Errorf("the maximum file size is %s", humanize.Bytes(config.MaxUserFileUploadSize)) + } + if !govalidator.StringLength(origFileName, "3", "50") { + return nil, errors.New("invalid file name, 3-50 characters") + } + if !govalidator.IsPrintableASCII(origFileName) { + return nil, errors.New("file name must be ascii printable only") + } + origFileName = tzRgx.ReplaceAllString(origFileName, "xxxx-xx-xx at xx.xx.xx XX") + origFileName = tz1Rgx.ReplaceAllString(origFileName, "xxxx-xx-xx xx-xx-xx") + origFileName = tz3Rgx.ReplaceAllString(origFileName, "xxxx-xx-xx xxxxxx") + origFileName = tz4Rgx.ReplaceAllString(origFileName, "xxxx-xx-xx_xx_xx_xx") + fileBytes, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + + // Validate image type and determine extension + mimeType := handler.Header.Get("Content-Type") + if mimeType == "image/jpeg" { + fileBytes, err = utils.ReencodeJpg(fileBytes) + } else if mimeType == "image/png" { + fileBytes, err = utils.ReencodePng(fileBytes) + } + if err != nil { + return nil, err + } + + // Uploaded files are encrypted on disk + upload, err := db.CreateEncryptedUploadWithSize(origFileName, fileBytes, authUser.ID, handler.Size) + if err != nil { + logrus.Error(err) + return nil, err + } + return upload, nil +} diff --git a/pkg/web/handlers/api/v1/interceptors/werewolf.go b/pkg/web/handlers/api/v1/interceptors/werewolf.go @@ -0,0 +1,701 @@ +package interceptors + +import ( + "bytes" + "context" + "dkforest/pkg/config" + "dkforest/pkg/database" + "dkforest/pkg/hashset" + "dkforest/pkg/utils" + "errors" + "fmt" + "github.com/sirupsen/logrus" + "html/template" + "math/rand" + "sort" + "strings" + "time" +) + +var WWInstance *Werewolf + +const ( + PreGameState = iota + 1 + DayState + NightState + VoteState + EndGameState +) + +const ( + TownspeopleRole = "townspeople" + WerewolfRole = "werewolf" + SeerRole = "seer" + HealerRole = "healer" +) + +var ErrInvalidPlayerName = errors.New("unknown player name, please send a valid name") + +type Werewolf struct { + db *database.DkfDB + ctx context.Context + cancel context.CancelFunc + readyCh chan bool + narratorID database.UserID + roomID database.RoomID + werewolfGroupID database.GroupID + spectatorGroupID database.GroupID + deadGroupID database.GroupID + players map[database.Username]*Player + playersAlive map[database.Username]*Player + state int64 + werewolfSet *hashset.HashSet[database.UserID] + spectatorSet *hashset.HashSet[database.UserID] + townspersonSet *hashset.HashSet[database.UserID] + healerID *database.UserID + seerID *database.UserID + werewolfCh chan string + seerCh chan string + healerCh chan string + votesCh chan string + voted *hashset.HashSet[database.UserID] // Keep track of which user voted already +} + +// Return either or not the userID is an active player (alive) +func (b *Werewolf) isAlivePlayer(userID database.UserID) bool { + for _, player := range b.playersAlive { + if player.UserID == userID { + return true + } + } + return false +} + +func (b *Werewolf) InterceptPreGameMsg(cmd *Command) { + if cmd.message == "/players" { + b.Narrate("Registered players: "+b.alivePlayersStr(), nil, nil) + cmd.err = ErrRedirect + return + + } else if cmd.message == "/join" { + if cmd.authUser.IsHellbanned { + cmd.err = ErrRedirect + return + } + if _, found := b.players[cmd.authUser.Username]; found { + cmd.err = ErrRedirect + return + } + player := &Player{ + UserID: cmd.authUser.ID, + Username: cmd.authUser.Username, + } + b.players[cmd.authUser.Username] = player + b.playersAlive[cmd.authUser.Username] = player + b.Narrate(cmd.authUser.Username.AtStr()+" joined the Game", nil, nil) + cmd.err = ErrRedirect + return + + } else if cmd.message == "/spectate" { + b.spectatorSet.Insert(cmd.authUser.ID) + b.Narrate(cmd.authUser.Username.AtStr()+" spectate the Game", nil, nil) + cmd.err = ErrRedirect + return + + } else if cmd.message == "/start" { + b.cancel() + time.Sleep(time.Second) + utils.SGo(func() { + b.StartGame(cmd.db) + }) + cmd.err = ErrRedirect + return + } +} + +func (b *Werewolf) InterceptNightMsg(cmd *Command) { + if cmd.groupID != nil && *cmd.groupID == b.werewolfGroupID { + select { + case b.werewolfCh <- cmd.message: + cmd.err = ErrRedirect + default: + cmd.err = errors.New("narrator doesn't need your input") + } + return + } else if b.isForNarrator(cmd) && b.seerID != nil && cmd.authUser.ID == *b.seerID { + select { + case b.seerCh <- cmd.message: + cmd.err = ErrRedirect + default: + cmd.err = errors.New("narrator doesn't need your input") + } + return + } else if b.isForNarrator(cmd) && b.healerID != nil && cmd.authUser.ID == *b.healerID { + select { + case b.healerCh <- cmd.message: + cmd.err = ErrRedirect + default: + cmd.err = errors.New("narrator doesn't need your input") + } + return + } + cmd.err = errors.New("chat disabled") + return +} + +// Return either or not the message is a PM for the narrator +func (b *Werewolf) isForNarrator(cmd *Command) bool { + return cmd.toUser != nil && cmd.toUser.ID == b.narratorID +} + +func (b *Werewolf) InterceptVoteMsg(cmd *Command) { + if !b.isAlivePlayer(cmd.authUser.ID) || !b.isForNarrator(cmd) { + cmd.err = errors.New("chat disabled") + return + } + if b.isForNarrator(cmd) { + if !b.voted.Contains(cmd.authUser.ID) { + name := cmd.message + if b.isValidPlayerName(name) { + b.votesCh <- name + } else { + b.Narrate(ErrInvalidPlayerName.Error(), &cmd.authUser.ID, nil) + } + } else { + b.Narrate("You have already voted", &cmd.authUser.ID, nil) + } + } +} + +var tuto = `Tutorial: +"/join" to join the Game +"/players" list the players that have joined the Game +"/start" to start the Game +"/stop" to stop the Game +"/ready" will skip the 5min conversation +"/tuto" will display this tutorial +"/clear" will reset the room and display this tutorial + +Werewolf: To kill someone during the night, you have to reply in the "werewolf" group with the name of the person to kill (no @) +Seer/Healer: You have reply to the narrator with the name (eg: "/pm 0 n0tr1v") +Townspeople: To vote, you have to pm the narrator with a name (eg: "/pm 0 n0tr1v")` + +func (b *Werewolf) InterceptMsg(cmd *Command) { + if cmd.room.ID != b.roomID { + return + } + + SlashInterceptor{}.InterceptMsg(cmd) + + // If the message is a PM not for the narrator, we reject it + if cmd.toUser != nil && (cmd.toUser.ID != b.narratorID && cmd.authUser.ID != b.narratorID) { + cmd.err = errors.New("PM not allowed at this room") + return + } + + // Spectator can chat all the time + if cmd.groupID != nil && *cmd.groupID == b.spectatorGroupID { + return + } + + if cmd.authUser.IsModerator() && cmd.message == "/stop" { + b.Narrate(fmt.Sprintf("@%s used /stop", cmd.authUser.Username), nil, nil) + b.cancel() + cmd.err = ErrRedirect + return + } else if cmd.authUser.IsModerator() && cmd.message == "/ready" { + b.Narrate(fmt.Sprintf("@%s used /ready", cmd.authUser.Username), nil, nil) + b.readyCh <- true + cmd.err = ErrRedirect + return + } else if cmd.authUser.IsModerator() && cmd.message == "/tuto" { + b.Narrate(tuto, nil, nil) + cmd.err = ErrRedirect + return + } else if cmd.authUser.IsModerator() && cmd.message == "/clear" { + _ = cmd.db.DeleteChatRoomMessages(b.roomID) + b.Narrate(tuto, nil, nil) + cmd.err = ErrRedirect + return + } + + // Anyone can talk during these states + if b.state == PreGameState || b.state == EndGameState { + if b.state == PreGameState { + b.InterceptPreGameMsg(cmd) + } + return + } + + // Otherwise, non-playing people cannot talk in public chat + if !b.isAlivePlayer(cmd.authUser.ID) { + cmd.err = errors.New("public chat disabled") + return + } + + switch b.state { + case DayState: + case VoteState: + b.InterceptVoteMsg(cmd) + case NightState: + b.InterceptNightMsg(cmd) + default: + cmd.err = errors.New("public chat disabled") + return + } +} + +// Wait until we receive the votes from all the players +func (b *Werewolf) waitVotes() (votes []string) { + for len(votes) < len(b.playersAlive) { + var vote string + select { + case vote = <-b.votesCh: + case <-time.After(15 * time.Second): + b.Narrate(fmt.Sprintf("Waiting votes %d/%d", len(votes), len(b.playersAlive)), nil, nil) + continue + case <-b.ctx.Done(): + return + } + votes = append(votes, vote) + } + return +} + +func (b *Werewolf) waitNameFromWerewolf() (name string) { + for { + select { + case name = <-b.werewolfCh: + case <-time.After(15 * time.Second): + b.Narrate("Waiting reply from werewolf", nil, nil) + continue + case <-b.ctx.Done(): + return + } + if b.isValidPlayerName(name) { + break + } + b.Narrate(ErrInvalidPlayerName.Error(), nil, &b.werewolfGroupID) + } + return name +} + +func (b *Werewolf) waitNameFromSeer() (name string) { + for { + select { + case name = <-b.seerCh: + case <-time.After(15 * time.Second): + b.Narrate("Waiting reply from seer", nil, nil) + continue + case <-b.ctx.Done(): + return + } + if b.isValidPlayerName(name) { + break + } + b.Narrate(ErrInvalidPlayerName.Error(), b.seerID, nil) + } + return name +} + +func (b *Werewolf) waitNameFromHealer() (name string) { + for { + select { + case name = <-b.healerCh: + case <-time.After(15 * time.Second): + b.Narrate("Waiting reply from healer", nil, nil) + continue + case <-b.ctx.Done(): + return + } + if b.isValidPlayerName(name) { + break + } + b.Narrate(ErrInvalidPlayerName.Error(), b.healerID, nil) + } + return name +} + +// Return either a name is a valid alive player name or not +func (b *Werewolf) isValidPlayerName(name string) bool { + name = strings.TrimSpace(name) + for _, player := range b.playersAlive { + if string(player.Username) == name { + return true + } + } + return false +} + +// Narrate register a chat message on behalf of the narrator user +func (b *Werewolf) Narrate(msg string, toUserID *database.UserID, groupID *database.GroupID) { + html, _, _ := ProcessRawMessage(b.db, msg, "", b.narratorID, b.roomID, nil, true) + b.NarrateRaw(html, toUserID, groupID) +} + +func (b *Werewolf) NarrateRaw(msg string, toUserID *database.UserID, groupID *database.GroupID) { + _, _ = b.db.CreateOrEditMessage(nil, msg, msg, "", b.roomID, b.narratorID, toUserID, nil, groupID, false, false, false) +} + +// Display roles assigned at beginning of the Game +func (b *Werewolf) displayRoles() { + msg := "Roles were:\n" + for _, player := range b.players { + msg += player.Username.AtStr() + " : " + player.Role + "\n" + } + b.Narrate(msg, nil, nil) +} + +func (b *Werewolf) StartGame(db *database.DkfDB) { + defer func() { + b.displayRoles() + b.reset() + }() + b.ctx, b.cancel = context.WithCancel(context.Background()) + // Assign roles + playersArr := make([]*Player, 0) + for _, player := range b.playersAlive { + playersArr = append(playersArr, player) + } + rand.Shuffle(len(playersArr), func(i, j int) { playersArr[i], playersArr[j] = playersArr[j], playersArr[i] }) + for idx, player := range playersArr { + if idx == 0 { + b.werewolfSet.Insert(player.UserID) + _, _ = db.AddUserToRoomGroup(b.roomID, b.werewolfGroupID, player.UserID) + player.Role = WerewolfRole + werewolfMsg := "During the day you seem to be a regular Townsperson.\n" + + "However, you’ve been kissed by the Night and transform into a Werewolf when the sun sets.\n" + + "Your new nature compels you to kill and eat a Townsperson every night." + b.Narrate(werewolfMsg, &player.UserID, nil) + } else if idx == 1 { + b.townspersonSet.Insert(player.UserID) + b.healerID = &player.UserID + player.Role = HealerRole + healerMsg := "You’re a Townsperson with the unique ability to save lives.\n" + + "During the night, you’ll get a chance to protect another Townsperson from death if they are attacked by the Werewolves.\n" + + "You can choose to protect yourself." + b.Narrate(healerMsg, &player.UserID, nil) + } else if idx == 2 { + b.townspersonSet.Insert(player.UserID) + b.seerID = &player.UserID + player.Role = SeerRole + seerMsg := "You’re a Townsperson with the unique ability to peer into a person’s soul and see their true nature.\n" + + "During the night, you’ll get a chance to see if another Townsperson is a Werewolf.\n" + + "However, use this information wisely because it can lead to you being targeted by the Werewolves the next night if they deduce your identity." + b.Narrate(seerMsg, &player.UserID, nil) + } else { + b.townspersonSet.Insert(player.UserID) + player.Role = TownspeopleRole + townspersonMsg := "You’re a regular member of the town.\n" + + "Perhaps you’re a baker, merchant, or soldier.\n" + + "Your job is to save the town by eliminating the Werewolves that have infiltrated your town and started feeding on your neighbors.\n" + + "Also, try to avoid getting killed yourself." + b.Narrate(townspersonMsg, &player.UserID, nil) + } + } + b.state = DayState + b.Narrate("Players: "+b.alivePlayersStr(), nil, nil) + b.Narrate("Day 1: It is day time. Players can now introduce themselves. (5min)", nil, nil) + + select { + case <-time.After(5 * time.Minute): + case <-b.readyCh: + case <-b.ctx.Done(): + b.Narrate("STOP SIGNAL - Game is being stopped", nil, nil) + return + } + + for { + b.state = NightState + b.Narrate("Townspeople, go to sleep", nil, nil) + playerNameToKill := b.processWerewolf() + b.processSeer() + playerNameToSave := b.processHealer() + + b.state = DayState + b.Narrate("Townspeople, wake up", nil, nil) + if playerNameToKill == playerNameToSave { + b.Narrate("Someone was attacked last night, but they survived", nil, nil) + } else { + b.Narrate("Everyone wakes up to see a trail of blood leading to the forest.\n"+ + "There you find @"+playerNameToKill+"’s mangled remains by the Great Oak.\n"+ + "Curiously, there are deep claw marks in the bark of the surrounding trees.\n"+ + "It looks like @"+playerNameToKill+" put up a fight.", nil, nil) + b.kill(db, database.Username(playerNameToKill)) + } + + b.Narrate("Players still alive: "+b.alivePlayersStr(), nil, nil) + if b.werewolfSet.Len() == 0 { + b.Narrate("Townspeople win", nil, nil) + break + } else if b.townspersonSet.Len() <= 1 { + b.Narrate("Werewolf win", nil, nil) + break + } + + b.Narrate("Townspeople now have 5min to discuss the events", nil, nil) + + select { + case <-time.After(5 * time.Minute): + case <-b.readyCh: + case <-b.ctx.Done(): + b.Narrate("STOP SIGNAL - Game is being stopped", nil, nil) + return + } + + b.state = VoteState + b.voted = hashset.New[database.UserID]() + b.Narrate("It's now time to vote for execution. PM me the name you vote to execute or \"none\"", nil, nil) + killName := b.killVote() + if killName == "" { + b.Narrate("Townspeople do not want to execute anyone", nil, nil) + } else { + b.Narrate("Townspeople execute @"+killName, nil, nil) + b.kill(db, database.Username(killName)) + } + + b.Narrate("Players still alive: "+b.alivePlayersStr(), nil, nil) + + if b.werewolfSet.Len() == 0 { + b.Narrate("Townspeople win", nil, nil) + break + } else if b.townspersonSet.Len() == 1 { + b.Narrate("Werewolf win", nil, nil) + break + } + } + b.state = EndGameState + b.Narrate("Game ended", nil, nil) +} + +// Return the names of alive players. ie: "user1, user2, user3" +func (b *Werewolf) alivePlayersStr() (out string) { + arr := make([]string, 0) + for _, player := range b.playersAlive { + arr = append(arr, player.Username.AtStr()) + } + sort.Slice(arr, func(i, j int) bool { return arr[i] < arr[j] }) + return strings.Join(arr, ", ") +} + +// Kill a player +func (b *Werewolf) kill(db *database.DkfDB, playerName database.Username) { + player, found := b.playersAlive[playerName] + if !found { + return + } + delete(b.playersAlive, playerName) + switch player.Role { + case WerewolfRole: + b.werewolfSet.Remove(player.UserID) + _ = db.RmUserFromRoomGroup(b.roomID, b.werewolfGroupID, player.UserID) + case TownspeopleRole: + b.townspersonSet.Remove(player.UserID) + case HealerRole: + b.townspersonSet.Remove(player.UserID) + b.healerID = nil + case SeerRole: + b.townspersonSet.Remove(player.UserID) + b.seerID = nil + } + _, _ = db.AddUserToRoomGroup(b.roomID, b.deadGroupID, player.UserID) +} + +// Return the name of the player name that receive the most vote +func (b *Werewolf) killVote() string { + + // Send a PM to all players saying they have to vote for a name + for _, player := range b.playersAlive { + msg := "Who do you vote to kill? (name | none)" + msg += b.createKillVoteForm() + b.NarrateRaw(msg, &player.UserID, nil) + } + + votes := b.waitVotes() + // Get the max voted name + maxName := "none" + maxCount := 0 + voteMap := make(map[string]int) // keep track of how many votes for each values + for _, vote := range votes { + tmp := voteMap[vote] + tmp++ + voteMap[vote] = tmp + if tmp > maxCount { + maxCount = tmp + maxName = vote + } + } + if maxName == "none" { + return "" + } + return maxName +} + +func (b *Werewolf) getAlivePlayersArr(includeWerewolves bool) []database.Username { + arr := make([]database.Username, 0) + for _, player := range b.playersAlive { + if !includeWerewolves && b.werewolfSet.Contains(player.UserID) { + continue + } + arr = append(arr, player.Username) + } + sort.Slice(arr, func(i, j int) bool { return arr[i] < arr[j] }) + return arr +} + +func (b *Werewolf) createPickUserForm() string { + arr := b.getAlivePlayersArr(true) + + htmlTmpl := ` +<form method="post" action="/api/v1/werewolf"> + {{ range $idx, $p := .Arr }} + <input type="radio" ID="player{{ $idx }}" name="message" value="/pm 0 {{ $p }}" /><label for="player{{ $idx }}">{{ $p }}</label><br /> + {{ end }} + <button type="submit" name="btn_submit">ok</button> +</form>` + data := map[string]any{ + "Arr": arr, + } + var buf bytes.Buffer + _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) + return buf.String() +} + +func (b *Werewolf) createKillVoteForm() string { + arr := b.getAlivePlayersArr(true) + + htmlTmpl := ` +<form method="post" action="/api/v1/werewolf"> + {{ range $idx, $p := .Arr }} + <input type="radio" ID="player{{ $idx }}" name="message" value="/pm 0 {{ $p }}" /><label for="player{{ $idx }}">{{ $p }}</label><br /> + {{ end }} + <input type="radio" ID="none" name="message" value="/pm 0 none" /><label for="none">none</label><br /> + <button type="submit" name="btn_submit">ok</button> +</form>` + data := map[string]any{ + "Arr": arr, + } + var buf bytes.Buffer + _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) + return buf.String() +} + +func (b *Werewolf) createWerewolfPickUserForm() string { + arr := b.getAlivePlayersArr(false) + + htmlTmpl := ` +<form method="post" action="/api/v1/werewolf"> + {{ range $idx, $p := .Arr }} + <input type="radio" ID="player{{ $idx }}" name="message" value="/g werewolf {{ $p }}" /><label for="player{{ $idx }}">{{ $p }}</label><br /> + {{ end }} + <button type="submit" name="btn_submit">ok</button> +</form>` + data := map[string]any{ + "Arr": arr, + } + var buf bytes.Buffer + _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) + return buf.String() +} + +func (b *Werewolf) processWerewolf() string { + b.UnlockGroup("werewolf") + msg := "Werewolf, who do you want to kill?" + msg += b.createWerewolfPickUserForm() + b.NarrateRaw(msg, nil, &b.werewolfGroupID) + name := b.waitNameFromWerewolf() + b.Narrate(name+" will be killed", nil, &b.werewolfGroupID) + b.LockGroup("werewolf") + return name +} + +func (b *Werewolf) processSeer() { + if b.seerID == nil { + return + } + msg := "Seer, who do you want to identify?" + msg += b.createPickUserForm() + b.NarrateRaw(msg, b.seerID, nil) + name := b.waitNameFromSeer() + player := b.playersAlive[database.Username(name)] + b.Narrate(name+" is a "+player.Role, b.seerID, nil) +} + +func (b *Werewolf) processHealer() string { + if b.healerID == nil { + return "" + } + msg := "Healer, who do you want to save?" + msg += b.createPickUserForm() + b.NarrateRaw(msg, b.healerID, nil) + name := b.waitNameFromHealer() + b.Narrate(name+" will survive the night", b.healerID, nil) + return name +} + +func (b *Werewolf) LockGroups() { + b.LockGroup("werewolf") +} + +func (b *Werewolf) LockGroup(groupName string) { + group, _ := b.db.GetRoomGroupByName(b.roomID, groupName) + group.Locked = true + group.DoSave(b.db) +} + +func (b *Werewolf) UnlockGroup(groupName string) { + group, _ := b.db.GetRoomGroupByName(b.roomID, groupName) + group.Locked = false + group.DoSave(b.db) +} + +type Player struct { + UserID database.UserID + Username database.Username + Role string +} + +func (b *Werewolf) reset() { + b.ctx, b.cancel = context.WithCancel(context.Background()) + b.state = PreGameState + b.players = make(map[database.Username]*Player) + b.playersAlive = make(map[database.Username]*Player) + b.werewolfSet = hashset.New[database.UserID]() + b.spectatorSet = hashset.New[database.UserID]() + b.townspersonSet = hashset.New[database.UserID]() + b.voted = hashset.New[database.UserID]() + b.werewolfCh = make(chan string) + b.seerCh = make(chan string) + b.healerCh = make(chan string) + b.votesCh = make(chan string) + b.readyCh = make(chan bool) + _ = b.db.ClearRoomGroup(b.roomID, b.werewolfGroupID) + _ = b.db.ClearRoomGroup(b.roomID, b.spectatorGroupID) + _ = b.db.ClearRoomGroup(b.roomID, b.deadGroupID) +} + +func NewWerewolf(db *database.DkfDB) *Werewolf { + // Prepare room + room, err := db.GetChatRoomByName("werewolf") + if err != nil { + logrus.Error("#werewolf room not found") + return nil + } + zeroUser, _ := db.GetUserByUsername(config.NullUsername) + _ = db.DeleteChatRoomGroups(room.ID) + werewolfGroup, _ := db.CreateChatRoomGroup(room.ID, "werewolf", "#ffffff") + werewolfGroup.Locked = true + werewolfGroup.DoSave(db) + spectatorGroup, _ := db.CreateChatRoomGroup(room.ID, "spectator", "#ffffff") + deadGroup, _ := db.CreateChatRoomGroup(room.ID, "dead", "#ffffff") + + b := new(Werewolf) + b.db = db + b.werewolfGroupID = werewolfGroup.ID + b.spectatorGroupID = spectatorGroup.ID + b.deadGroupID = deadGroup.ID + b.narratorID = zeroUser.ID + b.roomID = room.ID + b.reset() + return b +} diff --git a/pkg/web/handlers/api/v1/msgInterceptor.go b/pkg/web/handlers/api/v1/msgInterceptor.go @@ -1,196 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/config" - "dkforest/pkg/database" - "dkforest/pkg/managers" - "dkforest/pkg/utils" - "errors" - "fmt" - "github.com/microcosm-cc/bluemonday" - html2 "html" - "strings" -) - -type MsgInterceptor struct{} - -func (i MsgInterceptor) InterceptMsg(cmd *Command) { - if cmd.room.ReadOnly { - if cmd.room.OwnerUserID != nil && *cmd.room.OwnerUserID != cmd.authUser.ID { - cmd.err = fmt.Errorf("room is read-only") - return - } - } - - // Only check maximum length of message if we are uploading a file - // Trim whitespaces and ensure minimum length - minLen := utils.Ternary(cmd.upload != nil, 0, minMsgLen) - if !utils.ValidateRuneLength(strings.TrimSpace(cmd.message), minLen, maxMsgLen) { - cmd.dataMessage = cmd.origMessage - cmd.err = fmt.Errorf("%d - %d characters", minLen, maxMsgLen) - return - } - - html, taggedUsersIDsMap, err := ProcessRawMessage(cmd.db, cmd.message, cmd.roomKey, cmd.authUser.ID, cmd.room.ID, cmd.upload, cmd.authUser.CanUseMultiline) - if err != nil { - cmd.dataMessage = cmd.origMessage - cmd.err = err - return - } - - if len(strings.TrimSpace(html)) <= len("<p></p>") { - cmd.dataMessage = cmd.origMessage - cmd.err = errors.New("empty message") - return - } - - toUserID := database.UserPtrID(cmd.toUser) - - msgID, _ := cmd.db.CreateOrEditMessage(cmd.editMsg, html, cmd.origMessage, cmd.roomKey, cmd.room.ID, cmd.authUser.ID, toUserID, cmd.upload, cmd.groupID, cmd.hellbanMsg, cmd.modMsg, cmd.systemMsg) - - if !cmd.skipInboxes { - sendInboxes(cmd.db, cmd.room, cmd.authUser, cmd.toUser, msgID, cmd.groupID, html, cmd.modMsg, taggedUsersIDsMap) - } - - // Count public messages in #general room - if cmd.room.ID == config.GeneralRoomID && cmd.toUser == nil { - cmd.authUser.GeneralMessagesCount++ - generalRoomKarma(cmd.db, cmd.authUser) - cmd.authUser.DoSave(cmd.db) - } - - // Update chat read marker - cmd.db.UpdateChatReadMarker(cmd.authUser.ID, cmd.room.ID) - - // Update user activity - isPM := cmd.toUser != nil - updateUserActivity(isPM, cmd.modMsg, cmd.room, cmd.authUser) -} - -func generalRoomKarma(db *database.DkfDB, authUser *database.User) { - // Hellban users ain't getting karma - if authUser.IsHellbanned { - return - } - messagesCount := authUser.GeneralMessagesCount - if messagesCount%100 == 0 { - description := fmt.Sprintf("sent %d messages", messagesCount) - authUser.IncrKarma(db, 1, description) - } else if messagesCount == 20 { - authUser.IncrKarma(db, 1, "first 20 messages sent") - } -} - -var msgPolicy = bluemonday.NewPolicy(). - AllowElements("a", "p", "span", "strong", "del", "code", "pre", "em", "ul", "li", "br", "small", "i"). - AllowAttrs("href", "rel", "target").OnElements("a"). - AllowAttrs("tabindex", "style").OnElements("pre"). - AllowAttrs("style", "class", "title").OnElements("span"). - AllowAttrs("style").OnElements("small") - -// ProcessRawMessage return the new html, and a map of tagged users used for notifications -// This function takes an "unsafe" user input "in", and return html which will be safe to render. -func ProcessRawMessage(db *database.DkfDB, in, roomKey string, authUserID database.UserID, roomID database.RoomID, - upload *database.Upload, canUseMultiline bool) (string, map[database.UserID]database.User, error) { - html, quoted := convertQuote(db, in, roomKey, roomID) // Get raw quote text which is not safe to render - html = convertNewLines(html, canUseMultiline) - html = html2.EscapeString(html) // Makes user input safe to render - // All html generated from this point on shall be safe to render. - html = convertPGPClearsignToFile(db, html, authUserID) - html = convertPGPMessageToFile(db, html, authUserID) - html = convertPGPPublicKeyToFile(db, html, authUserID) - html = convertAgeMessageToFile(db, html, authUserID) - html = convertLinksWithoutScheme(html) - html = convertMarkdown(html) - html = convertBangShortcuts(html) - html = convertArchiveLinks(db, html, roomID, authUserID) - html = convertLinks(html, roomID, db.GetUserByUsername, db.GetLinkByShorthand, db.GetChatMessageByUUID) - html = linkDefaultRooms(html) - html, taggedUsersIDsMap := colorifyTaggedUsers(html, db.GetUsersByUsername) - html = linkRoomTags(db, html) - html = emojiReplacer.Replace(html) - html = styleQuote(html, quoted) - html = appendUploadLink(html, upload) - if quoted != nil { // Add quoted message owner for inboxes - taggedUsersIDsMap[quoted.UserID] = quoted.User - } - html = msgPolicy.Sanitize(html) - return html, taggedUsersIDsMap, nil -} - -func sendInboxes(db *database.DkfDB, room database.ChatRoom, authUser, toUser *database.User, msgID int64, groupID *database.GroupID, html string, modMsg bool, - taggedUsersIDsMap map[database.UserID]database.User) { - // Only have chat inbox for unencrypted messages - if room.IsProtected() { - return - } - // If user is hellbanned, do not send inboxes - if authUser.IsHellbanned { - return - } - // Early return if we don't need to send inboxes - if toUser == nil && len(taggedUsersIDsMap) == 0 { - return - } - - blacklistedBy, _ := db.GetPmBlacklistedByUsers(authUser.ID) - blacklistedByMap := make(map[database.UserID]struct{}) - for _, b := range blacklistedBy { - blacklistedByMap[b.UserID] = struct{}{} - } - - ignoredBy, _ := db.GetIgnoredByUsers(authUser.ID) - ignoredByMap := make(map[database.UserID]struct{}) - for _, b := range ignoredBy { - ignoredByMap[b.UserID] = struct{}{} - } - - sendInbox := func(user database.User, isPM, modCh bool) { - if !managers.ActiveUsers.IsUserActiveInRoom(user.ID, room) || user.AFK { - // Do not send notification if receiver is blacklisting you - if _, ok := blacklistedByMap[user.ID]; ok { - return - } - // Do not send notification if receiver is ignoring you - if _, ok := ignoredByMap[user.ID]; ok { - return - } - db.CreateInboxMessage(html, room.ID, authUser.ID, user.ID, isPM, modCh, &msgID) - } - } - - // If the message is a PM, only notify the receiver, not the tagged people in it. - if toUser != nil { - sendInbox(*toUser, true, false) - } else if room.Name == "moderators" { // Only tags other moderators on "moderators" room - for _, user := range taggedUsersIDsMap { - if user.IsModerator() { - sendInbox(user, false, false) - } - } - } else if modMsg { // Only tags other moderators on /m messages - for _, user := range taggedUsersIDsMap { - if user.IsModerator() { - sendInbox(user, false, true) - } - } - } else if groupID != nil { // Only tags other people in the group - for _, user := range taggedUsersIDsMap { - if db.IsUserInGroupByID(user.ID, *groupID) { - sendInbox(user, false, false) - } - } - } else { // Otherwise, notify tagged people - for _, user := range taggedUsersIDsMap { - sendInbox(user, false, false) - } - } -} - -func updateUserActivity(isPM, modMsg bool, room database.ChatRoom, authUser *database.User) { - // We do not update user presence when they send private messages or moderators group message - if isPM || modMsg { - return - } - managers.ActiveUsers.UpdateUserInRoom(room, managers.NewUserInfoUpdateActivity(authUser)) -} diff --git a/pkg/web/handlers/api/v1/slashInterceptor.go b/pkg/web/handlers/api/v1/slashInterceptor.go @@ -1,1805 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/clockwork" - "dkforest/pkg/config" - "dkforest/pkg/database" - dutils "dkforest/pkg/database/utils" - "dkforest/pkg/managers" - "dkforest/pkg/utils" - "errors" - "fmt" - "github.com/ProtonMail/go-crypto/openpgp/clearsign" - "github.com/ProtonMail/go-crypto/openpgp/packet" - "github.com/asaskevich/govalidator" - "github.com/dustin/go-humanize" - "github.com/sirupsen/logrus" - "html" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" -) - -// SlashInterceptor handle all forward slash commands. -// -// If by the end of this function, the c.err is set, it will trigger -// different behavior according to the type of error it holds. -// if c.err is set to ErrRedirect, the chat-bar iframe will refresh completely. -// if c.err is set to ErrStop, no further processing of the user input will be done, -// and the chat iframe will be rendered instead of redirected. -// This is useful to keep a prefix in the text box (eg: /pm user ) -// if c.err is set to an instance of ErrSuccess, -// a green message will appear beside the text box. -// otherwise if c.err is set to a different error, -// text box is retested to original message, -// and a red message will appear beside the text box. -type SlashInterceptor struct{} - -func (i SlashInterceptor) InterceptMsg(c *Command) { - if !strings.HasPrefix(c.message, "/") { - return - } - handled := handleUserCmd(c) || - handlePrivateRoomCmd(c) || - handlePrivateRoomOwnerCmd(c) || - handleModeratorCmd(c) || - handleAdminCmd(c) - if !handled { - c.err = errors.New("invalid slash command") - } -} - -func handleUserCmd(c *Command) (handled bool) { - return handleIgnoreCmd(c) || - handleUnIgnoreCmd(c) || - handleToggleAutocomplete(c) || - handleTutorialCmd(c) || - handleDeleteMsgCmd(c) || - handleHideMsgCmd(c) || - handleUnHideMsgCmd(c) || - handleListIgnoredCmd(c) || - handleListPmWhitelistCmd(c) || - handleSetPmModeWhitelistCmd(c) || - handleSetPmModeStandardCmd(c) || - handleTogglePmBlacklistedUser(c) || - handleTogglePmWhitelistedUser(c) || - handleGroupChatCmd(c) || - handleMeCmd(c) || - handleEditCmd(c) || - handlePMCmd(c) || - handleEditLastCmd(c) || - handleSubscribeCmd(c) || - handleUnsubscribeCmd(c) || - handleProfileCmd(c) || - handleInboxCmd(c) || - handleChessCmd(c) || - handleHbmCmd(c) || - handleHbmtCmd(c) || - handleTokenCmd(c) || - handleMd5Cmd(c) || - handleSha1Cmd(c) || - handleSha256Cmd(c) || - handleSha512Cmd(c) || - handleDiceCmd(c) || - handleRandCmd(c) || - handleChoiceCmd(c) || - handleListMemes(c) || - handleSuccessCmd(c) || - handleAfkCmd(c) || - handleDateCmd(c) || - handleUpdateReadMarkerCmd(c) || - handleCodeCmd(c) || - handleErrorCmd(c) -} - -func handlePrivateRoomCmd(c *Command) (handled bool) { - return handleGetModeCmd(c) || - handleWhitelistCmd(c) -} - -func handlePrivateRoomOwnerCmd(c *Command) (handled bool) { - if (c.room.OwnerUserID != nil && *c.room.OwnerUserID == c.authUser.ID) || c.authUser.IsAdmin { - return handleAddGroupCmd(c) || - handleRmGroupCmd(c) || - handleLockGroupCmd(c) || - handleUnlockGroupCmd(c) || - handleGroupUsersCmd(c) || - handleListGroupsCmd(c) || - handleGroupAddUserCmd(c) || - handleGroupRmUserCmd(c) || - handleSetModeWhitelistCmd(c) || - handleSetModeStandardCmd(c) || - handleGetRoomWhitelistCmd(c) || - handleToggleReadOnlyCmd(c) - } - return false -} - -func handleModeratorCmd(c *Command) (handled bool) { - if c.authUser.IsModerator() { - return handleModeratorGroupCmd(c) || - handleListModeratorsCmd(c) || - handleKickCmd(c) || - handleKickKeepCmd(c) || - handleKickSilentCmd(c) || - handleKickKeepSilentCmd(c) || - handleUnkickCmd(c) || - handleLogoutCmd(c) || - handleForceCaptchaCmd(c) || - handleResetTutorialCmd(c) || - handleHellbanCmd(c) || - handleUnhellbanCmd(c) - } - return false -} - -func handleAdminCmd(c *Command) (handled bool) { - if c.authUser.IsAdmin { - return handleSystemCmd(c) || - handleSetChatRoomExternalLink(c) || - handlePurge(c) || - handleRename(c) || - handleNewMeme(c) || - handleRenameMeme(c) || - handleRemoveMeme(c) || - handleRefreshCmd(c) - } - return false -} - -func handleModeratorGroupCmd(c *Command) (handled bool) { - if strings.HasPrefix(c.message, "/m ") || strings.HasPrefix(c.message, "/n ") { - if strings.HasPrefix(c.message, "/n ") { - c.message = strings.Replace(c.message, "/n ", "/m ", 1) - } - c.message = strings.TrimPrefix(c.message, "/m ") - c.redirectQP.Set(redirectModQP, "1") - c.modMsg = true - if handleMeCmd(c) { - return true - } else if handleCodeCmd(c) { - return true - } - return true - } - return false -} - -func handleListModeratorsCmd(c *Command) (handled bool) { - if c.message == "/moderators" || c.message == "/mods" { - mods, err := c.db.GetModeratorsUsers() - if err != nil { - c.err = err - return true - } - msg := "Moderators:\n" - if len(mods) > 0 { - msg += "\n" - for _, mod := range mods { - msg += mod.Username.AtStr() + "\n" - } - } else { - msg += "no moderators" - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleKickCmd(c *Command) (handled bool) { - if m := kickRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - if err := kickCmd(c, username, true, false); err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } - return -} - -// Kick a user but keep the messages -func handleKickKeepCmd(c *Command) (handled bool) { - if m := kickKeepRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - if err := kickCmd(c, username, false, false); err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } - return -} - -// Kick a user, no system message in chat -func handleKickSilentCmd(c *Command) (handled bool) { - if m := kickSilentRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - if err := kickCmd(c, username, true, true); err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } - return -} - -// Kick a user, keep the messages, no system message in chat -func handleKickKeepSilentCmd(c *Command) (handled bool) { - if m := kickKeepSilentRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - if err := kickCmd(c, username, false, true); err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } - return -} - -func kickCmd(c *Command, username database.Username, purge, silent bool) error { - user, err := c.db.GetUserByUsername(username) - if err != nil { - return ErrUsernameNotFound - } - return dutils.Kick(c.db, user, *c.authUser, purge, silent) -} - -var ErrUsernameNotFound = errors.New("username not found") -var ErrUnauthorized = errors.New("unauthorized") - -func handleUnkickCmd(c *Command) (handled bool) { - if m := unkickRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - if user.Verified { - c.err = errors.New("user already not kicked") - return true - } - c.db.NewAudit(*c.authUser, fmt.Sprintf("unkick %s #%d", user.Username, user.ID)) - user.Verified = true - user.DoSave(c.db) - - // Display unkick message - c.db.CreateUnkickMsg(user, *c.authUser) - - c.err = ErrRedirect - return true - } - return -} - -func handleForceCaptchaCmd(c *Command) (handled bool) { - if m := forceCaptchaRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - if c.authUser.IsAdmin || !user.IsModerator() || c.authUser.Username == username { - c.db.NewAudit(*c.authUser, fmt.Sprintf("force captcha %s #%d", user.Username, user.ID)) - user.CaptchaRequired = true - user.DoSave(c.db) - } - c.err = ErrRedirect - return true - } - return -} - -func handleLogoutCmd(c *Command) (handled bool) { - if m := logoutRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - if !c.authUser.IsAdmin && user.Vetted { - c.err = ErrUnauthorized - return true - } - if c.authUser.IsAdmin || !user.IsModerator() { - c.db.NewAudit(*c.authUser, fmt.Sprintf("logout %s #%d", user.Username, user.ID)) - - _ = c.db.DeleteUserSessions(user.ID) - - // Remove user from the user cache - managers.ActiveUsers.RemoveUser(user.ID) - } - - c.err = ErrRedirect - return true - } - return -} - -func handleResetTutorialCmd(c *Command) (handled bool) { - if m := rtutoRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - if !c.authUser.IsAdmin && user.Vetted { - c.err = ErrUnauthorized - return true - } - if c.authUser.IsAdmin || !user.IsModerator() { - c.db.NewAudit(*c.authUser, fmt.Sprintf("rtuto %s #%d", user.Username, user.ID)) - user.ChatTutorial = 0 - user.DoSave(c.db) - } - c.err = ErrRedirect - return true - } - return -} - -func handleHellbanCmd(c *Command) (handled bool) { - if m := hellbanRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - if !c.authUser.IsAdmin && (user.Vetted || user.IsModerator()) { - c.err = ErrUnauthorized - return true - } - c.db.NewAudit(*c.authUser, fmt.Sprintf("hellban %s #%d", user.Username, user.ID)) - user.HellBan(c.db) - managers.ActiveUsers.UpdateUserHBInRooms(managers.NewUserInfo(&user)) - - c.err = ErrRedirect - return true - } - return -} - -func handleUnhellbanCmd(c *Command) (handled bool) { - if m := unhellbanRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - if !c.authUser.IsAdmin && (user.Vetted || user.IsModerator()) { - c.err = ErrUnauthorized - return true - } - c.db.NewAudit(*c.authUser, fmt.Sprintf("unhellban %s #%d", user.Username, user.ID)) - user.UnHellBan(c.db) - managers.ActiveUsers.UpdateUserHBInRooms(managers.NewUserInfo(&user)) - - c.err = ErrRedirect - return true - } - return false -} - -func handleHbmCmd(c *Command) (handled bool) { - if !c.authUser.CanSeeHB() { - return - } - if strings.HasPrefix(c.message, "/hbm ") { - c.message = strings.TrimPrefix(c.message, "/hbm ") - c.hellbanMsg = true - c.redirectQP.Set(redirectHbmQP, "1") - return true - } - return -} - -func handleHbmtCmd(c *Command) (handled bool) { - if !c.authUser.CanSeeHB() { - return - } - if m := hbmtRgx.FindStringSubmatch(c.message); len(m) == 2 { - date := m[1] - if dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()); err == nil { - if msg, err := c.db.GetRoomChatMessageByDate(c.room.ID, c.authUser.ID, dt.UTC()); err == nil { - msg.IsHellbanned = !msg.IsHellbanned - msg.DoSave(c.db) - } else { - c.err = errors.New("no message found at this timestamp") - return true - } - } - c.err = ErrRedirect - return true - } - return -} - -func handleDiceCmd(c *Command) (handled bool) { - if strings.HasPrefix(c.message, "/dice") { - dice := utils.RandInt(1, 6) - raw := fmt.Sprintf(`rolling dice for @%s ... "%d"`, c.authUser.Username, dice) - msg := fmt.Sprintf(`rolling dice for @%s ... "<span style="color: white;">%d</span>"`, c.authUser.Username, dice) - msg, _ = colorifyTaggedUsers(msg, c.db.GetUsersByUsername) - go func() { - time.Sleep(time.Second) - c.zeroPublicMsg(raw, msg) - }() - return true - } - return -} - -func handleRandCmd(c *Command) (handled bool) { - if strings.HasPrefix(c.message, "/rand") { - min := 1 - max := 6 - var dice int - if m := randRgx.FindStringSubmatch(c.message); len(m) == 3 { - var err error - min, err = strconv.Atoi(m[1]) - if err != nil { - c.err = err - return true - } - max, err = strconv.Atoi(m[2]) - if err != nil { - c.err = err - return true - } - if max <= min { - c.err = errors.New("max must be greater than min") - return true - } - } else if c.message != "/rand" { - c.err = errors.New("invalid /rand command") - return true - } - dice = utils.RandInt(min, max) - raw := fmt.Sprintf(`rolling dice for @%s ... "%d"`, c.authUser.Username, dice) - msg := fmt.Sprintf(`rolling dice for @%s ... "<span style="color: white;">%d</span>"`, c.authUser.Username, dice) - msg, _ = colorifyTaggedUsers(msg, c.db.GetUsersByUsername) - go func() { - time.Sleep(time.Second) - c.zeroPublicMsg(raw, msg) - }() - return true - } - return -} - -func handleChoiceCmd(c *Command) (handled bool) { - if strings.HasPrefix(c.message, "/choice ") { - tmp := html.EscapeString(strings.TrimPrefix(c.message, "/choice ")) - words := strings.Fields(tmp) - answer := utils.RandChoice(words) - raw := fmt.Sprintf(`@%s choice %s ... "%s"`, c.authUser.Username, words, answer) - msg := fmt.Sprintf(`@%s choice %s ... "<span style="color: white;">%s</span>"`, c.authUser.Username, words, answer) - msg, _ = colorifyTaggedUsers(msg, c.db.GetUsersByUsername) - go func() { - time.Sleep(time.Second) - c.zeroPublicMsg(raw, msg) - }() - c.skipInboxes = true - return true - } - return -} - -func handleTokenCmd(c *Command) (handled bool) { - if c.message == "/token" { - c.zeroMsg(utils.GenerateToken10()) - c.err = ErrRedirect - return true - } else if m := tokenRgx.FindStringSubmatch(c.message); len(m) == 2 { - n, _ := strconv.Atoi(m[1]) - if n < 1 || n > 32 { - c.err = errors.New("value must be [1;32]") - return true - } - n = utils.Clamp(n, 1, 32) - c.zeroMsg(utils.GenerateTokenN(n)) - c.err = ErrRedirect - return true - } - return -} - -func handleMd5Cmd(c *Command) (handled bool) { - return handleHasherCmd(c, "/md5 ", utils.MD5) -} - -func handleSha1Cmd(c *Command) (handled bool) { - return handleHasherCmd(c, "/sha1 ", utils.Sha1) -} - -func handleSha256Cmd(c *Command) (handled bool) { - return handleHasherCmd(c, "/sha256 ", utils.Sha256) -} - -func handleSha512Cmd(c *Command) (handled bool) { - return handleHasherCmd(c, "/sha512 ", utils.Sha512) -} - -func handleHasherCmd(c *Command, prefix string, fn func([]byte) string) (handled bool) { - if strings.HasPrefix(c.message, prefix) { - c.message = strings.TrimPrefix(c.message, prefix) - c.dataMessage = prefix - c.zeroMsg(fn([]byte(c.message))) - c.err = ErrStop - return true - } - return -} - -func handleRmGroupCmd(c *Command) (handled bool) { - if m := rmGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { - groupName := m[1] - if err := c.db.DeleteChatRoomGroup(c.room.ID, groupName); err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } - return false -} - -func handleLockGroupCmd(c *Command) (handled bool) { - if m := lockGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { - groupName := m[1] - group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) - if err != nil { - c.err = err - return true - } - group.Locked = true - group.DoSave(c.db) - c.err = ErrRedirect - return true - } - return false -} - -func handleUnlockGroupCmd(c *Command) (handled bool) { - if m := unlockGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { - groupName := m[1] - group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) - if err != nil { - c.err = err - return true - } - group.Locked = false - group.DoSave(c.db) - c.err = ErrRedirect - return true - } - return false -} - -func handleGroupUsersCmd(c *Command) (handled bool) { - if m := groupUsersRgx.FindStringSubmatch(c.message); len(m) == 2 { - groupName := m[1] - group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) - if err != nil { - c.err = err - return true - } - users, err := c.db.GetRoomGroupUsers(c.room.ID, group.ID) - sort.Slice(users, func(i, j int) bool { - return users[i].User.Username < users[j].User.Username - }) - msg := "" - if len(users) > 0 { - msg += "\n" - for _, user := range users { - msg += user.User.Username.AtStr() + "\n" - } - } else { - msg += "no user in th group: " + groupName - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleListGroupsCmd(c *Command) (handled bool) { - if c.message == "/groups" { - groups, err := c.db.GetRoomGroups(c.room.ID) - if err != nil { - c.err = err - return true - } - msg := "" - if len(groups) > 0 { - msg += "\n" - for _, group := range groups { - msg += group.Name + " (" + group.Color + ")\n" - } - } else { - msg += "no groups" - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleGroupAddUserCmd(c *Command) (handled bool) { - if m := groupAddUserRgx.FindStringSubmatch(c.message); len(m) == 3 { - groupName := m[1] - username := database.Username(m[2]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = err - return true - } - group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) - if err != nil { - c.err = err - return true - } - _, err = c.db.AddUserToRoomGroup(c.room.ID, group.ID, user.ID) - if err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } else if strings.HasPrefix(c.message, "/gadduser ") { - c.err = errors.New("invalid /gadduser command") - } - return false -} - -func handleGroupRmUserCmd(c *Command) (handled bool) { - if m := groupRmUserRgx.FindStringSubmatch(c.message); len(m) == 3 { - groupName := m[1] - username := database.Username(m[2]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = err - return true - } - group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) - if err != nil { - c.err = err - return true - } - err = c.db.RmUserFromRoomGroup(c.room.ID, group.ID, user.ID) - if err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } else if strings.HasPrefix(c.message, "/grmuser ") { - c.err = errors.New("invalid /grmuser command") - } - return false -} - -func handleSetModeWhitelistCmd(c *Command) (handled bool) { - if c.message == "/mode user-whitelist" { - c.room.Mode = database.UserWhitelistRoomMode - c.room.DoSave(c.db) - msg := `room mode set to "user whitelist"` - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleSetModeStandardCmd(c *Command) (handled bool) { - if c.message == "/mode standard" { - c.room.Mode = database.NormalRoomMode - c.room.DoSave(c.db) - msg := `room mode set to "standard"` - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleGetRoomWhitelistCmd(c *Command) (handled bool) { - if m := whitelistUserRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - var msg string - if err != nil { - msg = fmt.Sprintf(`username "%s" not found`, username) - } else { - if _, err := c.db.WhitelistUser(c.room.ID, user.ID); err != nil { - if err := c.db.DeWhitelistUser(c.room.ID, user.ID); err != nil { - msg = fmt.Sprintf("failed to toggle @%s in whitelist", user.Username) - } else { - msg = fmt.Sprintf("@%s removed from whitelist", user.Username) - } - } else { - msg = fmt.Sprintf("@%s added to whitelist", user.Username) - } - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleToggleReadOnlyCmd(c *Command) (handled bool) { - if c.message == "/ro" { - c.room.ReadOnly = !c.room.ReadOnly - c.room.DoSave(c.db) - if c.room.ReadOnly { - c.err = NewErrSuccess("room is now read-only") - } else { - c.err = NewErrSuccess("room is no longer read-only") - } - return true - } - return -} - -func handleAddGroupCmd(c *Command) (handled bool) { - if m := addGroupRgx.FindStringSubmatch(c.message); len(m) == 2 { - name := m[1] - _, err := c.db.CreateChatRoomGroup(c.room.ID, name, "#fff") - if err != nil { - c.err = err - return true - } - c.err = ErrRedirect - return true - } - return false -} - -func handleWhitelistCmd(c *Command) (handled bool) { - if c.message == "/whitelist" || c.message == "/wl" { - whitelistedUsers, _ := c.db.GetWhitelistedUsers(c.room.ID) - var msg string - if len(whitelistedUsers) > 0 { - usernames := make([]string, 0) - for _, whitelistedUser := range whitelistedUsers { - usernames = append(usernames, whitelistedUser.User.Username.AtStr()) - } - sort.Slice(usernames, func(i, j int) bool { return usernames[i] < usernames[j] }) - msg = "whitelisted users: " + strings.Join(usernames, ", ") - } else { - msg = "no whitelisted user" - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleGetModeCmd(c *Command) (handled bool) { - if c.message == "/mode" { - var msg string - if c.room.Mode == database.NormalRoomMode { - msg = `room is in "standard" mode` - } else if c.room.Mode == database.UserWhitelistRoomMode { - msg = `room is in "user whitelist" mode` - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleMeCmd(c *Command) (handled bool) { - if c.message == "/me " { - c.err = errors.New("invalid /me command") - return true - } - if strings.HasPrefix(c.message, "/me ") { - return true - } - return -} - -func handleEditCmd(c *Command) (handled bool) { - if m := editRgx.FindStringSubmatch(c.message); len(m) == 3 { - date := m[1] - newMsg := m[2] - if dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()); err == nil { - if time.Since(dt) <= config.EditMessageTimeLimit { - if msg, err := c.db.GetRoomChatMessageByDate(c.room.ID, c.authUser.ID, dt.UTC()); err == nil { - c.editMsg = &msg - c.origMessage = newMsg - c.message = newMsg - - // If we're editing a message which contains a link to an uploaded file, - // we need to re-add the link to the html. - if msg.UploadID != nil { - if newUpload, err := c.db.GetUploadByID(*msg.UploadID); err == nil { - c.upload = &newUpload - } - } - - if pmRgx.MatchString(c.message) { - handlePMCmd(c) - } else if c.authUser.IsModerator() && strings.HasPrefix(c.message, "/m ") { - handleModeratorGroupCmd(c) - } else if strings.HasPrefix(c.message, "/hbm ") { - handleHbmCmd(c) - } else if strings.HasPrefix(c.message, "/g ") { - handleGroupChatCmd(c) - } else if strings.HasPrefix(c.message, "/system ") || strings.HasPrefix(c.message, "/sys ") { - handleSystemCmd(c) - } - } - } - } - return true - } - return -} - -func handleEditLastCmd(c *Command) (handled bool) { - if c.message == "/e" { - msg, err := c.db.GetUserLastChatMessageInRoom(c.authUser.ID, c.room.ID) - if err != nil { - return true - } - c.redirectQP.Set(redirectEditQP, msg.CreatedAt.Format("15:04:05")) - c.err = ErrRedirect - return true - } - return -} - -var ErrPMDenied = errors.New("you cannot pm/inbox this user") -var Err20Msgs = errors.New("you need 20 public messages to unlock PMs/Inbox; or be whitelisted") -var ErrOther20Msgs = errors.New("dest user must be whitelisted or have 20 public messages") - -func canUserInboxOther(db *database.DkfDB, user, other database.User) error { - doesNotMatter := false - _, err := canUserPmOther(db, user, other, doesNotMatter) - return err -} - -func canUserPmOther(db *database.DkfDB, user, other database.User, roomIsPrivate bool) (skipInbox bool, err error) { - errPMDenied := ErrPMDenied - - if user.ID == other.ID { - return false, errors.New("cannot /pm yourself") - } - - if db.IsUserPmWhitelisted(user.ID, other.ID) { - return false, nil - } - - switch other.PmMode { - case database.PmModeWhitelist: - // We are in whitelist mode, and user is not whitelisted - return false, errPMDenied - - case database.PmModeStandard: - if !user.CanSendPM() { - // In private rooms, can send PM but inboxes will be skipped if not enough public messages - if roomIsPrivate { - return true, nil - } - // Need at least 20 public messages to send PM in a public room - return false, Err20Msgs - } - - // User on blacklist cannot PM/Inbox - if db.IsUserPmBlacklisted(user.ID, other.ID) { - return false, errPMDenied - } - // Other doesn't want PM from new users - if !user.AccountOldEnough() && other.BlockNewUsersPm { - return false, errPMDenied - } - - if !other.CanSendPM() { - if db.IsUserPmWhitelisted(other.ID, user.ID) { - return true, nil - } - // In private rooms, can send PM but inboxes will be skipped if not enough public messages - if roomIsPrivate { - return true, nil - } - return false, ErrOther20Msgs - } - - return false, nil - } - - // Should never go here - return false, nil -} - -func handlePMCmd(c *Command) (handled bool) { - if m := pmRgx.FindStringSubmatch(c.message); len(m) == 3 { - username := database.Username(m[1]) - newMsg := m[2] - - // Chat helpers - if username == config.NullUsername { - return handlePm0(c, newMsg) - } - - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = errors.New("invalid username") - return true - } - - c.skipInboxes, c.err = canUserPmOther(c.db, *c.authUser, user, c.room.IsOwned()) - if c.err != nil { - return true - } - - c.toUser = &user - c.message = newMsg - c.redirectQP.Set(redirectPmQP, string(user.Username)) - - if newMsg == "/d" || strings.HasPrefix(newMsg, "/d ") { - handled = handleDeleteMsgCmd(c) - if c.err != nil && c.err != ErrRedirect { - return handled - } - c.err = ErrRedirect - return handled - } - - if handleCodeCmd(c) { - return true - } - - return true - } else if strings.HasPrefix(c.message, "/pm ") { - c.err = errors.New("invalid /pm command") - return true - } - return false -} - -// Handle PMs sent to user 0 (/pm 0 msg) -func handlePm0(c *Command, msg string) (handled bool) { - c.redirectQP.Set(redirectPmQP, "0") - if msg == "ping" { - c.zeroMsg("pong") - c.err = ErrRedirect - return true - - } else if msg == "talk" { - c.zeroMsg("talking") - c.err = ErrRedirect - return true - - } else if msg == "pgp" || msg == "gpg" { - pkey := c.authUser.GPGPublicKey - if pkey == "" { - c.message = "I could not find a public pgp key in your profile." - } else { - msg := "This is a sample text" - if encrypted, err := utils.GeneratePgpEncryptedMessage(pkey, msg); err != nil { - c.message = err.Error() - } else { - c.message = strings.Join(strings.Split(encrypted, "\n"), " ") - } - } - c.zeroProcMsg(c.message) - c.err = ErrRedirect - return true - - } else if pgpMsg := extractPGPMessage(msg); pgpMsg != "" { - decrypted, err := utils.PgpDecryptMessage(config.NullUserPrivateKey, pgpMsg) - if err != nil { - c.message = err.Error() - } else { - c.message = "Decrypted message: " + decrypted - } - c.zeroProcMsg(c.message) - c.err = ErrRedirect - return true - - } else if b, _ := clearsign.Decode([]byte(msg)); b != nil { - if p, err := packet.Read(b.ArmoredSignature.Body); err == nil { - if sig, ok := p.(*packet.Signature); ok { - zero := c.getZeroUser() - msg := fmt.Sprintf("<br />"+ - "<table %s>"+ - "<tr><td align=\"right\">Signature made:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ - "<tr><td align=\"right\">Fingerprint:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ - "<tr><td align=\"right\">Issuer:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ - "</table>", - zero.GenerateChatStyle(), - sig.CreationTime.Format(time.RFC1123), - utils.FormatPgPFingerprint(sig.IssuerFingerprint), - utils.Ternary(sig.SignerUserId != nil, *sig.SignerUserId, "n/a")) - c.zeroMsg(msg) - c.err = ErrRedirect - return true - } - } - - } else if c.upload != nil { - - // If we sent a clearsign file to @0, the bot will reply with information about the signature - if c.upload.FileSize < config.MaxFileSizeBeforeDownload { - if file, err := c.db.GetUploadByFileName(c.upload.FileName); err == nil { - if _, by, err := file.GetContent(); err == nil { - if b, _ := clearsign.Decode(by); b != nil { - if p, err := packet.Read(b.ArmoredSignature.Body); err == nil { - if sig, ok := p.(*packet.Signature); ok { - zero := c.getZeroUser() - msg := fmt.Sprintf("<br />"+ - "<table %s>"+ - "<tr><td align=\"right\">File:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span> (<span style=\"color: #82e17f;\">%s</span>)</td></tr>"+ - "<tr><td align=\"right\">Signature made:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ - "<tr><td align=\"right\">Fingerprint:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ - "<tr><td align=\"right\">Issuer:&nbsp;&nbsp;</td><td><span style=\"color: #82e17f;\">%s</span></td></tr>"+ - "</table>", - zero.GenerateChatStyle(), - c.upload.OrigFileName, - humanize.Bytes(uint64(c.upload.FileSize)), - sig.CreationTime.Format(time.RFC1123), - utils.FormatPgPFingerprint(sig.IssuerFingerprint), - utils.Ternary(sig.SignerUserId != nil, *sig.SignerUserId, "n/a")) - c.zeroMsg(msg) - c.err = ErrRedirect - return true - } - } - } - } - } - } - } - - zeroUser := c.getZeroUser() - c.toUser = &zeroUser - c.message = msg - - return true -} - -func handleSubscribeCmd(c *Command) (handled bool) { - if c.message == "/subscribe" { - _ = c.db.SubscribeToRoom(c.authUser.ID, c.room.ID) - c.err = ErrRedirect - return true - } - return -} - -func handleUnsubscribeCmd(c *Command) (handled bool) { - if m := unsubscribeRgx.FindStringSubmatch(c.message); len(m) == 2 { - room, err := c.db.GetChatRoomByName(m[1]) - if err != nil { - c.err = err - return true - } - _ = c.db.UnsubscribeFromRoom(c.authUser.ID, room.ID) - c.err = ErrRedirect - return true - - } else if c.message == "/unsubscribe" { - _ = c.db.UnsubscribeFromRoom(c.authUser.ID, c.room.ID) - c.err = ErrRedirect - return true - } - return -} - -func handleGroupChatCmd(c *Command) (handled bool) { - if m := groupRgx.FindStringSubmatch(c.message); len(m) == 3 { - groupName := m[1] - c.message = m[2] - group, err := c.db.GetRoomGroupByName(c.room.ID, groupName) - if err != nil { - c.err = err - return true - } - if group.Locked { - c.err = errors.New("group is locked") - return true - } - c.redirectQP.Set(redirectGroupQP, group.Name) - c.groupID = &group.ID - return true - } else if strings.HasPrefix(c.message, "/g ") { - c.err = errors.New("invalid /g command") - return true - } - return false -} - -func handleListIgnoredCmd(c *Command) (handled bool) { - if c.message == "/i" || c.message == "/ignore" { - ignoredUsers, _ := c.db.GetIgnoredUsers(c.authUser.ID) - sort.Slice(ignoredUsers, func(i, j int) bool { - return ignoredUsers[i].IgnoredUser.Username < ignoredUsers[j].IgnoredUser.Username - }) - msg := "" - if len(ignoredUsers) > 0 { - msg += "\n" - for _, ignoredUser := range ignoredUsers { - msg += ignoredUser.IgnoredUser.Username.AtStr() + "\n" - } - } else { - msg += "no ignored users" - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleListPmWhitelistCmd(c *Command) (handled bool) { - if c.message == "/pmwhitelist" { - pmWhitelistUsers, _ := c.db.GetPmWhitelistedUsers(c.authUser.ID) - sort.Slice(pmWhitelistUsers, func(i, j int) bool { - return pmWhitelistUsers[i].WhitelistedUser.Username < pmWhitelistUsers[j].WhitelistedUser.Username - }) - msg := "" - if len(pmWhitelistUsers) > 0 { - msg += "\n" - for _, ignoredUser := range pmWhitelistUsers { - msg += ignoredUser.WhitelistedUser.Username.AtStr() + "\n" - } - } else { - msg += "no PM whitelisted users" - } - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleSetPmModeWhitelistCmd(c *Command) (handled bool) { - if c.message == "/setpmmode whitelist" { - c.authUser.PmMode = database.PmModeWhitelist - c.authUser.DoSave(c.db) - msg := `pm mode set to "whitelist"` - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleSetPmModeStandardCmd(c *Command) (handled bool) { - if c.message == "/setpmmode standard" { - c.authUser.PmMode = database.PmModeStandard - c.authUser.DoSave(c.db) - msg := `pm mode set to "standard"` - c.zeroProcMsg(msg) - c.err = ErrRedirect - return true - } - return false -} - -func handleTogglePmBlacklistedUser(c *Command) (handled bool) { - if m := pmToggleBlacklistUserRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrRedirect - return true - } - if c.db.ToggleBlacklistedUser(c.authUser.ID, user.ID) { - c.err = NewErrSuccess("added to blacklist") - } else { - c.err = NewErrSuccess("removed from blacklist") - } - return true - } - return false -} - -func handleTogglePmWhitelistedUser(c *Command) (handled bool) { - if m := pmToggleWhitelistUserRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrRedirect - return true - } - if c.db.ToggleWhitelistedUser(c.authUser.ID, user.ID) { - c.err = NewErrSuccess("added to whitelist") - } else { - c.err = NewErrSuccess("removed from whitelist") - } - return true - } - return false -} - -func handleChessCmd(c *Command) (handled bool) { - if m := chessRgx.FindStringSubmatch(c.message); len(m) == 3 { - username := database.Username(m[1]) - color := m[2] - player1 := *c.authUser - player2, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = errors.New("invalid username") - return true - } - if color == "r" { - color = utils.RandChoice([]string{"w", "b"}) - } - if color == "b" { - player1, player2 = player2, player1 - } - if _, err := ChessInstance.NewGame1(c.roomKey, c.room.ID, player1, player2); err != nil { - c.err = err - return true - } - c.err = NewErrSuccess("chess game created") - return true - } - return -} - -func handleInboxCmd(c *Command) (handled bool) { - if m := inboxRgx.FindStringSubmatch(c.message); len(m) == 4 { - username := database.Username(m[1]) - encryptRaw := m[2] - message := m[3] - tryEncrypt := false - if encryptRaw == " -e" { - tryEncrypt = true - } - toUser, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = errors.New("invalid username") - return true - } - - if err := canUserInboxOther(c.db, *c.authUser, toUser); err != nil { - c.err = err - return true - } - - html := message - if tryEncrypt { - if toUser.GPGPublicKey == "" { - c.err = errors.New("user has no pgp public key") - return true - } - html, err = utils.GeneratePgpEncryptedMessage(toUser.GPGPublicKey, message) - if err != nil { - c.err = errors.New("failed to encrypt") - return true - } - html = strings.Join(strings.Split(html, "\n"), " ") - } - - html, _, _ = ProcessRawMessage(c.db, html, c.roomKey, c.authUser.ID, c.room.ID, nil, c.authUser.CanUseMultiline) - c.db.CreateInboxMessage(html, c.room.ID, c.authUser.ID, toUser.ID, true, false, nil) - - c.dataMessage = "/inbox " + string(username) + " " - c.err = NewErrSuccess("inbox sent") - return true - - } else if strings.HasPrefix(c.message, "/inbox ") { - c.err = errors.New("invalid /inbox command") - return true - } - return -} - -func handleProfileCmd(c *Command) (handled bool) { - if m := profileRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrUsernameNotFound - return true - } - profile := `/u/` + user.Username - c.zeroMsg(fmt.Sprintf(`[<a href="%s" rel="noopener noreferrer" target="_blank">profile of %s</a>]`, profile, user.Username)) - c.err = ErrRedirect - return true - } else if strings.HasPrefix(c.message, "/p ") { - c.err = errors.New("invalid profile command") - return true - } - return -} - -type tutorialSteps struct { - SendMessage bool - EditMessage bool - SendPM bool - EditPM bool - SendQuote bool - TagSomeone bool - VisitProfile bool -} - -func handleTutorialCmd(c *Command) (handled bool) { - if c.message == "/tuto" && false { - name := "tuto_" + utils.GenerateToken10() - room, _ := c.db.CreateRoom(name, "", c.authUser.ID, false) - c.err = ErrRedirect - c.zeroProcMsg("Tutorial here -> #" + room.Name) - c.zeroPublicProcMsgRoom("Welcome to the tutorial", "", room.ID) - return true - } - return -} - -func handleDeleteMsgCmd(c *Command) (handled bool) { - delMsgFn := func(msg database.ChatMessage) { - if msg.RoomID == config.GeneralRoomID && msg.ToUserID == nil { - msg.User.GeneralMessagesCount-- - msg.User.DoSave(c.db) - } - _ = msg.Delete(c.db) - } - if c.message == "/d" { - if msg, err := c.db.GetUserLastChatMessageInRoom(c.authUser.ID, c.room.ID); err != nil { - c.err = errors.New("unable to find last message") - return true - } else if msg.TooOldToDelete() { - c.err = errors.New("message is to old to be deleted") - return true - } else { - delMsgFn(msg) - } - c.err = ErrRedirect - return true - - } else if m := deleteMsgRgx.FindStringSubmatch(c.message); len(m) >= 3 { - if len(m) == 3 { - date := m[1] - matchUsername := m[2] - dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()) - if err != nil { - logrus.Error(err) - c.err = err - return true - } - msgs, err := c.db.GetRoomChatMessagesByDate(c.room.ID, dt.UTC()) - if err != nil { - c.err = err - return true - } - if len(msgs) == 0 { - c.err = errors.New("failed to find msg") - return true - - } else if len(msgs) == 1 { - msg := msgs[0] - if !c.authUser.IsModerator() { - if msg.User.Username != c.authUser.Username { - c.err = errors.New("failed to find msg") - return true - } - if msg.TooOldToDelete() { - c.err = errors.New("message is to old to be deleted") - return true - } - delMsgFn(msg) - c.err = ErrRedirect - return true - } - // Moderator - _ = msg.Delete(c.db) - c.err = ErrRedirect - return true - - } else if len(msgs) > 1 { - if !c.authUser.IsModerator() { - var msg database.ChatMessage - for _, msgTmp := range msgs { - if msgTmp.User.Username == c.authUser.Username { - msg = msgTmp - break - } - } - if msg.UUID == "" { - c.err = errors.New("failed to find msg") - return true - } - if msg.TooOldToDelete() { - c.err = errors.New("message is to old to be deleted") - return true - } - delMsgFn(msg) - c.err = ErrRedirect - return true - - } - - // Moderator - if matchUsername == "" { - c.err = errors.New("more the 1 msg with this timestamp") - return true - } - var msg database.ChatMessage - for _, msgTmp := range msgs { - if string(msgTmp.User.Username) == matchUsername { - msg = msgTmp - break - } - } - if msg.UUID == "" { - c.err = errors.New("failed to find msg") - return true - } - _ = msg.Delete(c.db) - c.err = ErrRedirect - return true - } - } - return true - - } else if strings.HasPrefix(c.message, "/d ") { - c.err = errors.New("invalid /d command") - return true - } - return -} - -func handleHideMsgCmd(c *Command) (handled bool) { - if m := hideRgx.FindStringSubmatch(c.message); len(m) == 2 { - date := m[1] - dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()) - if err != nil { - logrus.Error(err) - c.err = err - return true - } - msgs, err := c.db.GetRoomChatMessagesByDate(c.room.ID, dt.UTC()) - if err != nil { - c.err = err - return true - } - if len(msgs) == 1 { - c.db.IgnoreMessage(c.authUser.ID, msgs[0].ID) - c.err = ErrRedirect - } else { - c.err = errors.New("more than 1 message") - } - return true - } - return -} - -func handleUnHideMsgCmd(c *Command) (handled bool) { - if m := unhideRgx.FindStringSubmatch(c.message); len(m) == 2 { - date := m[1] - dt, err := utils.ParsePrevDatetimeAt(date, clockwork.NewRealClock()) - if err != nil { - logrus.Error(err) - c.err = err - return true - } - msgs, err := c.db.GetRoomChatMessagesByDate(c.room.ID, dt.UTC()) - if err != nil { - c.err = err - return true - } - if len(msgs) == 1 { - c.db.UnIgnoreMessage(c.authUser.ID, msgs[0].ID) - c.err = ErrRedirect - } else { - c.err = errors.New("more than 1 message") - } - return true - } - return -} - -func handleIgnoreCmd(c *Command) (handled bool) { - if m := ignoreRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrRedirect - return true - } - c.db.IgnoreUser(c.authUser.ID, user.ID) - database.MsgPubSub.Pub("refresh_"+string(c.authUser.Username), database.ChatMessageType{Typ: database.ForceRefresh}) - c.err = ErrRedirect - return true - } else if strings.HasPrefix(c.message, "/ignore ") || strings.HasPrefix(c.message, "/i ") { - c.err = errors.New("invalid ignore command") - return true - } - return -} - -func handleUnIgnoreCmd(c *Command) (handled bool) { - if m := unIgnoreRgx.FindStringSubmatch(c.message); len(m) == 2 { - username := database.Username(m[1]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = ErrRedirect - return true - } - c.db.UnIgnoreUser(c.authUser.ID, user.ID) - database.MsgPubSub.Pub("refresh_"+string(c.authUser.Username), database.ChatMessageType{Typ: database.ForceRefresh}) - c.err = ErrRedirect - return true - } else if strings.HasPrefix(c.message, "/unignore ") || strings.HasPrefix(c.message, "/ui ") { - c.err = errors.New("invalid unignore command") - return true - } - return -} - -func handleToggleAutocomplete(c *Command) (handled bool) { - if c.message == "/toggle-autocomplete" { - c.authUser.AutocompleteCommandsEnabled = !c.authUser.AutocompleteCommandsEnabled - c.authUser.DoSave(c.db) - c.err = ErrRedirect - return true - } - return -} - -func handleAfkCmd(c *Command) (handled bool) { - if c.message == "/afk" { - c.authUser.AFK = !c.authUser.AFK - c.authUser.DoSave(c.db) - c.err = ErrRedirect - return true - } - return -} - -func handleDateCmd(c *Command) (handled bool) { - if c.message == "/date" { - c.zeroMsg(time.Now().Format(time.RFC1123)) - c.err = ErrRedirect - return true - } - return -} - -func handleSuccessCmd(c *Command) (handled bool) { - if c.message == "/success" { - c.err = NewErrSuccess("success message") - return true - } - return -} - -func handleErrorCmd(c *Command) (handled bool) { - if c.message == "/error" { - c.err = errors.New("error message") - return true - } - return -} - -func handleSystemCmd(c *Command) (handled bool) { - if strings.HasPrefix(c.message, "/sys ") { - c.message = strings.Replace(c.message, "/sys ", "/system ", 1) - } - if strings.HasPrefix(c.message, "/system ") { - c.message = strings.TrimPrefix(c.message, "/system ") - c.systemMsg = true - return true - } - return false -} - -func handleSetChatRoomExternalLink(c *Command) (handled bool) { - if m := setUrlRgx.FindStringSubmatch(c.message); len(m) == 2 { - externalURL := m[1] - if !govalidator.IsURL(externalURL) { - externalURL = "" - } - room, err := c.db.GetChatRoomByID(c.room.ID) - if err != nil { - c.err = err - return true - } - room.ExternalLink = externalURL - room.DoSave(c.db) - c.err = ErrRedirect - return true - } - return -} - -func handlePurge(c *Command) (handled bool) { - if m := purgeRgx.FindStringSubmatch(c.message); len(m) == 3 { - isHB := m[1] == " -hb" - username := database.Username(m[2]) - user, err := c.db.GetUserByUsername(username) - if err != nil { - c.err = err - return true - } - c.db.NewAudit(*c.authUser, fmt.Sprintf("purge %s #%d", user.Username, user.ID)) - if isHB { - _ = c.db.DeleteUserHbChatMessages(user.ID) - } else { - _ = c.db.DeleteUserChatMessages(user.ID) - } - database.MsgPubSub.Pub(database.RefreshTopic, database.ChatMessageType{Typ: database.ForceRefresh}) - c.err = ErrRedirect - return true - } - return -} - -func handleRename(c *Command) (handled bool) { - if m := renameRgx.FindStringSubmatch(c.message); len(m) == 3 { - oldUsername := database.Username(m[1]) - newUsername := database.Username(m[2]) - user, err := c.db.GetUserByUsername(oldUsername) - if err != nil { - c.err = err - return true - } - c.db.NewAudit(*c.authUser, fmt.Sprintf("rename %s -> %s #%d", user.Username, newUsername, user.ID)) - - if err := c.db.CanRenameTo(oldUsername, newUsername); err != nil { - c.err = err - return true - } - - managers.ActiveUsers.RemoveUser(user.ID) - user.Username = newUsername - user.DoSave(c.db) - - c.err = ErrRedirect - return true - } - return -} - -func handleNewMeme(c *Command) (handled bool) { - if m := memeRgx.FindStringSubmatch(c.message); len(m) == 2 { - if c.upload == nil { - c.err = errors.New("no file uploaded") - return true - } - slug := m[1] - oldPath := filepath.Join(config.Global.ProjectUploadsPath(), c.upload.FileName) - newPath := filepath.Join(config.Global.ProjectMemesPath(), c.upload.FileName) - _ = os.Rename(oldPath, newPath) - - if err := c.db.DB().Delete(&c.upload).Error; err != nil { - logrus.Error(err) - } - - meme := database.Meme{ - Slug: slug, - FileName: c.upload.FileName, - OrigFileName: c.upload.OrigFileName, - FileSize: c.upload.FileSize, - } - if err := c.db.DB().Create(&meme).Error; err != nil { - _ = os.Remove(newPath) - logrus.Error(err) - } - - c.err = ErrRedirect - return true - } - return -} - -func handleRemoveMeme(c *Command) (handled bool) { - if m := memeRemoveRgx.FindStringSubmatch(c.message); len(m) == 2 { - slug := m[1] - meme, err := c.db.GetMemeBySlug(slug) - if err != nil { - c.err = errors.New("meme not found") - return true - } - if err := meme.Delete(c.db); err != nil { - c.err = err - return true - } - c.err = NewErrSuccess("meme removed") - return true - } - return -} - -func handleRenameMeme(c *Command) (handled bool) { - if m := memeRenameRgx.FindStringSubmatch(c.message); len(m) == 3 { - slug := m[1] - newSlug := m[2] - meme, err := c.db.GetMemeBySlug(slug) - if err != nil { - c.err = errors.New("meme not found") - return true - } - meme.Slug = newSlug - meme.DoSave(c.db) - c.err = NewErrSuccess("meme renamed") - return true - } - return -} - -func handleListMemes(c *Command) (handled bool) { - if m := memesRgx.FindStringSubmatch(c.message); len(m) == 1 { - memes, _ := c.db.GetMemes() - msg := "" - for _, m := range memes { - msg += fmt.Sprintf(`<a href="/memes/%s" rel="noopener noreferrer" target="_blank">%s</a>`, m.Slug, m.Slug) - if c.authUser.IsAdmin { - msg += fmt.Sprintf(` (%s)`, humanize.Bytes(uint64(m.FileSize))) - } - msg += "<br />" - } - c.zeroMsg(msg) - c.err = ErrRedirect - return true - } - return -} - -func handleRefreshCmd(c *Command) (handled bool) { - if c.message == "/refresh" { - c.err = ErrRedirect - database.MsgPubSub.Pub(database.RefreshTopic, database.ChatMessageType{Typ: database.ForceRefresh}) - return true - } - return -} - -func handleCodeCmd(c *Command) (handled bool) { - if c.message == "/code" { - c.err = ErrRedirect - if !c.authUser.CanUseMultiline { - c.err = errors.New("multiline is disabled for your account") - return true - } else if !c.authUser.UseStream { - c.err = errors.New("only work on stream version of this chat") - return true - } - payload := database.ChatMessageType{} - if c.modMsg { - payload.IsMod = true - } - if c.toUser != nil { - toUserUsername := c.toUser.Username - payload.ToUserUsername = &toUserUsername - } - // ModalManager.Pub("code", payload) - database.MsgPubSub.Pub("modal_code_show_"+c.authUser.ID.String()+"_"+c.room.ID.String(), payload) - return true - } - return -} - -func handleUpdateReadMarkerCmd(c *Command) (handled bool) { - if c.message == "/r" { - c.db.UpdateChatReadMarker(c.authUser.ID, c.room.ID) - c.err = ErrRedirect - return true - } - return -} diff --git a/pkg/web/handlers/api/v1/snippetInterceptor.go b/pkg/web/handlers/api/v1/snippetInterceptor.go @@ -1,57 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/database" - "dkforest/pkg/managers" - "strings" -) - -type SnippetInterceptor struct{} - -func (i SnippetInterceptor) InterceptMsg(cmd *Command) { - // Snippets actually mutate the original message, - // to simulate that the user actually typed the text - cmd.origMessage = snippets(cmd.db, cmd.authUser.ID, cmd.origMessage) - - cmd.origMessage = autocompleteTags(cmd.origMessage) - - cmd.message = cmd.origMessage -} - -func snippets(db *database.DkfDB, authUserID database.UserID, html string) string { - if snippetRgx.MatchString(html) { - userSnippets, _ := db.GetUserSnippets(authUserID) - if len(userSnippets) > 0 { - // Build hashmap for fast lookup - m := make(map[string]string) - for _, snippet := range userSnippets { - m["!"+snippet.Name] = snippet.Text - } - html = snippetRgx.ReplaceAllStringFunc(html, func(s string) string { - // If snippet name exists, use the mapped value - if v, ok := m[s]; ok { - return v - } - return s - }) - } - } - return html -} - -func autocompleteTags(html string) string { - activeUsers := managers.ActiveUsers.GetActiveUsers() - html = autoTagRgx.ReplaceAllStringFunc(html, func(s string) string { - s1 := strings.TrimPrefix(s, "@") - s1 = strings.TrimSuffix(s1, "*") - s1 = strings.ToLower(s1) - for _, au := range activeUsers { - l := strings.ToLower(string(au.Username)) - if strings.HasPrefix(l, s1) { - return au.Username.AtStr() - } - } - return s - }) - return html -} diff --git a/pkg/web/handlers/api/v1/spamInterceptor.go b/pkg/web/handlers/api/v1/spamInterceptor.go @@ -1,265 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/config" - "dkforest/pkg/database" - dutils "dkforest/pkg/database/utils" - "dkforest/pkg/utils" - "errors" - "github.com/sirupsen/logrus" - "regexp" - "strings" - "sync" - "time" -) - -type SpamInterceptor struct{} - -type Filter struct { - IsRegex bool - Term string - Rgx *regexp.Regexp - Kick bool - Hb bool -} - -var filters []Filter -var filtersMtx sync.RWMutex - -func LoadFilters(db *database.DkfDB) { - filtersMtx.Lock() - defer filtersMtx.Unlock() - filters = make([]Filter, 0) - dbFilters, _ := db.GetSpamFilters() - for _, dbFilter := range dbFilters { - f := Filter{IsRegex: dbFilter.IsRegex} - if dbFilter.Action == 1 { - f.Kick = true - } else if dbFilter.Action == 2 { - f.Hb = true - } - if dbFilter.IsRegex { - f.Rgx = regexp.MustCompile(dbFilter.Filter) - } else { - f.Term = dbFilter.Filter - } - filters = append(filters, f) - } -} - -// Check the filters that we have in the database. -func checkDynamicFilters(c *Command, lowerCaseMessage string, silentSelfKick bool) error { - filtersMtx.RLock() - defer filtersMtx.RUnlock() - for _, f := range filters { - isMatch := (f.IsRegex && f.Rgx.MatchString(c.message)) || - (!f.IsRegex && strings.Contains(lowerCaseMessage, f.Term)) - if isMatch { - if f.Hb { - dutils.SelfHellBan(c.db, c.authUser) - return ErrSilent - } - if f.Kick { - _ = dutils.SelfKick(c.db, *c.authUser, silentSelfKick) - } - return ErrSpamFilterTriggered - } - } - return nil -} - -func (i SpamInterceptor) InterceptMsg(c *Command) { - lowerCaseMessage := strings.ToLower(c.message) - silentSelfKick := config.SilentSelfKick.Load() - - if err := checkDynamicFilters(c, lowerCaseMessage, silentSelfKick); err != nil { - if !errors.Is(err, ErrSilent) { - c.err = err - } - return - } - - if c.room.IsOfficialRoom() { - if err := checkSpam(c.db, c.origMessage, lowerCaseMessage, c.authUser); err != nil { - c.err = err - return - } - } - - // Check CP links - if checkCPLinks(c.db, c.message) { - c.err = errors.New("forbidden url") - return - } - - if !c.authUser.CanUseUppercase { - c.message = strings.ToLower(c.message) - } -} - -var ErrSilent = errors.New("") -var ErrSpamFilterTriggered = errors.New("spam filter triggered") - -func checkSpam(db *database.DkfDB, origMessage, lowerCaseMessage string, authUser *database.User) error { - silentSelfKick := config.SilentSelfKick.Load() - - // Kick retard new users - if time.Since(authUser.CreatedAt) < 5*time.Hour { - if strings.Contains(lowerCaseMessage, "fucked up links") || - strings.Contains(lowerCaseMessage, "i wanna see gore") || - strings.Contains(lowerCaseMessage, "how can i make money") || - strings.Contains(lowerCaseMessage, "any links for scary stuff") { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - if authUser.GeneralMessagesCount < 20 || time.Since(authUser.CreatedAt) < 5*time.Hour { - if strings.Contains(lowerCaseMessage, "cp link") { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - - if strings.Contains(lowerCaseMessage, "#dorkforest") { - if authUser.IsModerator() { - return ErrSpamFilterTriggered - } - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - - // Auto kick upper case typing retards - if authUser.GeneralMessagesCount <= 5 { - count, total := utils.CountUppercase(origMessage) - pct := float64(count) / float64(total) - if total > 5 && pct > 0.8 { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - - // Auto HB "new here"/"legit market" retards - if autoHellbanCheck(authUser, lowerCaseMessage) { - dutils.SelfHellBan(db, authUser) - return nil - } - - if autoKickSpammers(authUser, lowerCaseMessage) { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - - tot, wordsMap := utils.WordCount(lowerCaseMessage) - if tot >= 5 { - totalUniqueWords := len(wordsMap) - uniqueRatio := float64(totalUniqueWords) / float64(tot) - repeatedWordsCount := 0 - for word, count := range wordsMap { - if len(word) >= 5 && count > 10 { - repeatedWordsCount++ - } - } - retardRatio := float64(repeatedWordsCount) / float64(totalUniqueWords) - //fmt.Println(tot, totalUniqueWords, uniqueRatio, repeatedWordsCount, retardRatio, wordsMap) - if uniqueRatio < 0.2 { - logrus.Error("failed unique ratio: " + origMessage) - return errors.New("failed unique ratio") - } - if retardRatio > 0.1 { - logrus.Error("failed retard ratio: " + origMessage) - return errors.New("failed retard ratio") - } - } - - if authUser.GeneralMessagesCount < 10 { - if autoKickProfanity(tot, wordsMap) { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - - if authUser.GeneralMessagesCount < 4 { - if (wordsMap["need"] > 0 && wordsMap["help"] > 0) || - (wordsMap["help"] > 0 && wordsMap["me"] > 0) || - (wordsMap["make"] > 0 && wordsMap["money"] > 0) || - wordsMap["porn"] > 0 || - wordsMap["murder"] > 0 { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - - if authUser.GeneralMessagesCount < 10 { - if ((wordsMap["learn"] > 0 || wordsMap["teach"] > 0) && (wordsMap["hacking"] > 0 || wordsMap["hack"] > 0)) || - (wordsMap["cook"] > 0 && wordsMap["meth"] > 0) || - (wordsMap["creepy"] > 0 && (wordsMap["site"] > 0 || wordsMap["sites"] > 0)) || - (wordsMap["porn"] > 0 && (wordsMap["link"] > 0 || wordsMap["links"] > 0)) || - (wordsMap["topic"] > 0 && wordsMap["link"] > 0) { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - - if authUser.GeneralMessagesCount < 20 || time.Since(authUser.CreatedAt) < 5*time.Hour { - if wordsMap["cp"] > 0 && (wordsMap["link"] > 0 || wordsMap["links"] > 0) { - _ = dutils.SelfKick(db, *authUser, silentSelfKick) - return ErrSpamFilterTriggered - } - } - - return nil -} - -func autoKickProfanityTmp(orig string) bool { - tot, m := utils.WordCount(strings.ToLower(orig)) - return autoKickProfanity(tot, m) -} - -func autoKickProfanity(tot int, wordsMap map[string]int) bool { - if tot > 4 && countProfanity(wordsMap) >= 4 { - return true - } - return false -} - -func countProfanity(wordsMap map[string]int) int { - profanityWords := []string{"anus", "asshole", "cock", "dick", "nigger", "niggers", "nigga", "niggas", "sex", "rape", "porn", - "cunt", "murder", "fuck", "blood", "corpse", "hole", "slut", "bitch", "shit", "poop", "butt", "faggot", - "submissive", "slurping", "suck", "nuts", "gore", "stupid", "dumb", "jerking", "rotten", "rotted", "stinky"} - profanity := 0 - for _, w := range profanityWords { - if n, ok := wordsMap[w]; ok { - profanity += n - } - } - return profanity -} - -var spamCharsRgx = regexp.MustCompile("[^a-z0-9]+") - -func autoKickSpammers(authUser *database.User, lowerCaseMessage string) bool { - if authUser.GeneralMessagesCount <= 10 { - processedString := spamCharsRgx.ReplaceAllString(lowerCaseMessage, "") - return strings.Contains(processedString, "lemybeauty") || - strings.Contains(processedString, "blacktorcc") || - strings.Contains(processedString, "profjerry") || - strings.Contains(processedString, "shopdarkse") - } - return false -} - -func autoHellbanCheck(authUser *database.User, lowerCaseMessage string) bool { - checks := []string{ - "new here", - "legit market", - "help me", - } - if authUser.GeneralMessagesCount <= 5 { - for _, check := range checks { - if strings.Contains(lowerCaseMessage, check) { - return true - } - } - } - return false -} diff --git a/pkg/web/handlers/api/v1/spamInterceptor_test.go b/pkg/web/handlers/api/v1/spamInterceptor_test.go @@ -1,69 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/database" - "github.com/stretchr/testify/assert" - "testing" -) - -func Test_autoHellbanCheck(t *testing.T) { - type args struct { - authUser *database.User - lowerCaseMessage string - } - tests := []struct { - name string - args args - want bool - }{ - {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "hi new here"}, want: true}, - {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "hello anybody know of any legit market places ? its getting tough on here to find any that actually do what they supposed to "}, want: true}, - {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "Hello Guys and Ladys someone can help me? I Have a Little problem.."}, want: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, autoHellbanCheck(tt.args.authUser, tt.args.lowerCaseMessage), "autoHellbanCheck(%v, %v)", tt.args.authUser, tt.args.lowerCaseMessage) - }) - } -} - -func Test_autoKickSpammers(t *testing.T) { - type args struct { - authUser *database.User - lowerCaseMessage string - } - tests := []struct { - name string - args args - want bool - }{ - {name: "", args: args{authUser: &database.User{GeneralMessagesCount: 2}, lowerCaseMessage: "blablabla l e m y _ b e a u t y on "}, want: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, autoKickSpammers(tt.args.authUser, tt.args.lowerCaseMessage), "autoKickSpammers(%v, %v)", tt.args.authUser, tt.args.lowerCaseMessage) - }) - } -} - -func Test_autoKickProfanityTmp(t *testing.T) { - type args struct { - orig string - } - tests := []struct { - name string - args args - want bool - }{ - {"", args{orig: "biden is dumb fuck can suck my dick stupid nigger"}, true}, - {"", args{orig: "u can suck his nuts like the submissive faggot u are. slurping eye contact for deep man love with dirty butthole sniffing."}, true}, - {"", args{orig: "how to tear a human slut bitch from the cunt to the part in her hairline then shit into the chest cavity for happy dumpling poop soup"}, true}, - {"", args{orig: "lets murder a nun and fuck the blood scabs into her corpse pussy hole"}, true}, - {"", args{orig: "quick question, whats the best method to plant a grenaed in old ladys stinky rotted cunt hole to blast her bloods on a hotel walls"}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, autoKickProfanityTmp(tt.args.orig), "autoKickProfanityTmp(%v)", tt.args.orig) - }) - } -} diff --git a/pkg/web/handlers/api/v1/topBarHandler.go b/pkg/web/handlers/api/v1/topBarHandler.go @@ -1,117 +1,31 @@ package v1 import ( - bf "dkforest/pkg/blackfriday/v2" "dkforest/pkg/clockwork" "dkforest/pkg/config" "dkforest/pkg/database" dutils "dkforest/pkg/database/utils" - "dkforest/pkg/hashset" - "dkforest/pkg/levenshtein" - "dkforest/pkg/managers" "dkforest/pkg/utils" - "errors" + "dkforest/pkg/web/handlers/api/v1/interceptors" "fmt" - "github.com/ProtonMail/go-crypto/openpgp/clearsign" "github.com/dustin/go-humanize" "github.com/labstack/echo" - html2 "html" - "math" "net/http" "net/url" - "regexp" "strings" "time" ) -const ( - agePrefix = "-----BEGIN AGE ENCRYPTED FILE-----" - ageSuffix = "-----END AGE ENCRYPTED FILE-----" - pgpPrefix = "-----BEGIN PGP MESSAGE-----" - pgpSuffix = "-----END PGP MESSAGE-----" - pgpPKeyPrefix = "-----BEGIN PGP PUBLIC KEY BLOCK-----" - pgpPKeySuffix = "-----END PGP PUBLIC KEY BLOCK-----" - pgpSignedPrefix = "-----BEGIN PGP SIGNED MESSAGE-----" - pgpSignedSuffix = "-----END PGP SIGNATURE-----" -) - -var emojiReplacer = strings.NewReplacer( - ":):", `<span class="emoji" title=":):">☺</span>`, - ":smile:", `<span class="emoji" title=":smile:">☺</span>`, - ":happy:", `<span class="emoji" title=":happy:">😃</span>`, - ":see-no-evil:", `<span class="emoji" title=":see-no-evil:">🙈</span>`, - ":hear-no-evil:", `<span class="emoji" title=":hear-no-evil:">🙉</span>`, - ":speak-no-evil:", `<span class="emoji" title=":speak-no-evil:">🙊</span>`, - ":poop:", `<span class="emoji" title=":poop:">💩</span>`, - ":+1:", `<span class="emoji" title=":+1:">👍</span>`, - ":evil:", `<span class="emoji" title=":evil:">😈</span>`, - ":cat-happy:", `<span class="emoji" title=":cat-happy:">😸</span>`, - ":eyes:", `<span class="emoji" title=":eyes:">👀</span>`, - ":wave:", `<span class="emoji" title=":wave:">👋</span>`, - ":clap:", `<span class="emoji" title=":clap:">👏</span>`, - ":fire:", `<span class="emoji" title=":fire:">🔥</span>`, - ":sparkles:", `<span class="emoji" title=":sparkles:">✨</span>`, - ":sweat:", `<span class="emoji" title=":sweat:">💦</span>`, - ":heart:", `<span class="emoji" title=":heart:">❤</span>`, - ":broken-heart:", `<span class="emoji" title=":broken-heart:">💔</span>`, - ":zzz:", `<span class="emoji" title=":zzz:">💤</span>`, - ":praise:", `<span class="emoji" title=":praise:">🙌</span>`, - ":joy:", `<span class="emoji" title=":joy:">😂</span>`, - ":sob:", `<span class="emoji" title=":sob:">😭</span>`, - ":scream:", `<span class="emoji" title=":scream:">😱</span>`, - ":heart-eyes:", `<span class="emoji" title=":heart-eyes:">😍</span>`, - ":blush:", `<span class="emoji" title=":blush:">☺</span>`, - ":crazy:", `<span class="emoji" title=":crazy:">😜</span>`, - ":angry:", `<span class="emoji" title=":angry:">😡</span>`, - ":triumph:", `<span class="emoji" title=":triumph:">😤</span>`, - ":vomit:", `<span class="emoji" title=":vomit:">🤮</span>`, - ":skull:", `<span class="emoji" title=":skull:">💀</span>`, - ":alien:", `<span class="emoji" title=":alien:">👽</span>`, - ":sleeping:", `<span class="emoji" title=":sleeping:">😴</span>`, - ":tongue:", `<span class="emoji" title=":tongue:">😛</span>`, - ":cool:", `<span class="emoji" title=":cool:">😎</span>`, - ":wink:", `<span class="emoji" title=":wink:">😉</span>`, - ":thinking:", `<span class="emoji" title=":thinking:">🤔</span>`, - ":happy-sweat:", `<span class="emoji" title=":happy-sweat:">😅</span>`, - ":nerd:", `<span class="emoji" title=":nerd:">🤓</span>`, - ":fox:", `<span class="emoji" title=":fox:">🦊</span>`, - ":popcorn:", `<span class="emoji" title=":popcorn:">🍿</span>`, - ":shrug:", `¯\_(ツ)_/¯`, - ":flip:", `(╯°□°)╯︵ ┻━┻`, - ":flip-all:", `┻━┻︵ \(°□°)/ ︵ ┻━┻`, - ":fix-table:", `(ヘ・_・)ヘ┳━┳`, - ":disap:", `ಠ_ಠ`, -) - -var ErrRedirect = errors.New("redirect") -var ErrStop = errors.New("stop") - -const minMsgLen = 1 -const maxMsgLen = 10000 - -const ( - redirectPmQP = "pm" - redirectEditQP = "e" - redirectGroupQP = "g" - redirectModQP = "m" - redirectHbmQP = "hbm" - redirectTagQP = "tag" - redirectHTagQP = "htag" - redirectMTagQP = "mtag" - redirectQuoteQP = "quote" - redirectMultilineQP = "ml" -) - func getDataMessagePrefix(db *database.DkfDB, c echo.Context, roomKey string, room database.ChatRoom, authUser *database.User) (out string, err error) { - pm := c.QueryParam(redirectPmQP) - edit := c.QueryParam(redirectEditQP) - group := c.QueryParam(redirectGroupQP) - mod := c.QueryParam(redirectModQP) - hbm := c.QueryParam(redirectHbmQP) - tag := c.QueryParam(redirectTagQP) - htag := c.QueryParam(redirectHTagQP) - mtag := c.QueryParam(redirectMTagQP) - quote := c.QueryParam(redirectQuoteQP) + pm := c.QueryParam(interceptors.RedirectPmQP) + edit := c.QueryParam(interceptors.RedirectEditQP) + group := c.QueryParam(interceptors.RedirectGroupQP) + mod := c.QueryParam(interceptors.RedirectModQP) + hbm := c.QueryParam(interceptors.RedirectHbmQP) + tag := c.QueryParam(interceptors.RedirectTagQP) + htag := c.QueryParam(interceptors.RedirectHTagQP) + mtag := c.QueryParam(interceptors.RedirectMTagQP) + quote := c.QueryParam(interceptors.RedirectQuoteQP) if pm != "" { out = "/pm " + pm + " " @@ -218,18 +132,18 @@ func ChatTopBarHandler(c echo.Context) error { data.RoomName = c.Param("roomName") queryParams := c.QueryParams() - origMl := utils.DoParseBool(c.QueryParam(redirectMultilineQP)) + origMl := utils.DoParseBool(c.QueryParam(interceptors.RedirectMultilineQP)) data.QueryParams = queryParams.Encode() - queryParams.Set(redirectMultilineQP, "1") + queryParams.Set(interceptors.RedirectMultilineQP, "1") data.QueryParamsMl = queryParams.Encode() - queryParams.Del(redirectMultilineQP) + queryParams.Del(interceptors.RedirectMultilineQP) data.QueryParamsNml = queryParams.Encode() redirectQP := url.Values{} if authUser.CanUseMultiline { data.Multiline = origMl if data.Multiline { - redirectQP.Set(redirectMultilineQP, "1") + redirectQP.Set(interceptors.RedirectMultilineQP, "1") } } @@ -269,42 +183,38 @@ func ChatTopBarHandler(c echo.Context) error { origMessage := strings.TrimSpace(c.Request().PostFormValue("message")) - cmd := NewCommand(c, origMessage, room, roomKey) - cmd.redirectQP = redirectQP - - type Interceptor interface { - InterceptMsg(*Command) - } - - interceptors := []Interceptor{ - SnippetInterceptor{}, - SpamInterceptor{}, - ChessInstance, - BattleshipInstance, - WWInstance, - BangInterceptor{}, - UploadInterceptor{}, - SlashInterceptor{}, - CodeModalInterceptor{}, - MsgInterceptor{}, + cmd := interceptors.NewCommand(c, origMessage, room, roomKey) + cmd.SetRedirectQP(redirectQP) + + interceptors := []interceptors.Interceptor{ + interceptors.SnippetInterceptor{}, + interceptors.SpamInterceptor{}, + interceptors.ChessInstance, + interceptors.BattleshipInstance, + interceptors.WWInstance, + interceptors.BangInterceptor{}, + interceptors.UploadInterceptor{}, + interceptors.SlashInterceptor{}, + interceptors.CodeModalInterceptor{}, + interceptors.MsgInterceptor{}, } for _, interceptor := range interceptors { interceptor.InterceptMsg(cmd) - data.Message = cmd.dataMessage - if cmd.err != nil { - return handleCmdError(cmd.err, c, data, cmd.redirectURL(), cmd.origMessage) + data.Message = cmd.DataMessage() + if cmd.Err() != nil { + return handleCmdError(cmd.Err(), c, data, cmd.RedirectURL(), cmd.OrigMessage()) } } - return c.Redirect(http.StatusFound, cmd.redirectURL()) + return c.Redirect(http.StatusFound, cmd.RedirectURL()) } func handleCmdError(err error, ctx echo.Context, data chatTopBarData, redirectURL, origMessage string) error { - if err == ErrRedirect { + if err == interceptors.ErrRedirect { return ctx.Redirect(http.StatusFound, redirectURL) - } else if err == ErrStop { + } else if err == interceptors.ErrStop { return ctx.Render(http.StatusOK, "chat-top-bar", data) - } else if serr, ok := err.(*ErrSuccess); ok { + } else if serr, ok := err.(*interceptors.ErrSuccess); ok { data.Success = serr.Error() return ctx.Render(http.StatusOK, "chat-top-bar", data) } @@ -313,29 +223,6 @@ func handleCmdError(err error, ctx echo.Context, data chatTopBarData, redirectUR return ctx.Render(http.StatusOK, "chat-top-bar", data) } -func convertMarkdown(in string) string { - out := strings.Replace(in, "\r", "", -1) - resBytes := bf.Run([]byte(out), bf.WithRenderer(utils.MyRenderer(false, false)), bf.WithExtensions( - bf.NoIntraEmphasis|bf.Tables|bf.FencedCode| - bf.Strikethrough|bf.SpaceHeadings| - bf.DefinitionLists|bf.HardLineBreak|bf.NoLink)) - out = string(resBytes) - return out -} - -func replTextPrefixSuffix(msg, prefix, suffix, repl string) (out string) { - out = msg - pgpPIdx := strings.Index(msg, prefix) - pgpSIdx := strings.Index(msg, suffix) - if pgpPIdx != -1 && pgpSIdx != -1 { - newMsg := msg[:pgpPIdx] - newMsg += repl - newMsg += msg[pgpSIdx+len(suffix):] - out = newMsg - } - return -} - func handleGetQuote(db *database.DkfDB, msgUUID, roomKey string, room database.ChatRoom, authUser *database.User) (dataMessage string, err error) { quoted, err := db.GetRoomChatMessageByUUID(room.ID, msgUUID) if err != nil { @@ -359,7 +246,7 @@ func handleGetQuote(db *database.DkfDB, msgUUID, roomKey string, room database.C } // Append the actual quoted text - dataMessage = prefix + getQuoteTxt(db, roomKey, quoted) + " " + dataMessage = prefix + interceptors.GetQuoteTxt(db, roomKey, quoted) + " " return } @@ -377,877 +264,3 @@ func handleGetEdit(db *database.DkfDB, hourMinSec, roomKey string, room database } return dataMessage, nil } - -type Command struct { - err error - - // Data that can be mutated - redirectQP url.Values // RedirectURL Query Parameters - origMessage string // This is the original text that the user input (can be changed by /e) - dataMessage string // This is what the user will have in his input box - message string // Un-sanitized message received from the user - room database.ChatRoom // Room the user is in - roomKey string // Room password (if any) - authUser *database.User // Authenticated user (sender of the message) - db *database.DkfDB // Database instance - toUser *database.User // If not nil, will be a PM - upload *database.Upload // If the message contains an uploaded file - editMsg *database.ChatMessage // If we're editing a message - groupID *database.GroupID // If the message is for a subgroup - hellbanMsg bool // Is the message will be marked HB - systemMsg bool // Is the message system - modMsg bool // Is the message part of the "moderators" group - c echo.Context - zeroUser *database.User // Cache the zero (@0) user - skipInboxes bool -} - -func NewCommand(c echo.Context, origMessage string, room database.ChatRoom, roomKey string) *Command { - authUser := c.Get("authUser").(*database.User) - db := c.Get("database").(*database.DkfDB) - return &Command{ - c: c, - authUser: authUser, - db: db, - hellbanMsg: authUser.IsHellbanned, - redirectQP: url.Values{}, - origMessage: origMessage, - message: origMessage, - room: room, - roomKey: roomKey, - } -} - -func (c *Command) redirectURL() string { - return fmt.Sprintf("/api/v1/chat/top-bar/%s?%s", c.room.Name, c.redirectQP.Encode()) -} - -// Lazy loading and cache of the zero user -func (c *Command) getZeroUser() database.User { - if c.zeroUser == nil { - zeroUser := dutils.GetZeroUser(c.db) - c.zeroUser = &zeroUser - } - return *c.zeroUser -} - -// Have the "zero user" send a processed message to the authUser -func (c *Command) zeroProcMsg(rawMsg string) { - c.zeroProcMsgRoom(rawMsg, c.roomKey, c.room.ID) -} - -// Have the "zero user" send a processed message in the specified room -func (c *Command) zeroPublicProcMsgRoom(rawMsg, roomKey string, roomID database.RoomID) { - c.zeroProcMsgRoomToUser(rawMsg, roomKey, roomID, nil) -} - -// Have the "zero user" send a processed message to the authUser in the specified room -func (c *Command) zeroProcMsgRoom(rawMsg, roomKey string, roomID database.RoomID) { - c.zeroProcMsgRoomToUser(rawMsg, roomKey, roomID, c.authUser) -} - -// Have the "zero user" send a "processed message" PM to a user in a specific room. -func (c *Command) zeroProcMsgRoomToUser(rawMsg, roomKey string, roomID database.RoomID, toUser *database.User) { - procMsg, _, _ := ProcessRawMessage(c.db, rawMsg, roomKey, c.authUser.ID, roomID, nil, true) - c.zeroRawMsg(toUser, rawMsg, procMsg) -} - -// Have the "zero usser" send an unprocessed private message to the authUser -func (c *Command) zeroMsg(msg string) { - c.zeroRawMsg(c.authUser, msg, msg) -} - -// Have the "zero usser" send an unprocessed message in the current room -func (c *Command) zeroPublicMsg(raw, msg string) { - c.zeroRawMsg(nil, raw, msg) -} - -func (c *Command) zeroRawMsg(user2 *database.User, raw, msg string) { - zeroUser := c.getZeroUser() - c.rawMsg(zeroUser, user2, raw, msg) -} - -func (c *Command) rawMsg(user1 database.User, user2 *database.User, raw, msg string) { - if c.room.ReadOnly { - return - } - rawMsgRoom(c.db, user1, user2, raw, msg, c.roomKey, c.room.ID) -} - -func rawMsgRoom(db *database.DkfDB, user1 database.User, user2 *database.User, raw, msg, roomKey string, roomID database.RoomID) { - var toUserID *database.UserID - if user2 != nil { - toUserID = &user2.ID - } - _, _ = db.CreateMsg(raw, msg, roomKey, roomID, user1.ID, toUserID) -} - -type ErrSuccess struct { - msg string -} - -func NewErrSuccess(msg string) *ErrSuccess { - return &ErrSuccess{msg: msg} -} - -func (e ErrSuccess) Error() string { - return e.msg -} - -func appendUploadLink(html string, upload *database.Upload) string { - if upload != nil { - if html != "" { - html += " " - } - html += `[` + upload.GetHTMLLink() + `]` - } - return html -} - -func checkCPLinks(db *database.DkfDB, html string) bool { - m1 := onionV3Rgx.FindAllStringSubmatch(html, -1) - m2 := onionV2Rgx.FindAllStringSubmatch(html, -1) - for _, m := range append(m1, m2...) { - hash := utils.MD5([]byte(m[0])) - if _, err := db.GetOnionBlacklist(hash); err == nil { - return true - } - } - return false -} - -func linkDefaultRooms(html string) string { - r := strings.NewReplacer( - "#general", `<a href="/chat/general" target="_top">#general</a>`, - "#programming", `<a href="/chat/programming" target="_top">#programming</a>`, - "#hacking", `<a href="/chat/hacking" target="_top">#hacking</a>`, - "#suggestions", `<a href="/chat/suggestions" target="_top">#suggestions</a>`, - "#announcements", `<a href="/chat/announcements" target="_top">#announcements</a>`, - ) - return r.Replace(html) -} - -// Convert timestamps such as 01:23:45 to an archive link if a message with that timestamp exists. -// eg: "Some text 14:31:46 some more text" -func convertArchiveLinks(db *database.DkfDB, html string, roomID database.RoomID, authUserID database.UserID) string { - start, rest := "", html - - // Do not replace timestamps that are inside a quote text - const quoteSuffix = `”` - endOfQuoteIdx := strings.LastIndex(html, quoteSuffix) - if endOfQuoteIdx != -1 { - start, rest = html[:endOfQuoteIdx], html[endOfQuoteIdx:] - } - - archiveRgx := regexp.MustCompile(`(\d{2}-\d{2} )?\d{2}:\d{2}:\d{2}`) - if archiveRgx.MatchString(rest) { - rest = archiveRgx.ReplaceAllStringFunc(rest, func(s string) string { - var dt time.Time - var err error - if len(s) == 8 { // HH:MM:SS - dt, err = utils.ParsePrevDatetimeAt(s, clockwork.NewRealClock()) - } else if len(s) == 14 { // mm-dd HH:MM:SS - dt, err = utils.ParsePrevDatetimeAt2(s, clockwork.NewRealClock()) - } - if err != nil { - return s - } - if msgs, err := db.GetRoomChatMessagesByDate(roomID, dt.UTC()); err == nil && len(msgs) > 0 { - msg := msgs[0] - if len(msgs) > 1 { - for _, msgTmp := range msgs { - if msgTmp.User.ID == authUserID || (msgTmp.ToUserID != nil && *msgTmp.ToUserID == authUserID) { - msg = msgTmp - break - } - } - } - return fmt.Sprintf(`<a href="/chat/%s/archive#%s" target="_blank" rel="noopener noreferrer">%s</a>`, msg.Room.Name, msg.UUID, s) - } - return s - }) - } - return start + rest -} - -func convertBangShortcuts(html string) string { - r := strings.NewReplacer( - "!bhc", config.BhcOnion, - "!cryptbb", config.CryptbbOnion, - "!dread", config.DreadOnion, - "!dkf", config.DkfOnion, - "!rroom", config.DkfOnion+`/red-room`, - "!dnmx", config.DnmxOnion, - "!whonix", config.WhonixOnion, - "!age", config.AgeUrl, - "!chattor", config.ChattorOnion, - "!lulbins", config.LulbinsOnion, - ) - return r.Replace(html) -} - -type getUsersByUsernameFn func(usernames []string) ([]database.User, error) - -// Update the given html to add user style for tags. -// Return the new html, and a map[userID]User of tagged users. -func colorifyTaggedUsers(html string, getUsersByUsername getUsersByUsernameFn) (string, map[database.UserID]database.User) { - usernameMatches := tagRgx.FindAllStringSubmatch(html, -1) - usernames := hashset.New[string]() - for _, usernameMatch := range usernameMatches { - usernames.Insert(usernameMatch[1]) - } - taggedUsers, _ := getUsersByUsername(usernames.ToArray()) - - taggedUsersMap := make(map[string]database.User) - taggedUsersIDsMap := make(map[database.UserID]database.User) - for _, taggedUser := range taggedUsers { - taggedUsersMap[strings.ToLower(taggedUser.Username.AtStr())] = taggedUser - if taggedUser.Username != config.NullUsername { - taggedUsersIDsMap[taggedUser.ID] = taggedUser - } - } - - if len(usernameMatches) > 0 { - html = tagRgx.ReplaceAllStringFunc(html, func(s string) string { - lowerS := strings.ToLower(s) - if user, ok := taggedUsersMap[lowerS]; ok { - return fmt.Sprintf("<span %s>@%s</span>", user.GenerateChatStyle1(), user.Username) - } - - // Not found, try to fix typos using levenshtein - activeUsers := managers.ActiveUsers.GetActiveUsers() - if len(activeUsers) > 0 { - minDist := math.MaxInt - minAu := activeUsers[0] - for _, au := range activeUsers { - lowerAu := strings.ToLower(string(au.Username)) - d := levenshtein.ComputeDistance(lowerS, lowerAu) - if d < minDist { - minDist = d - minAu = au - } - } - if minDist <= 3 { - if users, _ := getUsersByUsername([]string{minAu.Username.String()}); len(users) > 0 { - user := users[0] - return fmt.Sprintf("<span %s>@%s</span>", user.GenerateChatStyle1(), user.Username) - } - } - } - - return s - }) - } - return html, taggedUsersIDsMap -} - -func linkRoomTags(db *database.DkfDB, html string) string { - if roomTagRgx.MatchString(html) { - html = roomTagRgx.ReplaceAllStringFunc(html, func(s string) string { - if room, err := db.GetChatRoomByName(strings.TrimPrefix(s, "#")); err == nil { - return `<a href="/chat/` + room.Name + `" target="_top">` + s + `</a>` - } - return s - }) - } - return html -} - -// Given a roomID and hourMinSec (01:23:45) and a username, retrieve the message from database that fits the predicates. -func getQuotedChatMessage(db *database.DkfDB, hourMinSec string, username database.Username, roomID database.RoomID) (quoted *database.ChatMessage) { - if dt, err := utils.ParsePrevDatetimeAt(hourMinSec, clockwork.NewRealClock()); err == nil { - if msgs, err := db.GetRoomChatMessagesByDate(roomID, dt.UTC()); err == nil && len(msgs) > 0 { - msg := msgs[0] - if len(msgs) > 1 { - for _, msgTmp := range msgs { - if msgTmp.User.Username == username { - msg = msgTmp - break - } - } - } - quoted = &msg - } - } - return -} - -// Given a chat message, return the text to be used as a quote. -func getQuoteTxt(db *database.DkfDB, roomKey string, quoted database.ChatMessage) (out string) { - var err error - decrypted, err := quoted.GetRawMessage(roomKey) - if err != nil { - return - } - if quoted.ToUserID != nil { - if m := pmRgx.FindStringSubmatch(decrypted); len(m) == 3 { - decrypted = m[2] - } - } else if quoted.Moderators { - decrypted = strings.TrimPrefix(decrypted, "/m ") - } else if quoted.IsHellbanned { - decrypted = strings.TrimPrefix(decrypted, "/hbm ") - } - isMe := false - if strings.HasPrefix(decrypted, "/me") { - isMe = true - decrypted = strings.TrimPrefix(decrypted, "/me ") - } - - startIdx := 0 - if strings.HasPrefix(decrypted, `“[`) { - startIdx = strings.Index(decrypted, `” `) - if startIdx == -1 { - startIdx = 0 - } else { - startIdx += len(`” `) - } - } - - decrypted = replTextPrefixSuffix(decrypted, agePrefix, ageSuffix, "[age.txt]") - decrypted = replTextPrefixSuffix(decrypted, pgpPrefix, pgpSuffix, "[pgp.txt]") - decrypted = replTextPrefixSuffix(decrypted, pgpPKeyPrefix, pgpPKeySuffix, "[pgp_pkey.txt]") - - remaining := " " - if !quoted.System { - remaining += fmt.Sprintf(`%s `, quoted.User.Username) - } - if quoted.UploadID != nil { - if upload, err := db.GetUploadByID(*quoted.UploadID); err == nil { - if decrypted != "" { - decrypted += " " - } - decrypted += `[` + upload.OrigFileName + `]` - } - } - if !isMe { - remaining += "- " - } - - toBeQuoted := decrypted[startIdx:] - toBeQuoted = strings.ReplaceAll(toBeQuoted, "\n", ` `) - toBeQuoted = strings.ReplaceAll(toBeQuoted, `“`, `"`) - toBeQuoted = strings.ReplaceAll(toBeQuoted, `”`, `"`) - - remaining += utils.TruncStr2(toBeQuoted, 70, "…") - return `“[` + quoted.CreatedAt.Format("15:04:05") + "]" + remaining + `”` -} - -// This function will get the raw user input message which is not safe to directly render. -// -// To prevent people from altering the text of the quote, -// we retrieve the original quoted message using the timestamp and username, -// and we use the original message text. -// -// eg: we received altered quote, and return original quote -> -// “[01:23:45] username - Some maliciously altered quote” Some text -// “[01:23:45] username - The original text” Some text -func convertQuote(db *database.DkfDB, origHtml string, roomKey string, roomID database.RoomID) (html string, quoted *database.ChatMessage) { - const quotePrefix = `“[` - const quoteSuffix = `”` - html = origHtml - idx := strings.Index(origHtml, quoteSuffix) - if strings.HasPrefix(origHtml, quotePrefix) && idx > -1 { - prefixLen := len(quotePrefix) - suffixLen := len(quoteSuffix) - if len(origHtml) > prefixLen+9 { - hourMinSec := origHtml[prefixLen : prefixLen+8] - username := database.Username(origHtml[prefixLen+10 : strings.Index(origHtml[prefixLen+10:], " ")+prefixLen+10]) - if quoted = getQuotedChatMessage(db, hourMinSec, username, roomID); quoted != nil { - html = getQuoteTxt(db, roomKey, *quoted) - html += origHtml[idx+suffixLen:] - } - } - } - return html, quoted -} - -func styleQuote(origHtml string, quoted *database.ChatMessage) (html string) { - const quoteSuffix = `”` - html = origHtml - if quoted != nil { - idx := strings.Index(origHtml, quoteSuffix) - prefixLen := len(`<p>“[`) - suffixLen := len(quoteSuffix) - dateLen := 8 // 01:23:45 --> 8 - - // <p>“[01:23:45] username - quoted text” user text</p> - date := origHtml[prefixLen : prefixLen+dateLen] // `01:23:45` - quoteTxt := origHtml[prefixLen+dateLen+1 : idx] // ` username - quoted text` - userTxt := origHtml[idx+suffixLen:] // ` user text</p>` - - sb := strings.Builder{} - sb.WriteString(`<p>“<small style="opacity: 0.8;"><i>[`) - - // Date link - sb.WriteString(`<a href="/chat/`) - sb.WriteString(quoted.Room.Name) - sb.WriteString(`/archive#`) - sb.WriteString(quoted.UUID) - sb.WriteString(`" target="_blank" rel="noopener noreferrer">`) - sb.WriteString(date) - sb.WriteString(`</a>`) - - sb.WriteString(`]<span `) - sb.WriteString(quoted.User.GenerateChatStyle1()) - sb.WriteString(`>`) - sb.WriteString(quoteTxt) - sb.WriteString(`</span></i></small>”`) - sb.WriteString(userTxt) - html = sb.String() - } - return html -} - -var noSchemeOnionLinkRgx = regexp.MustCompile(`\s[a-z2-7]{56}\.onion`) - -// Fix up onion links that are missing the http scheme. This often happen when copy/pasting a link. -func convertLinksWithoutScheme(in string) string { - html := noSchemeOnionLinkRgx.ReplaceAllStringFunc(in, func(s string) string { - return " http://" + strings.TrimSpace(s) - }) - return html -} - -var linkRgxStr = `(http|ftp|https):\/\/([\w\-_]+(?:(?:\.[\w\-_]+)+))([\w\-\.,@?^=%&amp;:/~\+#\(\)]*[\w\-\@?^=%&amp;/~\+#\(\)])?` -var profileRgxStr = `/u/\w{3,20}` -var linkShorthandRgxStr = `/l/\w{3,20}` -var dkfArchiveRgx = regexp.MustCompile(`/chat/([\w_]{3,50})/archive\?uuid=([\w-]{36})#[\w-]{36}`) -var linkOrProfileRgx = regexp.MustCompile(`(` + linkRgxStr + `|` + profileRgxStr + `|` + linkShorthandRgxStr + `)`) -var userProfileLinkRgx = regexp.MustCompile(`^` + profileRgxStr + `$`) -var linkShorthandPageLinkRgx = regexp.MustCompile(`^` + linkShorthandRgxStr + `$`) -var youtubeComIDRgx = regexp.MustCompile(`watch\?v=([\w-]+)`) -var youtubeComShortsIDRgx = regexp.MustCompile(`/shorts/([\w-]+)`) -var youtuBeIDRgx = regexp.MustCompile(`https://youtu\.be/([\w-]+)`) -var yewtubeBeIDRgx = youtubeComIDRgx -var invidiousIDRgx = youtubeComIDRgx - -func makeHtmlLink(label, link string) string { - // We replace @ to prevent colorifyTaggedUsers from trying to generate html inside the links. - r := strings.NewReplacer("@", "&#64;", "#", "&#35;") - label = r.Replace(label) - link = r.Replace(link) - return fmt.Sprintf(`<a href="%s" rel="noopener noreferrer" target="_blank">%s</a>`, link, label) -} - -func splitQuote(in string) (string, string) { - const quotePrefix = `<p>“[` - const quoteSuffix = `”` - idx := strings.Index(in, quoteSuffix) - if idx == -1 || !strings.HasPrefix(in, quotePrefix) { - return "", in - } - return in[:idx], in[idx:] -} - -func convertLinks(in string, - roomID database.RoomID, - getUserByUsername func(database.Username) (database.User, error), - getLinkByShorthand func(string) (database.Link, error), - getChatMessageByUUID func(string) (database.ChatMessage, error)) string { - quote, rest := splitQuote(in) - - libredditURLs := []string{ - "http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion", - "http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion", - "http://kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion", - "http://inytumdgnri7xsqtvpntjevaelxtgbjqkuqhtf6txxhwbll2fwqtakqd.onion", - "http://liredejj74h5xjqr2dylnl5howb2bpikfowqoveub55ru27x43357iid.onion", - "http://kzhfp3nvb4qp575vy23ccbrgfocezjtl5dx66uthgrhu7nscu6rcwjyd.onion", - "http://ecue64ybzvn6vjzl37kcsnwt4ycmbsyf74nbttyg7rkc3t3qwnj7mcyd.onion", - "http://ledditqo2mxfvlgobxnlhrkq4dh34jss6evfkdkb2thlvy6dn4f4gpyd.onion", - "http://ol5begilptoou34emq2sshf3may3hlblvipdjtybbovpb7c7zodxmtqd.onion", - "http://lbrdtjaj7567ptdd4rv74lv27qhxfkraabnyphgcvptl64ijx2tijwid.onion", - } - - invidiousURLs := []string{ - "http://c7hqkpkpemu6e7emz5b4vyz7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion", - "http://kbjggqkzv65ivcqj6bumvp337z6264huv5kpkwuv6gu5yjiskvan7fad.onion", - "http://grwp24hodrefzvjjuccrkw3mjq4tzhaaq32amf33dzpmuxe7ilepcmad.onion"} - - wikilessURLs := []string{ - "http://c2pesewpalbi6lbfc5hf53q4g3ovnxe4s7tfa6k2aqkf7jd7a7dlz5ad.onion", - "http://dj2tbh2nqfxyfmvq33cjmhuw7nb6am7thzd3zsjvizeqf374fixbrxyd.onion"} - - nitterURLs := []string{ - "http://nitraeju2mipeziu2wtcrqsxg7h62v5y4eqgwi75uprynkj74gevvuqd.onion"} - - rimgoURLs := []string{ - "http://be7udfhmnzqyt7cxysg6c4pbawarvaofjjywp35nhd5qamewdfxl6sid.onion"} - - knownOnions := [][]string{ - {"http://dkfgit.onion", config.DkfGitOnion}, - {"http://dread.onion", config.DreadOnion}, - {"http://cryptbb.onion", config.CryptbbOnion}, - {"http://blkhat.onion", config.BhcOnion}, - {"http://dnmx.onion", config.DnmxOnion}, - {"http://whonix.onion", config.WhonixOnion}, - {"http://chattor.onion", config.ChattorOnion}, - {"http://lulbins.onion", config.LulbinsOnion}, - } - - newRest := linkOrProfileRgx.ReplaceAllStringFunc(rest, func(link string) string { - // Convert all occurrences of "/u/username" to a link to user profile page if the user exists - if userProfileLinkRgx.MatchString(link) { - user, err := getUserByUsername(database.Username(strings.TrimPrefix(link, "/u/"))) - if err != nil { - return link - } - href := "/u/" + string(user.Username) - return makeHtmlLink(href, href) - } - - // Convert all occurrences of "/l/shorthand" to a link to link page if the shorthand exists - if linkShorthandPageLinkRgx.MatchString(link) { - l, err := getLinkByShorthand(strings.TrimPrefix(link, "/l/")) - if err != nil { - return link - } - href := "/l/" + *l.Shorthand - return makeHtmlLink(href, href) - } - - // Handle reddit links - if strings.HasPrefix(link, "https://www.reddit.com/") { - old := strings.Replace(link, "https://www.reddit.com/", "https://old.reddit.com/", 1) - libredditLink := utils.RandChoice(libredditURLs) - libredditLink = strings.Replace(link, "https://www.reddit.com", libredditLink, 1) - oldHtmlLink := makeHtmlLink("old", old) - libredditHtmlLink := makeHtmlLink("libredditLink", libredditLink) - htmlLink := makeHtmlLink(link, link) - return htmlLink + ` (` + oldHtmlLink + ` | ` + libredditHtmlLink + `)` - } else if strings.HasPrefix(link, "https://old.reddit.com/") { - libredditLink := utils.RandChoice(libredditURLs) - libredditLink = strings.Replace(link, "https://old.reddit.com", libredditLink, 1) - libredditHtmlLink := makeHtmlLink("libredditLink", libredditLink) - htmlLink := makeHtmlLink(link, link) - return htmlLink + ` (` + libredditHtmlLink + `)` - } - for _, libredditURL := range libredditURLs { - if strings.HasPrefix(link, libredditURL) { - newPrefix := strings.Replace(link, libredditURL, "http://reddit.onion", 1) - old := strings.Replace(link, libredditURL, "https://old.reddit.com", 1) - oldHtmlLink := makeHtmlLink("old", old) - htmlLink := makeHtmlLink(newPrefix, link) - return htmlLink + ` (` + oldHtmlLink + `)` - } - } - - // Append YouTube link to invidious link - for _, invidiousURL := range invidiousURLs { - if strings.HasPrefix(link, invidiousURL) { - if strings.Contains(link, ".onion/watch?v=") { - newPrefix := strings.Replace(link, invidiousURL, "http://invidious.onion", 1) - m := invidiousIDRgx.FindStringSubmatch(link) - if len(m) == 2 { - videoID := m[1] - youtubeLink := "https://www.youtube.com/watch?v=" + videoID - youtubeHtmlLink := makeHtmlLink("Youtube", youtubeLink) - htmlLink := makeHtmlLink(newPrefix, link) - return htmlLink + ` (` + youtubeHtmlLink + `)` - } - } - } - } - // Unknown invidious links - if strings.Contains(link, ".onion/watch?v=") { - m := invidiousIDRgx.FindStringSubmatch(link) - if len(m) == 2 { - videoID := m[1] - youtubeLink := "https://www.youtube.com/watch?v=" + videoID - youtubeHtmlLink := makeHtmlLink("Youtube", youtubeLink) - htmlLink := makeHtmlLink(link, link) - return htmlLink + ` (` + youtubeHtmlLink + `)` - } - } - - // Append wikiless link to wikipedia link - if strings.HasPrefix(link, "https://en.wikipedia.org/") { - wikilessLink := utils.RandChoice(wikilessURLs) - wikilessLink = strings.Replace(link, "https://en.wikipedia.org", wikilessLink, 1) - wikilessHtmlLink := makeHtmlLink("Wikiless", wikilessLink) - htmlLink := makeHtmlLink(link, link) - return htmlLink + ` (` + wikilessHtmlLink + `)` - } - for _, wikilessURL := range wikilessURLs { - if strings.HasPrefix(link, wikilessURL) { - newPrefix := strings.Replace(link, wikilessURL, "http://wikiless.onion", 1) - wikipediaPrefix := strings.Replace(link, wikilessURL, "https://en.wikipedia.org", 1) - wikipediaHtmlLink := makeHtmlLink("Wikipedia", wikipediaPrefix) - htmlLink := makeHtmlLink(newPrefix, link) - return htmlLink + ` (` + wikipediaHtmlLink + `)` - } - } - - // Append nitter link to twitter link - if strings.HasPrefix(link, "https://twitter.com/") { - nitterLink := utils.RandChoice(nitterURLs) - nitterLink = strings.Replace(link, "https://twitter.com", nitterLink, 1) - nitterHtmlLink := makeHtmlLink("Nitter", nitterLink) - htmlLink := makeHtmlLink(link, link) - return htmlLink + ` (` + nitterHtmlLink + `)` - } - for _, nitterURL := range nitterURLs { - if strings.HasPrefix(link, nitterURL) { - newPrefix := strings.Replace(link, nitterURL, "http://nitter.onion", 1) - twitterPrefix := strings.Replace(link, nitterURL, "https://twitter.com", 1) - twitterHtmlLink := makeHtmlLink("Twitter", twitterPrefix) - htmlLink := makeHtmlLink(newPrefix, link) - return htmlLink + ` (` + twitterHtmlLink + `)` - } - } - - // Append rimgo link to imgur link - if strings.HasPrefix(link, "https://imgur.com/") { - rimgoLink := utils.RandChoice(rimgoURLs) - rimgoLink = strings.Replace(link, "https://imgur.com", rimgoLink, 1) - rimgoHtmlLink := makeHtmlLink("Rimgo", rimgoLink) - htmlLink := makeHtmlLink(link, link) - return htmlLink + ` (` + rimgoHtmlLink + `)` - } - for _, rimgoURL := range rimgoURLs { - if strings.HasPrefix(link, rimgoURL) { - newPrefix := strings.Replace(link, rimgoURL, "http://rimgo.onion", 1) - imgurPrefix := strings.Replace(link, rimgoURL, "https://imgur.com", 1) - imgurHtmlLink := makeHtmlLink("Imgur", imgurPrefix) - htmlLink := makeHtmlLink(newPrefix, link) - return htmlLink + ` (` + imgurHtmlLink + `)` - } - } - - // Append invidious link to YouTube/yewtube link - var videoID string - var m []string - var isShortUrl, isYewtube bool - if strings.HasPrefix(link, "https://youtu.be/") { - m = youtuBeIDRgx.FindStringSubmatch(link) - } else if strings.HasPrefix(link, "https://www.youtube.com/watch?v=") { - m = youtubeComIDRgx.FindStringSubmatch(link) - } else if strings.HasPrefix(link, "https://yewtu.be/") || strings.HasPrefix(link, "https://www.yewtu.be/") { - m = yewtubeBeIDRgx.FindStringSubmatch(link) - isYewtube = true - } else if strings.HasPrefix(link, "https://www.youtube.com/shorts/") { - m = youtubeComShortsIDRgx.FindStringSubmatch(link) - isShortUrl = true - } - if len(m) == 2 { - videoID = m[1] - } - if videoID != "" { - invidiousLink := utils.RandChoice(invidiousURLs) + "/watch?v=" + videoID + "&local=true" - invidiousHtmlLink := makeHtmlLink("Invidious", invidiousLink) - htmlLink := makeHtmlLink(link, link) - youtubeLink := "https://www.youtube.com/watch?v=" + videoID - youtubeHtmlLink := makeHtmlLink("YT", youtubeLink) - out := htmlLink + ` (` + invidiousHtmlLink + `)` - if isShortUrl || isYewtube { - out = htmlLink + ` (` + youtubeHtmlLink + ` | ` + invidiousHtmlLink + `)` - } - return out - } - - // Special case for dkf links. - { - dkfLocalPrefix := "http://127.0.0.1:8080" - dkfShortPrefix := "http://dkf.onion" - dkfLongPrefix := config.DkfOnion - hasLocalPrefix := strings.HasPrefix(link, dkfLocalPrefix) - hasDkfShortPrefix := strings.HasPrefix(link, dkfShortPrefix) - hasDkfLongPrefix := strings.HasPrefix(link, dkfLongPrefix) - if hasLocalPrefix || hasDkfLongPrefix || hasDkfShortPrefix { - var trimmed string - if hasLocalPrefix { - trimmed = strings.TrimPrefix(link, dkfLocalPrefix) - } else if hasDkfLongPrefix { - trimmed = strings.TrimPrefix(link, dkfLongPrefix) - } else if hasDkfShortPrefix { - trimmed = strings.TrimPrefix(link, dkfShortPrefix) - } - label := dkfShortPrefix + trimmed - href := trimmed - // Shorten archive links - if m := dkfArchiveRgx.FindStringSubmatch(label); len(m) == 3 { - if msg, err := getChatMessageByUUID(m[2]); err == nil { - if roomID == msg.RoomID { - label = msg.CreatedAt.Format("[Jan 02 03:04:05]") - } else { - label = msg.CreatedAt.Format("[#" + m[1] + " Jan 02 03:04:05]") - } - } - } - // Allows to have messages such as: "my profile is /u/username :)" - if userProfileLinkRgx.MatchString(trimmed) { - if user, err := getUserByUsername(database.Username(strings.TrimPrefix(trimmed, "/u/"))); err == nil { - label = "/u/" + string(user.Username) - href = "/u/" + string(user.Username) - } - } else if linkShorthandPageLinkRgx.MatchString(trimmed) { - // Convert all occurrences of "/l/shorthand" to a link to link page if the shorthand exists - if l, err := getLinkByShorthand(strings.TrimPrefix(trimmed, "/l/")); err == nil { - label = "/l/" + *l.Shorthand - href = "/l/" + *l.Shorthand - } - } - return makeHtmlLink(label, href) - } - } - - for _, el := range knownOnions { - shortPrefix := el[0] - longPrefix := el[1] - if strings.HasPrefix(link, longPrefix) { - return makeHtmlLink(shortPrefix+strings.TrimPrefix(link, longPrefix), link) - } else if strings.HasPrefix(link, shortPrefix) { - return makeHtmlLink(link, longPrefix+strings.TrimPrefix(link, shortPrefix)) - } - } - return makeHtmlLink(link, link) - }) - - return quote + newRest -} - -func convertNewLines(html string, canUseMultiline bool) string { - if !canUseMultiline { - html = strings.ReplaceAll(html, "\n", "") - } - return html -} - -func extractPGPMessage(html string) (out string) { - pgpPrefixL := pgpPrefix - pgpSuffixL := pgpSuffix - startIdx := strings.Index(html, pgpPrefixL) - endIdx := strings.Index(html, pgpSuffixL) - if startIdx != -1 && endIdx != -1 { - out = html[startIdx : endIdx+len(pgpSuffixL)] - out = strings.TrimSpace(out) - out = strings.TrimPrefix(out, pgpPrefixL) - out = strings.TrimSuffix(out, pgpSuffixL) - out = strings.Join(strings.Split(out, " "), "\n") - out = pgpPrefixL + out - out += pgpSuffixL - } - return out -} - -// Auto convert pasted pgp public key into uploaded file -func convertPGPPublicKeyToFile(db *database.DkfDB, html string, authUserID database.UserID) string { - pgpPKeyPrefixL := pgpPKeyPrefix - pgpPKeySuffixL := pgpPKeySuffix - startIdx := strings.Index(html, pgpPKeyPrefixL) - endIdx := strings.Index(html, pgpPKeySuffixL) - if startIdx != -1 && endIdx != -1 { - pkeySubSlice := html[startIdx : endIdx+len(pgpPKeySuffixL)] - unescapedPkey := html2.UnescapeString(pkeySubSlice) - tmp := convertInlinePGPPublicKey(unescapedPkey) - upload, _ := db.CreateUpload("pgp_pkey.txt", []byte(tmp), authUserID) - msgBefore := html[0:startIdx] - msgAfter := html[endIdx+len(pgpPKeySuffixL):] - html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter - html = strings.TrimSpace(html) - } - return html -} - -func convertPGPClearsignToFile(db *database.DkfDB, html string, authUserID database.UserID) string { - if b, _ := clearsign.Decode([]byte(html)); b != nil { - pgpSignedPrefixL := pgpSignedPrefix - pgpSignedSuffixL := pgpSignedSuffix - startIdx := strings.Index(html, pgpSignedPrefixL) - endIdx := strings.Index(html, pgpSignedSuffixL) - tmp := html[startIdx : endIdx+len(pgpSignedSuffixL)] - upload, _ := db.CreateUpload("pgp_clearsign.txt", []byte(tmp), authUserID) - msgBefore := html[0:startIdx] - msgAfter := html[endIdx+len(pgpSignedSuffixL):] - html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter - html = strings.TrimSpace(html) - } - return html -} - -// Auto convert pasted pgp message into uploaded file -func convertPGPMessageToFile(db *database.DkfDB, html string, authUserID database.UserID) string { - pgpPrefixL := pgpPrefix - pgpSuffixL := pgpSuffix - startIdx := strings.Index(html, pgpPrefixL) - endIdx := strings.Index(html, pgpSuffixL) - if startIdx != -1 && endIdx != -1 { - tmp := html[startIdx : endIdx+len(pgpSuffixL)] - tmp = strings.TrimSpace(tmp) - tmp = strings.TrimPrefix(tmp, pgpPrefixL) - tmp = strings.TrimSuffix(tmp, pgpSuffixL) - tmp = strings.Join(strings.Split(tmp, " "), "\n") - tmp = pgpPrefixL + tmp - tmp += pgpSuffixL - upload, _ := db.CreateUpload("pgp.txt", []byte(tmp), authUserID) - msgBefore := html[0:startIdx] - msgAfter := html[endIdx+len(pgpSuffixL):] - html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter - html = strings.TrimSpace(html) - } - return html -} - -// Auto convert pasted age message into uploaded file -func convertAgeMessageToFile(db *database.DkfDB, html string, authUserID database.UserID) string { - agePrefixL := agePrefix - ageSuffixL := ageSuffix - startIdx := strings.Index(html, agePrefixL) - endIdx := strings.Index(html, ageSuffixL) - if startIdx != -1 && endIdx != -1 { - tmp := html[startIdx : endIdx+len(ageSuffixL)] - tmp = strings.TrimSpace(tmp) - tmp = strings.TrimPrefix(tmp, agePrefixL) - tmp = strings.TrimSuffix(tmp, ageSuffixL) - tmp = strings.Join(strings.Split(tmp, " "), "\n") - tmp = agePrefixL + tmp - tmp += ageSuffixL - upload, _ := db.CreateUpload("age.txt", []byte(tmp), authUserID) - msgBefore := html[0:startIdx] - msgAfter := html[endIdx+len(ageSuffixL):] - html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter - html = strings.TrimSpace(html) - } - return html -} - -func convertInlinePGPPublicKey(inlinePKey string) string { - pgpPKeyPrefixL := pgpPKeyPrefix - pgpPKeySuffixL := pgpPKeySuffix - // If it contains new lines, it was probably pasted using multi-line text box - if strings.Contains(inlinePKey, "\n") { - return inlinePKey - } - inlinePKey = strings.TrimSpace(inlinePKey) - inlinePKey = strings.TrimPrefix(inlinePKey, pgpPKeyPrefixL) - inlinePKey = strings.TrimSuffix(inlinePKey, pgpPKeySuffixL) - inlinePKey = strings.TrimSpace(inlinePKey) - commentsParts := strings.Split(inlinePKey, "Comment: ") - commentsParts, lastCommentPart := commentsParts[:len(commentsParts)-1], commentsParts[len(commentsParts)-1] - newCommentsParts := make([]string, 0) - for idx := range commentsParts { - if commentsParts[idx] != "" { - commentsParts[idx] = "Comment: " + commentsParts[idx] - commentsParts[idx] = strings.TrimSpace(commentsParts[idx]) - newCommentsParts = append(newCommentsParts, commentsParts[idx]) - } - } - - rgx := regexp.MustCompile(`\s\s(\w|\+|/){64}`) - m := rgx.FindStringIndex(lastCommentPart) - commentsStr := "" - key := "" - if len(m) == 2 { - idx := m[0] - lastCommentP1 := lastCommentPart[:idx] - lastCommentP2 := lastCommentPart[idx+2:] - key = strings.Join(strings.Split(lastCommentP2, " "), "\n") - commentsStr = strings.Join(newCommentsParts, "\n") - commentsStr += "\nComment: " + lastCommentP1 + "\n\n" - } else { - key = "\n" + strings.Join(strings.Split(lastCommentPart, " "), "\n") - } - inlinePKey = pgpPKeyPrefixL + "\n" + commentsStr + key + "\n" + pgpPKeySuffixL - return inlinePKey -} diff --git a/pkg/web/handlers/api/v1/uploadInterceptor.go b/pkg/web/handlers/api/v1/uploadInterceptor.go @@ -1,77 +0,0 @@ -package v1 - -import ( - "dkforest/pkg/config" - "dkforest/pkg/database" - "dkforest/pkg/utils" - hutils "dkforest/pkg/web/handlers/utils" - "errors" - "fmt" - "github.com/asaskevich/govalidator" - "github.com/dustin/go-humanize" - "github.com/sirupsen/logrus" - "io/ioutil" - "mime/multipart" -) - -type UploadInterceptor struct{} - -func (i UploadInterceptor) InterceptMsg(cmd *Command) { - if file, handler, uploadErr := cmd.c.Request().FormFile("file"); uploadErr == nil { - // Save file on disk & database & append file link to html - var err error - cmd.upload, err = handleUploadedFile(cmd.db, file, handler, cmd.authUser) - if err != nil { - cmd.err = err - return - } - } -} - -func handleUploadedFile(db *database.DkfDB, file multipart.File, handler *multipart.FileHeader, authUser *database.User) (*database.Upload, error) { - defer file.Close() - if !authUser.CanUpload() { - return nil, hutils.AccountTooYoungErr - } - userSizeUploaded := db.GetUserTotalUploadSize(authUser.ID) - if handler.Size+userSizeUploaded > config.MaxUserTotalUploadSize { - return nil, fmt.Errorf("user upload limit reached (%s)", humanize.Bytes(config.MaxUserTotalUploadSize)) - } - origFileName := handler.Filename - if handler.Size > config.MaxUserFileUploadSize { - return nil, fmt.Errorf("the maximum file size is %s", humanize.Bytes(config.MaxUserFileUploadSize)) - } - if !govalidator.StringLength(origFileName, "3", "50") { - return nil, errors.New("invalid file name, 3-50 characters") - } - if !govalidator.IsPrintableASCII(origFileName) { - return nil, errors.New("file name must be ascii printable only") - } - origFileName = tzRgx.ReplaceAllString(origFileName, "xxxx-xx-xx at xx.xx.xx XX") - origFileName = tz1Rgx.ReplaceAllString(origFileName, "xxxx-xx-xx xx-xx-xx") - origFileName = tz3Rgx.ReplaceAllString(origFileName, "xxxx-xx-xx xxxxxx") - origFileName = tz4Rgx.ReplaceAllString(origFileName, "xxxx-xx-xx_xx_xx_xx") - fileBytes, err := ioutil.ReadAll(file) - if err != nil { - return nil, err - } - - // Validate image type and determine extension - mimeType := handler.Header.Get("Content-Type") - if mimeType == "image/jpeg" { - fileBytes, err = utils.ReencodeJpg(fileBytes) - } else if mimeType == "image/png" { - fileBytes, err = utils.ReencodePng(fileBytes) - } - if err != nil { - return nil, err - } - - // Uploaded files are encrypted on disk - upload, err := db.CreateEncryptedUploadWithSize(origFileName, fileBytes, authUser.ID, handler.Size) - if err != nil { - logrus.Error(err) - return nil, err - } - return upload, nil -} diff --git a/pkg/web/handlers/api/v1/werewolf.go b/pkg/web/handlers/api/v1/werewolf.go @@ -1,701 +0,0 @@ -package v1 - -import ( - "bytes" - "context" - "dkforest/pkg/config" - "dkforest/pkg/database" - "dkforest/pkg/hashset" - "dkforest/pkg/utils" - "errors" - "fmt" - "github.com/sirupsen/logrus" - "html/template" - "math/rand" - "sort" - "strings" - "time" -) - -var WWInstance *Werewolf - -const ( - PreGameState = iota + 1 - DayState - NightState - VoteState - EndGameState -) - -const ( - TownspeopleRole = "townspeople" - WerewolfRole = "werewolf" - SeerRole = "seer" - HealerRole = "healer" -) - -var ErrInvalidPlayerName = errors.New("unknown player name, please send a valid name") - -type Werewolf struct { - db *database.DkfDB - ctx context.Context - cancel context.CancelFunc - readyCh chan bool - narratorID database.UserID - roomID database.RoomID - werewolfGroupID database.GroupID - spectatorGroupID database.GroupID - deadGroupID database.GroupID - players map[database.Username]*Player - playersAlive map[database.Username]*Player - state int64 - werewolfSet *hashset.HashSet[database.UserID] - spectatorSet *hashset.HashSet[database.UserID] - townspersonSet *hashset.HashSet[database.UserID] - healerID *database.UserID - seerID *database.UserID - werewolfCh chan string - seerCh chan string - healerCh chan string - votesCh chan string - voted *hashset.HashSet[database.UserID] // Keep track of which user voted already -} - -// Return either or not the userID is an active player (alive) -func (b *Werewolf) isAlivePlayer(userID database.UserID) bool { - for _, player := range b.playersAlive { - if player.UserID == userID { - return true - } - } - return false -} - -func (b *Werewolf) InterceptPreGameMsg(cmd *Command) { - if cmd.message == "/players" { - b.Narrate("Registered players: "+b.alivePlayersStr(), nil, nil) - cmd.err = ErrRedirect - return - - } else if cmd.message == "/join" { - if cmd.authUser.IsHellbanned { - cmd.err = ErrRedirect - return - } - if _, found := b.players[cmd.authUser.Username]; found { - cmd.err = ErrRedirect - return - } - player := &Player{ - UserID: cmd.authUser.ID, - Username: cmd.authUser.Username, - } - b.players[cmd.authUser.Username] = player - b.playersAlive[cmd.authUser.Username] = player - b.Narrate(cmd.authUser.Username.AtStr()+" joined the Game", nil, nil) - cmd.err = ErrRedirect - return - - } else if cmd.message == "/spectate" { - b.spectatorSet.Insert(cmd.authUser.ID) - b.Narrate(cmd.authUser.Username.AtStr()+" spectate the Game", nil, nil) - cmd.err = ErrRedirect - return - - } else if cmd.message == "/start" { - b.cancel() - time.Sleep(time.Second) - utils.SGo(func() { - b.StartGame(cmd.db) - }) - cmd.err = ErrRedirect - return - } -} - -func (b *Werewolf) InterceptNightMsg(cmd *Command) { - if cmd.groupID != nil && *cmd.groupID == b.werewolfGroupID { - select { - case b.werewolfCh <- cmd.message: - cmd.err = ErrRedirect - default: - cmd.err = errors.New("narrator doesn't need your input") - } - return - } else if b.isForNarrator(cmd) && b.seerID != nil && cmd.authUser.ID == *b.seerID { - select { - case b.seerCh <- cmd.message: - cmd.err = ErrRedirect - default: - cmd.err = errors.New("narrator doesn't need your input") - } - return - } else if b.isForNarrator(cmd) && b.healerID != nil && cmd.authUser.ID == *b.healerID { - select { - case b.healerCh <- cmd.message: - cmd.err = ErrRedirect - default: - cmd.err = errors.New("narrator doesn't need your input") - } - return - } - cmd.err = errors.New("chat disabled") - return -} - -// Return either or not the message is a PM for the narrator -func (b *Werewolf) isForNarrator(cmd *Command) bool { - return cmd.toUser != nil && cmd.toUser.ID == b.narratorID -} - -func (b *Werewolf) InterceptVoteMsg(cmd *Command) { - if !b.isAlivePlayer(cmd.authUser.ID) || !b.isForNarrator(cmd) { - cmd.err = errors.New("chat disabled") - return - } - if b.isForNarrator(cmd) { - if !b.voted.Contains(cmd.authUser.ID) { - name := cmd.message - if b.isValidPlayerName(name) { - b.votesCh <- name - } else { - b.Narrate(ErrInvalidPlayerName.Error(), &cmd.authUser.ID, nil) - } - } else { - b.Narrate("You have already voted", &cmd.authUser.ID, nil) - } - } -} - -var tuto = `Tutorial: -"/join" to join the Game -"/players" list the players that have joined the Game -"/start" to start the Game -"/stop" to stop the Game -"/ready" will skip the 5min conversation -"/tuto" will display this tutorial -"/clear" will reset the room and display this tutorial - -Werewolf: To kill someone during the night, you have to reply in the "werewolf" group with the name of the person to kill (no @) -Seer/Healer: You have reply to the narrator with the name (eg: "/pm 0 n0tr1v") -Townspeople: To vote, you have to pm the narrator with a name (eg: "/pm 0 n0tr1v")` - -func (b *Werewolf) InterceptMsg(cmd *Command) { - if cmd.room.ID != b.roomID { - return - } - - SlashInterceptor{}.InterceptMsg(cmd) - - // If the message is a PM not for the narrator, we reject it - if cmd.toUser != nil && (cmd.toUser.ID != b.narratorID && cmd.authUser.ID != b.narratorID) { - cmd.err = errors.New("PM not allowed at this room") - return - } - - // Spectator can chat all the time - if cmd.groupID != nil && *cmd.groupID == b.spectatorGroupID { - return - } - - if cmd.authUser.IsModerator() && cmd.message == "/stop" { - b.Narrate(fmt.Sprintf("@%s used /stop", cmd.authUser.Username), nil, nil) - b.cancel() - cmd.err = ErrRedirect - return - } else if cmd.authUser.IsModerator() && cmd.message == "/ready" { - b.Narrate(fmt.Sprintf("@%s used /ready", cmd.authUser.Username), nil, nil) - b.readyCh <- true - cmd.err = ErrRedirect - return - } else if cmd.authUser.IsModerator() && cmd.message == "/tuto" { - b.Narrate(tuto, nil, nil) - cmd.err = ErrRedirect - return - } else if cmd.authUser.IsModerator() && cmd.message == "/clear" { - _ = cmd.db.DeleteChatRoomMessages(b.roomID) - b.Narrate(tuto, nil, nil) - cmd.err = ErrRedirect - return - } - - // Anyone can talk during these states - if b.state == PreGameState || b.state == EndGameState { - if b.state == PreGameState { - b.InterceptPreGameMsg(cmd) - } - return - } - - // Otherwise, non-playing people cannot talk in public chat - if !b.isAlivePlayer(cmd.authUser.ID) { - cmd.err = errors.New("public chat disabled") - return - } - - switch b.state { - case DayState: - case VoteState: - b.InterceptVoteMsg(cmd) - case NightState: - b.InterceptNightMsg(cmd) - default: - cmd.err = errors.New("public chat disabled") - return - } -} - -// Wait until we receive the votes from all the players -func (b *Werewolf) waitVotes() (votes []string) { - for len(votes) < len(b.playersAlive) { - var vote string - select { - case vote = <-b.votesCh: - case <-time.After(15 * time.Second): - b.Narrate(fmt.Sprintf("Waiting votes %d/%d", len(votes), len(b.playersAlive)), nil, nil) - continue - case <-b.ctx.Done(): - return - } - votes = append(votes, vote) - } - return -} - -func (b *Werewolf) waitNameFromWerewolf() (name string) { - for { - select { - case name = <-b.werewolfCh: - case <-time.After(15 * time.Second): - b.Narrate("Waiting reply from werewolf", nil, nil) - continue - case <-b.ctx.Done(): - return - } - if b.isValidPlayerName(name) { - break - } - b.Narrate(ErrInvalidPlayerName.Error(), nil, &b.werewolfGroupID) - } - return name -} - -func (b *Werewolf) waitNameFromSeer() (name string) { - for { - select { - case name = <-b.seerCh: - case <-time.After(15 * time.Second): - b.Narrate("Waiting reply from seer", nil, nil) - continue - case <-b.ctx.Done(): - return - } - if b.isValidPlayerName(name) { - break - } - b.Narrate(ErrInvalidPlayerName.Error(), b.seerID, nil) - } - return name -} - -func (b *Werewolf) waitNameFromHealer() (name string) { - for { - select { - case name = <-b.healerCh: - case <-time.After(15 * time.Second): - b.Narrate("Waiting reply from healer", nil, nil) - continue - case <-b.ctx.Done(): - return - } - if b.isValidPlayerName(name) { - break - } - b.Narrate(ErrInvalidPlayerName.Error(), b.healerID, nil) - } - return name -} - -// Return either a name is a valid alive player name or not -func (b *Werewolf) isValidPlayerName(name string) bool { - name = strings.TrimSpace(name) - for _, player := range b.playersAlive { - if string(player.Username) == name { - return true - } - } - return false -} - -// Narrate register a chat message on behalf of the narrator user -func (b *Werewolf) Narrate(msg string, toUserID *database.UserID, groupID *database.GroupID) { - html, _, _ := ProcessRawMessage(b.db, msg, "", b.narratorID, b.roomID, nil, true) - b.NarrateRaw(html, toUserID, groupID) -} - -func (b *Werewolf) NarrateRaw(msg string, toUserID *database.UserID, groupID *database.GroupID) { - _, _ = b.db.CreateOrEditMessage(nil, msg, msg, "", b.roomID, b.narratorID, toUserID, nil, groupID, false, false, false) -} - -// Display roles assigned at beginning of the Game -func (b *Werewolf) displayRoles() { - msg := "Roles were:\n" - for _, player := range b.players { - msg += player.Username.AtStr() + " : " + player.Role + "\n" - } - b.Narrate(msg, nil, nil) -} - -func (b *Werewolf) StartGame(db *database.DkfDB) { - defer func() { - b.displayRoles() - b.reset() - }() - b.ctx, b.cancel = context.WithCancel(context.Background()) - // Assign roles - playersArr := make([]*Player, 0) - for _, player := range b.playersAlive { - playersArr = append(playersArr, player) - } - rand.Shuffle(len(playersArr), func(i, j int) { playersArr[i], playersArr[j] = playersArr[j], playersArr[i] }) - for idx, player := range playersArr { - if idx == 0 { - b.werewolfSet.Insert(player.UserID) - _, _ = db.AddUserToRoomGroup(b.roomID, b.werewolfGroupID, player.UserID) - player.Role = WerewolfRole - werewolfMsg := "During the day you seem to be a regular Townsperson.\n" + - "However, you’ve been kissed by the Night and transform into a Werewolf when the sun sets.\n" + - "Your new nature compels you to kill and eat a Townsperson every night." - b.Narrate(werewolfMsg, &player.UserID, nil) - } else if idx == 1 { - b.townspersonSet.Insert(player.UserID) - b.healerID = &player.UserID - player.Role = HealerRole - healerMsg := "You’re a Townsperson with the unique ability to save lives.\n" + - "During the night, you’ll get a chance to protect another Townsperson from death if they are attacked by the Werewolves.\n" + - "You can choose to protect yourself." - b.Narrate(healerMsg, &player.UserID, nil) - } else if idx == 2 { - b.townspersonSet.Insert(player.UserID) - b.seerID = &player.UserID - player.Role = SeerRole - seerMsg := "You’re a Townsperson with the unique ability to peer into a person’s soul and see their true nature.\n" + - "During the night, you’ll get a chance to see if another Townsperson is a Werewolf.\n" + - "However, use this information wisely because it can lead to you being targeted by the Werewolves the next night if they deduce your identity." - b.Narrate(seerMsg, &player.UserID, nil) - } else { - b.townspersonSet.Insert(player.UserID) - player.Role = TownspeopleRole - townspersonMsg := "You’re a regular member of the town.\n" + - "Perhaps you’re a baker, merchant, or soldier.\n" + - "Your job is to save the town by eliminating the Werewolves that have infiltrated your town and started feeding on your neighbors.\n" + - "Also, try to avoid getting killed yourself." - b.Narrate(townspersonMsg, &player.UserID, nil) - } - } - b.state = DayState - b.Narrate("Players: "+b.alivePlayersStr(), nil, nil) - b.Narrate("Day 1: It is day time. Players can now introduce themselves. (5min)", nil, nil) - - select { - case <-time.After(5 * time.Minute): - case <-b.readyCh: - case <-b.ctx.Done(): - b.Narrate("STOP SIGNAL - Game is being stopped", nil, nil) - return - } - - for { - b.state = NightState - b.Narrate("Townspeople, go to sleep", nil, nil) - playerNameToKill := b.processWerewolf() - b.processSeer() - playerNameToSave := b.processHealer() - - b.state = DayState - b.Narrate("Townspeople, wake up", nil, nil) - if playerNameToKill == playerNameToSave { - b.Narrate("Someone was attacked last night, but they survived", nil, nil) - } else { - b.Narrate("Everyone wakes up to see a trail of blood leading to the forest.\n"+ - "There you find @"+playerNameToKill+"’s mangled remains by the Great Oak.\n"+ - "Curiously, there are deep claw marks in the bark of the surrounding trees.\n"+ - "It looks like @"+playerNameToKill+" put up a fight.", nil, nil) - b.kill(db, database.Username(playerNameToKill)) - } - - b.Narrate("Players still alive: "+b.alivePlayersStr(), nil, nil) - if b.werewolfSet.Len() == 0 { - b.Narrate("Townspeople win", nil, nil) - break - } else if b.townspersonSet.Len() <= 1 { - b.Narrate("Werewolf win", nil, nil) - break - } - - b.Narrate("Townspeople now have 5min to discuss the events", nil, nil) - - select { - case <-time.After(5 * time.Minute): - case <-b.readyCh: - case <-b.ctx.Done(): - b.Narrate("STOP SIGNAL - Game is being stopped", nil, nil) - return - } - - b.state = VoteState - b.voted = hashset.New[database.UserID]() - b.Narrate("It's now time to vote for execution. PM me the name you vote to execute or \"none\"", nil, nil) - killName := b.killVote() - if killName == "" { - b.Narrate("Townspeople do not want to execute anyone", nil, nil) - } else { - b.Narrate("Townspeople execute @"+killName, nil, nil) - b.kill(db, database.Username(killName)) - } - - b.Narrate("Players still alive: "+b.alivePlayersStr(), nil, nil) - - if b.werewolfSet.Len() == 0 { - b.Narrate("Townspeople win", nil, nil) - break - } else if b.townspersonSet.Len() == 1 { - b.Narrate("Werewolf win", nil, nil) - break - } - } - b.state = EndGameState - b.Narrate("Game ended", nil, nil) -} - -// Return the names of alive players. ie: "user1, user2, user3" -func (b *Werewolf) alivePlayersStr() (out string) { - arr := make([]string, 0) - for _, player := range b.playersAlive { - arr = append(arr, player.Username.AtStr()) - } - sort.Slice(arr, func(i, j int) bool { return arr[i] < arr[j] }) - return strings.Join(arr, ", ") -} - -// Kill a player -func (b *Werewolf) kill(db *database.DkfDB, playerName database.Username) { - player, found := b.playersAlive[playerName] - if !found { - return - } - delete(b.playersAlive, playerName) - switch player.Role { - case WerewolfRole: - b.werewolfSet.Remove(player.UserID) - _ = db.RmUserFromRoomGroup(b.roomID, b.werewolfGroupID, player.UserID) - case TownspeopleRole: - b.townspersonSet.Remove(player.UserID) - case HealerRole: - b.townspersonSet.Remove(player.UserID) - b.healerID = nil - case SeerRole: - b.townspersonSet.Remove(player.UserID) - b.seerID = nil - } - _, _ = db.AddUserToRoomGroup(b.roomID, b.deadGroupID, player.UserID) -} - -// Return the name of the player name that receive the most vote -func (b *Werewolf) killVote() string { - - // Send a PM to all players saying they have to vote for a name - for _, player := range b.playersAlive { - msg := "Who do you vote to kill? (name | none)" - msg += b.createKillVoteForm() - b.NarrateRaw(msg, &player.UserID, nil) - } - - votes := b.waitVotes() - // Get the max voted name - maxName := "none" - maxCount := 0 - voteMap := make(map[string]int) // keep track of how many votes for each values - for _, vote := range votes { - tmp := voteMap[vote] - tmp++ - voteMap[vote] = tmp - if tmp > maxCount { - maxCount = tmp - maxName = vote - } - } - if maxName == "none" { - return "" - } - return maxName -} - -func (b *Werewolf) getAlivePlayersArr(includeWerewolves bool) []database.Username { - arr := make([]database.Username, 0) - for _, player := range b.playersAlive { - if !includeWerewolves && b.werewolfSet.Contains(player.UserID) { - continue - } - arr = append(arr, player.Username) - } - sort.Slice(arr, func(i, j int) bool { return arr[i] < arr[j] }) - return arr -} - -func (b *Werewolf) createPickUserForm() string { - arr := b.getAlivePlayersArr(true) - - htmlTmpl := ` -<form method="post" action="/api/v1/werewolf"> - {{ range $idx, $p := .Arr }} - <input type="radio" ID="player{{ $idx }}" name="message" value="/pm 0 {{ $p }}" /><label for="player{{ $idx }}">{{ $p }}</label><br /> - {{ end }} - <button type="submit" name="btn_submit">ok</button> -</form>` - data := map[string]any{ - "Arr": arr, - } - var buf bytes.Buffer - _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) - return buf.String() -} - -func (b *Werewolf) createKillVoteForm() string { - arr := b.getAlivePlayersArr(true) - - htmlTmpl := ` -<form method="post" action="/api/v1/werewolf"> - {{ range $idx, $p := .Arr }} - <input type="radio" ID="player{{ $idx }}" name="message" value="/pm 0 {{ $p }}" /><label for="player{{ $idx }}">{{ $p }}</label><br /> - {{ end }} - <input type="radio" ID="none" name="message" value="/pm 0 none" /><label for="none">none</label><br /> - <button type="submit" name="btn_submit">ok</button> -</form>` - data := map[string]any{ - "Arr": arr, - } - var buf bytes.Buffer - _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) - return buf.String() -} - -func (b *Werewolf) createWerewolfPickUserForm() string { - arr := b.getAlivePlayersArr(false) - - htmlTmpl := ` -<form method="post" action="/api/v1/werewolf"> - {{ range $idx, $p := .Arr }} - <input type="radio" ID="player{{ $idx }}" name="message" value="/g werewolf {{ $p }}" /><label for="player{{ $idx }}">{{ $p }}</label><br /> - {{ end }} - <button type="submit" name="btn_submit">ok</button> -</form>` - data := map[string]any{ - "Arr": arr, - } - var buf bytes.Buffer - _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) - return buf.String() -} - -func (b *Werewolf) processWerewolf() string { - b.UnlockGroup("werewolf") - msg := "Werewolf, who do you want to kill?" - msg += b.createWerewolfPickUserForm() - b.NarrateRaw(msg, nil, &b.werewolfGroupID) - name := b.waitNameFromWerewolf() - b.Narrate(name+" will be killed", nil, &b.werewolfGroupID) - b.LockGroup("werewolf") - return name -} - -func (b *Werewolf) processSeer() { - if b.seerID == nil { - return - } - msg := "Seer, who do you want to identify?" - msg += b.createPickUserForm() - b.NarrateRaw(msg, b.seerID, nil) - name := b.waitNameFromSeer() - player := b.playersAlive[database.Username(name)] - b.Narrate(name+" is a "+player.Role, b.seerID, nil) -} - -func (b *Werewolf) processHealer() string { - if b.healerID == nil { - return "" - } - msg := "Healer, who do you want to save?" - msg += b.createPickUserForm() - b.NarrateRaw(msg, b.healerID, nil) - name := b.waitNameFromHealer() - b.Narrate(name+" will survive the night", b.healerID, nil) - return name -} - -func (b *Werewolf) LockGroups() { - b.LockGroup("werewolf") -} - -func (b *Werewolf) LockGroup(groupName string) { - group, _ := b.db.GetRoomGroupByName(b.roomID, groupName) - group.Locked = true - group.DoSave(b.db) -} - -func (b *Werewolf) UnlockGroup(groupName string) { - group, _ := b.db.GetRoomGroupByName(b.roomID, groupName) - group.Locked = false - group.DoSave(b.db) -} - -type Player struct { - UserID database.UserID - Username database.Username - Role string -} - -func (b *Werewolf) reset() { - b.ctx, b.cancel = context.WithCancel(context.Background()) - b.state = PreGameState - b.players = make(map[database.Username]*Player) - b.playersAlive = make(map[database.Username]*Player) - b.werewolfSet = hashset.New[database.UserID]() - b.spectatorSet = hashset.New[database.UserID]() - b.townspersonSet = hashset.New[database.UserID]() - b.voted = hashset.New[database.UserID]() - b.werewolfCh = make(chan string) - b.seerCh = make(chan string) - b.healerCh = make(chan string) - b.votesCh = make(chan string) - b.readyCh = make(chan bool) - _ = b.db.ClearRoomGroup(b.roomID, b.werewolfGroupID) - _ = b.db.ClearRoomGroup(b.roomID, b.spectatorGroupID) - _ = b.db.ClearRoomGroup(b.roomID, b.deadGroupID) -} - -func NewWerewolf(db *database.DkfDB) *Werewolf { - // Prepare room - room, err := db.GetChatRoomByName("werewolf") - if err != nil { - logrus.Error("#werewolf room not found") - return nil - } - zeroUser, _ := db.GetUserByUsername(config.NullUsername) - _ = db.DeleteChatRoomGroups(room.ID) - werewolfGroup, _ := db.CreateChatRoomGroup(room.ID, "werewolf", "#ffffff") - werewolfGroup.Locked = true - werewolfGroup.DoSave(db) - spectatorGroup, _ := db.CreateChatRoomGroup(room.ID, "spectator", "#ffffff") - deadGroup, _ := db.CreateChatRoomGroup(room.ID, "dead", "#ffffff") - - b := new(Werewolf) - b.db = db - b.werewolfGroupID = werewolfGroup.ID - b.spectatorGroupID = spectatorGroup.ID - b.deadGroupID = deadGroup.ID - b.narratorID = zeroUser.ID - b.roomID = room.ID - b.reset() - return b -} diff --git a/pkg/web/handlers/data.go b/pkg/web/handlers/data.go @@ -3,7 +3,7 @@ package handlers import ( "dkforest/pkg/managers" "dkforest/pkg/odometer" - v1 "dkforest/pkg/web/handlers/api/v1" + "dkforest/pkg/web/handlers/api/v1/interceptors" "time" "dkforest/pkg/database" @@ -912,7 +912,7 @@ type stego1RoadChallengeData struct { } type chessData struct { - Games []v1.ChessGame + Games []interceptors.ChessGame Error string Username database.Username } diff --git a/pkg/web/handlers/handlers.go b/pkg/web/handlers/handlers.go @@ -11,6 +11,7 @@ import ( "dkforest/pkg/pubsub" "dkforest/pkg/utils/crypto" v1 "dkforest/pkg/web/handlers/api/v1" + "dkforest/pkg/web/handlers/api/v1/interceptors" "dkforest/pkg/web/handlers/streamModals" "encoding/base64" "encoding/csv" @@ -4743,7 +4744,7 @@ func ChessHandler(c echo.Context) error { authUser := c.Get("authUser").(*database.User) db := c.Get("database").(*database.DkfDB) var data chessData - data.Games = v1.ChessInstance.GetGames() + data.Games = interceptors.ChessInstance.GetGames() if c.Request().Method == http.MethodPost { data.Username = database.Username(c.Request().PostFormValue("username")) @@ -4752,7 +4753,7 @@ func ChessHandler(c echo.Context) error { data.Error = "invalid username" return c.Render(http.StatusOK, "chess", data) } - if _, err := v1.ChessInstance.NewGame1("", config.GeneralRoomID, *authUser, player2); err != nil { + if _, err := interceptors.ChessInstance.NewGame1("", config.GeneralRoomID, *authUser, player2); err != nil { data.Error = err.Error() return c.Render(http.StatusOK, "chess", data) } @@ -4817,7 +4818,7 @@ func ChessGameHandler(c echo.Context) error { //db := c.Get("database").(*database.DkfDB) key := c.Param("key") - g := v1.ChessInstance.GetGame(key) + g := interceptors.ChessInstance.GetGame(key) if g == nil { // Chess debug //user1, _ := db.GetUserByID(1) @@ -4834,9 +4835,9 @@ func ChessGameHandler(c echo.Context) error { if msg == "resign" { resignColor := utils.Ternary(isFlipped, chess.Black, chess.White) g.Game.Resign(resignColor) - v1.ChessPubSub.Pub(key, true) + interceptors.ChessPubSub.Pub(key, true) } else { - if err := v1.ChessInstance.SendMove(key, authUser.ID, g, c); err != nil { + if err := interceptors.ChessInstance.SendMove(key, authUser.ID, g, c); err != nil { logrus.Error(err) } } @@ -4881,7 +4882,7 @@ func ChessGameHandler(c echo.Context) error { authorizedChannels := make([]string, 0) authorizedChannels = append(authorizedChannels, key) - sub := v1.ChessPubSub.Subscribe(authorizedChannels) + sub := interceptors.ChessPubSub.Subscribe(authorizedChannels) defer sub.Close() var card1 string