Merge branch 'master' into room-access-restrictions

# Conflicts:
#	common/chatcommands.go
#	main.go
#	settings.go
This commit is contained in:
Zorchenhimer 2019-03-30 16:04:06 -04:00
commit 4e35418a79
23 changed files with 776 additions and 257 deletions

3
.gitignore vendored
View File

@ -35,3 +35,6 @@ static/main.wasm
# tags for vim
tags
# channel and emote list from twitch
subscribers.json

View File

@ -5,6 +5,7 @@ import (
"html"
"regexp"
"strings"
"time"
"unicode"
"github.com/zorchenhimer/MovieNight/common"
@ -25,6 +26,36 @@ type Client struct {
IsColorForced bool
IsNameForced bool
regexName *regexp.Regexp
// Times since last event. use time.Duration.Since()
nextChat time.Time // rate limit chat messages
nextNick time.Time // rate limit nickname changes
nextColor time.Time // rate limit color changes
nextAuth time.Time // rate limit failed auth attempts. Sould prolly have a backoff policy.
authTries int // number of failed auth attempts
nextDuplicate time.Time
lastMsg string
}
func NewClient(connection *chatConnection, room *ChatRoom, name, color string) (*Client, error) {
c := &Client{
conn: connection,
belongsTo: room,
color: color,
}
if err := c.setName(name); err != nil {
return nil, fmt.Errorf("could not set client name to %#v: %v", name, err)
}
// Set initial vaules to their rate limit duration in the past.
c.nextChat = time.Now()
c.nextNick = time.Now()
c.nextColor = time.Now()
c.nextAuth = time.Now()
return c, nil
}
//Client has a new message to broadcast
@ -85,11 +116,42 @@ func (cl *Client) NewMsg(data common.ClientData) {
}
} else {
// Limit the rate of sent chat messages. Ignore mods and admins
if time.Now().Before(cl.nextChat) && cl.CmdLevel == common.CmdlUser {
err := cl.SendChatData(common.NewChatMessage("", "",
"Slow down.",
common.CmdlUser,
common.MsgCommandResponse))
if err != nil {
common.LogErrorf("Unable to send slowdown for chat: %v", err)
}
return
}
// Trim long messages
if len(msg) > 400 {
msg = msg[0:400]
}
// Limit the rate of duplicate messages. Ignore mods and admins.
// Only checks the last message.
if strings.TrimSpace(strings.ToLower(msg)) == cl.lastMsg &&
time.Now().Before(cl.nextDuplicate) &&
cl.CmdLevel == common.CmdlUser {
err := cl.SendChatData(common.NewChatMessage("", "",
common.ParseEmotes("You already sent that PeepoSus"),
common.CmdlUser,
common.MsgCommandResponse))
if err != nil {
common.LogErrorf("Unable to send slowdown for chat: %v", err)
}
return
}
cl.nextChat = time.Now().Add(time.Second * settings.RateLimitChat)
cl.nextDuplicate = time.Now().Add(time.Second * settings.RateLimitDuplicate)
cl.lastMsg = strings.TrimSpace(strings.ToLower(msg))
common.LogChatf("[chat] <%s> %q\n", cl.name, msg)
// Enable links for mods and admins
@ -180,16 +242,23 @@ func (cl *Client) Host() string {
}
func (cl *Client) setName(s string) error {
regex, err := regexp.Compile(fmt.Sprintf("(%s|@%s)", s, s))
// Case-insensitive search. Match whole words only (`\b` is word boundary).
regex, err := regexp.Compile(fmt.Sprintf(`(?i)\b(%s|@%s)\b`, s, s))
if err != nil {
return fmt.Errorf("could not compile regex: %v", err)
}
cl.name = s
cl.regexName = regex
cl.conn.clientName = s
return nil
}
func (cl *Client) setColor(s string) error {
cl.color = s
return cl.SendChatData(common.NewChatHiddenMessage(common.CdColor, cl.color))
}
func (cl *Client) replaceColorizedName(chatData common.ChatData) common.ChatData {
data := chatData.Data.(common.DataMessage)
data.Message = cl.regexName.ReplaceAllString(data.Message, `<span class="mention">$1</span>`)

View File

@ -4,6 +4,7 @@ import (
"fmt"
"html"
"strings"
"time"
"github.com/zorchenhimer/MovieNight/common"
)
@ -45,9 +46,122 @@ var commands = &CommandControl{
},
},
common.CNColor.String(): cmdColor,
common.CNColor.String(): Command{
HelpText: "Change user color.",
Function: func(cl *Client, args []string) string {
if len(args) > 2 {
return "Too many arguments!"
}
common.CNWhoAmI.String(): cmdWhoAmI,
// If the caller is priviledged enough, they can change the color of another user
if len(args) == 2 {
if cl.CmdLevel == common.CmdlUser {
return "You cannot change someone else's color. PeepoSus"
}
name, color := "", ""
if strings.ToLower(args[0]) == strings.ToLower(args[1]) ||
(common.IsValidColor(args[0]) && common.IsValidColor(args[1])) {
return "Name and color are ambiguous. Prefix the name with '@' or color with '#'"
}
// Check for explicit name
if strings.HasPrefix(args[0], "@") {
name = strings.TrimLeft(args[0], "@")
color = args[1]
common.LogDebugln("[color:mod] Found explicit name: ", name)
} else if strings.HasPrefix(args[1], "@") {
name = strings.TrimLeft(args[1], "@")
color = args[0]
common.LogDebugln("[color:mod] Found explicit name: ", name)
// Check for explicit color
} else if strings.HasPrefix(args[0], "#") {
name = strings.TrimPrefix(args[1], "@") // this shouldn't be needed, but just in case.
color = args[0]
common.LogDebugln("[color:mod] Found explicit color: ", color)
} else if strings.HasPrefix(args[1], "#") {
name = strings.TrimPrefix(args[0], "@") // this shouldn't be needed, but just in case.
color = args[1]
common.LogDebugln("[color:mod] Found explicit color: ", color)
// Guess
} else if common.IsValidColor(args[0]) {
name = strings.TrimPrefix(args[1], "@")
color = args[0]
common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color)
} else if common.IsValidColor(args[1]) {
name = strings.TrimPrefix(args[0], "@")
color = args[1]
common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color)
}
if name == "" {
return "Cannot determine name. Prefix name with @."
}
if color == "" {
return "Cannot determine color. Prefix name with @."
}
if color == "" {
common.LogInfof("[color:mod] %s missing color\n", cl.name)
return "Missing color"
}
if name == "" {
common.LogInfof("[color:mod] %s missing name\n", cl.name)
return "Missing name"
}
if err := cl.belongsTo.ForceColorChange(name, color); err != nil {
return err.Error()
}
return fmt.Sprintf("Color changed for user %s to %s\n", name, color)
}
// Don't allow an unprivilaged user to change their color if
// it was changed by a mod
if cl.IsColorForced {
common.LogInfof("[color] %s tried to change a forced color\n", cl.name)
return "You are not allowed to change your color."
}
if time.Now().Before(cl.nextColor) && cl.CmdLevel == common.CmdlUser {
return fmt.Sprintf("Slow down. You can change your color in %0.0f seconds.", time.Until(cl.nextColor).Seconds())
}
if len(args) == 0 {
cl.setColor(common.RandomColor())
return "Random color chosen: " + cl.color
}
// Change the color of the user
if !common.IsValidColor(args[0]) {
return "To choose a specific color use the format <i>/color #c029ce</i>. Hex values expected."
}
cl.nextColor = time.Now().Add(time.Second * settings.RateLimitColor)
err := cl.setColor(args[0])
if err != nil {
common.LogErrorf("[color] could not send color update to client: %v\n", err)
}
common.LogInfof("[color] %s new color: %s\n", cl.name, cl.color)
return "Color changed successfully."
},
},
common.CNWhoAmI.String(): Command{
HelpText: "Shows debug user info",
Function: func(cl *Client, args []string) string {
return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t",
cl.name,
cl.CmdLevel >= common.CmdlMod,
cl.CmdLevel == common.CmdlAdmin)
},
},
common.CNAuth.String(): Command{
HelpText: "Authenticate to admin",
@ -56,6 +170,14 @@ var commands = &CommandControl{
return "You are already authenticated."
}
// TODO: handle backoff policy
if time.Now().Before(cl.nextAuth) {
cl.nextAuth = time.Now().Add(time.Second * settings.RateLimitAuth)
return "Slow down."
}
cl.authTries += 1 // this isn't used yet
cl.nextAuth = time.Now().Add(time.Second * settings.RateLimitAuth)
pw := html.UnescapeString(strings.Join(args, " "))
if settings.AdminPassword == pw {
@ -89,6 +211,12 @@ var commands = &CommandControl{
common.CNNick.String(): Command{
HelpText: "Change display name",
Function: func(cl *Client, args []string) string {
if time.Now().Before(cl.nextNick) {
//cl.nextNick = time.Now().Add(time.Second * settings.RateLimitNick)
return fmt.Sprintf("Slow down. You can change your nick in %0.0f seconds.", time.Until(cl.nextNick).Seconds())
}
cl.nextNick = time.Now().Add(time.Second * settings.RateLimitNick)
if len(args) == 0 {
return "Missing name to change to."
}
@ -390,6 +518,37 @@ var commands = &CommandControl{
return "see console for output"
},
},
common.CNAddEmotes.String(): Command{
HelpText: "Add emotes from a given twitch channel.",
Function: func(cl *Client, args []string) string {
// Fire this off in it's own goroutine so the client doesn't
// block waiting for the emote download to finish.
go func() {
// Pretty sure this breaks on partial downloads (eg, one good channel and one non-existant)
_, err := GetEmotes(args)
if err != nil {
cl.SendChatData(common.NewChatMessage("", "",
err.Error(),
common.CmdlUser, common.MsgCommandResponse))
return
}
// reload emotes now that new ones were added
_, err = common.LoadEmotes()
if err != nil {
cl.SendChatData(common.NewChatMessage("", "",
err.Error(),
common.CmdlUser, common.MsgCommandResponse))
return
}
cl.belongsTo.AddModNotice(cl.name + " has added emotes from the following channels: " + strings.Join(args, ", "))
}()
return "Emote download initiated for the following channels: " + strings.Join(args, ", ")
},
},
},
}
@ -461,112 +620,3 @@ func getHelp(lvl common.CommandLevel) map[string]string {
}
return helptext
}
// Commands below have more than one invoking command (aliases).
var cmdColor = Command{
HelpText: "Change user color.",
Function: func(cl *Client, args []string) string {
if len(args) > 2 {
return "Too many arguments!"
}
// If the caller is priviledged enough, they can change the color of another user
if len(args) == 2 {
if cl.CmdLevel == common.CmdlUser {
return "You cannot change someone else's color. PeepoSus"
}
name, color := "", ""
if strings.ToLower(args[0]) == strings.ToLower(args[1]) ||
(common.IsValidColor(args[0]) && common.IsValidColor(args[1])) {
return "Name and color are ambiguous. Prefix the name with '@' or color with '#'"
}
// Check for explicit name
if strings.HasPrefix(args[0], "@") {
name = strings.TrimLeft(args[0], "@")
color = args[1]
common.LogDebugln("[color:mod] Found explicit name: ", name)
} else if strings.HasPrefix(args[1], "@") {
name = strings.TrimLeft(args[1], "@")
color = args[0]
common.LogDebugln("[color:mod] Found explicit name: ", name)
// Check for explicit color
} else if strings.HasPrefix(args[0], "#") {
name = strings.TrimPrefix(args[1], "@") // this shouldn't be needed, but just in case.
color = args[0]
common.LogDebugln("[color:mod] Found explicit color: ", color)
} else if strings.HasPrefix(args[1], "#") {
name = strings.TrimPrefix(args[0], "@") // this shouldn't be needed, but just in case.
color = args[1]
common.LogDebugln("[color:mod] Found explicit color: ", color)
// Guess
} else if common.IsValidColor(args[0]) {
name = strings.TrimPrefix(args[1], "@")
color = args[0]
common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color)
} else if common.IsValidColor(args[1]) {
name = strings.TrimPrefix(args[0], "@")
color = args[1]
common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color)
}
if name == "" {
return "Cannot determine name. Prefix name with @."
}
if color == "" {
return "Cannot determine color. Prefix name with @."
}
if color == "" {
common.LogInfof("[color:mod] %s missing color\n", cl.name)
return "Missing color"
}
if name == "" {
common.LogInfof("[color:mod] %s missing name\n", cl.name)
return "Missing name"
}
if err := cl.belongsTo.ForceColorChange(name, color); err != nil {
return err.Error()
}
return fmt.Sprintf("Color changed for user %s to %s\n", name, color)
}
// Don't allow an unprivilaged user to change their color if
// it was changed by a mod
if cl.IsColorForced {
common.LogInfof("[color] %s tried to change a forced color\n", cl.name)
return "You are not allowed to change your color."
}
if len(args) == 0 {
cl.color = common.RandomColor()
return "Random color chosen: " + cl.color
}
// Change the color of the user
if !common.IsValidColor(args[0]) {
return "To choose a specific color use the format <i>/color #c029ce</i>. Hex values expected."
}
cl.color = args[0]
common.LogInfof("[color] %s new color: %s\n", cl.name, cl.color)
return "Color changed successfully."
},
}
var cmdWhoAmI = Command{
HelpText: "Shows debug user info",
Function: func(cl *Client, args []string) string {
return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t",
cl.name,
cl.CmdLevel >= common.CmdlMod,
cl.CmdLevel == common.CmdlAdmin)
},
}

View File

@ -96,16 +96,9 @@ func (cr *ChatRoom) Join(name, uid string) (*Client, error) {
}
}
conn.clientName = name
client := &Client{
conn: conn,
belongsTo: cr,
color: common.RandomColor(),
}
err := client.setName(name)
client, err := NewClient(conn, cr, name, common.RandomColor())
if err != nil {
return nil, fmt.Errorf("could not set client name to %#v: %v", name, err)
return nil, fmt.Errorf("Unable to join client: %v", err)
}
host := client.Host()
@ -125,6 +118,9 @@ func (cr *ChatRoom) Join(name, uid string) (*Client, error) {
client.Send(playingCommand)
}
cr.AddEventMsg(common.EvJoin, name, client.color)
client.SendChatData(common.NewChatHiddenMessage(common.CdEmote, common.Emotes))
return client, nil
}

View File

@ -36,17 +36,40 @@ var (
CNReloadEmotes ChatCommandNames = []string{"reloademotes"}
CNModpass ChatCommandNames = []string{"modpass"}
CNIP ChatCommandNames = []string{"iplist"}
CNAddEmotes ChatCommandNames = []string{"addemotes"}
CNNewPin ChatCommandNames = []string{"newpin", "newpassword"}
CNRoomAccess ChatCommandNames = []string{"changeaccess", "hodor"}
)
var ChatCommands = []ChatCommandNames{
// User
CNMe, CNHelp, CNCount, CNColor, CNWhoAmI, CNAuth, CNUsers, CNNick,
CNMe,
CNHelp,
CNCount,
CNColor,
CNWhoAmI,
CNAuth,
CNUsers,
CNNick,
// Mod
CNSv, CNPlaying, CNUnmod, CNKick, CNBan, CNUnban, CNPurge, CNPin,
CNSv,
CNPlaying,
CNUnmod,
CNKick,
CNBan,
CNUnban,
CNPurge,
// Admin
CNMod, CNReloadPlayer, CNReloadEmotes, CNModpass, CNRoomAccess, CNIP,
CNMod,
CNReloadPlayer,
CNReloadEmotes,
CNModpass,
CNIP,
CNAddEmotes,
}
func GetFullChatCommand(c string) string {

View File

@ -78,7 +78,7 @@ type ClientData struct {
func (c ClientData) HTML() string {
// Client data is for client to server communication only, so clients should not see this
return `<div style="color: red;"><span>The developer messed up. You should not be seeing this.</span></div>`
return `<span style="color: red;">The developer messed up. You should not be seeing this.</span>`
}
type DataMessage struct {
@ -93,20 +93,20 @@ type DataMessage struct {
func (dc DataMessage) HTML() string {
switch dc.Type {
case MsgAction:
return `<div style="color:` + dc.Color + `"><span class="name">` + dc.From +
`</span> <span class="cmdme">` + dc.Message + `</span></div>`
return `<span style="color:` + dc.Color + `"><span class="name">` + dc.From +
`</span> <span class="cmdme">` + dc.Message + `</span></span>`
case MsgServer:
return `<div class="announcement">` + dc.Message + `</div>`
return `<span class="announcement">` + dc.Message + `</span>`
case MsgError:
return `<div class="error">` + dc.Message + `</div>`
return `<span class="error">` + dc.Message + `</span>`
case MsgNotice:
return `<div class="notice">` + dc.Message + `</div>`
return `<span class="notice">` + dc.Message + `</span>`
case MsgCommandResponse:
return `<div class="command">` + dc.Message + `</div>`
return `<span class="command">` + dc.Message + `</span>`
default:
badge := ""
@ -116,8 +116,8 @@ func (dc DataMessage) HTML() string {
case CmdlAdmin:
badge = `<img src="/static/img/admin.png" class="badge" />`
}
return `<div>` + badge + `<span class="name" style="color:` + dc.Color + `">` + dc.From +
`</span><b>:</b> <span class="msg">` + dc.Message + `</span></div>`
return `<span>` + badge + `<span class="name" style="color:` + dc.Color + `">` + dc.From +
`</span><b>:</b> <span class="msg">` + dc.Message + `</span></span>`
}
}
@ -142,7 +142,7 @@ type DataCommand struct {
func (de DataCommand) HTML() string {
switch de.Command {
case CmdPurgeChat:
return `<div class="notice">Chat has been purged by a moderator.</div>`
return `<span class="notice">Chat has been purged by a moderator.</span>`
default:
return ""
}
@ -167,37 +167,37 @@ type DataEvent struct {
func (de DataEvent) HTML() string {
switch de.Event {
case EvKick:
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has been kicked.</div>`
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has been kicked.</span>`
case EvLeave:
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has left the chat.</div>`
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has left the chat.</span>`
case EvBan:
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has been banned.</div>`
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has been banned.</span>`
case EvJoin:
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has joined the chat.</div>`
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
de.User + `</span> has joined the chat.</span>`
case EvNameChange:
names := strings.Split(de.User, ":")
if len(names) != 2 {
return `<div class="event">Somebody changed their name, but IDK who ` +
ParseEmotes("Jebaited") + `.</div>`
return `<span class="event">Somebody changed their name, but IDK who ` +
ParseEmotes("Jebaited") + `.</span>`
}
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
names[0] + `</span> has changed their name to <span class="name" style="color:` +
de.Color + `">` + names[1] + `</span>.</div>`
de.Color + `">` + names[1] + `</span>.</span>`
case EvNameChangeForced:
names := strings.Split(de.User, ":")
if len(names) != 2 {
return `<div class="event">An admin changed somebody's name, but IDK who ` +
ParseEmotes("Jebaited") + `.</div>`
return `<span class="event">An admin changed somebody's name, but IDK who ` +
ParseEmotes("Jebaited") + `.</span>`
}
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
names[0] + `</span> has had their name changed to <span class="name" style="color:` +
de.Color + `">` + names[1] + `</span> by an admin.</div>`
de.Color + `">` + names[1] + `</span> by an admin.</span>`
}
return ""
}

View File

@ -47,8 +47,7 @@ var (
)
// IsValidColor takes a string s and compares it against a list of css color names.
// It also accepts hex codes in the form of #000 (RGB), to #00000000 (RRGGBBAA), with A
// being the alpha value
// It also accepts hex codes in the form of #RGB and #RRGGBB
func IsValidColor(s string) bool {
s = strings.ToLower(s)
for _, c := range colors {

View File

@ -8,6 +8,8 @@ const (
CdUsers // get a list of users
CdPing // ping the server to keep the connection alive
CdAuth // get the auth levels of the user
CdColor // get the users color
CdEmote // get a list of emotes
)
type DataType int

View File

@ -8,15 +8,20 @@ import (
var Emotes map[string]string
func EmoteToHtml(file, title string) string {
return fmt.Sprintf(`<img src="/emotes/%s" height="28px" title="%s" />`, file, title)
}
func ParseEmotesArray(words []string) []string {
newWords := []string{}
for _, word := range words {
word = strings.Trim(word, "[]")
// make :emote: and [emote] valid for replacement.
wordTrimmed := strings.Trim(word, ":[]")
found := false
for key, val := range Emotes {
if key == word {
newWords = append(newWords, fmt.Sprintf(`<img src="/emotes/%s" height="28px" title="%s" />`, val, key))
if key == wordTrimmed {
newWords = append(newWords, EmoteToHtml(val, key))
found = true
}
}

165
emotes.go Normal file
View File

@ -0,0 +1,165 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
type twitchChannel struct {
ChannelName string `json:"channel_name"`
DisplayName string `json:"display_name"`
ChannelId string `json:"channel_id"`
BroadcasterType string `json:"broadcaster_type"`
Plans map[string]string `json:"plans"`
Emotes []struct {
Code string `json:"code"`
Set int `json:"emoticon_set"`
Id int `json:"id"`
} `json:"emotes"`
BaseSetId string `json:"base_set_id"`
GeneratedAt string `json:"generated_at"`
}
// Used in settings
type EmoteSet struct {
Channel string // channel name
Prefix string // emote prefix
Found bool `json:"-"`
}
const subscriberJson string = `subscribers.json`
// Download a single channel's emote set
func (tc *twitchChannel) downloadEmotes() (*EmoteSet, error) {
es := &EmoteSet{Channel: strings.ToLower(tc.ChannelName)}
for _, emote := range tc.Emotes {
url := fmt.Sprintf(`https://static-cdn.jtvnw.net/emoticons/v1/%d/1.0`, emote.Id)
png := `static/emotes/` + emote.Code + `.png`
if len(es.Prefix) == 0 {
// For each letter
for i := 0; i < len(emote.Code); i++ {
// Find the first capital
b := emote.Code[i]
if b >= 'A' && b <= 'Z' {
es.Prefix = emote.Code[0 : i-1]
fmt.Printf("Found prefix for channel %q: %q (%q)\n", es.Channel, es.Prefix, emote)
break
}
}
}
resp, err := http.Get(url)
if err != nil {
return nil, err
}
f, err := os.Create(png)
if err != nil {
return nil, err
}
_, err = io.Copy(f, resp.Body)
if err != nil {
return nil, err
}
}
return es, nil
}
func GetEmotes(names []string) ([]*EmoteSet, error) {
// Do this up-front
for i := 0; i < len(names); i++ {
names[i] = strings.ToLower(names[i])
}
channels, err := findChannels(names)
if err != nil {
return nil, fmt.Errorf("Error reading %q: %v", subscriberJson, err)
}
emoteSets := []*EmoteSet{}
for _, c := range channels {
es, err := c.downloadEmotes()
if err != nil {
return nil, fmt.Errorf("Error downloading emotes: %v", err)
}
emoteSets = append(emoteSets, es)
}
for _, es := range emoteSets {
found := false
for _, name := range names {
if es.Channel == name {
found = true
break
}
}
if !found {
es.Found = false
}
}
return emoteSets, nil
}
func findChannels(names []string) ([]twitchChannel, error) {
file, err := os.Open(subscriberJson)
if err != nil {
return nil, err
}
defer file.Close()
data := []twitchChannel{}
dec := json.NewDecoder(file)
// Open bracket
_, err = dec.Token()
if err != nil {
return nil, err
}
done := false
for dec.More() && !done {
// opening bracket of channel
_, err = dec.Token()
if err != nil {
return nil, err
}
// Decode the channel stuff
var c twitchChannel
err = dec.Decode(&c)
if err != nil {
return nil, err
}
// Is this a channel we are looking for?
found := false
for _, search := range names {
if strings.ToLower(c.ChannelName) == search {
found = true
break
}
}
// Yes it is. Add it to the data
if found {
data = append(data, c)
}
// Check for completion. Don't bother parsing the rest of
// the json file if we've already found everything that we're
// looking for.
if len(data) == len(names) {
done = true
}
}
return data, nil
}

View File

@ -64,7 +64,9 @@ func wsStaticFiles(w http.ResponseWriter, r *http.Request) {
}
func wsWasmFile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
if settings.NoCache {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
http.ServeFile(w, r, "./static/main.wasm")
}
@ -333,7 +335,9 @@ func handleIndexTemplate(w http.ResponseWriter, r *http.Request) {
}
// Force browser to replace cache since file was not changed
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
if settings.NoCache {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
err = t.Execute(w, data)
if err != nil {

34
main.go
View File

@ -1,10 +1,8 @@
package main
import (
"crypto/rand"
"flag"
"fmt"
"math/big"
"net/http"
"os"
"os/signal"
@ -31,33 +29,6 @@ func setupSettings() error {
return fmt.Errorf("Missing stream key is settings.json")
}
if err = settings.SetupLogging(); err != nil {
return fmt.Errorf("Unable to setup logger: %s", err)
}
// Is this a good way to do this? Probably not...
if len(settings.SessionKey) == 0 {
out := ""
large := big.NewInt(int64(1 << 60))
large = large.Add(large, large)
for len(out) < 50 {
num, err := rand.Int(rand.Reader, large)
if err != nil {
panic("Error generating session key: " + err.Error())
}
out = fmt.Sprintf("%s%X", out, num)
}
settings.SessionKey = out
}
if len(settings.RoomAccess) == 0 {
settings.RoomAccess = AccessOpen
}
if settings.RoomAccess != AccessOpen && len(settings.RoomAccessPin) == 0 {
settings.RoomAccessPin = "1234"
}
sstore = sessions.NewCookieStore([]byte(settings.SessionKey))
sstore.Options = &sessions.Options{
Path: "/",
@ -65,11 +36,6 @@ func setupSettings() error {
SameSite: http.SameSiteStrictMode,
}
// Save admin password to file
if err = settings.Save(); err != nil {
return fmt.Errorf("Unable to save settings: %s", err)
}
return nil
}

View File

@ -30,12 +30,23 @@ type Settings struct {
AdminPassword string
StreamKey string
ListenAddress string
SessionKey string // key for session data
ApprovedEmotes []EmoteSet // list of channels that have been approved for emote use. Global emotes are always "approved".
SessionKey string // key for session data
Bans []BanInfo
LogLevel common.LogLevel
LogFile string
RoomAccess AccessMode
RoomAccessPin string // auto generate this,
// Rate limiting stuff, in seconds
RateLimitChat time.Duration
RateLimitNick time.Duration
RateLimitColor time.Duration
RateLimitAuth time.Duration
RateLimitDuplicate time.Duration // Amount of seconds between allowed duplicate messages
// Send the NoCache header?
NoCache bool
}
type AccessMode string
@ -65,6 +76,10 @@ func LoadSettings(filename string) (*Settings, error) {
}
s.filename = filename
if err = common.SetupLogging(s.LogLevel, s.LogFile); err != nil {
return nil, fmt.Errorf("Unable to setup logger: %s", err)
}
// have a default of 200
if s.MaxMessageCount == 0 {
s.MaxMessageCount = 300
@ -77,6 +92,50 @@ func LoadSettings(filename string) (*Settings, error) {
return nil, fmt.Errorf("unable to generate admin password: %s", err)
}
if s.RateLimitChat == -1 {
s.RateLimitChat = 0
} else if s.RateLimitChat <= 0 {
s.RateLimitChat = 1
}
if s.RateLimitNick == -1 {
s.RateLimitNick = 0
} else if s.RateLimitNick <= 0 {
s.RateLimitNick = 300
}
if s.RateLimitColor == -1 {
s.RateLimitColor = 0
} else if s.RateLimitColor <= 0 {
s.RateLimitColor = 60
}
if s.RateLimitAuth == -1 {
s.RateLimitAuth = 0
} else if s.RateLimitAuth <= 0 {
s.RateLimitAuth = 5
}
if s.RateLimitDuplicate == -1 {
s.RateLimitDuplicate = 0
} else if s.RateLimitDuplicate <= 0 {
s.RateLimitDuplicate = 30
}
// Print this stuff before we multiply it by time.Second
common.LogInfof("RateLimitChat: %v", s.RateLimitChat)
common.LogInfof("RateLimitNick: %v", s.RateLimitNick)
common.LogInfof("RateLimitColor: %v", s.RateLimitColor)
common.LogInfof("RateLimitAuth: %v", s.RateLimitAuth)
if len(settings.RoomAccess) == 0 {
settings.RoomAccess = AccessOpen
}
if settings.RoomAccess != AccessOpen && len(settings.RoomAccessPin) == 0 {
settings.RoomAccessPin = "1234"
}
// Don't use LogInfof() here. Log isn't setup yet when LoadSettings() is called from init().
fmt.Printf("Settings reloaded. New admin password: %s\n", s.AdminPassword)
@ -84,6 +143,26 @@ func LoadSettings(filename string) (*Settings, error) {
s.TitleLength = 50
}
// Is this a good way to do this? Probably not...
if len(settings.SessionKey) == 0 {
out := ""
large := big.NewInt(int64(1 << 60))
large = large.Add(large, large)
for len(out) < 50 {
num, err := rand.Int(rand.Reader, large)
if err != nil {
panic("Error generating session key: " + err.Error())
}
out = fmt.Sprintf("%s%X", out, num)
}
settings.SessionKey = out
}
// Save admin password to file
if err = settings.Save(); err != nil {
return nil, fmt.Errorf("Unable to save settings: %s", err)
}
return s, nil
}
@ -178,10 +257,6 @@ func (s *Settings) GetStreamKey() string {
return s.StreamKey
}
func (s *Settings) SetupLogging() error {
return common.SetupLogging(s.LogLevel, s.LogFile)
}
func (s *Settings) generateNewPin() (string, error) {
num, err := rand.Int(rand.Reader, big.NewInt(int64(9999)))
if err != nil {

View File

@ -5,4 +5,14 @@
"Bans": [],
"StreamKey": "ALongStreamKey",
"ListenAddress": ":8089"
"ApprovedEmotes": null,
"Bans": [],
"LogLevel": "debug",
"LogFile": "thelog.log",
"RateLimitChat": 1,
"RateLimitNick": 300,
"RateLimitColor": 60,
"RateLimitAuth": 5,
"RateLimitDuplicate": 30,
"NoCache": false
}

View File

@ -12,6 +12,7 @@
</head>
<body class="scrollbar">
<img id="remote" src="/static/img/remote.png" style="display: none;" onclick="flipRemote();" />
<div class="root">
{{template "body" .}}
</div>

View File

@ -238,6 +238,7 @@ span.svmsg {
grid-gap: 10px;
margin: 0px 5px;
overflow: auto;
font-size: 12px;
}
#messages {
@ -258,14 +259,6 @@ span.svmsg {
display: grid;
}
#suggestions {
background: #3b3b43;
position: absolute;
min-width: 10em;
border-radius: 5px 5px 0px 5px;
color: var(--var-message-color);
}
#msg {
background: transparent;
border: var(--var-border);
@ -276,10 +269,24 @@ span.svmsg {
resize: none;
}
#suggestions div {
#suggestions {
background: #3b3b43;
position: absolute;
min-width: 10em;
border-radius: 5px 5px 0px 5px;
color: var(--var-message-color);
}
#suggestions>div {
display: flex;
align-items: center;
padding: 5px;
}
#suggestions>div>img {
margin-right: 1em;
}
#suggestions div.selectedName {
color: var(--var-contrast-color);
}
@ -290,4 +297,14 @@ span.svmsg {
#colorSubmit:disabled {
display: none;
}
#remote {
position: absolute;
left: 0;
right: 0;
top: 0;
margin: 1em auto;
width: 50px;
z-index: 999;
}

BIN
static/img/remote.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -1,8 +1,38 @@
/// <reference path="./jquery.js" />
let konamiCode = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"]
let lastKeys = []
// Make this on all pages so video page also doesn't do this
$(document).on("keydown", function (e) {
checkKonami(e);
if (e.which === 8 && !$(e.target).is("input, textarea")) {
e.preventDefault();
}
});
function checkKonami(e) {
lastKeys.push(e.key);
if (lastKeys.length > 10) {
lastKeys.shift();
}
if (lastKeys.length === konamiCode.length) {
for (let i = 0; i < lastKeys.length; i++) {
if (lastKeys[i] != konamiCode[i]) {
console.log(i);
return;
}
}
$("#remote").css("display", "");
}
}
function flipRemote() {
$("#remote").attr("src", "/static/img/remote_active.png");
setTimeout(() => {
$("#remote").attr("src", "/static/img/remote.png");
}, Math.round(Math.random() * 10000) + 1000);
}

View File

@ -1,5 +1,21 @@
/// <reference path="./both.js" />
function getCookie(cname) {
var name = cname + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
function setPlaying(title, link) {
if (title !== "") {
$('#playing').text(title);
@ -50,7 +66,7 @@ function appendMessages(msg) {
}
$("#messages").append(msg);
$("#messages").children().last()[0].scrollIntoView({ block: "end", behavior: "smooth" });
$("#messages").children().last()[0].scrollIntoView({ block: "end" });
}
function purgeChat() {
@ -89,6 +105,14 @@ function join() {
}
setNotifyBox();
openChat();
let color = getCookie("color");
if (color !== "") {
// Do a timeout because timings
setTimeout(() => {
sendMessage("/color " + color);
}, 250);
}
}
function websocketSend(data) {
@ -117,14 +141,14 @@ function setNotifyBox(msg = "") {
// Button Wrapper Functions
function auth() {
let pass = prompt("Enter pass");
if (pass != "") {
if (pass != "" && pass !== null) {
sendMessage("/auth " + pass);
}
}
function nick() {
let nick = prompt("Enter new name");
if (nick != "") {
if (nick != "" && nick !== null) {
sendMessage("/nick " + nick);
}
}
@ -169,6 +193,10 @@ function changeColor() {
}
}
function setTimestamp(v) {
showTimestamp(v)
document.cookie = "timestamp=" + v
}
// Get the websocket setup in a function so it can be recalled
function setupWebSocket() {
@ -220,9 +248,12 @@ function setupEvents() {
}
function defaultValues() {
$("#colorRed").val(0).trigger("input");
$("#colorGreen").val(0).trigger("input");
$("#colorBlue").val(0).trigger("input");
setTimeout(() => {
let timestamp = getCookie("timestamp")
if (timestamp !== "") {
showTimestamp(timestamp === "true")
}
}, 500);
}
window.addEventListener("onresize", updateSuggestionCss);
@ -270,4 +301,4 @@ function pleaseremovethis() {
"tomato", "turquoise", "violet", "wheat", "white",
"whitesmoke", "yellow", "yellowgreen",]
}
}

View File

@ -57,6 +57,11 @@
{{end}}
</div>
<hr />
<label class="contrast">
<input type="checkbox" checked="false" onchange="setTimestamp(this.checked);" />
Show Timestamp
</label>
<hr />
<div id="hiddencolor" class="hiddendiv">
<div class="range-div" style="background-image: linear-gradient(to right, transparent, red);">
<input id="colorRed" type="range" min="0" max="255" value="0" oninput="updateColor();" />

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
@ -10,7 +11,11 @@ import (
"github.com/zorchenhimer/MovieNight/common"
)
var auth common.CommandLevel
var (
timestamp bool
color string
auth common.CommandLevel
)
func recieve(v []js.Value) {
if len(v) == 0 {
@ -21,7 +26,7 @@ func recieve(v []js.Value) {
chatJSON, err := common.DecodeData(v[0].String())
if err != nil {
fmt.Printf("Error decoding data: %s\n", err)
js.Call("appendMessages", v)
js.Call("appendMessages", fmt.Sprintf("<div>%v</div>", v))
return
}
@ -39,8 +44,21 @@ func recieve(v []js.Value) {
for _, i := range h.Data.([]interface{}) {
names = append(names, i.(string))
}
sort.Strings(names)
case common.CdAuth:
auth = h.Data.(common.CommandLevel)
case common.CdColor:
color = h.Data.(string)
js.Get("document").Set("cookie", fmt.Sprintf("color=%s;", color))
case common.CdEmote:
data := h.Data.(map[string]interface{})
emoteNames = make([]string, 0, len(data))
emotes = make(map[string]string)
for k, v := range data {
emoteNames = append(emoteNames, k)
emotes[k] = v.(string)
}
sort.Strings(emoteNames)
}
case common.DTEvent:
d := chat.Data.(common.DataEvent)
@ -51,7 +69,7 @@ func recieve(v []js.Value) {
// on join or leave, update list of possible user names
fallthrough
case common.DTChat:
js.Call("appendMessages", chat.Data.HTML())
appendMessage(chat.Data.HTML())
case common.DTCommand:
d := chat.Data.(common.DataCommand)
@ -70,18 +88,26 @@ func recieve(v []js.Value) {
js.Call("initPlayer", nil)
case common.CmdPurgeChat:
js.Call("purgeChat", nil)
js.Call("appendMessages", d.HTML())
appendMessage(d.HTML())
case common.CmdHelp:
url := "/help"
if d.Arguments != nil && len(d.Arguments) > 0 {
url = d.Arguments[0]
}
js.Call("appendMessages", d.HTML())
appendMessage(d.HTML())
js.Get("window").Call("open", url, "_blank", "menubar=0,status=0,toolbar=0,width=300,height=600")
}
}
}
func appendMessage(msg string) {
if timestamp {
h, m, _ := time.Now().Clock()
msg = fmt.Sprintf(`<span class="time">%02d:%02d</span> %s`, h, m, msg)
}
js.Call("appendMessages", "<div>"+msg+"</div>")
}
func websocketSend(msg string, dataType common.ClientDataType) error {
if strings.TrimSpace(msg) == "" && dataType == common.CdMessage {
return nil
@ -101,25 +127,33 @@ func websocketSend(msg string, dataType common.ClientDataType) error {
func send(this js.Value, v []js.Value) interface{} {
if len(v) != 1 {
showSendError(fmt.Errorf("expected 1 parameter, got %d", len(v)))
showChatError(fmt.Errorf("expected 1 parameter, got %d", len(v)))
return false
}
err := websocketSend(v[0].String(), common.CdMessage)
if err != nil {
showSendError(err)
showChatError(err)
return false
}
return true
}
func showSendError(err error) {
func showChatError(err error) {
if err != nil {
fmt.Printf("Could not send: %v\n", err)
js.Call("appendMessages", `<div><span style="color: red;">Could not send message</span></div>`)
}
}
func showTimestamp(v []js.Value) {
if len(v) != 1 {
// Don't bother with returning a value
return
}
timestamp = v[0].Bool()
}
func isValidColor(this js.Value, v []js.Value) interface{} {
if len(v) != 1 {
return false
@ -135,10 +169,13 @@ func isValidName(this js.Value, v []js.Value) interface{} {
}
func debugValues(v []js.Value) {
fmt.Printf("currentName %#v\n", currentName)
fmt.Printf("auth %#v\n", auth)
fmt.Printf("names %#v\n", names)
fmt.Printf("filteredNames %#v\n", filteredNames)
fmt.Printf("timestamp: %#v\n", timestamp)
fmt.Printf("auth: %#v\n", auth)
fmt.Printf("color: %#v\n", color)
fmt.Printf("currentSuggestion: %#v\n", currentSug)
fmt.Printf("filteredSuggestions: %#v\n", filteredSug)
fmt.Printf("names: %#v\n", names)
fmt.Printf("emoteNames: %#v\n", emoteNames)
}
func main() {
@ -150,6 +187,7 @@ func main() {
js.Set("recieveMessage", js.CallbackOf(recieve))
js.Set("processMessage", js.CallbackOf(processMessage))
js.Set("debugValues", js.CallbackOf(debugValues))
js.Set("showTimestamp", js.CallbackOf(showTimestamp))
// This is needed so the goroutine does not end
for {

View File

@ -4,24 +4,30 @@ import (
"strings"
"github.com/dennwc/dom/js"
"github.com/zorchenhimer/MovieNight/common"
)
const (
keyTab = 9
keyEnter = 13
keyUp = 38
keyDown = 40
keyTab = 9
keyEnter = 13
keyUp = 38
keyDown = 40
suggestionName = '@'
suggestionEmote = ':'
)
var (
currentName string
names []string
filteredNames []string
currentSugType rune
currentSug string
filteredSug []string
names []string
emoteNames []string
emotes map[string]string
)
// The returned value is a bool deciding to prevent the event from propagating
func processMessageKey(this js.Value, v []js.Value) interface{} {
if len(filteredNames) == 0 || currentName == "" {
if len(filteredSug) == 0 || currentSug == "" {
return false
}
@ -30,12 +36,12 @@ func processMessageKey(this js.Value, v []js.Value) interface{} {
switch keyCode {
case keyUp, keyDown:
newidx := 0
for i, n := range filteredNames {
if n == currentName {
for i, n := range filteredSug {
if n == currentSug {
newidx = i
if keyCode == keyDown {
newidx = i + 1
if newidx == len(filteredNames) {
if newidx == len(filteredSug) {
newidx--
}
} else if keyCode == keyUp {
@ -47,14 +53,19 @@ func processMessageKey(this js.Value, v []js.Value) interface{} {
break
}
}
currentName = filteredNames[newidx]
currentSug = filteredSug[newidx]
case keyTab, keyEnter:
msg := js.Get("msg")
val := msg.Get("value").String()
newval := val[:startIdx]
if i := strings.LastIndex(newval, "@"); i != -1 {
newval = newval[:i+1] + currentName
if i := strings.LastIndex(newval, string(currentSugType)); i != -1 {
var offset int
if currentSugType == suggestionName {
offset = 1
}
newval = newval[:i+offset] + currentSug
}
endVal := val[startIdx:]
@ -67,7 +78,7 @@ func processMessageKey(this js.Value, v []js.Value) interface{} {
msg.Set("selectionEnd", len(newval)+1)
// Clear out filtered names since it is no longer needed
filteredNames = nil
filteredSug = nil
default:
// We only want to handle the caught keys, so return early
return false
@ -82,9 +93,9 @@ func processMessage(v []js.Value) {
text := strings.ToLower(msg.Get("value").String())
startIdx := msg.Get("selectionStart").Int()
filteredNames = nil
filteredSug = nil
if len(text) != 0 {
if len(names) > 0 {
if len(names) > 0 || len(emoteNames) > 0 {
var caretIdx int
textParts := strings.Split(text, " ")
@ -97,18 +108,29 @@ func processMessage(v []js.Value) {
// It is possible to have a double space " ", which will lead to an
// empty string element in the slice. Also check that the index of the
// cursor is between the start of the word and the end
if len(word) > 0 && word[0] == '@' &&
caretIdx <= startIdx && startIdx <= caretIdx+len(word) {
// fill filtered first so the "modifier" keys can modify it
for _, n := range names {
if len(word) == 1 || strings.HasPrefix(strings.ToLower(n), word[1:]) {
filteredNames = append(filteredNames, n)
if len(word) > 0 && caretIdx <= startIdx && startIdx <= caretIdx+len(word) {
var suggestions []string
if word[0] == suggestionName {
currentSugType = suggestionName
suggestions = names
} else if word[0] == suggestionEmote {
suggestions = emoteNames
currentSugType = suggestionEmote
}
for _, s := range suggestions {
if len(word) == 1 || strings.Contains(strings.ToLower(s), word[1:]) {
filteredSug = append(filteredSug, s)
}
if len(filteredSug) > 10 {
break
}
}
}
if len(filteredNames) > 0 {
currentName = ""
if len(filteredSug) > 0 {
currentSug = ""
break
}
@ -124,26 +146,34 @@ func updateSuggestionDiv() {
const selectedClass = ` class="selectedName"`
var divs []string
if len(filteredNames) > 0 {
if len(filteredSug) > 0 {
// set current name to first if not set already
if currentName == "" {
currentName = filteredNames[0]
if currentSug == "" {
currentSug = filteredSug[0]
}
var hasCurrentName bool
divs = make([]string, len(filteredNames))
var hascurrentSuggestion bool
divs = make([]string, len(filteredSug))
// Create inner body of html
for i := range filteredNames {
for i := range filteredSug {
divs[i] = "<div"
if filteredNames[i] == currentName {
hasCurrentName = true
sug := filteredSug[i]
if sug == currentSug {
hascurrentSuggestion = true
divs[i] += selectedClass
}
divs[i] += ">" + filteredNames[i] + "</div>"
divs[i] += ">"
if currentSugType == suggestionEmote {
divs[i] += common.EmoteToHtml(emotes[sug], sug)
}
divs[i] += sug + "</div>"
}
if !hasCurrentName {
if !hascurrentSuggestion {
divs[0] = divs[0][:4] + selectedClass + divs[0][4:]
}
}