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)
|
return Url("/", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildProjectHomepage(projectSlug string) string {
|
func BuildOfficialProjectHomepage(projectSlug string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return ProjectUrl("/", nil, projectSlug)
|
return ProjectUrl("/", nil, projectSlug)
|
||||||
}
|
}
|
||||||
|
@ -295,12 +295,11 @@ func BuildProjectNew() string {
|
||||||
return Url("/projects/new", nil)
|
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()
|
defer CatchPanic()
|
||||||
|
return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil)
|
||||||
return Url(fmt.Sprintf("/p/%s", slug), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
|
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
|
||||||
|
|
|
@ -2,6 +2,8 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -79,3 +81,17 @@ func (p *Project) Subdomain() string {
|
||||||
|
|
||||||
return p.Slug
|
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 {
|
func ProjectUrl(p *models.Project) string {
|
||||||
var url string
|
var url string
|
||||||
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
|
if p.Personal {
|
||||||
url = hmnurl.BuildProjectNotApproved(p.Slug)
|
url = hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name))
|
||||||
} else {
|
} else {
|
||||||
url = hmnurl.BuildProjectHomepage(p.Slug)
|
url = hmnurl.BuildOfficialProjectHomepage(p.Slug)
|
||||||
}
|
}
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"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/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
@ -16,8 +17,13 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectsQuery struct {
|
type ProjectsQuery struct {
|
||||||
Lifecycles []models.ProjectLifecycle
|
// Available on all project queries
|
||||||
Types ProjectTypeQuery // bitfield
|
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
|
// Ignored when using CountProjects
|
||||||
Limit, Offset int // if empty, no pagination
|
Limit, Offset int // if empty, no pagination
|
||||||
|
@ -58,6 +64,12 @@ func FetchProjects(
|
||||||
WHERE
|
WHERE
|
||||||
NOT hidden
|
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 {
|
if len(q.Lifecycles) > 0 {
|
||||||
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
|
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
|
||||||
} else {
|
} else {
|
||||||
|
@ -140,6 +152,62 @@ func FetchProjects(
|
||||||
return res, nil
|
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(
|
func CountProjects(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
dbConn db.ConnOrTx,
|
dbConn db.ConnOrTx,
|
||||||
|
@ -315,3 +383,11 @@ func FetchProjectOwners(
|
||||||
|
|
||||||
return projectOwners[0].Owners, nil
|
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"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
@ -158,39 +158,34 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
var project *models.Project
|
var project *models.Project
|
||||||
|
|
||||||
if c.CurrentProject.IsHMN() {
|
if c.CurrentProject.IsHMN() {
|
||||||
slug, hasSlug := c.PathParams["slug"]
|
// Viewing a personal project
|
||||||
if hasSlug && slug != "" {
|
idStr := c.PathParams["id"]
|
||||||
slug = strings.ToLower(slug)
|
slug := c.PathParams["slug"]
|
||||||
if slug == models.HMNProjectSlug {
|
|
||||||
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
id, err := strconv.Atoi(idStr)
|
||||||
}
|
if err != nil {
|
||||||
c.Perf.StartBlock("SQL", "Fetching project by slug")
|
panic(oops.New(err, "id was not numeric (bad regex in routing)"))
|
||||||
type projectQuery struct {
|
}
|
||||||
Project models.Project `db:"Project"`
|
|
||||||
}
|
if id == models.HMNProjectID {
|
||||||
projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{},
|
return c.Redirect(hmnurl.BuildHomepage(), http.StatusPermanentRedirect)
|
||||||
`
|
}
|
||||||
SELECT $columns
|
|
||||||
FROM
|
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{})
|
||||||
handmade_project AS project
|
if err != nil {
|
||||||
WHERE
|
if errors.Is(err, db.NotFound) {
|
||||||
LOWER(project.slug) = $1
|
return FourOhFour(c)
|
||||||
`,
|
} else {
|
||||||
slug,
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
correctSlug := models.GeneratePersonalProjectSlug(p.Project.Name)
|
||||||
|
if slug != correctSlug {
|
||||||
|
return c.Redirect(hmnurl.BuildPersonalProjectHomepage(id, correctSlug), http.StatusPermanentRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
project = &p.Project
|
||||||
} else {
|
} else {
|
||||||
project = c.CurrentProject
|
project = c.CurrentProject
|
||||||
}
|
}
|
||||||
|
@ -199,42 +194,14 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
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, project.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
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")
|
c.Perf.StartBlock("SQL", "Fetching screenshots")
|
||||||
type screenshotQuery struct {
|
type screenshotQuery struct {
|
||||||
Filename string `db:"screenshot.file"`
|
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.RegexShowcase, Showcase)
|
||||||
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
||||||
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
||||||
hmnOnly.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
hmnOnly.GET(hmnurl.RegexPersonalProjectHomepage, ProjectHomepage)
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||||
|
@ -277,31 +277,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
return router
|
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 {
|
func ProjectCSS(c *RequestContext) ResponseData {
|
||||||
color := c.URL().Query().Get("color")
|
color := c.URL().Query().Get("color")
|
||||||
if color == "" {
|
if color == "" {
|
||||||
|
@ -382,22 +357,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
||||||
defer c.Perf.EndBlock()
|
defer c.Perf.EndBlock()
|
||||||
|
|
||||||
// get project
|
// get user
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||||
if err == nil {
|
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.
|
// 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"
|
theme := "light"
|
||||||
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
||||||
theme = "dark"
|
theme = "dark"
|
||||||
|
|
Loading…
Reference in New Issue