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:
@@ -17,9 +17,7 @@ docker run -d \
|
|||||||
-v podstalk-data:/data \
|
-v podstalk-data:/data \
|
||||||
-v podstalk-uploads:/uploads \
|
-v podstalk-uploads:/uploads \
|
||||||
-e PODSTALK_LINK=https://podcast.example.com \
|
-e PODSTALK_LINK=https://podcast.example.com \
|
||||||
-e PODSTALK_TITLE="My Podcast" \
|
|
||||||
podstalk
|
podstalk
|
||||||
```
|
|
||||||
|
|
||||||
On first launch, visit `http://localhost:8080/signup` to create your account.
|
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_DB` | `podstalk.db` | SQLite database path |
|
||||||
| `PODSTALK_UPLOAD_DIR` | `uploads` | Audio/image file storage |
|
| `PODSTALK_UPLOAD_DIR` | `uploads` | Audio/image file storage |
|
||||||
| `PODSTALK_LINK` | `http://localhost:8080` | Public URL (used in RSS feed) |
|
| `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
|
## RSS Feed
|
||||||
|
|
||||||
The RSS 2.0 feed (with iTunes extensions) is available at `/rss`. It includes
|
The RSS 2.0 feed (with iTunes extensions) is available at `/rss`. Each user
|
||||||
`<enclosure>`, `<itunes:image>`, `<guid>`, and `<pubDate>` for each episode.
|
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 `<enclosure>`, `<itunes:image>`, `<guid>`, `<pubDate>`, and
|
||||||
|
`<itunes:author>` for each episode.
|
||||||
|
|
||||||
## Running Without Docker
|
## Running Without Docker
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -135,8 +136,16 @@ func (h *AuthHandler) ServeSignout(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
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 {
|
type AuthMiddleware struct {
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
Sessions *SessionStore
|
Sessions *SessionStore
|
||||||
@@ -149,11 +158,13 @@ func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
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)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
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) {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
|
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
title, _ := h.Store.GetSetting("podcast_title")
|
title := user.PodcastTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Podstalk"
|
title = "Podstalk"
|
||||||
}
|
}
|
||||||
|
|
||||||
podcastImage, _ := h.Store.GetSetting("podcast_image")
|
|
||||||
|
|
||||||
h.Tpl.Render(w, "dashboard", map[string]any{
|
h.Tpl.Render(w, "dashboard", map[string]any{
|
||||||
"Episodes": episodes,
|
"Episodes": episodes,
|
||||||
"PodcastTitle": title,
|
"PodcastTitle": title,
|
||||||
"PodcastImage": podcastImage,
|
"PodcastImage": user.PodcastImage,
|
||||||
"BaseURL": h.BaseURL,
|
"PodcastAuthor": user.PodcastAuthor,
|
||||||
|
"BaseURL": h.BaseURL,
|
||||||
|
"UserID": user.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := UserIDFromContext(r)
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
h.Tpl.Render(w, "episode_form", map[string]any{"Editing": false})
|
h.Tpl.Render(w, "episode_form", map[string]any{"Editing": false})
|
||||||
return
|
return
|
||||||
@@ -89,7 +97,7 @@ func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
imagePath, _ := h.saveUploadedFile(r, "image_file", "images")
|
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 {
|
if err != nil {
|
||||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||||
"Editing": false,
|
"Editing": false,
|
||||||
@@ -151,7 +159,6 @@ func (h *EpisodeHandler) ServeEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing episode to know which files to replace
|
|
||||||
existing, err := h.Store.GetEpisode(id)
|
existing, err := h.Store.GetEpisode(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "episode not found", http.StatusNotFound)
|
http.Error(w, "episode not found", http.StatusNotFound)
|
||||||
@@ -191,7 +198,6 @@ func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete associated files
|
|
||||||
ep, err := h.Store.GetEpisode(id)
|
ep, err := h.Store.GetEpisode(id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
h.removeUploadedFile(ep.AudioPath)
|
h.removeUploadedFile(ep.AudioPath)
|
||||||
@@ -205,9 +211,7 @@ func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeUpload serves files from the upload directory.
|
|
||||||
func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) {
|
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/")
|
relPath := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
||||||
if relPath == "" || strings.Contains(relPath, "..") {
|
if relPath == "" || strings.Contains(relPath, "..") {
|
||||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
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))
|
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) {
|
func (h *EpisodeHandler) saveUploadedFile(r *http.Request, field, category string) (string, error) {
|
||||||
file, header, err := r.FormFile(field)
|
file, header, err := r.FormFile(field)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -260,7 +262,6 @@ func (h *EpisodeHandler) removeUploadedFile(relPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeFilename(name string) string {
|
func sanitizeFilename(name string) string {
|
||||||
// Keep only the base name, lowercased, alphanumeric + dots + dashes
|
|
||||||
name = filepath.Base(name)
|
name = filepath.Base(name)
|
||||||
name = strings.Map(func(r rune) rune {
|
name = strings.Map(func(r rune) rune {
|
||||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' {
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' {
|
||||||
|
|||||||
+37
-33
@@ -3,35 +3,15 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"podstalk/store"
|
"podstalk/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RSSHandler struct {
|
type RSSHandler struct {
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
BaseTitle string
|
Link 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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type rssFeed struct {
|
type rssFeed struct {
|
||||||
@@ -46,6 +26,7 @@ type rssChannel struct {
|
|||||||
Link string `xml:"link"`
|
Link string `xml:"link"`
|
||||||
Description string `xml:"description"`
|
Description string `xml:"description"`
|
||||||
Language string `xml:"language"`
|
Language string `xml:"language"`
|
||||||
|
Author string `xml:"itunes:author,omitempty"`
|
||||||
Image *rssImage `xml:"image,omitempty"`
|
Image *rssImage `xml:"image,omitempty"`
|
||||||
ItunesImage *rssItunesImage `xml:"itunes:image,omitempty"`
|
ItunesImage *rssItunesImage `xml:"itunes:image,omitempty"`
|
||||||
Items []rssItem `xml:"item"`
|
Items []rssItem `xml:"item"`
|
||||||
@@ -77,21 +58,43 @@ type rssEnclosure struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
|
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
podcastTitle := h.resolveTitle()
|
title := user.PodcastTitle
|
||||||
podcastImage := h.resolveImage()
|
if title == "" {
|
||||||
|
title = "Podstalk"
|
||||||
|
}
|
||||||
|
author := user.PodcastAuthor
|
||||||
|
|
||||||
items := make([]rssItem, 0, len(episodes))
|
items := make([]rssItem, 0, len(episodes))
|
||||||
for _, ep := range episodes {
|
for _, ep := range episodes {
|
||||||
var itemImage *rssItunesImage
|
var itemImage *rssItunesImage
|
||||||
epImage := ep.ImagePath
|
epImage := ep.ImagePath
|
||||||
if epImage == "" {
|
if epImage == "" {
|
||||||
epImage = podcastImage
|
epImage = user.PodcastImage
|
||||||
}
|
}
|
||||||
if epImage != "" {
|
if epImage != "" {
|
||||||
itemImage = &rssItunesImage{Href: h.fullURL(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 chanImage *rssImage
|
||||||
var chanItunesImage *rssItunesImage
|
var chanItunesImage *rssItunesImage
|
||||||
if podcastImage != "" {
|
if user.PodcastImage != "" {
|
||||||
chanImage = &rssImage{
|
chanImage = &rssImage{
|
||||||
URL: h.fullURL(podcastImage),
|
URL: h.fullURL(user.PodcastImage),
|
||||||
Title: podcastTitle,
|
Title: title,
|
||||||
Link: h.Link,
|
Link: h.Link,
|
||||||
}
|
}
|
||||||
chanItunesImage = &rssItunesImage{Href: h.fullURL(podcastImage)}
|
chanItunesImage = &rssItunesImage{Href: h.fullURL(user.PodcastImage)}
|
||||||
}
|
}
|
||||||
|
|
||||||
feed := rssFeed{
|
feed := rssFeed{
|
||||||
Version: "2.0",
|
Version: "2.0",
|
||||||
ItunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
ItunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||||
Channel: rssChannel{
|
Channel: rssChannel{
|
||||||
Title: podcastTitle,
|
Title: title,
|
||||||
Link: h.Link,
|
Link: h.Link,
|
||||||
Description: podcastTitle,
|
Description: title,
|
||||||
Language: "en-us",
|
Language: "en-us",
|
||||||
|
Author: author,
|
||||||
Image: chanImage,
|
Image: chanImage,
|
||||||
ItunesImage: chanItunesImage,
|
ItunesImage: chanItunesImage,
|
||||||
Items: items,
|
Items: items,
|
||||||
|
|||||||
+26
-3
@@ -19,32 +19,55 @@ func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
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 {
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
http.Redirect(w, r, "/?error=file+too+large", http.StatusSeeOther)
|
http.Redirect(w, r, "/?error=file+too+large", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
title := r.FormValue("podcast_title")
|
title := r.FormValue("podcast_title")
|
||||||
if title != "" {
|
if title == "" {
|
||||||
h.Store.SetSetting("podcast_title", title)
|
title = user.PodcastTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
author := r.FormValue("podcast_author")
|
||||||
|
if author == "" {
|
||||||
|
author = user.PodcastAuthor
|
||||||
|
}
|
||||||
|
|
||||||
|
image := user.PodcastImage
|
||||||
|
|
||||||
// Handle podcast image upload
|
// Handle podcast image upload
|
||||||
file, header, err := r.FormFile("podcast_image")
|
file, header, err := r.FormFile("podcast_image")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
// Remove old image
|
||||||
|
if image != "" {
|
||||||
|
os.Remove(filepath.Join(h.UploadDir, image))
|
||||||
|
}
|
||||||
|
|
||||||
catDir := filepath.Join(h.UploadDir, "images")
|
catDir := filepath.Join(h.UploadDir, "images")
|
||||||
os.MkdirAll(catDir, 0755)
|
os.MkdirAll(catDir, 0755)
|
||||||
|
|
||||||
ext := filepath.Ext(header.Filename)
|
ext := filepath.Ext(header.Filename)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".png"
|
||||||
|
}
|
||||||
dst, err := os.Create(filepath.Join(catDir, "podcast"+ext))
|
dst, err := os.Create(filepath.Join(catDir, "podcast"+ext))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
dst.ReadFrom(file)
|
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)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,11 @@ func main() {
|
|||||||
UploadDir: uploadDir,
|
UploadDir: uploadDir,
|
||||||
}
|
}
|
||||||
rssH := &handler.RSSHandler{
|
rssH := &handler.RSSHandler{
|
||||||
Store: st,
|
Store: st,
|
||||||
BaseTitle: envOrDefault("PODSTALK_TITLE", "Podstalk"),
|
Link: baseURL,
|
||||||
Link: baseURL,
|
|
||||||
Author: envOrDefault("PODSTALK_AUTHOR", "Podstalk"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth routes — redirect to dashboard if already signed in.
|
// Auth routes.
|
||||||
http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup))
|
http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup))
|
||||||
http.HandleFunc("/signin", authMid.RedirectIfAuthed(authH.ServeSignin))
|
http.HandleFunc("/signin", authMid.RedirectIfAuthed(authH.ServeSignin))
|
||||||
http.HandleFunc("/signout", authH.ServeSignout)
|
http.HandleFunc("/signout", authH.ServeSignout)
|
||||||
@@ -77,7 +75,7 @@ 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.
|
// Public RSS feed — per-user via ?user=N query param.
|
||||||
http.HandleFunc("/rss", rssH.ServeRSS)
|
http.HandleFunc("/rss", rssH.ServeRSS)
|
||||||
|
|
||||||
// Public uploaded files.
|
// Public uploaded files.
|
||||||
|
|||||||
+39
-35
@@ -8,14 +8,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Email string
|
Email string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
CreatedAt time.Time
|
PodcastTitle string
|
||||||
|
PodcastImage string
|
||||||
|
PodcastAuthor string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Episode struct {
|
type Episode struct {
|
||||||
ID int64
|
ID int64
|
||||||
|
UserID int64
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
AudioPath string
|
AudioPath string
|
||||||
@@ -48,11 +52,15 @@ func migrate(db *sql.DB) error {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
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_image 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
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
audio_path 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,
|
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_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)
|
_, err := db.Exec(schema)
|
||||||
return err
|
return err
|
||||||
@@ -92,17 +95,34 @@ 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, created_at FROM users WHERE email = ?", email).
|
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.CreatedAt)
|
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.PodcastTitle, &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) 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 ---
|
// --- 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -111,7 +131,7 @@ func (s *Store) ListEpisodes() ([]Episode, error) {
|
|||||||
var eps []Episode
|
var eps []Episode
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Episode
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
eps = append(eps, e)
|
eps = append(eps, e)
|
||||||
@@ -121,17 +141,17 @@ func (s *Store) ListEpisodes() ([]Episode, error) {
|
|||||||
|
|
||||||
func (s *Store) GetEpisode(id int64) (*Episode, error) {
|
func (s *Store) GetEpisode(id int64) (*Episode, error) {
|
||||||
e := &Episode{}
|
e := &Episode{}
|
||||||
err := s.db.QueryRow("SELECT id, title, description, audio_path, image_path, published_at, created_at FROM episodes WHERE id = ?", id).
|
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.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt)
|
Scan(&e.ID, &e.UserID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateEpisode(title, description, audioPath, imagePath string, publishedAt time.Time) (int64, error) {
|
func (s *Store) CreateEpisode(userID int64, 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 (?, ?, ?, ?, ?)",
|
res, err := s.db.Exec("INSERT INTO episodes (user_id, title, description, audio_path, image_path, published_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
title, description, audioPath, imagePath, publishedAt)
|
userID, title, description, audioPath, imagePath, publishedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -148,19 +168,3 @@ func (s *Store) DeleteEpisode(id int64) error {
|
|||||||
_, err := s.db.Exec("DELETE FROM episodes WHERE id = ?", id)
|
_, err := s.db.Exec("DELETE FROM episodes WHERE id = ?", id)
|
||||||
return err
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,6 +40,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: 200px;">
|
||||||
|
<label for="podcast_author">Author</label>
|
||||||
|
<input type="text" id="podcast_author" name="podcast_author" class="nb-input blue"
|
||||||
|
value="{{.PodcastAuthor}}" placeholder="Podcast Host" />
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="podcast_image">Podcast Image</label>
|
<label for="podcast_image">Podcast Image</label>
|
||||||
<input type="file" id="podcast_image" name="podcast_image" accept="image/*" />
|
<input type="file" id="podcast_image" name="podcast_image" accept="image/*" />
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const rssURL = '{{.BaseURL}}/rss';
|
const rssURL = '{{.BaseURL}}/rss?user={{.UserID}}';
|
||||||
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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user