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