]> code.octet-stream.net Git - broadcaster/blob - server/main.go
Various UI improvements
[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/", 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 authenticatedHandler func(http.ResponseWriter, *http.Request, User)
110
111 type AuthMiddleware struct {
112 handler authenticatedHandler
113 mustBeAdmin bool
114 }
115
116 func (m AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
117 user, err := currentUser(w, r)
118 if err != nil || (m.mustBeAdmin && !user.IsAdmin) {
119 http.Redirect(w, r, "/login", http.StatusFound)
120 return
121 }
122 m.handler(w, r, user)
123 }
124
125 func requireUser(handler authenticatedHandler) AuthMiddleware {
126 return AuthMiddleware{
127 handler: handler,
128 mustBeAdmin: false,
129 }
130 }
131
132 func requireAdmin(handler authenticatedHandler) AuthMiddleware {
133 return AuthMiddleware{
134 handler: handler,
135 mustBeAdmin: true,
136 }
137 }
138
139 type HeaderData struct {
140 SelectedMenu string
141 User User
142 }
143
144 func renderHeader(w http.ResponseWriter, selectedMenu string, user User) {
145 tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
146 data := HeaderData{
147 SelectedMenu: selectedMenu,
148 User: user,
149 }
150 err := tmpl.Execute(w, data)
151 if err != nil {
152 log.Fatal(err)
153 }
154 }
155
156 func renderFooter(w http.ResponseWriter) {
157 tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
158 err := tmpl.Execute(w, nil)
159 if err != nil {
160 log.Fatal(err)
161 }
162 }
163
164 type HomeData struct {
165 LoggedIn bool
166 Username string
167 }
168
169 func homePage(w http.ResponseWriter, r *http.Request, user User) {
170 renderHeader(w, "status", user)
171 tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
172 data := HomeData{
173 LoggedIn: true,
174 Username: "Bob",
175 }
176 tmpl.Execute(w, data)
177 renderFooter(w)
178 }
179
180 type LogInData struct {
181 Error string
182 }
183
184 func logInPage(w http.ResponseWriter, r *http.Request) {
185 r.ParseForm()
186 username := r.Form["username"]
187 password := r.Form["password"]
188 errText := ""
189 if username != nil {
190 user, err := users.Authenticate(username[0], password[0])
191 if err != nil {
192 errText = "Incorrect login"
193 } else {
194 createSessionCookie(w, user.Username)
195 http.Redirect(w, r, "/", http.StatusFound)
196 return
197 }
198 }
199
200 data := LogInData{
201 Error: errText,
202 }
203 renderHeader(w, "", User{})
204 tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
205 tmpl.Execute(w, data)
206 renderFooter(w)
207 }
208
209 func playlistSection(w http.ResponseWriter, r *http.Request, user User) {
210 path := strings.Split(r.URL.Path, "/")
211 if len(path) != 3 {
212 http.NotFound(w, r)
213 return
214 }
215 if path[2] == "new" {
216 editPlaylistPage(w, r, 0, user)
217 } else if path[2] == "submit" && r.Method == "POST" {
218 submitPlaylist(w, r)
219 } else if path[2] == "delete" && r.Method == "POST" {
220 deletePlaylist(w, r)
221 } else if path[2] == "" {
222 playlistsPage(w, r, user)
223 } else {
224 id, err := strconv.Atoi(path[2])
225 if err != nil {
226 http.NotFound(w, r)
227 return
228 }
229 editPlaylistPage(w, r, id, user)
230 }
231 }
232
233 func fileSection(w http.ResponseWriter, r *http.Request, user User) {
234 path := strings.Split(r.URL.Path, "/")
235 if len(path) != 3 {
236 http.NotFound(w, r)
237 return
238 }
239 if path[2] == "upload" {
240 uploadFile(w, r)
241 } else if path[2] == "delete" && r.Method == "POST" {
242 deleteFile(w, r)
243 } else if path[2] == "" {
244 filesPage(w, r, user)
245 } else {
246 http.NotFound(w, r)
247 return
248 }
249 }
250
251 func radioSection(w http.ResponseWriter, r *http.Request, user User) {
252 path := strings.Split(r.URL.Path, "/")
253 if len(path) != 3 {
254 http.NotFound(w, r)
255 return
256 }
257 if path[2] == "new" {
258 editRadioPage(w, r, 0, user)
259 } else if path[2] == "submit" && r.Method == "POST" {
260 submitRadio(w, r)
261 } else if path[2] == "delete" && r.Method == "POST" {
262 deleteRadio(w, r)
263 } else if path[2] == "" {
264 radiosPage(w, r, user)
265 } else {
266 id, err := strconv.Atoi(path[2])
267 if err != nil {
268 http.NotFound(w, r)
269 return
270 }
271 editRadioPage(w, r, id, user)
272 }
273 }
274
275 func userSection(w http.ResponseWriter, r *http.Request, user User) {
276 path := strings.Split(r.URL.Path, "/")
277 if len(path) != 3 {
278 http.NotFound(w, r)
279 return
280 }
281 if path[2] == "new" {
282 editUserPage(w, r, 0, user)
283 } else if path[2] == "submit" && r.Method == "POST" {
284 submitUser(w, r)
285 } else if path[2] == "delete" && r.Method == "POST" {
286 deleteUser(w, r)
287 } else if path[2] == "reset-password" && r.Method == "POST" {
288 resetUserPassword(w, r)
289 } else if path[2] == "" {
290 usersPage(w, r, user)
291 } else {
292 id, err := strconv.Atoi(path[2])
293 if err != nil {
294 http.NotFound(w, r)
295 return
296 }
297 editUserPage(w, r, id, user)
298 }
299 }
300
301 type EditUserPageData struct {
302 User User
303 }
304
305 func editUserPage(w http.ResponseWriter, r *http.Request, id int, user User) {
306 var data EditUserPageData
307 if id != 0 {
308 user, err := db.GetUserById(id)
309 if err != nil {
310 http.NotFound(w, r)
311 return
312 }
313 data.User = user
314 }
315 renderHeader(w, "users", user)
316 tmpl := template.Must(template.ParseFS(content, "templates/user.html"))
317 tmpl.Execute(w, data)
318 renderFooter(w)
319 }
320
321 func submitUser(w http.ResponseWriter, r *http.Request) {
322 err := r.ParseForm()
323 if err == nil {
324 id, err := strconv.Atoi(r.Form.Get("userId"))
325 if err != nil {
326 return
327 }
328 if id == 0 {
329 newPassword := r.Form.Get("password")
330 hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
331 if err != nil {
332 return
333 }
334 user := User{
335 Id: 0,
336 Username: r.Form.Get("username"),
337 IsAdmin: r.Form.Get("isAdmin") == "1",
338 PasswordHash: string(hashed),
339 }
340 db.CreateUser(user)
341 } else {
342 user, err := db.GetUserById(id)
343 if err != nil {
344 http.NotFound(w, r)
345 return
346 }
347 db.SetUserIsAdmin(user.Username, r.Form.Get("isAdmin") == "1")
348 }
349 }
350 http.Redirect(w, r, "/users/", http.StatusFound)
351 }
352
353 func deleteUser(w http.ResponseWriter, r *http.Request) {
354 err := r.ParseForm()
355 if err == nil {
356 id, err := strconv.Atoi(r.Form.Get("userId"))
357 if err != nil {
358 return
359 }
360 user, err := db.GetUserById(id)
361 if err != nil {
362 http.NotFound(w, r)
363 return
364 }
365 db.DeleteUser(user.Username)
366 }
367 http.Redirect(w, r, "/users/", http.StatusFound)
368 }
369
370 func resetUserPassword(w http.ResponseWriter, r *http.Request) {
371 err := r.ParseForm()
372 if err == nil {
373 id, err := strconv.Atoi(r.Form.Get("userId"))
374 if err != nil {
375 return
376 }
377 user, err := db.GetUserById(id)
378 if err != nil {
379 http.NotFound(w, r)
380 return
381 }
382 newPassword := r.Form.Get("newPassword")
383 hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
384 if err != nil {
385 return
386 }
387 db.SetUserPassword(user.Username, string(hashed))
388 }
389 http.Redirect(w, r, "/users/", http.StatusFound)
390 }
391
392 type ChangePasswordPageData struct {
393 Message string
394 ShowForm bool
395 }
396
397 func changePasswordPage(w http.ResponseWriter, r *http.Request, user User) {
398 var data ChangePasswordPageData
399 if r.Method == "POST" {
400 err := r.ParseForm()
401 if err != nil {
402 w.WriteHeader(http.StatusBadRequest)
403 return
404 }
405 oldPassword := r.Form.Get("oldPassword")
406 newPassword := r.Form.Get("newPassword")
407 err = users.UpdatePassword(user.Username, oldPassword, newPassword)
408 if err != nil {
409 data.Message = "Failed to change password: " + err.Error()
410 data.ShowForm = true
411 } else {
412 data.Message = "Successfully changed password"
413 data.ShowForm = false
414 cookie, err := r.Cookie("broadcast_session")
415 if err == nil {
416 log.Println("Clearing other sessions for username", user.Username, "token", cookie.Value)
417 db.ClearOtherSessions(user.Username, cookie.Value)
418 }
419 }
420 } else {
421 data.Message = ""
422 data.ShowForm = true
423 }
424 renderHeader(w, "change-password", user)
425 tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
426 err := tmpl.Execute(w, data)
427 if err != nil {
428 log.Fatal(err)
429 }
430 renderFooter(w)
431 }
432
433 type UsersPageData struct {
434 Users []User
435 }
436
437 func usersPage(w http.ResponseWriter, _ *http.Request, user User) {
438 renderHeader(w, "users", user)
439 data := UsersPageData{
440 Users: db.GetUsers(),
441 }
442 tmpl := template.Must(template.ParseFS(content, "templates/users.html"))
443 err := tmpl.Execute(w, data)
444 if err != nil {
445 log.Fatal(err)
446 }
447 renderFooter(w)
448 }
449
450 type PlaylistsPageData struct {
451 Playlists []Playlist
452 }
453
454 func playlistsPage(w http.ResponseWriter, _ *http.Request, user User) {
455 renderHeader(w, "playlists", user)
456 data := PlaylistsPageData{
457 Playlists: db.GetPlaylists(),
458 }
459 tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
460 err := tmpl.Execute(w, data)
461 if err != nil {
462 log.Fatal(err)
463 }
464 renderFooter(w)
465 }
466
467 type RadiosPageData struct {
468 Radios []Radio
469 }
470
471 func radiosPage(w http.ResponseWriter, _ *http.Request, user User) {
472 renderHeader(w, "radios", user)
473 data := RadiosPageData{
474 Radios: db.GetRadios(),
475 }
476 tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
477 err := tmpl.Execute(w, data)
478 if err != nil {
479 log.Fatal(err)
480 }
481 renderFooter(w)
482 }
483
484 type EditPlaylistPageData struct {
485 Playlist Playlist
486 Entries []PlaylistEntry
487 Files []string
488 }
489
490 func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int, user User) {
491 var data EditPlaylistPageData
492 for _, f := range files.Files() {
493 data.Files = append(data.Files, f.Name)
494 }
495 if id == 0 {
496 data.Playlist.Enabled = true
497 data.Playlist.Name = "New Playlist"
498 data.Playlist.StartTime = time.Now().Format(formatString)
499 data.Entries = append(data.Entries, PlaylistEntry{})
500 } else {
501 playlist, err := db.GetPlaylist(id)
502 if err != nil {
503 http.NotFound(w, r)
504 return
505 }
506 data.Playlist = playlist
507 data.Entries = db.GetEntriesForPlaylist(id)
508 }
509 renderHeader(w, "radios", user)
510 tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
511 tmpl.Execute(w, data)
512 renderFooter(w)
513 }
514
515 func submitPlaylist(w http.ResponseWriter, r *http.Request) {
516 err := r.ParseForm()
517 if err == nil {
518 var p Playlist
519 id, err := strconv.Atoi(r.Form.Get("playlistId"))
520 if err != nil {
521 return
522 }
523 _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
524 if err != nil {
525 return
526 }
527 p.Id = id
528 p.Enabled = r.Form.Get("playlistEnabled") == "1"
529 p.Name = r.Form.Get("playlistName")
530 p.StartTime = r.Form.Get("playlistStartTime")
531
532 delays := r.Form["delaySeconds"]
533 filenames := r.Form["filename"]
534 isRelatives := r.Form["isRelative"]
535
536 entries := make([]PlaylistEntry, 0)
537 for i := range delays {
538 var e PlaylistEntry
539 delay, err := strconv.Atoi(delays[i])
540 if err != nil {
541 return
542 }
543 e.DelaySeconds = delay
544 e.Position = i
545 e.IsRelative = isRelatives[i] == "1"
546 e.Filename = filenames[i]
547 entries = append(entries, e)
548 }
549 cleanedEntries := make([]PlaylistEntry, 0)
550 for _, e := range entries {
551 if e.DelaySeconds != 0 || e.Filename != "" {
552 cleanedEntries = append(cleanedEntries, e)
553 }
554 }
555
556 if id != 0 {
557 db.UpdatePlaylist(p)
558 } else {
559 id = db.CreatePlaylist(p)
560 }
561 db.SetEntriesForPlaylist(cleanedEntries, id)
562 // Notify connected radios
563 playlists.NotifyChanges()
564 }
565 http.Redirect(w, r, "/playlists/", http.StatusFound)
566 }
567
568 func deletePlaylist(w http.ResponseWriter, r *http.Request) {
569 err := r.ParseForm()
570 if err == nil {
571 id, err := strconv.Atoi(r.Form.Get("playlistId"))
572 if err != nil {
573 return
574 }
575 db.DeletePlaylist(id)
576 playlists.NotifyChanges()
577 }
578 http.Redirect(w, r, "/playlists/", http.StatusFound)
579 }
580
581 type EditRadioPageData struct {
582 Radio Radio
583 }
584
585 func editRadioPage(w http.ResponseWriter, r *http.Request, id int, user User) {
586 var data EditRadioPageData
587 if id == 0 {
588 data.Radio.Name = "New Radio"
589 data.Radio.Token = generateSession()
590 } else {
591 radio, err := db.GetRadio(id)
592 if err != nil {
593 http.NotFound(w, r)
594 return
595 }
596 data.Radio = radio
597 }
598 renderHeader(w, "radios", user)
599 tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
600 tmpl.Execute(w, data)
601 renderFooter(w)
602 }
603
604 func submitRadio(w http.ResponseWriter, r *http.Request) {
605 err := r.ParseForm()
606 if err == nil {
607 var radio Radio
608 id, err := strconv.Atoi(r.Form.Get("radioId"))
609 if err != nil {
610 return
611 }
612 radio.Id = id
613 radio.Name = r.Form.Get("radioName")
614 radio.Token = r.Form.Get("radioToken")
615 if id != 0 {
616 db.UpdateRadio(radio)
617 } else {
618 db.CreateRadio(radio)
619 }
620 }
621 http.Redirect(w, r, "/radios/", http.StatusFound)
622 }
623
624 func deleteRadio(w http.ResponseWriter, r *http.Request) {
625 err := r.ParseForm()
626 if err == nil {
627 id, err := strconv.Atoi(r.Form.Get("radioId"))
628 if err != nil {
629 return
630 }
631 db.DeleteRadio(id)
632 }
633 http.Redirect(w, r, "/radios/", http.StatusFound)
634 }
635
636 type FilesPageData struct {
637 Files []FileSpec
638 }
639
640 func filesPage(w http.ResponseWriter, _ *http.Request, user User) {
641 renderHeader(w, "files", user)
642 data := FilesPageData{
643 Files: files.Files(),
644 }
645 tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
646 err := tmpl.Execute(w, data)
647 if err != nil {
648 log.Fatal(err)
649 }
650 renderFooter(w)
651 }
652
653 func deleteFile(w http.ResponseWriter, r *http.Request) {
654 err := r.ParseForm()
655 if err == nil {
656 filename := r.Form.Get("filename")
657 if filename == "" {
658 return
659 }
660 files.Delete(filename)
661 }
662 http.Redirect(w, r, "/files/", http.StatusFound)
663 }
664
665 func uploadFile(w http.ResponseWriter, r *http.Request) {
666 err := r.ParseMultipartForm(100 << 20)
667 file, handler, err := r.FormFile("file")
668 if err == nil {
669 path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
670 f, _ := os.Create(path)
671 defer f.Close()
672 io.Copy(f, file)
673 log.Println("Uploaded file to", path)
674 files.Refresh()
675 }
676 http.Redirect(w, r, "/files/", http.StatusFound)
677 }
678
679 func logOutPage(w http.ResponseWriter, r *http.Request, user User) {
680 cookie, err := r.Cookie("broadcast_session")
681 if err == nil {
682 db.ClearSession(user.Username, cookie.Value)
683 }
684 clearSessionCookie(w)
685 renderHeader(w, "", user)
686 tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
687 tmpl.Execute(w, nil)
688 renderFooter(w)
689 }
690
691 func stopPage(w http.ResponseWriter, r *http.Request, user User) {
692 r.ParseForm()
693 radioId, err := strconv.Atoi(r.Form.Get("radioId"))
694 if err != nil {
695 http.NotFound(w, r)
696 return
697 }
698 commandRouter.Stop(radioId)
699 http.Redirect(w, r, "/", http.StatusFound)
700 }