18 "code.octet-stream.net/broadcaster/internal/protocol"
19 "golang.org/x/crypto/bcrypt"
20 "golang.org/x/net/websocket"
23 const version
= "v1.2.0"
25 //go:embed templates/*
28 //var content = os.DirFS("../broadcaster-server/")
30 var config ServerConfig
= NewServerConfig()
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")
39 fmt
.Println("Broadcaster Server", version
)
42 if *configFlag
== "" {
43 log
.Fatal("must specify a configuration file with -c")
45 config
.LoadFromFile(*configFlag
)
48 defer db
.CloseDatabase()
51 scanner
:= bufio
.NewScanner(os
.Stdin
)
52 fmt
.Println("Enter new admin username:")
56 username
:= scanner
.Text()
57 fmt
.Println("Enter new admin password (will be printed in the clear):")
61 password
:= scanner
.Text()
62 if username
== "" || password
== "" {
63 fmt
.Println("Both username and password must be specified")
66 if err
:= users
.CreateUser(username
, password
, true); err
!= nil {
72 log
.Println("Broadcaster Server", version
, "starting up")
75 InitAudioFiles(config
.AudioFilesPath
)
80 http
.HandleFunc("/login", logInPage
)
81 http
.Handle("/file-downloads/", applyDisposition(http
.StripPrefix("/file-downloads/", http
.FileServer(http
.Dir(config
.AudioFilesPath
)))))
83 // Authenticated routes
85 http
.Handle("/", requireUser(homePage
))
86 http
.Handle("/logout", requireUser(logOutPage
))
87 http
.Handle("/change-password", requireUser(changePasswordPage
))
89 http
.Handle("/playlists/", requireUser(playlistSection
))
90 http
.Handle("/files/", requireUser(fileSection
))
91 http
.Handle("/radios/", requireUser(radioSection
))
93 http
.Handle("/stop", requireUser(stopPage
))
97 http
.Handle("/users/", requireAdmin(userSection
))
99 // Websocket routes, which perform their own auth
101 http
.Handle("/radio-ws", websocket
.Handler(RadioSync
))
102 http
.Handle("/web-ws", websocket
.Handler(WebSync
))
104 err
:= http
.ListenAndServe(config
.BindAddress
+":"+strconv
.Itoa(config
.Port
), nil)
110 type DispositionMiddleware
struct {
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")
119 m
.handler
.ServeHTTP(w
, r
)
122 func applyDisposition(handler http
.Handler
) DispositionMiddleware
{
123 return DispositionMiddleware
{
128 type authenticatedHandler
func(http
.ResponseWriter
, *http
.Request
, User
)
130 type AuthMiddleware
struct {
131 handler authenticatedHandler
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
)
141 m
.handler(w
, r
, user
)
144 func requireUser(handler authenticatedHandler
) AuthMiddleware
{
145 return AuthMiddleware
{
151 func requireAdmin(handler authenticatedHandler
) AuthMiddleware
{
152 return AuthMiddleware
{
158 type HeaderData
struct {
164 func renderHeader(w http
.ResponseWriter
, selectedMenu
string, user User
) {
165 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/header.html"))
167 SelectedMenu
: selectedMenu
,
171 err
:= tmpl
.Execute(w
, data
)
177 func renderFooter(w http
.ResponseWriter
) {
178 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/footer.html"))
179 err
:= tmpl
.Execute(w
, nil)
185 type HomeData
struct {
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"))
197 tmpl
.Execute(w
, data
)
201 type LogInData
struct {
205 func logInPage(w http
.ResponseWriter
, r
*http
.Request
) {
207 username
:= r
.Form
["username"]
208 password
:= r
.Form
["password"]
211 user
, err
:= users
.Authenticate(username
[0], password
[0])
213 errText
= "Incorrect login"
215 createSessionCookie(w
, user
.Username
)
216 http
.Redirect(w
, r
, "/", http
.StatusFound
)
224 renderHeader(w
, "", User
{})
225 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/login.html"))
226 tmpl
.Execute(w
, data
)
230 func playlistSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
231 path
:= strings
.Split(r
.URL
.Path
, "/")
236 if path
[2] == "new" {
237 editPlaylistPage(w
, r
, 0, user
)
238 } else if path
[2] == "submit" && r
.Method
== "POST" {
240 } else if path
[2] == "delete" && r
.Method
== "POST" {
242 } else if path
[2] == "" {
243 playlistsPage(w
, r
, user
)
245 id
, err
:= strconv
.Atoi(path
[2])
250 editPlaylistPage(w
, r
, id
, user
)
254 func fileSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
255 path
:= strings
.Split(r
.URL
.Path
, "/")
260 if path
[2] == "upload" {
262 } else if path
[2] == "delete" && r
.Method
== "POST" {
264 } else if path
[2] == "" {
265 filesPage(w
, r
, user
)
272 func radioSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
273 path
:= strings
.Split(r
.URL
.Path
, "/")
278 if path
[2] == "new" {
279 editRadioPage(w
, r
, 0, user
)
280 } else if path
[2] == "submit" && r
.Method
== "POST" {
282 } else if path
[2] == "delete" && r
.Method
== "POST" {
284 } else if path
[2] == "" {
285 radiosPage(w
, r
, user
)
287 id
, err
:= strconv
.Atoi(path
[2])
292 editRadioPage(w
, r
, id
, user
)
296 func userSection(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
297 path
:= strings
.Split(r
.URL
.Path
, "/")
302 if path
[2] == "new" {
303 editUserPage(w
, r
, 0, user
)
304 } else if path
[2] == "submit" && r
.Method
== "POST" {
306 } else if path
[2] == "delete" && r
.Method
== "POST" {
308 } else if path
[2] == "reset-password" && r
.Method
== "POST" {
309 resetUserPassword(w
, r
)
310 } else if path
[2] == "" {
311 usersPage(w
, r
, user
)
313 id
, err
:= strconv
.Atoi(path
[2])
318 editUserPage(w
, r
, id
, user
)
322 type EditUserPageData
struct {
326 func editUserPage(w http
.ResponseWriter
, r
*http
.Request
, id
int, user User
) {
327 var data EditUserPageData
329 user
, err
:= db
.GetUserById(id
)
336 renderHeader(w
, "users", user
)
337 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/user.html"))
338 tmpl
.Execute(w
, data
)
342 func submitUser(w http
.ResponseWriter
, r
*http
.Request
) {
345 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
350 newPassword
:= r
.Form
.Get("password")
351 hashed
, err
:= bcrypt
.GenerateFromPassword([]byte(newPassword
), bcrypt
.DefaultCost
)
357 Username
: r
.Form
.Get("username"),
358 IsAdmin
: r
.Form
.Get("isAdmin") == "1",
359 PasswordHash
: string(hashed
),
363 user
, err
:= db
.GetUserById(id
)
368 db
.SetUserIsAdmin(user
.Username
, r
.Form
.Get("isAdmin") == "1")
371 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
374 func deleteUser(w http
.ResponseWriter
, r
*http
.Request
) {
377 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
381 user
, err
:= db
.GetUserById(id
)
386 db
.DeleteUser(user
.Username
)
388 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
391 func resetUserPassword(w http
.ResponseWriter
, r
*http
.Request
) {
394 id
, err
:= strconv
.Atoi(r
.Form
.Get("userId"))
398 user
, err
:= db
.GetUserById(id
)
403 newPassword
:= r
.Form
.Get("newPassword")
404 hashed
, err
:= bcrypt
.GenerateFromPassword([]byte(newPassword
), bcrypt
.DefaultCost
)
408 db
.SetUserPassword(user
.Username
, string(hashed
))
410 http
.Redirect(w
, r
, "/users/", http
.StatusFound
)
413 type ChangePasswordPageData
struct {
418 func changePasswordPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
419 var data ChangePasswordPageData
420 if r
.Method
== "POST" {
423 w
.WriteHeader(http
.StatusBadRequest
)
426 oldPassword
:= r
.Form
.Get("oldPassword")
427 newPassword
:= r
.Form
.Get("newPassword")
428 err
= users
.UpdatePassword(user
.Username
, oldPassword
, newPassword
)
430 data
.Message
= "Failed to change password: " + err
.Error()
433 data
.Message
= "Successfully changed password"
434 data
.ShowForm
= false
435 cookie
, err
:= r
.Cookie("broadcast_session")
437 log
.Println("Clearing other sessions for username", user
.Username
, "token", cookie
.Value
)
438 db
.ClearOtherSessions(user
.Username
, cookie
.Value
)
445 renderHeader(w
, "change-password", user
)
446 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/change_password.html"))
447 err
:= tmpl
.Execute(w
, data
)
454 type UsersPageData
struct {
458 func usersPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
459 renderHeader(w
, "users", user
)
460 data
:= UsersPageData
{
461 Users
: db
.GetUsers(),
463 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/users.html"))
464 err
:= tmpl
.Execute(w
, data
)
471 type PlaylistsPageData
struct {
475 func playlistsPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
476 renderHeader(w
, "playlists", user
)
477 data
:= PlaylistsPageData
{
478 Playlists
: db
.GetPlaylists(),
480 for i
:= range data
.Playlists
{
481 data
.Playlists
[i
].StartTime
= strings
.Replace(data
.Playlists
[i
].StartTime
, "T", " ", -1)
483 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/playlists.html"))
484 err
:= tmpl
.Execute(w
, data
)
491 type RadiosPageData
struct {
495 func radiosPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
496 renderHeader(w
, "radios", user
)
497 data
:= RadiosPageData
{
498 Radios
: db
.GetRadios(),
500 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/radios.html"))
501 err
:= tmpl
.Execute(w
, data
)
508 type EditPlaylistPageData
struct {
510 Entries
[]PlaylistEntry
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
)
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
{})
525 playlist
, err
:= db
.GetPlaylist(id
)
530 data
.Playlist
= playlist
531 data
.Entries
= db
.GetEntriesForPlaylist(id
)
533 renderHeader(w
, "playlists", user
)
534 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/playlist.html"))
535 tmpl
.Execute(w
, data
)
539 func submitPlaylist(w http
.ResponseWriter
, r
*http
.Request
) {
543 id
, err
:= strconv
.Atoi(r
.Form
.Get("playlistId"))
547 _
, err
= time
.Parse(protocol
.StartTimeFormatSecs
, r
.Form
.Get("playlistStartTime"))
549 _
, err
= time
.Parse(protocol
.StartTimeFormat
, r
.Form
.Get("playlistStartTime"))
555 p
.Enabled
= r
.Form
.Get("playlistEnabled") == "1"
556 p
.Name
= r
.Form
.Get("playlistName")
557 p
.StartTime
= r
.Form
.Get("playlistStartTime")
559 delays
:= r
.Form
["delaySeconds"]
560 filenames
:= r
.Form
["filename"]
561 isRelatives
:= r
.Form
["isRelative"]
563 entries
:= make([]PlaylistEntry
, 0)
564 for i
:= range delays
{
566 delay
, err
:= strconv
.Atoi(delays
[i
])
570 e
.DelaySeconds
= delay
572 e
.IsRelative
= isRelatives
[i
] == "1"
573 e
.Filename
= filenames
[i
]
574 entries
= append(entries
, e
)
576 cleanedEntries
:= make([]PlaylistEntry
, 0)
577 for _
, e
:= range entries
{
578 if e
.DelaySeconds
!= 0 || e
.Filename
!= "" {
579 cleanedEntries
= append(cleanedEntries
, e
)
586 id
= db
.CreatePlaylist(p
)
588 db
.SetEntriesForPlaylist(cleanedEntries
, id
)
589 // Notify connected radios
590 playlists
.NotifyChanges()
592 http
.Redirect(w
, r
, "/playlists/", http
.StatusFound
)
595 func deletePlaylist(w http
.ResponseWriter
, r
*http
.Request
) {
598 id
, err
:= strconv
.Atoi(r
.Form
.Get("playlistId"))
602 db
.DeletePlaylist(id
)
603 playlists
.NotifyChanges()
605 http
.Redirect(w
, r
, "/playlists/", http
.StatusFound
)
608 type EditRadioPageData
struct {
612 func editRadioPage(w http
.ResponseWriter
, r
*http
.Request
, id
int, user User
) {
613 var data EditRadioPageData
615 data
.Radio
.Name
= "New Radio"
616 data
.Radio
.Token
= generateSession()
618 radio
, err
:= db
.GetRadio(id
)
625 renderHeader(w
, "radios", user
)
626 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/radio.html"))
627 tmpl
.Execute(w
, data
)
631 func submitRadio(w http
.ResponseWriter
, r
*http
.Request
) {
635 id
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
640 radio
.Name
= r
.Form
.Get("radioName")
641 radio
.Token
= r
.Form
.Get("radioToken")
643 db
.UpdateRadio(radio
)
645 db
.CreateRadio(radio
)
648 http
.Redirect(w
, r
, "/radios/", http
.StatusFound
)
651 func deleteRadio(w http
.ResponseWriter
, r
*http
.Request
) {
654 id
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
660 http
.Redirect(w
, r
, "/radios/", http
.StatusFound
)
663 type FilesPageData
struct {
667 func filesPage(w http
.ResponseWriter
, _
*http
.Request
, user User
) {
668 renderHeader(w
, "files", user
)
669 data
:= FilesPageData
{
670 Files
: files
.Files(),
672 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/files.html"))
673 err
:= tmpl
.Execute(w
, data
)
680 func deleteFile(w http
.ResponseWriter
, r
*http
.Request
) {
683 filename
:= r
.Form
.Get("filename")
687 files
.Delete(filename
)
689 http
.Redirect(w
, r
, "/files/", http
.StatusFound
)
692 func uploadFile(w http
.ResponseWriter
, r
*http
.Request
) {
693 err
:= r
.ParseMultipartForm(100 << 20)
694 file
, handler
, err
:= r
.FormFile("file")
696 path
:= filepath
.Join(files
.Path(), filepath
.Base(handler
.Filename
))
697 f
, _
:= os
.Create(path
)
700 log
.Println("Uploaded file to", path
)
703 http
.Redirect(w
, r
, "/files/", http
.StatusFound
)
706 func logOutPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
707 cookie
, err
:= r
.Cookie("broadcast_session")
709 db
.ClearSession(user
.Username
, cookie
.Value
)
711 clearSessionCookie(w
)
712 renderHeader(w
, "", user
)
713 tmpl
:= template
.Must(template
.ParseFS(content
, "templates/logout.html"))
718 func stopPage(w http
.ResponseWriter
, r
*http
.Request
, user User
) {
720 radioId
, err
:= strconv
.Atoi(r
.Form
.Get("radioId"))
725 commandRouter
.Stop(radioId
)
726 http
.Redirect(w
, r
, "/", http
.StatusFound
)