]> code.octet-stream.net Git - broadcaster/blob - server/main.go
Make headings consistent in UI
[broadcaster] / server / main.go
1 package main
2
3 import (
4 "bufio"
5 "embed"
6 "flag"
7 "fmt"
8 "golang.org/x/crypto/bcrypt"
9 "golang.org/x/net/websocket"
10 "html/template"
11 "io"
12 "log"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strconv"
17 "strings"
18 "time"
19 )
20
21 const version = "v1.0.0"
22 const formatString = "2006-01-02T15:04"
23
24 //go:embed templates/*
25 var content embed.FS
26
27 //var content = os.DirFS("../broadcaster-server/")
28
29 var config ServerConfig = NewServerConfig()
30
31 func main() {
32 configFlag := flag.String("c", "", "path to configuration file")
33 addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
34 versionFlag := flag.Bool("v", false, "print version then exit")
35 flag.Parse()
36
37 if *versionFlag {
38 fmt.Println("Broadcaster Server", version)
39 os.Exit(0)
40 }
41 if *configFlag == "" {
42 log.Fatal("must specify a configuration file with -c")
43 }
44 config.LoadFromFile(*configFlag)
45
46 InitDatabase()
47 defer db.CloseDatabase()
48
49 if *addUserFlag {
50 scanner := bufio.NewScanner(os.Stdin)
51 fmt.Println("Enter new admin username:")
52 if !scanner.Scan() {
53 os.Exit(1)
54 }
55 username := scanner.Text()
56 fmt.Println("Enter new admin password (will be printed in the clear):")
57 if !scanner.Scan() {
58 os.Exit(1)
59 }
60 password := scanner.Text()
61 if username == "" || password == "" {
62 fmt.Println("Both username and password must be specified")
63 os.Exit(1)
64 }
65 if err := users.CreateUser(username, password, true); err != nil {
66 log.Fatal(err)
67 }
68 os.Exit(0)
69 }
70
71 log.Println("Broadcaster Server", version, "starting up")
72 InitCommandRouter()
73 InitPlaylists()
74 InitAudioFiles(config.AudioFilesPath)
75 InitServerStatus()
76
77 // Public routes
78
79 http.HandleFunc("/login", logInPage)
80 http.Handle("/file-downloads/", applyDisposition(http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath)))))
81
82 // Authenticated routes
83
84 http.Handle("/", requireUser(homePage))
85 http.Handle("/logout", requireUser(logOutPage))
86 http.Handle("/change-password", requireUser(changePasswordPage))
87
88 http.Handle("/playlists/", requireUser(playlistSection))
89 http.Handle("/files/", requireUser(fileSection))
90 http.Handle("/radios/", requireUser(radioSection))
91
92 http.Handle("/stop", requireUser(stopPage))
93
94 // Admin routes
95
96 http.Handle("/users/", requireAdmin(userSection))
97
98 // Websocket routes, which perform their own auth
99
100 http.Handle("/radio-ws", websocket.Handler(RadioSync))
101 http.Handle("/web-ws", websocket.Handler(WebSync))
102
103 err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
104 if err != nil {
105 log.Fatal(err)
106 }
107 }
108
109 type DispositionMiddleware struct {
110 handler http.Handler
111 }
112
113 func (m DispositionMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114 log.Println("path", r.URL.Path)
115 if r.URL.Path != "/file-downloads/" {
116 w.Header().Add("Content-Disposition", "attachment")
117 }
118 m.handler.ServeHTTP(w, r)
119 }
120
121 func applyDisposition(handler http.Handler) DispositionMiddleware {
122 return DispositionMiddleware{
123 handler: handler,
124 }
125 }
126
127 type authenticatedHandler func(http.ResponseWriter, *http.Request, User)
128
129 type AuthMiddleware struct {
130 handler authenticatedHandler
131 mustBeAdmin bool
132 }
133
134 func (m AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
135 user, err := currentUser(w, r)
136 if err != nil || (m.mustBeAdmin && !user.IsAdmin) {
137 http.Redirect(w, r, "/login", http.StatusFound)
138 return
139 }
140 m.handler(w, r, user)
141 }
142
143 func requireUser(handler authenticatedHandler) AuthMiddleware {
144 return AuthMiddleware{
145 handler: handler,
146 mustBeAdmin: false,
147 }
148 }
149
150 func requireAdmin(handler authenticatedHandler) AuthMiddleware {
151 return AuthMiddleware{
152 handler: handler,
153 mustBeAdmin: true,
154 }
155 }
156
157 type HeaderData struct {
158 SelectedMenu string
159 User User
160 Version string
161 }
162
163 func renderHeader(w http.ResponseWriter, selectedMenu string, user User) {
164 tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
165 data := HeaderData{
166 SelectedMenu: selectedMenu,
167 User: user,
168 Version: version,
169 }
170 err := tmpl.Execute(w, data)
171 if err != nil {
172 log.Fatal(err)
173 }
174 }
175
176 func renderFooter(w http.ResponseWriter) {
177 tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
178 err := tmpl.Execute(w, nil)
179 if err != nil {
180 log.Fatal(err)
181 }
182 }
183
184 type HomeData struct {
185 LoggedIn bool
186 Username string
187 }
188
189 func homePage(w http.ResponseWriter, r *http.Request, user User) {
190 renderHeader(w, "status", user)
191 tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
192 data := HomeData{
193 LoggedIn: true,
194 Username: "Bob",
195 }
196 tmpl.Execute(w, data)
197 renderFooter(w)
198 }
199
200 type LogInData struct {
201 Error string
202 }
203
204 func logInPage(w http.ResponseWriter, r *http.Request) {
205 r.ParseForm()
206 username := r.Form["username"]
207 password := r.Form["password"]
208 errText := ""
209 if username != nil {
210 user, err := users.Authenticate(username[0], password[0])
211 if err != nil {
212 errText = "Incorrect login"
213 } else {
214 createSessionCookie(w, user.Username)
215 http.Redirect(w, r, "/", http.StatusFound)
216 return
217 }
218 }
219
220 data := LogInData{
221 Error: errText,
222 }
223 renderHeader(w, "", User{})
224 tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
225 tmpl.Execute(w, data)
226 renderFooter(w)
227 }
228
229 func playlistSection(w http.ResponseWriter, r *http.Request, user User) {
230 path := strings.Split(r.URL.Path, "/")
231 if len(path) != 3 {
232 http.NotFound(w, r)
233 return
234 }
235 if path[2] == "new" {
236 editPlaylistPage(w, r, 0, user)
237 } else if path[2] == "submit" && r.Method == "POST" {
238 submitPlaylist(w, r)
239 } else if path[2] == "delete" && r.Method == "POST" {
240 deletePlaylist(w, r)
241 } else if path[2] == "" {
242 playlistsPage(w, r, user)
243 } else {
244 id, err := strconv.Atoi(path[2])
245 if err != nil {
246 http.NotFound(w, r)
247 return
248 }
249 editPlaylistPage(w, r, id, user)
250 }
251 }
252
253 func fileSection(w http.ResponseWriter, r *http.Request, user User) {
254 path := strings.Split(r.URL.Path, "/")
255 if len(path) != 3 {
256 http.NotFound(w, r)
257 return
258 }
259 if path[2] == "upload" {
260 uploadFile(w, r)
261 } else if path[2] == "delete" && r.Method == "POST" {
262 deleteFile(w, r)
263 } else if path[2] == "" {
264 filesPage(w, r, user)
265 } else {
266 http.NotFound(w, r)
267 return
268 }
269 }
270
271 func radioSection(w http.ResponseWriter, r *http.Request, user User) {
272 path := strings.Split(r.URL.Path, "/")
273 if len(path) != 3 {
274 http.NotFound(w, r)
275 return
276 }
277 if path[2] == "new" {
278 editRadioPage(w, r, 0, user)
279 } else if path[2] == "submit" && r.Method == "POST" {
280 submitRadio(w, r)
281 } else if path[2] == "delete" && r.Method == "POST" {
282 deleteRadio(w, r)
283 } else if path[2] == "" {
284 radiosPage(w, r, user)
285 } else {
286 id, err := strconv.Atoi(path[2])
287 if err != nil {
288 http.NotFound(w, r)
289 return
290 }
291 editRadioPage(w, r, id, user)
292 }
293 }
294
295 func userSection(w http.ResponseWriter, r *http.Request, user User) {
296 path := strings.Split(r.URL.Path, "/")
297 if len(path) != 3 {
298 http.NotFound(w, r)
299 return
300 }
301 if path[2] == "new" {
302 editUserPage(w, r, 0, user)
303 } else if path[2] == "submit" && r.Method == "POST" {
304 submitUser(w, r)
305 } else if path[2] == "delete" && r.Method == "POST" {
306 deleteUser(w, r)
307 } else if path[2] == "reset-password" && r.Method == "POST" {
308 resetUserPassword(w, r)
309 } else if path[2] == "" {
310 usersPage(w, r, user)
311 } else {
312 id, err := strconv.Atoi(path[2])
313 if err != nil {
314 http.NotFound(w, r)
315 return
316 }
317 editUserPage(w, r, id, user)
318 }
319 }
320
321 type EditUserPageData struct {
322 User User
323 }
324
325 func editUserPage(w http.ResponseWriter, r *http.Request, id int, user User) {
326 var data EditUserPageData
327 if id != 0 {
328 user, err := db.GetUserById(id)
329 if err != nil {
330 http.NotFound(w, r)
331 return
332 }
333 data.User = user
334 }
335 renderHeader(w, "users", user)
336 tmpl := template.Must(template.ParseFS(content, "templates/user.html"))
337 tmpl.Execute(w, data)
338 renderFooter(w)
339 }
340
341 func submitUser(w http.ResponseWriter, r *http.Request) {
342 err := r.ParseForm()
343 if err == nil {
344 id, err := strconv.Atoi(r.Form.Get("userId"))
345 if err != nil {
346 return
347 }
348 if id == 0 {
349 newPassword := r.Form.Get("password")
350 hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
351 if err != nil {
352 return
353 }
354 user := User{
355 Id: 0,
356 Username: r.Form.Get("username"),
357 IsAdmin: r.Form.Get("isAdmin") == "1",
358 PasswordHash: string(hashed),
359 }
360 db.CreateUser(user)
361 } else {
362 user, err := db.GetUserById(id)
363 if err != nil {
364 http.NotFound(w, r)
365 return
366 }
367 db.SetUserIsAdmin(user.Username, r.Form.Get("isAdmin") == "1")
368 }
369 }
370 http.Redirect(w, r, "/users/", http.StatusFound)
371 }
372
373 func deleteUser(w http.ResponseWriter, r *http.Request) {
374 err := r.ParseForm()
375 if err == nil {
376 id, err := strconv.Atoi(r.Form.Get("userId"))
377 if err != nil {
378 return
379 }
380 user, err := db.GetUserById(id)
381 if err != nil {
382 http.NotFound(w, r)
383 return
384 }
385 db.DeleteUser(user.Username)
386 }
387 http.Redirect(w, r, "/users/", http.StatusFound)
388 }
389
390 func resetUserPassword(w http.ResponseWriter, r *http.Request) {
391 err := r.ParseForm()
392 if err == nil {
393 id, err := strconv.Atoi(r.Form.Get("userId"))
394 if err != nil {
395 return
396 }
397 user, err := db.GetUserById(id)
398 if err != nil {
399 http.NotFound(w, r)
400 return
401 }
402 newPassword := r.Form.Get("newPassword")
403 hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
404 if err != nil {
405 return
406 }
407 db.SetUserPassword(user.Username, string(hashed))
408 }
409 http.Redirect(w, r, "/users/", http.StatusFound)
410 }
411
412 type ChangePasswordPageData struct {
413 Message string
414 ShowForm bool
415 }
416
417 func changePasswordPage(w http.ResponseWriter, r *http.Request, user User) {
418 var data ChangePasswordPageData
419 if r.Method == "POST" {
420 err := r.ParseForm()
421 if err != nil {
422 w.WriteHeader(http.StatusBadRequest)
423 return
424 }
425 oldPassword := r.Form.Get("oldPassword")
426 newPassword := r.Form.Get("newPassword")
427 err = users.UpdatePassword(user.Username, oldPassword, newPassword)
428 if err != nil {
429 data.Message = "Failed to change password: " + err.Error()
430 data.ShowForm = true
431 } else {
432 data.Message = "Successfully changed password"
433 data.ShowForm = false
434 cookie, err := r.Cookie("broadcast_session")
435 if err == nil {
436 log.Println("Clearing other sessions for username", user.Username, "token", cookie.Value)
437 db.ClearOtherSessions(user.Username, cookie.Value)
438 }
439 }
440 } else {
441 data.Message = ""
442 data.ShowForm = true
443 }
444 renderHeader(w, "change-password", user)
445 tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
446 err := tmpl.Execute(w, data)
447 if err != nil {
448 log.Fatal(err)
449 }
450 renderFooter(w)
451 }
452
453 type UsersPageData struct {
454 Users []User
455 }
456
457 func usersPage(w http.ResponseWriter, _ *http.Request, user User) {
458 renderHeader(w, "users", user)
459 data := UsersPageData{
460 Users: db.GetUsers(),
461 }
462 tmpl := template.Must(template.ParseFS(content, "templates/users.html"))
463 err := tmpl.Execute(w, data)
464 if err != nil {
465 log.Fatal(err)
466 }
467 renderFooter(w)
468 }
469
470 type PlaylistsPageData struct {
471 Playlists []Playlist
472 }
473
474 func playlistsPage(w http.ResponseWriter, _ *http.Request, user User) {
475 renderHeader(w, "playlists", user)
476 data := PlaylistsPageData{
477 Playlists: db.GetPlaylists(),
478 }
479 for i := range data.Playlists {
480 data.Playlists[i].StartTime = strings.Replace(data.Playlists[i].StartTime, "T", " ", -1)
481 }
482 tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
483 err := tmpl.Execute(w, data)
484 if err != nil {
485 log.Fatal(err)
486 }
487 renderFooter(w)
488 }
489
490 type RadiosPageData struct {
491 Radios []Radio
492 }
493
494 func radiosPage(w http.ResponseWriter, _ *http.Request, user User) {
495 renderHeader(w, "radios", user)
496 data := RadiosPageData{
497 Radios: db.GetRadios(),
498 }
499 tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
500 err := tmpl.Execute(w, data)
501 if err != nil {
502 log.Fatal(err)
503 }
504 renderFooter(w)
505 }
506
507 type EditPlaylistPageData struct {
508 Playlist Playlist
509 Entries []PlaylistEntry
510 Files []string
511 }
512
513 func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int, user User) {
514 var data EditPlaylistPageData
515 for _, f := range files.Files() {
516 data.Files = append(data.Files, f.Name)
517 }
518 if id == 0 {
519 data.Playlist.Enabled = true
520 data.Playlist.Name = "New Playlist"
521 data.Playlist.StartTime = time.Now().Format(formatString)
522 data.Entries = append(data.Entries, PlaylistEntry{})
523 } else {
524 playlist, err := db.GetPlaylist(id)
525 if err != nil {
526 http.NotFound(w, r)
527 return
528 }
529 data.Playlist = playlist
530 data.Entries = db.GetEntriesForPlaylist(id)
531 }
532 renderHeader(w, "playlists", user)
533 tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
534 tmpl.Execute(w, data)
535 renderFooter(w)
536 }
537
538 func submitPlaylist(w http.ResponseWriter, r *http.Request) {
539 err := r.ParseForm()
540 if err == nil {
541 var p Playlist
542 id, err := strconv.Atoi(r.Form.Get("playlistId"))
543 if err != nil {
544 return
545 }
546 _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
547 if err != nil {
548 return
549 }
550 p.Id = id
551 p.Enabled = r.Form.Get("playlistEnabled") == "1"
552 p.Name = r.Form.Get("playlistName")
553 p.StartTime = r.Form.Get("playlistStartTime")
554
555 delays := r.Form["delaySeconds"]
556 filenames := r.Form["filename"]
557 isRelatives := r.Form["isRelative"]
558
559 entries := make([]PlaylistEntry, 0)
560 for i := range delays {
561 var e PlaylistEntry
562 delay, err := strconv.Atoi(delays[i])
563 if err != nil {
564 return
565 }
566 e.DelaySeconds = delay
567 e.Position = i
568 e.IsRelative = isRelatives[i] == "1"
569 e.Filename = filenames[i]
570 entries = append(entries, e)
571 }
572 cleanedEntries := make([]PlaylistEntry, 0)
573 for _, e := range entries {
574 if e.DelaySeconds != 0 || e.Filename != "" {
575 cleanedEntries = append(cleanedEntries, e)
576 }
577 }
578
579 if id != 0 {
580 db.UpdatePlaylist(p)
581 } else {
582 id = db.CreatePlaylist(p)
583 }
584 db.SetEntriesForPlaylist(cleanedEntries, id)
585 // Notify connected radios
586 playlists.NotifyChanges()
587 }
588 http.Redirect(w, r, "/playlists/", http.StatusFound)
589 }
590
591 func deletePlaylist(w http.ResponseWriter, r *http.Request) {
592 err := r.ParseForm()
593 if err == nil {
594 id, err := strconv.Atoi(r.Form.Get("playlistId"))
595 if err != nil {
596 return
597 }
598 db.DeletePlaylist(id)
599 playlists.NotifyChanges()
600 }
601 http.Redirect(w, r, "/playlists/", http.StatusFound)
602 }
603
604 type EditRadioPageData struct {
605 Radio Radio
606 }
607
608 func editRadioPage(w http.ResponseWriter, r *http.Request, id int, user User) {
609 var data EditRadioPageData
610 if id == 0 {
611 data.Radio.Name = "New Radio"
612 data.Radio.Token = generateSession()
613 } else {
614 radio, err := db.GetRadio(id)
615 if err != nil {
616 http.NotFound(w, r)
617 return
618 }
619 data.Radio = radio
620 }
621 renderHeader(w, "radios", user)
622 tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
623 tmpl.Execute(w, data)
624 renderFooter(w)
625 }
626
627 func submitRadio(w http.ResponseWriter, r *http.Request) {
628 err := r.ParseForm()
629 if err == nil {
630 var radio Radio
631 id, err := strconv.Atoi(r.Form.Get("radioId"))
632 if err != nil {
633 return
634 }
635 radio.Id = id
636 radio.Name = r.Form.Get("radioName")
637 radio.Token = r.Form.Get("radioToken")
638 if id != 0 {
639 db.UpdateRadio(radio)
640 } else {
641 db.CreateRadio(radio)
642 }
643 }
644 http.Redirect(w, r, "/radios/", http.StatusFound)
645 }
646
647 func deleteRadio(w http.ResponseWriter, r *http.Request) {
648 err := r.ParseForm()
649 if err == nil {
650 id, err := strconv.Atoi(r.Form.Get("radioId"))
651 if err != nil {
652 return
653 }
654 db.DeleteRadio(id)
655 }
656 http.Redirect(w, r, "/radios/", http.StatusFound)
657 }
658
659 type FilesPageData struct {
660 Files []FileSpec
661 }
662
663 func filesPage(w http.ResponseWriter, _ *http.Request, user User) {
664 renderHeader(w, "files", user)
665 data := FilesPageData{
666 Files: files.Files(),
667 }
668 tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
669 err := tmpl.Execute(w, data)
670 if err != nil {
671 log.Fatal(err)
672 }
673 renderFooter(w)
674 }
675
676 func deleteFile(w http.ResponseWriter, r *http.Request) {
677 err := r.ParseForm()
678 if err == nil {
679 filename := r.Form.Get("filename")
680 if filename == "" {
681 return
682 }
683 files.Delete(filename)
684 }
685 http.Redirect(w, r, "/files/", http.StatusFound)
686 }
687
688 func uploadFile(w http.ResponseWriter, r *http.Request) {
689 err := r.ParseMultipartForm(100 << 20)
690 file, handler, err := r.FormFile("file")
691 if err == nil {
692 path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
693 f, _ := os.Create(path)
694 defer f.Close()
695 io.Copy(f, file)
696 log.Println("Uploaded file to", path)
697 files.Refresh()
698 }
699 http.Redirect(w, r, "/files/", http.StatusFound)
700 }
701
702 func logOutPage(w http.ResponseWriter, r *http.Request, user User) {
703 cookie, err := r.Cookie("broadcast_session")
704 if err == nil {
705 db.ClearSession(user.Username, cookie.Value)
706 }
707 clearSessionCookie(w)
708 renderHeader(w, "", user)
709 tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
710 tmpl.Execute(w, nil)
711 renderFooter(w)
712 }
713
714 func stopPage(w http.ResponseWriter, r *http.Request, user User) {
715 r.ParseForm()
716 radioId, err := strconv.Atoi(r.Form.Get("radioId"))
717 if err != nil {
718 http.NotFound(w, r)
719 return
720 }
721 commandRouter.Stop(radioId)
722 http.Redirect(w, r, "/", http.StatusFound)
723 }