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\">