diff --git a/README.md b/README.md index 230ff78..f731fc3 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,7 @@ docker run -d \ -v podstalk-data:/data \ -v podstalk-uploads:/uploads \ -e PODSTALK_LINK=https://podcast.example.com \ - -e PODSTALK_TITLE="My Podcast" \ podstalk -``` On first launch, visit `http://localhost:8080/signup` to create your account. @@ -31,15 +29,17 @@ On first launch, visit `http://localhost:8080/signup` to create your account. | `PODSTALK_DB` | `podstalk.db` | SQLite database path | | `PODSTALK_UPLOAD_DIR` | `uploads` | Audio/image file storage | | `PODSTALK_LINK` | `http://localhost:8080` | Public URL (used in RSS feed) | -| `PODSTALK_TITLE` | `Podstalk` | Fallback podcast title | -| `PODSTALK_AUTHOR` | `Podstalk` | RSS author field | -The podcast title and image can also be set from the dashboard after signing in. +Podcast details (title, author, image) are managed from the dashboard after signing in. ## RSS Feed -The RSS 2.0 feed (with iTunes extensions) is available at `/rss`. It includes -``, ``, ``, and `` for each episode. +The RSS 2.0 feed (with iTunes extensions) is available at `/rss`. Each user +gets their own feed. By default `/rss` serves the first user's podcast; pass +`?user=N` to select a specific user's feed. + +The feed includes ``, ``, ``, ``, and +`` for each episode. ## Running Without Docker diff --git a/handler/auth.go b/handler/auth.go index 8739f2e..e44cb78 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -1,6 +1,7 @@ package handler import ( + "context" "crypto/rand" "encoding/hex" "net/http" @@ -135,8 +136,16 @@ func (h *AuthHandler) ServeSignout(w http.ResponseWriter, r *http.Request) { }) http.Redirect(w, r, "/signin", http.StatusSeeOther) } +type contextKey string + +const userIDKey contextKey = "userID" + +// UserIDFromContext extracts the authenticated user's ID from the request context. +func UserIDFromContext(r *http.Request) int64 { + id, _ := r.Context().Value(userIDKey).(int64) + return id +} -// AuthMiddleware redirects to /signin (or /signup if no users) when not authenticated. type AuthMiddleware struct { Store *store.Store Sessions *SessionStore @@ -149,11 +158,13 @@ func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc { http.Redirect(w, r, "/signin", http.StatusSeeOther) return } - if _, ok := am.Sessions.Get(cookie.Value); !ok { + userID, ok := am.Sessions.Get(cookie.Value) + if !ok { http.Redirect(w, r, "/signin", http.StatusSeeOther) return } - next(w, r) + ctx := context.WithValue(r.Context(), userIDKey, userID) + next(w, r.WithContext(ctx)) } } diff --git a/handler/episode.go b/handler/episode.go index 518322f..c5576f8 100644 --- a/handler/episode.go +++ b/handler/episode.go @@ -23,28 +23,36 @@ type EpisodeHandler struct { } func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request) { - episodes, err := h.Store.ListEpisodes() + userID := UserIDFromContext(r) + user, err := h.Store.GetUser(userID) + if err != nil { + http.Error(w, "user not found", http.StatusInternalServerError) + return + } + + episodes, err := h.Store.ListEpisodes(userID) if err != nil { http.Error(w, "failed to load episodes", http.StatusInternalServerError) return } - title, _ := h.Store.GetSetting("podcast_title") + title := user.PodcastTitle if title == "" { title = "Podstalk" } - - podcastImage, _ := h.Store.GetSetting("podcast_image") - h.Tpl.Render(w, "dashboard", map[string]any{ - "Episodes": episodes, - "PodcastTitle": title, - "PodcastImage": podcastImage, - "BaseURL": h.BaseURL, + "Episodes": episodes, + "PodcastTitle": title, + "PodcastImage": user.PodcastImage, + "PodcastAuthor": user.PodcastAuthor, + "BaseURL": h.BaseURL, + "UserID": user.ID, }) } func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromContext(r) + if r.Method == http.MethodGet { h.Tpl.Render(w, "episode_form", map[string]any{"Editing": false}) return @@ -89,7 +97,7 @@ func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) { imagePath, _ := h.saveUploadedFile(r, "image_file", "images") - _, err = h.Store.CreateEpisode(title, description, audioPath, imagePath, publishedAt) + _, err = h.Store.CreateEpisode(userID, title, description, audioPath, imagePath, publishedAt) if err != nil { h.Tpl.Render(w, "episode_form", map[string]any{ "Editing": false, @@ -151,7 +159,6 @@ func (h *EpisodeHandler) ServeEdit(w http.ResponseWriter, r *http.Request) { return } - // Load existing episode to know which files to replace existing, err := h.Store.GetEpisode(id) if err != nil { http.Error(w, "episode not found", http.StatusNotFound) @@ -191,7 +198,6 @@ func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) { return } - // Delete associated files ep, err := h.Store.GetEpisode(id) if err == nil { h.removeUploadedFile(ep.AudioPath) @@ -205,9 +211,7 @@ func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } -// ServeUpload serves files from the upload directory. func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) { - // Strip /uploads/ prefix to get the relative path relPath := strings.TrimPrefix(r.URL.Path, "/uploads/") if relPath == "" || strings.Contains(relPath, "..") { http.Error(w, "invalid path", http.StatusBadRequest) @@ -216,8 +220,6 @@ func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Join(h.UploadDir, relPath)) } -// saveUploadedFile saves a file from a multipart form field to the upload directory. -// Returns the relative path (category/filename) or empty string if no file uploaded. func (h *EpisodeHandler) saveUploadedFile(r *http.Request, field, category string) (string, error) { file, header, err := r.FormFile(field) if err != nil { @@ -260,7 +262,6 @@ func (h *EpisodeHandler) removeUploadedFile(relPath string) { } func sanitizeFilename(name string) string { - // Keep only the base name, lowercased, alphanumeric + dots + dashes name = filepath.Base(name) name = strings.Map(func(r rune) rune { if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' { diff --git a/handler/rss.go b/handler/rss.go index 858cb5b..5dff178 100644 --- a/handler/rss.go +++ b/handler/rss.go @@ -3,35 +3,15 @@ package handler import ( "encoding/xml" "net/http" + "strconv" "time" "podstalk/store" ) type RSSHandler struct { - Store *store.Store - BaseTitle string - Link string - Author string -} - -func (h *RSSHandler) resolveTitle() string { - title, err := h.Store.GetSetting("podcast_title") - if err == nil && title != "" { - return title - } - if h.BaseTitle != "" { - return h.BaseTitle - } - return "Podstalk" -} - -func (h *RSSHandler) resolveImage() string { - img, err := h.Store.GetSetting("podcast_image") - if err == nil && img != "" { - return img - } - return "" + Store *store.Store + Link string } type rssFeed struct { @@ -46,6 +26,7 @@ type rssChannel struct { Link string `xml:"link"` Description string `xml:"description"` Language string `xml:"language"` + Author string `xml:"itunes:author,omitempty"` Image *rssImage `xml:"image,omitempty"` ItunesImage *rssItunesImage `xml:"itunes:image,omitempty"` Items []rssItem `xml:"item"` @@ -77,21 +58,43 @@ type rssEnclosure struct { } func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) { - episodes, err := h.Store.ListEpisodes() + // Resolve user: check query param, fall back to first user. + var user *store.User + idStr := r.URL.Query().Get("user") + if idStr != "" { + id, err := strconv.ParseInt(idStr, 10, 64) + if err == nil { + user, _ = h.Store.GetUser(id) + } + } + if user == nil { + // Fall back to first user with any episodes + // Simplest: just get user ID 1 + user, _ = h.Store.GetUser(1) + } + if user == nil { + http.Error(w, "no podcast found", http.StatusNotFound) + return + } + + episodes, err := h.Store.ListEpisodes(user.ID) if err != nil { http.Error(w, "failed to load episodes", http.StatusInternalServerError) return } - podcastTitle := h.resolveTitle() - podcastImage := h.resolveImage() + title := user.PodcastTitle + if title == "" { + title = "Podstalk" + } + author := user.PodcastAuthor items := make([]rssItem, 0, len(episodes)) for _, ep := range episodes { var itemImage *rssItunesImage epImage := ep.ImagePath if epImage == "" { - epImage = podcastImage + epImage = user.PodcastImage } if epImage != "" { itemImage = &rssItunesImage{Href: h.fullURL(epImage)} @@ -112,23 +115,24 @@ func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) { var chanImage *rssImage var chanItunesImage *rssItunesImage - if podcastImage != "" { + if user.PodcastImage != "" { chanImage = &rssImage{ - URL: h.fullURL(podcastImage), - Title: podcastTitle, + URL: h.fullURL(user.PodcastImage), + Title: title, Link: h.Link, } - chanItunesImage = &rssItunesImage{Href: h.fullURL(podcastImage)} + chanItunesImage = &rssItunesImage{Href: h.fullURL(user.PodcastImage)} } feed := rssFeed{ Version: "2.0", ItunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd", Channel: rssChannel{ - Title: podcastTitle, + Title: title, Link: h.Link, - Description: podcastTitle, + Description: title, Language: "en-us", + Author: author, Image: chanImage, ItunesImage: chanItunesImage, Items: items, diff --git a/handler/settings.go b/handler/settings.go index 1518261..0517cf5 100644 --- a/handler/settings.go +++ b/handler/settings.go @@ -19,32 +19,55 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request) return } + userID := UserIDFromContext(r) + user, err := h.Store.GetUser(userID) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if err := r.ParseMultipartForm(10 << 20); err != nil { http.Redirect(w, r, "/?error=file+too+large", http.StatusSeeOther) return } title := r.FormValue("podcast_title") - if title != "" { - h.Store.SetSetting("podcast_title", title) + if title == "" { + title = user.PodcastTitle } + author := r.FormValue("podcast_author") + if author == "" { + author = user.PodcastAuthor + } + + image := user.PodcastImage + // Handle podcast image upload file, header, err := r.FormFile("podcast_image") if err == nil { defer file.Close() + // Remove old image + if image != "" { + os.Remove(filepath.Join(h.UploadDir, image)) + } + catDir := filepath.Join(h.UploadDir, "images") os.MkdirAll(catDir, 0755) ext := filepath.Ext(header.Filename) + if ext == "" { + ext = ".png" + } dst, err := os.Create(filepath.Join(catDir, "podcast"+ext)) if err == nil { defer dst.Close() dst.ReadFrom(file) - h.Store.SetSetting("podcast_image", "images/podcast"+ext) + image = "images/podcast" + ext } } + h.Store.UpdateUserPodcast(userID, title, image, author) http.Redirect(w, r, "/", http.StatusSeeOther) } diff --git a/main.go b/main.go index ae47bb5..8ac825a 100644 --- a/main.go +++ b/main.go @@ -59,13 +59,11 @@ func main() { UploadDir: uploadDir, } rssH := &handler.RSSHandler{ - Store: st, - BaseTitle: envOrDefault("PODSTALK_TITLE", "Podstalk"), - Link: baseURL, - Author: envOrDefault("PODSTALK_AUTHOR", "Podstalk"), + Store: st, + Link: baseURL, } - // Auth routes — redirect to dashboard if already signed in. + // Auth routes. http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup)) http.HandleFunc("/signin", authMid.RedirectIfAuthed(authH.ServeSignin)) http.HandleFunc("/signout", authH.ServeSignout) @@ -77,7 +75,7 @@ func main() { http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete)) http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings)) - // Public RSS feed. + // Public RSS feed — per-user via ?user=N query param. http.HandleFunc("/rss", rssH.ServeRSS) // Public uploaded files. diff --git a/store/store.go b/store/store.go index 76128ed..bd6f8d0 100644 --- a/store/store.go +++ b/store/store.go @@ -8,14 +8,18 @@ import ( ) type User struct { - ID int64 - Email string - PasswordHash string - CreatedAt time.Time + ID int64 + Email string + PasswordHash string + PodcastTitle string + PodcastImage string + PodcastAuthor string + CreatedAt time.Time } type Episode struct { ID int64 + UserID int64 Title string Description string AudioPath string @@ -48,11 +52,15 @@ func migrate(db *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, + podcast_title TEXT NOT NULL DEFAULT '', + podcast_image TEXT NOT NULL DEFAULT '', + podcast_author TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS episodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', audio_path TEXT NOT NULL DEFAULT '', @@ -60,11 +68,6 @@ func migrate(db *sql.DB) error { published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); - - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL DEFAULT '' - ); ` _, err := db.Exec(schema) return err @@ -92,17 +95,34 @@ func (s *Store) CreateUser(email, passwordHash string) (int64, error) { func (s *Store) UserByEmail(email string) (*User, error) { u := &User{} - err := s.db.QueryRow("SELECT id, email, password_hash, created_at FROM users WHERE email = ?", email). - Scan(&u.ID, &u.Email, &u.PasswordHash, &u.CreatedAt) + err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, 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) if err != nil { return nil, err } return u, nil } +func (s *Store) GetUser(id int64) (*User, error) { + u := &User{} + err := s.db.QueryRow("SELECT id, email, password_hash, podcast_title, 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) + if err != nil { + return nil, err + } + return u, nil +} + +func (s *Store) UpdateUserPodcast(id int64, title, image, author string) error { + _, err := s.db.Exec("UPDATE users SET podcast_title=?, podcast_image=?, podcast_author=? WHERE id=?", + title, image, author, id) + return err +} + // --- Episodes --- -func (s *Store) ListEpisodes() ([]Episode, error) { - rows, err := s.db.Query("SELECT id, title, description, audio_path, image_path, published_at, created_at FROM episodes ORDER BY published_at DESC") + +func (s *Store) ListEpisodes(userID int64) ([]Episode, error) { + rows, err := s.db.Query("SELECT id, user_id, title, description, audio_path, image_path, published_at, created_at FROM episodes WHERE user_id = ? ORDER BY published_at DESC", userID) if err != nil { return nil, err } @@ -111,7 +131,7 @@ func (s *Store) ListEpisodes() ([]Episode, error) { var eps []Episode for rows.Next() { var e Episode - if err := rows.Scan(&e.ID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.UserID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt); err != nil { return nil, err } eps = append(eps, e) @@ -121,17 +141,17 @@ func (s *Store) ListEpisodes() ([]Episode, error) { func (s *Store) GetEpisode(id int64) (*Episode, error) { e := &Episode{} - err := s.db.QueryRow("SELECT id, title, description, audio_path, image_path, published_at, created_at FROM episodes WHERE id = ?", id). - Scan(&e.ID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt) + err := s.db.QueryRow("SELECT id, user_id, title, description, audio_path, image_path, published_at, created_at FROM episodes WHERE id = ?", id). + Scan(&e.ID, &e.UserID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt) if err != nil { return nil, err } return e, nil } -func (s *Store) CreateEpisode(title, description, audioPath, imagePath string, publishedAt time.Time) (int64, error) { - res, err := s.db.Exec("INSERT INTO episodes (title, description, audio_path, image_path, published_at) VALUES (?, ?, ?, ?, ?)", - title, description, audioPath, imagePath, publishedAt) +func (s *Store) CreateEpisode(userID int64, title, description, audioPath, imagePath string, publishedAt time.Time) (int64, error) { + res, err := s.db.Exec("INSERT INTO episodes (user_id, title, description, audio_path, image_path, published_at) VALUES (?, ?, ?, ?, ?, ?)", + userID, title, description, audioPath, imagePath, publishedAt) if err != nil { return 0, err } @@ -148,19 +168,3 @@ func (s *Store) DeleteEpisode(id int64) error { _, err := s.db.Exec("DELETE FROM episodes WHERE id = ?", id) return err } - -// --- Settings --- - -func (s *Store) GetSetting(key string) (string, error) { - var val string - err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&val) - if err != nil { - return "", err - } - return val, nil -} - -func (s *Store) SetSetting(key, value string) error { - _, err := s.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value) - return err -} diff --git a/template/dashboard.html b/template/dashboard.html index 9a0d5d9..7eb7be8 100644 --- a/template/dashboard.html +++ b/template/dashboard.html @@ -40,6 +40,11 @@ +
+ + +
@@ -66,7 +71,7 @@