package handler import ( "context" "encoding/xml" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "podstalk/store" ) type ImportHandler struct { Store *store.Store Tpl *Templates UploadDir string } type importFeed struct { XMLName xml.Name `xml:"rss"` Channel importChannel `xml:"channel"` } type importChannel struct { Title string `xml:"title"` Description string `xml:"description"` Image *importImage `xml:"image"` ItunesImage *importItunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"` Items []importItem `xml:"item"` } type importItem struct { Title string `xml:"title"` Description string `xml:"description"` Enclosure importEnclosure `xml:"enclosure"` PubDate string `xml:"pubDate"` GUID string `xml:"guid"` ItunesImage *importItunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"` } type importEnclosure struct { URL string `xml:"url,attr"` Length string `xml:"length,attr"` Type string `xml:"type,attr"` } type importImage struct { URL string `xml:"url"` } type importItunesImage struct { Href string `xml:"href,attr"` } type ImportEpisodeView struct { Index int Title string Description string DescriptionTruncated string PubDate string AudioURL string } func truncateString(s string, maxLen int) string { runes := []rune(s) if len(runes) <= maxLen { return s } return string(runes[:maxLen]) + "…" } func (h *ImportHandler) ServeImport(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { h.Tpl.Render(w, "import", map[string]any{ "Fetched": false, }) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } rssURL := strings.TrimSpace(r.FormValue("rss_url")) if rssURL == "" { h.Tpl.Render(w, "import", map[string]any{ "Fetched": false, "Error": "RSS URL is required.", }) return } if r.FormValue("import") == "1" { h.importSelected(w, r, rssURL) return } episodes, err := h.fetchRSS(rssURL) if err != nil { h.Tpl.Render(w, "import", map[string]any{ "Fetched": false, "Error": fmt.Sprintf("Failed to fetch RSS: %v", err), "RSSURL": rssURL, }) return } if len(episodes) == 0 { h.Tpl.Render(w, "import", map[string]any{ "Fetched": false, "Error": "No episodes found in the RSS feed.", "RSSURL": rssURL, }) return } h.Tpl.Render(w, "import", map[string]any{ "Fetched": true, "RSSURL": rssURL, "Episodes": episodes, }) } var rssHTTPClient = &http.Client{ Timeout: 30 * time.Second, } func (h *ImportHandler) fetchRSS(rssURL string) ([]ImportEpisodeView, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rssURL, nil) if err != nil { return nil, err } resp, err := rssHTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var feed importFeed dec := xml.NewDecoder(resp.Body) if err := dec.Decode(&feed); err != nil { return nil, err } var episodes []ImportEpisodeView for i, item := range feed.Channel.Items { pubDate := item.PubDate if t, err := parseRSSDate(pubDate); err == nil { pubDate = t.Format("Jan 2, 2006") } episodes = append(episodes, ImportEpisodeView{ Index: i, Title: item.Title, Description: item.Description, DescriptionTruncated: truncateString(item.Description, 150), PubDate: pubDate, AudioURL: item.Enclosure.URL, }) } return episodes, nil } func (h *ImportHandler) importSelected(w http.ResponseWriter, r *http.Request, rssURL string) { userID := UserIDFromContext(r) selected := r.Form["selected"] if len(selected) == 0 { http.Redirect(w, r, "/import?error=no+episodes+selected", http.StatusSeeOther) return } selectedSet := make(map[int]bool) for _, s := range selected { idx, err := strconv.Atoi(s) if err == nil { selectedSet[idx] = true } } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rssURL, nil) if err != nil { http.Redirect(w, r, "/import?error=failed+to+fetch+rss", http.StatusSeeOther) return } resp, err := rssHTTPClient.Do(req) if err != nil { http.Redirect(w, r, "/import?error=failed+to+fetch+rss", http.StatusSeeOther) return } defer resp.Body.Close() var feed importFeed dec := xml.NewDecoder(resp.Body) if err := dec.Decode(&feed); err != nil { http.Redirect(w, r, "/import?error=failed+to+parse+rss", http.StatusSeeOther) return } imported := 0 var errors []string for i, item := range feed.Channel.Items { if !selectedSet[i] { continue } audioPath, err := h.downloadFile(item.Enclosure.URL, "audio") if err != nil { errors = append(errors, fmt.Sprintf("%s: audio download failed: %v", item.Title, err)) continue } imagePath := "" imageURL := h.episodeImageURL(&item, &feed.Channel) if imageURL != "" { if p, err := h.downloadFile(imageURL, "images"); err == nil { imagePath = p } } publishedAt := time.Now() if t, err := parseRSSDate(item.PubDate); err == nil { publishedAt = t } _, err = h.Store.CreateEpisode(userID, item.Title, item.Description, audioPath, imagePath, publishedAt.Format("2006-01-02 15:04:05")) if err != nil { errors = append(errors, fmt.Sprintf("%s: database error: %v", item.Title, err)) h.removeFile(audioPath) if imagePath != "" { h.removeFile(imagePath) } continue } imported++ } if imported > 0 { http.Redirect(w, r, fmt.Sprintf("/?success=%d+episodes+imported+successfully", imported), http.StatusSeeOther) return } http.Redirect(w, r, "/import?error=no+episodes+could+be+imported", http.StatusSeeOther) } func (h *ImportHandler) episodeImageURL(item *importItem, channel *importChannel) string { if item.ItunesImage != nil && item.ItunesImage.Href != "" { return item.ItunesImage.Href } if channel.ItunesImage != nil && channel.ItunesImage.Href != "" { return channel.ItunesImage.Href } if channel.Image != nil && channel.Image.URL != "" { return channel.Image.URL } return "" } var importHTTPClient = &http.Client{ Timeout: 5 * time.Minute, } func (h *ImportHandler) downloadFile(url, category string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } resp, err := importHTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP %d", resp.StatusCode) } filename := filepath.Base(url) if filename == "" || filename == "." || filename == "/" { filename = "file" } base := sanitizeFilename(filename) ext := filepath.Ext(base) if ext == "" { ct := resp.Header.Get("Content-Type") switch { case strings.HasPrefix(ct, "audio/"): ext = ".mp3" case strings.HasPrefix(ct, "image/png"): ext = ".png" case strings.HasPrefix(ct, "image/"): ext = ".jpg" default: 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, resp.Body); err != nil { return "", err } return category + "/" + filename, nil } func (h *ImportHandler) removeFile(relPath string) { if relPath == "" { return } os.Remove(filepath.Join(h.UploadDir, relPath)) } func parseRSSDate(s string) (time.Time, error) { formats := []string{ time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822, time.RFC3339, "Mon, 02 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 -0700", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05-07:00", } for _, f := range formats { if t, err := time.Parse(f, s); err == nil { return t, nil } } return time.Now(), nil }