8 "golang.org/x/crypto/bcrypt"
9 "golang.org/x/net/websocket"
21 const version
= "v1.0.0"
22 const formatString
= "2006-01-02T15:04"
24 //go:embed templates/*
27 //var content = os.DirFS("../broadcaster-server/")
29 var config ServerConfig
= NewServerConfig()
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")
38 fmt
.Println("Broadcaster Server", version
)
41 if *configFlag
== "" {
42 log
.Fatal("must specify a configuration file with -c")
44 config
.LoadFromFile(*configFlag
)
47 defer db
.CloseDatabase()
50 scanner
:= bufio
.NewScanner(os
.Stdin
)
51 fmt
.Println("Enter new admin username:")
55 username
:= scanner
.Text()
56 fmt
.Println("Enter new admin password (will be printed in the clear):")
60 password
:= scanner
.Text()
61 if username
== "" || password
== "" {
62 fmt
.Println("Both username and password must be specified")
65 if err
:= users
.CreateUser(username
, password
, true); err
!= nil {
71 log
.Println("Broadcaster Server", version
, "starting up")
74 InitAudioFiles(config
.AudioFilesPath
)
79 http
.HandleFunc("/login", logInPage
)
80 http
.Handle("/file-downloads/", applyDisposition(http
.StripPrefix("/file-downloads/", http
.FileServer(http
.Dir(config
.AudioFilesPath
)))))
82 // Authenticated routes
84 http
.Handle("/", requireUser(homePage
))
85 http
.Handle("/logout", requireUser(logOutPage
))
86 http
.Handle("/change-password", requireUser(changePasswordPage
))
88 http
.Handle("/playlists/", requireUser(playlistSection
))
89 http
.Handle("/files/", requireUser(fileSection
))
90 http
.Handle("/radios/", requireUser(radioSection
))
92 http
.Handle("/stop", requireUser(stopPage
))
96 http
.Handle("/users/", requireAdmin(userSection
))
98 // Websocket routes, which perform their own auth
100 http
.Handle("/radio-ws", websocket
.Handler(RadioSync
))
101 http
.Handle("/web-ws", websocket
.Handler(WebSync
))
103 err
:= http
.ListenAndServe(config
.BindAddress
+":"+strconv
.Itoa(config
.Port
), nil)
109 type DispositionMiddleware
struct {
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")
118 m
.handler
.ServeHTTP(w
, r
)
121 func applyDisposition(handler http
.Handler
) DispositionMiddleware
{
122 return DispositionMiddleware
{
127 type authenticatedHandler
func(http
.ResponseWriter
, *http
.Request
, User
)
129 type AuthMiddleware
struct {
130 handler authenticatedHandler
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
)
140 m
.handler(w
, r
, user
)
143 func requireUser(handler authenticatedHandler
) AuthMiddleware
{
144 return AuthMiddleware
{
150 func requireAdmin(handler authenticatedHandler
) AuthMiddleware
{
151 return AuthMiddleware
{
157 type HeaderData
struct {
163 func renderHeader(w http
.ResponseWriter
, selectedMenu
string, user User
) {
164 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/header.html"))
166 SelectedMenu
: selectedMenu
,
170 err
:= tmpl
.Execute(w
, data
)
176 func renderFooter(w http
.ResponseWriter
) {
177 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/footer.html"))
178 err
:= tmpl
.Execute(w
, nil)
184 type HomeData
struct {
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"))
196 tmpl
.Execute(w
, data
)
200 type LogInData
struct {
204 func logInPage(w http
.ResponseWriter
, r
*http
.Request
) {
206 username
:= r
.Form
["username"]
207 password
:= r
.Form
["password"]
210 user
, err
:= users
.Authenticate(username
[0], password
[0])
212 errText
= "Incorrect login"
214 createSessionCookie(w
, user
.Username
)
215 http
.Redirect(w
, r
, "/", http
.StatusFound
)
223 renderHeader(w
, "", User
{})
224 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/login.html"))
225 tmpl
.Execute(w
, data
)
229 func playlistSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
230 path
:= strings
.Split(r
.URL
.Path
, "/")
235 if path
[2] == "new" {
236 editPlaylistPage(w
, r
, 0, user
)
237 } else if path
[2] == "submit" && r
.Method
== "POST" {
239 } else if path
[2] == "delete" && r
.Method
== "POST" {
241 } else if path
[2] == "" {
242 playlistsPage(w
, r
, user
)
244 id
, err
:= strconv
.Atoi(path
[2])
249 editPlaylistPage(w
, r
, id
, user
)
253 func fileSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
254 path
:= strings
.Split(r
.URL
.Path
, "/")
259 if path
[2] == "upload" {
261 } else if path
[2] == "delete" && r
.Method
== "POST" {
263 } else if path
[2] == "" {
264 filesPage(w
, r
, user
)
271 func radioSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
272 path
:= strings
.Split(r
.URL
.Path
, "/")
277 if path
[2] == "new" {
278 editRadioPage(w
, r
, 0, user
)
279 } else if path
[2] == "submit" && r
.Method
== "POST" {
281 } else if path
[2] == "delete" && r
.Method
== "POST" {
283 } else if path
[2] == "" {
284 radiosPage(w
, r
, user
)
286 id
, err
:= strconv
.Atoi(path
[2])
291 editRadioPage(w
, r
, id
, user
)
295 func userSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
296 path
:= strings
.Split(r
.URL
.Path
, "/")
301 if path
[2] == "new" {
302 editUserPage(w
, r
, 0, user
)
303 } else if path
[2] == "submit" && r
.Method
== "POST" {
305 } else if path
[2] == "delete" && r
.Method
== "POST" {
307 } else if path
[2] == "reset-password" && r
.Method
== "POST" {
308 resetUserPassword(w
, r
)
309 } else if path
[2] == "" {
310 usersPage(w
, r
, user
)
312 id
, err
:= strconv
.Atoi(path
[2])
317 editUserPage(w
, r
, id
, user
)
321 type EditUserPageData
struct {
325 func editUserPage(w http
.ResponseWriter
, r
*http
.Request
, id
int, user User
) {
326 var data EditUserPageData
328 user
, err
:= db
.GetUserById(id
)
335 renderHeader(w
, "users", user
)
336 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/user.html"))
337 tmpl
.Execute(w
, data
)
341 func submitUser(w http
.ResponseWriter
, r
*http
.Request
) {
344 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
349 newPassword
:= r
.Form
.Get("password")
350 hashed
, err
:= bcrypt
.GenerateFromPassword([]byte(newPassword
), bcrypt
.DefaultCost
)
356 Username
: r
.Form
.Get("username"),
357 IsAdmin
: r
.Form
.Get("isAdmin") == "1",
358 PasswordHash
: string(hashed
),
362 user
, err
:= db
.GetUserById(id
)
367 db
.SetUserIsAdmin(user
.Username
, r
.Form
.Get("isAdmin") == "1")
370 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
373 func deleteUser(w http
.ResponseWriter
, r
*http
.Request
) {
376 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
380 user
, err
:= db
.GetUserById(id
)
385 db
.DeleteUser(user
.Username
)
387 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
390 func resetUserPassword(w http
.ResponseWriter
, r
*http
.Request
) {
393 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
397 user
, err
:= db
.GetUserById(id
)
402 newPassword
:= r
.Form
.Get("newPassword")
403 hashed
, err
:= bcrypt
.GenerateFromPassword([]byte(newPassword
), bcrypt
.DefaultCost
)
407 db
.SetUserPassword(user
.Username
, string(hashed
))
409 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
412 type ChangePasswordPageData
struct {
417 func changePasswordPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
418 var data ChangePasswordPageData
419 if r
.Method
== "POST" {
422 w
.WriteHeader(http
.StatusBadRequest
)
425 oldPassword
:= r
.Form
.Get("oldPassword")
426 newPassword
:= r
.Form
.Get("newPassword")
427 err
= users
.UpdatePassword(user
.Username
, oldPassword
, newPassword
)
429 data
.Message
= "Failed to change password: " + err
.Error()
432 data
.Message
= "Successfully changed password"
433 data
.ShowForm
= false
434 cookie
, err
:= r
.Cookie("broadcast_session")
436 log
.Println("Clearing other sessions for username", user
.Username
, "token", cookie
.Value
)
437 db
.ClearOtherSessions(user
.Username
, cookie
.Value
)
444 renderHeader(w
, "change-password", user
)
445 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/change_password.html"))
446 err
:= tmpl
.Execute(w
, data
)
453 type UsersPageData
struct {
457 func usersPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
458 renderHeader(w
, "users", user
)
459 data
:= UsersPageData
{
460 Users
: db
.GetUsers(),
462 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/users.html"))
463 err
:= tmpl
.Execute(w
, data
)
470 type PlaylistsPageData
struct {
474 func playlistsPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
475 renderHeader(w
, "playlists", user
)
476 data
:= PlaylistsPageData
{
477 Playlists
: db
.GetPlaylists(),
479 for i
:= range data
.Playlists
{
480 data
.Playlists
[i
].StartTime
= strings
.Replace(data
.Playlists
[i
].StartTime
, "T", " ", -1)
482 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/playlists.html"))
483 err
:= tmpl
.Execute(w
, data
)
490 type RadiosPageData
struct {
494 func radiosPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
495 renderHeader(w
, "radios", user
)
496 data
:= RadiosPageData
{
497 Radios
: db
.GetRadios(),
499 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/radios.html"))
500 err
:= tmpl
.Execute(w
, data
)
507 type EditPlaylistPageData
struct {
509 Entries
[]PlaylistEntry
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
)
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
{})
524 playlist
, err
:= db
.GetPlaylist(id
)
529 data
.Playlist
= playlist
530 data
.Entries
= db
.GetEntriesForPlaylist(id
)
532 renderHeader(w
, "playlists", user
)
533 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/playlist.html"))
534 tmpl
.Execute(w
, data
)
538 func submitPlaylist(w http
.ResponseWriter
, r
*http
.Request
) {
542 id
, err
:= strconv
.Atoi(r
.Form
.Get("playlistId"))
546 _
, err
= time
.Parse(formatString
, r
.Form
.Get("playlistStartTime"))
551 p
.Enabled
= r
.Form
.Get("playlistEnabled") == "1"
552 p
.Name
= r
.Form
.Get("playlistName")
553 p
.StartTime
= r
.Form
.Get("playlistStartTime")
555 delays
:= r
.Form
["delaySeconds"]
556 filenames
:= r
.Form
["filename"]
557 isRelatives
:= r
.Form
["isRelative"]
559 entries
:= make([]PlaylistEntry
, 0)
560 for i
:= range delays
{
562 delay
, err
:= strconv
.Atoi(delays
[i
])
566 e
.DelaySeconds
= delay
568 e
.IsRelative
= isRelatives
[i
] == "1"
569 e
.Filename
= filenames
[i
]
570 entries
= append(entries
, e
)
572 cleanedEntries
:= make([]PlaylistEntry
, 0)
573 for _
, e
:= range entries
{
574 if e
.DelaySeconds
!= 0 || e
.Filename
!= "" {
575 cleanedEntries
= append(cleanedEntries
, e
)
582 id
= db
.CreatePlaylist(p
)
584 db
.SetEntriesForPlaylist(cleanedEntries
, id
)
585 // Notify connected radios
586 playlists
.NotifyChanges()
588 http
.Redirect(w
, r
, "/playlists/", http
.StatusFound
)
591 func deletePlaylist(w http
.ResponseWriter
, r
*http
.Request
) {
594 id
, err
:= strconv
.Atoi(r
.Form
.Get("playlistId"))
598 db
.DeletePlaylist(id
)
599 playlists
.NotifyChanges()
601 http
.Redirect(w
, r
, "/playlists/", http
.StatusFound
)
604 type EditRadioPageData
struct {
608 func editRadioPage(w http
.ResponseWriter
, r
*http
.Request
, id
int, user User
) {
609 var data EditRadioPageData
611 data
.Radio
.Name
= "New Radio"
612 data
.Radio
.Token
= generateSession()
614 radio
, err
:= db
.GetRadio(id
)
621 renderHeader(w
, "radios", user
)
622 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/radio.html"))
623 tmpl
.Execute(w
, data
)
627 func submitRadio(w http
.ResponseWriter
, r
*http
.Request
) {
631 id
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
636 radio
.Name
= r
.Form
.Get("radioName")
637 radio
.Token
= r
.Form
.Get("radioToken")
639 db
.UpdateRadio(radio
)
641 db
.CreateRadio(radio
)
644 http
.Redirect(w
, r
, "/radios/", http
.StatusFound
)
647 func deleteRadio(w http
.ResponseWriter
, r
*http
.Request
) {
650 id
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
656 http
.Redirect(w
, r
, "/radios/", http
.StatusFound
)
659 type FilesPageData
struct {
663 func filesPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
664 renderHeader(w
, "files", user
)
665 data
:= FilesPageData
{
666 Files
: files
.Files(),
668 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/files.html"))
669 err
:= tmpl
.Execute(w
, data
)
676 func deleteFile(w http
.ResponseWriter
, r
*http
.Request
) {
679 filename
:= r
.Form
.Get("filename")
683 files
.Delete(filename
)
685 http
.Redirect(w
, r
, "/files/", http
.StatusFound
)
688 func uploadFile(w http
.ResponseWriter
, r
*http
.Request
) {
689 err
:= r
.ParseMultipartForm(100 << 20)
690 file
, handler
, err
:= r
.FormFile("file")
692 path
:= filepath
.Join(files
.Path(), filepath
.Base(handler
.Filename
))
693 f
, _
:= os
.Create(path
)
696 log
.Println("Uploaded file to", path
)
699 http
.Redirect(w
, r
, "/files/", http
.StatusFound
)
702 func logOutPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
703 cookie
, err
:= r
.Cookie("broadcast_session")
705 db
.ClearSession(user
.Username
, cookie
.Value
)
707 clearSessionCookie(w
)
708 renderHeader(w
, "", user
)
709 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/logout.html"))
714 func stopPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
716 radioId
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
721 commandRouter
.Stop(radioId
)
722 http
.Redirect(w
, r
, "/", http
.StatusFound
)