From: Thomas Karpiniec Date: Tue, 22 Oct 2024 13:22:38 +0000 (+1100) Subject: Simplify package names X-Git-Tag: v1.0.0~7 X-Git-Url: https://code.octet-stream.net/broadcaster/commitdiff_plain/7b615b3c71825b5b229b78509a16db37e1d3f38d Simplify package names --- diff --git a/broadcaster-radio/config.go b/broadcaster-radio/config.go deleted file mode 100644 index 6837b7d..0000000 --- a/broadcaster-radio/config.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "errors" - "log" - "os" - "strings" - - "github.com/BurntSushi/toml" -) - -type RadioConfig struct { - GpioDevice string - PTTPin int - COSPin int - ServerURL string - Token string - CachePath string - TimeZone string -} - -func NewRadioConfig() RadioConfig { - return RadioConfig{ - GpioDevice: "gpiochip0", - PTTPin: -1, - COSPin: -1, - ServerURL: "", - Token: "", - CachePath: "", - TimeZone: "Australia/Hobart", - } -} - -func (c *RadioConfig) LoadFromFile(path string) { - _, err := toml.DecodeFile(path, &c) - if err != nil { - log.Fatal("could not read config file for reading at path:", path, err) - } - err = c.Validate() - if err != nil { - log.Fatal(err) - } - c.ApplyDefaults() -} - -func (c *RadioConfig) Validate() error { - if c.ServerURL == "" { - return errors.New("ServerURL must be provided in the configuration") - } - if c.Token == "" { - return errors.New("Token must be provided in the configuration") - } - return nil -} - -func (c *RadioConfig) ApplyDefaults() { - if c.CachePath == "" { - dir, err := os.MkdirTemp("", "broadcast") - if err != nil { - log.Fatal(err) - } - c.CachePath = dir - } -} - -func (c *RadioConfig) WebsocketURL() string { - addr := strings.Replace(c.ServerURL, "https://", "wss://", -1) - addr = strings.Replace(addr, "http://", "ws://", -1) - return addr + "/radio-ws" -} diff --git a/broadcaster-radio/files_machine.go b/broadcaster-radio/files_machine.go deleted file mode 100644 index 143397a..0000000 --- a/broadcaster-radio/files_machine.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "code.octet-stream.net/broadcaster/internal/protocol" - "crypto/sha256" - "encoding/hex" - "io" - "log" - "net/http" - "os" - "path/filepath" -) - -type FilesMachine struct { - specs []protocol.FileSpec - cachePath string - missing []string -} - -func NewFilesMachine(cachePath string) FilesMachine { - if err := os.MkdirAll(cachePath, 0750); err != nil { - log.Fatal(err) - } - return FilesMachine{ - cachePath: cachePath, - } -} - -func (m *FilesMachine) UpdateSpecs(specs []protocol.FileSpec) { - m.specs = specs - m.RefreshMissing() -} - -func (m *FilesMachine) RefreshMissing() { - // Delete any files in the cache dir who are not in the spec - entries, err := os.ReadDir(m.cachePath) - if err != nil { - log.Fatal(err) - } - okay := make([]string, 0) - for _, file := range entries { - hash := "" - for _, spec := range m.specs { - if file.Name() == spec.Name { - hash = spec.Hash - break - } - } - // if we have an extraneous file, delete it - if hash == "" { - log.Println("Deleting extraneous cached audio file:", file.Name()) - os.Remove(filepath.Join(m.cachePath, file.Name())) - continue - } - // if the hash isn't right, delete it - f, err := os.Open(filepath.Join(m.cachePath, file.Name())) - if err != nil { - log.Fatal(err) - } - hasher := sha256.New() - io.Copy(hasher, f) - if hex.EncodeToString(hasher.Sum(nil)) != hash { - log.Println("Deleting cached audio file with incorrect hash:", file.Name()) - os.Remove(filepath.Join(m.cachePath, file.Name())) - } else { - okay = append(okay, file.Name()) - } - } - m.missing = nil - for _, spec := range m.specs { - missing := true - for _, file := range okay { - if spec.Name == file { - missing = false - } - } - if missing { - m.missing = append(m.missing, spec.Name) - } - } - if len(m.missing) > 1 { - log.Println(len(m.missing), "missing files") - } else if len(m.missing) == 1 { - log.Println("1 missing file") - } else { - log.Println("All files are in sync with server") - } - statusCollector.FilesInSync <- len(m.missing) == 0 -} - -func (m *FilesMachine) IsCacheComplete() bool { - return len(m.missing) == 0 -} - -func (m *FilesMachine) NextFile() string { - next, remainder := m.missing[0], m.missing[1:] - m.missing = remainder - return next -} - -func (m *FilesMachine) DownloadSingle(filename string, downloadResult chan<- error) { - log.Println("Downloading", filename) - out, err := os.Create(filepath.Join(m.cachePath, filename)) - if err != nil { - downloadResult <- err - return - } - defer out.Close() - resp, err := http.Get(config.ServerURL + "/file-downloads/" + filename) - if err != nil { - downloadResult <- err - return - } - defer resp.Body.Close() - _, err = io.Copy(out, resp.Body) - downloadResult <- err -} diff --git a/broadcaster-radio/gpio.go b/broadcaster-radio/gpio.go deleted file mode 100644 index 69807ac..0000000 --- a/broadcaster-radio/gpio.go +++ /dev/null @@ -1,123 +0,0 @@ -package main - -import ( - gpio "github.com/warthog618/go-gpiocdev" - "github.com/warthog618/go-gpiocdev/device/rpi" - "log" - "strconv" -) - -type PTT interface { - EngagePTT() - DisengagePTT() -} - -type COS interface { - WaitForChannelClear() - COSValue() bool -} - -var ptt PTT = &DefaultPTT{} -var cos COS = &DefaultCOS{} - -type PiPTT struct { - pttLine *gpio.Line -} - -type PiCOS struct { - cosLine *gpio.Line - clearWait chan bool -} - -func InitRaspberryPiPTT(pttNum int, chipName string) { - pttPin, err := rpi.Pin("GPIO" + strconv.Itoa(pttNum)) - if err != nil { - log.Fatal("invalid PTT pin configured", ptt) - } - pttLine, err := gpio.RequestLine(chipName, pttPin, gpio.AsOutput(0)) - if err != nil { - log.Fatal("unable to open requested pin for PTT GPIO:", ptt, ". Are you running as root?") - } - ptt = &PiPTT{ - pttLine: pttLine, - } -} - -func InitRaspberryPiCOS(cosNum int, chipName string) { - var piCOS PiCOS - piCOS.clearWait = make(chan bool) - cosPin, err := rpi.Pin("GPIO" + strconv.Itoa(cosNum)) - if err != nil { - log.Fatal("invalid COS Pin configured", cos) - } - cosHandler := func(event gpio.LineEvent) { - if event.Type == gpio.LineEventFallingEdge { - log.Println("COS: channel clear") - close(piCOS.clearWait) - piCOS.clearWait = make(chan bool) - statusCollector.COS <- false - } - if event.Type == gpio.LineEventRisingEdge { - log.Println("COS: channel in use") - statusCollector.COS <- true - } - } - cosLine, err := gpio.RequestLine(chipName, cosPin, gpio.AsInput, gpio.WithBothEdges, gpio.WithEventHandler(cosHandler)) - if err != nil { - log.Fatal("unable to open requested pin for COS GPIO:", cos, ". Are you running as root?") - } - piCOS.cosLine = cosLine - cos = &piCOS -} - -func (g *PiCOS) COSValue() bool { - val, err := g.cosLine.Value() - if err != nil { - log.Fatal("Unable to read COS value") - } - return val != 0 -} - -func (g *PiCOS) WaitForChannelClear() { - ch := g.clearWait - val, err := g.cosLine.Value() - if err != nil || val == 0 { - return - } - // wait for close - <-ch -} - -func (g *PiPTT) EngagePTT() { - log.Println("PTT: on") - g.pttLine.SetValue(1) - statusCollector.PTT <- true -} - -func (g *PiPTT) DisengagePTT() { - log.Println("PTT: off") - g.pttLine.SetValue(0) - statusCollector.PTT <- false -} - -type DefaultPTT struct { -} - -func (g *DefaultPTT) EngagePTT() { - statusCollector.PTT <- true -} - -func (g *DefaultPTT) DisengagePTT() { - statusCollector.PTT <- false -} - -type DefaultCOS struct { -} - -func (g *DefaultCOS) WaitForChannelClear() { - log.Println("Assuming channel is clear since COS GPIO is not configured") -} - -func (g *DefaultCOS) COSValue() bool { - return false -} diff --git a/broadcaster-radio/main.go b/broadcaster-radio/main.go deleted file mode 100644 index 6e31772..0000000 --- a/broadcaster-radio/main.go +++ /dev/null @@ -1,334 +0,0 @@ -package main - -import ( - "code.octet-stream.net/broadcaster/internal/protocol" - "encoding/json" - "flag" - "fmt" - "github.com/gopxl/beep/v2" - "github.com/gopxl/beep/v2/mp3" - "github.com/gopxl/beep/v2/speaker" - "github.com/gopxl/beep/v2/wav" - "golang.org/x/net/websocket" - "log" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" -) - -const version = "v1.0.0" -const sampleRate = 44100 - -var config RadioConfig = NewRadioConfig() - -func main() { - configFlag := flag.String("c", "", "path to configuration file") - versionFlag := flag.Bool("v", false, "print version and exit") - flag.Parse() - - if *versionFlag { - fmt.Println("Broadcaster Radio", version) - os.Exit(0) - } - if *configFlag == "" { - log.Fatal("must specify a configuration file with -c") - } - - log.Println("Broadcaster Radio", version, "starting up") - config.LoadFromFile(*configFlag) - statusCollector.Config <- config - - playbackSampleRate := beep.SampleRate(sampleRate) - speaker.Init(playbackSampleRate, playbackSampleRate.N(time.Second/10)) - - if config.PTTPin != -1 { - InitRaspberryPiPTT(config.PTTPin, config.GpioDevice) - } - if config.COSPin != -1 { - InitRaspberryPiCOS(config.COSPin, config.GpioDevice) - } - - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - go func() { - sig := <-sig - log.Println("Radio shutting down due to signal", sig) - // Make sure we always stop PTT when program ends - ptt.DisengagePTT() - os.Exit(0) - }() - - log.Println("Config checks out, radio coming online") - log.Println("Audio file cache:", config.CachePath) - - fileSpecChan := make(chan []protocol.FileSpec) - go filesWorker(config.CachePath, fileSpecChan) - - stop := make(chan bool) - playlistSpecChan := make(chan []protocol.PlaylistSpec) - go playlistWorker(playlistSpecChan, stop) - - for { - runWebsocket(fileSpecChan, playlistSpecChan, stop) - log.Println("Websocket failed, retry in 30 seconds") - time.Sleep(time.Second * time.Duration(30)) - } -} - -func runWebsocket(fileSpecChan chan []protocol.FileSpec, playlistSpecChan chan []protocol.PlaylistSpec, stop chan bool) error { - log.Println("Establishing websocket connection to:", config.WebsocketURL()) - ws, err := websocket.Dial(config.WebsocketURL(), "", config.ServerURL) - if err != nil { - return err - } - - auth := protocol.AuthenticateMessage{ - T: "authenticate", - Token: config.Token, - } - msg, _ := json.Marshal(auth) - - if _, err := ws.Write(msg); err != nil { - log.Fatal(err) - } - statusCollector.Websocket <- ws - - buf := make([]byte, 16384) - badRead := false - for { - n, err := ws.Read(buf) - if err != nil { - log.Println("Lost websocket to server") - return err - } - // Ignore any massively oversize messages - if n == len(buf) { - badRead = true - continue - } else if badRead { - badRead = false - continue - } - - t, msg, err := protocol.ParseMessage(buf[:n]) - if err != nil { - log.Println("Message parse error", err) - return err - } - - if t == protocol.FilesType { - filesMsg := msg.(protocol.FilesMessage) - fileSpecChan <- filesMsg.Files - } - - if t == protocol.PlaylistsType { - playlistsMsg := msg.(protocol.PlaylistsMessage) - playlistSpecChan <- playlistsMsg.Playlists - } - - if t == protocol.StopType { - stop <- true - } - } -} - -func filesWorker(cachePath string, ch chan []protocol.FileSpec) { - machine := NewFilesMachine(cachePath) - isDownloading := false - downloadResult := make(chan error) - var timer *time.Timer - - for { - var timerCh <-chan time.Time = nil - if timer != nil { - timerCh = timer.C - } - doNext := false - select { - case specs := <-ch: - log.Println("Received new file specs", specs) - machine.UpdateSpecs(specs) - doNext = true - timer = nil - case err := <-downloadResult: - isDownloading = false - machine.RefreshMissing() - if err != nil { - log.Println(err) - if !machine.IsCacheComplete() { - timer = time.NewTimer(30 * time.Second) - } - } else { - if !machine.IsCacheComplete() { - timer = time.NewTimer(10 * time.Millisecond) - } - } - case <-timerCh: - doNext = true - timer = nil - } - - if doNext && !isDownloading && !machine.IsCacheComplete() { - next := machine.NextFile() - isDownloading = true - go machine.DownloadSingle(next, downloadResult) - } - } -} - -func playlistWorker(ch <-chan []protocol.PlaylistSpec, stop <-chan bool) { - var specs []protocol.PlaylistSpec - isPlaying := false - playbackFinished := make(chan error) - cancel := make(chan bool) - nextId := 0 - var timer *time.Timer - - for { - var timerCh <-chan time.Time = nil - if timer != nil { - timerCh = timer.C - } - doNext := false - select { - case specs = <-ch: - log.Println("Received new playlist specs", specs) - doNext = true - case <-playbackFinished: - isPlaying = false - doNext = true - cancel = make(chan bool) - case <-timerCh: - timer = nil - isPlaying = true - for _, v := range specs { - if v.Id == nextId { - go playPlaylist(v, playbackFinished, cancel) - } - } - case <-stop: - if isPlaying { - log.Println("Cancelling playlist in progress") - cancel <- true - } - } - - if doNext && !isPlaying { - timer = nil - found := false - loc, err := time.LoadLocation(config.TimeZone) - if err != nil { - log.Fatal(err) - } - var soonestTime time.Time - for _, v := range specs { - t, err := time.ParseInLocation(protocol.StartTimeFormat, v.StartTime, loc) - if err != nil { - log.Println("Error parsing start time", err) - continue - } - if t.Before(time.Now()) { - continue - } - if !found || t.Before(soonestTime) { - soonestTime = t - found = true - nextId = v.Id - } - } - if found { - duration := soonestTime.Sub(time.Now()) - log.Println("Next playlist will be id", nextId, "in", duration.Seconds(), "seconds") - timer = time.NewTimer(duration) - } else { - log.Println("No future playlists") - } - } - } -} - -func playPlaylist(playlist protocol.PlaylistSpec, playbackFinished chan<- error, cancel <-chan bool) { - startTime := time.Now() - log.Println("Beginning playback of playlist", playlist.Name) -entries: - for _, p := range playlist.Entries { - // delay - var duration time.Duration - if p.IsRelative { - duration = time.Second * time.Duration(p.DelaySeconds) - } else { - duration = time.Until(startTime.Add(time.Second * time.Duration(p.DelaySeconds))) - } - statusCollector.PlaylistBeginDelay <- BeginDelayStatus{ - Playlist: playlist.Name, - Seconds: int(duration.Seconds()), - Filename: p.Filename, - } - select { - case <-time.After(duration): - case <-cancel: - break entries - } - - statusCollector.PlaylistBeginWaitForChannel <- BeginWaitForChannelStatus{ - Playlist: playlist.Name, - Filename: p.Filename, - } - cos.WaitForChannelClear() - - // then play - statusCollector.PlaylistBeginPlayback <- BeginPlaybackStatus{ - Playlist: playlist.Name, - Filename: p.Filename, - } - ptt.EngagePTT() - f, err := os.Open(filepath.Join(config.CachePath, p.Filename)) - if err != nil { - log.Println("Couldn't open file for playlist", p.Filename) - continue - } - log.Println("Playing file", p.Filename) - l := strings.ToLower(p.Filename) - var streamer beep.StreamSeekCloser - var format beep.Format - if strings.HasSuffix(l, ".mp3") { - streamer, format, err = mp3.Decode(f) - } else if strings.HasSuffix(l, ".wav") { - streamer, format, err = wav.Decode(f) - } else { - log.Println("Unrecognised file extension (.wav and .mp3 supported), moving on") - } - if err != nil { - log.Println("Could not decode media file", err) - continue - } - defer streamer.Close() - - done := make(chan bool) - - if format.SampleRate != sampleRate { - resampled := beep.Resample(4, format.SampleRate, sampleRate, streamer) - speaker.Play(beep.Seq(resampled, beep.Callback(func() { - done <- true - }))) - } else { - speaker.Play(beep.Seq(streamer, beep.Callback(func() { - done <- true - }))) - } - - select { - case <-done: - case <-cancel: - ptt.DisengagePTT() - break entries - } - ptt.DisengagePTT() - } - log.Println("Playlist finished", playlist.Name) - statusCollector.PlaylistBeginIdle <- true - playbackFinished <- nil -} diff --git a/broadcaster-radio/status.go b/broadcaster-radio/status.go deleted file mode 100644 index 7836815..0000000 --- a/broadcaster-radio/status.go +++ /dev/null @@ -1,140 +0,0 @@ -package main - -import ( - "code.octet-stream.net/broadcaster/internal/protocol" - "encoding/json" - "golang.org/x/net/websocket" - "time" -) - -type BeginDelayStatus struct { - Playlist string - Seconds int - Filename string -} - -type BeginWaitForChannelStatus struct { - Playlist string - Filename string -} - -type BeginPlaybackStatus struct { - Playlist string - Filename string -} - -type StatusCollector struct { - Websocket chan *websocket.Conn - PlaylistBeginIdle chan bool - PlaylistBeginDelay chan BeginDelayStatus - PlaylistBeginWaitForChannel chan BeginWaitForChannelStatus - PlaylistBeginPlayback chan BeginPlaybackStatus - PTT chan bool - COS chan bool - Config chan RadioConfig - FilesInSync chan bool -} - -var statusCollector = NewStatusCollector() - -func NewStatusCollector() StatusCollector { - sc := StatusCollector{ - Websocket: make(chan *websocket.Conn), - PlaylistBeginIdle: make(chan bool), - PlaylistBeginDelay: make(chan BeginDelayStatus), - PlaylistBeginWaitForChannel: make(chan BeginWaitForChannelStatus), - PlaylistBeginPlayback: make(chan BeginPlaybackStatus), - PTT: make(chan bool), - COS: make(chan bool), - Config: make(chan RadioConfig), - FilesInSync: make(chan bool), - } - go runStatusCollector(sc) - return sc -} - -func runStatusCollector(sc StatusCollector) { - config := <-sc.Config - var msg protocol.StatusMessage - var lastSent protocol.StatusMessage - msg.T = protocol.StatusType - msg.TimeZone = config.TimeZone - msg.Status = protocol.StatusIdle - var ws *websocket.Conn - // Go 1.23: no need to stop tickers when finished - var ticker = time.NewTicker(time.Second * time.Duration(30)) - - for { - select { - case newWebsocket := <-sc.Websocket: - ws = newWebsocket - case <-ticker.C: - // should always be ticking at 1 second for these - if msg.Status == protocol.StatusDelay { - if msg.DelaySecondsRemaining > 0 { - msg.DelaySecondsRemaining -= 1 - } - } - if msg.Status == protocol.StatusChannelInUse { - msg.WaitingForChannelSeconds += 1 - } - if msg.Status == protocol.StatusPlaying { - msg.PlaybackSecondsElapsed += 1 - } - case <-sc.PlaylistBeginIdle: - msg.Status = protocol.StatusIdle - msg.DelaySecondsRemaining = 0 - msg.WaitingForChannelSeconds = 0 - msg.PlaybackSecondsElapsed = 0 - msg.Playlist = "" - msg.Filename = "" - // Update things more slowly when nothing's playing - ticker = time.NewTicker(time.Second * time.Duration(30)) - case delay := <-sc.PlaylistBeginDelay: - msg.Status = protocol.StatusDelay - msg.DelaySecondsRemaining = delay.Seconds - msg.WaitingForChannelSeconds = 0 - msg.PlaybackSecondsElapsed = 0 - msg.Playlist = delay.Playlist - msg.Filename = delay.Filename - // Align ticker with start of state change, make sure it's faster - ticker = time.NewTicker(time.Second * time.Duration(1)) - case wait := <-sc.PlaylistBeginWaitForChannel: - msg.Status = protocol.StatusChannelInUse - msg.DelaySecondsRemaining = 0 - msg.WaitingForChannelSeconds = 0 - msg.PlaybackSecondsElapsed = 0 - msg.Playlist = wait.Playlist - msg.Filename = wait.Filename - ticker = time.NewTicker(time.Second * time.Duration(1)) - case playback := <-sc.PlaylistBeginPlayback: - msg.Status = protocol.StatusPlaying - msg.DelaySecondsRemaining = 0 - msg.WaitingForChannelSeconds = 0 - msg.PlaybackSecondsElapsed = 0 - msg.Playlist = playback.Playlist - msg.Filename = playback.Filename - ticker = time.NewTicker(time.Second * time.Duration(1)) - case ptt := <-sc.PTT: - msg.PTT = ptt - case cos := <-sc.COS: - msg.COS = cos - case inSync := <-sc.FilesInSync: - msg.FilesInSync = inSync - } - msg.LocalTime = time.Now().Format(protocol.LocalTimeFormat) - msg.COS = cos.COSValue() - - if msg == lastSent { - continue - } - if ws != nil { - msgJson, _ := json.Marshal(msg) - if _, err := ws.Write(msgJson); err != nil { - // If websocket has failed, wait 'til we get a new one - ws = nil - } - lastSent = msg - } - } -} diff --git a/broadcaster-server/command.go b/broadcaster-server/command.go deleted file mode 100644 index 346e842..0000000 --- a/broadcaster-server/command.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "code.octet-stream.net/broadcaster/internal/protocol" - "encoding/json" - "golang.org/x/net/websocket" - "sync" -) - -type CommandRouter struct { - connsMutex sync.Mutex - conns map[int]*websocket.Conn -} - -var commandRouter CommandRouter - -func InitCommandRouter() { - commandRouter.conns = make(map[int]*websocket.Conn) -} - -func (c *CommandRouter) AddWebsocket(radioId int, ws *websocket.Conn) { - c.connsMutex.Lock() - defer c.connsMutex.Unlock() - c.conns[radioId] = ws -} - -func (c *CommandRouter) RemoveWebsocket(ws *websocket.Conn) { - c.connsMutex.Lock() - defer c.connsMutex.Unlock() - key := -1 - for k, v := range c.conns { - if v == ws { - key = k - } - } - if key != -1 { - delete(c.conns, key) - } - -} - -func (c *CommandRouter) Stop(radioId int) { - c.connsMutex.Lock() - defer c.connsMutex.Unlock() - ws := c.conns[radioId] - if ws != nil { - stop := protocol.StopMessage{ - T: protocol.StopType, - } - msg, _ := json.Marshal(stop) - ws.Write(msg) - } -} diff --git a/broadcaster-server/config.go b/broadcaster-server/config.go deleted file mode 100644 index 4a4866c..0000000 --- a/broadcaster-server/config.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "errors" - "log" - - "github.com/BurntSushi/toml" -) - -type ServerConfig struct { - BindAddress string - Port int - SqliteDB string - AudioFilesPath string -} - -func NewServerConfig() ServerConfig { - return ServerConfig{ - BindAddress: "0.0.0.0", - Port: 55134, - SqliteDB: "", - AudioFilesPath: "", - } -} - -func (c *ServerConfig) LoadFromFile(path string) { - _, err := toml.DecodeFile(path, &c) - if err != nil { - log.Fatal("could not read config file for reading at path:", path, err) - } - err = c.Validate() - if err != nil { - log.Fatal(err) - } -} - -func (c *ServerConfig) Validate() error { - if c.SqliteDB == "" { - return errors.New("Configuration must provide SqliteDB") - } - if c.AudioFilesPath == "" { - return errors.New("Configuration must provide AudioFilesPath") - } - return nil -} diff --git a/broadcaster-server/database.go b/broadcaster-server/database.go deleted file mode 100644 index 1312208..0000000 --- a/broadcaster-server/database.go +++ /dev/null @@ -1,247 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "log" - _ "modernc.org/sqlite" - "time" -) - -type Database struct { - sqldb *sql.DB -} - -var db Database - -func InitDatabase() { - sqldb, err := sql.Open("sqlite", config.SqliteDB) - if err != nil { - log.Fatal(err) - } - db.sqldb = sqldb - - _, err = db.sqldb.Exec("PRAGMA journal_mode = WAL") - if err != nil { - log.Fatal(err) - } - - _, err = db.sqldb.Exec("PRAGMA foreign_keys = ON") - if err != nil { - log.Fatal(err) - } - - _, err = db.sqldb.Exec("PRAGMA busy_timeout = 5000") - if err != nil { - log.Fatal(err) - } - - sqlStmt := ` - CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT, username TEXT, created TIMESTAMP, expiry TIMESTAMP); - CREATE TABLE IF NOT EXISTS playlists (id INTEGER PRIMARY KEY AUTOINCREMENT, enabled INTEGER, name TEXT, start_time TEXT); - CREATE TABLE IF NOT EXISTS playlist_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id INTEGER, position INTEGER, filename TEXT, delay_seconds INTEGER, is_relative INTEGER, CONSTRAINT fk_playlists FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE); - CREATE TABLE IF NOT EXISTS radios (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, token TEXT); - CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, is_admin INTEGER); - ` - _, err = db.sqldb.Exec(sqlStmt) - if err != nil { - log.Printf("%q: %s\n", err, sqlStmt) - return - } -} - -func (d *Database) CloseDatabase() { - d.sqldb.Close() -} - -func (d *Database) InsertSession(user string, token string, expiry time.Time) { - _, err := d.sqldb.Exec("INSERT INTO sessions (token, username, created, expiry) values (?, ?, CURRENT_TIMESTAMP, ?)", token, user, expiry) - if err != nil { - log.Fatal(err) - } -} - -func (d *Database) GetUserNameForSession(token string) (string, error) { - var username string - err := d.sqldb.QueryRow("SELECT username FROM sessions WHERE token = ? AND expiry > CURRENT_TIMESTAMP", token).Scan(&username) - if err != nil { - return "", errors.New("no matching token") - } - return username, nil -} - -func (d *Database) GetUser(username string) (User, error) { - var user User - err := d.sqldb.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", username).Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin) - if err != nil { - return User{}, errors.New("no user with that username") - } - return user, nil -} - -func (d *Database) GetUsers() []User { - ret := make([]User, 0) - rows, err := d.sqldb.Query("SELECT id, username, password_hash, is_admin FROM users ORDER BY username ASC") - if err != nil { - return ret - } - defer rows.Close() - for rows.Next() { - var u User - if err := rows.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin); err != nil { - return ret - } - ret = append(ret, u) - } - return ret -} - -func (d *Database) SetUserPassword(username string, passwordHash string) { - d.sqldb.Exec("UPDATE users SET password_hash = ? WHERE username = ?", passwordHash, username) -} - -func (d *Database) ClearOtherSessions(username string, token string) { - d.sqldb.Exec("DELETE FROM sessions WHERE username = ? AND token != ?", username, token) -} - -func (d *Database) SetUserIsAdmin(username string, isAdmin bool) { - d.sqldb.Exec("UPDATE users SET is_admin = ? WHERE username = ?", isAdmin, username) -} - -func (d *Database) CreateUser(user User) error { - _, err := d.sqldb.Exec("INSERT INTO users (username, password_hash, is_admin) values (?, ?, ?)", user.Username, user.PasswordHash, user.IsAdmin) - return err -} - -func (d *Database) DeleteUser(username string) error { - _, err := d.sqldb.Exec("DELETE FROM users WHERE username = ?", username) - return err -} - -func (d *Database) CreatePlaylist(playlist Playlist) int { - var id int - tx, _ := d.sqldb.Begin() - _, err := tx.Exec("INSERT INTO playlists (enabled, name, start_time) values (?, ?, ?)", playlist.Enabled, playlist.Name, playlist.StartTime) - if err != nil { - log.Fatal(err) - } - err = tx.QueryRow("SELECT last_insert_rowid()").Scan(&id) - if err != nil { - log.Fatal(err) - } - err = tx.Commit() - if err != nil { - log.Fatal(err) - } - return id -} - -func (d *Database) DeletePlaylist(playlistId int) { - d.sqldb.Exec("DELETE FROM playlists WHERE id = ?", playlistId) -} - -func (d *Database) GetPlaylists() []Playlist { - ret := make([]Playlist, 0) - rows, err := d.sqldb.Query("SELECT id, enabled, name, start_time FROM playlists ORDER BY id ASC") - if err != nil { - return ret - } - defer rows.Close() - for rows.Next() { - var p Playlist - if err := rows.Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime); err != nil { - return ret - } - ret = append(ret, p) - } - return ret -} - -func (d *Database) GetPlaylist(playlistId int) (Playlist, error) { - var p Playlist - err := d.sqldb.QueryRow("SELECT id, enabled, name, start_time FROM playlists WHERE id = ?", playlistId).Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime) - if err != nil { - return p, err - } - return p, nil -} - -func (d *Database) UpdatePlaylist(playlist Playlist) { - d.sqldb.Exec("UPDATE playlists SET enabled = ?, name = ?, start_time = ? WHERE id = ?", playlist.Enabled, playlist.Name, playlist.StartTime, playlist.Id) -} - -func (d *Database) SetEntriesForPlaylist(entries []PlaylistEntry, playlistId int) { - tx, _ := d.sqldb.Begin() - _, err := tx.Exec("DELETE FROM playlist_entries WHERE playlist_id = ?", playlistId) - for _, e := range entries { - _, err = tx.Exec("INSERT INTO playlist_entries (playlist_id, position, filename, delay_seconds, is_relative) values (?, ?, ?, ?, ?)", playlistId, e.Position, e.Filename, e.DelaySeconds, e.IsRelative) - if err != nil { - log.Fatal(err) - } - } - tx.Commit() // ignore errors -} - -func (d *Database) GetEntriesForPlaylist(playlistId int) []PlaylistEntry { - ret := make([]PlaylistEntry, 0) - rows, err := d.sqldb.Query("SELECT id, position, filename, delay_seconds, is_relative FROM playlist_entries WHERE playlist_id = ? ORDER by position ASC", playlistId) - if err != nil { - return ret - } - defer rows.Close() - for rows.Next() { - var entry PlaylistEntry - if err := rows.Scan(&entry.Id, &entry.Position, &entry.Filename, &entry.DelaySeconds, &entry.IsRelative); err != nil { - return ret - } - ret = append(ret, entry) - } - return ret -} - -func (d *Database) GetRadio(radioId int) (Radio, error) { - var r Radio - err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE id = ?", radioId).Scan(&r.Id, &r.Name, &r.Token) - if err != nil { - return r, err - } - return r, nil -} - -func (d *Database) GetRadioByToken(token string) (Radio, error) { - var r Radio - err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE token = ?", token).Scan(&r.Id, &r.Name, &r.Token) - if err != nil { - return r, err - } - return r, nil -} - -func (d *Database) GetRadios() []Radio { - ret := make([]Radio, 0) - rows, err := d.sqldb.Query("SELECT id, name, token FROM radios ORDER BY id ASC") - if err != nil { - return ret - } - defer rows.Close() - for rows.Next() { - var r Radio - if err := rows.Scan(&r.Id, &r.Name, &r.Token); err != nil { - return ret - } - ret = append(ret, r) - } - return ret -} - -func (d *Database) DeleteRadio(radioId int) { - d.sqldb.Exec("DELETE FROM radios WHERE id = ?", radioId) -} - -func (d *Database) CreateRadio(radio Radio) { - d.sqldb.Exec("INSERT INTO radios (name, token) values (?, ?)", radio.Name, radio.Token) -} - -func (d *Database) UpdateRadio(radio Radio) { - d.sqldb.Exec("UPDATE radios SET name = ?, token = ? WHERE id = ?", radio.Name, radio.Token, radio.Id) -} diff --git a/broadcaster-server/files.go b/broadcaster-server/files.go deleted file mode 100644 index b452997..0000000 --- a/broadcaster-server/files.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "io" - "log" - "os" - "path/filepath" - "sync" -) - -type FileSpec struct { - Name string - Hash string -} - -type AudioFiles struct { - path string - list []FileSpec - changeWait chan bool - filesMutex sync.Mutex -} - -var files AudioFiles - -func InitAudioFiles(path string) { - files.changeWait = make(chan bool) - files.path = path - log.Println("initing audio files") - files.Refresh() - log.Println("done") -} - -func (r *AudioFiles) Refresh() { - entries, err := os.ReadDir(r.path) - if err != nil { - log.Println("couldn't read dir", r.path) - return - } - r.filesMutex.Lock() - defer r.filesMutex.Unlock() - r.list = nil - for _, file := range entries { - f, err := os.Open(filepath.Join(r.path, file.Name())) - if err != nil { - log.Println("couldn't open", file.Name()) - return - } - hash := sha256.New() - io.Copy(hash, f) - r.list = append(r.list, FileSpec{Name: file.Name(), Hash: hex.EncodeToString(hash.Sum(nil))}) - } - log.Println("Files updated", r.list) - close(files.changeWait) - files.changeWait = make(chan bool) -} - -func (r *AudioFiles) Path() string { - return r.path -} - -func (r *AudioFiles) Files() []FileSpec { - r.filesMutex.Lock() - defer r.filesMutex.Unlock() - return r.list -} - -func (r *AudioFiles) Delete(filename string) { - path := filepath.Join(r.path, filepath.Base(filename)) - if filepath.Clean(r.path) != filepath.Clean(path) { - os.Remove(path) - r.Refresh() - } -} - -func (r *AudioFiles) WatchForChanges() ([]FileSpec, chan bool) { - r.filesMutex.Lock() - defer r.filesMutex.Unlock() - return r.list, r.changeWait -} diff --git a/broadcaster-server/main.go b/broadcaster-server/main.go deleted file mode 100644 index 5a3cbb9..0000000 --- a/broadcaster-server/main.go +++ /dev/null @@ -1,537 +0,0 @@ -package main - -import ( - "bufio" - _ "embed" - "flag" - "fmt" - "golang.org/x/net/websocket" - "html/template" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" -) - -const version = "v1.0.0" -const formatString = "2006-01-02T15:04" - -// //go:embed templates/* -//var content embed.FS -var content = os.DirFS("../broadcaster-server/") - -var config ServerConfig = NewServerConfig() - -func main() { - configFlag := flag.String("c", "", "path to configuration file") - addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit") - versionFlag := flag.Bool("v", false, "print version then exit") - flag.Parse() - - if *versionFlag { - fmt.Println("Broadcaster Server", version) - os.Exit(0) - } - if *configFlag == "" { - log.Fatal("must specify a configuration file with -c") - } - config.LoadFromFile(*configFlag) - - InitDatabase() - defer db.CloseDatabase() - - if *addUserFlag { - scanner := bufio.NewScanner(os.Stdin) - fmt.Println("Enter new admin username:") - if !scanner.Scan() { - os.Exit(1) - } - username := scanner.Text() - fmt.Println("Enter new admin password (will be printed in the clear):") - if !scanner.Scan() { - os.Exit(1) - } - password := scanner.Text() - if username == "" || password == "" { - fmt.Println("Both username and password must be specified") - os.Exit(1) - } - if err := users.CreateUser(username, password, true); err != nil { - log.Fatal(err) - } - os.Exit(0) - } - - log.Println("Broadcaster Server", version, "starting up") - InitCommandRouter() - InitPlaylists() - InitAudioFiles(config.AudioFilesPath) - InitServerStatus() - - // Public routes - - http.HandleFunc("/login", logInPage) - http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath)))) - - // Authenticated routes - - http.HandleFunc("/", homePage) - http.HandleFunc("/logout", logOutPage) - http.HandleFunc("/change-password", changePasswordPage) - - http.HandleFunc("/playlists/", playlistSection) - http.HandleFunc("/files/", fileSection) - http.HandleFunc("/radios/", radioSection) - - http.Handle("/radio-ws", websocket.Handler(RadioSync)) - http.Handle("/web-ws", websocket.Handler(WebSync)) - http.HandleFunc("/stop", stopPage) - - // Admin routes - - err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil) - if err != nil { - log.Fatal(err) - } -} - -type HeaderData struct { - SelectedMenu string - Username string -} - -func renderHeader(w http.ResponseWriter, selectedMenu string) { - tmpl := template.Must(template.ParseFS(content, "templates/header.html")) - data := HeaderData{ - SelectedMenu: selectedMenu, - Username: "username", - } - err := tmpl.Execute(w, data) - if err != nil { - log.Fatal(err) - } -} - -func renderFooter(w http.ResponseWriter) { - tmpl := template.Must(template.ParseFS(content, "templates/footer.html")) - err := tmpl.Execute(w, nil) - if err != nil { - log.Fatal(err) - } -} - -type HomeData struct { - LoggedIn bool - Username string -} - -func homePage(w http.ResponseWriter, r *http.Request) { - renderHeader(w, "status") - tmpl := template.Must(template.ParseFS(content, "templates/index.html")) - data := HomeData{ - LoggedIn: true, - Username: "Bob", - } - tmpl.Execute(w, data) - renderFooter(w) -} - -type LogInData struct { - Error string -} - -func logInPage(w http.ResponseWriter, r *http.Request) { - log.Println("Log in page!") - r.ParseForm() - username := r.Form["username"] - password := r.Form["password"] - errText := "" - if username != nil { - user, err := users.Authenticate(username[0], password[0]) - if err != nil { - errText = "Incorrect login" - } else { - createSessionCookie(w, user.Username) - http.Redirect(w, r, "/", http.StatusFound) - return - } - } - - data := LogInData{ - Error: errText, - } - renderHeader(w, "") - tmpl := template.Must(template.ParseFS(content, "templates/login.html")) - tmpl.Execute(w, data) - renderFooter(w) -} - -func playlistSection(w http.ResponseWriter, r *http.Request) { - path := strings.Split(r.URL.Path, "/") - if len(path) != 3 { - http.NotFound(w, r) - return - } - if path[2] == "new" { - editPlaylistPage(w, r, 0) - } else if path[2] == "submit" && r.Method == "POST" { - submitPlaylist(w, r) - } else if path[2] == "delete" && r.Method == "POST" { - deletePlaylist(w, r) - } else if path[2] == "" { - playlistsPage(w, r) - } else { - id, err := strconv.Atoi(path[2]) - if err != nil { - http.NotFound(w, r) - return - } - editPlaylistPage(w, r, id) - } -} - -func fileSection(w http.ResponseWriter, r *http.Request) { - path := strings.Split(r.URL.Path, "/") - if len(path) != 3 { - http.NotFound(w, r) - return - } - if path[2] == "upload" { - uploadFile(w, r) - } else if path[2] == "delete" && r.Method == "POST" { - deleteFile(w, r) - } else if path[2] == "" { - filesPage(w, r) - } else { - http.NotFound(w, r) - return - } -} - -func radioSection(w http.ResponseWriter, r *http.Request) { - path := strings.Split(r.URL.Path, "/") - if len(path) != 3 { - http.NotFound(w, r) - return - } - if path[2] == "new" { - editRadioPage(w, r, 0) - } else if path[2] == "submit" && r.Method == "POST" { - submitRadio(w, r) - } else if path[2] == "delete" && r.Method == "POST" { - deleteRadio(w, r) - } else if path[2] == "" { - radiosPage(w, r) - } else { - id, err := strconv.Atoi(path[2]) - if err != nil { - http.NotFound(w, r) - return - } - editRadioPage(w, r, id) - } -} - -type ChangePasswordPageData struct { - Message string - ShowForm bool -} - -func changePasswordPage(w http.ResponseWriter, r *http.Request) { - user, err := currentUser(w, r) - if err != nil { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - var data ChangePasswordPageData - if r.Method == "POST" { - err := r.ParseForm() - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - oldPassword := r.Form.Get("oldPassword") - newPassword := r.Form.Get("newPassword") - err = users.UpdatePassword(user.Username, oldPassword, newPassword) - if err != nil { - data.Message = "Failed to change password: " + err.Error() - data.ShowForm = true - } else { - data.Message = "Successfully changed password" - data.ShowForm = false - cookie, err := r.Cookie("broadcast_session") - if err == nil { - log.Println("clearing other sessions for username", user.Username, "token", cookie.Value) - db.ClearOtherSessions(user.Username, cookie.Value) - } - } - } else { - data.Message = "" - data.ShowForm = true - } - renderHeader(w, "change-password") - tmpl := template.Must(template.ParseFS(content, "templates/change_password.html")) - err = tmpl.Execute(w, data) - if err != nil { - log.Fatal(err) - } - renderFooter(w) -} - -type PlaylistsPageData struct { - Playlists []Playlist -} - -func playlistsPage(w http.ResponseWriter, _ *http.Request) { - renderHeader(w, "playlists") - data := PlaylistsPageData{ - Playlists: db.GetPlaylists(), - } - tmpl := template.Must(template.ParseFS(content, "templates/playlists.html")) - err := tmpl.Execute(w, data) - if err != nil { - log.Fatal(err) - } - renderFooter(w) -} - -type RadiosPageData struct { - Radios []Radio -} - -func radiosPage(w http.ResponseWriter, _ *http.Request) { - renderHeader(w, "radios") - data := RadiosPageData{ - Radios: db.GetRadios(), - } - tmpl := template.Must(template.ParseFS(content, "templates/radios.html")) - err := tmpl.Execute(w, data) - if err != nil { - log.Fatal(err) - } - renderFooter(w) -} - -type EditPlaylistPageData struct { - Playlist Playlist - Entries []PlaylistEntry - Files []string -} - -func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) { - var data EditPlaylistPageData - for _, f := range files.Files() { - data.Files = append(data.Files, f.Name) - } - if id == 0 { - data.Playlist.Enabled = true - data.Playlist.Name = "New Playlist" - data.Playlist.StartTime = time.Now().Format(formatString) - data.Entries = append(data.Entries, PlaylistEntry{}) - } else { - playlist, err := db.GetPlaylist(id) - if err != nil { - http.NotFound(w, r) - return - } - data.Playlist = playlist - data.Entries = db.GetEntriesForPlaylist(id) - } - renderHeader(w, "radios") - tmpl := template.Must(template.ParseFS(content, "templates/playlist.html")) - tmpl.Execute(w, data) - renderFooter(w) -} - -func submitPlaylist(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err == nil { - var p Playlist - id, err := strconv.Atoi(r.Form.Get("playlistId")) - if err != nil { - return - } - _, err = time.Parse(formatString, r.Form.Get("playlistStartTime")) - if err != nil { - return - } - p.Id = id - p.Enabled = r.Form.Get("playlistEnabled") == "1" - p.Name = r.Form.Get("playlistName") - p.StartTime = r.Form.Get("playlistStartTime") - - delays := r.Form["delaySeconds"] - filenames := r.Form["filename"] - isRelatives := r.Form["isRelative"] - - entries := make([]PlaylistEntry, 0) - for i := range delays { - var e PlaylistEntry - delay, err := strconv.Atoi(delays[i]) - if err != nil { - return - } - e.DelaySeconds = delay - e.Position = i - e.IsRelative = isRelatives[i] == "1" - e.Filename = filenames[i] - entries = append(entries, e) - } - cleanedEntries := make([]PlaylistEntry, 0) - for _, e := range entries { - if e.DelaySeconds != 0 || e.Filename != "" { - cleanedEntries = append(cleanedEntries, e) - } - } - - if id != 0 { - db.UpdatePlaylist(p) - } else { - id = db.CreatePlaylist(p) - } - db.SetEntriesForPlaylist(cleanedEntries, id) - // Notify connected radios - playlists.NotifyChanges() - } - http.Redirect(w, r, "/playlists/", http.StatusFound) -} - -func deletePlaylist(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err == nil { - id, err := strconv.Atoi(r.Form.Get("playlistId")) - if err != nil { - return - } - db.DeletePlaylist(id) - playlists.NotifyChanges() - } - http.Redirect(w, r, "/playlists/", http.StatusFound) -} - -type EditRadioPageData struct { - Radio Radio -} - -func editRadioPage(w http.ResponseWriter, r *http.Request, id int) { - var data EditRadioPageData - if id == 0 { - data.Radio.Name = "New Radio" - data.Radio.Token = generateSession() - } else { - radio, err := db.GetRadio(id) - if err != nil { - http.NotFound(w, r) - return - } - data.Radio = radio - } - renderHeader(w, "radios") - tmpl := template.Must(template.ParseFS(content, "templates/radio.html")) - tmpl.Execute(w, data) - renderFooter(w) -} - -func submitRadio(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err == nil { - var radio Radio - id, err := strconv.Atoi(r.Form.Get("radioId")) - if err != nil { - return - } - radio.Id = id - radio.Name = r.Form.Get("radioName") - radio.Token = r.Form.Get("radioToken") - if id != 0 { - db.UpdateRadio(radio) - } else { - db.CreateRadio(radio) - } - } - http.Redirect(w, r, "/radios/", http.StatusFound) -} - -func deleteRadio(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err == nil { - id, err := strconv.Atoi(r.Form.Get("radioId")) - if err != nil { - return - } - db.DeleteRadio(id) - } - http.Redirect(w, r, "/radios/", http.StatusFound) -} - -type FilesPageData struct { - Files []FileSpec -} - -func filesPage(w http.ResponseWriter, _ *http.Request) { - renderHeader(w, "files") - data := FilesPageData{ - Files: files.Files(), - } - log.Println("file page data", data) - tmpl := template.Must(template.ParseFS(content, "templates/files.html")) - err := tmpl.Execute(w, data) - if err != nil { - log.Fatal(err) - } - renderFooter(w) -} - -func deleteFile(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err == nil { - filename := r.Form.Get("filename") - if filename == "" { - return - } - files.Delete(filename) - } - http.Redirect(w, r, "/files/", http.StatusFound) -} - -func uploadFile(w http.ResponseWriter, r *http.Request) { - err := r.ParseMultipartForm(100 << 20) - file, handler, err := r.FormFile("file") - if err == nil { - path := filepath.Join(files.Path(), filepath.Base(handler.Filename)) - f, _ := os.Create(path) - defer f.Close() - io.Copy(f, file) - log.Println("uploaded file to", path) - files.Refresh() - } - http.Redirect(w, r, "/files/", http.StatusFound) -} - -func logOutPage(w http.ResponseWriter, r *http.Request) { - clearSessionCookie(w) - renderHeader(w, "") - tmpl := template.Must(template.ParseFS(content, "templates/logout.html")) - tmpl.Execute(w, nil) - renderFooter(w) -} - -func stopPage(w http.ResponseWriter, r *http.Request) { - _, err := currentUser(w, r) - if err != nil { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - r.ParseForm() - radioId, err := strconv.Atoi(r.Form.Get("radioId")) - if err != nil { - http.NotFound(w, r) - return - } - commandRouter.Stop(radioId) - http.Redirect(w, r, "/", http.StatusFound) -} diff --git a/broadcaster-server/model.go b/broadcaster-server/model.go deleted file mode 100644 index 11f94e7..0000000 --- a/broadcaster-server/model.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -type PlaylistEntry struct { - Id int - Position int - Filename string - DelaySeconds int - IsRelative bool -} - -type User struct { - Id int - Username string - PasswordHash string - IsAdmin bool -} - -type Playlist struct { - Id int - Enabled bool - Name string - StartTime string -} - -type Radio struct { - Id int - Name string - Token string -} diff --git a/broadcaster-server/playlist.go b/broadcaster-server/playlist.go deleted file mode 100644 index f0a6c89..0000000 --- a/broadcaster-server/playlist.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "sync" -) - -type Playlists struct { - changeWait chan bool - playlistMutex sync.Mutex -} - -var playlists Playlists - -func InitPlaylists() { - playlists.changeWait = make(chan bool) -} - -func (p *Playlists) GetPlaylists() []Playlist { - p.playlistMutex.Lock() - defer p.playlistMutex.Unlock() - return db.GetPlaylists() -} - -func (p *Playlists) WatchForChanges() ([]Playlist, chan bool) { - p.playlistMutex.Lock() - defer p.playlistMutex.Unlock() - return db.GetPlaylists(), p.changeWait -} - -func (p *Playlists) NotifyChanges() { - p.playlistMutex.Lock() - defer p.playlistMutex.Unlock() - close(p.changeWait) - p.changeWait = make(chan bool) -} diff --git a/broadcaster-server/radio_sync.go b/broadcaster-server/radio_sync.go deleted file mode 100644 index 66521f8..0000000 --- a/broadcaster-server/radio_sync.go +++ /dev/null @@ -1,125 +0,0 @@ -package main - -import ( - "code.octet-stream.net/broadcaster/internal/protocol" - "encoding/json" - "golang.org/x/net/websocket" - "log" -) - -func RadioSync(ws *websocket.Conn) { - log.Println("A websocket connected, I think") - buf := make([]byte, 16384) - - badRead := false - isAuthenticated := false - var radio Radio - for { - // Ignore any massively oversize messages - n, err := ws.Read(buf) - if err != nil { - if radio.Name != "" { - log.Println("Lost websocket to radio:", radio.Name) - status.RadioDisconnected(radio.Id) - } else { - log.Println("Lost unauthenticated websocket") - } - return - } - if n == len(buf) { - badRead = true - continue - } else if badRead { - badRead = false - continue - } - - t, msg, err := protocol.ParseMessage(buf[:n]) - if err != nil { - log.Println(err) - return - } - - if !isAuthenticated && t != protocol.AuthenticateType { - continue - } - - if t == protocol.AuthenticateType && !isAuthenticated { - authMsg := msg.(protocol.AuthenticateMessage) - r, err := db.GetRadioByToken(authMsg.Token) - if err != nil { - log.Println("Could not find radio for offered token", authMsg.Token) - } - radio = r - log.Println("Radio authenticated:", radio.Name) - isAuthenticated = true - commandRouter.AddWebsocket(r.Id, ws) - defer commandRouter.RemoveWebsocket(ws) - - go KeepFilesUpdated(ws) - go KeepPlaylistsUpdated(ws) - } - - if t == protocol.StatusType { - statusMsg := msg.(protocol.StatusMessage) - log.Println("Received Status from", radio.Name, ":", statusMsg) - status.MergeStatus(radio.Id, statusMsg) - } - } -} - -func sendPlaylistsMessageToRadio(ws *websocket.Conn, p []Playlist) error { - playlistSpecs := make([]protocol.PlaylistSpec, 0) - for _, v := range p { - if v.Enabled { - entrySpecs := make([]protocol.EntrySpec, 0) - for _, e := range db.GetEntriesForPlaylist(v.Id) { - entrySpecs = append(entrySpecs, protocol.EntrySpec{Filename: e.Filename, DelaySeconds: e.DelaySeconds, IsRelative: e.IsRelative}) - } - playlistSpecs = append(playlistSpecs, protocol.PlaylistSpec{Id: v.Id, Name: v.Name, StartTime: v.StartTime, Entries: entrySpecs}) - } - } - playlists := protocol.PlaylistsMessage{ - T: protocol.PlaylistsType, - Playlists: playlistSpecs, - } - msg, _ := json.Marshal(playlists) - _, err := ws.Write(msg) - return err -} - -func KeepPlaylistsUpdated(ws *websocket.Conn) { - for { - p, ch := playlists.WatchForChanges() - err := sendPlaylistsMessageToRadio(ws, p) - if err != nil { - return - } - <-ch - } -} - -func sendFilesMessageToRadio(ws *websocket.Conn, f []FileSpec) error { - specs := make([]protocol.FileSpec, 0) - for _, v := range f { - specs = append(specs, protocol.FileSpec{Name: v.Name, Hash: v.Hash}) - } - files := protocol.FilesMessage{ - T: protocol.FilesType, - Files: specs, - } - msg, _ := json.Marshal(files) - _, err := ws.Write(msg) - return err -} - -func KeepFilesUpdated(ws *websocket.Conn) { - for { - f, ch := files.WatchForChanges() - err := sendFilesMessageToRadio(ws, f) - if err != nil { - return - } - <-ch - } -} diff --git a/broadcaster-server/session.go b/broadcaster-server/session.go deleted file mode 100644 index a097989..0000000 --- a/broadcaster-server/session.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "log" - "net/http" - "time" -) - -func generateSession() string { - b := make([]byte, 32) - _, err := rand.Read(b) - if err != nil { - log.Fatal(err) - } - return hex.EncodeToString(b) -} - -func currentUser(_ http.ResponseWriter, r *http.Request) (User, error) { - cookie, e := r.Cookie("broadcast_session") - if e != nil { - return User{}, e - } - - return users.GetUserForSession(cookie.Value) -} - -func createSessionCookie(w http.ResponseWriter, username string) { - sess := generateSession() - log.Println("Generated a random session", sess) - expiration := time.Now().Add(365 * 24 * time.Hour) - cookie := http.Cookie{Name: "broadcast_session", Value: sess, Expires: expiration, SameSite: http.SameSiteLaxMode} - db.InsertSession(username, sess, expiration) - http.SetCookie(w, &cookie) -} - -func clearSessionCookie(w http.ResponseWriter) { - c := &http.Cookie{ - Name: "broadcast_session", - Value: "", - MaxAge: -1, - HttpOnly: true, - } - http.SetCookie(w, c) -} diff --git a/broadcaster-server/status.go b/broadcaster-server/status.go deleted file mode 100644 index b2edd49..0000000 --- a/broadcaster-server/status.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "code.octet-stream.net/broadcaster/internal/protocol" - "sync" -) - -type ServerStatus struct { - statuses map[int]protocol.StatusMessage - statusesMutex sync.Mutex - changeWait chan bool -} - -var status ServerStatus - -func InitServerStatus() { - status = ServerStatus{ - statuses: make(map[int]protocol.StatusMessage), - changeWait: make(chan bool), - } -} - -func (s *ServerStatus) MergeStatus(radioId int, status protocol.StatusMessage) { - s.statusesMutex.Lock() - defer s.statusesMutex.Unlock() - s.statuses[radioId] = status - s.TriggerChange() -} - -func (s *ServerStatus) RadioDisconnected(radioId int) { - s.statusesMutex.Lock() - defer s.statusesMutex.Unlock() - delete(s.statuses, radioId) - s.TriggerChange() -} - -func (s *ServerStatus) TriggerChange() { - close(s.changeWait) - s.changeWait = make(chan bool) -} - -func (s *ServerStatus) Statuses() map[int]protocol.StatusMessage { - s.statusesMutex.Lock() - defer s.statusesMutex.Unlock() - c := make(map[int]protocol.StatusMessage) - for k, v := range s.statuses { - c[k] = v - } - return c -} - -func (s *ServerStatus) ChangeChannel() chan bool { - return s.changeWait -} diff --git a/broadcaster-server/templates/change_password.html b/broadcaster-server/templates/change_password.html deleted file mode 100644 index bab3a82..0000000 --- a/broadcaster-server/templates/change_password.html +++ /dev/null @@ -1,14 +0,0 @@ - -

Change Password

- {{if ne .Message ""}} -

{{.Message}}

- {{end}} - {{if .ShowForm}} -
-
-
-
-
- -
- {{end}} diff --git a/broadcaster-server/templates/files.html b/broadcaster-server/templates/files.html deleted file mode 100644 index c86c6ce..0000000 --- a/broadcaster-server/templates/files.html +++ /dev/null @@ -1,15 +0,0 @@ - -

Files! List

-

All files can be downloaded from the public file listing.

- -

Upload New File

-

-

- - -
-

diff --git a/broadcaster-server/templates/footer.html b/broadcaster-server/templates/footer.html deleted file mode 100644 index df60a12..0000000 --- a/broadcaster-server/templates/footer.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/broadcaster-server/templates/header.html b/broadcaster-server/templates/header.html deleted file mode 100644 index ae59b16..0000000 --- a/broadcaster-server/templates/header.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - Broadcaster - - - -
- {{if .SelectedMenu}} - - {{end}} -
diff --git a/broadcaster-server/templates/index.html b/broadcaster-server/templates/index.html deleted file mode 100644 index b688947..0000000 --- a/broadcaster-server/templates/index.html +++ /dev/null @@ -1,39 +0,0 @@ - - -

Welcome!

- {{if .LoggedIn}} -

Your username is: {{.Username}}.

-

Log Out

- {{else}} -

Log In

- {{end}} -

File Management

-

Playlist Management

-

Radio Management

-

Connected Radios

-
- Loading... -
diff --git a/broadcaster-server/templates/login.html b/broadcaster-server/templates/login.html deleted file mode 100644 index 2211b2f..0000000 --- a/broadcaster-server/templates/login.html +++ /dev/null @@ -1,12 +0,0 @@ - -

Log In

-
- {{if ne .Error ""}} -

{{.Error}}

- {{end}} -
-
-
-
- -
diff --git a/broadcaster-server/templates/logout.html b/broadcaster-server/templates/logout.html deleted file mode 100644 index 8b7ee1c..0000000 --- a/broadcaster-server/templates/logout.html +++ /dev/null @@ -1,3 +0,0 @@ - -

Logged Out

-

Log In again

diff --git a/broadcaster-server/templates/playlist.html b/broadcaster-server/templates/playlist.html deleted file mode 100644 index d778250..0000000 --- a/broadcaster-server/templates/playlist.html +++ /dev/null @@ -1,90 +0,0 @@ - - - -

A specific playlist

-

- {{if .Playlist.Id}} - Edit Playlist - {{else}} - Create New Playlist - {{end}} -

-
- -

- -
-

-

- - -

-

- - -

-

Playlist Items

- {{range .Entries}} -

- Wait until - - seconds from - - then play - - (Delete Item) -

- {{end}} -

- Add Item -

-

- -

-
- {{if .Playlist.Id}} -

Delete

-
- -

- -

-
- {{end}} - diff --git a/broadcaster-server/templates/playlists.html b/broadcaster-server/templates/playlists.html deleted file mode 100644 index e1ba60f..0000000 --- a/broadcaster-server/templates/playlists.html +++ /dev/null @@ -1,8 +0,0 @@ - -

Playlists!

-
    - {{range .Playlists}} -
  • {{.Name}} {{.StartTime}} (Edit)
  • - {{end}} -
-

Add New Playlist

diff --git a/broadcaster-server/templates/radio.html b/broadcaster-server/templates/radio.html deleted file mode 100644 index c2ec9dd..0000000 --- a/broadcaster-server/templates/radio.html +++ /dev/null @@ -1,32 +0,0 @@ - -

A specific radio

-

- {{if .Radio.Id}} - Edit Radio - {{else}} - Register New Radio - {{end}} -

-
- -

- - -

-

- Authentication token: {{.Radio.Token}} - -

-

- -

-
- {{if .Radio.Id}} -

Delete

-
- -

- -

-
- {{end}} diff --git a/broadcaster-server/templates/radios.html b/broadcaster-server/templates/radios.html deleted file mode 100644 index 0c1288f..0000000 --- a/broadcaster-server/templates/radios.html +++ /dev/null @@ -1,8 +0,0 @@ - -

Radios

-
    - {{range .Radios}} -
  • {{.Name}} {{.Token}} (Edit)
  • - {{end}} -
-

Register New Radio

diff --git a/broadcaster-server/templates/radios.partial.html b/broadcaster-server/templates/radios.partial.html deleted file mode 100644 index 010fe0a..0000000 --- a/broadcaster-server/templates/radios.partial.html +++ /dev/null @@ -1,84 +0,0 @@ -{{if .Radios}} -{{range .Radios}} - - - - - - - - - - - - - - -
- {{.Name}} -
- - - - - - - - - - - - - -
- Local Time - - {{.LocalTime}} -
- Time Zone - - {{.TimeZone}} -
- Files In Sync - - {{if .FilesInSync}} Yes {{else}} No {{end}} -
-
- {{.ChannelState}} - - - - - - - - - - - - - - -
- Playlist: - - {{.Playlist}} -
- File: - - {{.File}} -
- Status: - - {{.Status}} -
-
-
- - -
-
-{{end}} -{{else}} -

There are no radios online.

-{{end}} diff --git a/broadcaster-server/user.go b/broadcaster-server/user.go deleted file mode 100644 index 275f0ec..0000000 --- a/broadcaster-server/user.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "errors" - "golang.org/x/crypto/bcrypt" -) - -var users Users - -type Users struct{} - -func (u *Users) GetUserForSession(token string) (User, error) { - username, err := db.GetUserNameForSession(token) - if err != nil { - return User{}, err - } - user, err := db.GetUser(username) - if err != nil { - return User{}, err - } - return user, nil -} - -func (u *Users) Authenticate(username string, clearPassword string) (User, error) { - user, err := db.GetUser(username) - if err != nil { - return User{}, err - } - err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(clearPassword)) - if err != nil { - return User{}, err - } - return user, nil -} - -func (u *Users) CreateUser(username string, clearPassword string, isAdmin bool) error { - if clearPassword == "" { - return errors.New("password cannot be empty") - } - hashed, err := bcrypt.GenerateFromPassword([]byte(clearPassword), bcrypt.DefaultCost) - if err != nil { - return err - } - return db.CreateUser(User{ - Id: 0, - Username: username, - PasswordHash: string(hashed), - IsAdmin: isAdmin, - }) -} - -func (u *Users) DeleteUser(username string) { - db.DeleteUser(username) -} - -func (u *Users) UpdatePassword(username string, oldClearPassword string, newClearPassword string) error { - user, err := db.GetUser(username) - if err != nil { - return err - } - err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldClearPassword)) - if err != nil { - return errors.New("old password is incorrect") - } - if newClearPassword == "" { - return errors.New("password cannot be empty") - } - hashed, err := bcrypt.GenerateFromPassword([]byte(newClearPassword), bcrypt.DefaultCost) - if err != nil { - return err - } - db.SetUserPassword(username, string(hashed)) - return nil -} - -func (u *Users) UpdateIsAdmin(username string, isAdmin bool) { - db.SetUserIsAdmin(username, isAdmin) -} - -func (u *Users) Users() []User { - return db.GetUsers() -} diff --git a/broadcaster-server/web_sync.go b/broadcaster-server/web_sync.go deleted file mode 100644 index a6b1a6e..0000000 --- a/broadcaster-server/web_sync.go +++ /dev/null @@ -1,162 +0,0 @@ -package main - -import ( - "fmt" - "html/template" - "log" - "sort" - "strconv" - "strings" - - "code.octet-stream.net/broadcaster/internal/protocol" - "golang.org/x/net/websocket" -) - -func WebSync(ws *websocket.Conn) { - log.Println("A web user connected with WebSocket") - buf := make([]byte, 16384) - - badRead := false - isAuthenticated := false - var user User - for { - // Ignore any massively oversize messages - n, err := ws.Read(buf) - if err != nil { - if user.Username != "" { - log.Println("Lost websocket to user:", user) - } else { - log.Println("Lost unauthenticated website websocket") - } - return - } - if n == len(buf) { - badRead = true - continue - } else if badRead { - badRead = false - continue - } - - if !isAuthenticated { - token := string(buf[:n]) - u, err := users.GetUserForSession(token) - if err != nil { - log.Println("Could not find user for offered token", token, err) - ws.Close() - return - } - user = u - log.Println("User authenticated:", user) - isAuthenticated = true - - go KeepWebUpdated(ws) - - // send initial playlists message - err = sendRadioStatusToWeb(ws) - if err != nil { - return - } - } - } -} - -type WebStatusData struct { - Radios []WebRadioStatus -} - -type WebRadioStatus struct { - Name string - LocalTime string - TimeZone string - ChannelClass string - ChannelState string - Playlist string - File string - Status string - Id string - DisableCancel bool - FilesInSync bool -} - -func sendRadioStatusToWeb(ws *websocket.Conn) error { - webStatuses := make([]WebRadioStatus, 0) - radioStatuses := status.Statuses() - keys := make([]int, 0) - for i := range radioStatuses { - keys = append(keys, i) - } - sort.Ints(keys) - for _, i := range keys { - v := radioStatuses[i] - radio, err := db.GetRadio(i) - if err != nil { - continue - } - var channelClass, channelState string - if v.PTT { - channelClass = "ptt" - channelState = "PTT" - } else if v.COS { - channelClass = "cos" - channelState = "RX" - } else { - channelClass = "clear" - channelState = "CLEAR" - } - var statusText string - var disableCancel bool - if v.Status == protocol.StatusIdle { - statusText = "Idle" - disableCancel = true - } else if v.Status == protocol.StatusDelay { - statusText = fmt.Sprintf("Performing delay before transmit: %ds remain", v.DelaySecondsRemaining) - disableCancel = false - } else if v.Status == protocol.StatusChannelInUse { - statusText = fmt.Sprintf("Waiting for channel to clear: %ds", v.WaitingForChannelSeconds) - disableCancel = false - } else if v.Status == protocol.StatusPlaying { - statusText = fmt.Sprintf("Playing: %d:%02d", v.PlaybackSecondsElapsed/60, v.PlaybackSecondsElapsed%60) - disableCancel = false - } - playlist := v.Playlist - if playlist == "" { - playlist = "-" - } - filename := v.Filename - if filename == "" { - filename = "-" - } - webStatuses = append(webStatuses, WebRadioStatus{ - Name: radio.Name, - LocalTime: v.LocalTime, - TimeZone: v.TimeZone, - ChannelClass: channelClass, - ChannelState: channelState, - Playlist: playlist, - File: filename, - Status: statusText, - Id: strconv.Itoa(i), - DisableCancel: disableCancel, - FilesInSync: v.FilesInSync, - }) - } - data := WebStatusData{ - Radios: webStatuses, - } - buf := new(strings.Builder) - tmpl := template.Must(template.ParseFS(content, "templates/radios.partial.html")) - tmpl.Execute(buf, data) - _, err := ws.Write([]byte(buf.String())) - return err -} - -func KeepWebUpdated(ws *websocket.Conn) { - for { - <-status.ChangeChannel() - err := sendRadioStatusToWeb(ws) - if err != nil { - return - } - } -} diff --git a/radio/config.go b/radio/config.go new file mode 100644 index 0000000..6837b7d --- /dev/null +++ b/radio/config.go @@ -0,0 +1,70 @@ +package main + +import ( + "errors" + "log" + "os" + "strings" + + "github.com/BurntSushi/toml" +) + +type RadioConfig struct { + GpioDevice string + PTTPin int + COSPin int + ServerURL string + Token string + CachePath string + TimeZone string +} + +func NewRadioConfig() RadioConfig { + return RadioConfig{ + GpioDevice: "gpiochip0", + PTTPin: -1, + COSPin: -1, + ServerURL: "", + Token: "", + CachePath: "", + TimeZone: "Australia/Hobart", + } +} + +func (c *RadioConfig) LoadFromFile(path string) { + _, err := toml.DecodeFile(path, &c) + if err != nil { + log.Fatal("could not read config file for reading at path:", path, err) + } + err = c.Validate() + if err != nil { + log.Fatal(err) + } + c.ApplyDefaults() +} + +func (c *RadioConfig) Validate() error { + if c.ServerURL == "" { + return errors.New("ServerURL must be provided in the configuration") + } + if c.Token == "" { + return errors.New("Token must be provided in the configuration") + } + return nil +} + +func (c *RadioConfig) ApplyDefaults() { + if c.CachePath == "" { + dir, err := os.MkdirTemp("", "broadcast") + if err != nil { + log.Fatal(err) + } + c.CachePath = dir + } +} + +func (c *RadioConfig) WebsocketURL() string { + addr := strings.Replace(c.ServerURL, "https://", "wss://", -1) + addr = strings.Replace(addr, "http://", "ws://", -1) + return addr + "/radio-ws" +} diff --git a/radio/files_machine.go b/radio/files_machine.go new file mode 100644 index 0000000..143397a --- /dev/null +++ b/radio/files_machine.go @@ -0,0 +1,117 @@ +package main + +import ( + "code.octet-stream.net/broadcaster/internal/protocol" + "crypto/sha256" + "encoding/hex" + "io" + "log" + "net/http" + "os" + "path/filepath" +) + +type FilesMachine struct { + specs []protocol.FileSpec + cachePath string + missing []string +} + +func NewFilesMachine(cachePath string) FilesMachine { + if err := os.MkdirAll(cachePath, 0750); err != nil { + log.Fatal(err) + } + return FilesMachine{ + cachePath: cachePath, + } +} + +func (m *FilesMachine) UpdateSpecs(specs []protocol.FileSpec) { + m.specs = specs + m.RefreshMissing() +} + +func (m *FilesMachine) RefreshMissing() { + // Delete any files in the cache dir who are not in the spec + entries, err := os.ReadDir(m.cachePath) + if err != nil { + log.Fatal(err) + } + okay := make([]string, 0) + for _, file := range entries { + hash := "" + for _, spec := range m.specs { + if file.Name() == spec.Name { + hash = spec.Hash + break + } + } + // if we have an extraneous file, delete it + if hash == "" { + log.Println("Deleting extraneous cached audio file:", file.Name()) + os.Remove(filepath.Join(m.cachePath, file.Name())) + continue + } + // if the hash isn't right, delete it + f, err := os.Open(filepath.Join(m.cachePath, file.Name())) + if err != nil { + log.Fatal(err) + } + hasher := sha256.New() + io.Copy(hasher, f) + if hex.EncodeToString(hasher.Sum(nil)) != hash { + log.Println("Deleting cached audio file with incorrect hash:", file.Name()) + os.Remove(filepath.Join(m.cachePath, file.Name())) + } else { + okay = append(okay, file.Name()) + } + } + m.missing = nil + for _, spec := range m.specs { + missing := true + for _, file := range okay { + if spec.Name == file { + missing = false + } + } + if missing { + m.missing = append(m.missing, spec.Name) + } + } + if len(m.missing) > 1 { + log.Println(len(m.missing), "missing files") + } else if len(m.missing) == 1 { + log.Println("1 missing file") + } else { + log.Println("All files are in sync with server") + } + statusCollector.FilesInSync <- len(m.missing) == 0 +} + +func (m *FilesMachine) IsCacheComplete() bool { + return len(m.missing) == 0 +} + +func (m *FilesMachine) NextFile() string { + next, remainder := m.missing[0], m.missing[1:] + m.missing = remainder + return next +} + +func (m *FilesMachine) DownloadSingle(filename string, downloadResult chan<- error) { + log.Println("Downloading", filename) + out, err := os.Create(filepath.Join(m.cachePath, filename)) + if err != nil { + downloadResult <- err + return + } + defer out.Close() + resp, err := http.Get(config.ServerURL + "/file-downloads/" + filename) + if err != nil { + downloadResult <- err + return + } + defer resp.Body.Close() + _, err = io.Copy(out, resp.Body) + downloadResult <- err +} diff --git a/radio/gpio.go b/radio/gpio.go new file mode 100644 index 0000000..69807ac --- /dev/null +++ b/radio/gpio.go @@ -0,0 +1,123 @@ +package main + +import ( + gpio "github.com/warthog618/go-gpiocdev" + "github.com/warthog618/go-gpiocdev/device/rpi" + "log" + "strconv" +) + +type PTT interface { + EngagePTT() + DisengagePTT() +} + +type COS interface { + WaitForChannelClear() + COSValue() bool +} + +var ptt PTT = &DefaultPTT{} +var cos COS = &DefaultCOS{} + +type PiPTT struct { + pttLine *gpio.Line +} + +type PiCOS struct { + cosLine *gpio.Line + clearWait chan bool +} + +func InitRaspberryPiPTT(pttNum int, chipName string) { + pttPin, err := rpi.Pin("GPIO" + strconv.Itoa(pttNum)) + if err != nil { + log.Fatal("invalid PTT pin configured", ptt) + } + pttLine, err := gpio.RequestLine(chipName, pttPin, gpio.AsOutput(0)) + if err != nil { + log.Fatal("unable to open requested pin for PTT GPIO:", ptt, ". Are you running as root?") + } + ptt = &PiPTT{ + pttLine: pttLine, + } +} + +func InitRaspberryPiCOS(cosNum int, chipName string) { + var piCOS PiCOS + piCOS.clearWait = make(chan bool) + cosPin, err := rpi.Pin("GPIO" + strconv.Itoa(cosNum)) + if err != nil { + log.Fatal("invalid COS Pin configured", cos) + } + cosHandler := func(event gpio.LineEvent) { + if event.Type == gpio.LineEventFallingEdge { + log.Println("COS: channel clear") + close(piCOS.clearWait) + piCOS.clearWait = make(chan bool) + statusCollector.COS <- false + } + if event.Type == gpio.LineEventRisingEdge { + log.Println("COS: channel in use") + statusCollector.COS <- true + } + } + cosLine, err := gpio.RequestLine(chipName, cosPin, gpio.AsInput, gpio.WithBothEdges, gpio.WithEventHandler(cosHandler)) + if err != nil { + log.Fatal("unable to open requested pin for COS GPIO:", cos, ". Are you running as root?") + } + piCOS.cosLine = cosLine + cos = &piCOS +} + +func (g *PiCOS) COSValue() bool { + val, err := g.cosLine.Value() + if err != nil { + log.Fatal("Unable to read COS value") + } + return val != 0 +} + +func (g *PiCOS) WaitForChannelClear() { + ch := g.clearWait + val, err := g.cosLine.Value() + if err != nil || val == 0 { + return + } + // wait for close + <-ch +} + +func (g *PiPTT) EngagePTT() { + log.Println("PTT: on") + g.pttLine.SetValue(1) + statusCollector.PTT <- true +} + +func (g *PiPTT) DisengagePTT() { + log.Println("PTT: off") + g.pttLine.SetValue(0) + statusCollector.PTT <- false +} + +type DefaultPTT struct { +} + +func (g *DefaultPTT) EngagePTT() { + statusCollector.PTT <- true +} + +func (g *DefaultPTT) DisengagePTT() { + statusCollector.PTT <- false +} + +type DefaultCOS struct { +} + +func (g *DefaultCOS) WaitForChannelClear() { + log.Println("Assuming channel is clear since COS GPIO is not configured") +} + +func (g *DefaultCOS) COSValue() bool { + return false +} diff --git a/radio/main.go b/radio/main.go new file mode 100644 index 0000000..6e31772 --- /dev/null +++ b/radio/main.go @@ -0,0 +1,334 @@ +package main + +import ( + "code.octet-stream.net/broadcaster/internal/protocol" + "encoding/json" + "flag" + "fmt" + "github.com/gopxl/beep/v2" + "github.com/gopxl/beep/v2/mp3" + "github.com/gopxl/beep/v2/speaker" + "github.com/gopxl/beep/v2/wav" + "golang.org/x/net/websocket" + "log" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +const version = "v1.0.0" +const sampleRate = 44100 + +var config RadioConfig = NewRadioConfig() + +func main() { + configFlag := flag.String("c", "", "path to configuration file") + versionFlag := flag.Bool("v", false, "print version and exit") + flag.Parse() + + if *versionFlag { + fmt.Println("Broadcaster Radio", version) + os.Exit(0) + } + if *configFlag == "" { + log.Fatal("must specify a configuration file with -c") + } + + log.Println("Broadcaster Radio", version, "starting up") + config.LoadFromFile(*configFlag) + statusCollector.Config <- config + + playbackSampleRate := beep.SampleRate(sampleRate) + speaker.Init(playbackSampleRate, playbackSampleRate.N(time.Second/10)) + + if config.PTTPin != -1 { + InitRaspberryPiPTT(config.PTTPin, config.GpioDevice) + } + if config.COSPin != -1 { + InitRaspberryPiCOS(config.COSPin, config.GpioDevice) + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sig + log.Println("Radio shutting down due to signal", sig) + // Make sure we always stop PTT when program ends + ptt.DisengagePTT() + os.Exit(0) + }() + + log.Println("Config checks out, radio coming online") + log.Println("Audio file cache:", config.CachePath) + + fileSpecChan := make(chan []protocol.FileSpec) + go filesWorker(config.CachePath, fileSpecChan) + + stop := make(chan bool) + playlistSpecChan := make(chan []protocol.PlaylistSpec) + go playlistWorker(playlistSpecChan, stop) + + for { + runWebsocket(fileSpecChan, playlistSpecChan, stop) + log.Println("Websocket failed, retry in 30 seconds") + time.Sleep(time.Second * time.Duration(30)) + } +} + +func runWebsocket(fileSpecChan chan []protocol.FileSpec, playlistSpecChan chan []protocol.PlaylistSpec, stop chan bool) error { + log.Println("Establishing websocket connection to:", config.WebsocketURL()) + ws, err := websocket.Dial(config.WebsocketURL(), "", config.ServerURL) + if err != nil { + return err + } + + auth := protocol.AuthenticateMessage{ + T: "authenticate", + Token: config.Token, + } + msg, _ := json.Marshal(auth) + + if _, err := ws.Write(msg); err != nil { + log.Fatal(err) + } + statusCollector.Websocket <- ws + + buf := make([]byte, 16384) + badRead := false + for { + n, err := ws.Read(buf) + if err != nil { + log.Println("Lost websocket to server") + return err + } + // Ignore any massively oversize messages + if n == len(buf) { + badRead = true + continue + } else if badRead { + badRead = false + continue + } + + t, msg, err := protocol.ParseMessage(buf[:n]) + if err != nil { + log.Println("Message parse error", err) + return err + } + + if t == protocol.FilesType { + filesMsg := msg.(protocol.FilesMessage) + fileSpecChan <- filesMsg.Files + } + + if t == protocol.PlaylistsType { + playlistsMsg := msg.(protocol.PlaylistsMessage) + playlistSpecChan <- playlistsMsg.Playlists + } + + if t == protocol.StopType { + stop <- true + } + } +} + +func filesWorker(cachePath string, ch chan []protocol.FileSpec) { + machine := NewFilesMachine(cachePath) + isDownloading := false + downloadResult := make(chan error) + var timer *time.Timer + + for { + var timerCh <-chan time.Time = nil + if timer != nil { + timerCh = timer.C + } + doNext := false + select { + case specs := <-ch: + log.Println("Received new file specs", specs) + machine.UpdateSpecs(specs) + doNext = true + timer = nil + case err := <-downloadResult: + isDownloading = false + machine.RefreshMissing() + if err != nil { + log.Println(err) + if !machine.IsCacheComplete() { + timer = time.NewTimer(30 * time.Second) + } + } else { + if !machine.IsCacheComplete() { + timer = time.NewTimer(10 * time.Millisecond) + } + } + case <-timerCh: + doNext = true + timer = nil + } + + if doNext && !isDownloading && !machine.IsCacheComplete() { + next := machine.NextFile() + isDownloading = true + go machine.DownloadSingle(next, downloadResult) + } + } +} + +func playlistWorker(ch <-chan []protocol.PlaylistSpec, stop <-chan bool) { + var specs []protocol.PlaylistSpec + isPlaying := false + playbackFinished := make(chan error) + cancel := make(chan bool) + nextId := 0 + var timer *time.Timer + + for { + var timerCh <-chan time.Time = nil + if timer != nil { + timerCh = timer.C + } + doNext := false + select { + case specs = <-ch: + log.Println("Received new playlist specs", specs) + doNext = true + case <-playbackFinished: + isPlaying = false + doNext = true + cancel = make(chan bool) + case <-timerCh: + timer = nil + isPlaying = true + for _, v := range specs { + if v.Id == nextId { + go playPlaylist(v, playbackFinished, cancel) + } + } + case <-stop: + if isPlaying { + log.Println("Cancelling playlist in progress") + cancel <- true + } + } + + if doNext && !isPlaying { + timer = nil + found := false + loc, err := time.LoadLocation(config.TimeZone) + if err != nil { + log.Fatal(err) + } + var soonestTime time.Time + for _, v := range specs { + t, err := time.ParseInLocation(protocol.StartTimeFormat, v.StartTime, loc) + if err != nil { + log.Println("Error parsing start time", err) + continue + } + if t.Before(time.Now()) { + continue + } + if !found || t.Before(soonestTime) { + soonestTime = t + found = true + nextId = v.Id + } + } + if found { + duration := soonestTime.Sub(time.Now()) + log.Println("Next playlist will be id", nextId, "in", duration.Seconds(), "seconds") + timer = time.NewTimer(duration) + } else { + log.Println("No future playlists") + } + } + } +} + +func playPlaylist(playlist protocol.PlaylistSpec, playbackFinished chan<- error, cancel <-chan bool) { + startTime := time.Now() + log.Println("Beginning playback of playlist", playlist.Name) +entries: + for _, p := range playlist.Entries { + // delay + var duration time.Duration + if p.IsRelative { + duration = time.Second * time.Duration(p.DelaySeconds) + } else { + duration = time.Until(startTime.Add(time.Second * time.Duration(p.DelaySeconds))) + } + statusCollector.PlaylistBeginDelay <- BeginDelayStatus{ + Playlist: playlist.Name, + Seconds: int(duration.Seconds()), + Filename: p.Filename, + } + select { + case <-time.After(duration): + case <-cancel: + break entries + } + + statusCollector.PlaylistBeginWaitForChannel <- BeginWaitForChannelStatus{ + Playlist: playlist.Name, + Filename: p.Filename, + } + cos.WaitForChannelClear() + + // then play + statusCollector.PlaylistBeginPlayback <- BeginPlaybackStatus{ + Playlist: playlist.Name, + Filename: p.Filename, + } + ptt.EngagePTT() + f, err := os.Open(filepath.Join(config.CachePath, p.Filename)) + if err != nil { + log.Println("Couldn't open file for playlist", p.Filename) + continue + } + log.Println("Playing file", p.Filename) + l := strings.ToLower(p.Filename) + var streamer beep.StreamSeekCloser + var format beep.Format + if strings.HasSuffix(l, ".mp3") { + streamer, format, err = mp3.Decode(f) + } else if strings.HasSuffix(l, ".wav") { + streamer, format, err = wav.Decode(f) + } else { + log.Println("Unrecognised file extension (.wav and .mp3 supported), moving on") + } + if err != nil { + log.Println("Could not decode media file", err) + continue + } + defer streamer.Close() + + done := make(chan bool) + + if format.SampleRate != sampleRate { + resampled := beep.Resample(4, format.SampleRate, sampleRate, streamer) + speaker.Play(beep.Seq(resampled, beep.Callback(func() { + done <- true + }))) + } else { + speaker.Play(beep.Seq(streamer, beep.Callback(func() { + done <- true + }))) + } + + select { + case <-done: + case <-cancel: + ptt.DisengagePTT() + break entries + } + ptt.DisengagePTT() + } + log.Println("Playlist finished", playlist.Name) + statusCollector.PlaylistBeginIdle <- true + playbackFinished <- nil +} diff --git a/radio/status.go b/radio/status.go new file mode 100644 index 0000000..7836815 --- /dev/null +++ b/radio/status.go @@ -0,0 +1,140 @@ +package main + +import ( + "code.octet-stream.net/broadcaster/internal/protocol" + "encoding/json" + "golang.org/x/net/websocket" + "time" +) + +type BeginDelayStatus struct { + Playlist string + Seconds int + Filename string +} + +type BeginWaitForChannelStatus struct { + Playlist string + Filename string +} + +type BeginPlaybackStatus struct { + Playlist string + Filename string +} + +type StatusCollector struct { + Websocket chan *websocket.Conn + PlaylistBeginIdle chan bool + PlaylistBeginDelay chan BeginDelayStatus + PlaylistBeginWaitForChannel chan BeginWaitForChannelStatus + PlaylistBeginPlayback chan BeginPlaybackStatus + PTT chan bool + COS chan bool + Config chan RadioConfig + FilesInSync chan bool +} + +var statusCollector = NewStatusCollector() + +func NewStatusCollector() StatusCollector { + sc := StatusCollector{ + Websocket: make(chan *websocket.Conn), + PlaylistBeginIdle: make(chan bool), + PlaylistBeginDelay: make(chan BeginDelayStatus), + PlaylistBeginWaitForChannel: make(chan BeginWaitForChannelStatus), + PlaylistBeginPlayback: make(chan BeginPlaybackStatus), + PTT: make(chan bool), + COS: make(chan bool), + Config: make(chan RadioConfig), + FilesInSync: make(chan bool), + } + go runStatusCollector(sc) + return sc +} + +func runStatusCollector(sc StatusCollector) { + config := <-sc.Config + var msg protocol.StatusMessage + var lastSent protocol.StatusMessage + msg.T = protocol.StatusType + msg.TimeZone = config.TimeZone + msg.Status = protocol.StatusIdle + var ws *websocket.Conn + // Go 1.23: no need to stop tickers when finished + var ticker = time.NewTicker(time.Second * time.Duration(30)) + + for { + select { + case newWebsocket := <-sc.Websocket: + ws = newWebsocket + case <-ticker.C: + // should always be ticking at 1 second for these + if msg.Status == protocol.StatusDelay { + if msg.DelaySecondsRemaining > 0 { + msg.DelaySecondsRemaining -= 1 + } + } + if msg.Status == protocol.StatusChannelInUse { + msg.WaitingForChannelSeconds += 1 + } + if msg.Status == protocol.StatusPlaying { + msg.PlaybackSecondsElapsed += 1 + } + case <-sc.PlaylistBeginIdle: + msg.Status = protocol.StatusIdle + msg.DelaySecondsRemaining = 0 + msg.WaitingForChannelSeconds = 0 + msg.PlaybackSecondsElapsed = 0 + msg.Playlist = "" + msg.Filename = "" + // Update things more slowly when nothing's playing + ticker = time.NewTicker(time.Second * time.Duration(30)) + case delay := <-sc.PlaylistBeginDelay: + msg.Status = protocol.StatusDelay + msg.DelaySecondsRemaining = delay.Seconds + msg.WaitingForChannelSeconds = 0 + msg.PlaybackSecondsElapsed = 0 + msg.Playlist = delay.Playlist + msg.Filename = delay.Filename + // Align ticker with start of state change, make sure it's faster + ticker = time.NewTicker(time.Second * time.Duration(1)) + case wait := <-sc.PlaylistBeginWaitForChannel: + msg.Status = protocol.StatusChannelInUse + msg.DelaySecondsRemaining = 0 + msg.WaitingForChannelSeconds = 0 + msg.PlaybackSecondsElapsed = 0 + msg.Playlist = wait.Playlist + msg.Filename = wait.Filename + ticker = time.NewTicker(time.Second * time.Duration(1)) + case playback := <-sc.PlaylistBeginPlayback: + msg.Status = protocol.StatusPlaying + msg.DelaySecondsRemaining = 0 + msg.WaitingForChannelSeconds = 0 + msg.PlaybackSecondsElapsed = 0 + msg.Playlist = playback.Playlist + msg.Filename = playback.Filename + ticker = time.NewTicker(time.Second * time.Duration(1)) + case ptt := <-sc.PTT: + msg.PTT = ptt + case cos := <-sc.COS: + msg.COS = cos + case inSync := <-sc.FilesInSync: + msg.FilesInSync = inSync + } + msg.LocalTime = time.Now().Format(protocol.LocalTimeFormat) + msg.COS = cos.COSValue() + + if msg == lastSent { + continue + } + if ws != nil { + msgJson, _ := json.Marshal(msg) + if _, err := ws.Write(msgJson); err != nil { + // If websocket has failed, wait 'til we get a new one + ws = nil + } + lastSent = msg + } + } +} diff --git a/server/command.go b/server/command.go new file mode 100644 index 0000000..346e842 --- /dev/null +++ b/server/command.go @@ -0,0 +1,53 @@ +package main + +import ( + "code.octet-stream.net/broadcaster/internal/protocol" + "encoding/json" + "golang.org/x/net/websocket" + "sync" +) + +type CommandRouter struct { + connsMutex sync.Mutex + conns map[int]*websocket.Conn +} + +var commandRouter CommandRouter + +func InitCommandRouter() { + commandRouter.conns = make(map[int]*websocket.Conn) +} + +func (c *CommandRouter) AddWebsocket(radioId int, ws *websocket.Conn) { + c.connsMutex.Lock() + defer c.connsMutex.Unlock() + c.conns[radioId] = ws +} + +func (c *CommandRouter) RemoveWebsocket(ws *websocket.Conn) { + c.connsMutex.Lock() + defer c.connsMutex.Unlock() + key := -1 + for k, v := range c.conns { + if v == ws { + key = k + } + } + if key != -1 { + delete(c.conns, key) + } + +} + +func (c *CommandRouter) Stop(radioId int) { + c.connsMutex.Lock() + defer c.connsMutex.Unlock() + ws := c.conns[radioId] + if ws != nil { + stop := protocol.StopMessage{ + T: protocol.StopType, + } + msg, _ := json.Marshal(stop) + ws.Write(msg) + } +} diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..4a4866c --- /dev/null +++ b/server/config.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "log" + + "github.com/BurntSushi/toml" +) + +type ServerConfig struct { + BindAddress string + Port int + SqliteDB string + AudioFilesPath string +} + +func NewServerConfig() ServerConfig { + return ServerConfig{ + BindAddress: "0.0.0.0", + Port: 55134, + SqliteDB: "", + AudioFilesPath: "", + } +} + +func (c *ServerConfig) LoadFromFile(path string) { + _, err := toml.DecodeFile(path, &c) + if err != nil { + log.Fatal("could not read config file for reading at path:", path, err) + } + err = c.Validate() + if err != nil { + log.Fatal(err) + } +} + +func (c *ServerConfig) Validate() error { + if c.SqliteDB == "" { + return errors.New("Configuration must provide SqliteDB") + } + if c.AudioFilesPath == "" { + return errors.New("Configuration must provide AudioFilesPath") + } + return nil +} diff --git a/server/database.go b/server/database.go new file mode 100644 index 0000000..1312208 --- /dev/null +++ b/server/database.go @@ -0,0 +1,247 @@ +package main + +import ( + "database/sql" + "errors" + "log" + _ "modernc.org/sqlite" + "time" +) + +type Database struct { + sqldb *sql.DB +} + +var db Database + +func InitDatabase() { + sqldb, err := sql.Open("sqlite", config.SqliteDB) + if err != nil { + log.Fatal(err) + } + db.sqldb = sqldb + + _, err = db.sqldb.Exec("PRAGMA journal_mode = WAL") + if err != nil { + log.Fatal(err) + } + + _, err = db.sqldb.Exec("PRAGMA foreign_keys = ON") + if err != nil { + log.Fatal(err) + } + + _, err = db.sqldb.Exec("PRAGMA busy_timeout = 5000") + if err != nil { + log.Fatal(err) + } + + sqlStmt := ` + CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT, username TEXT, created TIMESTAMP, expiry TIMESTAMP); + CREATE TABLE IF NOT EXISTS playlists (id INTEGER PRIMARY KEY AUTOINCREMENT, enabled INTEGER, name TEXT, start_time TEXT); + CREATE TABLE IF NOT EXISTS playlist_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id INTEGER, position INTEGER, filename TEXT, delay_seconds INTEGER, is_relative INTEGER, CONSTRAINT fk_playlists FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS radios (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, token TEXT); + CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, is_admin INTEGER); + ` + _, err = db.sqldb.Exec(sqlStmt) + if err != nil { + log.Printf("%q: %s\n", err, sqlStmt) + return + } +} + +func (d *Database) CloseDatabase() { + d.sqldb.Close() +} + +func (d *Database) InsertSession(user string, token string, expiry time.Time) { + _, err := d.sqldb.Exec("INSERT INTO sessions (token, username, created, expiry) values (?, ?, CURRENT_TIMESTAMP, ?)", token, user, expiry) + if err != nil { + log.Fatal(err) + } +} + +func (d *Database) GetUserNameForSession(token string) (string, error) { + var username string + err := d.sqldb.QueryRow("SELECT username FROM sessions WHERE token = ? AND expiry > CURRENT_TIMESTAMP", token).Scan(&username) + if err != nil { + return "", errors.New("no matching token") + } + return username, nil +} + +func (d *Database) GetUser(username string) (User, error) { + var user User + err := d.sqldb.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", username).Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin) + if err != nil { + return User{}, errors.New("no user with that username") + } + return user, nil +} + +func (d *Database) GetUsers() []User { + ret := make([]User, 0) + rows, err := d.sqldb.Query("SELECT id, username, password_hash, is_admin FROM users ORDER BY username ASC") + if err != nil { + return ret + } + defer rows.Close() + for rows.Next() { + var u User + if err := rows.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin); err != nil { + return ret + } + ret = append(ret, u) + } + return ret +} + +func (d *Database) SetUserPassword(username string, passwordHash string) { + d.sqldb.Exec("UPDATE users SET password_hash = ? WHERE username = ?", passwordHash, username) +} + +func (d *Database) ClearOtherSessions(username string, token string) { + d.sqldb.Exec("DELETE FROM sessions WHERE username = ? AND token != ?", username, token) +} + +func (d *Database) SetUserIsAdmin(username string, isAdmin bool) { + d.sqldb.Exec("UPDATE users SET is_admin = ? WHERE username = ?", isAdmin, username) +} + +func (d *Database) CreateUser(user User) error { + _, err := d.sqldb.Exec("INSERT INTO users (username, password_hash, is_admin) values (?, ?, ?)", user.Username, user.PasswordHash, user.IsAdmin) + return err +} + +func (d *Database) DeleteUser(username string) error { + _, err := d.sqldb.Exec("DELETE FROM users WHERE username = ?", username) + return err +} + +func (d *Database) CreatePlaylist(playlist Playlist) int { + var id int + tx, _ := d.sqldb.Begin() + _, err := tx.Exec("INSERT INTO playlists (enabled, name, start_time) values (?, ?, ?)", playlist.Enabled, playlist.Name, playlist.StartTime) + if err != nil { + log.Fatal(err) + } + err = tx.QueryRow("SELECT last_insert_rowid()").Scan(&id) + if err != nil { + log.Fatal(err) + } + err = tx.Commit() + if err != nil { + log.Fatal(err) + } + return id +} + +func (d *Database) DeletePlaylist(playlistId int) { + d.sqldb.Exec("DELETE FROM playlists WHERE id = ?", playlistId) +} + +func (d *Database) GetPlaylists() []Playlist { + ret := make([]Playlist, 0) + rows, err := d.sqldb.Query("SELECT id, enabled, name, start_time FROM playlists ORDER BY id ASC") + if err != nil { + return ret + } + defer rows.Close() + for rows.Next() { + var p Playlist + if err := rows.Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime); err != nil { + return ret + } + ret = append(ret, p) + } + return ret +} + +func (d *Database) GetPlaylist(playlistId int) (Playlist, error) { + var p Playlist + err := d.sqldb.QueryRow("SELECT id, enabled, name, start_time FROM playlists WHERE id = ?", playlistId).Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime) + if err != nil { + return p, err + } + return p, nil +} + +func (d *Database) UpdatePlaylist(playlist Playlist) { + d.sqldb.Exec("UPDATE playlists SET enabled = ?, name = ?, start_time = ? WHERE id = ?", playlist.Enabled, playlist.Name, playlist.StartTime, playlist.Id) +} + +func (d *Database) SetEntriesForPlaylist(entries []PlaylistEntry, playlistId int) { + tx, _ := d.sqldb.Begin() + _, err := tx.Exec("DELETE FROM playlist_entries WHERE playlist_id = ?", playlistId) + for _, e := range entries { + _, err = tx.Exec("INSERT INTO playlist_entries (playlist_id, position, filename, delay_seconds, is_relative) values (?, ?, ?, ?, ?)", playlistId, e.Position, e.Filename, e.DelaySeconds, e.IsRelative) + if err != nil { + log.Fatal(err) + } + } + tx.Commit() // ignore errors +} + +func (d *Database) GetEntriesForPlaylist(playlistId int) []PlaylistEntry { + ret := make([]PlaylistEntry, 0) + rows, err := d.sqldb.Query("SELECT id, position, filename, delay_seconds, is_relative FROM playlist_entries WHERE playlist_id = ? ORDER by position ASC", playlistId) + if err != nil { + return ret + } + defer rows.Close() + for rows.Next() { + var entry PlaylistEntry + if err := rows.Scan(&entry.Id, &entry.Position, &entry.Filename, &entry.DelaySeconds, &entry.IsRelative); err != nil { + return ret + } + ret = append(ret, entry) + } + return ret +} + +func (d *Database) GetRadio(radioId int) (Radio, error) { + var r Radio + err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE id = ?", radioId).Scan(&r.Id, &r.Name, &r.Token) + if err != nil { + return r, err + } + return r, nil +} + +func (d *Database) GetRadioByToken(token string) (Radio, error) { + var r Radio + err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE token = ?", token).Scan(&r.Id, &r.Name, &r.Token) + if err != nil { + return r, err + } + return r, nil +} + +func (d *Database) GetRadios() []Radio { + ret := make([]Radio, 0) + rows, err := d.sqldb.Query("SELECT id, name, token FROM radios ORDER BY id ASC") + if err != nil { + return ret + } + defer rows.Close() + for rows.Next() { + var r Radio + if err := rows.Scan(&r.Id, &r.Name, &r.Token); err != nil { + return ret + } + ret = append(ret, r) + } + return ret +} + +func (d *Database) DeleteRadio(radioId int) { + d.sqldb.Exec("DELETE FROM radios WHERE id = ?", radioId) +} + +func (d *Database) CreateRadio(radio Radio) { + d.sqldb.Exec("INSERT INTO radios (name, token) values (?, ?)", radio.Name, radio.Token) +} + +func (d *Database) UpdateRadio(radio Radio) { + d.sqldb.Exec("UPDATE radios SET name = ?, token = ? WHERE id = ?", radio.Name, radio.Token, radio.Id) +} diff --git a/server/files.go b/server/files.go new file mode 100644 index 0000000..b452997 --- /dev/null +++ b/server/files.go @@ -0,0 +1,81 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "log" + "os" + "path/filepath" + "sync" +) + +type FileSpec struct { + Name string + Hash string +} + +type AudioFiles struct { + path string + list []FileSpec + changeWait chan bool + filesMutex sync.Mutex +} + +var files AudioFiles + +func InitAudioFiles(path string) { + files.changeWait = make(chan bool) + files.path = path + log.Println("initing audio files") + files.Refresh() + log.Println("done") +} + +func (r *AudioFiles) Refresh() { + entries, err := os.ReadDir(r.path) + if err != nil { + log.Println("couldn't read dir", r.path) + return + } + r.filesMutex.Lock() + defer r.filesMutex.Unlock() + r.list = nil + for _, file := range entries { + f, err := os.Open(filepath.Join(r.path, file.Name())) + if err != nil { + log.Println("couldn't open", file.Name()) + return + } + hash := sha256.New() + io.Copy(hash, f) + r.list = append(r.list, FileSpec{Name: file.Name(), Hash: hex.EncodeToString(hash.Sum(nil))}) + } + log.Println("Files updated", r.list) + close(files.changeWait) + files.changeWait = make(chan bool) +} + +func (r *AudioFiles) Path() string { + return r.path +} + +func (r *AudioFiles) Files() []FileSpec { + r.filesMutex.Lock() + defer r.filesMutex.Unlock() + return r.list +} + +func (r *AudioFiles) Delete(filename string) { + path := filepath.Join(r.path, filepath.Base(filename)) + if filepath.Clean(r.path) != filepath.Clean(path) { + os.Remove(path) + r.Refresh() + } +} + +func (r *AudioFiles) WatchForChanges() ([]FileSpec, chan bool) { + r.filesMutex.Lock() + defer r.filesMutex.Unlock() + return r.list, r.changeWait +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..8f76cbf --- /dev/null +++ b/server/main.go @@ -0,0 +1,537 @@ +package main + +import ( + "bufio" + "embed" + "flag" + "fmt" + "golang.org/x/net/websocket" + "html/template" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const version = "v1.0.0" +const formatString = "2006-01-02T15:04" + +//go:embed templates/* +var content embed.FS +//var content = os.DirFS("../broadcaster-server/") + +var config ServerConfig = NewServerConfig() + +func main() { + configFlag := flag.String("c", "", "path to configuration file") + addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit") + versionFlag := flag.Bool("v", false, "print version then exit") + flag.Parse() + + if *versionFlag { + fmt.Println("Broadcaster Server", version) + os.Exit(0) + } + if *configFlag == "" { + log.Fatal("must specify a configuration file with -c") + } + config.LoadFromFile(*configFlag) + + InitDatabase() + defer db.CloseDatabase() + + if *addUserFlag { + scanner := bufio.NewScanner(os.Stdin) + fmt.Println("Enter new admin username:") + if !scanner.Scan() { + os.Exit(1) + } + username := scanner.Text() + fmt.Println("Enter new admin password (will be printed in the clear):") + if !scanner.Scan() { + os.Exit(1) + } + password := scanner.Text() + if username == "" || password == "" { + fmt.Println("Both username and password must be specified") + os.Exit(1) + } + if err := users.CreateUser(username, password, true); err != nil { + log.Fatal(err) + } + os.Exit(0) + } + + log.Println("Broadcaster Server", version, "starting up") + InitCommandRouter() + InitPlaylists() + InitAudioFiles(config.AudioFilesPath) + InitServerStatus() + + // Public routes + + http.HandleFunc("/login", logInPage) + http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath)))) + + // Authenticated routes + + http.HandleFunc("/", homePage) + http.HandleFunc("/logout", logOutPage) + http.HandleFunc("/change-password", changePasswordPage) + + http.HandleFunc("/playlists/", playlistSection) + http.HandleFunc("/files/", fileSection) + http.HandleFunc("/radios/", radioSection) + + http.Handle("/radio-ws", websocket.Handler(RadioSync)) + http.Handle("/web-ws", websocket.Handler(WebSync)) + http.HandleFunc("/stop", stopPage) + + // Admin routes + + err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil) + if err != nil { + log.Fatal(err) + } +} + +type HeaderData struct { + SelectedMenu string + Username string +} + +func renderHeader(w http.ResponseWriter, selectedMenu string) { + tmpl := template.Must(template.ParseFS(content, "templates/header.html")) + data := HeaderData{ + SelectedMenu: selectedMenu, + Username: "username", + } + err := tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } +} + +func renderFooter(w http.ResponseWriter) { + tmpl := template.Must(template.ParseFS(content, "templates/footer.html")) + err := tmpl.Execute(w, nil) + if err != nil { + log.Fatal(err) + } +} + +type HomeData struct { + LoggedIn bool + Username string +} + +func homePage(w http.ResponseWriter, r *http.Request) { + renderHeader(w, "status") + tmpl := template.Must(template.ParseFS(content, "templates/index.html")) + data := HomeData{ + LoggedIn: true, + Username: "Bob", + } + tmpl.Execute(w, data) + renderFooter(w) +} + +type LogInData struct { + Error string +} + +func logInPage(w http.ResponseWriter, r *http.Request) { + log.Println("Log in page!") + r.ParseForm() + username := r.Form["username"] + password := r.Form["password"] + errText := "" + if username != nil { + user, err := users.Authenticate(username[0], password[0]) + if err != nil { + errText = "Incorrect login" + } else { + createSessionCookie(w, user.Username) + http.Redirect(w, r, "/", http.StatusFound) + return + } + } + + data := LogInData{ + Error: errText, + } + renderHeader(w, "") + tmpl := template.Must(template.ParseFS(content, "templates/login.html")) + tmpl.Execute(w, data) + renderFooter(w) +} + +func playlistSection(w http.ResponseWriter, r *http.Request) { + path := strings.Split(r.URL.Path, "/") + if len(path) != 3 { + http.NotFound(w, r) + return + } + if path[2] == "new" { + editPlaylistPage(w, r, 0) + } else if path[2] == "submit" && r.Method == "POST" { + submitPlaylist(w, r) + } else if path[2] == "delete" && r.Method == "POST" { + deletePlaylist(w, r) + } else if path[2] == "" { + playlistsPage(w, r) + } else { + id, err := strconv.Atoi(path[2]) + if err != nil { + http.NotFound(w, r) + return + } + editPlaylistPage(w, r, id) + } +} + +func fileSection(w http.ResponseWriter, r *http.Request) { + path := strings.Split(r.URL.Path, "/") + if len(path) != 3 { + http.NotFound(w, r) + return + } + if path[2] == "upload" { + uploadFile(w, r) + } else if path[2] == "delete" && r.Method == "POST" { + deleteFile(w, r) + } else if path[2] == "" { + filesPage(w, r) + } else { + http.NotFound(w, r) + return + } +} + +func radioSection(w http.ResponseWriter, r *http.Request) { + path := strings.Split(r.URL.Path, "/") + if len(path) != 3 { + http.NotFound(w, r) + return + } + if path[2] == "new" { + editRadioPage(w, r, 0) + } else if path[2] == "submit" && r.Method == "POST" { + submitRadio(w, r) + } else if path[2] == "delete" && r.Method == "POST" { + deleteRadio(w, r) + } else if path[2] == "" { + radiosPage(w, r) + } else { + id, err := strconv.Atoi(path[2]) + if err != nil { + http.NotFound(w, r) + return + } + editRadioPage(w, r, id) + } +} + +type ChangePasswordPageData struct { + Message string + ShowForm bool +} + +func changePasswordPage(w http.ResponseWriter, r *http.Request) { + user, err := currentUser(w, r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + var data ChangePasswordPageData + if r.Method == "POST" { + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + oldPassword := r.Form.Get("oldPassword") + newPassword := r.Form.Get("newPassword") + err = users.UpdatePassword(user.Username, oldPassword, newPassword) + if err != nil { + data.Message = "Failed to change password: " + err.Error() + data.ShowForm = true + } else { + data.Message = "Successfully changed password" + data.ShowForm = false + cookie, err := r.Cookie("broadcast_session") + if err == nil { + log.Println("clearing other sessions for username", user.Username, "token", cookie.Value) + db.ClearOtherSessions(user.Username, cookie.Value) + } + } + } else { + data.Message = "" + data.ShowForm = true + } + renderHeader(w, "change-password") + tmpl := template.Must(template.ParseFS(content, "templates/change_password.html")) + err = tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } + renderFooter(w) +} + +type PlaylistsPageData struct { + Playlists []Playlist +} + +func playlistsPage(w http.ResponseWriter, _ *http.Request) { + renderHeader(w, "playlists") + data := PlaylistsPageData{ + Playlists: db.GetPlaylists(), + } + tmpl := template.Must(template.ParseFS(content, "templates/playlists.html")) + err := tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } + renderFooter(w) +} + +type RadiosPageData struct { + Radios []Radio +} + +func radiosPage(w http.ResponseWriter, _ *http.Request) { + renderHeader(w, "radios") + data := RadiosPageData{ + Radios: db.GetRadios(), + } + tmpl := template.Must(template.ParseFS(content, "templates/radios.html")) + err := tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } + renderFooter(w) +} + +type EditPlaylistPageData struct { + Playlist Playlist + Entries []PlaylistEntry + Files []string +} + +func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) { + var data EditPlaylistPageData + for _, f := range files.Files() { + data.Files = append(data.Files, f.Name) + } + if id == 0 { + data.Playlist.Enabled = true + data.Playlist.Name = "New Playlist" + data.Playlist.StartTime = time.Now().Format(formatString) + data.Entries = append(data.Entries, PlaylistEntry{}) + } else { + playlist, err := db.GetPlaylist(id) + if err != nil { + http.NotFound(w, r) + return + } + data.Playlist = playlist + data.Entries = db.GetEntriesForPlaylist(id) + } + renderHeader(w, "radios") + tmpl := template.Must(template.ParseFS(content, "templates/playlist.html")) + tmpl.Execute(w, data) + renderFooter(w) +} + +func submitPlaylist(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err == nil { + var p Playlist + id, err := strconv.Atoi(r.Form.Get("playlistId")) + if err != nil { + return + } + _, err = time.Parse(formatString, r.Form.Get("playlistStartTime")) + if err != nil { + return + } + p.Id = id + p.Enabled = r.Form.Get("playlistEnabled") == "1" + p.Name = r.Form.Get("playlistName") + p.StartTime = r.Form.Get("playlistStartTime") + + delays := r.Form["delaySeconds"] + filenames := r.Form["filename"] + isRelatives := r.Form["isRelative"] + + entries := make([]PlaylistEntry, 0) + for i := range delays { + var e PlaylistEntry + delay, err := strconv.Atoi(delays[i]) + if err != nil { + return + } + e.DelaySeconds = delay + e.Position = i + e.IsRelative = isRelatives[i] == "1" + e.Filename = filenames[i] + entries = append(entries, e) + } + cleanedEntries := make([]PlaylistEntry, 0) + for _, e := range entries { + if e.DelaySeconds != 0 || e.Filename != "" { + cleanedEntries = append(cleanedEntries, e) + } + } + + if id != 0 { + db.UpdatePlaylist(p) + } else { + id = db.CreatePlaylist(p) + } + db.SetEntriesForPlaylist(cleanedEntries, id) + // Notify connected radios + playlists.NotifyChanges() + } + http.Redirect(w, r, "/playlists/", http.StatusFound) +} + +func deletePlaylist(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err == nil { + id, err := strconv.Atoi(r.Form.Get("playlistId")) + if err != nil { + return + } + db.DeletePlaylist(id) + playlists.NotifyChanges() + } + http.Redirect(w, r, "/playlists/", http.StatusFound) +} + +type EditRadioPageData struct { + Radio Radio +} + +func editRadioPage(w http.ResponseWriter, r *http.Request, id int) { + var data EditRadioPageData + if id == 0 { + data.Radio.Name = "New Radio" + data.Radio.Token = generateSession() + } else { + radio, err := db.GetRadio(id) + if err != nil { + http.NotFound(w, r) + return + } + data.Radio = radio + } + renderHeader(w, "radios") + tmpl := template.Must(template.ParseFS(content, "templates/radio.html")) + tmpl.Execute(w, data) + renderFooter(w) +} + +func submitRadio(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err == nil { + var radio Radio + id, err := strconv.Atoi(r.Form.Get("radioId")) + if err != nil { + return + } + radio.Id = id + radio.Name = r.Form.Get("radioName") + radio.Token = r.Form.Get("radioToken") + if id != 0 { + db.UpdateRadio(radio) + } else { + db.CreateRadio(radio) + } + } + http.Redirect(w, r, "/radios/", http.StatusFound) +} + +func deleteRadio(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err == nil { + id, err := strconv.Atoi(r.Form.Get("radioId")) + if err != nil { + return + } + db.DeleteRadio(id) + } + http.Redirect(w, r, "/radios/", http.StatusFound) +} + +type FilesPageData struct { + Files []FileSpec +} + +func filesPage(w http.ResponseWriter, _ *http.Request) { + renderHeader(w, "files") + data := FilesPageData{ + Files: files.Files(), + } + log.Println("file page data", data) + tmpl := template.Must(template.ParseFS(content, "templates/files.html")) + err := tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } + renderFooter(w) +} + +func deleteFile(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err == nil { + filename := r.Form.Get("filename") + if filename == "" { + return + } + files.Delete(filename) + } + http.Redirect(w, r, "/files/", http.StatusFound) +} + +func uploadFile(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(100 << 20) + file, handler, err := r.FormFile("file") + if err == nil { + path := filepath.Join(files.Path(), filepath.Base(handler.Filename)) + f, _ := os.Create(path) + defer f.Close() + io.Copy(f, file) + log.Println("uploaded file to", path) + files.Refresh() + } + http.Redirect(w, r, "/files/", http.StatusFound) +} + +func logOutPage(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w) + renderHeader(w, "") + tmpl := template.Must(template.ParseFS(content, "templates/logout.html")) + tmpl.Execute(w, nil) + renderFooter(w) +} + +func stopPage(w http.ResponseWriter, r *http.Request) { + _, err := currentUser(w, r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + r.ParseForm() + radioId, err := strconv.Atoi(r.Form.Get("radioId")) + if err != nil { + http.NotFound(w, r) + return + } + commandRouter.Stop(radioId) + http.Redirect(w, r, "/", http.StatusFound) +} diff --git a/server/model.go b/server/model.go new file mode 100644 index 0000000..11f94e7 --- /dev/null +++ b/server/model.go @@ -0,0 +1,29 @@ +package main + +type PlaylistEntry struct { + Id int + Position int + Filename string + DelaySeconds int + IsRelative bool +} + +type User struct { + Id int + Username string + PasswordHash string + IsAdmin bool +} + +type Playlist struct { + Id int + Enabled bool + Name string + StartTime string +} + +type Radio struct { + Id int + Name string + Token string +} diff --git a/server/playlist.go b/server/playlist.go new file mode 100644 index 0000000..f0a6c89 --- /dev/null +++ b/server/playlist.go @@ -0,0 +1,35 @@ +package main + +import ( + "sync" +) + +type Playlists struct { + changeWait chan bool + playlistMutex sync.Mutex +} + +var playlists Playlists + +func InitPlaylists() { + playlists.changeWait = make(chan bool) +} + +func (p *Playlists) GetPlaylists() []Playlist { + p.playlistMutex.Lock() + defer p.playlistMutex.Unlock() + return db.GetPlaylists() +} + +func (p *Playlists) WatchForChanges() ([]Playlist, chan bool) { + p.playlistMutex.Lock() + defer p.playlistMutex.Unlock() + return db.GetPlaylists(), p.changeWait +} + +func (p *Playlists) NotifyChanges() { + p.playlistMutex.Lock() + defer p.playlistMutex.Unlock() + close(p.changeWait) + p.changeWait = make(chan bool) +} diff --git a/server/radio_sync.go b/server/radio_sync.go new file mode 100644 index 0000000..66521f8 --- /dev/null +++ b/server/radio_sync.go @@ -0,0 +1,125 @@ +package main + +import ( + "code.octet-stream.net/broadcaster/internal/protocol" + "encoding/json" + "golang.org/x/net/websocket" + "log" +) + +func RadioSync(ws *websocket.Conn) { + log.Println("A websocket connected, I think") + buf := make([]byte, 16384) + + badRead := false + isAuthenticated := false + var radio Radio + for { + // Ignore any massively oversize messages + n, err := ws.Read(buf) + if err != nil { + if radio.Name != "" { + log.Println("Lost websocket to radio:", radio.Name) + status.RadioDisconnected(radio.Id) + } else { + log.Println("Lost unauthenticated websocket") + } + return + } + if n == len(buf) { + badRead = true + continue + } else if badRead { + badRead = false + continue + } + + t, msg, err := protocol.ParseMessage(buf[:n]) + if err != nil { + log.Println(err) + return + } + + if !isAuthenticated && t != protocol.AuthenticateType { + continue + } + + if t == protocol.AuthenticateType && !isAuthenticated { + authMsg := msg.(protocol.AuthenticateMessage) + r, err := db.GetRadioByToken(authMsg.Token) + if err != nil { + log.Println("Could not find radio for offered token", authMsg.Token) + } + radio = r + log.Println("Radio authenticated:", radio.Name) + isAuthenticated = true + commandRouter.AddWebsocket(r.Id, ws) + defer commandRouter.RemoveWebsocket(ws) + + go KeepFilesUpdated(ws) + go KeepPlaylistsUpdated(ws) + } + + if t == protocol.StatusType { + statusMsg := msg.(protocol.StatusMessage) + log.Println("Received Status from", radio.Name, ":", statusMsg) + status.MergeStatus(radio.Id, statusMsg) + } + } +} + +func sendPlaylistsMessageToRadio(ws *websocket.Conn, p []Playlist) error { + playlistSpecs := make([]protocol.PlaylistSpec, 0) + for _, v := range p { + if v.Enabled { + entrySpecs := make([]protocol.EntrySpec, 0) + for _, e := range db.GetEntriesForPlaylist(v.Id) { + entrySpecs = append(entrySpecs, protocol.EntrySpec{Filename: e.Filename, DelaySeconds: e.DelaySeconds, IsRelative: e.IsRelative}) + } + playlistSpecs = append(playlistSpecs, protocol.PlaylistSpec{Id: v.Id, Name: v.Name, StartTime: v.StartTime, Entries: entrySpecs}) + } + } + playlists := protocol.PlaylistsMessage{ + T: protocol.PlaylistsType, + Playlists: playlistSpecs, + } + msg, _ := json.Marshal(playlists) + _, err := ws.Write(msg) + return err +} + +func KeepPlaylistsUpdated(ws *websocket.Conn) { + for { + p, ch := playlists.WatchForChanges() + err := sendPlaylistsMessageToRadio(ws, p) + if err != nil { + return + } + <-ch + } +} + +func sendFilesMessageToRadio(ws *websocket.Conn, f []FileSpec) error { + specs := make([]protocol.FileSpec, 0) + for _, v := range f { + specs = append(specs, protocol.FileSpec{Name: v.Name, Hash: v.Hash}) + } + files := protocol.FilesMessage{ + T: protocol.FilesType, + Files: specs, + } + msg, _ := json.Marshal(files) + _, err := ws.Write(msg) + return err +} + +func KeepFilesUpdated(ws *websocket.Conn) { + for { + f, ch := files.WatchForChanges() + err := sendFilesMessageToRadio(ws, f) + if err != nil { + return + } + <-ch + } +} diff --git a/server/session.go b/server/session.go new file mode 100644 index 0000000..a097989 --- /dev/null +++ b/server/session.go @@ -0,0 +1,46 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "log" + "net/http" + "time" +) + +func generateSession() string { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + log.Fatal(err) + } + return hex.EncodeToString(b) +} + +func currentUser(_ http.ResponseWriter, r *http.Request) (User, error) { + cookie, e := r.Cookie("broadcast_session") + if e != nil { + return User{}, e + } + + return users.GetUserForSession(cookie.Value) +} + +func createSessionCookie(w http.ResponseWriter, username string) { + sess := generateSession() + log.Println("Generated a random session", sess) + expiration := time.Now().Add(365 * 24 * time.Hour) + cookie := http.Cookie{Name: "broadcast_session", Value: sess, Expires: expiration, SameSite: http.SameSiteLaxMode} + db.InsertSession(username, sess, expiration) + http.SetCookie(w, &cookie) +} + +func clearSessionCookie(w http.ResponseWriter) { + c := &http.Cookie{ + Name: "broadcast_session", + Value: "", + MaxAge: -1, + HttpOnly: true, + } + http.SetCookie(w, c) +} diff --git a/server/status.go b/server/status.go new file mode 100644 index 0000000..b2edd49 --- /dev/null +++ b/server/status.go @@ -0,0 +1,54 @@ +package main + +import ( + "code.octet-stream.net/broadcaster/internal/protocol" + "sync" +) + +type ServerStatus struct { + statuses map[int]protocol.StatusMessage + statusesMutex sync.Mutex + changeWait chan bool +} + +var status ServerStatus + +func InitServerStatus() { + status = ServerStatus{ + statuses: make(map[int]protocol.StatusMessage), + changeWait: make(chan bool), + } +} + +func (s *ServerStatus) MergeStatus(radioId int, status protocol.StatusMessage) { + s.statusesMutex.Lock() + defer s.statusesMutex.Unlock() + s.statuses[radioId] = status + s.TriggerChange() +} + +func (s *ServerStatus) RadioDisconnected(radioId int) { + s.statusesMutex.Lock() + defer s.statusesMutex.Unlock() + delete(s.statuses, radioId) + s.TriggerChange() +} + +func (s *ServerStatus) TriggerChange() { + close(s.changeWait) + s.changeWait = make(chan bool) +} + +func (s *ServerStatus) Statuses() map[int]protocol.StatusMessage { + s.statusesMutex.Lock() + defer s.statusesMutex.Unlock() + c := make(map[int]protocol.StatusMessage) + for k, v := range s.statuses { + c[k] = v + } + return c +} + +func (s *ServerStatus) ChangeChannel() chan bool { + return s.changeWait +} diff --git a/server/templates/change_password.html b/server/templates/change_password.html new file mode 100644 index 0000000..bab3a82 --- /dev/null +++ b/server/templates/change_password.html @@ -0,0 +1,14 @@ + +

Change Password

+ {{if ne .Message ""}} +

{{.Message}}

+ {{end}} + {{if .ShowForm}} +
+
+
+
+
+ +
+ {{end}} diff --git a/server/templates/files.html b/server/templates/files.html new file mode 100644 index 0000000..c86c6ce --- /dev/null +++ b/server/templates/files.html @@ -0,0 +1,15 @@ + +

Files! List

+

All files can be downloaded from the public file listing.

+
    + {{range .Files}} +
  • {{.Name}}
  • + {{end}} +
+

Upload New File

+

+

+ + +
+

diff --git a/server/templates/footer.html b/server/templates/footer.html new file mode 100644 index 0000000..df60a12 --- /dev/null +++ b/server/templates/footer.html @@ -0,0 +1,4 @@ +
+
+ + diff --git a/server/templates/header.html b/server/templates/header.html new file mode 100644 index 0000000..49d734f --- /dev/null +++ b/server/templates/header.html @@ -0,0 +1,117 @@ + + + + + + Broadcaster + + + +
+ {{if .SelectedMenu}} + + {{end}} +
diff --git a/server/templates/index.html b/server/templates/index.html new file mode 100644 index 0000000..b688947 --- /dev/null +++ b/server/templates/index.html @@ -0,0 +1,39 @@ + + +

Welcome!

+ {{if .LoggedIn}} +

Your username is: {{.Username}}.

+

Log Out

+ {{else}} +

Log In

+ {{end}} +

File Management

+

Playlist Management

+

Radio Management

+

Connected Radios

+
+ Loading... +
diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..2211b2f --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,12 @@ + +

Log In

+
+ {{if ne .Error ""}} +

{{.Error}}

+ {{end}} +
+
+
+
+ +
diff --git a/server/templates/logout.html b/server/templates/logout.html new file mode 100644 index 0000000..8b7ee1c --- /dev/null +++ b/server/templates/logout.html @@ -0,0 +1,3 @@ + +

Logged Out

+

Log In again

diff --git a/server/templates/playlist.html b/server/templates/playlist.html new file mode 100644 index 0000000..d778250 --- /dev/null +++ b/server/templates/playlist.html @@ -0,0 +1,90 @@ + + + +

A specific playlist

+

+ {{if .Playlist.Id}} + Edit Playlist + {{else}} + Create New Playlist + {{end}} +

+
+ +

+ +
+

+

+ + +

+

+ + +

+

Playlist Items

+ {{range .Entries}} +

+ Wait until + + seconds from + + then play + + (Delete Item) +

+ {{end}} +

+ Add Item +

+

+ +

+
+ {{if .Playlist.Id}} +

Delete

+
+ +

+ +

+
+ {{end}} + diff --git a/server/templates/playlists.html b/server/templates/playlists.html new file mode 100644 index 0000000..e1ba60f --- /dev/null +++ b/server/templates/playlists.html @@ -0,0 +1,8 @@ + +

Playlists!

+
    + {{range .Playlists}} +
  • {{.Name}} {{.StartTime}} (Edit)
  • + {{end}} +
+

Add New Playlist

diff --git a/server/templates/radio.html b/server/templates/radio.html new file mode 100644 index 0000000..c2ec9dd --- /dev/null +++ b/server/templates/radio.html @@ -0,0 +1,32 @@ + +

A specific radio

+

+ {{if .Radio.Id}} + Edit Radio + {{else}} + Register New Radio + {{end}} +

+
+ +

+ + +

+

+ Authentication token: {{.Radio.Token}} + +

+

+ +

+
+ {{if .Radio.Id}} +

Delete

+
+ +

+ +

+
+ {{end}} diff --git a/server/templates/radios.html b/server/templates/radios.html new file mode 100644 index 0000000..0c1288f --- /dev/null +++ b/server/templates/radios.html @@ -0,0 +1,8 @@ + +

Radios

+
    + {{range .Radios}} +
  • {{.Name}} {{.Token}} (Edit)
  • + {{end}} +
+

Register New Radio

diff --git a/server/templates/radios.partial.html b/server/templates/radios.partial.html new file mode 100644 index 0000000..010fe0a --- /dev/null +++ b/server/templates/radios.partial.html @@ -0,0 +1,84 @@ +{{if .Radios}} +{{range .Radios}} + + + + + + + + + + + + + + +
+ {{.Name}} +
+ + + + + + + + + + + + + +
+ Local Time + + {{.LocalTime}} +
+ Time Zone + + {{.TimeZone}} +
+ Files In Sync + + {{if .FilesInSync}} Yes {{else}} No {{end}} +
+
+ {{.ChannelState}} + + + + + + + + + + + + + + +
+ Playlist: + + {{.Playlist}} +
+ File: + + {{.File}} +
+ Status: + + {{.Status}} +
+
+
+ + +
+
+{{end}} +{{else}} +

There are no radios online.

+{{end}} diff --git a/server/user.go b/server/user.go new file mode 100644 index 0000000..275f0ec --- /dev/null +++ b/server/user.go @@ -0,0 +1,82 @@ +package main + +import ( + "errors" + "golang.org/x/crypto/bcrypt" +) + +var users Users + +type Users struct{} + +func (u *Users) GetUserForSession(token string) (User, error) { + username, err := db.GetUserNameForSession(token) + if err != nil { + return User{}, err + } + user, err := db.GetUser(username) + if err != nil { + return User{}, err + } + return user, nil +} + +func (u *Users) Authenticate(username string, clearPassword string) (User, error) { + user, err := db.GetUser(username) + if err != nil { + return User{}, err + } + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(clearPassword)) + if err != nil { + return User{}, err + } + return user, nil +} + +func (u *Users) CreateUser(username string, clearPassword string, isAdmin bool) error { + if clearPassword == "" { + return errors.New("password cannot be empty") + } + hashed, err := bcrypt.GenerateFromPassword([]byte(clearPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + return db.CreateUser(User{ + Id: 0, + Username: username, + PasswordHash: string(hashed), + IsAdmin: isAdmin, + }) +} + +func (u *Users) DeleteUser(username string) { + db.DeleteUser(username) +} + +func (u *Users) UpdatePassword(username string, oldClearPassword string, newClearPassword string) error { + user, err := db.GetUser(username) + if err != nil { + return err + } + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldClearPassword)) + if err != nil { + return errors.New("old password is incorrect") + } + if newClearPassword == "" { + return errors.New("password cannot be empty") + } + hashed, err := bcrypt.GenerateFromPassword([]byte(newClearPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + db.SetUserPassword(username, string(hashed)) + return nil +} + +func (u *Users) UpdateIsAdmin(username string, isAdmin bool) { + db.SetUserIsAdmin(username, isAdmin) +} + +func (u *Users) Users() []User { + return db.GetUsers() +} diff --git a/server/web_sync.go b/server/web_sync.go new file mode 100644 index 0000000..a6b1a6e --- /dev/null +++ b/server/web_sync.go @@ -0,0 +1,162 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "sort" + "strconv" + "strings" + + "code.octet-stream.net/broadcaster/internal/protocol" + "golang.org/x/net/websocket" +) + +func WebSync(ws *websocket.Conn) { + log.Println("A web user connected with WebSocket") + buf := make([]byte, 16384) + + badRead := false + isAuthenticated := false + var user User + for { + // Ignore any massively oversize messages + n, err := ws.Read(buf) + if err != nil { + if user.Username != "" { + log.Println("Lost websocket to user:", user) + } else { + log.Println("Lost unauthenticated website websocket") + } + return + } + if n == len(buf) { + badRead = true + continue + } else if badRead { + badRead = false + continue + } + + if !isAuthenticated { + token := string(buf[:n]) + u, err := users.GetUserForSession(token) + if err != nil { + log.Println("Could not find user for offered token", token, err) + ws.Close() + return + } + user = u + log.Println("User authenticated:", user) + isAuthenticated = true + + go KeepWebUpdated(ws) + + // send initial playlists message + err = sendRadioStatusToWeb(ws) + if err != nil { + return + } + } + } +} + +type WebStatusData struct { + Radios []WebRadioStatus +} + +type WebRadioStatus struct { + Name string + LocalTime string + TimeZone string + ChannelClass string + ChannelState string + Playlist string + File string + Status string + Id string + DisableCancel bool + FilesInSync bool +} + +func sendRadioStatusToWeb(ws *websocket.Conn) error { + webStatuses := make([]WebRadioStatus, 0) + radioStatuses := status.Statuses() + keys := make([]int, 0) + for i := range radioStatuses { + keys = append(keys, i) + } + sort.Ints(keys) + for _, i := range keys { + v := radioStatuses[i] + radio, err := db.GetRadio(i) + if err != nil { + continue + } + var channelClass, channelState string + if v.PTT { + channelClass = "ptt" + channelState = "PTT" + } else if v.COS { + channelClass = "cos" + channelState = "RX" + } else { + channelClass = "clear" + channelState = "CLEAR" + } + var statusText string + var disableCancel bool + if v.Status == protocol.StatusIdle { + statusText = "Idle" + disableCancel = true + } else if v.Status == protocol.StatusDelay { + statusText = fmt.Sprintf("Performing delay before transmit: %ds remain", v.DelaySecondsRemaining) + disableCancel = false + } else if v.Status == protocol.StatusChannelInUse { + statusText = fmt.Sprintf("Waiting for channel to clear: %ds", v.WaitingForChannelSeconds) + disableCancel = false + } else if v.Status == protocol.StatusPlaying { + statusText = fmt.Sprintf("Playing: %d:%02d", v.PlaybackSecondsElapsed/60, v.PlaybackSecondsElapsed%60) + disableCancel = false + } + playlist := v.Playlist + if playlist == "" { + playlist = "-" + } + filename := v.Filename + if filename == "" { + filename = "-" + } + webStatuses = append(webStatuses, WebRadioStatus{ + Name: radio.Name, + LocalTime: v.LocalTime, + TimeZone: v.TimeZone, + ChannelClass: channelClass, + ChannelState: channelState, + Playlist: playlist, + File: filename, + Status: statusText, + Id: strconv.Itoa(i), + DisableCancel: disableCancel, + FilesInSync: v.FilesInSync, + }) + } + data := WebStatusData{ + Radios: webStatuses, + } + buf := new(strings.Builder) + tmpl := template.Must(template.ParseFS(content, "templates/radios.partial.html")) + tmpl.Execute(buf, data) + _, err := ws.Write([]byte(buf.String())) + return err +} + +func KeepWebUpdated(ws *websocket.Conn) { + for { + <-status.ChangeChannel() + err := sendRadioStatusToWeb(ws) + if err != nil { + return + } + } +}