From a4ad2c5f04e3bca3cae31d74fa0c9e25aa24f278 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sat, 6 Nov 2021 15:25:31 -0500 Subject: [PATCH 01/14] Update data model for personal projects Also: - Added a helper for fetching posts with appropriate visibility - Added personal projects to the project index --- local/resetdb.sh | 6 +- src/db/db.go | 6 + src/migration/migrationTemplate.txt | 1 - .../2021-11-06T033835Z_CleanUpProjects.go | 68 ++++ .../2021-11-06T033930Z_PersonalProjects.go | 81 +++++ src/models/project.go | 6 +- src/templates/src/project_index.html | 44 +-- src/website/blogs.go | 2 +- src/website/feed.go | 2 +- src/website/project_helper.go | 300 ++++++++++++++++-- src/website/projects.go | 134 +++----- src/website/threads_and_posts_helper.go | 8 +- src/website/user.go | 2 +- 13 files changed, 501 insertions(+), 159 deletions(-) create mode 100644 src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go create mode 100644 src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go diff --git a/local/resetdb.sh b/local/resetdb.sh index 7f1e7401..1cc03996 100755 --- a/local/resetdb.sh +++ b/local/resetdb.sh @@ -9,8 +9,8 @@ set -euxo pipefail # TODO(opensource): We should adapt Asaf's seedfile command and then delete this. THIS_PATH=$(pwd) -#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' - BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' +BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' +# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' pushd $BETA_PATH docker-compose down -v @@ -19,4 +19,4 @@ pushd $BETA_PATH docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\"" popd -go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23 +go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 diff --git a/src/db/db.go b/src/db/db.go index 32996e2f..01c20c96 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -60,6 +60,12 @@ type ConnOrTx interface { Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) + + // Both raw database connections and transactions in pgx can begin/commit + // transactions. For database connections it does the obvious thing; for + // transactions it creates a "pseudo-nested transaction" but conceptually + // works the same. See the documentation of pgx.Tx.Begin. + Begin(ctx context.Context) (pgx.Tx, error) } var connInfo = pgtype.NewConnInfo() diff --git a/src/migration/migrationTemplate.txt b/src/migration/migrationTemplate.txt index 7656e2fd..53b4e355 100644 --- a/src/migration/migrationTemplate.txt +++ b/src/migration/migrationTemplate.txt @@ -5,7 +5,6 @@ import ( "time" "git.handmade.network/hmn/hmn/src/migration/types" - "git.handmade.network/hmn/hmn/src/oops" "github.com/jackc/pgx/v4" ) diff --git a/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go new file mode 100644 index 00000000..5bc0a552 --- /dev/null +++ b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go @@ -0,0 +1,68 @@ +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(CleanUpProjects{}) +} + +type CleanUpProjects struct{} + +func (m CleanUpProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 11, 6, 3, 38, 35, 0, time.UTC)) +} + +func (m CleanUpProjects) Name() string { + return "CleanUpProjects" +} + +func (m CleanUpProjects) Description() string { + return "Clean up projects with data violating our new constraints" +} + +func (m CleanUpProjects) Up(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + DELETE FROM handmade_project WHERE id IN (91, 92); + DELETE FROM handmade_communicationchoicelist + WHERE project_id IN (91, 92); + DELETE FROM handmade_project_languages + WHERE project_id IN (91, 92); + DELETE FROM handmade_project_screenshots + WHERE project_id IN (91, 92); + + UPDATE handmade_project + SET slug = 'hmh-notes' + WHERE slug = 'hmh_notes'; + `) + if err != nil { + return oops.New(err, "failed to patch up project slugs") + } + + return nil +} + +func (m CleanUpProjects) Down(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + -- Don't bother restoring those old projects + + UPDATE handmade_project + SET slug = 'hmh_notes' + WHERE slug = 'hmh-notes'; + `) + if err != nil { + return oops.New(err, "failed to restore project slugs") + } + + return nil +} diff --git a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go new file mode 100644 index 00000000..b07b286d --- /dev/null +++ b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go @@ -0,0 +1,81 @@ +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(PersonalProjects{}) +} + +type PersonalProjects struct{} + +func (m PersonalProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 11, 6, 3, 39, 30, 0, time.UTC)) +} + +func (m PersonalProjects) Name() string { + return "PersonalProjects" +} + +func (m PersonalProjects) Description() string { + return "Add data model for personal projects / tags" +} + +func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE handmade_project + DROP featurevotes, + DROP parent_id, + DROP quota, + DROP quota_used, + DROP standalone, + ALTER flags TYPE BOOLEAN USING flags > 0; + + ALTER TABLE handmade_project RENAME flags TO hidden; + `) + if err != nil { + return oops.New(err, "failed to clean up existing fields") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_project + ADD personal BOOLEAN NOT NULL DEFAULT TRUE, + ADD tag VARCHAR(20) NOT NULL DEFAULT ''; + `) + if err != nil { + return oops.New(err, "failed to add new fields") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_project + ADD CONSTRAINT slug_syntax CHECK ( + slug ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$' + ), + ADD CONSTRAINT tag_syntax CHECK ( + tag ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$' + ) + `) + if err != nil { + return oops.New(err, "failed to add check constraints") + } + + _, err = tx.Exec(ctx, ` + UPDATE handmade_project + SET personal = FALSE; + `) + if err != nil { + return oops.New(err, "failed to make existing projects official") + } + + return nil +} + +func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/project.go b/src/models/project.go index 5900312a..9aee904f 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -15,7 +15,7 @@ var ProjectType = reflect.TypeOf(Project{}) type ProjectLifecycle int const ( - ProjectLifecycleUnapproved = iota + ProjectLifecycleUnapproved ProjectLifecycle = iota ProjectLifecycleApprovalRequired ProjectLifecycleActive ProjectLifecycleHiatus @@ -41,6 +41,7 @@ type Project struct { Slug string `db:"slug"` Name string `db:"name"` + Tag string `db:"tag"` Blurb string `db:"blurb"` Description string `db:"description"` ParsedDescription string `db:"descparsed"` @@ -53,7 +54,8 @@ type Project struct { LogoLight string `db:"logolight"` LogoDark string `db:"logodark"` - Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible. + Personal bool `db:"personal"` + Hidden bool `db:"hidden"` Featured bool `db:"featured"` DateApproved time.Time `db:"date_approved"` AllLastUpdated time.Time `db:"all_last_updated"` diff --git a/src/templates/src/project_index.html b/src/templates/src/project_index.html index 69d7f027..5962f0ea 100644 --- a/src/templates/src/project_index.html +++ b/src/templates/src/project_index.html @@ -47,8 +47,12 @@
- 4 RSS Feed – New Projects - {{ template "pagination.html" .Pagination }} + +
+ {{ template "pagination.html" .Pagination }} +
@@ -66,38 +70,10 @@
- {{ if not .UserPendingProject }} -
-

Project submissions are closed

-

- We are reworking the way we approach projects on the network. In the meantime feel free to share your work on the forums or on our Discord. -

-
- {{ else }} -
-

Project pending

-

Thanks for considering us as a home for
{{ .UserPendingProject.Name }}!

-
- {{ if .UserPendingProjectUnderReview }} -

We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.

- {{ else }} -

When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.

- {{ end }} -
- {{ end }} - {{ if .UserApprovedProjects }} -
- {{ if .UserPendingProject }} -

Your other projects

- {{ else }} -

Your projects

- {{ end }} - {{ range .UserApprovedProjects }} -
- {{ template "project_card.html" projectcarddata . "" }} -
- {{ end }} -
+

Personal Projects

+

Many community members have projects of their own that are currently works in progress. Here's a few:

+ {{ range .PersonalProjects }} +
{{ .Name }}
{{ end }}
diff --git a/src/website/blogs.go b/src/website/blogs.go index e535fd7f..9e318683 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -75,7 +75,7 @@ func BlogIndex(c *RequestContext) ResponseData { canCreate := false if c.CurrentUser != nil { isProjectOwner := false - owners, err := FetchProjectOwners(c, c.CurrentProject.ID) + owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners")) } diff --git a/src/website/feed.go b/src/website/feed.go index bb74862f..5d6f1663 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData { handmade_project AS project WHERE project.lifecycle = ANY($1) - AND project.flags = 0 + AND NOT project.hidden ORDER BY date_approved DESC LIMIT $2 `, diff --git a/src/website/project_helper.go b/src/website/project_helper.go index 83ba7435..f5fed7aa 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -1,17 +1,173 @@ package website import ( + "context" + "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" ) +type ProjectTypeQuery int + +const ( + PersonalProjects ProjectTypeQuery = 1 << iota + OfficialProjects +) + +type ProjectsQuery struct { + Lifecycles []models.ProjectLifecycle + Types ProjectTypeQuery // bitfield + + // Ignored when using CountProjects + Limit, Offset int // if empty, no pagination +} + +type ProjectAndStuff struct { + Project models.Project + Owners []*models.User +} + +func FetchProjects( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + q ProjectsQuery, +) ([]ProjectAndStuff, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch projects") + defer perf.EndBlock() + + var currentUserID *int + if currentUser != nil { + currentUserID = ¤tUser.ID + } + + tx, err := dbConn.Begin(ctx) + if err != nil { + return nil, oops.New(err, "failed to start transaction") + } + defer tx.Rollback(ctx) + + // Fetch all valid projects (not yet subject to user permission checks) + var qb db.QueryBuilder + qb.Add(` + SELECT $columns + FROM + handmade_project AS project + WHERE + NOT hidden + `) + if len(q.Lifecycles) > 0 { + qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles) + } else { + qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles) + } + if q.Types != 0 { + qb.Add(`AND (FALSE`) + if q.Types&PersonalProjects != 0 { + qb.Add(`OR project.personal`) + } + if q.Types&OfficialProjects != 0 { + qb.Add(`OR NOT project.personal`) + } + qb.Add(`)`) + } + if q.Limit > 0 { + qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) + } + itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...) + if err != nil { + return nil, oops.New(err, "failed to fetch projects") + } + iprojects := itProjects.ToSlice() + + // Fetch project owners to do permission checks + projectIds := make([]int, len(iprojects)) + for i, iproject := range iprojects { + projectIds[i] = iproject.(*models.Project).ID + } + projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) + if err != nil { + return nil, err + } + + var res []ProjectAndStuff + for i, iproject := range iprojects { + project := iproject.(*models.Project) + owners := projectOwners[i].Owners + + /* + Per our spec, a user can see a project if: + - The project is official + - The project is personal and all of the project's owners are approved + - The project is personal and the current user is a collaborator (regardless of user status) + + See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c + */ + + var projectVisible bool + if project.Personal { + allOwnersApproved := true + for _, owner := range owners { + if owner.Status != models.UserStatusApproved { + allOwnersApproved = false + } + if currentUserID != nil && *currentUserID == owner.ID { + projectVisible = true + } + } + if allOwnersApproved { + projectVisible = true + } + } else { + projectVisible = true + } + + if projectVisible { + res = append(res, ProjectAndStuff{ + Project: *project, + Owners: owners, + }) + } + } + + err = tx.Commit(ctx) + if err != nil { + return nil, oops.New(err, "failed to commit transaction") + } + + return res, nil +} + +func CountProjects( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + q ProjectsQuery, +) (int, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Count projects") + defer perf.EndBlock() + + q.Limit = 0 + q.Offset = 0 + + // I'm lazy and there probably won't ever be that many projects. + projects, err := FetchProjects(ctx, dbConn, currentUser, q) + if err != nil { + return 0, oops.New(err, "failed to fetch projects") + } + + return len(projects), nil +} + 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) + owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId) if err != nil { return false, err } @@ -25,29 +181,137 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, 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"` +type ProjectOwners struct { + ProjectID int + Owners []*models.User +} + +// Fetches all owners for multiple projects. Does NOT check permissions on the +// project IDs, since the assumption is that you will check permissions on the +// projects themselves before using any of this data. +// +// The returned slice will always have one entry for each project ID given, in +// the same order as they were provided. If there are duplicate project IDs in +// projectIds, the results will be wrong, so don't do that. +// +// This function does not verify that the requested projects do in fact exist. +func FetchMultipleProjectsOwners( + ctx context.Context, + dbConn db.ConnOrTx, + projectIds []int, +) ([]ProjectOwners, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch owners for multiple projects") + defer perf.EndBlock() + + tx, err := dbConn.Begin(ctx) + if err != nil { + return nil, oops.New(err, "failed to start transaction") } - ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{}, + defer tx.Rollback(ctx) + + // Fetch all user/project pairs for the given projects + type userProject struct { + UserID int `db:"user_id"` + ProjectID int `db:"project_id"` + } + it, err := db.Query(ctx, tx, userProject{}, ` SELECT $columns - FROM - auth_user - INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id - WHERE - uproj.project_id = $1 + FROM handmade_user_projects + WHERE project_id = ANY($1) `, - projectId, + projectIds, ) - c.Perf.EndBlock() if err != nil { - return result, oops.New(err, "failed to fetch owners for project") + return nil, oops.New(err, "failed to fetch project IDs") } - for _, ownerRow := range ownerQueryResult.ToSlice() { - result = append(result, &ownerRow.(*ownerQuery).Owner) + iuserprojects := it.ToSlice() + + // Get the unique user IDs from this set and fetch the users from the db + var userIds []int + for _, iuserproject := range iuserprojects { + userProject := iuserproject.(*userProject) + + addUserId := true + for _, uid := range userIds { + if uid == userProject.UserID { + addUserId = false + } + } + if addUserId { + userIds = append(userIds, userProject.UserID) + } } - return result, nil + it, err = db.Query(ctx, tx, models.User{}, + ` + SELECT $columns + FROM auth_user + WHERE + id = ANY($1) + `, + userIds, + ) + if err != nil { + return nil, oops.New(err, "failed to fetch users for projects") + } + iusers := it.ToSlice() + + // Build the final result set with real user data + res := make([]ProjectOwners, len(projectIds)) + for i, pid := range projectIds { + res[i] = ProjectOwners{ProjectID: pid} + } + for _, iuserproject := range iuserprojects { + userProject := iuserproject.(*userProject) + + // Get a pointer to the existing record in the result + var projectOwners *ProjectOwners + for i := range res { + if res[i].ProjectID == userProject.ProjectID { + projectOwners = &res[i] + } + } + + // Get the full user record we fetched + var user *models.User + for _, iuser := range iusers { + u := iuser.(*models.User) + if u.ID == userProject.UserID { + user = u + } + } + if user == nil { + panic("we apparently failed to fetch a project's owner") + } + + // Slam 'em together + projectOwners.Owners = append(projectOwners.Owners, user) + } + + err = tx.Commit(ctx) + if err != nil { + return nil, oops.New(err, "failed to commit transaction") + } + + return res, nil +} + +// Fetches project owners for a single project. It is subject to all the same +// restrictions as FetchMultipleProjectsOwners. +func FetchProjectOwners( + ctx context.Context, + dbConn db.ConnOrTx, + projectId int, +) ([]*models.User, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch owners for project") + defer perf.EndBlock() + + projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId}) + if err != nil { + return nil, err + } + + return projectOwners[0].Owners, nil } diff --git a/src/website/projects.go b/src/website/projects.go index 7daf7007..8f533f25 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -6,7 +6,6 @@ import ( "math" "math/rand" "net/http" - "strconv" "strings" "time" @@ -24,10 +23,7 @@ type ProjectTemplateData struct { Pagination templates.Pagination CarouselProjects []templates.Project Projects []templates.Project - - UserPendingProjectUnderReview bool - UserPendingProject *templates.Project - UserApprovedProjects []templates.Project + PersonalProjects []templates.Project ProjectAtomFeedUrl string WIPForumUrl string @@ -36,47 +32,19 @@ type ProjectTemplateData struct { func ProjectIndex(c *RequestContext) ResponseData { const projectsPerPage = 20 const maxCarouselProjects = 10 + const maxPersonalProjects = 10 - page := 1 - pageString, hasPage := c.PathParams["page"] - if hasPage && pageString != "" { - if pageParsed, err := strconv.Atoi(pageString); err == nil { - page = pageParsed - } else { - return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther) - } - } - - if page < 1 { - return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther) - } - - c.Perf.StartBlock("SQL", "Fetching all visible projects") - type projectResult struct { - Project models.Project `db:"project"` - } - allProjects, err := db.Query(c.Context(), c.Conn, projectResult{}, - ` - SELECT $columns - FROM - handmade_project AS project - WHERE - project.lifecycle = ANY($1) - AND project.flags = 0 - ORDER BY project.date_approved ASC - `, - models.VisibleProjectLifecycles, - ) + officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{ + Types: OfficialProjects, + }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects")) } - allProjectsSlice := allProjects.ToSlice() - c.Perf.EndBlock() - numPages := int(math.Ceil(float64(len(allProjectsSlice)) / projectsPerPage)) - - if page > numPages { - return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther) + numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage)) + page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage) + if !ok { + return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther) } pagination := templates.Pagination{ @@ -89,63 +57,22 @@ func ProjectIndex(c *RequestContext) ResponseData { PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)), } - var userApprovedProjects []templates.Project - var userPendingProject *templates.Project - userPendingProjectUnderReview := false - if c.CurrentUser != nil { - c.Perf.StartBlock("SQL", "fetching user projects") - type UserProjectQuery struct { - Project models.Project `db:"project"` - } - userProjectsResult, err := db.Query(c.Context(), c.Conn, UserProjectQuery{}, - ` - SELECT $columns - FROM - handmade_project AS project - INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id - WHERE - uproj.user_id = $1 - `, - c.CurrentUser.ID, - ) - if err != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects")) - } - for _, project := range userProjectsResult.ToSlice() { - p := project.(*UserProjectQuery).Project - if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { - if userPendingProject == nil { - // NOTE(asaf): Technically a user could have more than one pending project. - // For example, if they created one project themselves and were added as an additional owner to another user's project. - // So we'll just take the first one. I don't think it matters. I guess it especially won't matter after Projects 2.0. - tmplProject := templates.ProjectToTemplate(&p, c.Theme) - userPendingProject = &tmplProject - userPendingProjectUnderReview = (p.Lifecycle == models.ProjectLifecycleApprovalRequired) - } - } else { - userApprovedProjects = append(userApprovedProjects, templates.ProjectToTemplate(&p, c.Theme)) - } - } - c.Perf.EndBlock() - } - c.Perf.StartBlock("PROJECTS", "Grouping and sorting") var handmadeHero *templates.Project var featuredProjects []templates.Project var recentProjects []templates.Project var restProjects []templates.Project now := time.Now() - for _, p := range allProjectsSlice { - project := &p.(*projectResult).Project - templateProject := templates.ProjectToTemplate(project, c.Theme) - if project.Slug == "hero" { + for _, p := range officialProjects { + templateProject := templates.ProjectToTemplate(&p.Project, c.Theme) + if p.Project.Slug == "hero" { // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. handmadeHero = &templateProject continue } - if project.Featured { + if p.Project.Featured { featuredProjects = append(featuredProjects, templateProject) - } else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec { + } else if now.Sub(p.Project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec { recentProjects = append(recentProjects, templateProject) } else { restProjects = append(restProjects, templateProject) @@ -178,6 +105,28 @@ func ProjectIndex(c *RequestContext) ResponseData { } c.Perf.EndBlock() + // Fetch and highlight a random selection of personal projects + var personalProjects []templates.Project + { + projects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{ + Types: PersonalProjects, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal projects")) + } + + randSeed := now.YearDay() + random := rand.New(rand.NewSource(int64(randSeed))) + random.Shuffle(len(projects), func(i, j int) { projects[i], projects[j] = projects[j], projects[i] }) + + for i, p := range projects { + if i >= maxPersonalProjects { + break + } + personalProjects = append(personalProjects, templates.ProjectToTemplate(&p.Project, c.Theme)) + } + } + baseData := getBaseDataAutocrumb(c, "Projects") var res ResponseData res.MustWriteTemplate("project_index.html", ProjectTemplateData{ @@ -186,10 +135,7 @@ func ProjectIndex(c *RequestContext) ResponseData { Pagination: pagination, CarouselProjects: carouselProjects, Projects: pageProjects, - - UserPendingProjectUnderReview: userPendingProjectUnderReview, - UserPendingProject: userPendingProject, - UserApprovedProjects: userApprovedProjects, + PersonalProjects: personalProjects, ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(), WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1), @@ -253,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { return FourOhFour(c) } - owners, err := FetchProjectOwners(c, project.ID) + owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -275,7 +221,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { } } if !canView { - if project.Flags == 0 { + if !project.Hidden { for _, lc := range models.VisibleProjectLifecycles { if project.Lifecycle == lc { canView = true @@ -377,7 +323,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) } - if project.Flags == 1 { + if project.Hidden { projectHomepageData.BaseData.AddImmediateNotice( "hidden", "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", diff --git a/src/website/threads_and_posts_helper.go b/src/website/threads_and_posts_helper.go index b1d32edc..9cac64b8 100644 --- a/src/website/threads_and_posts_helper.go +++ b/src/website/threads_and_posts_helper.go @@ -88,7 +88,7 @@ func FetchThreads( WHERE NOT thread.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, @@ -219,7 +219,7 @@ func CountThreads( WHERE NOT thread.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, @@ -343,7 +343,7 @@ func FetchPosts( NOT thread.deleted AND NOT post.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, @@ -543,7 +543,7 @@ func CountPosts( NOT thread.deleted AND NOT post.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, diff --git a/src/website/user.go b/src/website/user.go index 92fba61b..e1f79b7c 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData { INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id WHERE uproj.user_id = $1 - AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3))) + AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3))) `, profileUser.ID, (c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)), From 7486f9e57df7a498c1ca0fceaf5ebd29ce7352f4 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Mon, 8 Nov 2021 13:16:54 -0600 Subject: [PATCH 02/14] I really have no idea where I left off --- src/hmnurl/urls.go | 9 ++-- src/models/project.go | 16 ++++++ src/models/project_test.go | 18 +++++++ src/templates/mapping.go | 6 +-- src/website/project_helper.go | 80 +++++++++++++++++++++++++++++- src/website/projects.go | 93 +++++++++++------------------------ src/website/routes.go | 61 +++++++---------------- 7 files changed, 168 insertions(+), 115 deletions(-) create mode 100644 src/models/project_test.go diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 68568f52..b98dbf1d 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -24,7 +24,7 @@ func BuildHomepage() string { return Url("/", nil) } -func BuildProjectHomepage(projectSlug string) string { +func BuildOfficialProjectHomepage(projectSlug string) string { defer CatchPanic() return ProjectUrl("/", nil, projectSlug) } @@ -295,12 +295,11 @@ func BuildProjectNew() string { return Url("/projects/new", nil) } -var RegexProjectNotApproved = regexp.MustCompile("^/p/(?P.+)$") +var RegexPersonalProjectHomepage = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[^/]*))?") -func BuildProjectNotApproved(slug string) string { +func BuildPersonalProjectHomepage(id int, slug string) string { defer CatchPanic() - - return Url(fmt.Sprintf("/p/%s", slug), nil) + return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil) } var RegexProjectEdit = regexp.MustCompile("^/p/(?P.+)/edit$") diff --git a/src/models/project.go b/src/models/project.go index 9aee904f..43f62638 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -2,6 +2,8 @@ package models import ( "reflect" + "regexp" + "strings" "time" ) @@ -79,3 +81,17 @@ func (p *Project) Subdomain() string { return p.Slug } + +var slugUnsafeChars = regexp.MustCompile(`[^a-zA-Z0-9-]`) +var slugHyphenRun = regexp.MustCompile(`-+`) + +// Generates a URL-safe version of a personal project's name. +func GeneratePersonalProjectSlug(name string) string { + slug := name + slug = slugUnsafeChars.ReplaceAllLiteralString(slug, "-") + slug = slugHyphenRun.ReplaceAllLiteralString(slug, "-") + slug = strings.Trim(slug, "-") + slug = strings.ToLower(slug) + + return slug +} diff --git a/src/models/project_test.go b/src/models/project_test.go new file mode 100644 index 00000000..13ed2681 --- /dev/null +++ b/src/models/project_test.go @@ -0,0 +1,18 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateSlug(t *testing.T) { + assert.Equal(t, "godspeed-you-black-emperor", GeneratePersonalProjectSlug("Godspeed You! Black Emperor")) + assert.Equal(t, "", GeneratePersonalProjectSlug("!@#$%^&")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("-- Foo Bar --")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("--foo-bar")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo--bar")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo-bar--")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug(" Foo Bar ")) + assert.Equal(t, "20-000-leagues-under-the-sea", GeneratePersonalProjectSlug("20,000 Leagues Under the Sea")) +} diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 460bcad2..39b74732 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -61,10 +61,10 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ func ProjectUrl(p *models.Project) string { var url string - if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { - url = hmnurl.BuildProjectNotApproved(p.Slug) + if p.Personal { + url = hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name)) } else { - url = hmnurl.BuildProjectHomepage(p.Slug) + url = hmnurl.BuildOfficialProjectHomepage(p.Slug) } return url } diff --git a/src/website/project_helper.go b/src/website/project_helper.go index f5fed7aa..c022f7c1 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -4,6 +4,7 @@ import ( "context" "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" ) @@ -16,8 +17,13 @@ const ( ) type ProjectsQuery struct { - Lifecycles []models.ProjectLifecycle - Types ProjectTypeQuery // bitfield + // Available on all project queries + Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles + Types ProjectTypeQuery // bitfield + + // Ignored when using FetchProject + ProjectIDs []int // if empty, all projects + Slugs []string // if empty, all projects // Ignored when using CountProjects Limit, Offset int // if empty, no pagination @@ -58,6 +64,12 @@ func FetchProjects( WHERE NOT hidden `) + if len(q.ProjectIDs) > 0 { + qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) + } + if len(q.Slugs) > 0 { + qb.Add(`AND project.slug = ANY ($?)`, q.Slugs) + } if len(q.Lifecycles) > 0 { qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles) } else { @@ -140,6 +152,62 @@ func FetchProjects( return res, nil } +/* +Fetches a single project. A wrapper around FetchProjects. + +Returns db.NotFound if no result is found. +*/ +func FetchProject( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + projectID int, + q ProjectsQuery, +) (ProjectAndStuff, error) { + q.ProjectIDs = []int{projectID} + q.Limit = 1 + q.Offset = 0 + + res, err := FetchProjects(ctx, dbConn, currentUser, q) + if err != nil { + return ProjectAndStuff{}, oops.New(err, "failed to fetch project") + } + + if len(res) == 0 { + return ProjectAndStuff{}, db.NotFound + } + + return res[0], nil +} + +/* +Fetches a single project by slug. A wrapper around FetchProjects. + +Returns db.NotFound if no result is found. +*/ +func FetchProjectBySlug( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + projectSlug string, + q ProjectsQuery, +) (ProjectAndStuff, error) { + q.Slugs = []string{projectSlug} + q.Limit = 1 + q.Offset = 0 + + res, err := FetchProjects(ctx, dbConn, currentUser, q) + if err != nil { + return ProjectAndStuff{}, oops.New(err, "failed to fetch project") + } + + if len(res) == 0 { + return ProjectAndStuff{}, db.NotFound + } + + return res[0], nil +} + func CountProjects( ctx context.Context, dbConn db.ConnOrTx, @@ -315,3 +383,11 @@ func FetchProjectOwners( return projectOwners[0].Owners, nil } + +func UrlForProject(p *models.Project) string { + if p.Personal { + return hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name)) + } else { + return hmnurl.BuildOfficialProjectHomepage(p.Slug) + } +} diff --git a/src/website/projects.go b/src/website/projects.go index 8f533f25..345d359c 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -6,7 +6,7 @@ import ( "math" "math/rand" "net/http" - "strings" + "strconv" "time" "git.handmade.network/hmn/hmn/src/db" @@ -158,39 +158,34 @@ func ProjectHomepage(c *RequestContext) ResponseData { var project *models.Project if c.CurrentProject.IsHMN() { - slug, hasSlug := c.PathParams["slug"] - if hasSlug && slug != "" { - slug = strings.ToLower(slug) - if slug == models.HMNProjectSlug { - return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) - } - c.Perf.StartBlock("SQL", "Fetching project by slug") - type projectQuery struct { - Project models.Project `db:"Project"` - } - projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{}, - ` - SELECT $columns - FROM - handmade_project AS project - WHERE - LOWER(project.slug) = $1 - `, - slug, - ) - c.Perf.EndBlock() - if err != nil { - if errors.Is(err, db.NotFound) { - return FourOhFour(c) - } else { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug")) - } - } - project = &projectQueryResult.(*projectQuery).Project - if project.Lifecycle != models.ProjectLifecycleUnapproved && project.Lifecycle != models.ProjectLifecycleApprovalRequired { - return c.Redirect(hmnurl.BuildProjectHomepage(project.Slug), http.StatusSeeOther) + // Viewing a personal project + idStr := c.PathParams["id"] + slug := c.PathParams["slug"] + + id, err := strconv.Atoi(idStr) + if err != nil { + panic(oops.New(err, "id was not numeric (bad regex in routing)")) + } + + if id == models.HMNProjectID { + return c.Redirect(hmnurl.BuildHomepage(), http.StatusPermanentRedirect) + } + + p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{}) + if err != nil { + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug")) } } + + correctSlug := models.GeneratePersonalProjectSlug(p.Project.Name) + if slug != correctSlug { + return c.Redirect(hmnurl.BuildPersonalProjectHomepage(id, correctSlug), http.StatusPermanentRedirect) + } + + project = &p.Project } else { project = c.CurrentProject } @@ -199,42 +194,14 @@ func ProjectHomepage(c *RequestContext) ResponseData { return FourOhFour(c) } + // There are no further permission checks to do, because permissions are + // checked whatever way we fetch the project. + owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } - canView := false - canEdit := false - if c.CurrentUser != nil { - if c.CurrentUser.IsStaff { - canView = true - canEdit = true - } else { - for _, owner := range owners { - if owner.ID == c.CurrentUser.ID { - canView = true - canEdit = true - break - } - } - } - } - if !canView { - if !project.Hidden { - for _, lc := range models.VisibleProjectLifecycles { - if project.Lifecycle == lc { - canView = true - break - } - } - } - } - - if !canView { - return FourOhFour(c) - } - c.Perf.StartBlock("SQL", "Fetching screenshots") type screenshotQuery struct { Filename string `db:"screenshot.file"` diff --git a/src/website/routes.go b/src/website/routes.go index a3c2d8e7..b3df8a51 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -202,7 +202,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexShowcase, Showcase) hmnOnly.GET(hmnurl.RegexSnippet, Snippet) hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex) - hmnOnly.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage) + hmnOnly.GET(hmnurl.RegexPersonalProjectHomepage, ProjectHomepage) hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback)) hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink))) @@ -277,31 +277,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe return router } -func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) { - if len(slug) > 0 && slug != models.HMNProjectSlug { - subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug) - if err == nil { - subdomainProject := subdomainProjectRow.(*models.Project) - return subdomainProject, nil - } else if !errors.Is(err, db.NotFound) { - return nil, oops.New(err, "failed to get projects by slug") - } else { - return nil, nil - } - } else { - defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID) - if err != nil { - if errors.Is(err, db.NotFound) { - return nil, oops.New(nil, "default project didn't exist in the database") - } else { - return nil, oops.New(err, "failed to get default project") - } - } - defaultProject := defaultProjectRow.(*models.Project) - return defaultProject, nil - } -} - func ProjectCSS(c *RequestContext) ResponseData { color := c.URL().Query().Get("color") if color == "" { @@ -382,22 +357,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { c.Perf.StartBlock("MIDDLEWARE", "Load common website data") defer c.Perf.EndBlock() - // get project - { - hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost()) - slug := strings.TrimRight(hostPrefix, ".") - - dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, slug) - if err != nil { - return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) - } - if dbProject == nil { - return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) - } - - c.CurrentProject = dbProject - } - + // get user { sessionCookie, err := c.Req.Cookie(auth.SessionCookieName) if err == nil { @@ -412,6 +372,23 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { // http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here. } + // get official project + { + hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost()) + slug := strings.TrimRight(hostPrefix, ".") + + dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{}) + if err != nil { + if errors.Is(err, db.NotFound) { + return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + } else { + return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) + } + } + + c.CurrentProject = &dbProject.Project + } + theme := "light" if c.CurrentUser != nil && c.CurrentUser.DarkTheme { theme = "dark" From ff901e4fb86a19968ca1f74ae2ad6e7a8eabfbb7 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 9 Nov 2021 11:14:38 -0800 Subject: [PATCH 03/14] Add route grouping stuff for projects (needs thorough testing) --- local/resetdb.sh | 7 +- src/hmnurl/urls.go | 10 +- src/templates/mapping.go | 2 +- src/website/base_data.go | 4 +- src/website/blogs.go | 2 +- src/website/breadcrumb_helper.go | 2 +- src/website/episode_guide.go | 4 +- src/website/post_helper.go | 8 +- src/website/project_helper.go | 12 ++- src/website/projects.go | 70 ++++---------- src/website/requesthandling.go | 72 ++++++++++----- src/website/routes.go | 152 ++++++++++++++++++++++--------- 12 files changed, 204 insertions(+), 141 deletions(-) diff --git a/local/resetdb.sh b/local/resetdb.sh index 1cc03996..ba5af70f 100755 --- a/local/resetdb.sh +++ b/local/resetdb.sh @@ -9,8 +9,8 @@ set -euxo pipefail # TODO(opensource): We should adapt Asaf's seedfile command and then delete this. THIS_PATH=$(pwd) -BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' -# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' +#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' +BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' pushd $BETA_PATH docker-compose down -v @@ -19,4 +19,5 @@ pushd $BETA_PATH docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\"" popd -go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 +#go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 +go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23 diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index b98dbf1d..3975b3cf 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -97,8 +97,6 @@ func BuildRegistrationSuccess() string { return Url("/registered_successfully", nil) } -// TODO(asaf): Delete the old version a bit after launch -var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P[\w\ \.\,\-@\+\_]+)/(?P[\d\w]+)/(?P.+)[\/]?$`) var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P[^/]+)/(?P[^/]+)$") func BuildEmailConfirmation(username, token string) string { @@ -295,14 +293,14 @@ func BuildProjectNew() string { return Url("/projects/new", nil) } -var RegexPersonalProjectHomepage = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[^/]*))?") +var RegexPersonalProject = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[a-zA-Z0-9-]+))?") -func BuildPersonalProjectHomepage(id int, slug string) string { +func BuildPersonalProject(id int, slug string) string { defer CatchPanic() return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil) } -var RegexProjectEdit = regexp.MustCompile("^/p/(?P.+)/edit$") +var RegexProjectEdit = regexp.MustCompile("^/edit$") func BuildProjectEdit(slug string, section string) string { defer CatchPanic() @@ -730,7 +728,7 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string { return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexCatchAll = regexp.MustCompile("") +var RegexCatchAll = regexp.MustCompile("^") /* * Helper functions diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 39b74732..e6d18d4c 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -62,7 +62,7 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ func ProjectUrl(p *models.Project) string { var url string if p.Personal { - url = hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name)) + url = hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name)) } else { url = hmnurl.BuildOfficialProjectHomepage(p.Slug) } diff --git a/src/website/base_data.go b/src/website/base_data.go index 6d37b28a..794f20c7 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -26,7 +26,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc notices := getNoticesFromCookie(c) if len(breadcrumbs) > 0 { - projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug) + projectUrl := UrlForProject(c.CurrentProject) if breadcrumbs[0].Url != projectUrl { rootBreadcrumb := templates.Breadcrumb{ Name: c.CurrentProject.Name, @@ -42,7 +42,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc Breadcrumbs: breadcrumbs, CurrentUrl: c.FullUrl(), - CurrentProjectUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug), + CurrentProjectUrl: UrlForProject(c.CurrentProject), LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1), diff --git a/src/website/blogs.go b/src/website/blogs.go index 9e318683..df76def5 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -517,7 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData { } if threadDeleted { - projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug) + projectUrl := UrlForProject(c.CurrentProject) return c.Redirect(projectUrl, http.StatusSeeOther) } else { thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{ diff --git a/src/website/breadcrumb_helper.go b/src/website/breadcrumb_helper.go index 0f69794a..b40ace78 100644 --- a/src/website/breadcrumb_helper.go +++ b/src/website/breadcrumb_helper.go @@ -9,7 +9,7 @@ import ( func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb { return templates.Breadcrumb{ Name: project.Name, - Url: hmnurl.BuildProjectHomepage(project.Slug), + Url: UrlForProject(project), } } diff --git a/src/website/episode_guide.go b/src/website/episode_guide.go index 6be4069f..76cba499 100644 --- a/src/website/episode_guide.go +++ b/src/website/episode_guide.go @@ -53,7 +53,7 @@ func EpisodeList(c *RequestContext) ResponseData { defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug] if !hasEpisodeGuide { - return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther) + return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther) } if topic == "" { @@ -114,7 +114,7 @@ func Episode(c *RequestContext) ResponseData { _, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug] if !hasEpisodeGuide { - return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther) + return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther) } _, foundTopic := topicsForProject(slug, topic) diff --git a/src/website/post_helper.go b/src/website/post_helper.go index 8c8fb764..c60c148b 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -15,7 +15,7 @@ func UrlForGenericThread(thread *models.Thread, lineageBuilder *models.SubforumL return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1) } - return hmnurl.BuildProjectHomepage(projectSlug) + return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects } func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string { @@ -26,7 +26,7 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID) } - return hmnurl.BuildProjectHomepage(projectSlug) + return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects } var PostTypeMap = map[models.ThreadType][]templates.PostType{ @@ -55,7 +55,7 @@ func GenericThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, pro result = []templates.Breadcrumb{ { Name: project.Name, - Url: hmnurl.BuildProjectHomepage(project.Slug), + Url: UrlForProject(project), }, { Name: ThreadTypeDisplayNames[thread.Type], @@ -73,7 +73,7 @@ func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) str case models.ThreadTypeForumPost: return hmnurl.BuildForum(projectSlug, nil, 1) } - return hmnurl.BuildProjectHomepage(projectSlug) + return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects } func MakePostListItem( diff --git a/src/website/project_helper.go b/src/website/project_helper.go index c022f7c1..de6e7582 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -18,8 +18,9 @@ const ( type ProjectsQuery struct { // Available on all project queries - Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles - Types ProjectTypeQuery // bitfield + Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles + Types ProjectTypeQuery // bitfield + IncludeHidden bool // Ignored when using FetchProject ProjectIDs []int // if empty, all projects @@ -62,8 +63,11 @@ func FetchProjects( FROM handmade_project AS project WHERE - NOT hidden + TRUE `) + if !q.IncludeHidden { + qb.Add(`AND NOT hidden`) + } if len(q.ProjectIDs) > 0 { qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) } @@ -386,7 +390,7 @@ func FetchProjectOwners( func UrlForProject(p *models.Project) string { if p.Personal { - return hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name)) + return hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name)) } else { return hmnurl.BuildOfficialProjectHomepage(p.Slug) } diff --git a/src/website/projects.go b/src/website/projects.go index 345d359c..54e6da9a 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -1,12 +1,10 @@ package website import ( - "errors" "fmt" "math" "math/rand" "net/http" - "strconv" "time" "git.handmade.network/hmn/hmn/src/db" @@ -155,49 +153,15 @@ type ProjectHomepageData struct { func ProjectHomepage(c *RequestContext) ResponseData { maxRecentActivity := 15 - var project *models.Project - if c.CurrentProject.IsHMN() { - // Viewing a personal project - idStr := c.PathParams["id"] - slug := c.PathParams["slug"] - - id, err := strconv.Atoi(idStr) - if err != nil { - panic(oops.New(err, "id was not numeric (bad regex in routing)")) - } - - if id == models.HMNProjectID { - return c.Redirect(hmnurl.BuildHomepage(), http.StatusPermanentRedirect) - } - - p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{}) - if err != nil { - if errors.Is(err, db.NotFound) { - return FourOhFour(c) - } else { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug")) - } - } - - correctSlug := models.GeneratePersonalProjectSlug(p.Project.Name) - if slug != correctSlug { - return c.Redirect(hmnurl.BuildPersonalProjectHomepage(id, correctSlug), http.StatusPermanentRedirect) - } - - project = &p.Project - } else { - project = c.CurrentProject - } - - if project == nil { + if c.CurrentProject == nil { return FourOhFour(c) } // There are no further permission checks to do, because permissions are // checked whatever way we fetch the project. - owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID) + owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -215,7 +179,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { WHERE handmade_project_screenshots.project_id = $1 `, - project.ID, + c.CurrentProject.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project")) @@ -235,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { link.project_id = $1 ORDER BY link.ordering ASC `, - project.ID, + c.CurrentProject.ID, ) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links")) @@ -265,7 +229,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { ORDER BY post.postdate DESC LIMIT $2 `, - project.ID, + c.CurrentProject.ID, maxRecentActivity, ) if err != nil { @@ -275,36 +239,36 @@ func ProjectHomepage(c *RequestContext) ResponseData { var projectHomepageData ProjectHomepageData - projectHomepageData.BaseData = getBaseData(c, project.Name, nil) - if canEdit { - // TODO: Move to project-specific navigation - // projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "") - } + projectHomepageData.BaseData = getBaseData(c, c.CurrentProject.Name, nil) + //if canEdit { + // // TODO: Move to project-specific navigation + // // projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "") + //} projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{ Property: "og:description", - Value: project.Blurb, + Value: c.CurrentProject.Blurb, }) - projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme) + projectHomepageData.Project = templates.ProjectToTemplate(c.CurrentProject, c.Theme) for _, owner := range owners { projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) } - if project.Hidden { + if c.CurrentProject.Hidden { projectHomepageData.BaseData.AddImmediateNotice( "hidden", "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", ) } - if project.Lifecycle != models.ProjectLifecycleActive { - switch project.Lifecycle { + if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive { + switch c.CurrentProject.Lifecycle { case models.ProjectLifecycleUnapproved: projectHomepageData.BaseData.AddImmediateNotice( "unapproved", fmt.Sprintf( "NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please submit it for approval when the project content is ready for review.", - hmnurl.BuildProjectEdit(project.Slug, "submit"), + hmnurl.BuildProjectEdit(c.CurrentProject.Slug, "submit"), ), ) case models.ProjectLifecycleApprovalRequired: @@ -348,7 +312,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { lineageBuilder, &post.(*postQuery).Post, &post.(*postQuery).Thread, - project, + c.CurrentProject, &post.(*postQuery).Author, c.Theme, )) diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 6ca84ecb..0a6eee51 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -30,12 +30,13 @@ type Router struct { type Route struct { Method string - Regex *regexp.Regexp + Regexes []*regexp.Regexp Handler Handler } type RouteBuilder struct { Router *Router + Prefixes []*regexp.Regexp Middleware Middleware } @@ -44,11 +45,17 @@ type Handler func(c *RequestContext) ResponseData type Middleware func(h Handler) Handler func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) { + // Ensure that this regex matches the start of the string + regexStr := regex.String() + if len(regexStr) == 0 || regexStr[0] != '^' { + panic("All routing regexes must begin with '^'") + } + h = rb.Middleware(h) for _, method := range methods { rb.Router.Routes = append(rb.Router.Routes, Route{ Method: method, - Regex: regex, + Regexes: append(rb.Prefixes, regex), Handler: h, }) } @@ -66,33 +73,36 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) { rb.Handle([]string{http.MethodPost}, regex, h) } +func (rb *RouteBuilder) Group(regex *regexp.Regexp, addRoutes func(rb *RouteBuilder)) { + newRb := *rb + newRb.Prefixes = append(newRb.Prefixes, regex) + addRoutes(&newRb) +} + func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - path := req.URL.Path +nextroute: for _, route := range r.Routes { if route.Method != "" && req.Method != route.Method { continue } - path = strings.TrimSuffix(path, "/") - if path == "" { - path = "/" + currentPath := strings.TrimSuffix(req.URL.Path, "/") + if currentPath == "" { + currentPath = "/" } - match := route.Regex.FindStringSubmatch(path) - if match == nil { - continue - } + var params map[string]string + for _, regex := range route.Regexes { - c := &RequestContext{ - Route: route.Regex.String(), - Logger: logging.GlobalLogger(), - Req: req, - Res: rw, - } + match := regex.FindStringSubmatch(currentPath) + if len(match) == 0 { + continue nextroute + } - if len(match) > 0 { - params := map[string]string{} - subexpNames := route.Regex.SubexpNames() + if params == nil { + params = map[string]string{} + } + subexpNames := regex.SubexpNames() for i, paramValue := range match { paramName := subexpNames[i] if paramName == "" { @@ -100,15 +110,35 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } params[paramName] = paramValue } - c.PathParams = params + + // Make sure that we never consume trailing slashes even if the route regex matches them + toConsume := strings.TrimSuffix(match[0], "/") + currentPath = currentPath[len(toConsume):] + if currentPath == "" { + currentPath = "/" + } } + var routeStrings []string + for _, regex := range route.Regexes { + routeStrings = append(routeStrings, regex.String()) + } + + c := &RequestContext{ + Route: fmt.Sprintf("%v", routeStrings), + Logger: logging.GlobalLogger(), + Req: req, + Res: rw, + PathParams: params, + } + c.PathParams = params + doRequest(rw, c, route.Handler) return } - panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", path)) + panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", req.URL)) } type RequestContext struct { diff --git a/src/website/routes.go b/src/website/routes.go index b3df8a51..042c4a85 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -8,6 +8,7 @@ import ( "math/rand" "net/http" "net/url" + "strconv" "strings" "time" @@ -154,14 +155,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe return res }) - anyProject.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData { - if c.CurrentProject.IsHMN() { - return Index(c) - } else { - return ProjectHomepage(c) - } - }) - // NOTE(asaf): HMN-only routes: hmnOnly.GET(hmnurl.RegexManifesto, Manifesto) hmnOnly.GET(hmnurl.RegexAbout, About) @@ -175,14 +168,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexOldHome, Index) - hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login)) // TODO(asaf): Adjust this after launch + hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login)) hmnOnly.GET(hmnurl.RegexLogoutAction, Logout) hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage) hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser) hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit)) hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess) - hmnOnly.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation) hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit) @@ -202,7 +194,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexShowcase, Showcase) hmnOnly.GET(hmnurl.RegexSnippet, Snippet) hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex) - hmnOnly.GET(hmnurl.RegexPersonalProjectHomepage, ProjectHomepage) hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback)) hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink))) @@ -225,36 +216,97 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet) // NOTE(asaf): Any-project routes: - anyProject.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) - anyProject.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit))) - anyProject.GET(hmnurl.RegexForumThread, ForumThread) - anyProject.GET(hmnurl.RegexForum, Forum) - anyProject.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead))) - anyProject.GET(hmnurl.RegexForumPost, ForumPostRedirect) - anyProject.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply)) - anyProject.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit))) - anyProject.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit)) - anyProject.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit))) - anyProject.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) - anyProject.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit))) - anyProject.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect) + attachProjectRoutes := func(rb *RouteBuilder) { + rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData { + if c.CurrentProject.IsHMN() { + return Index(c) + } else { + return ProjectHomepage(c) + } + }) - anyProject.GET(hmnurl.RegexBlog, BlogIndex) - anyProject.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread)) - anyProject.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit))) - anyProject.GET(hmnurl.RegexBlogThread, BlogThread) - anyProject.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread) - anyProject.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply)) - anyProject.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit))) - anyProject.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit)) - anyProject.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit))) - anyProject.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete)) - anyProject.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit))) - anyProject.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData { - return c.Redirect(hmnurl.ProjectUrl( - fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil, - c.CurrentProject.Slug, - ), http.StatusMovedPermanently) + rb.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit))) + rb.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) + rb.GET(hmnurl.RegexForumThread, ForumThread) + rb.GET(hmnurl.RegexForum, Forum) + rb.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead))) + rb.GET(hmnurl.RegexForumPost, ForumPostRedirect) + rb.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply)) + rb.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit))) + rb.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit)) + rb.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit))) + rb.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) + rb.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit))) + rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect) + + rb.GET(hmnurl.RegexBlog, BlogIndex) + rb.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread)) + rb.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit))) + rb.GET(hmnurl.RegexBlogThread, BlogThread) + rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread) + rb.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply)) + rb.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit))) + rb.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit)) + rb.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit))) + rb.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete)) + rb.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit))) + rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData { + return c.Redirect(hmnurl.ProjectUrl( + fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil, + c.CurrentProject.Slug, + ), http.StatusMovedPermanently) + }) + } + hmnOnly.Group(hmnurl.RegexPersonalProject, func(rb *RouteBuilder) { + // TODO(ben): Perhaps someday we can make this middleware modification feel better? It seems + // pretty common to run the outermost middleware first before doing other stuff, but having + // to nest functions this way feels real bad. + rb.Middleware = func(h Handler) Handler { + return hmnOnly.Middleware(func(c *RequestContext) ResponseData { + // At this point we are definitely on the plain old HMN subdomain. + + // Fetch personal project and do whatever + id, err := strconv.Atoi(c.PathParams["projectid"]) + if err != nil { + panic(oops.New(err, "project id was not numeric (bad regex in routing)")) + } + p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{}) + if err != nil { + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project")) + } + } + + if !p.Project.Personal { + // TODO: Redirect to the same page on the other prefix + return c.Redirect(hmnurl.BuildOfficialProjectHomepage(p.Project.Slug), http.StatusSeeOther) + } + + c.CurrentProject = &p.Project + + return h(c) + }) + } + attachProjectRoutes(rb) + }) + anyProject.Group(hmnurl.RegexHomepage, func(rb *RouteBuilder) { + rb.Middleware = func(h Handler) Handler { + return anyProject.Middleware(func(c *RequestContext) ResponseData { + // We could be on any project's subdomain. + + // Check if the current project (matched by subdomain) is actually no longer official + // and therefore needs to be redirected to the personal project version of the route. + if c.CurrentProject.Personal { + // TODO: Redirect to the same page on the other prefix + return c.Redirect(hmnurl.BuildPersonalProject(c.CurrentProject.ID, c.CurrentProject.Slug), http.StatusSeeOther) + } + + return h(c) + }) + } + attachProjectRoutes(rb) }) anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload) @@ -378,15 +430,29 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { slug := strings.TrimRight(hostPrefix, ".") dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{}) - if err != nil { + if err == nil { + c.CurrentProject = &dbProject.Project + } else { if errors.Is(err, db.NotFound) { - return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + // do nothing, this is fine } else { return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) } } - c.CurrentProject = &dbProject.Project + if c.CurrentProject == nil { + dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{ + IncludeHidden: true, + }) + if err != nil { + panic(oops.New(err, "failed to fetch HMN project")) + } + c.CurrentProject = &dbProject.Project + } + + if c.CurrentProject == nil { + panic("failed to load project data") + } } theme := "light" From 6ef391b2e8ef71a85d627ea19fa1bada9c8a3b8f Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 9 Nov 2021 11:23:36 -0800 Subject: [PATCH 04/14] Redirect to generated personal project slug --- src/website/routes.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/website/routes.go b/src/website/routes.go index 042c4a85..a53d73c3 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -284,6 +284,11 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe return c.Redirect(hmnurl.BuildOfficialProjectHomepage(p.Project.Slug), http.StatusSeeOther) } + if c.PathParams["slug"] != models.GeneratePersonalProjectSlug(p.Project.Name) { + // TODO: Redirect to the same page on the other path + return c.Redirect(hmnurl.BuildPersonalProject(p.Project.ID, models.GeneratePersonalProjectSlug(p.Project.Name)), http.StatusSeeOther) + } + c.CurrentProject = &p.Project return h(c) From dfbcfbeacc44176077f3cb6717bfea4e45054cd0 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 9 Nov 2021 20:11:39 -0800 Subject: [PATCH 05/14] Use new UrlContext for project URLs Wow that was a lot to change --- src/hmnurl/hmnurl.go | 44 ++++------ src/hmnurl/urls.go | 140 ++++++++++++++++++++----------- src/templates/mapping.go | 18 +--- src/templates/types.go | 5 +- src/website/admin.go | 2 +- src/website/base_data.go | 16 ++-- src/website/blogs.go | 69 ++++++++------- src/website/breadcrumb_helper.go | 34 ++++---- src/website/episode_guide.go | 11 ++- src/website/feed.go | 4 +- src/website/forums.go | 99 +++++++++++----------- src/website/landing.go | 6 +- src/website/post_helper.go | 40 ++++----- src/website/project_helper.go | 14 ++-- src/website/projects.go | 16 ++-- src/website/requesthandling.go | 1 + src/website/routes.go | 14 ++-- src/website/timeline_helper.go | 6 +- src/website/user.go | 8 +- 19 files changed, 288 insertions(+), 259 deletions(-) diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index 354f4d1f..be701d97 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -6,8 +6,9 @@ import ( "regexp" "time" - "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/models" + + "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/oops" ) @@ -62,34 +63,25 @@ func GetBaseHost() string { return baseUrlParsed.Host } +type UrlContext struct { + PersonalProject bool + ProjectID int + ProjectSlug string + ProjectName string +} + +var HMNProjectContext = UrlContext{ + PersonalProject: false, + ProjectID: models.HMNProjectID, + ProjectSlug: models.HMNProjectSlug, +} + func Url(path string, query []Q) string { - return ProjectUrl(path, query, "") + return UrlWithFragment(path, query, "") } -func ProjectUrl(path string, query []Q, slug string) string { - return ProjectUrlWithFragment(path, query, slug, "") -} - -func ProjectUrlWithFragment(path string, query []Q, slug string, fragment string) string { - subdomain := slug - if slug == models.HMNProjectSlug { - subdomain = "" - } - - host := baseUrlParsed.Host - if len(subdomain) > 0 { - host = slug + "." + host - } - - url := url.URL{ - Scheme: baseUrlParsed.Scheme, - Host: host, - Path: trim(path), - RawQuery: encodeQuery(query), - Fragment: fragment, - } - - return url.String() +func UrlWithFragment(path string, query []Q, fragment string) string { + return HMNProjectContext.UrlWithFragment(path, query, fragment) } func trim(path string) string { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 3975b3cf..2d269ab4 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -21,12 +21,11 @@ var RegexOldHome = regexp.MustCompile("^/home$") var RegexHomepage = regexp.MustCompile("^/$") func BuildHomepage() string { - return Url("/", nil) + return HMNProjectContext.BuildHomepage() } -func BuildOfficialProjectHomepage(projectSlug string) string { - defer CatchPanic() - return ProjectUrl("/", nil, projectSlug) +func (c *UrlContext) BuildHomepage() string { + return c.Url("/", nil) } var RegexShowcase = regexp.MustCompile("^/showcase$") @@ -196,7 +195,7 @@ func BuildUserProfile(username string) string { var RegexUserSettings = regexp.MustCompile(`^/settings$`) func BuildUserSettings(section string) string { - return ProjectUrlWithFragment("/settings", nil, "", section) + return UrlWithFragment("/settings", nil, section) } /* @@ -302,10 +301,10 @@ func BuildPersonalProject(id int, slug string) string { var RegexProjectEdit = regexp.MustCompile("^/edit$") -func BuildProjectEdit(slug string, section string) string { +func (c *UrlContext) BuildProjectEdit(section string) string { defer CatchPanic() - return ProjectUrlWithFragment(fmt.Sprintf("/p/%s/edit", slug), nil, "", section) + return c.UrlWithFragment("/edit", nil, section) } /* @@ -367,7 +366,50 @@ func BuildPodcastEpisodeFile(filename string) string { // Make sure to match Thread before Subforum in the router. var RegexForum = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?(/(?P\d+))?$`) -func BuildForum(projectSlug string, subforums []string, page int) string { +func (c *UrlContext) Url(path string, query []Q) string { + return c.UrlWithFragment(path, query, "") +} + +func (c *UrlContext) UrlWithFragment(path string, query []Q, fragment string) string { + if c == nil { + logging.Warn().Stack().Msg("URL context was nil; defaulting to the HMN URL context") + c = &HMNProjectContext + } + + if c.PersonalProject { + url := url.URL{ + Scheme: baseUrlParsed.Scheme, + Host: baseUrlParsed.Host, + Path: fmt.Sprintf("p/%d/%s/%s", c.ProjectID, models.GeneratePersonalProjectSlug(c.ProjectName), trim(path)), + RawQuery: encodeQuery(query), + Fragment: fragment, + } + + return url.String() + } else { + subdomain := c.ProjectSlug + if c.ProjectSlug == models.HMNProjectSlug { + subdomain = "" + } + + host := baseUrlParsed.Host + if len(subdomain) > 0 { + host = c.ProjectSlug + "." + host + } + + url := url.URL{ + Scheme: baseUrlParsed.Scheme, + Host: host, + Path: trim(path), + RawQuery: encodeQuery(query), + Fragment: fragment, + } + + return url.String() + } +} + +func (c *UrlContext) BuildForum(subforums []string, page int) string { defer CatchPanic() if page < 1 { panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) @@ -380,13 +422,13 @@ func BuildForum(projectSlug string, subforums []string, page int) string { builder.WriteString(strconv.Itoa(page)) } - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/new$`) var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/new/submit$`) -func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string { +func (c *UrlContext) BuildForumNewThread(subforums []string, submit bool) string { defer CatchPanic() builder := buildSubforumPath(subforums) builder.WriteString("/t/new") @@ -394,59 +436,59 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st builder.WriteString("/submit") } - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)(-([^/]+))?(/(?P\d+))?$`) -func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string { +func (c *UrlContext) BuildForumThread(subforums []string, threadId int, title string, page int) string { defer CatchPanic() builder := buildForumThreadPath(subforums, threadId, title, page) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } -func BuildForumThreadWithPostHash(projectSlug string, subforums []string, threadId int, title string, page int, postId int) string { +func (c *UrlContext) BuildForumThreadWithPostHash(subforums []string, threadId int, title string, page int, postId int) string { defer CatchPanic() builder := buildForumThreadPath(subforums, threadId, title, page) - return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) + return UrlWithFragment(builder.String(), nil, strconv.Itoa(postId)) } var RegexForumPost = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)$`) -func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPost(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/delete$`) -func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPostDelete(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) builder.WriteString("/delete") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/edit$`) -func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPostEdit(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) builder.WriteString("/edit") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/reply$`) -func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPostReply(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) builder.WriteString("/reply") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P\d+)(-([^/]+))?$`) @@ -459,7 +501,7 @@ var RegexBlogsRedirect = regexp.MustCompile(`^/blogs(?P.*)`) var RegexBlog = regexp.MustCompile(`^/blog(/(?P\d+))?$`) -func BuildBlog(projectSlug string, page int) string { +func (c *UrlContext) BuildBlog(page int) string { defer CatchPanic() if page < 1 { panic(oops.New(nil, "Invalid blog page (%d), must be >= 1", page)) @@ -470,63 +512,63 @@ func BuildBlog(projectSlug string, page int) string { path += "/" + strconv.Itoa(page) } - return ProjectUrl(path, nil, projectSlug) + return c.Url(path, nil) } var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P\d+)(-([^/]+))?$`) -func BuildBlogThread(projectSlug string, threadId int, title string) string { +func (c *UrlContext) BuildBlogThread(threadId int, title string) string { defer CatchPanic() builder := buildBlogThreadPath(threadId, title) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } -func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, postId int) string { +func (c *UrlContext) BuildBlogThreadWithPostHash(threadId int, title string, postId int) string { defer CatchPanic() builder := buildBlogThreadPath(threadId, title) - return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) + return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId)) } var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`) -func BuildBlogNewThread(projectSlug string) string { +func (c *UrlContext) BuildBlogNewThread() string { defer CatchPanic() - return ProjectUrl("/blog/new", nil, projectSlug) + return c.Url("/blog/new", nil) } var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)$`) -func BuildBlogPost(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPost(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexBlogPostDelete = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)/delete$`) -func BuildBlogPostDelete(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPostDelete(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) builder.WriteString("/delete") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexBlogPostEdit = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)/edit$`) -func BuildBlogPostEdit(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPostEdit(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) builder.WriteString("/edit") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexBlogPostReply = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)/reply$`) -func BuildBlogPostReply(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) builder.WriteString("/reply") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } /* @@ -580,7 +622,7 @@ func BuildLibraryResource(resourceId int) string { var RegexEpisodeList = regexp.MustCompile(`^/episode(/(?P[^/]+))?$`) -func BuildEpisodeList(projectSlug string, topic string) string { +func (c *UrlContext) BuildEpisodeList(topic string) string { defer CatchPanic() var builder strings.Builder @@ -589,21 +631,21 @@ func BuildEpisodeList(projectSlug string, topic string) string { builder.WriteString("/") builder.WriteString(topic) } - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexEpisode = regexp.MustCompile(`^/episode/(?P[^/]+)/(?P[^/]+)$`) -func BuildEpisode(projectSlug string, topic string, episode string) string { +func (c *UrlContext) BuildEpisode(topic string, episode string) string { defer CatchPanic() - return ProjectUrl(fmt.Sprintf("/episode/%s/%s", topic, episode), nil, projectSlug) + return c.Url(fmt.Sprintf("/episode/%s/%s", topic, episode), nil) } var RegexCineraIndex = regexp.MustCompile(`^/(?P[^/]+).index$`) -func BuildCineraIndex(projectSlug string, topic string) string { +func (c *UrlContext) BuildCineraIndex(topic string) string { defer CatchPanic() - return ProjectUrl(fmt.Sprintf("/%s.index", topic), nil, projectSlug) + return c.Url(fmt.Sprintf("/%s.index", topic), nil) } /* @@ -635,8 +677,8 @@ func BuildDiscordShowcaseBacklog() string { var RegexAssetUpload = regexp.MustCompile("^/upload_asset$") // NOTE(asaf): Providing the projectSlug avoids any CORS problems. -func BuildAssetUpload(projectSlug string) string { - return ProjectUrl("/upload_asset", nil, projectSlug) +func (c *UrlContext) BuildAssetUpload() string { + return c.Url("/upload_asset", nil) } /* @@ -715,7 +757,7 @@ func BuildUserFile(filepath string) string { var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P\d+)$`) // NOTE(asaf): subforumId == 0 means ALL SUBFORUMS -func BuildForumMarkRead(projectSlug string, subforumId int) string { +func (c *UrlContext) BuildForumMarkRead(subforumId int) string { defer CatchPanic() if subforumId < 0 { panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId)) @@ -725,7 +767,7 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string { builder.WriteString("/markread/") builder.WriteString(strconv.Itoa(subforumId)) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexCatchAll = regexp.MustCompile("^") diff --git a/src/templates/mapping.go b/src/templates/mapping.go index e6d18d4c..364f4abd 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -59,22 +59,11 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ models.ProjectLifecycleLTS: "Complete", } -func ProjectUrl(p *models.Project) string { - var url string - if p.Personal { - url = hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name)) - } else { - url = hmnurl.BuildOfficialProjectHomepage(p.Slug) - } - return url -} - -func ProjectToTemplate(p *models.Project, theme string) Project { +func ProjectToTemplate(p *models.Project, url string, theme string) Project { logo := p.LogoLight if theme == "dark" { logo = p.LogoDark } - url := ProjectUrl(p) return Project{ Name: p.Name, Subdomain: p.Subdomain(), @@ -91,9 +80,8 @@ func ProjectToTemplate(p *models.Project, theme string) Project { IsHMN: p.IsHMN(), - HasBlog: p.BlogEnabled, - HasForum: p.ForumEnabled, - HasLibrary: false, // TODO: port the library lol + HasBlog: p.BlogEnabled, + HasForum: p.ForumEnabled, DateApproved: p.DateApproved, } diff --git a/src/templates/types.go b/src/templates/types.go index 50a358e1..14c25aed 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -126,9 +126,8 @@ type Project struct { IsHMN bool - HasBlog bool - HasForum bool - HasLibrary bool + HasBlog bool + HasForum bool UUID string DateApproved time.Time diff --git a/src/website/admin.go b/src/website/admin.go index b2f58385..2dd6b734 100644 --- a/src/website/admin.go +++ b/src/website/admin.go @@ -137,7 +137,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData { for _, p := range posts { post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme) post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here - post.Url = UrlForGenericPost(&p.Thread, &p.Post, lineageBuilder, p.Project.Slug) + post.Url = UrlForGenericPost(UrlContextForProject(&p.Project), &p.Thread, &p.Post, lineageBuilder) data.Posts = append(data.Posts, postWithTitle{ Post: post, Title: p.Thread.Title, diff --git a/src/website/base_data.go b/src/website/base_data.go index 794f20c7..eb767ed0 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -26,7 +26,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc notices := getNoticesFromCookie(c) if len(breadcrumbs) > 0 { - projectUrl := UrlForProject(c.CurrentProject) + projectUrl := c.UrlContext.BuildHomepage() if breadcrumbs[0].Url != projectUrl { rootBreadcrumb := templates.Breadcrumb{ Name: c.CurrentProject.Name, @@ -42,11 +42,11 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc Breadcrumbs: breadcrumbs, CurrentUrl: c.FullUrl(), - CurrentProjectUrl: UrlForProject(c.CurrentProject), + CurrentProjectUrl: c.UrlContext.BuildHomepage(), LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1), - Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme), + Project: templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme), User: templateUser, Session: templateSession, Notices: notices, @@ -67,7 +67,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc HMNHomepageUrl: hmnurl.BuildHomepage(), ProjectIndexUrl: hmnurl.BuildProjectIndex(1), PodcastUrl: hmnurl.BuildPodcast(), - ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1), + ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1), LibraryUrl: hmnurl.BuildLibrary(), }, Footer: templates.Footer{ @@ -77,7 +77,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc CodeOfConductUrl: hmnurl.BuildCodeOfConduct(), CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(), ProjectIndexUrl: hmnurl.BuildProjectIndex(1), - ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1), + ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1), ContactUrl: hmnurl.BuildContactPage(), }, } @@ -90,15 +90,15 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc episodeGuideUrl := "" defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug] if hasAnnotations { - episodeGuideUrl = hmnurl.BuildEpisodeList(c.CurrentProject.Slug, defaultTopic) + episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic) } baseData.Header.Project = &templates.ProjectHeader{ HasForums: c.CurrentProject.ForumEnabled, HasBlog: c.CurrentProject.BlogEnabled, HasEpisodeGuide: hasAnnotations, - ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1), - BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1), + ForumsUrl: c.UrlContext.BuildForum(nil, 1), + BlogUrl: c.UrlContext.BuildBlog(1), EpisodeGuideUrl: episodeGuideUrl, } } diff --git a/src/website/blogs.go b/src/website/blogs.go index df76def5..9b8a666b 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -46,7 +46,7 @@ func BlogIndex(c *RequestContext) ResponseData { numPages := utils.NumPages(numPosts, postsPerPage) page, ok := ParsePageNumber(c, "page", numPages) if !ok { - c.Redirect(hmnurl.BuildBlog(c.CurrentProject.Slug, page), http.StatusSeeOther) + c.Redirect(c.UrlContext.BuildBlog(page), http.StatusSeeOther) } threads, err := FetchThreads(c.Context(), c.Conn, c.CurrentUser, ThreadsQuery{ @@ -63,14 +63,14 @@ func BlogIndex(c *RequestContext) ResponseData { for _, thread := range threads { entries = append(entries, blogIndexEntry{ Title: thread.Thread.Title, - Url: hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.Thread.ID, thread.Thread.Title), + Url: c.UrlContext.BuildBlogThread(thread.Thread.ID, thread.Thread.Title), Author: templates.UserToTemplate(thread.FirstPostAuthor, c.Theme), Date: thread.FirstPost.PostDate, Content: template.HTML(thread.FirstPostCurrentVersion.TextParsed), }) } - baseData := getBaseData(c, fmt.Sprintf("%s Blog", c.CurrentProject.Name), []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)}) + baseData := getBaseData(c, fmt.Sprintf("%s Blog", c.CurrentProject.Name), []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)}) canCreate := false if c.CurrentUser != nil { @@ -97,14 +97,14 @@ func BlogIndex(c *RequestContext) ResponseData { Current: page, Total: numPages, - FirstUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1), - LastUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, numPages), - PreviousUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page-1, numPages)), - NextUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page+1, numPages)), + FirstUrl: c.UrlContext.BuildBlog(1), + LastUrl: c.UrlContext.BuildBlog(numPages), + PreviousUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page-1, numPages)), + NextUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page+1, numPages)), }, CanCreatePost: canCreate, - NewPostUrl: hmnurl.BuildBlogNewThread(c.CurrentProject.Slug), + NewPostUrl: c.UrlContext.BuildBlogNewThread(), }, c.Perf) return res } @@ -138,11 +138,11 @@ func BlogThread(c *RequestContext) ResponseData { for _, p := range posts { post := templates.PostToTemplate(&p.Post, p.Author, c.Theme) post.AddContentVersion(p.CurrentVersion, p.Editor) - addBlogUrlsToPost(&post, c.CurrentProject.Slug, &p.Thread, p.Post.ID) + addBlogUrlsToPost(c.UrlContext, &post, &p.Thread, p.Post.ID) if p.ReplyPost != nil { reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme) - addBlogUrlsToPost(&reply, c.CurrentProject.Slug, &p.Thread, p.Post.ID) + addBlogUrlsToPost(c.UrlContext, &reply, &p.Thread, p.Post.ID) post.ReplyPost = &reply } @@ -168,7 +168,7 @@ func BlogThread(c *RequestContext) ResponseData { } } - baseData := getBaseData(c, thread.Title, []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)}) + baseData := getBaseData(c, thread.Title, []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)}) baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{ Property: "og:description", Value: posts[0].Post.Preview, @@ -180,7 +180,7 @@ func BlogThread(c *RequestContext) ResponseData { Thread: templates.ThreadToTemplate(&thread), MainPost: templatePosts[0], Comments: templatePosts[1:], - ReplyLink: hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, posts[0].Post.ID), + ReplyLink: c.UrlContext.BuildBlogPostReply(cd.ThreadID, posts[0].Post.ID), LoginLink: hmnurl.BuildLoginPage(c.FullUrl()), }, c.Perf) return res @@ -202,7 +202,7 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread for blog redirect")) } - threadUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, thread.Thread.Title, cd.PostID) + threadUrl := c.UrlContext.BuildBlogThreadWithPostHash(cd.ThreadID, thread.Thread.Title, cd.PostID) return c.Redirect(threadUrl, http.StatusFound) } @@ -210,11 +210,11 @@ func BlogNewThread(c *RequestContext) ResponseData { baseData := getBaseData( c, fmt.Sprintf("Create New Post | %s", c.CurrentProject.Name), - []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)}, + []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)}, ) - editData := getEditorDataForNew(c.CurrentUser, baseData, nil) - editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug) + editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil) + editData.SubmitUrl = c.UrlContext.BuildBlogNewThread() editData.SubmitLabel = "Create Post" var res ResponseData @@ -268,7 +268,7 @@ func BlogNewThreadSubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post")) } - newThreadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, threadId, title) + newThreadUrl := c.UrlContext.BuildBlogThread(threadId, title) return c.Redirect(newThreadUrl, http.StatusSeeOther) } @@ -301,11 +301,11 @@ func BlogPostEdit(c *RequestContext) ResponseData { baseData := getBaseData( c, title, - BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread), + BlogThreadBreadcrumbs(c.UrlContext, &post.Thread), ) - editData := getEditorDataForEdit(c.CurrentUser, baseData, post) - editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID) + editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post) + editData.SubmitUrl = c.UrlContext.BuildBlogPostEdit(cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Edited Post" if post.Thread.FirstID != post.Post.ID { editData.SubmitLabel = "Submit Edited Comment" @@ -373,7 +373,7 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit blog post")) } - postUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, post.Thread.Title, cd.PostID) + postUrl := c.UrlContext.BuildBlogThreadWithPostHash(cd.ThreadID, post.Thread.Title, cd.PostID) return c.Redirect(postUrl, http.StatusSeeOther) } @@ -396,14 +396,14 @@ func BlogPostReply(c *RequestContext) ResponseData { baseData := getBaseData( c, fmt.Sprintf("Replying to comment in \"%s\" | %s", post.Thread.Title, c.CurrentProject.Name), - BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread), + BlogThreadBreadcrumbs(c.UrlContext, &post.Thread), ) replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme) replyPost.AddContentVersion(post.CurrentVersion, post.Editor) - editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost) - editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID) + editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost) + editData.SubmitUrl = c.UrlContext.BuildBlogPostReply(cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Reply" var res ResponseData @@ -439,7 +439,7 @@ func BlogPostReplySubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post")) } - newPostUrl := hmnurl.BuildBlogPost(c.CurrentProject.Slug, cd.ThreadID, newPostId) + newPostUrl := c.UrlContext.BuildBlogPost(cd.ThreadID, newPostId) return c.Redirect(newPostUrl, http.StatusSeeOther) } @@ -472,7 +472,7 @@ func BlogPostDelete(c *RequestContext) ResponseData { baseData := getBaseData( c, title, - BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread), + BlogThreadBreadcrumbs(c.UrlContext, &post.Thread), ) templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme) @@ -487,7 +487,7 @@ func BlogPostDelete(c *RequestContext) ResponseData { var res ResponseData res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{ BaseData: baseData, - SubmitUrl: hmnurl.BuildBlogPostDelete(c.CurrentProject.Slug, cd.ThreadID, cd.PostID), + SubmitUrl: c.UrlContext.BuildBlogPostDelete(cd.ThreadID, cd.PostID), Post: templatePost, }, c.Perf) return res @@ -517,8 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData { } if threadDeleted { - projectUrl := UrlForProject(c.CurrentProject) - return c.Redirect(projectUrl, http.StatusSeeOther) + return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther) } else { thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{ ProjectIDs: []int{c.CurrentProject.ID}, @@ -529,7 +528,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData { } else if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread after blog post delete")) } - threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.Thread.ID, thread.Thread.Title) + threadUrl := c.UrlContext.BuildBlogThread(thread.Thread.ID, thread.Thread.Title) return c.Redirect(threadUrl, http.StatusSeeOther) } } @@ -608,9 +607,9 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) { return res, true } -func addBlogUrlsToPost(p *templates.Post, projectSlug string, thread *models.Thread, postId int) { - p.Url = hmnurl.BuildBlogThreadWithPostHash(projectSlug, thread.ID, thread.Title, postId) - p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, thread.ID, postId) - p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, thread.ID, postId) - p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, thread.ID, postId) +func addBlogUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, thread *models.Thread, postId int) { + p.Url = urlContext.BuildBlogThreadWithPostHash(thread.ID, thread.Title, postId) + p.DeleteUrl = urlContext.BuildBlogPostDelete(thread.ID, postId) + p.EditUrl = urlContext.BuildBlogPostEdit(thread.ID, postId) + p.ReplyUrl = urlContext.BuildBlogPostReply(thread.ID, postId) } diff --git a/src/website/breadcrumb_helper.go b/src/website/breadcrumb_helper.go index b40ace78..ad823e4d 100644 --- a/src/website/breadcrumb_helper.go +++ b/src/website/breadcrumb_helper.go @@ -6,58 +6,58 @@ import ( "git.handmade.network/hmn/hmn/src/templates" ) -func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb { +func ProjectBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb { return templates.Breadcrumb{ - Name: project.Name, - Url: UrlForProject(project), + Name: projectUrlContext.ProjectName, + Url: projectUrlContext.BuildHomepage(), } } -func ForumBreadcrumb(projectSlug string) templates.Breadcrumb { +func ForumBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb { return templates.Breadcrumb{ Name: "Forums", - Url: hmnurl.BuildForum(projectSlug, nil, 1), + Url: projectUrlContext.BuildForum(nil, 1), } } -func SubforumBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, subforumID int) []templates.Breadcrumb { +func SubforumBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, subforumID int) []templates.Breadcrumb { var result []templates.Breadcrumb result = []templates.Breadcrumb{ - ProjectBreadcrumb(project), - ForumBreadcrumb(project.Slug), + ProjectBreadcrumb(projectUrlContext), + ForumBreadcrumb(projectUrlContext), } subforums := lineageBuilder.GetSubforumLineage(subforumID) slugs := lineageBuilder.GetSubforumLineageSlugs(subforumID) for i, subforum := range subforums { result = append(result, templates.Breadcrumb{ Name: subforum.Name, - Url: hmnurl.BuildForum(project.Slug, slugs[0:i+1], 1), + Url: projectUrlContext.BuildForum(slugs[0:i+1], 1), }) } return result } -func ForumThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb { - result := SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID) +func ForumThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb { + result := SubforumBreadcrumbs(projectUrlContext, lineageBuilder, *thread.SubforumID) result = append(result, templates.Breadcrumb{ Name: thread.Title, - Url: hmnurl.BuildForumThread(project.Slug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1), + Url: projectUrlContext.BuildForumThread(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1), }) return result } -func BlogBreadcrumb(projectSlug string) templates.Breadcrumb { +func BlogBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb { return templates.Breadcrumb{ Name: "Blog", - Url: hmnurl.BuildBlog(projectSlug, 1), + Url: projectUrlContext.BuildBlog(1), } } -func BlogThreadBreadcrumbs(projectSlug string, thread *models.Thread) []templates.Breadcrumb { +func BlogThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, thread *models.Thread) []templates.Breadcrumb { result := []templates.Breadcrumb{ - BlogBreadcrumb(projectSlug), - {Name: thread.Title, Url: hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)}, + BlogBreadcrumb(projectUrlContext), + {Name: thread.Title, Url: projectUrlContext.BuildBlogThread(thread.ID, thread.Title)}, } return result } diff --git a/src/website/episode_guide.go b/src/website/episode_guide.go index 76cba499..23305a7f 100644 --- a/src/website/episode_guide.go +++ b/src/website/episode_guide.go @@ -11,7 +11,6 @@ import ( "strings" "git.handmade.network/hmn/hmn/src/config" - "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/templates" ) @@ -53,11 +52,11 @@ func EpisodeList(c *RequestContext) ResponseData { defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug] if !hasEpisodeGuide { - return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther) + return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther) } if topic == "" { - return c.Redirect(hmnurl.BuildEpisodeList(slug, defaultTopic), http.StatusSeeOther) + return c.Redirect(c.UrlContext.BuildEpisodeList(defaultTopic), http.StatusSeeOther) } allTopics, foundTopic := topicsForProject(slug, topic) @@ -82,7 +81,7 @@ func EpisodeList(c *RequestContext) ResponseData { for _, t := range allTopics { url := "" if t != foundTopic { - url = hmnurl.BuildEpisodeList(slug, t) + url = c.UrlContext.BuildEpisodeList(t) } topicLinks = append(topicLinks, templates.Link{LinkText: t, Url: url}) } @@ -114,7 +113,7 @@ func Episode(c *RequestContext) ResponseData { _, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug] if !hasEpisodeGuide { - return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther) + return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther) } _, foundTopic := topicsForProject(slug, topic) @@ -150,7 +149,7 @@ func Episode(c *RequestContext) ResponseData { baseData := getBaseData( c, title, - []templates.Breadcrumb{{Name: "Episode Guide", Url: hmnurl.BuildEpisodeList(c.CurrentProject.Slug, foundTopic)}}, + []templates.Breadcrumb{{Name: "Episode Guide", Url: c.UrlContext.BuildEpisodeList(foundTopic)}}, ) res.MustWriteTemplate("episode.html", EpisodeData{ BaseData: baseData, diff --git a/src/website/feed.go b/src/website/feed.go index 5d6f1663..5250d478 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -71,7 +71,7 @@ func Feed(c *RequestContext) ResponseData { BaseData: baseData, AtomFeedUrl: hmnurl.BuildAtomFeed(), - MarkAllReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, 0), + MarkAllReadUrl: c.UrlContext.BuildForumMarkRead(0), Posts: posts, Pagination: pagination, }, c.Perf) @@ -181,7 +181,7 @@ func AtomFeed(c *RequestContext) ResponseData { projectMap := make(map[int]int) // map[project id]index in slice for _, p := range projects.ToSlice() { project := p.(*projectResult).Project - templateProject := templates.ProjectToTemplate(&project, c.Theme) + templateProject := templates.ProjectToTemplate(&project, UrlContextForProject(&project).BuildHomepage(), c.Theme) templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN() projectIds = append(projectIds, project.ID) diff --git a/src/website/forums.go b/src/website/forums.go index 190d7822..efc68db1 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -35,8 +35,6 @@ type forumSubforumData struct { TotalThreads int } -type editActionType string - type editorData struct { templates.BaseData SubmitUrl string @@ -54,13 +52,13 @@ type editorData struct { UploadUrl string } -func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData { +func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData { result := editorData{ BaseData: baseData, CanEditTitle: replyPost == nil, PostReplyingTo: replyPost, MaxFileSize: AssetMaxSize(currentUser), - UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain), + UploadUrl: urlContext.BuildAssetUpload(), } if replyPost != nil { @@ -70,7 +68,7 @@ func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData, return result } -func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData, p PostAndStuff) editorData { +func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p PostAndStuff) editorData { return editorData{ BaseData: baseData, Title: p.Thread.Title, @@ -78,7 +76,7 @@ func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData, IsEditing: true, EditInitialContents: p.CurrentVersion.TextRaw, MaxFileSize: AssetMaxSize(currentUser), - UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain), + UploadUrl: urlContext.BuildAssetUpload(), } } @@ -104,7 +102,7 @@ func Forum(c *RequestContext) ResponseData { numPages := utils.NumPages(numThreads, threadsPerPage) page, ok := ParsePageNumber(c, "page", numPages) if !ok { - c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, page), http.StatusSeeOther) + c.Redirect(c.UrlContext.BuildForum(currentSubforumSlugs, page), http.StatusSeeOther) } howManyThreadsToSkip := (page - 1) * threadsPerPage @@ -119,7 +117,7 @@ func Forum(c *RequestContext) ResponseData { makeThreadListItem := func(row ThreadAndStuff) templates.ThreadListItem { return templates.ThreadListItem{ Title: row.Thread.Title, - Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1), + Url: c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1), FirstUser: templates.UserToTemplate(row.FirstPostAuthor, c.Theme), FirstDate: row.FirstPost.PostDate, LastUser: templates.UserToTemplate(row.LastPostAuthor, c.Theme), @@ -165,7 +163,7 @@ func Forum(c *RequestContext) ResponseData { subforums = append(subforums, forumSubforumData{ Name: sfNode.Name, - Url: hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1), + Url: c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1), Threads: threads, TotalThreads: numThreads, }) @@ -179,23 +177,23 @@ func Forum(c *RequestContext) ResponseData { baseData := getBaseData( c, fmt.Sprintf("%s Forums", c.CurrentProject.Name), - SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID), + SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID), ) var res ResponseData res.MustWriteTemplate("forum.html", forumData{ BaseData: baseData, - NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false), - MarkReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, cd.SubforumID), + NewThreadUrl: c.UrlContext.BuildForumNewThread(currentSubforumSlugs, false), + MarkReadUrl: c.UrlContext.BuildForumMarkRead(cd.SubforumID), Threads: threads, Pagination: templates.Pagination{ Current: page, Total: numPages, - FirstUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), - LastUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages), - NextUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)), - PreviousUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)), + FirstUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1), + LastUrl: c.UrlContext.BuildForum(currentSubforumSlugs, numPages), + NextUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)), + PreviousUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)), }, Subforums: subforums, }, c.Perf) @@ -308,7 +306,7 @@ func ForumMarkRead(c *RequestContext) ResponseData { if sfId == 0 { redirUrl = hmnurl.BuildFeed() } else { - redirUrl = hmnurl.BuildForum(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(sfId), 1) + redirUrl = c.UrlContext.BuildForum(lineageBuilder.GetSubforumLineageSlugs(sfId), 1) } return c.Redirect(redirUrl, http.StatusSeeOther) } @@ -358,17 +356,17 @@ func ForumThread(c *RequestContext) ResponseData { } page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadPostsPerPage) if !ok { - urlNoPage := hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1) + urlNoPage := c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1) return c.Redirect(urlNoPage, http.StatusSeeOther) } pagination := templates.Pagination{ Current: page, Total: numPages, - FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1), - LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages), - NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)), - PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)), + FirstUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1), + LastUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, numPages), + NextUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)), + PreviousUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)), } postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{ @@ -385,11 +383,11 @@ func ForumThread(c *RequestContext) ResponseData { for _, p := range postsAndStuff { post := templates.PostToTemplate(&p.Post, p.Author, c.Theme) post.AddContentVersion(p.CurrentVersion, p.Editor) - addForumUrlsToPost(&post, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID) + addForumUrlsToPost(c.UrlContext, &post, currentSubforumSlugs, thread.ID, post.ID) if p.ReplyPost != nil { reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme) - addForumUrlsToPost(&reply, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, reply.ID) + addForumUrlsToPost(c.UrlContext, &reply, currentSubforumSlugs, thread.ID, reply.ID) post.ReplyPost = &reply } @@ -418,7 +416,7 @@ func ForumThread(c *RequestContext) ResponseData { } } - baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID)) + baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID)) baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{ Property: "og:description", Value: threadResult.FirstPost.Preview, @@ -429,8 +427,8 @@ func ForumThread(c *RequestContext) ResponseData { BaseData: baseData, Thread: templates.ThreadToTemplate(&thread), Posts: posts, - SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), - ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.FirstID), + SubforumUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1), + ReplyUrl: c.UrlContext.BuildForumPostReply(currentSubforumSlugs, thread.ID, thread.FirstID), Pagination: pagination, }, c.Perf) return res @@ -466,8 +464,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData { page := (postIdx / threadPostsPerPage) + 1 - return c.Redirect(hmnurl.BuildForumThreadWithPostHash( - c.CurrentProject.Slug, + return c.Redirect(c.UrlContext.BuildForumThreadWithPostHash( cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, post.Thread.Title, @@ -482,9 +479,9 @@ func ForumNewThread(c *RequestContext) ResponseData { return FourOhFour(c) } - baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID)) - editData := getEditorDataForNew(c.CurrentUser, baseData, nil) - editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true) + baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID)) + editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil) + editData.SubmitUrl = c.UrlContext.BuildForumNewThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true) editData.SubmitLabel = "Post New Thread" var res ResponseData @@ -549,7 +546,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread")) } - newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1) + newThreadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1) return c.Redirect(newThreadUrl, http.StatusSeeOther) } @@ -572,14 +569,14 @@ func ForumPostReply(c *RequestContext) ResponseData { baseData := getBaseData( c, fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name), - ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread), + ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread), ) replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme) replyPost.AddContentVersion(post.CurrentVersion, post.Editor) - editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost) - editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) + editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost) + editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Reply" var res ResponseData @@ -629,7 +626,7 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post")) } - newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId) + newPostUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId) return c.Redirect(newPostUrl, http.StatusSeeOther) } @@ -659,10 +656,10 @@ func ForumPostEdit(c *RequestContext) ResponseData { } else { title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name) } - baseData := getBaseData(c, title, ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread)) + baseData := getBaseData(c, title, ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread)) - editData := getEditorDataForEdit(c.CurrentUser, baseData, post) - editData.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) + editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post) + editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Edited Post" var res ResponseData @@ -727,7 +724,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post")) } - postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) + postUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) return c.Redirect(postUrl, http.StatusSeeOther) } @@ -754,7 +751,7 @@ func ForumPostDelete(c *RequestContext) ResponseData { baseData := getBaseData( c, fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[cd.SubforumID].Name), - ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread), + ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread), ) templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme) @@ -769,7 +766,7 @@ func ForumPostDelete(c *RequestContext) ResponseData { var res ResponseData res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{ BaseData: baseData, - SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID), + SubmitUrl: c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID), Post: templatePost, }, c.Perf) return res @@ -799,10 +796,10 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData { } if threadDeleted { - forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1) + forumUrl := c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1) return c.Redirect(forumUrl, http.StatusSeeOther) } else { - threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted? + threadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted? return c.Redirect(threadUrl, http.StatusSeeOther) } } @@ -829,7 +826,7 @@ func WikiArticleRedirect(c *RequestContext) ResponseData { lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree) c.Perf.EndBlock() - dest := UrlForGenericThread(&thread.Thread, lineageBuilder, c.CurrentProject.Slug) + dest := UrlForGenericThread(c.UrlContext, &thread.Thread, lineageBuilder) return c.Redirect(dest, http.StatusFound) } @@ -928,11 +925,11 @@ func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *m return subforumId, valid } -func addForumUrlsToPost(p *templates.Post, projectSlug string, subforums []string, threadId int, postId int) { - p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId) - p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId) - p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId) - p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId) +func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subforums []string, threadId int, postId int) { + p.Url = urlContext.BuildForumPost(subforums, threadId, postId) + p.DeleteUrl = urlContext.BuildForumPostDelete(subforums, threadId, postId) + p.EditUrl = urlContext.BuildForumPostEdit(subforums, threadId, postId) + p.ReplyUrl = urlContext.BuildForumPostReply(subforums, threadId, postId) } // Takes a template post and adds information about how many posts the user has made diff --git a/src/website/landing.go b/src/website/landing.go index 600daf69..188655d6 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -74,7 +74,7 @@ func Index(c *RequestContext) ResponseData { c.Logger.Warn().Err(err).Msg("failed to fetch latest posts") } for _, p := range posts { - item := PostToTimelineItem(lineageBuilder, &p.Post, &p.Thread, &p.Project, p.Author, c.Theme) + item := PostToTimelineItem(UrlContextForProject(&p.Project), lineageBuilder, &p.Post, &p.Thread, p.Author, c.Theme) if p.Thread.Type == models.ThreadTypeProjectBlogPost && p.Post.ID == p.Thread.FirstID { // blog post item.Description = template.HTML(p.CurrentVersion.TextParsed) @@ -95,7 +95,7 @@ func Index(c *RequestContext) ResponseData { var newsPostItem *templates.TimelineItem if len(newsThreads) > 0 { t := newsThreads[0] - item := PostToTimelineItem(lineageBuilder, &t.FirstPost, &t.Thread, &t.Project, t.FirstPostAuthor, c.Theme) + item := PostToTimelineItem(UrlContextForProject(&t.Project), lineageBuilder, &t.FirstPost, &t.Thread, t.FirstPostAuthor, c.Theme) item.OwnerAvatarUrl = "" item.Breadcrumbs = nil item.TypeTitle = "" @@ -167,7 +167,7 @@ func Index(c *RequestContext) ResponseData { StreamsUrl: hmnurl.BuildStreams(), ShowcaseUrl: hmnurl.BuildShowcase(), AtomFeedUrl: hmnurl.BuildAtomFeed(), - MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0), + MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0), WheelJamUrl: hmnurl.BuildJamIndex(), }, c.Perf) diff --git a/src/website/post_helper.go b/src/website/post_helper.go index c60c148b..538b653e 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -7,26 +7,26 @@ import ( ) // NOTE(asaf): Please don't use these if you already know the kind of the thread beforehand. Just call the appropriate build function. -func UrlForGenericThread(thread *models.Thread, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string { +func UrlForGenericThread(urlContext *hmnurl.UrlContext, thread *models.Thread, lineageBuilder *models.SubforumLineageBuilder) string { switch thread.Type { case models.ThreadTypeProjectBlogPost: - return hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title) + return urlContext.BuildBlogThread(thread.ID, thread.Title) case models.ThreadTypeForumPost: - return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1) + return urlContext.BuildForumThread(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1) } - return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects + return urlContext.BuildHomepage() } -func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string { +func UrlForGenericPost(urlContext *hmnurl.UrlContext, thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder) string { switch post.ThreadType { case models.ThreadTypeProjectBlogPost: - return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID) + return urlContext.BuildBlogThreadWithPostHash(post.ThreadID, thread.Title, post.ID) case models.ThreadTypeForumPost: - return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID) + return urlContext.BuildForumPost(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID) } - return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects + return urlContext.BuildHomepage() } var PostTypeMap = map[models.ThreadType][]templates.PostType{ @@ -47,33 +47,33 @@ var ThreadTypeDisplayNames = map[models.ThreadType]string{ models.ThreadTypeForumPost: "Forums", } -func GenericThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb { +func GenericThreadBreadcrumbs(urlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb { var result []templates.Breadcrumb if thread.Type == models.ThreadTypeForumPost { - result = SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID) + result = SubforumBreadcrumbs(urlContext, lineageBuilder, *thread.SubforumID) } else { result = []templates.Breadcrumb{ { - Name: project.Name, - Url: UrlForProject(project), + Name: urlContext.ProjectName, + Url: urlContext.BuildHomepage(), }, { Name: ThreadTypeDisplayNames[thread.Type], - Url: BuildProjectRootResourceUrl(project.Slug, thread.Type), + Url: BuildProjectRootResourceUrl(urlContext, thread.Type), }, } } return result } -func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string { +func BuildProjectRootResourceUrl(urlContext *hmnurl.UrlContext, kind models.ThreadType) string { switch kind { case models.ThreadTypeProjectBlogPost: - return hmnurl.BuildBlog(projectSlug, 1) + return urlContext.BuildBlog(1) case models.ThreadTypeForumPost: - return hmnurl.BuildForum(projectSlug, nil, 1) + return urlContext.BuildForum(nil, 1) } - return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects + return urlContext.BuildHomepage() } func MakePostListItem( @@ -88,11 +88,13 @@ func MakePostListItem( ) templates.PostListItem { var result templates.PostListItem + urlContext := UrlContextForProject(project) + result.Title = thread.Title result.User = templates.UserToTemplate(user, currentTheme) result.Date = post.PostDate result.Unread = unread - result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug) + result.Url = UrlForGenericPost(urlContext, thread, post, lineageBuilder) result.Preview = post.Preview postType := templates.PostTypeUnknown @@ -108,7 +110,7 @@ func MakePostListItem( result.PostTypePrefix = PostTypePrefix[result.PostType] if includeBreadcrumbs { - result.Breadcrumbs = GenericThreadBreadcrumbs(lineageBuilder, project, thread) + result.Breadcrumbs = GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread) } return result diff --git a/src/website/project_helper.go b/src/website/project_helper.go index de6e7582..26e800b9 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -3,8 +3,9 @@ package website import ( "context" - "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/hmnurl" + + "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" ) @@ -388,10 +389,11 @@ func FetchProjectOwners( return projectOwners[0].Owners, nil } -func UrlForProject(p *models.Project) string { - if p.Personal { - return hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name)) - } else { - return hmnurl.BuildOfficialProjectHomepage(p.Slug) +func UrlContextForProject(p *models.Project) *hmnurl.UrlContext { + return &hmnurl.UrlContext{ + PersonalProject: p.Personal, + ProjectID: p.ID, + ProjectSlug: p.Slug, + ProjectName: p.Name, } } diff --git a/src/website/projects.go b/src/website/projects.go index 54e6da9a..f906feee 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -62,7 +62,7 @@ func ProjectIndex(c *RequestContext) ResponseData { var restProjects []templates.Project now := time.Now() for _, p := range officialProjects { - templateProject := templates.ProjectToTemplate(&p.Project, c.Theme) + templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage(), c.Theme) if p.Project.Slug == "hero" { // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. handmadeHero = &templateProject @@ -121,7 +121,11 @@ func ProjectIndex(c *RequestContext) ResponseData { if i >= maxPersonalProjects { break } - personalProjects = append(personalProjects, templates.ProjectToTemplate(&p.Project, c.Theme)) + personalProjects = append(personalProjects, templates.ProjectToTemplate( + &p.Project, + UrlContextForProject(&p.Project).BuildHomepage(), + c.Theme, + )) } } @@ -136,7 +140,7 @@ func ProjectIndex(c *RequestContext) ResponseData { PersonalProjects: personalProjects, ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(), - WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1), + WIPForumUrl: hmnurl.HMNProjectContext.BuildForum([]string{"wip"}, 1), }, c.Perf) return res } @@ -249,7 +253,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { Value: c.CurrentProject.Blurb, }) - projectHomepageData.Project = templates.ProjectToTemplate(c.CurrentProject, c.Theme) + projectHomepageData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme) for _, owner := range owners { projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) } @@ -268,7 +272,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { "unapproved", fmt.Sprintf( "NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please submit it for approval when the project content is ready for review.", - hmnurl.BuildProjectEdit(c.CurrentProject.Slug, "submit"), + c.UrlContext.BuildProjectEdit("submit"), ), ) case models.ProjectLifecycleApprovalRequired: @@ -309,10 +313,10 @@ func ProjectHomepage(c *RequestContext) ResponseData { for _, post := range postQueryResult.ToSlice() { projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem( + c.UrlContext, lineageBuilder, &post.(*postQuery).Post, &post.(*postQuery).Thread, - c.CurrentProject, &post.(*postQuery).Author, c.Theme, )) diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 0a6eee51..eec57297 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -156,6 +156,7 @@ type RequestContext struct { CurrentUser *models.User CurrentSession *models.Session Theme string + UrlContext *hmnurl.UrlContext Perf *perf.RequestPerf diff --git a/src/website/routes.go b/src/website/routes.go index a53d73c3..d080d359 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -251,9 +251,8 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe rb.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete)) rb.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit))) rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData { - return c.Redirect(hmnurl.ProjectUrl( + return c.Redirect(c.UrlContext.Url( fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil, - c.CurrentProject.Slug, ), http.StatusMovedPermanently) }) } @@ -281,7 +280,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe if !p.Project.Personal { // TODO: Redirect to the same page on the other prefix - return c.Redirect(hmnurl.BuildOfficialProjectHomepage(p.Project.Slug), http.StatusSeeOther) + return c.Redirect(UrlContextForProject(&p.Project).BuildHomepage(), http.StatusSeeOther) } if c.PathParams["slug"] != models.GeneratePersonalProjectSlug(p.Project.Name) { @@ -290,6 +289,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe } c.CurrentProject = &p.Project + c.UrlContext = UrlContextForProject(c.CurrentProject) return h(c) }) @@ -458,15 +458,15 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { if c.CurrentProject == nil { panic("failed to load project data") } + + c.UrlContext = UrlContextForProject(c.CurrentProject) } - theme := "light" + c.Theme = "light" if c.CurrentUser != nil && c.CurrentUser.DarkTheme { - theme = "dark" + c.Theme = "dark" } - c.Theme = theme - return true, ResponseData{} } diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go index f5b06e98..ef3ca3c1 100644 --- a/src/website/timeline_helper.go +++ b/src/website/timeline_helper.go @@ -24,18 +24,18 @@ var TimelineTypeTitleMap = map[models.ThreadType]TimelineTypeTitles{ } func PostToTimelineItem( + urlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, post *models.Post, thread *models.Thread, - project *models.Project, owner *models.User, currentTheme string, ) templates.TimelineItem { item := templates.TimelineItem{ Date: post.PostDate, Title: thread.Title, - Breadcrumbs: GenericThreadBreadcrumbs(lineageBuilder, project, thread), - Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug), + Breadcrumbs: GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread), + Url: UrlForGenericPost(urlContext, thread, post, lineageBuilder), OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), OwnerName: owner.BestName(), diff --git a/src/website/user.go b/src/website/user.go index e1f79b7c..597aa3b3 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -121,7 +121,11 @@ func UserProfile(c *RequestContext) ResponseData { templateProjects := make([]templates.Project, 0, len(projectQuerySlice)) for _, projectRow := range projectQuerySlice { projectData := projectRow.(*projectQuery) - templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme)) + templateProjects = append(templateProjects, templates.ProjectToTemplate( + &projectData.Project, + UrlContextForProject(&projectData.Project).BuildHomepage(), + c.Theme, + )) } c.Perf.EndBlock() @@ -166,10 +170,10 @@ func UserProfile(c *RequestContext) ResponseData { for _, post := range posts { timelineItems = append(timelineItems, PostToTimelineItem( + UrlContextForProject(&post.Project), lineageBuilder, &post.Post, &post.Thread, - &post.Project, profileUser, c.Theme, )) From ff2183087d9e2f6bde0bcdd8588574718161df8f Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 9 Nov 2021 20:51:28 -0800 Subject: [PATCH 06/14] Preserve path when redirecting between official/personal projects --- src/hmnurl/hmnurl.go | 6 ++++++ src/website/requesthandling.go | 1 - src/website/routes.go | 18 ++++++++---------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index be701d97..c5103b79 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -84,6 +84,12 @@ func UrlWithFragment(path string, query []Q, fragment string) string { return HMNProjectContext.UrlWithFragment(path, query, fragment) } +func (c *UrlContext) RewriteProjectUrl(u *url.URL) string { + // we need to strip anything matching the personal project regex to get the base path + match := RegexPersonalProject.FindString(u.Path) + return c.Url(u.Path[len(match):], QFromURL(u)) +} + func trim(path string) string { if len(path) > 0 && path[0] == '/' { return path[1:] diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index eec57297..0818a0c4 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -93,7 +93,6 @@ nextroute: var params map[string]string for _, regex := range route.Regexes { - match := regex.FindStringSubmatch(currentPath) if len(match) == 0 { continue nextroute diff --git a/src/website/routes.go b/src/website/routes.go index d080d359..a16864f4 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -8,6 +8,7 @@ import ( "math/rand" "net/http" "net/url" + "regexp" "strconv" "strings" "time" @@ -278,25 +279,23 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe } } + c.CurrentProject = &p.Project + c.UrlContext = UrlContextForProject(c.CurrentProject) + if !p.Project.Personal { - // TODO: Redirect to the same page on the other prefix - return c.Redirect(UrlContextForProject(&p.Project).BuildHomepage(), http.StatusSeeOther) + return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther) } if c.PathParams["slug"] != models.GeneratePersonalProjectSlug(p.Project.Name) { - // TODO: Redirect to the same page on the other path - return c.Redirect(hmnurl.BuildPersonalProject(p.Project.ID, models.GeneratePersonalProjectSlug(p.Project.Name)), http.StatusSeeOther) + return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther) } - c.CurrentProject = &p.Project - c.UrlContext = UrlContextForProject(c.CurrentProject) - return h(c) }) } attachProjectRoutes(rb) }) - anyProject.Group(hmnurl.RegexHomepage, func(rb *RouteBuilder) { + anyProject.Group(regexp.MustCompile("^"), func(rb *RouteBuilder) { rb.Middleware = func(h Handler) Handler { return anyProject.Middleware(func(c *RequestContext) ResponseData { // We could be on any project's subdomain. @@ -304,8 +303,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe // Check if the current project (matched by subdomain) is actually no longer official // and therefore needs to be redirected to the personal project version of the route. if c.CurrentProject.Personal { - // TODO: Redirect to the same page on the other prefix - return c.Redirect(hmnurl.BuildPersonalProject(c.CurrentProject.ID, c.CurrentProject.Slug), http.StatusSeeOther) + return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther) } return h(c) From ab84332b23f972b6ce74c68b39ed28c085d0d210 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 9 Nov 2021 21:21:19 -0800 Subject: [PATCH 07/14] Improve appearance of the project index --- src/templates/src/project_index.html | 49 ++++++++++++---------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/templates/src/project_index.html b/src/templates/src/project_index.html index 5962f0ea..6bd1e2a4 100644 --- a/src/templates/src/project_index.html +++ b/src/templates/src/project_index.html @@ -1,37 +1,28 @@ {{ template "base.html" . }} {{ define "content" }} -
+
{{ with .CarouselProjects }} -
{{ end }} From df2942e84b0147e6ec5dbef2c4d94e0a457de7c8 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Thu, 11 Nov 2021 12:00:36 -0800 Subject: [PATCH 14/14] Show project snippets on project pages we need better filter UI, but do we really, though --- src/website/jam.go | 10 +++--- src/website/projects.go | 65 ++++++++++++++++++++++++++--------- src/website/snippet_helper.go | 31 +++++++++++++---- 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/src/website/jam.go b/src/website/jam.go index aadb3051..3d2fdac4 100644 --- a/src/website/jam.go +++ b/src/website/jam.go @@ -18,16 +18,18 @@ func JamIndex(c *RequestContext) ResponseData { daysUntil = 0 } - var tagIds []int - jamTag, err := FetchTag(c.Context(), c.Conn, "wheeljam") + tagId := -1 + jamTag, err := FetchTag(c.Context(), c.Conn, TagQuery{ + Text: []string{"wheeljam"}, + }) if err == nil { - tagIds = []int{jamTag.ID} + tagId = jamTag.ID } else { c.Logger.Warn().Err(err).Msg("failed to fetch jam tag; will fetch all snippets as a result") } snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{ - Tags: tagIds, + Tags: []int{tagId}, }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets")) diff --git a/src/website/projects.go b/src/website/projects.go index f906feee..8fce962f 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -5,6 +5,7 @@ import ( "math" "math/rand" "net/http" + "sort" "time" "git.handmade.network/hmn/hmn/src/db" @@ -241,25 +242,25 @@ func ProjectHomepage(c *RequestContext) ResponseData { } c.Perf.EndBlock() - var projectHomepageData ProjectHomepageData + var templateData ProjectHomepageData - projectHomepageData.BaseData = getBaseData(c, c.CurrentProject.Name, nil) + templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil) //if canEdit { // // TODO: Move to project-specific navigation - // // projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "") + // // templateData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "") //} - projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{ + templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{ Property: "og:description", Value: c.CurrentProject.Blurb, }) - projectHomepageData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme) + templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme) for _, owner := range owners { - projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) + templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme)) } if c.CurrentProject.Hidden { - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "hidden", "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", ) @@ -268,7 +269,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive { switch c.CurrentProject.Lifecycle { case models.ProjectLifecycleUnapproved: - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "unapproved", fmt.Sprintf( "NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please submit it for approval when the project content is ready for review.", @@ -276,27 +277,27 @@ func ProjectHomepage(c *RequestContext) ResponseData { ), ) case models.ProjectLifecycleApprovalRequired: - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "unapproved", "NOTICE: This project is awaiting approval. It is only visible to owners and site admins.", ) case models.ProjectLifecycleHiatus: - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "hiatus", "NOTICE: This project is on hiatus and may not update for a while.", ) case models.ProjectLifecycleDead: - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "dead", "NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.", ) case models.ProjectLifecycleLTSRequired: - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "lts-reqd", "NOTICE: This project is awaiting approval for maintenance-mode status.", ) case models.ProjectLifecycleLTS: - projectHomepageData.BaseData.AddImmediateNotice( + templateData.BaseData.AddImmediateNotice( "lts", "NOTICE: This project has reached a state of completion.", ) @@ -304,15 +305,15 @@ func ProjectHomepage(c *RequestContext) ResponseData { } for _, screenshot := range screenshotQueryResult.ToSlice() { - projectHomepageData.Screenshots = append(projectHomepageData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename)) + templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename)) } for _, link := range projectLinkResult.ToSlice() { - projectHomepageData.ProjectLinks = append(projectHomepageData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link)) + templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link)) } for _, post := range postQueryResult.ToSlice() { - projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem( + templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem( c.UrlContext, lineageBuilder, &post.(*postQuery).Post, @@ -322,8 +323,38 @@ func ProjectHomepage(c *RequestContext) ResponseData { )) } + tagId := -1 + if c.CurrentProject.TagID != nil { + tagId = *c.CurrentProject.TagID + } + + snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{ + Tags: []int{tagId}, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets")) + } + for _, s := range snippets { + item := SnippetToTimelineItem( + &s.Snippet, + s.Asset, + s.DiscordMessage, + s.Tags, + s.Owner, + c.Theme, + ) + item.SmallInfo = true + templateData.RecentActivity = append(templateData.RecentActivity, item) + } + + c.Perf.StartBlock("PROFILE", "Sort timeline") + sort.Slice(templateData.RecentActivity, func(i, j int) bool { + return templateData.RecentActivity[j].Date.Before(templateData.RecentActivity[i].Date) + }) + c.Perf.EndBlock() + var res ResponseData - err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf) + err = res.WriteTemplate("project_homepage.html", templateData, c.Perf) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template")) } diff --git a/src/website/snippet_helper.go b/src/website/snippet_helper.go index 50d748ab..b75cfefc 100644 --- a/src/website/snippet_helper.go +++ b/src/website/snippet_helper.go @@ -153,19 +153,38 @@ func FetchSnippet( return res[0], nil } -func FetchTags(ctx context.Context, dbConn db.ConnOrTx, text []string) ([]*models.Tag, error) { +type TagQuery struct { + IDs []int + Text []string + + Limit, Offset int +} + +func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.Tag, error) { perf := ExtractPerf(ctx) perf.StartBlock("SQL", "Fetch snippets") defer perf.EndBlock() - it, err := db.Query(ctx, dbConn, models.Tag{}, + var qb db.QueryBuilder + qb.Add( ` SELECT $columns FROM tags - WHERE text = ANY ($1) + WHERE + TRUE `, - text, ) + if len(q.IDs) > 0 { + qb.Add(`AND id = ANY ($?)`, q.IDs) + } + if len(q.Text) > 0 { + qb.Add(`AND text = ANY ($?)`, q.Text) + } + if q.Limit > 0 { + qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) + } + + it, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...) if err != nil { return nil, oops.New(err, "failed to fetch tags") } @@ -180,8 +199,8 @@ func FetchTags(ctx context.Context, dbConn db.ConnOrTx, text []string) ([]*model return res, nil } -func FetchTag(ctx context.Context, dbConn db.ConnOrTx, text string) (*models.Tag, error) { - tags, err := FetchTags(ctx, dbConn, []string{text}) +func FetchTag(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) (*models.Tag, error) { + tags, err := FetchTags(ctx, dbConn, q) if err != nil { return nil, err }