This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user