package handler
import (
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"podstalk/store"
)
const maxUploadSize = 200 << 20 // 200 MB
const timeLayout = "2006-01-02 15:04:05"
func fmtTime(t time.Time) string { return t.Format(timeLayout) }
type EpisodeHandler struct {
Store *store.Store
Tpl *Templates
UploadDir string
BaseURL string
}
func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request) {
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 := user.PodcastTitle
if title == "" {
title = "Podstalk"
}
type epView struct {
ID int64
Title string
Description string
AudioPath string
ImageURL template.URL
PublishedAt string
}
var epViews []epView
for _, ep := range episodes {
pubFormatted := ep.PublishedAt
if pt, err := time.Parse(timeLayout, ep.PublishedAt); err == nil {
pubFormatted = pt.Format("Jan 2, 2006")
}
ev := epView{
ID: ep.ID,
Title: ep.Title,
Description: ep.Description,
AudioPath: ep.AudioPath,
PublishedAt: pubFormatted,
}
if ep.ImagePath != "" {
ev.ImageURL = template.URL("/uploads/" + ep.ImagePath)
} else if user.PodcastImage != "" {
ev.ImageURL = template.URL("/uploads/" + user.PodcastImage)
}
epViews = append(epViews, ev)
}
podcastImageURL := template.URL("")
if user.PodcastImage != "" {
podcastImageURL = template.URL("/uploads/" + user.PodcastImage)
}
h.Tpl.Render(w, "dashboard", map[string]any{
"Episodes": epViews,
"PodcastTitle": title,
"PodcastSlug": user.PodcastSlug,
"PodcastImageURL": podcastImageURL,
"PodcastAuthor": user.PodcastAuthor,
"PodcastEmail": user.PodcastEmail,
"RSSURL": template.URL(h.BaseURL + "/rss/" + user.PodcastSlug),
})
}
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
}
if r.Method == http.MethodPost {
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": false,
"Error": "File too large (max 200 MB).",
})
return
}
title := r.FormValue("title")
description := r.FormValue("description")
publishedStr := r.FormValue("published_at")
publishedAt := time.Now()
if publishedStr != "" {
if t, err := time.Parse("2006-01-02T15:04", publishedStr); err == nil {
publishedAt = t
}
}
if title == "" {
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": false,
"Error": "Title is required.",
})
return
}
audioPath, err := h.saveUploadedFile(r, "audio_file", "audio")
if err != nil {
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": false,
"Error": fmt.Sprintf("Audio upload failed: %v", err),
})
return
}
imagePath, _ := h.saveUploadedFile(r, "image_file", "images")
_, err = h.Store.CreateEpisode(userID, title, description, audioPath, imagePath, fmtTime(publishedAt))
if err != nil {
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": false,
"Error": "Failed to create episode.",
})
return
}
http.Redirect(w, r, "/?success=episode+created", http.StatusSeeOther)
}
}
func (h *EpisodeHandler) ServeEdit(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
ep, err := h.Store.GetEpisode(id)
if err != nil {
http.Error(w, "episode not found", http.StatusNotFound)
return
}
pubStr := ""
if pt, err := time.Parse(timeLayout, ep.PublishedAt); err == nil {
pubStr = pt.Format("2006-01-02T15:04")
}
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": true,
"Episode": ep,
"PublishedAt": pubStr,
})
return
}
if r.Method == http.MethodPost {
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
title := r.FormValue("title")
description := r.FormValue("description")
publishedStr := r.FormValue("published_at")
publishedAt := time.Now()
if publishedStr != "" {
if t, err := time.Parse("2006-01-02T15:04", publishedStr); err == nil {
publishedAt = t
}
}
if title == "" {
ep, _ := h.Store.GetEpisode(id)
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": true,
"Episode": ep,
"PublishedAt": publishedStr,
"Error": "Title is required.",
})
return
}
existing, err := h.Store.GetEpisode(id)
if err != nil {
http.Error(w, "episode not found", http.StatusNotFound)
return
}
audioPath := existing.AudioPath
if newPath, err := h.saveUploadedFile(r, "audio_file", "audio"); err == nil && newPath != "" {
h.removeUploadedFile(audioPath)
audioPath = newPath
}
imagePath := existing.ImagePath
if newPath, err := h.saveUploadedFile(r, "image_file", "images"); err == nil && newPath != "" {
h.removeUploadedFile(imagePath)
imagePath = newPath
}
if err := h.Store.UpdateEpisode(id, title, description, audioPath, imagePath, fmtTime(publishedAt)); err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?success=episode+updated", http.StatusSeeOther)
}
}
func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
ep, err := h.Store.GetEpisode(id)
if err == nil {
h.removeUploadedFile(ep.AudioPath)
h.removeUploadedFile(ep.ImagePath)
}
if err := h.Store.DeleteEpisode(id); err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?success=episode+deleted", http.StatusSeeOther)
}
func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) {
relPath := strings.TrimPrefix(r.URL.Path, "/uploads/")
if relPath == "" || strings.Contains(relPath, "..") {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
http.ServeFile(w, r, filepath.Join(h.UploadDir, relPath))
}
func (h *EpisodeHandler) saveUploadedFile(r *http.Request, field, category string) (string, error) {
file, header, err := r.FormFile(field)
if err != nil {
return "", err
}
defer file.Close()
base := sanitizeFilename(header.Filename)
ext := filepath.Ext(base)
if ext == "" {
ext = ".bin"
} else {
base = base[:len(base)-len(ext)]
}
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), base, ext)
catDir := filepath.Join(h.UploadDir, category)
if err := os.MkdirAll(catDir, 0755); err != nil {
return "", err
}
dst, err := os.Create(filepath.Join(catDir, filename))
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
return "", err
}
return category + "/" + filename, nil
}
func (h *EpisodeHandler) removeUploadedFile(relPath string) {
if relPath == "" {
return
}
os.Remove(filepath.Join(h.UploadDir, relPath))
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
name = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' {
return r
}
if r >= 'A' && r <= 'Z' {
return r + 32
}
return -1
}, name)
return name
}