diff --git a/distro-packages/Arch-Linux/skald-git/PKGBUILD b/distro-packages/Arch-Linux/skald-git/PKGBUILD index 4d41677..235704e 100644 --- a/distro-packages/Arch-Linux/skald-git/PKGBUILD +++ b/distro-packages/Arch-Linux/skald-git/PKGBUILD @@ -2,7 +2,7 @@ # shellcheck shell=bash disable=SC2034,SC2154 pkgname=skald-git -pkgver=0.0.0.r1552.ge31bffd +pkgver=0.0.0.r1553.g0c852e8 pkgrel=1 pkgdesc='Audio-only hall-based conferencing server' arch=('x86_64' 'aarch64') diff --git a/webserver/webserver.go b/webserver/webserver.go index 7845437..ec56d2a 100644 --- a/webserver/webserver.go +++ b/webserver/webserver.go @@ -689,17 +689,16 @@ func handleHallAction(w http.ResponseWriter, r *http.Request, hall string) { q := r.Form.Get("q") switch q { - case "delete": - filename := r.Form.Get("filename") - if hall == "" || filename == "" { - http.Error(w, "No filename provided", - http.StatusBadRequest) + case "confirm-delete": + filename, ok := recordingFilenameFromForm(w, r, hall) + if !ok { return } - if strings.ContainsRune(filename, '/') || - strings.ContainsRune(filename, filepath.Separator) { - http.Error(w, "Bad character in filename", - http.StatusBadRequest) + serveDeleteRecordingConfirmation(w, hall, filename) + return + case "delete": + filename, ok := recordingFilenameFromForm(w, r, hall) + if !ok { return } err := os.Remove( @@ -713,7 +712,7 @@ func handleHallAction(w http.ResponseWriter, r *http.Request, hall string) { httpError(w, err) return } - http.Redirect(w, r, "/recordings/"+hall+"/", + http.Redirect(w, r, recordingsHallPath(hall), http.StatusSeeOther) return default: @@ -721,6 +720,22 @@ func handleHallAction(w http.ResponseWriter, r *http.Request, hall string) { } } +func recordingFilenameFromForm(w http.ResponseWriter, r *http.Request, hall string) (string, bool) { + filename := r.Form.Get("filename") + if hall == "" || filename == "" { + http.Error(w, "No filename provided", + http.StatusBadRequest) + return "", false + } + if strings.ContainsRune(filename, '/') || + strings.ContainsRune(filename, filepath.Separator) { + http.Error(w, "Bad character in filename", + http.StatusBadRequest) + return "", false + } + return filename, true +} + func checkHallPermissions(w http.ResponseWriter, r *http.Request, hallname string) bool { user, pass, ok := r.BasicAuth() if !ok { @@ -758,6 +773,37 @@ func checkHallPermissions(w http.ResponseWriter, r *http.Request, hallname strin return true } +func recordingsHallPath(hall string) string { + parts := strings.Split(hall, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return "/recordings/" + strings.Join(parts, "/") + "/" +} + +func serveDeleteRecordingConfirmation(w http.ResponseWriter, hall string, filename string) { + hallPath := recordingsHallPath(hall) + escapedFilename := html.EscapeString(filename) + + w.Header().Set("content-type", "text/html; charset=utf-8") + w.Header().Set("cache-control", "no-cache") + + fmt.Fprintf(w, "\n\n") + fmt.Fprintf(w, "Delete recording %v\n", escapedFilename) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "\n") + fmt.Fprintf(w, "

Delete recording?

\n") + fmt.Fprintf(w, "

Delete %v? This cannot be undone.

\n", escapedFilename) + fmt.Fprintf(w, + "
"+ + ""+ + ""+ + "
\n", + hallPath, escapedFilename) + fmt.Fprintf(w, "

Cancel

\n", hallPath) + fmt.Fprintf(w, "\n") +} + func serveHallRecordings(w http.ResponseWriter, r *http.Request, f *os.File, hall string) { // read early, so we return permission errors to HEAD fis, err := f.Readdir(-1) @@ -793,11 +839,12 @@ func serveHallRecordings(w http.ResponseWriter, r *http.Request, f *os.File, hal fi.Size(), ) fmt.Fprintf(w, - "
"+ + ""+ ""+ - ""+ + ""+ "
\n", - url.PathEscape(hall), fi.Name()) + recordingsHallPath(hall), + html.EscapeString(fi.Name())) } fmt.Fprintf(w, "\n") fmt.Fprintf(w, "\n") diff --git a/webserver/webserver_test.go b/webserver/webserver_test.go index 7d99dd9..4a69321 100644 --- a/webserver/webserver_test.go +++ b/webserver/webserver_test.go @@ -59,6 +59,61 @@ func TestRecordingsAuthenticationFailureMessage(t *testing.T) { } } +func TestHallRecordingsDeleteButtonOpensConfirmation(t *testing.T) { + dir := t.TempDir() + recordingName := `skald-recording-20260707-"010203".ogg` + err := os.WriteFile(filepath.Join(dir, recordingName), nil, 0600) + if err != nil { + t.Fatalf("write recording: %v", err) + } + f, err := os.Open(dir) + if err != nil { + t.Fatalf("open recordings dir: %v", err) + } + defer f.Close() + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/recordings/class/audio/", nil) + serveHallRecordings(w, r, f, "class/audio") + + body := w.Body.String() + if !strings.Contains(body, `action="/recordings/class/audio/"`) { + t.Fatalf("recordings list missing encoded hall action: %q", body) + } + if !strings.Contains(body, `name="q" value="confirm-delete">Delete`) { + t.Fatalf("delete button does not open confirmation: %q", body) + } + if strings.Contains(body, `name="q" value="delete">Delete`) { + t.Fatalf("recordings list still has direct delete button: %q", body) + } + if !strings.Contains(body, `value="skald-recording-20260707-"010203".ogg"`) { + t.Fatalf("recordings list did not escape filename value: %q", body) + } +} + +func TestHallRecordingsDeleteConfirmationRequiresSecondPost(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/recordings/class/audio/", + strings.NewReader("q=confirm-delete&filename=skald-recording-20260707-010203.ogg")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + handleHallAction(w, r, "class/audio") + + body := w.Body.String() + if !strings.Contains(body, "

Delete recording?

") { + t.Fatalf("confirmation page missing heading: %q", body) + } + if !strings.Contains(body, `action="/recordings/class/audio/"`) { + t.Fatalf("confirmation page missing encoded hall action: %q", body) + } + if !strings.Contains(body, `name="q" value="delete">Delete recording`) { + t.Fatalf("confirmation page missing final delete button: %q", body) + } + if !strings.Contains(body, `Cancel`) { + t.Fatalf("confirmation page missing cancel link: %q", body) + } +} + func TestBase(t *testing.T) { a := []struct { p string