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/rs/zerolog v1.21.0
github.com/spf13/cobra v1.1.3 github.com/spf13/cobra v1.1.3
github.com/stretchr/testify v1.7.0 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/teacat/noire v1.1.0
github.com/wellington/go-libsass v0.9.2 github.com/wellington/go-libsass v0.9.2
github.com/yuin/goldmark v1.4.1 github.com/yuin/goldmark v1.4.1
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
) )
replace ( 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 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/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 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk= 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= 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/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-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-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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 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.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.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.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.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-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/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= 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; padding: 0px;
min-height: 0em; } 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 { .project .pair {
display: flex; display: flex;
align-items: flex-start; } align-items: flex-start; }
@ -9264,30 +9255,6 @@ span.icon-rss::before {
.project .forum .thread-entry-right { .project .forum .thread-entry-right {
display: none; } 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 { .project-card {
color: black; color: black;
color: var(--fg-font-color); color: var(--fg-font-color);
@ -9546,3 +9513,40 @@ span.icon-rss::before {
.carousel-container .carousel-button.active:hover { .carousel-container .carousel-button.active:hover {
background-color: #ccc; background-color: #ccc;
background-color: var(--theme-color-dimmest); } 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; --text-background: #181818;
--spoiler-border: #777; --spoiler-border: #777;
--background-even-background: #242424; --background-even-background: #242424;
--project-notice-text-color: #eee;
--project-card-border-color: #333; --project-card-border-color: #333;
--project-user-suggestions-background: #222; --project-user-suggestions-background: #222;
--project-user-suggestions-border-color: #444; --project-user-suggestions-border-color: #444;
--project-edit-logo-previw-border-color: #444; --project-edit-logo-previw-border-color: #444;
--project-edit-quota-bar-border-color: #444; --project-edit-quota-bar-border-color: #444;
--project-edit-quota-bar-filled-background: #888; --project-edit-quota-bar-filled-background: #888;
--notice-text-color: #eee;
--notice-unapproved-color: #7a2020; --notice-unapproved-color: #7a2020;
--notice-hidden-color: #494949; --notice-hidden-color: #494949;
--notice-hiatus-color: #876327; --notice-hiatus-color: #876327;
--notice-dead-color: #7a2020; --notice-dead-color: #7a2020;
--notice-lts-color: #2a681d; --notice-lts-color: #2a681d;
--notice-lts-reqd-color: #876327; --notice-lts-reqd-color: #876327;
--notice-success-color: #2a681d;
--optionbar-border-color: #333; --optionbar-border-color: #333;
--tab-background: #181818; --tab-background: #181818;
--tab-border-color: #3f3f3f; --tab-border-color: #3f3f3f;

View File

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

View File

@ -85,7 +85,7 @@ func TestUserProfile(t *testing.T) {
} }
func TestSnippet(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) { func TestFeed(t *testing.T) {
@ -123,6 +123,28 @@ func TestPodcast(t *testing.T) {
AssertSubdomain(t, BuildPodcast("hero"), "hero") 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) { func TestForumCategory(t *testing.T) {
AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil) AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil)
AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"}) 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) { 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) { func TestForumThread(t *testing.T) {

View File

@ -251,6 +251,56 @@ func BuildPodcast(projectSlug string) string {
return ProjectUrl("/podcast", nil, projectSlug) 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 * 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 ( import (
_ "git.handmade.network/hmn/hmn/src/admintools" _ "git.handmade.network/hmn/hmn/src/admintools"
_ "git.handmade.network/hmn/hmn/src/buildscss" _ "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/migration"
"git.handmade.network/hmn/hmn/src/website" "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 { type RequestPerf struct {
Route string Route string
Path string // the path actually matched Path string // the path actually matched
Method string
Start time.Time Start time.Time
End time.Time End time.Time
Blocks []PerfBlock Blocks []PerfBlock
} }
func MakeNewRequestPerf(route string, path string) *RequestPerf { func MakeNewRequestPerf(route string, method string, path string) *RequestPerf {
return &RequestPerf{ return &RequestPerf{
Start: time.Now(), Start: time.Now(),
Route: route, Route: route,
Path: path, 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 { .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 { .pair {
display: flex; display: flex;
align-items: flex-start; 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 { .project-card {
@include usevar(color, 'fg-font-color'); @include usevar(color, 'fg-font-color');
@include usevar(background-color, 'card-background'); @include usevar(background-color, 'card-background');

View File

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

View File

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

View File

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

View File

@ -304,6 +304,46 @@ func TimelineItemsToJSON(items []TimelineItem) string {
return builder.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 { func maybeString(s *string) string {
if s == nil { if s == nil {
return "" 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" }} {{ define "content" }}
<div class="flex flex-column flex-row-l"> <div class="flex flex-column flex-row-l">
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
{{ range .Notices }} {{ template "notices.html" .Notices }}
<div class="content-block notice notice-{{ .Class }}">
{{ .Content }}
</div>
{{ end }}
{{ with .Screenshots }} {{ with .Screenshots }}
<div class="carousel-container mw-100 mv2 mv3-ns margin-center"> <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"> <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 //go:embed svg/chevron-right.svg
var SVGChevronRight string 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{ var SVGMap = map[string]string{
"close": SVGClose, "close": SVGClose,
"chevron-left": SVGChevronLeft, "chevron-left": SVGChevronLeft,
"chevron-right": SVGChevronRight, "chevron-right": SVGChevronRight,
"appleinc": SVGAppleInc,
"google": SVGGoogle,
"spotify": SVGSpotify,
} }
var HMNTemplateFuncs = template.FuncMap{ var HMNTemplateFuncs = template.FuncMap{

View File

@ -147,6 +147,33 @@ type Link struct {
Icon string 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 { type Notice struct {
Content template.HTML Content template.HTML
Class string 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) return FourOhFour(c)
} }
c.Perf.StartBlock("SQL", "Fetching project owners") owners, err := FetchProjectOwners(c, project.ID)
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,
)
if err != nil { 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 canView := false
canEdit := false canEdit := false
if c.CurrentUser != nil { if c.CurrentUser != nil {
if c.CurrentUser.IsSuperuser { if c.CurrentUser.IsStaff {
canView = true canView = true
canEdit = true canEdit = true
} else { } else {
for _, ownerRow := range ownerQueryData { for _, owner := range owners {
if ownerRow.(*ownerQuery).Owner.ID == c.CurrentUser.ID { if owner.ID == c.CurrentUser.ID {
canView = true canView = true
canEdit = true canEdit = true
break break
@ -397,8 +380,8 @@ func ProjectHomepage(c *RequestContext) ResponseData {
projectHomepageData.BaseData.Header.EditUrl = hmnurl.BuildProjectEdit(project.Slug, "") projectHomepageData.BaseData.Header.EditUrl = hmnurl.BuildProjectEdit(project.Slug, "")
} }
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme) projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
for _, ownerRow := range ownerQueryData { for _, owner := range owners {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(&ownerRow.(*ownerQuery).Owner, c.Theme)) projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
} }
if project.Flags == 1 { if project.Flags == 1 {

View File

@ -79,6 +79,7 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
Route: route.Regex.String(), Route: route.Regex.String(),
Logger: logging.GlobalLogger(), Logger: logging.GlobalLogger(),
Req: req, Req: req,
Res: rw,
} }
if len(match) > 0 { if len(match) > 0 {
@ -110,6 +111,10 @@ type RequestContext struct {
Req *http.Request Req *http.Request
PathParams map[string]string 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 Conn *pgxpool.Pool
CurrentProject *models.Project CurrentProject *models.Project
CurrentUser *models.User 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.RegexForumCategory, ForumCategory)
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect) 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) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
// Other // Other
@ -368,6 +378,7 @@ func AddCORSHeaders(c *RequestContext, res *ResponseData) {
} }
if strings.HasSuffix(origin, parsed.Host) { if strings.HasSuffix(origin, parsed.Host) {
res.Header().Add("Access-Control-Allow-Origin", origin) 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()) { 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() { return func() {
c.Perf.EndRequest() c.Perf.EndRequest()
log := logging.Info() 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())) 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) 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) perfCollector.SubmitRun(c.Perf)
} }
} }