]> code.octet-stream.net Git - broadcaster/blob - server/main.go
Bump to v1.2.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 "code.octet-stream.net/broadcaster/internal/protocol"
19 "golang.org/x/crypto/bcrypt"
20 "golang.org/x/net/websocket"
21 )
22
23 const version = "v1.2.0"
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(protocol.StartTimeFormatSecs)
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(protocol.StartTimeFormatSecs, r.Form.Get("playlistStartTime"))
548 if err != nil {
549 _, err = time.Parse(protocol.StartTimeFormat, r.Form.Get("playlistStartTime"))
550 }
551 if err != nil {
552 return
553 }
554 p.Id = id
555 p.Enabled = r.Form.Get("playlistEnabled") == "1"
556 p.Name = r.Form.Get("playlistName")
557 p.StartTime = r.Form.Get("playlistStartTime")
558
559 delays := r.Form["delaySeconds"]
560 filenames := r.Form["filename"]
561 isRelatives := r.Form["isRelative"]
562
563 entries := make([]PlaylistEntry, 0)
564 for i := range delays {
565 var e PlaylistEntry
566 delay, err := strconv.Atoi(delays[i])
567 if err != nil {
568 return
569 }
570 e.DelaySeconds = delay
571 e.Position = i
572 e.IsRelative = isRelatives[i] == "1"
573 e.Filename = filenames[i]
574 entries = append(entries, e)
575 }
576 cleanedEntries := make([]PlaylistEntry, 0)
577 for _, e := range entries {
578 if e.DelaySeconds != 0 || e.Filename != "" {
579 cleanedEntries = append(cleanedEntries, e)
580 }
581 }
582
583 if id != 0 {
584 db.UpdatePlaylist(p)
585 } else {
586 id = db.CreatePlaylist(p)
587 }
588 db.SetEntriesForPlaylist(cleanedEntries, id)
589 // Notify connected radios
590 playlists.NotifyChanges()
591 }
592 http.Redirect(w, r, "/playlists/", http.StatusFound)
593 }
594
595 func deletePlaylist(w http.ResponseWriter, r *http.Request) {
596 err := r.ParseForm()
597 if err == nil {
598 id, err := strconv.Atoi(r.Form.Get("playlistId"))
599 if err != nil {
600 return
601 }
602 db.DeletePlaylist(id)
603 playlists.NotifyChanges()
604 }
605 http.Redirect(w, r, "/playlists/", http.StatusFound)
606 }
607
608 type EditRadioPageData struct {
609 Radio Radio
610 }
611
612 func editRadioPage(w http.ResponseWriter, r *http.Request, id int, user User) {
613 var data EditRadioPageData
614 if id == 0 {
615 data.Radio.Name = "New Radio"
616 data.Radio.Token = generateSession()
617 } else {
618 radio, err := db.GetRadio(id)
619 if err != nil {
620 http.NotFound(w, r)
621 return
622 }
623 data.Radio = radio
624 }
625 renderHeader(w, "radios", user)
626 tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
627 tmpl.Execute(w, data)
628 renderFooter(w)
629 }
630
631 func submitRadio(w http.ResponseWriter, r *http.Request) {
632 err := r.ParseForm()
633 if err == nil {
634 var radio Radio
635 id, err := strconv.Atoi(r.Form.Get("radioId"))
636 if err != nil {
637 return
638 }
639 radio.Id = id
640 radio.Name = r.Form.Get("radioName")
641 radio.Token = r.Form.Get("radioToken")
642 if id != 0 {
643 db.UpdateRadio(radio)
644 } else {
645 db.CreateRadio(radio)
646 }
647 }
648 http.Redirect(w, r, "/radios/", http.StatusFound)
649 }
650
651 func deleteRadio(w http.ResponseWriter, r *http.Request) {
652 err := r.ParseForm()
653 if err == nil {
654 id, err := strconv.Atoi(r.Form.Get("radioId"))
655 if err != nil {
656 return
657 }
658 db.DeleteRadio(id)
659 }
660 http.Redirect(w, r, "/radios/", http.StatusFound)
661 }
662
663 type FilesPageData struct {
664 Files []FileSpec
665 }
666
667 func filesPage(w http.ResponseWriter, _ *http.Request, user User) {
668 renderHeader(w, "files", user)
669 data := FilesPageData{
670 Files: files.Files(),
671 }
672 tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
673 err := tmpl.Execute(w, data)
674 if err != nil {
675 log.Fatal(err)
676 }
677 renderFooter(w)
678 }
679
680 func deleteFile(w http.ResponseWriter, r *http.Request) {
681 err := r.ParseForm()
682 if err == nil {
683 filename := r.Form.Get("filename")
684 if filename == "" {
685 return
686 }
687 files.Delete(filename)
688 }
689 http.Redirect(w, r, "/files/", http.StatusFound)
690 }
691
692 func uploadFile(w http.ResponseWriter, r *http.Request) {
693 err := r.ParseMultipartForm(100 << 20)
694 file, handler, err := r.FormFile("file")
695 if err == nil {
696 path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
697 f, _ := os.Create(path)
698 defer f.Close()
699 io.Copy(f, file)
700 log.Println("Uploaded file to", path)
701 files.Refresh()
702 }
703 http.Redirect(w, r, "/files/", http.StatusFound)
704 }
705
706 func logOutPage(w http.ResponseWriter, r *http.Request, user User) {
707 cookie, err := r.Cookie("broadcast_session")
708 if err == nil {
709 db.ClearSession(user.Username, cookie.Value)
710 }
711 clearSessionCookie(w)
712 renderHeader(w, "", user)
713 tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
714 tmpl.Execute(w, nil)
715 renderFooter(w)
716 }
717
718 func stopPage(w http.ResponseWriter, r *http.Request, user User) {
719 r.ParseForm()
720 radioId, err := strconv.Atoi(r.Form.Get("radioId"))
721 if err != nil {
722 http.NotFound(w, r)
723 return
724 }
725 commandRouter.Stop(radioId)
726 http.Redirect(w, r, "/", http.StatusFound)
727 }