Files
podstalk/handler/episode.go
T
jbrechtel 68c1f38b8d
Build and Deploy / build-and-deploy (push) Failing after 36s
Initial commit
2026-07-07 21:58:39 -04:00

276 lines
6.8 KiB
Go

package handler
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"podstalk/store"
)
const maxUploadSize = 200 << 20 // 200 MB
type EpisodeHandler struct {
Store *store.Store
Tpl *Templates
UploadDir string
BaseURL string
}
func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request) {
episodes, err := h.Store.ListEpisodes()
if err != nil {
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
return
}
title, _ := h.Store.GetSetting("podcast_title")
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,
})
}
func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
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(title, description, audioPath, imagePath, 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, "/", 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
}
h.Tpl.Render(w, "episode_form", map[string]any{
"Editing": true,
"Episode": ep,
"PublishedAt": ep.PublishedAt.Format("2006-01-02T15:04"),
})
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
}
// 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)
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, publishedAt); err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", 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
}
// Delete associated files
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, "/", 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)
return
}
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 {
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 {
// 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 == '-' {
return r
}
if r >= 'A' && r <= 'Z' {
return r + 32
}
return -1
}, name)
return name
}