diff --git a/.gitignore b/.gitignore index 5f549c3a..e97999fb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ src/config/config.go vendor/ dbclones/ coverage.out +public/media/ diff --git a/go.mod b/go.mod index 374dbf46..b6165575 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,12 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible github.com/go-stack/stack v1.8.0 - github.com/google/uuid v1.2.0 // indirect + github.com/google/uuid v1.2.0 github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect - github.com/jackc/pgconn v1.8.0 // indirect + github.com/jackc/pgconn v1.8.0 github.com/jackc/pgtype v1.6.2 github.com/jackc/pgx/v4 v4.10.1 - github.com/julienschmidt/httprouter v1.3.0 github.com/mitchellh/copystructure v1.1.1 // indirect github.com/rs/zerolog v1.20.0 github.com/spf13/cobra v1.1.3 diff --git a/go.sum b/go.sum index ab532624..fe48867a 100644 --- a/go.sum +++ b/go.sum @@ -170,8 +170,6 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= diff --git a/public/icons.ttf b/public/icons.ttf new file mode 100755 index 00000000..99dfe748 Binary files /dev/null and b/public/icons.ttf differ diff --git a/public/style.css b/public/style.css index fd4862c5..51cfa76e 100644 --- a/public/style.css +++ b/public/style.css @@ -8947,7 +8947,7 @@ div.mark_as_read_toplevel_blog { @font-face { font-family: icons; - src: url("/static/icon/icons.ttf?v=4"); } + src: url("/public/icons.ttf?v=4"); } span.icon { font-family: "icons"; } diff --git a/src/config/config.go.example b/src/config/config.go.example index a0047446..393452bd 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -18,4 +18,13 @@ var Config = HMNConfig{ CookieDomain: ".handmade.local", CookieSecure: false, }, + DigitalOcean: DigitalOceanConfig{ + AssetsSpacesKey: "", + AssetsSpacesSecret: "", + AssetsSpacesRegion: "", + AssetsSpacesEndpoint: "", + AssetsSpacesBucket: "", + AssetsPathPrefix: "", + AssetsPublicUrlRoot: "", + }, } diff --git a/src/config/types.go b/src/config/types.go index 6e9563c6..461e4f4d 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -16,12 +16,13 @@ const ( ) type HMNConfig struct { - Env Environment - Addr string - BaseUrl string - LogLevel zerolog.Level - Postgres PostgresConfig - Auth AuthConfig + Env Environment + Addr string + BaseUrl string + LogLevel zerolog.Level + Postgres PostgresConfig + Auth AuthConfig + DigitalOcean DigitalOceanConfig } type PostgresConfig struct { @@ -40,6 +41,16 @@ type AuthConfig struct { CookieSecure bool } +type DigitalOceanConfig struct { + AssetsSpacesKey string + AssetsSpacesSecret string + AssetsSpacesRegion string + AssetsSpacesEndpoint string + AssetsSpacesBucket string + AssetsPathPrefix string + AssetsPublicUrlRoot string +} + func (info PostgresConfig) DSN() string { return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName) } diff --git a/src/db/db.go b/src/db/db.go index c5e043b0..accfb961 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -10,6 +10,7 @@ import ( "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/oops" + "github.com/google/uuid" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/log/zerologadapter" @@ -38,6 +39,8 @@ func typeIsQueryable(t reflect.Type) bool { if isRecognizedByPgtype { return true + } else if t == reflect.TypeOf(uuid.UUID{}) { + return true } // pgtype doesn't recognize it, but maybe it's a primitive type we can deal with @@ -100,10 +103,12 @@ func (it *StructQueryIterator) Next() (interface{}, bool) { // Better logging of panics in this confusing reflection process var currentField reflect.StructField var currentValue reflect.Value + var currentIdx int defer func() { if r := recover(); r != nil { if currentValue.IsValid() { logging.Error(). + Int("index", currentIdx). Str("field name", currentField.Name). Stringer("field type", currentField.Type). Interface("value", currentValue.Interface()). @@ -120,6 +125,7 @@ func (it *StructQueryIterator) Next() (interface{}, bool) { }() for i, val := range vals { + currentIdx = i if val == nil { continue } diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index eb05dfe1..a7479c40 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -10,9 +10,6 @@ import ( "git.handmade.network/hmn/hmn/src/oops" ) -const StaticPath = "/public" -const StaticThemePath = "/public/themes" - type Q struct { Name string Value string @@ -30,11 +27,13 @@ func QFromURL(u *url.URL) []Q { var baseUrlParsed url.URL var cacheBust string +var S3BaseUrl string var isTest bool func init() { SetGlobalBaseUrl(config.Config.BaseUrl) SetCacheBust(fmt.Sprint(time.Now().Unix())) + SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot) } func SetGlobalBaseUrl(fullBaseUrl string) { @@ -53,6 +52,10 @@ func SetCacheBust(newCacheBust string) { cacheBust = newCacheBust } +func SetS3BaseUrl(base string) { + S3BaseUrl = base +} + func Url(path string, query []Q) string { return ProjectUrl(path, query, "") } diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index bd35d3c5..b772c821 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -80,8 +80,12 @@ func TestStaticPages(t *testing.T) { AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil) } -func TestMember(t *testing.T) { - AssertRegexMatch(t, BuildMember("test"), RegexMember, map[string]string{"member": "test"}) +func TestUserProfile(t *testing.T) { + AssertRegexMatch(t, BuildUserProfile("test"), RegexUserProfile, map[string]string{"username": "test"}) +} + +func TestSnippet(t *testing.T) { + AssetRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"}) } func TestFeed(t *testing.T) { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 0160f867..9fe16163 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -132,12 +132,12 @@ func BuildProjectSubmissionGuidelines() string { } /* -* Member +* User */ -var RegexMember = regexp.MustCompile(`^/m/(?P[^/]+)$`) +var RegexUserProfile = regexp.MustCompile(`^/m/(?P[^/]+)$`) -func BuildMember(username string) string { +func BuildUserProfile(username string) string { defer CatchPanic() if len(username) == 0 { panic(oops.New(nil, "Username must not be blank")) @@ -145,6 +145,17 @@ func BuildMember(username string) string { return Url("/m/"+username, nil) } +/* +* Snippets + */ + +var RegexSnippet = regexp.MustCompile(`^/snippet/(?P\d+)$`) + +func BuildSnippet(snippetId int) string { + defer CatchPanic() + return Url("/snippet/"+strconv.Itoa(snippetId), nil) +} + /* * Feed */ @@ -683,6 +694,12 @@ func BuildProjectCSS(color string) string { return Url("/assets/project.css", []Q{{"color", color}}) } +// NOTE(asaf): No Regex or tests for remote assets, since we don't parse it ourselves +func BuildS3Asset(s3key string) string { + defer CatchPanic() + return fmt.Sprintf("%s%s", S3BaseUrl, s3key) +} + var RegexPublic = regexp.MustCompile("^/public/.+$") func BuildPublic(filepath string, cachebust bool) string { diff --git a/src/models/asset.go b/src/models/asset.go new file mode 100644 index 00000000..f45fb05c --- /dev/null +++ b/src/models/asset.go @@ -0,0 +1,18 @@ +package models + +import ( + "github.com/google/uuid" +) + +type Asset struct { + ID uuid.UUID `db:"id"` + UploaderID *int `db:"uploader_id"` + + S3Key string `db:"s3_key"` + Filename string `db:"filename"` + Size int `db:"size"` + MimeType string `db:"mime_type"` + Sha1Sum string `db:"sha1sum"` + Width int `db:"width"` + Height int `db:"height"` +} diff --git a/src/models/discord.go b/src/models/discord.go new file mode 100644 index 00000000..52c0f4f6 --- /dev/null +++ b/src/models/discord.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" +) + +type DiscordMessage struct { + ID string `db:"id"` + ChannelID string `db:"channel_id"` + GuildID *string `db:"guild_id"` + Url string `db:"url"` + UserID string `db:"user_id"` + SentAt time.Time `db:"sent_at"` + SnippetCreated bool `db:"snippet_created"` +} diff --git a/src/models/link.go b/src/models/link.go new file mode 100644 index 00000000..319339a5 --- /dev/null +++ b/src/models/link.go @@ -0,0 +1,11 @@ +package models + +type Link struct { + ID int `db:"id"` + Key string `db:"key"` + Name *string `db:"name"` + Value string `db:"value"` + Ordering int `db:"ordering"` + UserID *int `db:"user_id"` + ProjectID *int `db:"project_id"` +} diff --git a/src/models/snippet.go b/src/models/snippet.go new file mode 100644 index 00000000..3721e225 --- /dev/null +++ b/src/models/snippet.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Snippet struct { + ID int `db:"id"` + OwnerID int `db:"owner_id"` + + When time.Time `db:"when"` + + Description string `db:"description"` + DescriptionHtml string `db:"_description_html"` + + Url *string `db:"url"` + AssetID *uuid.UUID `db:"asset_id"` + + EditedOnWebsite bool `db:"edited_on_website"` + DiscordMessageID *string `db:"discord_message_id"` +} diff --git a/src/rawdata/scss/_icons.scss b/src/rawdata/scss/_icons.scss index b77a4b3c..0c3307c4 100644 --- a/src/rawdata/scss/_icons.scss +++ b/src/rawdata/scss/_icons.scss @@ -1,6 +1,6 @@ @font-face { font-family: icons; - src: url("/static/icon/icons.ttf?v=4"); + src: url("/public/icons.ttf?v=4"); } span.icon { diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 80244b50..f1bcf125 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -3,6 +3,7 @@ package templates import ( "html/template" "net" + "regexp" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -71,17 +72,22 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ models.ProjectLifecycleLTS: "Complete", } -func ProjectToTemplate(p *models.Project, theme string) Project { - logo := p.LogoLight - if theme == "dark" { - logo = p.LogoDark - } +func ProjectUrl(p *models.Project) string { var url string if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { url = hmnurl.BuildProjectNotApproved(p.Slug) } else { url = hmnurl.BuildProjectHomepage(p.Slug) } + return url +} + +func ProjectToTemplate(p *models.Project, theme string) Project { + logo := p.LogoLight + if theme == "dark" { + logo = p.LogoDark + } + url := ProjectUrl(p) return Project{ Name: p.Name, Subdomain: p.Subdomain(), @@ -115,36 +121,49 @@ func ThreadToTemplate(t *models.Thread) Thread { } } -func UserToTemplate(u *models.User, currentTheme string) User { - // TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function. +func UserAvatarUrl(u *models.User, currentTheme string) string { if currentTheme == "" { currentTheme = "light" } - avatar := "" if u.Avatar != nil && len(*u.Avatar) > 0 { avatar = hmnurl.BuildUserFile(*u.Avatar) } else { avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) } + return avatar +} +func UserDisplayName(u *models.User) string { name := u.Name if u.Name == "" { name = u.Username } + return name +} + +func UserToTemplate(u *models.User, currentTheme string) User { + // TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function. + + email := "" + if u.ShowEmail { + // TODO(asaf): Always show email to admins + email = u.Email + } return User{ ID: u.ID, Username: u.Username, - Email: u.Email, + Email: email, IsSuperuser: u.IsSuperuser, IsStaff: u.IsStaff, - Name: name, + Name: UserDisplayName(u), Blurb: u.Blurb, Signature: u.Signature, - AvatarUrl: avatar, - ProfileUrl: hmnurl.BuildMember(u.Username), + DateJoined: u.DateJoined, + AvatarUrl: UserAvatarUrl(u, currentTheme), + ProfileUrl: hmnurl.BuildUserProfile(u.Username), DarkTheme: u.DarkTheme, Timezone: u.Timezone, @@ -157,6 +176,53 @@ func UserToTemplate(u *models.User, currentTheme string) User { } } +var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P[\w/-]+)$`) +var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P\w+)$`) +var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P[\w/-]+)$`) +var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P[\w/-]+)$`) +var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P[\w/-]+)$`) +var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P[\w/-]+)$`) +var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P[\w/-]+)$`) +var RegexServiceItch = regexp.MustCompile(`(?P[\w/-]+)\.itch\.io/?$`) + +var LinkServiceMap = map[string]*regexp.Regexp{ + "youtube": RegexServiceYoutube, + "twitter": RegexServiceTwitter, + "github": RegexServiceGithub, + "twitch": RegexServiceTwitch, + "hitbox": RegexServiceHitbox, + "patreon": RegexServicePatreon, + "soundcloud": RegexServiceSoundcloud, + "itch": RegexServiceItch, +} + +func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData string) { + for name, re := range LinkServiceMap { + match := re.FindStringSubmatch(link.Value) + if match != nil { + serviceName = name + userData = match[re.SubexpIndex("userdata")] + return + } + } + return "", "" +} + +func LinkToTemplate(link *models.Link) Link { + name := "" + if link.Name != nil { + name = *link.Name + } + serviceName, serviceUserData := ParseKnownServicesForLink(link) + return Link{ + Key: link.Key, + ServiceName: serviceName, + ServiceUserData: serviceUserData, + Name: name, + Value: link.Value, + } +} + func maybeString(s *string) string { if s == nil { return "" diff --git a/src/templates/src/include/breadcrumbs.html b/src/templates/src/include/breadcrumbs.html new file mode 100644 index 00000000..837f3489 --- /dev/null +++ b/src/templates/src/include/breadcrumbs.html @@ -0,0 +1,6 @@ + diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index c907acf3..90db2838 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -4,7 +4,7 @@ {{ if .User.IsSuperuser }} Admin {{ end }} - {{ .User.Username }} + {{ .User.Username }} Logout {{ else }} Register diff --git a/src/templates/src/user_profile.html b/src/templates/src/user_profile.html new file mode 100644 index 00000000..58c5653e --- /dev/null +++ b/src/templates/src/user_profile.html @@ -0,0 +1,126 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+
+ {{ with .ProfileUserProjects }} +
+

Projects

+
+ {{ range . }} +
+ {{ template "project_card.html" projectcarddata . "" }} +
+ {{ end }} +
+
+ {{ end }} + {{ if gt (len .TimelineItems) 0 }} +
+

Recent Activity

+
+ {{ if gt .NumForums 0 }} +
+ {{ end }} + {{ if gt .NumBlogs 0 }} +
+ {{ end }} + {{ if gt .NumWiki 0 }} +
+ {{ end }} + {{ if gt .NumLibrary 0 }} +
+ {{ end }} + {{ if gt .NumSnippets 0 }} +
+ {{ end }} +
+
+ {{ range .TimelineItems }} + {{ if timelinepostitem . }} +
+ +
+ {{ template "breadcrumbs.html" .Breadcrumbs }} +
{{ .TypeTitle }}: {{ .Title }}
+
+ {{ .OwnerName }} — {{ timehtml (relativedate .Date) .Date }} +
+
+
+ {{ else if timelinesnippetitem . }} +
+ +

{{ .Description }}

+
+ {{ if snippetvideo . }} +
+
+ {{ end }} + {{ end }} +
+
+ {{ end }} +
+ +
+ +{{ end }} diff --git a/src/templates/templates.go b/src/templates/templates.go index aad8cb92..450dba27 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -72,9 +72,18 @@ func names(ts []*template.Template) []string { } var HMNTemplateFuncs = template.FuncMap{ + "add": func(a int, b ...int) int { + for _, num := range b { + a += num + } + return a + }, "absolutedate": func(t time.Time) string { return t.UTC().Format("January 2, 2006, 3:04pm") }, + "absoluteshortdate": func(t time.Time) string { + return t.UTC().Format("January 2, 2006") + }, "rfc3339": func(t time.Time) string { return t.UTC().Format(time.RFC3339) }, @@ -166,6 +175,43 @@ var HMNTemplateFuncs = template.FuncMap{ Classes: classes, } }, + + "timelinepostitem": func(item TimelineItem) bool { + if item.Type == TimelineTypeForumThread || + item.Type == TimelineTypeForumReply || + item.Type == TimelineTypeBlogPost || + item.Type == TimelineTypeBlogComment || + item.Type == TimelineTypeWikiCreate || + item.Type == TimelineTypeWikiEdit || + item.Type == TimelineTypeWikiTalk || + item.Type == TimelineTypeLibraryComment { + + return true + } + return false + }, + "timelinesnippetitem": func(item TimelineItem) bool { + if item.Type == TimelineTypeSnippetImage || + item.Type == TimelineTypeSnippetVideo || + item.Type == TimelineTypeSnippetAudio || + item.Type == TimelineTypeSnippetYoutube { + + return true + } + return false + }, + "snippetvideo": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetVideo + }, + "snippetaudio": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetAudio + }, + "snippetimage": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetImage + }, + "snippetyoutube": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetYoutube + }, } type ErrInvalidHexColor struct { diff --git a/src/templates/types.go b/src/templates/types.go index 0ee81792..6f26b91a 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -27,7 +27,7 @@ type BaseData struct { type Header struct { AdminUrl string - MemberSettingsUrl string + UserSettingsUrl string LoginActionUrl string LogoutActionUrl string RegisterUrl string @@ -124,6 +124,7 @@ type User struct { Blurb string Bio string Signature string + DateJoined time.Time AvatarUrl string ProfileUrl string @@ -137,6 +138,14 @@ type User struct { DiscordDeleteSnippetOnMessageDelete bool } +type Link struct { + Key string + ServiceName string + ServiceUserData string + Name string + Value string +} + type OpenGraphItem struct { Property string Name string @@ -197,6 +206,51 @@ type ThreadListItem struct { Content string } +type TimelineType int + +const ( + TimelineTypeUnknown TimelineType = iota + + TimelineTypeForumThread + TimelineTypeForumReply + + TimelineTypeBlogPost + TimelineTypeBlogComment + + TimelineTypeWikiCreate + TimelineTypeWikiEdit + TimelineTypeWikiTalk + + TimelineTypeLibraryComment + + TimelineTypeSnippetImage + TimelineTypeSnippetVideo + TimelineTypeSnippetAudio + TimelineTypeSnippetYoutube +) + +type TimelineItem struct { + Type TimelineType + TypeTitle string + Class string + Date time.Time + Url string + + OwnerAvatarUrl string + OwnerName string + OwnerUrl string + Description template.HTML + + DiscordMessageUrl string + Width int + Height int + AssetUrl string + YoutubeID string + + Title string + Breadcrumbs []Breadcrumb +} + type ProjectCardData struct { Project *Project Classes string diff --git a/src/website/post_helper.go b/src/website/post_helper.go index 3f9fc194..55bdbf82 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -47,6 +47,35 @@ var PostTypePrefix = map[templates.PostType]string{ templates.PostTypeLibraryComment: "Library comment", } +func PostBreadcrumbs(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, post *models.Post, libraryResource *models.LibraryResource) []templates.Breadcrumb { + var result []templates.Breadcrumb + result = append(result, templates.Breadcrumb{ + Name: project.Name, + Url: hmnurl.BuildProjectHomepage(project.Slug), + }) + result = append(result, templates.Breadcrumb{ + Name: CategoryKindDisplayNames[post.CategoryKind], + Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), + }) + switch post.CategoryKind { + case models.CatKindForum: + subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) + slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) + for i, subforum := range subforums { + result = append(result, templates.Breadcrumb{ + Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. + Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), + }) + } + case models.CatKindLibraryResource: + result = append(result, templates.Breadcrumb{ + Name: libraryResource.Name, + Url: hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID), + }) + } + return result +} + // NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts. func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem { var result templates.PostListItem @@ -75,30 +104,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo result.PostTypePrefix = PostTypePrefix[result.PostType] if includeBreadcrumbs { - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: project.Name, - Url: hmnurl.BuildProjectHomepage(project.Slug), - }) - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: CategoryKindDisplayNames[post.CategoryKind], - Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), - }) - switch post.CategoryKind { - case models.CatKindForum: - subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) - slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) - for i, subforum := range subforums { - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. - Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), - }) - } - case models.CatKindLibraryResource: - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: libraryResource.Name, - Url: hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID), - }) - } + result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource) } return result diff --git a/src/website/routes.go b/src/website/routes.go index 90652cce..a5030ab0 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -103,16 +103,20 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy) staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines) + // TODO(asaf): Have separate middleware for HMN-only routes and any-project routes + // NOTE(asaf): HMN-only routes: mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) - mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) + mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) + // NOTE(asaf): Any-project routes: mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) + // Other mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour) return router @@ -139,7 +143,7 @@ func getBaseData(c *RequestContext) templates.BaseData { IsProjectPage: !c.CurrentProject.IsHMN(), Header: templates.Header{ AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf) - MemberSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) + UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()), LogoutActionUrl: hmnurl.BuildLogoutAction(), RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf) @@ -256,6 +260,7 @@ func FourOhFour(c *RequestContext) ResponseData { func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { c.Perf.StartBlock("MIDDLEWARE", "Load common website data") defer c.Perf.EndBlock() + // get project { slug := "" diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go new file mode 100644 index 00000000..8a7c69bb --- /dev/null +++ b/src/website/timeline_helper.go @@ -0,0 +1,170 @@ +package website + +import ( + "html/template" + "regexp" + "strings" + + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/templates" +) + +var TimelineTypeMap = map[models.CategoryKind][]templates.TimelineType{ + // No parent, Has parent + models.CatKindBlog: []templates.TimelineType{templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, + models.CatKindForum: []templates.TimelineType{templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, + models.CatKindWiki: []templates.TimelineType{templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk}, + models.CatKindLibraryResource: []templates.TimelineType{templates.TimelineTypeLibraryComment, templates.TimelineTypeLibraryComment}, +} + +var TimelineItemClassMap = map[templates.TimelineType]string{ + templates.TimelineTypeUnknown: "", + + templates.TimelineTypeForumThread: "forums", + templates.TimelineTypeForumReply: "forums", + + templates.TimelineTypeBlogPost: "blogs", + templates.TimelineTypeBlogComment: "blogs", + + templates.TimelineTypeWikiCreate: "wiki", + templates.TimelineTypeWikiEdit: "wiki", + templates.TimelineTypeWikiTalk: "wiki", + + templates.TimelineTypeLibraryComment: "library", + + templates.TimelineTypeSnippetImage: "snippets", + templates.TimelineTypeSnippetVideo: "snippets", + templates.TimelineTypeSnippetAudio: "snippets", + templates.TimelineTypeSnippetYoutube: "snippets", +} + +var TimelineTypeTitleMap = map[templates.TimelineType]string{ + templates.TimelineTypeUnknown: "", + + templates.TimelineTypeForumThread: "New forums thread", + templates.TimelineTypeForumReply: "Forum reply", + + templates.TimelineTypeBlogPost: "New blog post", + templates.TimelineTypeBlogComment: "Blog comment", + + templates.TimelineTypeWikiCreate: "New wiki article", + templates.TimelineTypeWikiEdit: "Wiki edit", + templates.TimelineTypeWikiTalk: "Wiki talk", + + templates.TimelineTypeLibraryComment: "Library comment", + + templates.TimelineTypeSnippetImage: "Snippet", + templates.TimelineTypeSnippetVideo: "Snippet", + templates.TimelineTypeSnippetAudio: "Snippet", + templates.TimelineTypeSnippetYoutube: "Snippet", +} + +func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, libraryResource *models.LibraryResource, owner *models.User, currentTheme string) templates.TimelineItem { + itemType := templates.TimelineTypeUnknown + typeByCatKind, found := TimelineTypeMap[post.CategoryKind] + if found { + hasParent := 0 + if post.ParentID != nil { + hasParent = 1 + } + itemType = typeByCatKind[hasParent] + } + + libraryResourceId := 0 + if libraryResource != nil { + libraryResourceId = libraryResource.ID + } + + return templates.TimelineItem{ + Type: itemType, + TypeTitle: TimelineTypeTitleMap[itemType], + Class: TimelineItemClassMap[itemType], + Date: post.PostDate, + Url: UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug), + + OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), + OwnerName: templates.UserDisplayName(owner), + OwnerUrl: hmnurl.BuildUserProfile(owner.Username), + Description: "", // NOTE(asaf): No description for posts + + Title: thread.Title, + Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, libraryResource), + } +} + +func PostVersionToWikiTimelineItem(lineageBuilder *models.CategoryLineageBuilder, version *models.PostVersion, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem { + return templates.TimelineItem{ + Type: templates.TimelineTypeWikiEdit, + TypeTitle: TimelineTypeTitleMap[templates.TimelineTypeWikiEdit], + Class: TimelineItemClassMap[templates.TimelineTypeWikiEdit], + Date: version.EditDate, + Url: hmnurl.BuildWikiArticle(project.Slug, thread.ID, thread.Title), + + OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), + OwnerName: templates.UserDisplayName(owner), + OwnerUrl: hmnurl.BuildUserProfile(owner.Username), + Description: "", // NOTE(asaf): No description for posts + + Title: thread.Title, + Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, nil), + } +} + +var YoutubeRegex = regexp.MustCompile(`(?i)youtube\.com/watch\?.*v=(?P[^/&]+)`) +var YoutubeShortRegex = regexp.MustCompile(`(?i)youtu\.be/(?P[^/]+)`) + +func SnippetToTimelineItem(snippet *models.Snippet, asset *models.Asset, discordMessage *models.DiscordMessage, owner *models.User, currentTheme string) templates.TimelineItem { + itemType := templates.TimelineTypeUnknown + youtubeId := "" + assetUrl := "" + width := 0 + height := 0 + discordMessageUrl := "" + + if asset == nil { + match := YoutubeRegex.FindStringSubmatch(*snippet.Url) + index := YoutubeRegex.SubexpIndex("videoid") + if match == nil { + match = YoutubeShortRegex.FindStringSubmatch(*snippet.Url) + index = YoutubeShortRegex.SubexpIndex("videoid") + } + if match != nil { + youtubeId = match[index] + itemType = templates.TimelineTypeSnippetYoutube + } + } else { + if strings.HasPrefix(asset.MimeType, "image/") { + itemType = templates.TimelineTypeSnippetImage + } else if strings.HasPrefix(asset.MimeType, "video/") { + itemType = templates.TimelineTypeSnippetVideo + } else if strings.HasPrefix(asset.MimeType, "audio/") { + itemType = templates.TimelineTypeSnippetAudio + } + assetUrl = hmnurl.BuildS3Asset(asset.S3Key) + width = asset.Width + height = asset.Height + } + + if discordMessage != nil { + discordMessageUrl = discordMessage.Url + } + + return templates.TimelineItem{ + Type: itemType, + Class: TimelineItemClassMap[itemType], + Date: snippet.When, + Url: hmnurl.BuildSnippet(snippet.ID), + + OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), + OwnerName: templates.UserDisplayName(owner), + OwnerUrl: hmnurl.BuildUserProfile(owner.Username), + Description: template.HTML(snippet.DescriptionHtml), + + DiscordMessageUrl: discordMessageUrl, + Width: width, + Height: height, + AssetUrl: assetUrl, + YoutubeID: youtubeId, + } +} diff --git a/src/website/user.go b/src/website/user.go new file mode 100644 index 00000000..6fb5f036 --- /dev/null +++ b/src/website/user.go @@ -0,0 +1,302 @@ +package website + +import ( + "errors" + "net/http" + "sort" + "strings" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/templates" +) + +type UserProfileTemplateData struct { + templates.BaseData + ProfileUser templates.User + ProfileUserLinks []templates.Link + ProfileUserProjects []templates.Project + TimelineItems []templates.TimelineItem + NumForums int + NumBlogs int + NumWiki int + NumLibrary int + NumSnippets int +} + +func UserProfile(c *RequestContext) ResponseData { + username, hasUsername := c.PathParams["username"] + + if !hasUsername || len(strings.TrimSpace(username)) == 0 { + return FourOhFour(c) + } + + username = strings.ToLower(username) + + var profileUser *models.User + if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username { + profileUser = c.CurrentUser + } else { + c.Perf.StartBlock("SQL", "Fetch user") + userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + ` + SELECT $columns + FROM + auth_user + WHERE + LOWER(auth_user.username) = $1 + `, + username, + ) + c.Perf.EndBlock() + if err != nil { + if errors.Is(err, db.ErrNoMatchingRows) { + return FourOhFour(c) + } else { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username)) + } + } + profileUser = userResult.(*models.User) + } + c.Perf.StartBlock("SQL", "Fetch user links") + type userLinkQuery struct { + UserLink models.Link `db:"link"` + } + userLinkQueryResult, err := db.Query(c.Context(), c.Conn, userLinkQuery{}, + ` + SELECT $columns + FROM + handmade_links as link + WHERE + link.user_id = $1 + ORDER BY link.ordering ASC + `, + profileUser.ID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username)) + } + userLinksSlice := userLinkQueryResult.ToSlice() + profileUserLinks := make([]templates.Link, 0, len(userLinksSlice)) + for _, l := range userLinksSlice { + profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink)) + } + c.Perf.EndBlock() + + type projectQuery struct { + Project models.Project `db:"project"` + } + c.Perf.StartBlock("SQL", "Fetch projects") + projectQueryResult, err := db.Query(c.Context(), c.Conn, projectQuery{}, + ` + SELECT $columns + FROM + handmade_project AS project + INNER JOIN handmade_project_groups AS project_groups ON project_groups.project_id = project.id + INNER JOIN auth_user_groups AS user_groups ON user_groups.group_id = project_groups.group_id + WHERE + user_groups.user_id = $1 + AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3))) + `, + profileUser.ID, + (c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsSuperuser)), + models.VisibleProjectLifecycles, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for user: %s", username)) + } + projectQuerySlice := projectQueryResult.ToSlice() + templateProjects := make([]templates.Project, 0, len(projectQuerySlice)) + for _, projectRow := range projectQuerySlice { + projectData := projectRow.(*projectQuery) + templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme)) + } + c.Perf.EndBlock() + + type postQuery struct { + Post models.Post `db:"post"` + Thread models.Thread `db:"thread"` + LibraryResource *models.LibraryResource `db:"lib_resource"` + Project models.Project `db:"project"` + } + c.Perf.StartBlock("SQL", "Fetch posts") + postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{}, + ` + SELECT $columns + FROM + handmade_post AS post + INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id + INNER JOIN handmade_project AS project ON project.id = post.project_id + LEFT JOIN handmade_libraryresource AS lib_resource ON lib_resource.category_id = post.category_id + WHERE + post.author_id = $1 + AND project.lifecycle = ANY ($2) + `, + profileUser.ID, + models.VisibleProjectLifecycles, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch posts for user: %s", username)) + } + postQuerySlice := postQueryResult.ToSlice() + c.Perf.EndBlock() + + type wikiEditQuery struct { + PostVersion models.PostVersion `db:"version"` + Post models.Post `db:"post"` + Thread models.Thread `db:"thread"` + Project models.Project `db:"project"` + } + c.Perf.StartBlock("SQL", "Fetch wiki edits") + wikiEditQueryResult, err := db.Query(c.Context(), c.Conn, wikiEditQuery{}, + ` + SELECT $columns + FROM + handmade_postversion AS version + INNER JOIN handmade_post AS post ON post.id = version.post_id + INNER JOIN handmade_thread AS thread on thread.id = post.thread_id + INNER JOIN handmade_project AS project ON project.id = post.project_id + WHERE + version.editor_id = $1 + AND post.parent_id IS NULL + AND post.category_kind = $2 + AND project.lifecycle = ANY ($3) + `, + profileUser.ID, + models.CatKindWiki, + models.VisibleProjectLifecycles, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch wiki edits for user: %s", username)) + } + wikiEditQuerySlice := wikiEditQueryResult.ToSlice() + c.Perf.EndBlock() + + type snippetQuery struct { + Snippet models.Snippet `db:"snippet"` + Asset *models.Asset `db:"asset"` + DiscordMessage *models.DiscordMessage `db:"discord_message"` + } + c.Perf.StartBlock("SQL", "Fetch snippets") + snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{}, + ` + SELECT $columns + FROM + handmade_snippet AS snippet + LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id + LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id + WHERE + snippet.owner_id = $1 + `, + profileUser.ID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username)) + } + snippetQuerySlice := snippetQueryResult.ToSlice() + c.Perf.EndBlock() + + c.Perf.StartBlock("SQL", "Fetch category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + c.Perf.EndBlock() + + c.Perf.StartBlock("PROFILE", "Construct timeline items") + timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(wikiEditQuerySlice)+len(snippetQuerySlice)) + numForums := 0 + numBlogs := 0 + numWiki := len(wikiEditQuerySlice) + numLibrary := 0 + numSnippets := len(snippetQuerySlice) + + for _, postRow := range postQuerySlice { + postData := postRow.(*postQuery) + timelineItem := PostToTimelineItem( + lineageBuilder, + &postData.Post, + &postData.Thread, + &postData.Project, + postData.LibraryResource, + profileUser, + c.Theme, + ) + switch timelineItem.Type { + case templates.TimelineTypeForumThread: + numForums += 1 + case templates.TimelineTypeForumReply: + numForums += 1 + + case templates.TimelineTypeBlogPost: + numBlogs += 1 + case templates.TimelineTypeBlogComment: + numBlogs += 1 + + case templates.TimelineTypeWikiCreate: + numWiki += 1 + case templates.TimelineTypeWikiTalk: + numWiki += 1 + + case templates.TimelineTypeLibraryComment: + numLibrary += 1 + } + if timelineItem.Type != templates.TimelineTypeUnknown { + timelineItems = append(timelineItems, timelineItem) + } else { + c.Logger.Warn().Int("post ID", postData.Post.ID).Msg("Unknown timeline item type for post") + } + } + + for _, wikiEditRow := range wikiEditQuerySlice { + wikiEditData := wikiEditRow.(*wikiEditQuery) + timelineItem := PostVersionToWikiTimelineItem( + lineageBuilder, + &wikiEditData.PostVersion, + &wikiEditData.Post, + &wikiEditData.Thread, + &wikiEditData.Project, + profileUser, + c.Theme, + ) + timelineItems = append(timelineItems, timelineItem) + } + + for _, snippetRow := range snippetQuerySlice { + snippetData := snippetRow.(*snippetQuery) + timelineItem := SnippetToTimelineItem( + &snippetData.Snippet, + snippetData.Asset, + snippetData.DiscordMessage, + profileUser, + c.Theme, + ) + timelineItems = append(timelineItems, timelineItem) + } + + c.Perf.StartBlock("PROFILE", "Sort timeline") + sort.Slice(timelineItems, func(i, j int) bool { + return timelineItems[j].Date.Before(timelineItems[i].Date) + }) + c.Perf.EndBlock() + + c.Perf.EndBlock() + + baseData := getBaseData(c) + var res ResponseData + err = res.WriteTemplate("user_profile.html", UserProfileTemplateData{ + BaseData: baseData, + ProfileUser: templates.UserToTemplate(profileUser, c.Theme), + ProfileUserLinks: profileUserLinks, + ProfileUserProjects: templateProjects, + TimelineItems: timelineItems, + NumForums: numForums, + NumBlogs: numBlogs, + NumWiki: numWiki, + NumLibrary: numLibrary, + NumSnippets: numSnippets, + }, c.Perf) + if err != nil { + panic(err) + } + return res +}