b179f57628
Build and Deploy / build-and-deploy (push) Successful in 45s
- Add podcast_slug to users with global uniqueness check
- RSS available at /rss/{slug} (path-based) with legacy ?slug= fallback
- Slug field in dashboard settings with validation (lowercase, hyphens)
- JS flash messages for success/error after form submissions
- Remove unused imports
278 lines
6.7 KiB
Go
278 lines
6.7 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) {
|
|
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"
|
|
}
|
|
h.Tpl.Render(w, "dashboard", map[string]any{
|
|
"Episodes": episodes,
|
|
"PodcastTitle": title,
|
|
"PodcastSlug": user.PodcastSlug,
|
|
"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
|
|
}
|
|
|
|
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, 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
|
|
}
|
|
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
|
|
}
|
|
|
|
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, "/?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
|
|
}
|