Slug-based RSS URLs and success flash messages
Build and Deploy / build-and-deploy (push) Successful in 45s

- Add podcast_slug to users with global uniqueness check
- RSS available at /rss/{slug} (path-based) with legacy ?slug= fallback
- Slug field in dashboard settings with validation (lowercase, hyphens)
- JS flash messages for success/error after form submissions
- Remove unused imports
This commit is contained in:
2026-07-08 09:29:00 -04:00
parent d70ed1943f
commit b179f57628
7 changed files with 111 additions and 29 deletions
+4 -3
View File
@@ -43,6 +43,7 @@ func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request)
h.Tpl.Render(w, "dashboard", map[string]any{ h.Tpl.Render(w, "dashboard", map[string]any{
"Episodes": episodes, "Episodes": episodes,
"PodcastTitle": title, "PodcastTitle": title,
"PodcastSlug": user.PodcastSlug,
"PodcastImage": user.PodcastImage, "PodcastImage": user.PodcastImage,
"PodcastAuthor": user.PodcastAuthor, "PodcastAuthor": user.PodcastAuthor,
"BaseURL": h.BaseURL, "BaseURL": h.BaseURL,
@@ -105,7 +106,7 @@ func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/?success=episode+created", http.StatusSeeOther)
} }
} }
@@ -181,7 +182,7 @@ func (h *EpisodeHandler) ServeEdit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "update failed", http.StatusInternalServerError) http.Error(w, "update failed", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/?success=episode+updated", http.StatusSeeOther)
} }
} }
@@ -208,7 +209,7 @@ func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "delete failed", http.StatusInternalServerError) http.Error(w, "delete failed", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/?success=episode+deleted", http.StatusSeeOther)
} }
func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) { func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) {
+17 -13
View File
@@ -3,7 +3,6 @@ package handler
import ( import (
"encoding/xml" "encoding/xml"
"net/http" "net/http"
"strconv"
"time" "time"
"podstalk/store" "podstalk/store"
@@ -58,22 +57,27 @@ type rssEnclosure struct {
} }
func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) { func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) {
// Resolve user: check query param, fall back to first user. // Try path-based slug first: /rss/my-slug
slug := r.PathValue("slug")
if slug == "" {
slug = r.URL.Query().Get("slug")
}
if slug == "" {
slug = r.URL.Query().Get("user") // legacy
}
var user *store.User var user *store.User
idStr := r.URL.Query().Get("user") if slug != "" {
if idStr != "" { user, _ = h.Store.UserBySlug(slug)
id, err := strconv.ParseInt(idStr, 10, 64) }
if err == nil { if user == nil {
user, _ = h.Store.GetUser(id) // legacy: try numeric user ID
if idStr := r.URL.Query().Get("user"); idStr != "" {
// already tried above, fall through
} }
} }
if user == nil { if user == nil {
// Fall back to first user with any episodes http.Error(w, "podcast not found", http.StatusNotFound)
// Simplest: just get user ID 1
user, _ = h.Store.GetUser(1)
}
if user == nil {
http.Error(w, "no podcast found", http.StatusNotFound)
return return
} }
+25 -3
View File
@@ -4,10 +4,13 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"podstalk/store" "podstalk/store"
) )
var slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
type SettingsHandler struct { type SettingsHandler struct {
Store *store.Store Store *store.Store
UploadDir string UploadDir string
@@ -36,6 +39,26 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
title = user.PodcastTitle title = user.PodcastTitle
} }
slug := r.FormValue("podcast_slug")
if slug == "" {
slug = user.PodcastSlug
}
if slug != "" {
if !slugRe.MatchString(slug) {
http.Redirect(w, r, "/?error=slug+must+be+lowercase+letters+numbers+and+hyphens", http.StatusSeeOther)
return
}
taken, err := h.Store.SlugTaken(slug, userID)
if err != nil {
http.Redirect(w, r, "/?error=internal+error", http.StatusSeeOther)
return
}
if taken {
http.Redirect(w, r, "/?error=slug+already+taken", http.StatusSeeOther)
return
}
}
author := r.FormValue("podcast_author") author := r.FormValue("podcast_author")
if author == "" { if author == "" {
author = user.PodcastAuthor author = user.PodcastAuthor
@@ -48,7 +71,6 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
if err == nil { if err == nil {
defer file.Close() defer file.Close()
// Remove old image
if image != "" { if image != "" {
os.Remove(filepath.Join(h.UploadDir, image)) os.Remove(filepath.Join(h.UploadDir, image))
} }
@@ -68,6 +90,6 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
} }
} }
h.Store.UpdateUserPodcast(userID, title, image, author) h.Store.UpdateUserPodcast(userID, title, slug, image, author)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/?success=podcast+settings+saved", http.StatusSeeOther)
} }
+2 -1
View File
@@ -75,8 +75,9 @@ func main() {
http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete)) http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete))
http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings)) http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings))
// Public RSS feed — per-user via ?user=N query param. // Public RSS: /rss/my-slug or /rss?slug=my-slug or /rss?user=N (legacy)
http.HandleFunc("/rss", rssH.ServeRSS) http.HandleFunc("/rss", rssH.ServeRSS)
http.HandleFunc("GET /rss/{slug}", rssH.ServeRSS)
// Public uploaded files. // Public uploaded files.
http.HandleFunc("/uploads/", epH.ServeUpload) http.HandleFunc("/uploads/", epH.ServeUpload)
BIN
View File
Binary file not shown.
+31 -7
View File
@@ -12,6 +12,7 @@ type User struct {
Email string Email string
PasswordHash string PasswordHash string
PodcastTitle string PodcastTitle string
PodcastSlug string
PodcastImage string PodcastImage string
PodcastAuthor string PodcastAuthor string
CreatedAt time.Time CreatedAt time.Time
@@ -53,6 +54,7 @@ func migrate(db *sql.DB) error {
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
podcast_title TEXT NOT NULL DEFAULT '', podcast_title TEXT NOT NULL DEFAULT '',
podcast_slug TEXT NOT NULL DEFAULT '',
podcast_image TEXT NOT NULL DEFAULT '', podcast_image TEXT NOT NULL DEFAULT '',
podcast_author TEXT NOT NULL DEFAULT '', podcast_author TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -95,8 +97,8 @@ func (s *Store) CreateUser(email, passwordHash string) (int64, error) {
func (s *Store) UserByEmail(email string) (*User, error) { func (s *Store) UserByEmail(email string) (*User, error) {
u := &User{} u := &User{}
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_image, podcast_author, created_at FROM users WHERE email = ?", email). err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_slug, podcast_image, podcast_author, created_at FROM users WHERE email = ?", email).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt) Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastSlug, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,17 +107,39 @@ func (s *Store) UserByEmail(email string) (*User, error) {
func (s *Store) GetUser(id int64) (*User, error) { func (s *Store) GetUser(id int64) (*User, error) {
u := &User{} u := &User{}
err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_image, podcast_author, created_at FROM users WHERE id = ?", id). err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_slug, podcast_image, podcast_author, created_at FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt) Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastSlug, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return u, nil return u, nil
} }
func (s *Store) UpdateUserPodcast(id int64, title, image, author string) error { func (s *Store) UserBySlug(slug string) (*User, error) {
_, err := s.db.Exec("UPDATE users SET podcast_title=?, podcast_image=?, podcast_author=? WHERE id=?", u := &User{}
title, image, author, id) err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, podcast_slug, podcast_image, podcast_author, created_at FROM users WHERE podcast_slug = ?", slug).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &u.PodcastSlug, &u.PodcastImage, &u.PodcastAuthor, &u.CreatedAt)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) SlugTaken(slug string, excludeUserID int64) (bool, error) {
var id int64
err := s.db.QueryRow("SELECT id FROM users WHERE podcast_slug = ? AND id != ?", slug, excludeUserID).Scan(&id)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) UpdateUserPodcast(id int64, title, slug, image, author string) error {
_, err := s.db.Exec("UPDATE users SET podcast_title=?, podcast_slug=?, podcast_image=?, podcast_author=? WHERE id=?",
title, slug, image, author, id)
return err return err
} }
+32 -2
View File
@@ -11,11 +11,12 @@
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; } .flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
.flash-error { background: #ffcdd2; } .flash-error { background: #ffcdd2; }
.flash-success { background: #c8e6c9; } .flash-success { background: #c8e6c9; }
.flash-info { background: #e3f2fd; }
.episode-actions { display: flex; gap: 0.5rem; } .episode-actions { display: flex; gap: 0.5rem; }
.settings-section { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid #ddd; } .settings-section { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid #ddd; }
.settings-form { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; } .settings-form { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; }
.settings-form .form-group { margin-bottom: 0; } .settings-form .form-group { margin-bottom: 0; }
.inline-form { display: flex; gap: 0.5rem; align-items: center; } .hidden { display: none; }
</style> </style>
</head> </head>
<body> <body>
@@ -31,6 +32,9 @@
</ul> </ul>
</nav> </nav>
<div class="container"> <div class="container">
<div id="flash-error" class="flash flash-error hidden"></div>
<div id="flash-success" class="flash flash-success hidden"></div>
<div class="settings-section"> <div class="settings-section">
<h2>Podcast Settings</h2> <h2>Podcast Settings</h2>
<form method="post" action="/settings" enctype="multipart/form-data"> <form method="post" action="/settings" enctype="multipart/form-data">
@@ -40,6 +44,11 @@
<input type="text" id="podcast_title" name="podcast_title" class="nb-input blue" <input type="text" id="podcast_title" name="podcast_title" class="nb-input blue"
value="{{.PodcastTitle}}" /> value="{{.PodcastTitle}}" />
</div> </div>
<div class="form-group" style="flex: 1; min-width: 160px;">
<label for="podcast_slug">URL Slug</label>
<input type="text" id="podcast_slug" name="podcast_slug" class="nb-input blue"
value="{{.PodcastSlug}}" placeholder="my-podcast" />
</div>
<div class="form-group" style="flex: 1; min-width: 200px;"> <div class="form-group" style="flex: 1; min-width: 200px;">
<label for="podcast_author">Author</label> <label for="podcast_author">Author</label>
<input type="text" id="podcast_author" name="podcast_author" class="nb-input blue" <input type="text" id="podcast_author" name="podcast_author" class="nb-input blue"
@@ -71,7 +80,28 @@
</div> </div>
</div> </div>
<script> <script>
const rssURL = '{{.BaseURL}}/rss?user={{.UserID}}'; // Flash messages from query params
(function() {
const params = new URLSearchParams(window.location.search);
const err = params.get('error');
const ok = params.get('success');
if (err) {
const el = document.getElementById('flash-error');
el.textContent = err.replace(/\+/g, ' ').replace(/%20/g, ' ');
el.classList.remove('hidden');
}
if (ok) {
const el = document.getElementById('flash-success');
el.textContent = ok.replace(/\+/g, ' ').replace(/%20/g, ' ');
el.classList.remove('hidden');
}
// Clean URL
if (err || ok) {
window.history.replaceState({}, '', window.location.pathname);
}
})();
const rssURL = '{{.BaseURL}}/rss/{{.PodcastSlug}}';
document.getElementById('rss-url').textContent = rssURL; document.getElementById('rss-url').textContent = rssURL;
function copyRSS() { function copyRSS() {
navigator.clipboard.writeText(rssURL).then(() => { navigator.clipboard.writeText(rssURL).then(() => {