]> code.octet-stream.net Git - broadcaster/blob - server/main.go
Require authentication consistently
[broadcaster] / server / main.go
1 package main
2
3 import (
4 "bufio"
5 "embed"
6 "flag"
7 "fmt"
8 "golang.org/x/net/websocket"
9 "html/template"
10 "io"
11 "log"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strconv"
16 "strings"
17 "time"
18 )
19
20 const version = "v1.0.0"
21 const formatString = "2006-01-02T15:04"
22
23 //go:embed templates/*
24 var content embed.FS
25
26 //var content = os.DirFS("../broadcaster-server/")
27
28 var config ServerConfig = NewServerConfig()
29
30 func main() {
31 configFlag := flag.String("c", "", "path to configuration file")
32 addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
33 versionFlag := flag.Bool("v", false, "print version then exit")
34 flag.Parse()
35
36 if *versionFlag {
37 fmt.Println("Broadcaster Server", version)
38 os.Exit(0)
39 }
40 if *configFlag == "" {
41 log.Fatal("must specify a configuration file with -c")
42 }
43 config.LoadFromFile(*configFlag)
44
45 InitDatabase()
46 defer db.CloseDatabase()
47
48 if *addUserFlag {
49 scanner := bufio.NewScanner(os.Stdin)
50 fmt.Println("Enter new admin username:")
51 if !scanner.Scan() {
52 os.Exit(1)
53 }
54 username := scanner.Text()
55 fmt.Println("Enter new admin password (will be printed in the clear):")
56 if !scanner.Scan() {
57 os.Exit(1)
58 }
59 password := scanner.Text()
60 if username == "" || password == "" {
61 fmt.Println("Both username and password must be specified")
62 os.Exit(1)
63 }
64 if err := users.CreateUser(username, password, true); err != nil {
65 log.Fatal(err)
66 }
67 os.Exit(0)
68 }
69
70 log.Println("Broadcaster Server", version, "starting up")
71 InitCommandRouter()
72 InitPlaylists()
73 InitAudioFiles(config.AudioFilesPath)
74 InitServerStatus()
75
76 // Public routes
77
78 http.HandleFunc("/login", logInPage)
79 http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath))))
80
81 // Authenticated routes
82
83 http.Handle("/", requireUser(homePage))
84 http.Handle("/logout", requireUser(logOutPage))
85 http.Handle("/change-password", requireUser(changePasswordPage))
86
87 http.Handle("/playlists/", requireUser(playlistSection))
88 http.Handle("/files/", requireUser(fileSection))
89 http.Handle("/radios/", requireUser(radioSection))
90
91 http.Handle("/stop", requireUser(stopPage))
92
93 // Admin routes
94
95 // TODO: user management
96
97 // Websocket routes, which perform their own auth
98
99 http.Handle("/radio-ws", websocket.Handler(RadioSync))
100 http.Handle("/web-ws", websocket.Handler(WebSync))
101
102 err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
103 if err != nil {
104 log.Fatal(err)
105 }
106 }
107
108 type authenticatedHandler func(http.ResponseWriter, *http.Request, User)
109
110 type AuthMiddleware struct {
111 handler authenticatedHandler
112 mustBeAdmin bool
113 }
114
115 func (m AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
116 user, err := currentUser(w, r)
117 if err != nil || (m.mustBeAdmin && !user.IsAdmin) {
118 http.Redirect(w, r, "/login", http.StatusFound)
119 return
120 }
121 m.handler(w, r, user)
122 }
123
124 func requireUser(handler authenticatedHandler) AuthMiddleware {
125 return AuthMiddleware{
126 handler: handler,
127 mustBeAdmin: false,
128 }
129 }
130
131 func requireAdmin(handler authenticatedHandler) AuthMiddleware {
132 return AuthMiddleware{
133 handler: handler,
134 mustBeAdmin: true,
135 }
136 }
137
138 type HeaderData struct {
139 SelectedMenu string
140 Username string
141 }
142
143 func renderHeader(w http.ResponseWriter, selectedMenu string) {
144 tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
145 data := HeaderData{
146 SelectedMenu: selectedMenu,
147 Username: "username",
148 }
149 err := tmpl.Execute(w, data)
150 if err != nil {
151 log.Fatal(err)
152 }
153 }
154
155 func renderFooter(w http.ResponseWriter) {
156 tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
157 err := tmpl.Execute(w, nil)
158 if err != nil {
159 log.Fatal(err)
160 }
161 }
162
163 type HomeData struct {
164 LoggedIn bool
165 Username string
166 }
167
168 func homePage(w http.ResponseWriter, r *http.Request, user User) {
169 renderHeader(w, "status")
170 tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
171 data := HomeData{
172 LoggedIn: true,
173 Username: "Bob",
174 }
175 tmpl.Execute(w, data)
176 renderFooter(w)
177 }
178
179 type LogInData struct {
180 Error string
181 }
182
183 func logInPage(w http.ResponseWriter, r *http.Request) {
184 r.ParseForm()
185 username := r.Form["username"]
186 password := r.Form["password"]
187 errText := ""
188 if username != nil {
189 user, err := users.Authenticate(username[0], password[0])
190 if err != nil {
191 errText = "Incorrect login"
192 } else {
193 createSessionCookie(w, user.Username)
194 http.Redirect(w, r, "/", http.StatusFound)
195 return
196 }
197 }
198
199 data := LogInData{
200 Error: errText,
201 }
202 renderHeader(w, "")
203 tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
204 tmpl.Execute(w, data)
205 renderFooter(w)
206 }
207
208 func playlistSection(w http.ResponseWriter, r *http.Request, user User) {
209 path := strings.Split(r.URL.Path, "/")
210 if len(path) != 3 {
211 http.NotFound(w, r)
212 return
213 }
214 if path[2] == "new" {
215 editPlaylistPage(w, r, 0)
216 } else if path[2] == "submit" && r.Method == "POST" {
217 submitPlaylist(w, r)
218 } else if path[2] == "delete" && r.Method == "POST" {
219 deletePlaylist(w, r)
220 } else if path[2] == "" {
221 playlistsPage(w, r)
222 } else {
223 id, err := strconv.Atoi(path[2])
224 if err != nil {
225 http.NotFound(w, r)
226 return
227 }
228 editPlaylistPage(w, r, id)
229 }
230 }
231
232 func fileSection(w http.ResponseWriter, r *http.Request, user User) {
233 path := strings.Split(r.URL.Path, "/")
234 if len(path) != 3 {
235 http.NotFound(w, r)
236 return
237 }
238 if path[2] == "upload" {
239 uploadFile(w, r)
240 } else if path[2] == "delete" && r.Method == "POST" {
241 deleteFile(w, r)
242 } else if path[2] == "" {
243 filesPage(w, r)
244 } else {
245 http.NotFound(w, r)
246 return
247 }
248 }
249
250 func radioSection(w http.ResponseWriter, r *http.Request, user User) {
251 path := strings.Split(r.URL.Path, "/")
252 if len(path) != 3 {
253 http.NotFound(w, r)
254 return
255 }
256 if path[2] == "new" {
257 editRadioPage(w, r, 0)
258 } else if path[2] == "submit" && r.Method == "POST" {
259 submitRadio(w, r)
260 } else if path[2] == "delete" && r.Method == "POST" {
261 deleteRadio(w, r)
262 } else if path[2] == "" {
263 radiosPage(w, r)
264 } else {
265 id, err := strconv.Atoi(path[2])
266 if err != nil {
267 http.NotFound(w, r)
268 return
269 }
270 editRadioPage(w, r, id)
271 }
272 }
273
274 type ChangePasswordPageData struct {
275 Message string
276 ShowForm bool
277 }
278
279 func changePasswordPage(w http.ResponseWriter, r *http.Request, user User) {
280 var data ChangePasswordPageData
281 if r.Method == "POST" {
282 err := r.ParseForm()
283 if err != nil {
284 w.WriteHeader(http.StatusBadRequest)
285 return
286 }
287 oldPassword := r.Form.Get("oldPassword")
288 newPassword := r.Form.Get("newPassword")
289 err = users.UpdatePassword(user.Username, oldPassword, newPassword)
290 if err != nil {
291 data.Message = "Failed to change password: " + err.Error()
292 data.ShowForm = true
293 } else {
294 data.Message = "Successfully changed password"
295 data.ShowForm = false
296 cookie, err := r.Cookie("broadcast_session")
297 if err == nil {
298 log.Println("clearing other sessions for username", user.Username, "token", cookie.Value)
299 db.ClearOtherSessions(user.Username, cookie.Value)
300 }
301 }
302 } else {
303 data.Message = ""
304 data.ShowForm = true
305 }
306 renderHeader(w, "change-password")
307 tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
308 err := tmpl.Execute(w, data)
309 if err != nil {
310 log.Fatal(err)
311 }
312 renderFooter(w)
313 }
314
315 type PlaylistsPageData struct {
316 Playlists []Playlist
317 }
318
319 func playlistsPage(w http.ResponseWriter, _ *http.Request) {
320 renderHeader(w, "playlists")
321 data := PlaylistsPageData{
322 Playlists: db.GetPlaylists(),
323 }
324 tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
325 err := tmpl.Execute(w, data)
326 if err != nil {
327 log.Fatal(err)
328 }
329 renderFooter(w)
330 }
331
332 type RadiosPageData struct {
333 Radios []Radio
334 }
335
336 func radiosPage(w http.ResponseWriter, _ *http.Request) {
337 renderHeader(w, "radios")
338 data := RadiosPageData{
339 Radios: db.GetRadios(),
340 }
341 tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
342 err := tmpl.Execute(w, data)
343 if err != nil {
344 log.Fatal(err)
345 }
346 renderFooter(w)
347 }
348
349 type EditPlaylistPageData struct {
350 Playlist Playlist
351 Entries []PlaylistEntry
352 Files []string
353 }
354
355 func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
356 var data EditPlaylistPageData
357 for _, f := range files.Files() {
358 data.Files = append(data.Files, f.Name)
359 }
360 if id == 0 {
361 data.Playlist.Enabled = true
362 data.Playlist.Name = "New Playlist"
363 data.Playlist.StartTime = time.Now().Format(formatString)
364 data.Entries = append(data.Entries, PlaylistEntry{})
365 } else {
366 playlist, err := db.GetPlaylist(id)
367 if err != nil {
368 http.NotFound(w, r)
369 return
370 }
371 data.Playlist = playlist
372 data.Entries = db.GetEntriesForPlaylist(id)
373 }
374 renderHeader(w, "radios")
375 tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
376 tmpl.Execute(w, data)
377 renderFooter(w)
378 }
379
380 func submitPlaylist(w http.ResponseWriter, r *http.Request) {
381 err := r.ParseForm()
382 if err == nil {
383 var p Playlist
384 id, err := strconv.Atoi(r.Form.Get("playlistId"))
385 if err != nil {
386 return
387 }
388 _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
389 if err != nil {
390 return
391 }
392 p.Id = id
393 p.Enabled = r.Form.Get("playlistEnabled") == "1"
394 p.Name = r.Form.Get("playlistName")
395 p.StartTime = r.Form.Get("playlistStartTime")
396
397 delays := r.Form["delaySeconds"]
398 filenames := r.Form["filename"]
399 isRelatives := r.Form["isRelative"]
400
401 entries := make([]PlaylistEntry, 0)
402 for i := range delays {
403 var e PlaylistEntry
404 delay, err := strconv.Atoi(delays[i])
405 if err != nil {
406 return
407 }
408 e.DelaySeconds = delay
409 e.Position = i
410 e.IsRelative = isRelatives[i] == "1"
411 e.Filename = filenames[i]
412 entries = append(entries, e)
413 }
414 cleanedEntries := make([]PlaylistEntry, 0)
415 for _, e := range entries {
416 if e.DelaySeconds != 0 || e.Filename != "" {
417 cleanedEntries = append(cleanedEntries, e)
418 }
419 }
420
421 if id != 0 {
422 db.UpdatePlaylist(p)
423 } else {
424 id = db.CreatePlaylist(p)
425 }
426 db.SetEntriesForPlaylist(cleanedEntries, id)
427 // Notify connected radios
428 playlists.NotifyChanges()
429 }
430 http.Redirect(w, r, "/playlists/", http.StatusFound)
431 }
432
433 func deletePlaylist(w http.ResponseWriter, r *http.Request) {
434 err := r.ParseForm()
435 if err == nil {
436 id, err := strconv.Atoi(r.Form.Get("playlistId"))
437 if err != nil {
438 return
439 }
440 db.DeletePlaylist(id)
441 playlists.NotifyChanges()
442 }
443 http.Redirect(w, r, "/playlists/", http.StatusFound)
444 }
445
446 type EditRadioPageData struct {
447 Radio Radio
448 }
449
450 func editRadioPage(w http.ResponseWriter, r *http.Request, id int) {
451 var data EditRadioPageData
452 if id == 0 {
453 data.Radio.Name = "New Radio"
454 data.Radio.Token = generateSession()
455 } else {
456 radio, err := db.GetRadio(id)
457 if err != nil {
458 http.NotFound(w, r)
459 return
460 }
461 data.Radio = radio
462 }
463 renderHeader(w, "radios")
464 tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
465 tmpl.Execute(w, data)
466 renderFooter(w)
467 }
468
469 func submitRadio(w http.ResponseWriter, r *http.Request) {
470 err := r.ParseForm()
471 if err == nil {
472 var radio Radio
473 id, err := strconv.Atoi(r.Form.Get("radioId"))
474 if err != nil {
475 return
476 }
477 radio.Id = id
478 radio.Name = r.Form.Get("radioName")
479 radio.Token = r.Form.Get("radioToken")
480 if id != 0 {
481 db.UpdateRadio(radio)
482 } else {
483 db.CreateRadio(radio)
484 }
485 }
486 http.Redirect(w, r, "/radios/", http.StatusFound)
487 }
488
489 func deleteRadio(w http.ResponseWriter, r *http.Request) {
490 err := r.ParseForm()
491 if err == nil {
492 id, err := strconv.Atoi(r.Form.Get("radioId"))
493 if err != nil {
494 return
495 }
496 db.DeleteRadio(id)
497 }
498 http.Redirect(w, r, "/radios/", http.StatusFound)
499 }
500
501 type FilesPageData struct {
502 Files []FileSpec
503 }
504
505 func filesPage(w http.ResponseWriter, _ *http.Request) {
506 renderHeader(w, "files")
507 data := FilesPageData{
508 Files: files.Files(),
509 }
510 log.Println("file page data", data)
511 tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
512 err := tmpl.Execute(w, data)
513 if err != nil {
514 log.Fatal(err)
515 }
516 renderFooter(w)
517 }
518
519 func deleteFile(w http.ResponseWriter, r *http.Request) {
520 err := r.ParseForm()
521 if err == nil {
522 filename := r.Form.Get("filename")
523 if filename == "" {
524 return
525 }
526 files.Delete(filename)
527 }
528 http.Redirect(w, r, "/files/", http.StatusFound)
529 }
530
531 func uploadFile(w http.ResponseWriter, r *http.Request) {
532 err := r.ParseMultipartForm(100 << 20)
533 file, handler, err := r.FormFile("file")
534 if err == nil {
535 path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
536 f, _ := os.Create(path)
537 defer f.Close()
538 io.Copy(f, file)
539 log.Println("uploaded file to", path)
540 files.Refresh()
541 }
542 http.Redirect(w, r, "/files/", http.StatusFound)
543 }
544
545 func logOutPage(w http.ResponseWriter, r *http.Request, user User) {
546 clearSessionCookie(w)
547 renderHeader(w, "")
548 tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
549 tmpl.Execute(w, nil)
550 renderFooter(w)
551 }
552
553 func stopPage(w http.ResponseWriter, r *http.Request, user User) {
554 r.ParseForm()
555 radioId, err := strconv.Atoi(r.Form.Get("radioId"))
556 if err != nil {
557 http.NotFound(w, r)
558 return
559 }
560 commandRouter.Stop(radioId)
561 http.Redirect(w, r, "/", http.StatusFound)
562 }