package website import ( "crypto/sha1" "encoding/hex" "errors" "fmt" "image" "io" "io/fs" "net/http" "os" "strconv" "strings" "time" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/templates" "github.com/google/uuid" "github.com/tcolgate/mp3" ) type PodcastIndexData struct { templates.BaseData Podcast templates.Podcast Episodes []templates.PodcastEpisode EditUrl string NewEpisodeUrl string } func PodcastIndex(c *RequestContext) ResponseData { podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "") if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil { return FourOhFour(c) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } baseData := getBaseData(c) baseData.Title = podcastResult.Podcast.Title podcastIndexData := PodcastIndexData{ BaseData: baseData, Podcast: templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile), } if canEdit { podcastIndexData.EditUrl = hmnurl.BuildPodcastEdit(c.CurrentProject.Slug) podcastIndexData.NewEpisodeUrl = hmnurl.BuildPodcastEpisodeNew(c.CurrentProject.Slug) } for _, episode := range podcastResult.Episodes { podcastIndexData.Episodes = append(podcastIndexData.Episodes, templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, 0, podcastResult.ImageFile)) } var res ResponseData err = res.WriteTemplate("podcast_index.html", podcastIndexData, c.Perf) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast index page")) } return res } type PodcastEditData struct { templates.BaseData Podcast templates.Podcast Notices []templates.Notice } func PodcastEdit(c *RequestContext) ResponseData { podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "") if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil || !canEdit { return FourOhFour(c) } podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile) baseData := getBaseData(c) baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}} podcastEditData := PodcastEditData{ BaseData: baseData, Podcast: podcast, } success := c.URL().Query().Get("success") if success != "" { podcastEditData.Notices = append(podcastEditData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."}) } var res ResponseData err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast edit page")) } return res } func PodcastEditSubmit(c *RequestContext) ResponseData { podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "") if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil || !canEdit { return FourOhFour(c) } c.Perf.StartBlock("PODCAST", "Handling file upload") c.Perf.StartBlock("PODCAST", "Parsing form") maxFileSize := int64(2 * 1024 * 1024) maxBodySize := maxFileSize + 1024*1024 c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize) err = c.Req.ParseMultipartForm(maxBodySize) c.Perf.EndBlock() if err != nil { // NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here. return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form")) } title := c.Req.Form.Get("title") if len(strings.TrimSpace(title)) == 0 { return RejectRequest(c, "Podcast title is empty") } description := c.Req.Form.Get("description") if len(strings.TrimSpace(description)) == 0 { return RejectRequest(c, "Podcast description is empty") } podcastImage, header, err := c.Req.FormFile("podcast_image") imageFilename := "" imageWidth := 0 imageHeight := 0 if err != nil && !errors.Is(err, http.ErrMissingFile) { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file")) } if header != nil { if header.Size > maxFileSize { return RejectRequest(c, fmt.Sprintf("Image filesize too big. Max size: %d bytes", maxFileSize)) } else { c.Perf.StartBlock("PODCAST", "Decoding image") config, format, err := image.DecodeConfig(podcastImage) c.Perf.EndBlock() if err != nil { return RejectRequest(c, "Image type not supported") } imageWidth = config.Width imageHeight = config.Height if imageWidth == 0 || imageHeight == 0 { return RejectRequest(c, "Image has zero size") } imageFilename = fmt.Sprintf("podcast/%s/logo%d.%s", c.CurrentProject.Slug, time.Now().UTC().Unix(), format) storageFilename := fmt.Sprintf("public/media/%s", imageFilename) c.Perf.StartBlock("PODCAST", "Writing image file") file, err := os.Create(storageFilename) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create local image file")) } podcastImage.Seek(0, io.SeekStart) _, err = io.Copy(file, podcastImage) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to write image to file")) } file.Close() podcastImage.Close() c.Perf.EndBlock() } } c.Perf.EndBlock() c.Perf.StartBlock("SQL", "Updating podcast") tx, err := c.Conn.Begin(c.Context()) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction")) } defer tx.Rollback(c.Context()) if imageFilename != "" { hasher := sha1.New() podcastImage.Seek(0, io.SeekStart) io.Copy(hasher, podcastImage) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs sha1sum := hasher.Sum(nil) var imageId int err = tx.QueryRow(c.Context(), ` INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, imageFilename, header.Size, hex.EncodeToString(sha1sum), false, imageWidth, imageHeight, ).Scan(&imageId) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row")) } _, err = tx.Exec(c.Context(), ` UPDATE handmade_podcast SET title = $1, description = $2, image_id = $3 WHERE id = $4 `, title, description, imageId, podcastResult.Podcast.ID, ) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast")) } } else { _, err = tx.Exec(c.Context(), ` UPDATE handmade_podcast SET title = $1, description = $2 WHERE id = $3 `, title, description, podcastResult.Podcast.ID, ) } err = tx.Commit(c.Context()) c.Perf.EndBlock() if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction")) } return c.Redirect(hmnurl.BuildPodcastEditSuccess(c.CurrentProject.Slug), http.StatusSeeOther) } type PodcastEpisodeData struct { templates.BaseData Podcast templates.Podcast Episode templates.PodcastEpisode EditUrl string } func PodcastEpisode(c *RequestContext) ResponseData { episodeGUIDStr := c.PathParams["episodeid"] podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 { return FourOhFour(c) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { c.Logger.Error().Err(err).Msg("Failed to check if user can edit podcast. Assuming they can't.") // NOTE(asaf): No need to return an error response here if it failed. canEdit = false } editUrl := "" if canEdit { editUrl = hmnurl.BuildPodcastEpisodeEdit(c.CurrentProject.Slug, podcastResult.Episodes[0].GUID.String()) } podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile) episode := templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, podcastResult.Episodes[0], 0, podcastResult.ImageFile) baseData := getBaseData(c) baseData.Title = podcastResult.Podcast.Title baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}} podcastEpisodeData := PodcastEpisodeData{ BaseData: baseData, Podcast: podcast, Episode: episode, EditUrl: editUrl, } var res ResponseData err = res.WriteTemplate("podcast_episode.html", podcastEpisodeData, c.Perf) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode page")) } return res } type PodcastEpisodeEditData struct { templates.BaseData IsEdit bool Title string Description string EpisodeNumber int CurrentFile string EpisodeFiles []string Notices []templates.Notice } func PodcastEpisodeNew(c *RequestContext) ResponseData { podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "") if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil || !canEdit { return FourOhFour(c) } episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list")) } podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, "") var res ResponseData baseData := getBaseData(c) baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}} err = res.WriteTemplate("podcast_episode_edit.html", PodcastEpisodeEditData{ BaseData: baseData, IsEdit: false, EpisodeFiles: episodeFiles, }, c.Perf) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode new page")) } return res } func PodcastEpisodeEdit(c *RequestContext) ResponseData { episodeGUIDStr, found := c.PathParams["episodeid"] if !found || episodeGUIDStr == "" { return FourOhFour(c) } podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 || !canEdit { return FourOhFour(c) } episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list")) } episode := podcastResult.Episodes[0] podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, "") podcastEpisode := templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, 0, "") baseData := getBaseData(c) baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}, {Name: podcastEpisode.Title, Url: podcastEpisode.Url}} podcastEpisodeEditData := PodcastEpisodeEditData{ BaseData: baseData, IsEdit: true, Title: episode.Title, Description: episode.Description, EpisodeNumber: episode.EpisodeNumber, CurrentFile: episode.AudioFile, EpisodeFiles: episodeFiles, } success := c.URL().Query().Get("success") if success != "" { podcastEpisodeEditData.Notices = append(podcastEpisodeEditData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."}) } var res ResponseData err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode edit page")) } return res } func PodcastEpisodeSubmit(c *RequestContext) ResponseData { episodeGUIDStr, found := c.PathParams["episodeid"] isEdit := found && episodeGUIDStr != "" podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, isEdit, episodeGUIDStr) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil || (isEdit && len(podcastResult.Episodes) == 0) || !canEdit { return FourOhFour(c) } c.Perf.StartBlock("OS", "Fetching podcast episode files") episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug) c.Perf.EndBlock() if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list")) } c.Req.ParseForm() title := c.Req.Form.Get("title") if len(strings.TrimSpace(title)) == 0 { return RejectRequest(c, "Episode title is empty") } description := c.Req.Form.Get("description") if len(strings.TrimSpace(description)) == 0 { return RejectRequest(c, "Episode description is empty") } episodeNumberStr := c.Req.Form.Get("episode_number") episodeNumber, err := strconv.Atoi(episodeNumberStr) if err != nil { return RejectRequest(c, "Episode number can't be parsed") } episodeFile := c.Req.Form.Get("episode_file") found = false for _, ef := range episodeFiles { if episodeFile == ef { found = true break } } if !found { return RejectRequest(c, "Requested episode file not found") } c.Perf.StartBlock("MP3", "Parsing mp3 file for duration") file, err := os.Open(fmt.Sprintf("public/media/podcast/%s/%s", c.CurrentProject.Slug, episodeFile)) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to open podcast file")) } mp3Decoder := mp3.NewDecoder(file) var duration float64 skipped := 0 var decodingError error var f mp3.Frame for { if err = mp3Decoder.Decode(&f, &skipped); err != nil { if err == io.EOF { break } else { decodingError = err break } } duration = duration + f.Duration().Seconds() } file.Close() c.Perf.EndBlock() if decodingError != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to decode mp3 file")) } c.Perf.StartBlock("MARKDOWN", "Parsing description") descriptionRendered := parsing.ParseMarkdown(description, parsing.RealMarkdown) c.Perf.EndBlock() guidStr := "" if isEdit { guidStr = podcastResult.Episodes[0].GUID.String() c.Perf.StartBlock("SQL", "Updating podcast episode") _, err := c.Conn.Exec(c.Context(), ` UPDATE handmade_podcastepisode SET title = $1, description = $2, description_rendered = $3, audio_filename = $4, duration = $5, episode_number = $6 WHERE guid = $7 `, title, description, descriptionRendered, episodeFile, duration, episodeNumber, podcastResult.Episodes[0].GUID, ) c.Perf.EndBlock() if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast episode")) } } else { guid := uuid.New() guidStr = guid.String() c.Perf.StartBlock("SQL", "Creating new podcast episode") _, err := c.Conn.Exec(c.Context(), ` INSERT INTO handmade_podcastepisode (guid, title, description, description_rendered, audio_filename, duration, pub_date, episode_number, podcast_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, guid, title, description, descriptionRendered, episodeFile, duration, time.Now(), episodeNumber, podcastResult.Podcast.ID, ) c.Perf.EndBlock() if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create podcast episode")) } } return c.Redirect(hmnurl.BuildPodcastEpisodeEditSuccess(c.CurrentProject.Slug, guidStr), http.StatusSeeOther) } func GetEpisodeFiles(projectSlug string) ([]string, error) { folderStr := fmt.Sprintf("public/media/podcast/%s/", projectSlug) folder := os.DirFS(folderStr) files, err := fs.Glob(folder, "*.mp3") return files, err } type PodcastRSSData struct { Podcast templates.Podcast Episodes []templates.PodcastEpisode } func PodcastRSS(c *RequestContext) ResponseData { podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "") if err != nil { return ErrorResponse(http.StatusInternalServerError, err) } if podcastResult.Podcast == nil { return FourOhFour(c) } podcastRSSData := PodcastRSSData{ Podcast: templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile), } for _, episode := range podcastResult.Episodes { var filesize int64 stat, err := os.Stat(fmt.Sprintf("./public/media/podcast/%s/%s", c.CurrentProject.Slug, episode.AudioFile)) if err != nil { c.Logger.Err(err).Msg("Couldn't get filesize for podcast episode") } else { filesize = stat.Size() } podcastRSSData.Episodes = append(podcastRSSData.Episodes, templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, filesize, podcastResult.ImageFile)) } var res ResponseData err = res.WriteTemplate("podcast.xml", podcastRSSData, c.Perf) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast RSS")) } return res } type PodcastResult struct { Podcast *models.Podcast ImageFile string Episodes []*models.PodcastEpisode } func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeGUID string) (PodcastResult, error) { var result PodcastResult c.Perf.StartBlock("SQL", "Fetch podcast") type podcastQuery struct { Podcast models.Podcast `db:"podcast"` ImageFilename string `db:"imagefile.file"` } podcastQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastQuery{}, ` SELECT $columns FROM handmade_podcast AS podcast LEFT JOIN handmade_imagefile AS imagefile ON imagefile.id = podcast.image_id WHERE podcast.project_id = $1 `, projectId, ) c.Perf.EndBlock() if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { return result, nil } else { return result, oops.New(err, "failed to fetch podcast") } } podcast := podcastQueryResult.(*podcastQuery).Podcast podcastImageFilename := podcastQueryResult.(*podcastQuery).ImageFilename result.Podcast = &podcast result.ImageFile = podcastImageFilename if fetchEpisodes { type podcastEpisodeQuery struct { Episode models.PodcastEpisode `db:"episode"` } if episodeGUID == "" { c.Perf.StartBlock("SQL", "Fetch podcast episodes") podcastEpisodeQueryResult, err := db.Query(c.Context(), c.Conn, podcastEpisodeQuery{}, ` SELECT $columns FROM handmade_podcastepisode AS episode WHERE episode.podcast_id = $1 ORDER BY episode.season_number DESC, episode.episode_number DESC `, podcast.ID, ) c.Perf.EndBlock() if err != nil { return result, oops.New(err, "failed to fetch podcast episodes") } for _, episodeRow := range podcastEpisodeQueryResult.ToSlice() { result.Episodes = append(result.Episodes, &episodeRow.(*podcastEpisodeQuery).Episode) } } else { guid, err := uuid.Parse(episodeGUID) if err != nil { return result, err } c.Perf.StartBlock("SQL", "Fetch podcast episode") podcastEpisodeQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastEpisodeQuery{}, ` SELECT $columns FROM handmade_podcastepisode AS episode WHERE episode.podcast_id = $1 AND episode.guid = $2 `, podcast.ID, guid, ) c.Perf.EndBlock() if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { return result, nil } else { return result, oops.New(err, "failed to fetch podcast episode") } } episode := podcastEpisodeQueryResult.(*podcastEpisodeQuery).Episode result.Episodes = append(result.Episodes, &episode) } } return result, nil }