]> code.octet-stream.net Git - broadcaster/blob - broadcaster-server/main.go
5a3cbb9dd8f07d60c3f276300ac9dbd8cf0c38bd
[broadcaster] / broadcaster-server / main.go
1 package main
2
3 import (
4 "bufio"
5 _ "embed"
6 "flag"
7 "fmt"
8 "golang.org/x/net/websocket"
9 "html/template"
10 "io"
11 "log"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strconv"
16 "strings"
17 "time"
18 )
19
20 const version = "v1.0.0"
21 const formatString = "2006-01-02T15:04"
22
23 // //go:embed templates/*
24 //var content embed.FS
25 var content = os.DirFS("../broadcaster-server/")
26
27 var config ServerConfig = NewServerConfig()
28
29 func main() {
30 configFlag := flag.String("c", "", "path to configuration file")
31 addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
32 versionFlag := flag.Bool("v", false, "print version then exit")
33 flag.Parse()
34
35 if *versionFlag {
36 fmt.Println("Broadcaster Server", version)
37 os.Exit(0)
38 }
39 if *configFlag == "" {
40 log.Fatal("must specify a configuration file with -c")
41 }
42 config.LoadFromFile(*configFlag)
43
44 InitDatabase()
45 defer db.CloseDatabase()
46
47 if *addUserFlag {
48 scanner := bufio.NewScanner(os.Stdin)
49 fmt.Println("Enter new admin username:")
50 if !scanner.Scan() {
51 os.Exit(1)
52 }
53 username := scanner.Text()
54 fmt.Println("Enter new admin password (will be printed in the clear):")
55 if !scanner.Scan() {
56 os.Exit(1)
57 }
58 password := scanner.Text()
59 if username == "" || password == "" {
60 fmt.Println("Both username and password must be specified")
61 os.Exit(1)
62 }
63 if err := users.CreateUser(username, password, true); err != nil {
64 log.Fatal(err)
65 }
66 os.Exit(0)
67 }
68
69 log.Println("Broadcaster Server", version, "starting up")
70 InitCommandRouter()
71 InitPlaylists()
72 InitAudioFiles(config.AudioFilesPath)
73 InitServerStatus()
74
75 // Public routes
76
77 http.HandleFunc("/login", logInPage)
78 http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath))))
79
80 // Authenticated routes
81
82 http.HandleFunc("/", homePage)
83 http.HandleFunc("/logout", logOutPage)
84 http.HandleFunc("/change-password", changePasswordPage)
85
86 http.HandleFunc("/playlists/", playlistSection)
87 http.HandleFunc("/files/", fileSection)
88 http.HandleFunc("/radios/", radioSection)
89
90 http.Handle("/radio-ws", websocket.Handler(RadioSync))
91 http.Handle("/web-ws", websocket.Handler(WebSync))
92 http.HandleFunc("/stop", stopPage)
93
94 // Admin routes
95
96 err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
97 if err != nil {
98 log.Fatal(err)
99 }
100 }
101
102 type HeaderData struct {
103 SelectedMenu string
104 Username string
105 }
106
107 func renderHeader(w http.ResponseWriter, selectedMenu string) {
108 tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
109 data := HeaderData{
110 SelectedMenu: selectedMenu,
111 Username: "username",
112 }
113 err := tmpl.Execute(w, data)
114 if err != nil {
115 log.Fatal(err)
116 }
117 }
118
119 func renderFooter(w http.ResponseWriter) {
120 tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
121 err := tmpl.Execute(w, nil)
122 if err != nil {
123 log.Fatal(err)
124 }
125 }
126
127 type HomeData struct {
128 LoggedIn bool
129 Username string
130 }
131
132 func homePage(w http.ResponseWriter, r *http.Request) {
133 renderHeader(w, "status")
134 tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
135 data := HomeData{
136 LoggedIn: true,
137 Username: "Bob",
138 }
139 tmpl.Execute(w, data)
140 renderFooter(w)
141 }
142
143 type LogInData struct {
144 Error string
145 }
146
147 func logInPage(w http.ResponseWriter, r *http.Request) {
148 log.Println("Log in page!")
149 r.ParseForm()
150 username := r.Form["username"]
151 password := r.Form["password"]
152 errText := ""
153 if username != nil {
154 user, err := users.Authenticate(username[0], password[0])
155 if err != nil {
156 errText = "Incorrect login"
157 } else {
158 createSessionCookie(w, user.Username)
159 http.Redirect(w, r, "/", http.StatusFound)
160 return
161 }
162 }
163
164 data := LogInData{
165 Error: errText,
166 }
167 renderHeader(w, "")
168 tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
169 tmpl.Execute(w, data)
170 renderFooter(w)
171 }
172
173 func playlistSection(w http.ResponseWriter, r *http.Request) {
174 path := strings.Split(r.URL.Path, "/")
175 if len(path) != 3 {
176 http.NotFound(w, r)
177 return
178 }
179 if path[2] == "new" {
180 editPlaylistPage(w, r, 0)
181 } else if path[2] == "submit" && r.Method == "POST" {
182 submitPlaylist(w, r)
183 } else if path[2] == "delete" && r.Method == "POST" {
184 deletePlaylist(w, r)
185 } else if path[2] == "" {
186 playlistsPage(w, r)
187 } else {
188 id, err := strconv.Atoi(path[2])
189 if err != nil {
190 http.NotFound(w, r)
191 return
192 }
193 editPlaylistPage(w, r, id)
194 }
195 }
196
197 func fileSection(w http.ResponseWriter, r *http.Request) {
198 path := strings.Split(r.URL.Path, "/")
199 if len(path) != 3 {
200 http.NotFound(w, r)
201 return
202 }
203 if path[2] == "upload" {
204 uploadFile(w, r)
205 } else if path[2] == "delete" && r.Method == "POST" {
206 deleteFile(w, r)
207 } else if path[2] == "" {
208 filesPage(w, r)
209 } else {
210 http.NotFound(w, r)
211 return
212 }
213 }
214
215 func radioSection(w http.ResponseWriter, r *http.Request) {
216 path := strings.Split(r.URL.Path, "/")
217 if len(path) != 3 {
218 http.NotFound(w, r)
219 return
220 }
221 if path[2] == "new" {
222 editRadioPage(w, r, 0)
223 } else if path[2] == "submit" && r.Method == "POST" {
224 submitRadio(w, r)
225 } else if path[2] == "delete" && r.Method == "POST" {
226 deleteRadio(w, r)
227 } else if path[2] == "" {
228 radiosPage(w, r)
229 } else {
230 id, err := strconv.Atoi(path[2])
231 if err != nil {
232 http.NotFound(w, r)
233 return
234 }
235 editRadioPage(w, r, id)
236 }
237 }
238
239 type ChangePasswordPageData struct {
240 Message string
241 ShowForm bool
242 }
243
244 func changePasswordPage(w http.ResponseWriter, r *http.Request) {
245 user, err := currentUser(w, r)
246 if err != nil {
247 http.Redirect(w, r, "/login", http.StatusFound)
248 return
249 }
250 var data ChangePasswordPageData
251 if r.Method == "POST" {
252 err := r.ParseForm()
253 if err != nil {
254 w.WriteHeader(http.StatusBadRequest)
255 return
256 }
257 oldPassword := r.Form.Get("oldPassword")
258 newPassword := r.Form.Get("newPassword")
259 err = users.UpdatePassword(user.Username, oldPassword, newPassword)
260 if err != nil {
261 data.Message = "Failed to change password: " + err.Error()
262 data.ShowForm = true
263 } else {
264 data.Message = "Successfully changed password"
265 data.ShowForm = false
266 cookie, err := r.Cookie("broadcast_session")
267 if err == nil {
268 log.Println("clearing other sessions for username", user.Username, "token", cookie.Value)
269 db.ClearOtherSessions(user.Username, cookie.Value)
270 }
271 }
272 } else {
273 data.Message = ""
274 data.ShowForm = true
275 }
276 renderHeader(w, "change-password")
277 tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
278 err = tmpl.Execute(w, data)
279 if err != nil {
280 log.Fatal(err)
281 }
282 renderFooter(w)
283 }
284
285 type PlaylistsPageData struct {
286 Playlists []Playlist
287 }
288
289 func playlistsPage(w http.ResponseWriter, _ *http.Request) {
290 renderHeader(w, "playlists")
291 data := PlaylistsPageData{
292 Playlists: db.GetPlaylists(),
293 }
294 tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
295 err := tmpl.Execute(w, data)
296 if err != nil {
297 log.Fatal(err)
298 }
299 renderFooter(w)
300 }
301
302 type RadiosPageData struct {
303 Radios []Radio
304 }
305
306 func radiosPage(w http.ResponseWriter, _ *http.Request) {
307 renderHeader(w, "radios")
308 data := RadiosPageData{
309 Radios: db.GetRadios(),
310 }
311 tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
312 err := tmpl.Execute(w, data)
313 if err != nil {
314 log.Fatal(err)
315 }
316 renderFooter(w)
317 }
318
319 type EditPlaylistPageData struct {
320 Playlist Playlist
321 Entries []PlaylistEntry
322 Files []string
323 }
324
325 func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
326 var data EditPlaylistPageData
327 for _, f := range files.Files() {
328 data.Files = append(data.Files, f.Name)
329 }
330 if id == 0 {
331 data.Playlist.Enabled = true
332 data.Playlist.Name = "New Playlist"
333 data.Playlist.StartTime = time.Now().Format(formatString)
334 data.Entries = append(data.Entries, PlaylistEntry{})
335 } else {
336 playlist, err := db.GetPlaylist(id)
337 if err != nil {
338 http.NotFound(w, r)
339 return
340 }
341 data.Playlist = playlist
342 data.Entries = db.GetEntriesForPlaylist(id)
343 }
344 renderHeader(w, "radios")
345 tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
346 tmpl.Execute(w, data)
347 renderFooter(w)
348 }
349
350 func submitPlaylist(w http.ResponseWriter, r *http.Request) {
351 err := r.ParseForm()
352 if err == nil {
353 var p Playlist
354 id, err := strconv.Atoi(r.Form.Get("playlistId"))
355 if err != nil {
356 return
357 }
358 _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
359 if err != nil {
360 return
361 }
362 p.Id = id
363 p.Enabled = r.Form.Get("playlistEnabled") == "1"
364 p.Name = r.Form.Get("playlistName")
365 p.StartTime = r.Form.Get("playlistStartTime")
366
367 delays := r.Form["delaySeconds"]
368 filenames := r.Form["filename"]
369 isRelatives := r.Form["isRelative"]
370
371 entries := make([]PlaylistEntry, 0)
372 for i := range delays {
373 var e PlaylistEntry
374 delay, err := strconv.Atoi(delays[i])
375 if err != nil {
376 return
377 }
378 e.DelaySeconds = delay
379 e.Position = i
380 e.IsRelative = isRelatives[i] == "1"
381 e.Filename = filenames[i]
382 entries = append(entries, e)
383 }
384 cleanedEntries := make([]PlaylistEntry, 0)
385 for _, e := range entries {
386 if e.DelaySeconds != 0 || e.Filename != "" {
387 cleanedEntries = append(cleanedEntries, e)
388 }
389 }
390
391 if id != 0 {
392 db.UpdatePlaylist(p)
393 } else {
394 id = db.CreatePlaylist(p)
395 }
396 db.SetEntriesForPlaylist(cleanedEntries, id)
397 // Notify connected radios
398 playlists.NotifyChanges()
399 }
400 http.Redirect(w, r, "/playlists/", http.StatusFound)
401 }
402
403 func deletePlaylist(w http.ResponseWriter, r *http.Request) {
404 err := r.ParseForm()
405 if err == nil {
406 id, err := strconv.Atoi(r.Form.Get("playlistId"))
407 if err != nil {
408 return
409 }
410 db.DeletePlaylist(id)
411 playlists.NotifyChanges()
412 }
413 http.Redirect(w, r, "/playlists/", http.StatusFound)
414 }
415
416 type EditRadioPageData struct {
417 Radio Radio
418 }
419
420 func editRadioPage(w http.ResponseWriter, r *http.Request, id int) {
421 var data EditRadioPageData
422 if id == 0 {
423 data.Radio.Name = "New Radio"
424 data.Radio.Token = generateSession()
425 } else {
426 radio, err := db.GetRadio(id)
427 if err != nil {
428 http.NotFound(w, r)
429 return
430 }
431 data.Radio = radio
432 }
433 renderHeader(w, "radios")
434 tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
435 tmpl.Execute(w, data)
436 renderFooter(w)
437 }
438
439 func submitRadio(w http.ResponseWriter, r *http.Request) {
440 err := r.ParseForm()
441 if err == nil {
442 var radio Radio
443 id, err := strconv.Atoi(r.Form.Get("radioId"))
444 if err != nil {
445 return
446 }
447 radio.Id = id
448 radio.Name = r.Form.Get("radioName")
449 radio.Token = r.Form.Get("radioToken")
450 if id != 0 {
451 db.UpdateRadio(radio)
452 } else {
453 db.CreateRadio(radio)
454 }
455 }
456 http.Redirect(w, r, "/radios/", http.StatusFound)
457 }
458
459 func deleteRadio(w http.ResponseWriter, r *http.Request) {
460 err := r.ParseForm()
461 if err == nil {
462 id, err := strconv.Atoi(r.Form.Get("radioId"))
463 if err != nil {
464 return
465 }
466 db.DeleteRadio(id)
467 }
468 http.Redirect(w, r, "/radios/", http.StatusFound)
469 }
470
471 type FilesPageData struct {
472 Files []FileSpec
473 }
474
475 func filesPage(w http.ResponseWriter, _ *http.Request) {
476 renderHeader(w, "files")
477 data := FilesPageData{
478 Files: files.Files(),
479 }
480 log.Println("file page data", data)
481 tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
482 err := tmpl.Execute(w, data)
483 if err != nil {
484 log.Fatal(err)
485 }
486 renderFooter(w)
487 }
488
489 func deleteFile(w http.ResponseWriter, r *http.Request) {
490 err := r.ParseForm()
491 if err == nil {
492 filename := r.Form.Get("filename")
493 if filename == "" {
494 return
495 }
496 files.Delete(filename)
497 }
498 http.Redirect(w, r, "/files/", http.StatusFound)
499 }
500
501 func uploadFile(w http.ResponseWriter, r *http.Request) {
502 err := r.ParseMultipartForm(100 << 20)
503 file, handler, err := r.FormFile("file")
504 if err == nil {
505 path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
506 f, _ := os.Create(path)
507 defer f.Close()
508 io.Copy(f, file)
509 log.Println("uploaded file to", path)
510 files.Refresh()
511 }
512 http.Redirect(w, r, "/files/", http.StatusFound)
513 }
514
515 func logOutPage(w http.ResponseWriter, r *http.Request) {
516 clearSessionCookie(w)
517 renderHeader(w, "")
518 tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
519 tmpl.Execute(w, nil)
520 renderFooter(w)
521 }
522
523 func stopPage(w http.ResponseWriter, r *http.Request) {
524 _, err := currentUser(w, r)
525 if err != nil {
526 http.Redirect(w, r, "/login", http.StatusFound)
527 return
528 }
529 r.ParseForm()
530 radioId, err := strconv.Atoi(r.Form.Get("radioId"))
531 if err != nil {
532 http.NotFound(w, r)
533 return
534 }
535 commandRouter.Stop(radioId)
536 http.Redirect(w, r, "/", http.StatusFound)
537 }