X-Git-Url: https://code.octet-stream.net/broadcaster/blobdiff_plain/8320951221d45c5f5f3d387c5cb4b97d9fa2094c..33a19d553807d171f6ba9f4dafe30f43bc4bab5e:/broadcaster-server/main.go diff --git a/broadcaster-server/main.go b/broadcaster-server/main.go new file mode 100644 index 0000000..079f977 --- /dev/null +++ b/broadcaster-server/main.go @@ -0,0 +1,494 @@ +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 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 HomeData struct { + LoggedIn bool + Username string +} + +func homePage(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFS(content, "templates/index.html")) + data := HomeData{ + LoggedIn: true, + Username: "Bob", + } + tmpl.Execute(w, data) +} + +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, + } + + tmpl := template.Must(template.ParseFS(content, "templates/login.html")) + tmpl.Execute(w, data) +} + +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 + } + tmpl := template.Must(template.ParseFS(content, "templates/change_password.html")) + err = tmpl.Execute(w, data) + if err != nil { + log.Fatal(err) + } +} + +type PlaylistsPageData struct { + Playlists []Playlist +} + +func playlistsPage(w http.ResponseWriter, _ *http.Request) { + 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) + } +} + +type RadiosPageData struct { + Radios []Radio +} + +func radiosPage(w http.ResponseWriter, _ *http.Request) { + 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) + } +} + +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) + } + tmpl := template.Must(template.ParseFS(content, "templates/playlist.html")) + tmpl.Execute(w, data) +} + +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 + } + tmpl := template.Must(template.ParseFS(content, "templates/radio.html")) + tmpl.Execute(w, data) +} + +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) { + 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) + } +} + +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) + tmpl := template.Must(template.ParseFS(content, "templates/logout.html")) + tmpl.Execute(w, nil) +} + +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) +}