9011a00475
Build and Deploy / build-and-deploy (push) Successful in 52s
The root cause was that modernc.org/sqlite stores time.Time as strings but cannot scan strings back into time.Time, causing ListEpisodes to fail. Changed PublishedAt and CreatedAt in store structs from time.Time to string, with format/parse handled at the application level. Also fixed a secondary issue where the published_at format stored during import had a duplicated timezone offset.
317 lines
7.8 KiB
Go
317 lines
7.8 KiB
Go
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,
|
|
"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
|
|
}
|