This commit is contained in:
Asaf Gartner 2021-07-23 06:09:46 +03:00
parent 6c53688e06
commit a46fd988f5
36 changed files with 1322 additions and 108 deletions

2
go.mod
View File

@ -19,11 +19,13 @@ require (
github.com/rs/zerolog v1.21.0
github.com/spf13/cobra v1.1.3
github.com/stretchr/testify v1.7.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
github.com/teacat/noire v1.1.0
github.com/wellington/go-libsass v0.9.2
github.com/yuin/goldmark v1.4.1
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
)
replace (

7
go.sum
View File

@ -298,6 +298,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@ -340,6 +342,8 @@ golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm0
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -409,8 +413,9 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -9212,15 +9212,6 @@ span.icon-rss::before {
padding: 0px;
min-height: 0em; }
.project .notice {
color: #fff;
color: var(--project-notice-text-color); }
.project .notice a {
color: #fff;
color: var(--project-notice-text-color);
border-bottom-color: #fff;
border-bottom-color: var(--project-notice-text-color); }
.project .pair {
display: flex;
align-items: flex-start; }
@ -9264,30 +9255,6 @@ span.icon-rss::before {
.project .forum .thread-entry-right {
display: none; }
.notice-unapproved {
background-color: #b42222;
background-color: var(--notice-unapproved-color); }
.notice-hidden {
background-color: #b6b6b6;
background-color: var(--notice-hidden-color); }
.notice-hiatus {
background-color: #aa7d30;
background-color: var(--notice-hiatus-color); }
.notice-dead {
background-color: #b42222;
background-color: var(--notice-dead-color); }
.notice-lts {
background-color: #43a52f;
background-color: var(--notice-lts-color); }
.notice-lts-reqd {
background-color: #aa7d30;
background-color: var(--notice-lts-reqd-color); }
.project-card {
color: black;
color: var(--fg-font-color);
@ -9546,3 +9513,40 @@ span.icon-rss::before {
.carousel-container .carousel-button.active:hover {
background-color: #ccc;
background-color: var(--theme-color-dimmest); }
.notice {
color: #fff;
color: var(--notice-text-color); }
.notice a {
color: #fff;
color: var(--notice-text-color);
border-bottom-color: #fff;
border-bottom-color: var(--notice-text-color); }
.notice-unapproved {
background-color: #b42222;
background-color: var(--notice-unapproved-color); }
.notice-hidden {
background-color: #b6b6b6;
background-color: var(--notice-hidden-color); }
.notice-hiatus {
background-color: #aa7d30;
background-color: var(--notice-hiatus-color); }
.notice-dead {
background-color: #b42222;
background-color: var(--notice-dead-color); }
.notice-lts {
background-color: #43a52f;
background-color: var(--notice-lts-color); }
.notice-lts-reqd {
background-color: #aa7d30;
background-color: var(--notice-lts-reqd-color); }
.notice-success {
background-color: #43a52f;
background-color: var(--notice-success-color); }

View File

@ -234,19 +234,20 @@ will throw an error.
--text-background: #181818;
--spoiler-border: #777;
--background-even-background: #242424;
--project-notice-text-color: #eee;
--project-card-border-color: #333;
--project-user-suggestions-background: #222;
--project-user-suggestions-border-color: #444;
--project-edit-logo-previw-border-color: #444;
--project-edit-quota-bar-border-color: #444;
--project-edit-quota-bar-filled-background: #888;
--notice-text-color: #eee;
--notice-unapproved-color: #7a2020;
--notice-hidden-color: #494949;
--notice-hiatus-color: #876327;
--notice-dead-color: #7a2020;
--notice-lts-color: #2a681d;
--notice-lts-reqd-color: #876327;
--notice-success-color: #2a681d;
--optionbar-border-color: #333;
--tab-background: #181818;
--tab-border-color: #3f3f3f;

View File

@ -252,19 +252,20 @@ will throw an error.
--text-background: #f9f9f9;
--spoiler-border: #aaa;
--background-even-background: #f8f8f8;
--project-notice-text-color: #fff;
--project-card-border-color: #aaa;
--project-user-suggestions-background: #fff;
--project-user-suggestions-border-color: #ddd;
--project-edit-logo-previw-border-color: #999;
--project-edit-quota-bar-border-color: #999;
--project-edit-quota-bar-filled-background: #444;
--notice-text-color: #fff;
--notice-unapproved-color: #b42222;
--notice-hidden-color: #b6b6b6;
--notice-hiatus-color: #aa7d30;
--notice-dead-color: #b42222;
--notice-lts-color: #43a52f;
--notice-lts-reqd-color: #aa7d30;
--notice-success-color: #43a52f;
--optionbar-border-color: #ccc;
--tab-background: #fff;
--tab-border-color: #d8d8d8;

View File

@ -85,7 +85,7 @@ func TestUserProfile(t *testing.T) {
}
func TestSnippet(t *testing.T) {
AssetRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
}
func TestFeed(t *testing.T) {
@ -123,6 +123,28 @@ func TestPodcast(t *testing.T) {
AssertSubdomain(t, BuildPodcast("hero"), "hero")
}
func TestPodcastEdit(t *testing.T) {
AssertRegexMatch(t, BuildPodcastEdit(""), RegexPodcastEdit, nil)
AssertRegexMatch(t, BuildPodcastEditSuccess(""), RegexPodcastEdit, nil)
}
func TestPodcastEpisode(t *testing.T) {
AssertRegexMatch(t, BuildPodcastEpisode("", "test"), RegexPodcastEpisode, map[string]string{"episodeid": "test"})
}
func TestPodcastEpisodeNew(t *testing.T) {
AssertRegexMatch(t, BuildPodcastEpisodeNew(""), RegexPodcastEpisodeNew, nil)
}
func TestPodcastEpisodeEdit(t *testing.T) {
AssertRegexMatch(t, BuildPodcastEpisodeEdit("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
AssertRegexMatch(t, BuildPodcastEpisodeEditSuccess("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
}
func TestPodcastRSS(t *testing.T) {
AssertRegexMatch(t, BuildPodcastRSS(""), RegexPodcastRSS, nil)
}
func TestForumCategory(t *testing.T) {
AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil)
AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"})
@ -137,7 +159,8 @@ func TestForumCategory(t *testing.T) {
}
func TestForumNewThread(t *testing.T) {
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"cats": "sub/wip"})
}
func TestForumThread(t *testing.T) {

View File

@ -251,6 +251,56 @@ func BuildPodcast(projectSlug string) string {
return ProjectUrl("/podcast", nil, projectSlug)
}
var RegexPodcastEdit = regexp.MustCompile(`^/podcast/edit$`)
func BuildPodcastEdit(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/podcast/edit", nil, projectSlug)
}
func BuildPodcastEditSuccess(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/podcast/edit", []Q{Q{"success", "true"}}, projectSlug)
}
var RegexPodcastEpisode = regexp.MustCompile(`^/podcast/ep/(?P<episodeid>[^/]+)$`)
func BuildPodcastEpisode(projectSlug string, episodeGUID string) string {
defer CatchPanic()
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s", episodeGUID), nil, projectSlug)
}
var RegexPodcastEpisodeNew = regexp.MustCompile(`^/podcast/ep/new$`)
func BuildPodcastEpisodeNew(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/podcast/ep/new", nil, projectSlug)
}
var RegexPodcastEpisodeEdit = regexp.MustCompile(`^/podcast/ep/(?P<episodeid>[^/]+)/edit$`)
func BuildPodcastEpisodeEdit(projectSlug string, episodeGUID string) string {
defer CatchPanic()
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), nil, projectSlug)
}
func BuildPodcastEpisodeEditSuccess(projectSlug string, episodeGUID string) string {
defer CatchPanic()
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), []Q{Q{"success", "true"}}, projectSlug)
}
var RegexPodcastRSS = regexp.MustCompile(`^/podcast/podcast.xml$`)
func BuildPodcastRSS(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/podcast/podcast.xml", nil, projectSlug)
}
func BuildPodcastEpisodeFile(projectSlug string, filename string) string {
defer CatchPanic()
return BuildUserFile(fmt.Sprintf("podcast/%s/%s", projectSlug, filename))
}
/*
* Forums
*/

View File

@ -0,0 +1,10 @@
package initimage
import (
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp" // NOTE(asaf): webp handles vp8 and vp8l
_ "image/gif"
_ "image/jpeg"
_ "image/png"
)

View File

@ -3,6 +3,7 @@ package main
import (
_ "git.handmade.network/hmn/hmn/src/admintools"
_ "git.handmade.network/hmn/hmn/src/buildscss"
_ "git.handmade.network/hmn/hmn/src/initimage"
_ "git.handmade.network/hmn/hmn/src/migration"
"git.handmade.network/hmn/hmn/src/website"
)

View File

@ -0,0 +1,53 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(RenamePodcastColumns{})
}
type RenamePodcastColumns struct{}
func (m RenamePodcastColumns) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 7, 11, 6, 8, 38, 0, time.UTC))
}
func (m RenamePodcastColumns) Name() string {
return "RenamePodcastColumns"
}
func (m RenamePodcastColumns) Description() string {
return "Rename columns to lowercase"
}
func (m RenamePodcastColumns) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_podcastepisode
RENAME COLUMN "enclosureFile" TO "audio_filename";
ALTER TABLE handmade_podcastepisode
RENAME COLUMN "pubDate" TO "pub_date";
ALTER TABLE handmade_podcastepisode
RENAME COLUMN "episodeNumber" TO "episode_number";
ALTER TABLE handmade_podcastepisode
RENAME COLUMN "seasonNumber" TO "season_number";
`)
if err != nil {
return oops.New(err, "failed to rename podcast episode columns")
}
return nil
}
func (m RenamePodcastColumns) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

31
src/models/podcast.go Normal file
View File

@ -0,0 +1,31 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Podcast struct {
ID int `db:"id"`
ImageID int `db:"image_id"`
ProjectID int `db:"project_id"`
Title string `db:"title"`
Description string `db:"description"`
Language string `db:"language"`
}
type PodcastEpisode struct {
GUID uuid.UUID `db:"guid"`
PodcastID int `db:"podcast_id"`
Title string `db:"title"`
Description string `db:"description"`
DescriptionHtml string `db:"description_rendered"`
AudioFile string `db:"audio_filename"`
PublicationDate time.Time `db:"pub_date"`
Duration int `db:"duration"` // NOTE(asaf): In seconds
EpisodeNumber int `db:"episode_number"`
SeasonNumber *int `db:"season_number"` // TODO(asaf): Do we need this??
}

View File

@ -8,16 +8,18 @@ import (
type RequestPerf struct {
Route string
Path string // the path actually matched
Method string
Start time.Time
End time.Time
Blocks []PerfBlock
}
func MakeNewRequestPerf(route string, path string) *RequestPerf {
func MakeNewRequestPerf(route string, method string, path string) *RequestPerf {
return &RequestPerf{
Start: time.Now(),
Route: route,
Path: path,
Start: time.Now(),
Route: route,
Path: path,
Method: method,
}
}

View File

@ -0,0 +1,35 @@
.notice {
@include usevar(color, notice-text-color);
a {
@include usevar(color, notice-text-color);
@include usevar(border-bottom-color, notice-text-color);
}
}
.notice-unapproved {
@include usevar(background-color, notice-unapproved-color);
}
.notice-hidden {
@include usevar(background-color, notice-hidden-color);
}
.notice-hiatus {
@include usevar(background-color, notice-hiatus-color);
}
.notice-dead {
@include usevar(background-color, notice-dead-color);
}
.notice-lts {
@include usevar(background-color, notice-lts-color);
}
.notice-lts-reqd {
@include usevar(background-color, notice-lts-reqd-color);
}
.notice-success {
@include usevar(background-color, notice-success-color);
}

View File

@ -1,13 +1,4 @@
.project {
.notice {
@include usevar(color, project-notice-text-color);
a {
@include usevar(color, project-notice-text-color);
@include usevar(border-bottom-color, project-notice-text-color);
}
}
.pair {
display: flex;
align-items: flex-start;
@ -69,30 +60,6 @@
}
}
.notice-unapproved {
@include usevar(background-color, notice-unapproved-color);
}
.notice-hidden {
@include usevar(background-color, notice-hidden-color);
}
.notice-hiatus {
@include usevar(background-color, notice-hiatus-color);
}
.notice-dead {
@include usevar(background-color, notice-dead-color);
}
.notice-lts {
@include usevar(background-color, notice-lts-color);
}
.notice-lts-reqd {
@include usevar(background-color, notice-lts-reqd-color);
}
.project-card {
@include usevar(color, 'fg-font-color');
@include usevar(background-color, 'card-background');

View File

@ -24,3 +24,4 @@
@import 'streams';
@import 'timeline';
@import 'carousel';
@import 'notices';

View File

@ -35,7 +35,6 @@ $vars: (
background-even-background: #242424,
project-notice-text-color: $fg-font-color,
project-card-border-color: #333,
project-user-suggestions-background: #222,
project-user-suggestions-border-color: #444,
@ -43,12 +42,14 @@ $vars: (
project-edit-quota-bar-border-color: #444,
project-edit-quota-bar-filled-background: #888,
notice-text-color: $fg-font-color,
notice-unapproved-color: #7a2020,
notice-hidden-color: #494949,
notice-hiatus-color: #876327,
notice-dead-color: #7a2020,
notice-lts-color: #2a681d,
notice-lts-reqd-color: #876327,
notice-success-color: #2a681d,
optionbar-border-color: #333,

View File

@ -35,7 +35,6 @@ $vars: (
background-even-background: #f8f8f8,
project-notice-text-color: #fff,
project-card-border-color: #aaa,
project-user-suggestions-background: #fff,
project-user-suggestions-border-color: #ddd,
@ -43,12 +42,14 @@ $vars: (
project-edit-quota-bar-border-color: #999,
project-edit-quota-bar-filled-background: #444,
notice-text-color: #fff,
notice-unapproved-color: #b42222,
notice-hidden-color: #b6b6b6,
notice-hiatus-color: #aa7d30,
notice-dead-color: #b42222,
notice-lts-color: #43a52f,
notice-lts-reqd-color: #aa7d30,
notice-success-color: #43a52f,
optionbar-border-color: #ccc,

View File

@ -304,6 +304,46 @@ func TimelineItemsToJSON(items []TimelineItem) string {
return builder.String()
}
func PodcastToTemplate(projectSlug string, podcast *models.Podcast, imageFilename string) Podcast {
imageUrl := ""
if imageFilename != "" {
imageUrl = hmnurl.BuildUserFile(imageFilename)
}
return Podcast{
Title: podcast.Title,
Description: podcast.Description,
Language: podcast.Language,
ImageUrl: imageUrl,
Url: hmnurl.BuildPodcast(projectSlug),
RSSUrl: hmnurl.BuildPodcastRSS(projectSlug),
// TODO(asaf): Move this to the db if we want to support user podcasts
AppleUrl: "https://podcasts.apple.com/us/podcast/the-handmade-network-podcast/id1507790631",
GoogleUrl: "https://www.google.com/podcasts?feed=aHR0cHM6Ly9oYW5kbWFkZS5uZXR3b3JrL3BvZGNhc3QvcG9kY2FzdC54bWw%3D",
SpotifyUrl: "https://open.spotify.com/show/2Nd9NjXscrBbQwYULiYKiU",
}
}
func PodcastEpisodeToTemplate(projectSlug string, episode *models.PodcastEpisode, audioFileSize int64, imageFilename string) PodcastEpisode {
imageUrl := ""
if imageFilename != "" {
imageUrl = hmnurl.BuildUserFile(imageFilename)
}
return PodcastEpisode{
GUID: episode.GUID.String(),
Title: episode.Title,
Description: episode.Description,
DescriptionHtml: template.HTML(episode.DescriptionHtml),
EpisodeNumber: episode.EpisodeNumber,
Url: hmnurl.BuildPodcastEpisode(projectSlug, episode.GUID.String()),
ImageUrl: imageUrl,
FileUrl: hmnurl.BuildPodcastEpisodeFile(projectSlug, episode.AudioFile),
FileSize: audioFileSize,
PublicationDate: episode.PublicationDate,
Duration: episode.Duration,
}
}
func maybeString(s *string) string {
if s == nil {
return ""

View File

@ -0,0 +1,5 @@
{{ range . }}
<div class="content-block notice notice-{{ .Class }}">
{{ .Content }}
</div>
{{ end }}

View File

@ -0,0 +1,4 @@
<a href="{{ .RSSUrl }}"><span class="icon big">4</span> Subscribe</a>
<a class="pl2" target="_blank" href="{{ .AppleUrl }}"><span class="svgicon">{{ svg "appleinc" }}</span> Apple Podcasts</a>
<a class="pl2" target="_blank" href="{{ .GoogleUrl }}"><span class="svgicon">{{ svg "google" }}</span> Google Podcasts</a>
<a class="pl2" target="_blank" href="{{ .SpotifyUrl }}"><span class="svgicon">{{ svg "spotify" }}</span> Spotify</a>

View File

@ -0,0 +1,32 @@
{{ noescape "<?xml version=\"1.0\" encoding=\"utf-8\"?>" }}
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>{{ .Podcast.Title }}</title>
<link>{{ .Podcast.Url }}</link>
<language>{{ .Podcast.Language }}</language>
<copyright>&#169; 2021 The Handmade Network</copyright>{{/* TODO(asaf): Change this in case we want to allow user podcasts */}}
<itunes:author>The Handmade Network</itunes:author>
<itunes:owner>
<itunes:name>The Handmade Network</itunes:name>
<itunes:email>team@handmadedev.org</itunes:email>
</itunes:owner>
<description>{{ .Podcast.Description }}</description>
<itunes:image href="{{ .Podcast.ImageUrl }}" />
<itunes:category text="Technology" />
<itunes:explicit>false</itunes:explicit>
{{ range .Episodes }}
<item>
<guid>{{ .GUID }}</guid>
<title>{{ .Title }}</title>
<description>{{ .Description }}</description>
<itunes:episode>{{ .EpisodeNumber }}</itunes:episode>
<itunes:order>{{ .EpisodeNumber }}</itunes:order>
<enclosure url="{{ .FileUrl }}" length="{{ .FileSize }}" type="audio/mpeg" />
<pubDate>{{ .PublicationDate }}</pubDate>
<itunes:duration>{{ .Duration }}</itunes:duration>
<link>{{ .Url }}</link>
</item>
{{ end }}
</channel>
</rss>

View File

@ -0,0 +1,71 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
{{ template "notices.html" .Notices }}
<form id="podcast_form" method="POST" enctype="multipart/form-data">
{{ csrftoken .Session }}
<input class="b w-100 mb1" type="text" name="title" required placeholder="Podcast title..." value="{{ .Podcast.Title }}" />
<textarea name="description" required class="w-100 minw-100 mw-100 h3 minh-3">{{ .Podcast.Description }}</textarea>
<div>
<label for="podcast_image">Podcast image (2mb max): </label>
<input id="file_input" type="file" accept="image/*" name="podcast_image" {{ if eq (len .Podcast.ImageUrl) 0 }}required{{ end }} />
<a href="javascript:;" class="db" id="reset_image">Reset Image</a>
<label style="display: none" id="file_too_big" for="podcast_image">File too big.</label>
</div>
<div>
<img id="image" src="{{ .Podcast.ImageUrl }}" />
</div>
<input type="submit" name="submit" value="Submit" />
</form>
</div>
<script>
let fileInput = document.querySelector("#file_input");
let image = document.querySelector("#image");
let fileTooBigLabel = document.querySelector("#file_too_big");
let resetImage = document.querySelector("#reset_image");
let form = document.querySelector("#podcast_form");
let originalImageUrl = "{{ .Podcast.ImageUrl }}";
fileInput.value = "";
let fileTooBig = false;
let maxFileSize = 2*1024*1024;
fileInput.addEventListener("change", function(ev) {
if (fileInput.files.length > 0) {
let file = fileInput.files[0];
handleNewImageFile(file);
}
});
function handleNewImageFile(file) {
fileTooBig = false;
if (file) {
if (file.size > maxFileSize) {
fileTooBig = true;
}
image.src = URL.createObjectURL(file);
} else {
image.src = originalImageUrl;
}
if (fileTooBig) {
fileTooBigLabel.style.display = "block";
} else {
fileTooBigLabel.style.display = "none";
}
}
resetImage.addEventListener("click", function(ev) {
fileInput.value = "";
handleNewImageFile(null);
});
form.addEventListener("submit", function(ev) {
if (fileTooBig) {
ev.preventDefault();
}
});
</script>
{{ end }}

View File

@ -0,0 +1,31 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<div class="ph2 ph0-ns">
<h2>Episode {{ .Episode.EpisodeNumber }}: {{ .Episode.Title }}</h2>
<div>
{{ template "podcast_actions.html" .Podcast }}
{{ with .EditUrl }}
<a class="pl2" href="{{ . }}">&#9998; Edit Episode</a>
{{ end }}
</div>
<div class="flex flex-row-ns flex-column-reverse items-center">
<div class="flex-grow-1 w-100">
<audio preload="metadata" class="w-100" controls src="{{ .Episode.FileUrl }}"></audio>
<div>
<a download href="{{ .Episode.FileUrl }}">Download</a>
</div>
</div>
<div class="ml3-ns flex-shrink-0 w4">
<img class="br3" src="{{ .Podcast.ImageUrl }}">
</div>
</div>
<div class="pv2 p-spaced w-100 overflow-hidden">
{{ .Episode.DescriptionHtml }}
</div>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,23 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
{{ template "notices.html" .Notices }}
<h1>{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode</h1>
<form method="POST">
{{ csrftoken .Session }}
<input required type="text" class="b w-100 mb1" name="title" placeholder="Title" value="{{ .Title }}" />
<label for="episode_number">Episode number: </label><input required type="number" class="" name="episode_number" value="{{ .EpisodeNumber }}" />
<textarea required name="description" class="w-100 mv1" maxlength="4000" placeholder="Description (max 4000 chars)">{{ .Description }}</textarea>
<select required name="episode_file">
{{ $currentFile := .CurrentFile }}
{{ range .EpisodeFiles }}
<option {{ if eq $currentFile . }}selected{{ end }} value="{{ . }}">{{ . }}</option>
{{ end }}
</select>
<div class="mt3">
<input type="submit" name="submit" value="Submit" />
</div>
</form>
</div>
{{ end }}

View File

@ -0,0 +1,38 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
<link type="application/rss+xml" rel="alternate" title="{{ .Podcast.Title }}" href="{{ .Podcast.RSSUrl }}"/>
{{ end }}
{{ define "content" }}
<div class="content-block">
<div class="ph2 ph0-ns">
<h2>{{ .Podcast.Title }}</h2>
<div>
{{ template "podcast_actions.html" .Podcast }}
{{ with .EditUrl }}
<a class="pl2" href="{{ . }}">&#9998; Edit Podcast</a>
{{ end }}
{{ with .NewEpisodeUrl }}
<a class="pl2" href="{{ . }}">+ New Episode</a>
{{ end }}
</div>
<p class="pt2 pb3">{{ .Podcast.Description }}</p>
<ul>
{{ range .Episodes }}
<li class="flex ml0 pl0 mw7 pb3">
<div class="dn db-ns w4 h4 flex-shrink-0">
<img class="br3" src="{{ .ImageUrl }}">
</div>
<div class="ph3-ns w-100 overflow-hidden">
<a href="{{ .Url }}"><h3 class="f4">Episode {{ .EpisodeNumber }}: {{ .Title }}</h3></a>
<div class="p-spaced">{{ .DescriptionHtml }}</div>
</div>
</li>
{{ end }}
</ul>
</div>
</div>
{{ end }}

View File

@ -9,11 +9,7 @@
{{ define "content" }}
<div class="flex flex-column flex-row-l">
<div class="flex-grow-1 overflow-hidden">
{{ range .Notices }}
<div class="content-block notice notice-{{ .Class }}">
{{ .Content }}
</div>
{{ end }}
{{ template "notices.html" .Notices }}
{{ with .Screenshots }}
<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="carousel aspect-ratio aspect-ratio--16x9 overflow-hidden bg--dim br2-ns">

5
src/templates/svg/appleinc.svg Executable file
View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>appleinc</title>
<path d="M24.734 17.003c-0.040-4.053 3.305-5.996 3.454-6.093-1.88-2.751-4.808-3.127-5.851-3.171-2.492-0.252-4.862 1.467-6.127 1.467-1.261 0-3.213-1.43-5.28-1.392-2.716 0.040-5.221 1.579-6.619 4.012-2.822 4.897-0.723 12.151 2.028 16.123 1.344 1.944 2.947 4.127 5.051 4.049 2.026-0.081 2.793-1.311 5.242-1.311s3.138 1.311 5.283 1.271c2.18-0.041 3.562-1.981 4.897-3.931 1.543-2.255 2.179-4.439 2.216-4.551-0.048-0.022-4.252-1.632-4.294-6.473zM20.705 5.11c1.117-1.355 1.871-3.235 1.665-5.11-1.609 0.066-3.559 1.072-4.713 2.423-1.036 1.199-1.942 3.113-1.699 4.951 1.796 0.14 3.629-0.913 4.747-2.264z"></path>
</svg>

After

Width:  |  Height:  |  Size: 766 B

5
src/templates/svg/google.svg Executable file
View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>google</title>
<path d="M16.319 13.713v5.487h9.075c-0.369 2.356-2.744 6.9-9.075 6.9-5.463 0-9.919-4.525-9.919-10.1s4.456-10.1 9.919-10.1c3.106 0 5.188 1.325 6.375 2.469l4.344-4.181c-2.788-2.612-6.4-4.188-10.719-4.188-8.844 0-16 7.156-16 16s7.156 16 16 16c9.231 0 15.363-6.494 15.363-15.631 0-1.050-0.113-1.85-0.25-2.65l-15.113-0.006z"></path>
</svg>

After

Width:  |  Height:  |  Size: 488 B

5
src/templates/svg/spotify.svg Executable file
View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>spotify</title>
<path d="M16 0c-8.8 0-16 7.2-16 16s7.2 16 16 16 16-7.2 16-16-7.119-16-16-16zM23.363 23.119c-0.319 0.481-0.881 0.637-1.363 0.319-3.762-2.319-8.481-2.8-14.081-1.519-0.563 0.163-1.037-0.238-1.2-0.719-0.162-0.563 0.237-1.038 0.719-1.2 6.081-1.363 11.363-0.8 15.519 1.762 0.563 0.238 0.644 0.875 0.406 1.356zM25.281 18.719c-0.4 0.563-1.119 0.8-1.681 0.4-4.319-2.637-10.881-3.438-15.919-1.837-0.638 0.163-1.362-0.163-1.519-0.8-0.162-0.637 0.162-1.363 0.8-1.519 5.838-1.762 13.037-0.881 18 2.163 0.475 0.238 0.719 1.038 0.319 1.594zM25.438 14.238c-5.119-3.037-13.681-3.363-18.563-1.838-0.8 0.238-1.6-0.238-1.838-0.963-0.237-0.8 0.237-1.6 0.963-1.838 5.681-1.681 15.038-1.363 20.962 2.162 0.719 0.4 0.962 1.363 0.563 2.081-0.406 0.556-1.363 0.794-2.087 0.394z"></path>
</svg>

After

Width:  |  Height:  |  Size: 922 B

View File

@ -81,10 +81,22 @@ var SVGChevronLeft string
//go:embed svg/chevron-right.svg
var SVGChevronRight string
//go:embed svg/appleinc.svg
var SVGAppleInc string
//go:embed svg/google.svg
var SVGGoogle string
//go:embed svg/spotify.svg
var SVGSpotify string
var SVGMap = map[string]string{
"close": SVGClose,
"chevron-left": SVGChevronLeft,
"chevron-right": SVGChevronRight,
"appleinc": SVGAppleInc,
"google": SVGGoogle,
"spotify": SVGSpotify,
}
var HMNTemplateFuncs = template.FuncMap{

View File

@ -147,6 +147,33 @@ type Link struct {
Icon string
}
type Podcast struct {
Title string
Description string
Language string
ImageUrl string
Url string
RSSUrl string
AppleUrl string
GoogleUrl string
SpotifyUrl string
}
type PodcastEpisode struct {
GUID string
Title string
Description string
DescriptionHtml template.HTML
EpisodeNumber int
Url string
ImageUrl string
FileUrl string
FileSize int64
PublicationDate time.Time
Duration int
}
type Notice struct {
Content template.HTML
Class string

679
src/website/podcast.go Normal file
View File

@ -0,0 +1,679 @@
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 {
// TODO(asaf): Report this back to the user
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Missing title"))
}
description := c.Req.Form.Get("description")
if len(strings.TrimSpace(description)) == 0 {
// TODO(asaf): Report this back to the user
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Missing description"))
}
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 {
// TODO(asaf): Report this back to the user
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Filesize too big"))
} else {
c.Perf.StartBlock("PODCAST", "Decoding image")
config, format, err := image.DecodeConfig(podcastImage)
c.Perf.EndBlock()
if err != nil {
// TODO(asaf): Report this back to the user
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Can't parse podcast logo"))
}
imageWidth = config.Width
imageHeight = config.Height
if imageWidth == 0 || imageHeight == 0 {
// TODO(asaf): Report this back to the user
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Invalid image 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")
description := c.Req.Form.Get("description")
episodeNumberStr := c.Req.Form.Get("episode_number")
episodeNumber, err := strconv.Atoi(episodeNumberStr)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to parse episode number"))
}
episodeFile := c.Req.Form.Get("episode_file")
found = false
for _, ef := range episodeFiles {
if episodeFile == ef {
found = true
break
}
}
if !found {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "User-provided episode filename doesn't match existing files"))
}
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.ParsePostInput(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
}

View File

@ -0,0 +1,54 @@
package website
import (
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
)
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
if user != nil {
if user.IsStaff {
return true, nil
} else {
owners, err := FetchProjectOwners(c, projectId)
if err != nil {
return false, err
}
for _, owner := range owners {
if owner.ID == user.ID {
return true, nil
}
}
}
}
return false, nil
}
func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) {
var result []*models.User
c.Perf.StartBlock("SQL", "Fetching project owners")
type ownerQuery struct {
Owner models.User `db:"auth_user"`
}
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
`
SELECT $columns
FROM
auth_user
INNER JOIN auth_user_groups AS user_groups ON auth_user.id = user_groups.user_id
INNER JOIN handmade_project_groups AS project_groups ON user_groups.group_id = project_groups.group_id
WHERE
project_groups.project_id = $1
`,
projectId,
)
c.Perf.EndBlock()
if err != nil {
return result, oops.New(err, "failed to fetch owners for project")
}
for _, ownerRow := range ownerQueryResult.ToSlice() {
result = append(result, &ownerRow.(*ownerQuery).Owner)
}
return result, nil
}

View File

@ -266,37 +266,20 @@ func ProjectHomepage(c *RequestContext) ResponseData {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetching project owners")
type ownerQuery struct {
Owner models.User `db:"auth_user"`
}
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
`
SELECT $columns
FROM
auth_user
INNER JOIN auth_user_groups AS user_groups ON auth_user.id = user_groups.user_id
INNER JOIN handmade_project_groups AS project_groups ON user_groups.group_id = project_groups.group_id
WHERE
project_groups.project_id = $1
`,
project.ID,
)
owners, err := FetchProjectOwners(c, project.ID)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch owners for project"))
return ErrorResponse(http.StatusInternalServerError, err)
}
ownerQueryData := ownerQueryResult.ToSlice()
c.Perf.EndBlock()
canView := false
canEdit := false
if c.CurrentUser != nil {
if c.CurrentUser.IsSuperuser {
if c.CurrentUser.IsStaff {
canView = true
canEdit = true
} else {
for _, ownerRow := range ownerQueryData {
if ownerRow.(*ownerQuery).Owner.ID == c.CurrentUser.ID {
for _, owner := range owners {
if owner.ID == c.CurrentUser.ID {
canView = true
canEdit = true
break
@ -397,8 +380,8 @@ func ProjectHomepage(c *RequestContext) ResponseData {
projectHomepageData.BaseData.Header.EditUrl = hmnurl.BuildProjectEdit(project.Slug, "")
}
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
for _, ownerRow := range ownerQueryData {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(&ownerRow.(*ownerQuery).Owner, c.Theme))
for _, owner := range owners {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
}
if project.Flags == 1 {

View File

@ -79,6 +79,7 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
Route: route.Regex.String(),
Logger: logging.GlobalLogger(),
Req: req,
Res: rw,
}
if len(match) > 0 {
@ -110,6 +111,10 @@ type RequestContext struct {
Req *http.Request
PathParams map[string]string
// NOTE(asaf): This is the http package's internal response object. Not just a ResponseWriter.
// We sometimes need the original response object so that some functions of the http package can set connection-management flags on it.
Res http.ResponseWriter
Conn *pgxpool.Pool
CurrentProject *models.Project
CurrentUser *models.User

View File

@ -159,6 +159,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)
mainRoutes.GET(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeNew)
mainRoutes.POST(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeSubmit)
mainRoutes.GET(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeEdit)
mainRoutes.POST(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeSubmit)
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
// Other
@ -368,6 +378,7 @@ func AddCORSHeaders(c *RequestContext, res *ResponseData) {
}
if strings.HasSuffix(origin, parsed.Host) {
res.Header().Add("Access-Control-Allow-Origin", origin)
res.Header().Add("Vary", "Origin")
}
}
@ -398,7 +409,7 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
}
func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.URL.Path)
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.Method, c.Req.URL.Path)
return func() {
c.Perf.EndRequest()
log := logging.Info()
@ -410,7 +421,7 @@ func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (aft
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
blockStack = append(blockStack, block.End)
}
log.Msg(fmt.Sprintf("Served %s in %.4fms", c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", c.Perf.Method, c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
perfCollector.SubmitRun(c.Perf)
}
}