Initial commit

So far things work.  Needs lots of improvements.
This commit is contained in:
Zorchenhimer 2019-03-10 11:42:12 -04:00
commit 3276295421
17 changed files with 1676 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.gif
*.png
*.exe
# Linux binary
MovieNight
# Twitch channel info
static/subscriber.json

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
#export GOOS=linux
#export GOARCH=386
.PHONY: sync fmt vet
all: vet fmt MovieNight MovieNight.exe
MovieNight.exe: *.go
GOOS=windows GOARCH=amd64 go build -o MovieNight.exe
MovieNight: *.go
GOOS=linux GOARCH=386 go build -o MovieNight
clean:
rm MovieNight.exe MovieNight
fmt:
gofmt -w .
vet:
go vet
sync:
#rsync -v --no-perms --chmod=ugo=rwX -r ./ zorchenhimer@movienight.zorchenhimer.com:/home/zorchenhimer/movienight/
#rsync -v --no-perms --chmod=ugo=rwX -e "ssh -i /c/movienight/movienight-deploy.key" -r ./ zorchenhimer@movienight.zorchenhimer.com:/home/zorchenhimer/movienight/
scp -i /c/movienight/movienight-deploy.key -r . zorchenhimer@movienight.zorchenhimer.com:/home/zorchenhimer/movienight

168
chatclient.go Normal file
View File

@ -0,0 +1,168 @@
package main
import (
"fmt"
"html"
"net"
"strings"
"unicode"
"github.com/gorilla/websocket"
)
type Client struct {
name string // Display name
conn *websocket.Conn
belongsTo *ChatRoom
color string
IsMod bool
IsAdmin bool
IsColorForced bool
}
var emotes map[string]string
func ParseEmotes(msg string) string {
words := strings.Split(msg, " ")
newWords := []string{}
for _, word := range words {
word = strings.Trim(word, "[]")
found := false
for key, val := range emotes {
if key == word {
newWords = append(newWords, fmt.Sprintf("<img src=\"/emotes/%s\" title=\"%s\" />", val, key))
//fmt.Printf("[emote] %s\n", val)
found = true
}
}
if !found {
newWords = append(newWords, word)
}
}
return strings.Join(newWords, " ")
}
//Client has a new message to broadcast
func (cl *Client) NewMsg(msg string) {
msg = html.EscapeString(msg)
msg = removeDumbSpaces(msg)
msg = strings.Trim(msg, " ")
// Don't send zero-length messages
if len(msg) == 0 {
return
}
if strings.HasPrefix(msg, "/") {
// is a command
msg = msg[1:len(msg)]
fullcmd := strings.Split(msg, " ")
cmd := strings.ToLower(fullcmd[0])
args := fullcmd[1:len(fullcmd)]
response := commands.RunCommand(cmd, args, cl)
if response != "" {
cl.ServerMessage(response)
return
}
} else {
// Trim long messages
if len(msg) > 400 {
msg = msg[0:400]
}
fmt.Printf("[chat] <%s> %q\n", cl.name, msg)
// Enable links for mods and admins
if cl.IsMod || cl.IsAdmin {
msg = formatLinks(msg)
}
cl.Message(msg)
}
}
// Make links clickable
func formatLinks(input string) string {
newMsg := []string{}
for _, word := range strings.Split(input, " ") {
if strings.HasPrefix(word, "http:&#x2F;&#x2F;") || strings.HasPrefix(word, "https:&#x2F;&#x2F;") {
//word = unescape(word)
word = html.UnescapeString(word)
word = fmt.Sprintf(`<a href="%s" target="_blank">%s</a>`, word, word)
}
newMsg = append(newMsg, word)
}
return strings.Join(newMsg, " ")
}
//Exiting out
func (cl *Client) Exit() {
cl.belongsTo.Leave(cl.name, cl.color)
}
//Sending message block to the client
func (cl *Client) Send(msgs string) {
cl.conn.WriteMessage(websocket.TextMessage, []byte(msgs))
}
// Send server message to this client
func (cl *Client) ServerMessage(msg string) {
msg = ParseEmotes(msg)
cl.Send(`<span class="svmsg">` + msg + `</span><br />`)
}
// Outgoing messages
func (cl *Client) Message(msg string) {
msg = ParseEmotes(msg)
cl.belongsTo.AddMsg(
`<span class="name" style="color:` + cl.color + `">` + cl.name +
`</span><b>:</b> <span class="msg">` + msg + `</span><br />`)
}
// Outgoing /me command
func (cl *Client) Me(msg string) {
msg = ParseEmotes(msg)
cl.belongsTo.AddMsg(fmt.Sprintf(`<span style="color:%s"><span class="name">%s</span> <span class="cmdme">%s</span><br />`, cl.color, cl.name, msg))
}
func (cl *Client) Mod() {
cl.IsMod = true
}
func (cl *Client) Unmod() {
cl.IsMod = false
}
func (cl *Client) Host() string {
host, _, err := net.SplitHostPort(cl.conn.RemoteAddr().String())
if err != nil {
host = "err"
}
return host
}
var dumbSpaces []string = []string{
"\n",
"\t",
"\r",
"\u200b",
}
func removeDumbSpaces(msg string) string {
for _, ds := range dumbSpaces {
msg = strings.ReplaceAll(msg, ds, " ")
}
newMsg := ""
for _, r := range msg {
if unicode.IsSpace(r) {
newMsg += " "
} else {
newMsg += string(r)
}
}
return newMsg
}

238
chatcommands.go Normal file
View File

@ -0,0 +1,238 @@
package main
import (
"fmt"
"html"
"regexp"
"strings"
)
var commands *CommandControl
var colorRegex *regexp.Regexp = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
type CommandControl struct {
user map[string]CommandFunction
mod map[string]CommandFunction
admin map[string]CommandFunction
}
type CommandFunction func(client *Client, args []string) string
func init() {
commands = &CommandControl{
user: map[string]CommandFunction{
"me": func(client *Client, args []string) string {
client.Me(strings.Join(args, " "))
return ""
},
"help": func(client *Client, args []string) string {
return "I haven't written this yet LUL"
},
"count": func(client *Client, args []string) string {
return fmt.Sprintf("Users in chat: %d", client.belongsTo.UserCount())
},
"color": cmdColor,
"colour": cmdColor,
"w": func(cl *Client, args []string) string {
return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t", cl.name, cl.IsMod, cl.IsAdmin)
},
"whoami": func(cl *Client, args []string) string {
return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t", cl.name, cl.IsMod, cl.IsAdmin)
},
"auth": func(cl *Client, args []string) string {
if cl.IsAdmin {
return "You are already authenticated."
}
pw := html.UnescapeString(strings.Join(args, " "))
//fmt.Printf("/auth from %s. expecting %q [%X], received %q [%X]\n", cl.name, settings.AdminPassword, settings.AdminPassword, pw, pw)
if settings.AdminPassword == pw {
cl.IsMod = true
cl.IsAdmin = true
return "Admin rights granted."
}
// Don't let on that this command exists. Not the most secure, but should be "good enough" LUL.
return "Invalid command."
},
"users": func(cl *Client, args []string) string {
names := cl.belongsTo.GetNames()
return strings.Join(names, " ")
},
},
mod: map[string]CommandFunction{
"sv": func(cl *Client, args []string) string {
if len(args) == 0 {
return "Missing message"
}
svmsg := formatLinks(ParseEmotes(strings.Join(args, " ")))
cl.belongsTo.AddCmdMsg(fmt.Sprintf(`<div class="announcement">%s</div>`, svmsg))
return ""
},
"playing": func(cl *Client, args []string) string {
// Clear/hide title if sent with no arguments.
if len(args) == 1 {
cl.belongsTo.ClearPlaying()
//cl.belongsTo.AddMsg(`<script>setPlaying("","");</script>`)
return ""
}
link := ""
title := ""
// pickout the link (can be anywhere, as long as there are no spaces).
for _, word := range args {
word = html.UnescapeString(word)
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
link = word
} else {
title = title + " " + word
}
}
cl.belongsTo.SetPlaying(title, link)
//cl.belongsTo.AddMsg(fmt.Sprintf(`<script>setPlaying("%s","%s");</script>`, title, link))
return ""
},
"unmod": func(cl *Client, args []string) string {
if len(args) > 0 && !cl.IsAdmin {
return "You can only unmod yourself, not others."
}
if len(args) == 0 {
cl.Unmod()
return "You have unmodded yourself."
}
if err := cl.belongsTo.Unmod(args[0]); err != nil {
return err.Error()
}
return fmt.Sprintf(`%s has been unmodded.`, args[0])
},
"kick": func(cl *Client, args []string) string {
if len(args) == 0 {
return "Missing name to kick."
}
return cl.belongsTo.Kick(args[0])
},
"ban": func(cl *Client, args []string) string {
if len(args) == 0 {
return "missing name to ban."
}
fmt.Printf("[ban] Attempting to ban %s\n", strings.Join(args, ""))
return cl.belongsTo.Ban(args[0])
},
"unban": func(cl *Client, args []string) string {
if len(args) == 0 {
return "missing name to unban."
}
fmt.Printf("[ban] Attempting to unban %s\n", strings.Join(args, ""))
err := settings.RemoveBan(args[0])
if err != nil {
return err.Error()
}
return ""
},
},
admin: map[string]CommandFunction{
"mod": func(cl *Client, args []string) string {
if err := cl.belongsTo.Mod(args[0]); err != nil {
return err.Error()
}
return fmt.Sprintf(`%s has been modded.`, args[0])
},
"reloadplayer": func(cl *Client, args []string) string {
cl.belongsTo.AddCmdMsg(`<span class="svmsg">[SERVER] Video player reload forced.</span><script>initPlayer();</script><br />`)
return "Reloading player for all chatters."
},
"reloademotes": func(cl *Client, args []string) string {
cl.ServerMessage("Reloading emotes")
num, err := LoadEmotes()
if err != nil {
fmt.Printf("Unbale to reload emotes: %s\n", err)
return fmt.Sprintf("ERROR: %s", err)
}
fmt.Printf("Loaded %d emotes\n", num)
return fmt.Sprintf("Emotes loaded: %d", num)
},
//"reloadsettings": func(cl *Client, args []string) string {
// return ""
//},
},
}
}
func (cc *CommandControl) RunCommand(command string, args []string, sender *Client) string {
// Look for user command
if userCmd, ok := cc.user[command]; ok {
fmt.Printf("[user] %s /%s %s\n", sender.name, command, strings.Join(args, " "))
return userCmd(sender, args)
}
// Look for mod command
if modCmd, ok := cc.mod[command]; ok {
if sender.IsMod || sender.IsAdmin {
fmt.Printf("[mod] %s /%s %s\n", sender.name, command, strings.Join(args, " "))
return modCmd(sender, args)
}
fmt.Printf("[mod REJECTED] %s /%s %s\n", sender.name, command, strings.Join(args, " "))
return "You are not a mod Jebaited"
}
// Look for admin command
if adminCmd, ok := cc.admin[command]; ok {
if sender.IsAdmin {
fmt.Printf("[admin] %s /%s %s\n", sender.name, command, strings.Join(args, " "))
return adminCmd(sender, args)
}
fmt.Printf("[admin REJECTED] %s /%s %s\n", sender.name, command, strings.Join(args, " "))
return "You are not the admin Jebaited"
}
// Command not found
fmt.Printf("[cmd] %s /%s %s\n", sender.name, command, strings.Join(args, " "))
return "Invalid command."
}
func cmdColor(cl *Client, args []string) string {
if cl.IsMod || cl.IsAdmin && len(args) == 2 {
color := ""
name := ""
for _, s := range args {
if strings.HasPrefix(s, "#") {
color = s
} else {
name = s
}
}
if color == "" {
fmt.Printf("[color:mod] %s missing color\n", cl.name)
return "Missing color"
}
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)
}
if len(args) == 0 || !colorRegex.MatchString(args[0]) {
cl.color = randomColor()
return "Random color chosen. To choose a specific color use the format <i>/color #c029ce</i>. Hex values expected."
}
if cl.IsColorForced {
fmt.Printf("[color] %s tried to change a forced color\n", cl.name)
return "You are not allowed to change your color."
}
cl.color = args[0]
fmt.Printf("[color] %s new color: %s\n", cl.name, cl.color)
return "Color changed successfully."
}

78
chathandler.go Normal file
View File

@ -0,0 +1,78 @@
package main
import (
"fmt"
//"net"
"net/http"
"path/filepath"
"github.com/gorilla/websocket"
)
//global variable for handling all chat traffic
var chat ChatRoom
// Serving static files
func wsStaticFiles(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/favicon.ico":
http.ServeFile(w, r, "./static/favicon.png")
return
case "/justchat":
http.ServeFile(w, r, "./static/justchat.html")
return
case "/justvideo":
http.ServeFile(w, r, "./static/justvideo.html")
return
}
goodPath := r.URL.Path[8:len(r.URL.Path)]
fmt.Printf("[static] serving %q from folder ./static/\n", goodPath)
http.ServeFile(w, r, "./static/"+goodPath)
}
func wsEmotes(w http.ResponseWriter, r *http.Request) {
emotefile := filepath.Base(r.URL.Path)
//fmt.Printf("serving emote: %s\n", emotefile)
http.ServeFile(w, r, "./static/emotes/"+emotefile)
}
// Handling the websocket
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true }, //not checking origin
}
//this is also the handler for joining to the chat
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Println("Error upgrading to websocket:", err)
return
}
go func() {
//first message has to be the name
_, msg, err := conn.ReadMessage()
client := chat.Join(string(msg), conn)
if client == nil || err != nil {
fmt.Printf("[handler] Client closed connection: %s\n", conn.RemoteAddr().String())
conn.Close() //closing connection to indicate failed Join
return
}
//then watch for incoming messages
for {
_, msg, err := conn.ReadMessage()
if err != nil { //if error then assuming that the connection is closed
client.Exit()
return
}
client.NewMsg(string(msg))
}
}()
}

316
chatroom.go Normal file
View File

@ -0,0 +1,316 @@
package main
import (
"fmt"
//"html"
"math/rand"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
const UsernameMaxLength int = 36
var re_username *regexp.Regexp = regexp.MustCompile(`^[0-9a-zA-Z_-]+$`)
type ChatRoom struct {
clients map[string]*Client // this needs to be a pointer.
clientsMtx sync.Mutex
queue chan string
playing string
playingLink string
}
//initializing the chatroom
func (cr *ChatRoom) Init() error {
cr.queue = make(chan string, 5)
cr.clients = make(map[string]*Client)
num, err := LoadEmotes()
if err != nil {
return fmt.Errorf("Error loading emotes: %s", err)
}
fmt.Printf("Loaded %d emotes\n", num)
//the "heartbeat" for broadcasting messages
go func() {
for {
cr.BroadCast()
time.Sleep(100 * time.Millisecond)
}
}()
return nil
}
func LoadEmotes() (int, error) {
newEmotes := map[string]string{}
emotePNGs, err := filepath.Glob("./static/emotes/*.png")
if err != nil {
return 0, fmt.Errorf("Unable to glob emote directory: %s\n", err)
}
emoteGIFs, err := filepath.Glob("./static/emotes/*.gif")
if err != nil {
return 0, fmt.Errorf("Unable to glob emote directory: %s\n", err)
}
globbed_files := []string(emotePNGs)
globbed_files = append(globbed_files, emoteGIFs...)
fmt.Println("Loading emotes...")
for _, file := range globbed_files {
file = filepath.Base(file)
key := file[0 : len(file)-4]
newEmotes[key] = file
fmt.Printf("%s ", key)
}
emotes = newEmotes
fmt.Println("")
return len(emotes), nil
}
func randomColor() string {
nums := []int32{}
for i := 0; i < 6; i++ {
nums = append(nums, rand.Int31n(15))
}
return fmt.Sprintf("#%X%X%X%X%X%X",
nums[0], nums[1], nums[2],
nums[3], nums[4], nums[5])
}
//registering a new client
//returns pointer to a Client, or Nil, if the name is already taken
func (cr *ChatRoom) Join(name string, conn *websocket.Conn) *Client {
if len(name) > UsernameMaxLength || !re_username.MatchString(name) {
return nil
}
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock() //preventing simultaneous access to the `clients` map
if _, exists := cr.clients[strings.ToLower(name)]; exists {
return nil
}
client := &Client{
name: name,
conn: conn,
belongsTo: cr,
color: randomColor(),
}
host := client.Host()
if banned, names := settings.IsBanned(host); banned {
fmt.Printf("[BAN] Banned user tried to connect with IP %s: %q (banned with name(s) %q)\n", host, name, strings.Join(names, ", "))
conn.Close()
return nil
}
cr.clients[strings.ToLower(name)] = client
fmt.Printf("[join] %s %s\n", host, name)
client.Send(cr.GetPlayingString())
cr.AddMsg(fmt.Sprintf("<i><b style=\"color:%s\">%s</b> has joined the chat.</i><br />", client.color, name))
return client
}
// TODO: fix this up a bit. kick and leave are the same, incorrect, error: "That name was already used!"
//leaving the chatroom
func (cr *ChatRoom) Leave(name, color string) {
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock() //preventing simultaneous access to the `clients` map
client, err := cr.getClient(name)
if err != nil {
fmt.Printf("[leave] Unable to get client for name %q\n", name)
return
}
client.conn.Close()
cr.delClient(name)
cr.AddMsg(fmt.Sprintf("<i><b style=\"color:%s\">%s</b> has left the chat.</i><br />", color, name))
fmt.Printf("[leave] %s %s\n", client.Host(), client.name)
}
// kicked from the chatroom
func (cr *ChatRoom) Kick(name string) string {
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock() //preventing simultaneous access to the `clients` map
client, err := cr.getClient(name)
if err != nil {
return "Unable to get client for name " + name
}
if client.IsMod {
return "You cannot kick another mod."
}
if client.IsAdmin {
return "Jebaited No."
}
host := client.Host()
client.conn.Close()
cr.delClient(name)
cr.AddMsg(fmt.Sprintf("<i><b>%s</b> has been kicked.</i><br />", name))
fmt.Printf("[kick] %s %s has been kicked\n", host, name)
return ""
}
func (cr *ChatRoom) Ban(name string) string {
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock()
client, err := cr.getClient(name)
if err != nil {
fmt.Printf("[ban] Unable to get client for name %q\n", name)
return "Cannot find that name"
}
names := []string{}
host := client.Host()
client.conn.Close()
cr.delClient(name)
for name, c := range cr.clients {
if c.Host() == host {
names = append(names, name)
client.conn.Close()
cr.delClient(name)
}
}
defer settingsMtx.Unlock()
settingsMtx.Lock()
err = settings.AddBan(host, names)
if err != nil {
fmt.Printf("[BAN] Error banning %q: %s\n", name, err)
cr.AddMsg(fmt.Sprintf("<i><b>%s</b> has been kicked.</i><br />", name))
} else {
cr.AddMsg(fmt.Sprintf("<i><b>%s</b> has been banned.</i><br />", name))
}
return ""
}
//adding message to queue
func (cr *ChatRoom) AddMsg(msg string) {
cr.queue <- msg
}
func (cr *ChatRoom) AddCmdMsg(msg string) {
cr.queue <- msg
}
func (cr *ChatRoom) Unmod(name string) error {
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock()
client, err := cr.getClient(name)
if err != nil {
return err
}
client.Unmod()
client.ServerMessage(`You have been unmodded.`)
return nil
}
func (cr *ChatRoom) Mod(name string) error {
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock()
client, err := cr.getClient(name)
if err != nil {
return err
}
client.IsMod = true
client.ServerMessage(`You have been modded.`)
return nil
}
func (cr *ChatRoom) ForceColorChange(name, color string) error {
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock()
client, err := cr.getClient(name)
if err != nil {
return err
}
client.IsColorForced = true
client.color = color
return nil
}
func (cr *ChatRoom) UserCount() int {
return len(cr.clients)
}
//broadcasting all the messages in the queue in one block
func (cr *ChatRoom) BroadCast() {
msgBlock := ""
infLoop:
for {
select {
case m := <-cr.queue:
msgBlock += m // + "<br />"
default:
break infLoop
}
}
if len(msgBlock) > 0 {
for _, client := range cr.clients {
client.Send(msgBlock)
}
}
}
func (cr *ChatRoom) ClearPlaying() {
cr.playing = ""
cr.playingLink = ""
cr.AddCmdMsg(`<script>setPlaying("","");</script>`)
}
func (cr *ChatRoom) SetPlaying(title, link string) {
cr.playing = title
cr.playingLink = link
cr.AddCmdMsg(cr.GetPlayingString())
}
func (cr *ChatRoom) GetPlayingString() string {
return fmt.Sprintf(`<script>setPlaying("%s","%s");</script>`, cr.playing, cr.playingLink)
}
func (cr *ChatRoom) GetNames() []string {
names := []string{}
defer cr.clientsMtx.Unlock()
cr.clientsMtx.Lock()
for _, val := range cr.clients {
names = append(names, val.name)
}
return names
}
func (cr *ChatRoom) delClient(name string) {
delete(cr.clients, strings.ToLower(name))
}
func (cr *ChatRoom) getClient(name string) (*Client, error) {
if client, ok := cr.clients[strings.ToLower(name)]; ok {
return client, nil
}
return nil, fmt.Errorf("Client with that name not found.")
}

141
main.go Normal file
View File

@ -0,0 +1,141 @@
package main
import (
"flag"
"fmt"
"io"
"net/http"
"os"
"sync"
"github.com/nareix/joy4/av/avutil"
"github.com/nareix/joy4/av/pubsub"
"github.com/nareix/joy4/format"
"github.com/nareix/joy4/format/flv"
"github.com/nareix/joy4/format/rtmp"
)
var (
addr = flag.String("l", ":8089", "host:port of the go-rtmp-server")
sKey = flag.String("k", "", "Stream key, to protect your stream")
)
func init() {
format.RegisterAll()
}
type writeFlusher struct {
httpflusher http.Flusher
io.Writer
}
func (self writeFlusher) Flush() error {
self.httpflusher.Flush()
return nil
}
func main() {
flag.Parse()
server := &rtmp.Server{}
l := &sync.RWMutex{}
type Channel struct {
que *pubsub.Queue
}
channels := map[string]*Channel{}
server.HandlePlay = func(conn *rtmp.Conn) {
l.RLock()
ch := channels[conn.URL.Path]
l.RUnlock()
if ch != nil {
cursor := ch.que.Latest()
avutil.CopyFile(conn, cursor)
}
}
server.HandlePublish = func(conn *rtmp.Conn) {
streams, _ := conn.Streams()
l.Lock()
fmt.Println("request string->", conn.URL.RequestURI())
fmt.Println("request key->", conn.URL.Query().Get("key"))
streamKey := conn.URL.Query().Get("key")
if streamKey != *sKey {
fmt.Println("Due to key not match, denied stream")
return //If key not match, deny stream
}
ch := channels[conn.URL.Path]
if ch == nil {
ch = &Channel{}
ch.que = pubsub.NewQueue()
ch.que.WriteHeader(streams)
channels[conn.URL.Path] = ch
} else {
ch = nil
}
l.Unlock()
if ch == nil {
return
}
avutil.CopyPackets(ch.que, conn)
l.Lock()
delete(channels, conn.URL.Path)
l.Unlock()
ch.que.Close()
}
// Chat websocket
http.HandleFunc("/ws", wsHandler)
http.HandleFunc("/static/", wsStaticFiles)
http.HandleFunc("/emotes/", wsEmotes)
http.HandleFunc("/favicon.ico", wsStaticFiles)
http.HandleFunc("/jquery.js", wsStaticFiles)
http.HandleFunc("/ractive.min.js", wsStaticFiles)
http.HandleFunc("/justchat", wsStaticFiles)
http.HandleFunc("/justvideo", wsStaticFiles)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
l.RLock()
ch := channels[r.URL.Path]
l.RUnlock()
if ch != nil {
w.Header().Set("Content-Type", "video/x-flv")
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(200)
flusher := w.(http.Flusher)
flusher.Flush()
muxer := flv.NewMuxerWriteFlusher(writeFlusher{httpflusher: flusher, Writer: w})
cursor := ch.que.Latest()
avutil.CopyFile(muxer, cursor)
} else {
if r.URL.Path != "/" {
fmt.Println("[http 404] ", r.URL.Path)
http.NotFound(w, r)
} else {
http.ServeFile(w, r, "./static/index.html")
}
}
})
go http.ListenAndServe(*addr, nil)
fmt.Println("Listen and serve ", *addr)
if err := chat.Init(); err != nil {
fmt.Println(err)
os.Exit(1)
}
server.ListenAndServe()
// ffmpeg -re -i movie.flv -c copy -f flv rtmp://localhost/movie
// ffmpeg -f avfoundation -i "0:0" .... -f flv rtmp://localhost/screen
// ffplay http://localhost:8089/movie
// ffplay http://localhost:8089/screen
}

63
notes.txt Normal file
View File

@ -0,0 +1,63 @@
== TODO
- break long words across lines
- mod commands
- auth command to gain mod status
- kick/mute/timeout
- list users
- purge chat
- mods cannot kick/ban other mods or admin
- only admin can kick/ban mods
- admin revoke command with password
- broadcast mod/unmod command results to mods and admins
- fix /color for mods and admins
- "login" options
- IP admin/mod?
- save ip/name combo for reconnects?
- Move kick/ban core functionality into command instead of room?
or to (server-side) client?
- add a Chatroom.FindUser(name) function
- rewrite Javascript to accept json data.
- separate data into commands and chat
- commands will just execute more JS (eg, changing title)
- chat will append chat message
- moves all styling to client
- rewrite javascript client in go webasm?
== Commands
/color
change user color
/me
italic chat message without leading colon. message is the same color as name.
/count
display the number of users in chat
/w
/whoami
debugging command. prints name, mod, and admin status
/auth
authenticate to admin
= Mod commands
/playing [title] [link]
update title and link. clears title if no arguments
/sv <message>
server announcement message. it's red, with a red border, centered in chat.
/kick
kick user from chat
/unmod
unmod self only
= Admin commands
/reloademotes
reload emotes map
/reloadplayer
reloads the video player of everybody in chat
/unmod <name>
unmod a user
/mod <name> mod a user

41
readme.md Normal file
View File

@ -0,0 +1,41 @@
# Golang rtmp server demo
This is a very tiny demo with rtmp protocol server/client side implement.
## Requirement
You need golang to build all tools.
## Install
```bash
go get -u -v github.com/netroby/go-rtmp-server
~/go/bin/go-rtmp-server -l :8089 -k longSecurityKey
```
## Usage
now you can using obs to push stream to rtmp server
the stream url maybe ```rtmp://your.domain.host/live?key=longSecurityKey```
You can using obs to stream
Now you may visit the demo at
```
http://your.domain.host:8089/
```
the :8089 is the default listen port of the http server. and you can change it as you want
```
Usage of .\go-rtmp-server.exe:
-k string
Stream key, to protect your stream
-l string
host:port of the go-rtmp-server (default ":8089")
```

120
settings.go Normal file
View File

@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"strings"
"sync"
"time"
)
var settings *Settings
var settingsMtx sync.Mutex
type Settings struct {
filename string
AdminPassword string
Bans []BanInfo
}
type BanInfo struct {
IP string
Names []string
When time.Time
}
func init() {
var err error
settings, err = LoadSettings("settings.json")
if err != nil {
panic("Unable to load settings: " + err.Error())
}
}
func LoadSettings(filename string) (*Settings, error) {
raw, err := ioutil.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("Error reading file: %s", err)
}
var s *Settings
err = json.Unmarshal(raw, &s)
if err != nil {
return nil, fmt.Errorf("Error unmarshaling: %s", err)
}
s.filename = filename
s.AdminPassword = generateAdminPass(time.Now().Unix())
fmt.Printf("Settings reloaded. New admin password: %s\n", s.AdminPassword)
return s, nil
}
func generateAdminPass(seed int64) string {
out := ""
r := rand.New(rand.NewSource(seed))
//for i := 0; i < 20; i++ {
for len(out) < 20 {
out = fmt.Sprintf("%s%X", out, r.Int31())
}
return out
}
func (s *Settings) Save() error {
marshaled, err := json.Marshal(s)
if err != nil {
return fmt.Errorf("Error marshaling: %s", err)
}
err = ioutil.WriteFile(s.filename, marshaled, 0777)
if err != nil {
return fmt.Errorf("Error saving: %s", err)
}
return nil
}
func (s *Settings) AddBan(host string, names []string) error {
b := BanInfo{
Names: names,
IP: host,
When: time.Now(),
}
settings.Bans = append(settings.Bans, b)
fmt.Printf("[BAN] %q (%s) has been banned.\n", strings.Join(names, ", "), host)
return settings.Save()
}
func (s *Settings) RemoveBan(name string) error {
defer settingsMtx.Unlock()
settingsMtx.Lock()
name = strings.ToLower(name)
newBans := []BanInfo{}
for _, b := range s.Bans {
for _, n := range b.Names {
if n == name {
fmt.Printf("[ban] Removed ban for %s [%s]\n", b.IP, n)
} else {
newBans = append(newBans, b)
}
}
}
s.Bans = newBans
return settings.Save()
}
func (s *Settings) IsBanned(host string) (bool, []string) {
defer settingsMtx.Unlock()
settingsMtx.Lock()
for _, b := range s.Bans {
if b.IP == host {
return true, b.Names
}
}
return false, nil
}

3
settings.json Normal file
View File

@ -0,0 +1,3 @@
{
"Bans": []
}

7
static/flv.min.js vendored Normal file

File diff suppressed because one or more lines are too long

229
static/index.html Normal file
View File

@ -0,0 +1,229 @@
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Movie Night!</title>
<style>
body {
margin:0;
padding:0;
background:#000;
}
html, body, #messages, #phase2 {
height:100%;
}
video {
width:100%;
}
#streambox {
width: 80%;
float: left;
margin: 0;
}
#chatbox {
width: 19%;
float: right;
height:85%;
}
#messages {
border: 1px solid #666;
width: 95%;
overflow: auto;
color: #f4f4f4;
}
#msg {
width: 94%;
height: 3em;
}
#error {
color: #f00;
padding: 5px;
font-weight: bold;
}
span.name {
font-weight:bold;
}
span.cmdme {
font-style: italic;
}
span.msg {
font-style: normal;
color: #cfccd1;
}
span.svmsg {
font-style: italic;
color: #ea6260;
}
.announcement {
font-weight: bold;
color: #ea6260;
text-align: center;
width: 100%;
margin-top: 10px;
margin-bottom: 10px;
border-top: 3px solid red;
border-bottom: 3px solid red;
}
#playingDiv {
color: #8b6a96;
font-weight: bold;
padding: 10px;
}
#playing {
font-size: x-Large;
}
</style>
<script src="/static/jquery.js"></script>
<script src="/static/flv.min.js"></script>
<script>
function initPlayer() {
if (flvjs.isSupported()) {
var videoElement = document.getElementById('videoElement');
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: '/live'
});
flvPlayer.attachMediaElement(videoElement);
flvPlayer.load();
flvPlayer.play();
}
}
function escapeHtml(string) {
return string;
//return String(string).replace(/[&<>"'\/]/g, function (s) {
// return entityMap[s];
//});
}
//helper function for debugging purposes
function toHex(str) {
var result = '';
for (var i=0; i<str.length; i++) {
result += ("0"+str.charCodeAt(i).toString(16)).slice(-2)+" ";
}
return result.toUpperCase();
}
function setPlaying(title, link) {
if (title === "") {
$('#playingDiv').hide();
document.title = "Movie Night"
return;
}
$('#playingDiv').show();
$('#playing').text(title);
document.title = "Movie Night | " + title
if (link === "") {
$('#playinglink').hide();
return;
}
$('#playinglink').show();
$('#playinglink').text('[Info Link]');
$('#playinglink').attr('href', link);
}
</script>
</head>
<body>
<div id="streambox">
<video id="videoElement" controls autoplay x5-video-player-type="h5" x5-video-player-fullscreen="true" playsinline webkit-playsinline>
Your browser is too old and doesn't support HTML5 video.
</video>
<script>initPlayer();</script>
<button style="float:right" id="reload" onclick="initPlayer();">Reload Player</button>
<div id="playingDiv"><span id="playing"></span><br /><a href="" target="_blank" id="playinglink"></a></div>
</div>
<div id="chatbox">
<div id="phase1">
<p style="color:#e5e0e5">Please enter your name to Join the chat</P>
<input id="name">
<button id="join">Join</button>
</div>
<div id="error"></div>
<div id="phase2" style="opacity:0">
<div id="messages"></div>
<textarea id="msg"></textarea>
<br/><button id="send">Send</button>
</div>
<script>
$("INPUT").val("")
$("#name").keypress(function(evt){
if(evt.originalEvent.keyCode==13){
$("#join").trigger("click")
//submit name
}
})
//handling the start of the chat
$("#join").click(function(){
$("#error").html("");
var name= escapeHtml($("#name").val())
if(name.length<3){
$("#error").html("Name is too short!");
return
}
console.log("join started")
chat = new WebSocket("ws://"+window.location.host+":8089/ws");
chat.onopen = function(evt) {
chat.send(name); //sending the chat name
$("#phase1").animate({opacity:0},500,"linear",function(){
$("#phase1").css({display:"none"})
$("#phase2").css({opacity:1})
$("#msg").focus()
})
};
chat.onerror = function(evt) {
console.log("Websocket Error:",evt)
};
chat.onclose = function(evt) {
console.log("chat closing")
$("#phase1").stop().css({display:"block"}).animate({opacity:1},500)
$("#phase2").stop().animate({opacity:0})
$("#error").html("That name was already used!")
};
chat.onmessage = function(evt) {
$("#messages").append(evt.data).scrollTop(9e6)
};
})
$("#msg").keypress(function(evt){
if(evt.originalEvent.keyCode==13 && !evt.originalEvent.shiftKey){
$("#send").trigger("click")
evt.preventDefault();
// submit name
}
})
$("#send").click(function(){
chat.send(escapeHtml($("#msg").val()));
$("#msg").val("");
})
//helper function for escaping HTML
var entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': '&quot;',
"'": '&#39;',
"/": '&#x2F;',
"\n": '<BR/>'
};
</script>
</div>
</body>
</html>

4
static/jquery.js vendored Normal file

File diff suppressed because one or more lines are too long

169
static/justchat.html Normal file
View File

@ -0,0 +1,169 @@
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Movie Night! - Chat</title>
<style>
body {
margin:0;
padding:0;
background:#000;
}
html, body, #messages, #phase2 {
height:100%;
}
video {
width:100%;
}
#chatbox {
width: 100%;
height:85%;
}
#messages {
border: 1px solid #666;
width: 95%;
overflow: auto;
color: #f4f4f4;
}
#msg {
width: 94%;
height: 3em;
}
#error {
color: #f00;
padding: 5px;
font-weight: bold;
}
span.name {
font-weight:bold;
}
span.cmdme {
font-style: italic;
}
span.msg {
font-style: normal;
color: #cfccd1;
}
span.svmsg {
font-style: italic;
color: #ea6260;
}
.announcement {
font-weight: bold;
color: #ea6260;
text-align: center;
width: 100%;
margin-top: 10px;
margin-bottom: 10px;
border-top: 3px solid red;
border-bottom: 3px solid red;
}
</style>
<script src="/static/jquery.js"></script>
<script>
function escapeHtml(string) {
return string
//return String(string).replace(/[&<>"'\/]/g, function (s) {
// return entityMap[s];
//});
}
//helper function for debugging purposes
function toHex(str) {
var result = '';
for (var i=0; i<str.length; i++) {
result += ("0"+str.charCodeAt(i).toString(16)).slice(-2)+" ";
}
return result.toUpperCase();
}
</script>
</head>
<body>
<div id="chatbox">
<div id="phase1">
<p style="color:#e5e0e5">Please enter your name to Join the chat</P>
<input id="name">
<button id="join">Join</button>
</div>
<div id="error"></div>
<div id="phase2" style="opacity:0">
<div id="messages"></div>
<textarea id="msg"></textarea>
<br/><button id="send">Send</button>
</div>
<script>
$("INPUT").val("")
$("#name").keypress(function(evt){
if(evt.originalEvent.keyCode==13){
$("#join").trigger("click")
//submit name
}
})
//handling the start of the chat
$("#join").click(function(){
$("#error").html("");
var name= escapeHtml($("#name").val())
if(name.length<3){
$("#error").html("Name is too short!");
return
}
console.log("join started")
chat = new WebSocket("ws://"+window.location.host+":8089/ws");
chat.onopen = function(evt) {
chat.send(name); //sending the chat name
$("#phase1").animate({opacity:0},500,"linear",function(){
$("#phase1").css({display:"none"})
$("#phase2").css({opacity:1})
$("#msg").focus()
})
};
chat.onerror = function(evt) {
console.log("Websocket Error:",evt)
};
chat.onclose = function(evt) {
console.log("chat closing")
$("#phase1").stop().css({display:"block"}).animate({opacity:1},500)
$("#phase2").stop().animate({opacity:0})
$("#error").html("That name was already used!")
};
chat.onmessage = function(evt) {
$("#messages").append(evt.data).scrollTop(9e6)
};
})
$("#msg").keypress(function(evt){
if(evt.originalEvent.keyCode==13 && !evt.originalEvent.shiftKey){
$("#send").trigger("click")
evt.preventDefault();
// submit name
}
})
$("#send").click(function(){
chat.send(escapeHtml($("#msg").val()));
$("#msg").val("");
})
//helper function for escaping HTML
var entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': '&quot;',
"'": '&#39;',
"/": '&#x2F;',
"\n": '<BR/>'
};
</script>
</div>
</body>
</html>

48
static/justvideo.html Normal file
View File

@ -0,0 +1,48 @@
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Movie Night!</title>
<style>
body {
margin:0;
padding:0;
background:#000;
}
html, body{
height:100%;
}
video {
width:100%;
height:100%;
}
</style>
<script src="/static/jquery.js"></script>
<script src="/static/flv.min.js"></script>
<script>
function initPlayer() {
if (flvjs.isSupported()) {
var videoElement = document.getElementById('videoElement');
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: '/live'
});
flvPlayer.attachMediaElement(videoElement);
flvPlayer.load();
flvPlayer.play();
}
}
</script>
</head>
<body>
<video id="videoElement" controls autoplay x5-video-player-type="h5" x5-video-player-fullscreen="true" playsinline webkit-playsinline>
Your browser is too old and doesn't support HTML5 video.
</video>
<script>initPlayer();</script>
</body>
</html>

15
static/ractive.min.js vendored Normal file

File diff suppressed because one or more lines are too long