diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 54e5ca7f..29fd1625 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -806,3 +806,79 @@ func TestPodcastDownloadAllPolicyFlip(t *testing.T) { assert.Equal(t, "all", sub.AutoDownload, "PIN: Download All flips newest → all (dynamic + visible)") }) } + +// ---- logout actually signs out (fake-signout bug, 2026-10-30) ---- + +func TestLogoutClearsSession(t *testing.T) { + setup := setupTestServer(t) + + // Real login: capture the session cookie AND the refresh token. + req, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/login", + strings.NewReader(`{"login":"testuser@tests.bookhoard.internal","password":"Test@Pass123!"}`)) + req.Header.Set("Content-Type", "application/json") + loginResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = loginResp.Body.Close() }() + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + var cookies []*http.Cookie + for _, c := range loginResp.Cookies() { + if c.Name == "token" && c.Value != "" { + cookies = append(cookies, c) + } + } + require.NotEmpty(t, cookies, "login must set the session cookie") + var loginBody struct { + RefreshToken string `json:"refresh_token"` + } + require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody)) + require.NotEmpty(t, loginBody.RefreshToken, "login must hand back a refresh token") + + // Logout WITH the session cookie + refresh token. + logoutReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", + strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`)) + logoutReq.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + logoutReq.AddCookie(c) + } + logoutResp, err := http.DefaultClient.Do(logoutReq) + require.NoError(t, err) + defer func() { _ = logoutResp.Body.Close() }() + require.Equal(t, http.StatusOK, logoutResp.StatusCode) + + // PIN 1: the response deletes the session cookie. + var cleared bool + for _, c := range logoutResp.Cookies() { + if c.Name == "token" && c.Value == "" && c.MaxAge < 0 { + cleared = true + } + } + assert.True(t, cleared, "PIN: logout must Set-Cookie an empty/expired token (HttpOnly — the browser is the only eraser)") + + // PIN 2: the refresh token is revoked server-side. + refreshReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/refresh", + strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`)) + refreshReq.Header.Set("Content-Type", "application/json") + refreshResp, err := http.DefaultClient.Do(refreshReq) + require.NoError(t, err) + defer func() { _ = refreshResp.Body.Close() }() + assert.NotEqual(t, http.StatusOK, refreshResp.StatusCode, + "PIN: a logged-out refresh token must not mint new access tokens") + + // Logout WITHOUT a body still clears the cookie (malformed/absent + // payloads must never leave the browser signed in). + logoutReq2, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", nil) + for _, c := range cookies { + logoutReq2.AddCookie(c) + } + logoutResp2, err := http.DefaultClient.Do(logoutReq2) + require.NoError(t, err) + defer func() { _ = logoutResp2.Body.Close() }() + cleared = false + for _, c := range logoutResp2.Cookies() { + if c.Name == "token" && c.Value == "" && c.MaxAge < 0 { + cleared = true + } + } + assert.True(t, cleared, "cookie clear must not depend on the request body") +} diff --git a/internal/handlers/refresh_token.go b/internal/handlers/refresh_token.go index 4863926c..5b581cb3 100644 --- a/internal/handlers/refresh_token.go +++ b/internal/handlers/refresh_token.go @@ -74,6 +74,23 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error { // Logout handles POST /api/auth/logout func (h *AuthHandler) Logout(c *echo.Context) error { + // Clear the HttpOnly session cookie — the SSR pages authenticate + // with it, and JavaScript cannot clear an HttpOnly cookie by + // design. Without this, "logout" wiped localStorage but left the + // cookie session alive for up to session_duration_seconds (the + // fake-signout bug: users roamed every cookie-authed page). The + // cookie is cleared BEFORE anything else — even a malformed body + // must sign the browser out. + c.SetCookie(&http.Cookie{ + Name: "token", + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure == "true", + SameSite: http.SameSiteLaxMode, + MaxAge: -1, // delete + }) + var req RefreshTokenRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusOK, map[string]string{"message": "logged out successfully"}) diff --git a/templates/header_templ.go b/templates/header_templ.go index 2c8254ba..9291626b 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -915,7 +915,7 @@ func SidebarSignIn(currentPath string) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/podcast_detail_templ.go b/templates/podcast_detail_templ.go index 40c349ab..8c271346 100644 --- a/templates/podcast_detail_templ.go +++ b/templates/podcast_detail_templ.go @@ -15,6 +15,9 @@ import templruntime "github.com/a-h/templ/runtime" // refreshing htmx partial: it re-fetches on the podcast:changed event // AND polls every 4s ONLY while downloads are active (server decides — // the polling attributes render only then). + +import "fmt" + func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -43,7 +46,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 17, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 19, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -99,7 +102,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + data.Feed.LibraryID + "/" + data.Feed.ArtworkPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 34, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 36, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -112,7 +115,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + data.Feed.DisplayTitle()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 35, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 37, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -135,7 +138,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.TypeLabel()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 45, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 47, Col: 118} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -159,7 +162,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.Language) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 50, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 52, Col: 129} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -177,7 +180,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 53, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 55, Col: 118} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -196,7 +199,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("Host: " + data.Feed.Host) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 57, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 59, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -211,7 +214,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(" · Publisher: " + data.Feed.Publisher) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 60, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 62, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -231,7 +234,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 65, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 67, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -255,7 +258,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 70, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 72, Col: 113} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -278,7 +281,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 75, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 77, Col: 113} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -292,7 +295,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · last checked " + data.Feed.LastFetched) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 77, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 79, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -306,13 +309,13 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 87, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 89, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"none\" title=\"Queue every episode that is not downloaded yet\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-target=\"#episode-list\" hx-swap=\"outerHTML\" title=\"Queue every episode that is not downloaded yet\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -327,13 +330,13 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/refresh") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 96, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 99, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-swap=\"none\" title=\"Check the feed for new episodes now\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-target=\"#episode-list\" hx-swap=\"outerHTML\" title=\"Check the feed for new episodes now\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -348,20 +351,20 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 110, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 114, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-swap=\"none\">