diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 68568f5..b98dbf1 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 9aee904..43f6263 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 0000000..13ed268 --- /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 460bcad..39b7473 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 f5fed7a..c022f7c 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 8f533f2..345d359 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 a3c2d8e..b3df8a5 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"