Per-user podcasts, file uploads, RSS images, Docker support
Build and Deploy / build-and-deploy (push) Failing after 13s
Build and Deploy / build-and-deploy (push) Failing after 13s
- Each user owns a podcast: podcast title/author/image on users table - Episodes scoped by user_id with context-based auth - Audio file upload replaces URL linking, served from /uploads/ - Podcast and per-episode images with itunes:image in RSS - RSS per-user via ?user=N, dashboard shows user-specific feed URL - Settings form for title + author + image per user - Docker multi-stage build (golang:1.25-alpine / alpine:3.21) - Removed PODSTALK_TITLE/AUTHOR env vars
This commit is contained in:
+14
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-17
@@ -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 == '-' {
|
||||
|
||||
+37
-33
@@ -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,
|
||||
|
||||
+26
-3
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user