I really have no idea where I left off
This commit is contained in:
parent
a4ad2c5f04
commit
7486f9e57d
|
@ -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$")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue