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/", 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 authenticatedHandler
func(http
.ResponseWriter
, *http
.Request
, User
)
111 type AuthMiddleware
struct {
112 handler authenticatedHandler
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
)
122 m
.handler(w
, r
, user
)
125 func requireUser(handler authenticatedHandler
) AuthMiddleware
{
126 return AuthMiddleware
{
132 func requireAdmin(handler authenticatedHandler
) AuthMiddleware
{
133 return AuthMiddleware
{
139 type HeaderData
struct {
144 func renderHeader(w http
.ResponseWriter
, selectedMenu
string, user User
) {
145 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/header.html"))
147 SelectedMenu
: selectedMenu
,
150 err
:= tmpl
.Execute(w
, data
)
156 func renderFooter(w http
.ResponseWriter
) {
157 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/footer.html"))
158 err
:= tmpl
.Execute(w
, nil)
164 type HomeData
struct {
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"))
176 tmpl
.Execute(w
, data
)
180 type LogInData
struct {
184 func logInPage(w http
.ResponseWriter
, r
*http
.Request
) {
186 username
:= r
.Form
["username"]
187 password
:= r
.Form
["password"]
190 user
, err
:= users
.Authenticate(username
[0], password
[0])
192 errText
= "Incorrect login"
194 createSessionCookie(w
, user
.Username
)
195 http
.Redirect(w
, r
, "/", http
.StatusFound
)
203 renderHeader(w
, "", User
{})
204 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/login.html"))
205 tmpl
.Execute(w
, data
)
209 func playlistSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
210 path
:= strings
.Split(r
.URL
.Path
, "/")
215 if path
[2] == "new" {
216 editPlaylistPage(w
, r
, 0, user
)
217 } else if path
[2] == "submit" && r
.Method
== "POST" {
219 } else if path
[2] == "delete" && r
.Method
== "POST" {
221 } else if path
[2] == "" {
222 playlistsPage(w
, r
, user
)
224 id
, err
:= strconv
.Atoi(path
[2])
229 editPlaylistPage(w
, r
, id
, user
)
233 func fileSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
234 path
:= strings
.Split(r
.URL
.Path
, "/")
239 if path
[2] == "upload" {
241 } else if path
[2] == "delete" && r
.Method
== "POST" {
243 } else if path
[2] == "" {
244 filesPage(w
, r
, user
)
251 func radioSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
252 path
:= strings
.Split(r
.URL
.Path
, "/")
257 if path
[2] == "new" {
258 editRadioPage(w
, r
, 0, user
)
259 } else if path
[2] == "submit" && r
.Method
== "POST" {
261 } else if path
[2] == "delete" && r
.Method
== "POST" {
263 } else if path
[2] == "" {
264 radiosPage(w
, r
, user
)
266 id
, err
:= strconv
.Atoi(path
[2])
271 editRadioPage(w
, r
, id
, user
)
275 func userSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
276 path
:= strings
.Split(r
.URL
.Path
, "/")
281 if path
[2] == "new" {
282 editUserPage(w
, r
, 0, user
)
283 } else if path
[2] == "submit" && r
.Method
== "POST" {
285 } else if path
[2] == "delete" && r
.Method
== "POST" {
287 } else if path
[2] == "reset-password" && r
.Method
== "POST" {
288 resetUserPassword(w
, r
)
289 } else if path
[2] == "" {
290 usersPage(w
, r
, user
)
292 id
, err
:= strconv
.Atoi(path
[2])
297 editUserPage(w
, r
, id
, user
)
301 type EditUserPageData
struct {
305 func editUserPage(w http
.ResponseWriter
, r
*http
.Request
, id
int, user User
) {
306 var data EditUserPageData
308 user
, err
:= db
.GetUserById(id
)
315 renderHeader(w
, "users", user
)
316 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/user.html"))
317 tmpl
.Execute(w
, data
)
321 func submitUser(w http
.ResponseWriter
, r
*http
.Request
) {
324 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
329 newPassword
:= r
.Form
.Get("password")
330 hashed
, err
:= bcrypt
.GenerateFromPassword([]byte(newPassword
), bcrypt
.DefaultCost
)
336 Username
: r
.Form
.Get("username"),
337 IsAdmin
: r
.Form
.Get("isAdmin") == "1",
338 PasswordHash
: string(hashed
),
342 user
, err
:= db
.GetUserById(id
)
347 db
.SetUserIsAdmin(user
.Username
, r
.Form
.Get("isAdmin") == "1")
350 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
353 func deleteUser(w http
.ResponseWriter
, r
*http
.Request
) {
356 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
360 user
, err
:= db
.GetUserById(id
)
365 db
.DeleteUser(user
.Username
)
367 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
370 func resetUserPassword(w http
.ResponseWriter
, r
*http
.Request
) {
373 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
377 user
, err
:= db
.GetUserById(id
)
382 newPassword
:= r
.Form
.Get("newPassword")
383 hashed
, err
:= bcrypt
.GenerateFromPassword([]byte(newPassword
), bcrypt
.DefaultCost
)
387 db
.SetUserPassword(user
.Username
, string(hashed
))
389 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
392 type ChangePasswordPageData
struct {
397 func changePasswordPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
398 var data ChangePasswordPageData
399 if r
.Method
== "POST" {
402 w
.WriteHeader(http
.StatusBadRequest
)
405 oldPassword
:= r
.Form
.Get("oldPassword")
406 newPassword
:= r
.Form
.Get("newPassword")
407 err
= users
.UpdatePassword(user
.Username
, oldPassword
, newPassword
)
409 data
.Message
= "Failed to change password: " + err
.Error()
412 data
.Message
= "Successfully changed password"
413 data
.ShowForm
= false
414 cookie
, err
:= r
.Cookie("broadcast_session")
416 log
.Println("Clearing other sessions for username", user
.Username
, "token", cookie
.Value
)
417 db
.ClearOtherSessions(user
.Username
, cookie
.Value
)
424 renderHeader(w
, "change-password", user
)
425 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/change_password.html"))
426 err
:= tmpl
.Execute(w
, data
)
433 type UsersPageData
struct {
437 func usersPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
438 renderHeader(w
, "users", user
)
439 data
:= UsersPageData
{
440 Users
: db
.GetUsers(),
442 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/users.html"))
443 err
:= tmpl
.Execute(w
, data
)
450 type PlaylistsPageData
struct {
454 func playlistsPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
455 renderHeader(w
, "playlists", user
)
456 data
:= PlaylistsPageData
{
457 Playlists
: db
.GetPlaylists(),
459 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/playlists.html"))
460 err
:= tmpl
.Execute(w
, data
)
467 type RadiosPageData
struct {
471 func radiosPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
472 renderHeader(w
, "radios", user
)
473 data
:= RadiosPageData
{
474 Radios
: db
.GetRadios(),
476 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/radios.html"))
477 err
:= tmpl
.Execute(w
, data
)
484 type EditPlaylistPageData
struct {
486 Entries
[]PlaylistEntry
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
)
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
{})
501 playlist
, err
:= db
.GetPlaylist(id
)
506 data
.Playlist
= playlist
507 data
.Entries
= db
.GetEntriesForPlaylist(id
)
509 renderHeader(w
, "radios", user
)
510 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/playlist.html"))
511 tmpl
.Execute(w
, data
)
515 func submitPlaylist(w http
.ResponseWriter
, r
*http
.Request
) {
519 id
, err
:= strconv
.Atoi(r
.Form
.Get("playlistId"))
523 _
, err
= time
.Parse(formatString
, r
.Form
.Get("playlistStartTime"))
528 p
.Enabled
= r
.Form
.Get("playlistEnabled") == "1"
529 p
.Name
= r
.Form
.Get("playlistName")
530 p
.StartTime
= r
.Form
.Get("playlistStartTime")
532 delays
:= r
.Form
["delaySeconds"]
533 filenames
:= r
.Form
["filename"]
534 isRelatives
:= r
.Form
["isRelative"]
536 entries
:= make([]PlaylistEntry
, 0)
537 for i
:= range delays
{
539 delay
, err
:= strconv
.Atoi(delays
[i
])
543 e
.DelaySeconds
= delay
545 e
.IsRelative
= isRelatives
[i
] == "1"
546 e
.Filename
= filenames
[i
]
547 entries
= append(entries
, e
)
549 cleanedEntries
:= make([]PlaylistEntry
, 0)
550 for _
, e
:= range entries
{
551 if e
.DelaySeconds
!= 0 || e
.Filename
!= "" {
552 cleanedEntries
= append(cleanedEntries
, e
)
559 id
= db
.CreatePlaylist(p
)
561 db
.SetEntriesForPlaylist(cleanedEntries
, id
)
562 // Notify connected radios
563 playlists
.NotifyChanges()
565 http
.Redirect(w
, r
, "/playlists/", http
.StatusFound
)
568 func deletePlaylist(w http
.ResponseWriter
, r
*http
.Request
) {
571 id
, err
:= strconv
.Atoi(r
.Form
.Get("playlistId"))
575 db
.DeletePlaylist(id
)
576 playlists
.NotifyChanges()
578 http
.Redirect(w
, r
, "/playlists/", http
.StatusFound
)
581 type EditRadioPageData
struct {
585 func editRadioPage(w http
.ResponseWriter
, r
*http
.Request
, id
int, user User
) {
586 var data EditRadioPageData
588 data
.Radio
.Name
= "New Radio"
589 data
.Radio
.Token
= generateSession()
591 radio
, err
:= db
.GetRadio(id
)
598 renderHeader(w
, "radios", user
)
599 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/radio.html"))
600 tmpl
.Execute(w
, data
)
604 func submitRadio(w http
.ResponseWriter
, r
*http
.Request
) {
608 id
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
613 radio
.Name
= r
.Form
.Get("radioName")
614 radio
.Token
= r
.Form
.Get("radioToken")
616 db
.UpdateRadio(radio
)
618 db
.CreateRadio(radio
)
621 http
.Redirect(w
, r
, "/radios/", http
.StatusFound
)
624 func deleteRadio(w http
.ResponseWriter
, r
*http
.Request
) {
627 id
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
633 http
.Redirect(w
, r
, "/radios/", http
.StatusFound
)
636 type FilesPageData
struct {
640 func filesPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
641 renderHeader(w
, "files", user
)
642 data
:= FilesPageData
{
643 Files
: files
.Files(),
645 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/files.html"))
646 err
:= tmpl
.Execute(w
, data
)
653 func deleteFile(w http
.ResponseWriter
, r
*http
.Request
) {
656 filename
:= r
.Form
.Get("filename")
660 files
.Delete(filename
)
662 http
.Redirect(w
, r
, "/files/", http
.StatusFound
)
665 func uploadFile(w http
.ResponseWriter
, r
*http
.Request
) {
666 err
:= r
.ParseMultipartForm(100 << 20)
667 file
, handler
, err
:= r
.FormFile("file")
669 path
:= filepath
.Join(files
.Path(), filepath
.Base(handler
.Filename
))
670 f
, _
:= os
.Create(path
)
673 log
.Println("Uploaded file to", path
)
676 http
.Redirect(w
, r
, "/files/", http
.StatusFound
)
679 func logOutPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
680 cookie
, err
:= r
.Cookie("broadcast_session")
682 db
.ClearSession(user
.Username
, cookie
.Value
)
684 clearSessionCookie(w
)
685 renderHeader(w
, "", user
)
686 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/logout.html"))
691 func stopPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
693 radioId
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
698 commandRouter
.Stop(radioId
)
699 http
.Redirect(w
, r
, "/", http
.StatusFound
)