I really have no idea where I left off

This commit is contained in:
Ben Visness 2021-11-08 13:16:54 -06:00
parent a4ad2c5f04
commit 7486f9e57d
7 changed files with 168 additions and 115 deletions

View File

@ -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<slug>.+)$")
var RegexPersonalProjectHomepage = regexp.MustCompile("^/p/(?P<id>[0-9]+)(/(?P<slug>[^/]*))?")
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<slug>.+)/edit$")

View File

@ -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
}

View File

@ -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"))
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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"`

View File

@ -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"