Personal project creation

This commit is contained in:
Asaf Gartner 2021-12-02 12:53:36 +02:00
parent 03c82c9d1a
commit 950e84d53a
16 changed files with 498 additions and 117 deletions

View File

@ -48,6 +48,7 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
p, err := website.FetchProject(ctx, tx, nil, models.HMNProjectID, website.ProjectsQuery{ p, err := website.FetchProject(ctx, tx, nil, models.HMNProjectID, website.ProjectsQuery{
IncludeHidden: true, IncludeHidden: true,
Lifecycles: models.AllProjectLifecycles,
}) })
if err != nil { if err != nil {
panic(err) panic(err)
@ -170,6 +171,7 @@ func addProjectTagCommand(projectCommand *cobra.Command) {
p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{ p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{
IncludeHidden: true, IncludeHidden: true,
Lifecycles: models.AllProjectLifecycles,
}) })
if err != nil { if err != nil {
panic(err) panic(err)

View File

@ -285,10 +285,36 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix) return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix)
} }
type AnonPrefix struct {
Path []int
Prefix string
}
var anonPrefixes []AnonPrefix
for _, field := range reflect.VisibleFields(destType) { for _, field := range reflect.VisibleFields(destType) {
path := append(pathSoFar, field.Index...) path := append(pathSoFar, field.Index...)
if columnName := field.Tag.Get("db"); columnName != "" { if columnName := field.Tag.Get("db"); columnName != "" {
if field.Anonymous {
anonPrefixes = append(anonPrefixes, AnonPrefix{Path: field.Index, Prefix: columnName})
continue
} else {
for _, anonPrefix := range anonPrefixes {
if len(field.Index) > len(anonPrefix.Path) {
equal := true
for i := range anonPrefix.Path {
if anonPrefix.Path[i] != field.Index[i] {
equal = false
break
}
}
if equal {
columnName = anonPrefix.Prefix + "." + columnName
break
}
}
}
}
fieldType := field.Type fieldType := field.Type
if fieldType.Kind() == reflect.Ptr { if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem() fieldType = fieldType.Elem()

View File

@ -0,0 +1,49 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(AddLogoAssetsToProjects{})
}
type AddLogoAssetsToProjects struct{}
func (m AddLogoAssetsToProjects) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 11, 28, 16, 23, 0, 0, time.UTC))
}
func (m AddLogoAssetsToProjects) Name() string {
return "AddLogoAssetsToProjects"
}
func (m AddLogoAssetsToProjects) Description() string {
return "Add optional asset references for project logos"
}
func (m AddLogoAssetsToProjects) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE handmade_project
ADD COLUMN logodark_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL,
ADD COLUMN logolight_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL;
`,
)
return err
}
func (m AddLogoAssetsToProjects) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE handmade_project
DROP COLUMN logodark_asset_id,
DROP COLUMN logolight_asset_id;
`,
)
return err
}

View File

@ -0,0 +1,73 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(AddDefaultsToProjects{})
}
type AddDefaultsToProjects struct{}
func (m AddDefaultsToProjects) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 11, 28, 17, 2, 18, 0, time.UTC))
}
func (m AddDefaultsToProjects) Name() string {
return "AddDefaultsToProjects"
}
func (m AddDefaultsToProjects) Description() string {
return "Add default values to many project columns"
}
func (m AddDefaultsToProjects) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE handmade_project
ALTER COLUMN slug SET DEFAULT '',
ALTER COLUMN color_1 SET DEFAULT 'ab4c47',
ALTER COLUMN color_2 SET DEFAULT 'a5467d',
ALTER COLUMN featured SET DEFAULT FALSE,
ALTER COLUMN hidden SET DEFAULT FALSE,
ALTER COLUMN blog_enabled SET DEFAULT FALSE,
ALTER COLUMN forum_enabled SET DEFAULT FALSE,
ALTER COLUMN all_last_updated SET DEFAULT 'epoch',
ALTER COLUMN annotation_last_updated SET DEFAULT 'epoch',
ALTER COLUMN blog_last_updated SET DEFAULT 'epoch',
ALTER COLUMN forum_last_updated SET DEFAULT 'epoch',
ALTER COLUMN date_approved SET DEFAULT 'epoch',
ALTER COLUMN bg_flags SET DEFAULT 0,
ALTER COLUMN library_enabled SET DEFAULT FALSE;
`,
)
return err
}
func (m AddDefaultsToProjects) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE handmade_project
ALTER COLUMN slug DROP DEFAULT,
ALTER COLUMN color_1 DROP DEFAULT,
ALTER COLUMN color_2 DROP DEFAULT,
ALTER COLUMN featured DROP DEFAULT,
ALTER COLUMN hidden DROP DEFAULT,
ALTER COLUMN blog_enabled DROP DEFAULT,
ALTER COLUMN forum_enabled DROP DEFAULT,
ALTER COLUMN all_last_updated DROP DEFAULT,
ALTER COLUMN annotation_last_updated DROP DEFAULT,
ALTER COLUMN blog_last_updated DROP DEFAULT,
ALTER COLUMN forum_last_updated DROP DEFAULT,
ALTER COLUMN date_approved DROP DEFAULT,
ALTER COLUMN bg_flags DROP DEFAULT,
ALTER COLUMN library_enabled DROP DEFAULT;
`,
)
return err
}

View File

@ -26,6 +26,16 @@ const (
ProjectLifecycleLTS ProjectLifecycleLTS
) )
var AllProjectLifecycles = []ProjectLifecycle{
ProjectLifecycleUnapproved,
ProjectLifecycleApprovalRequired,
ProjectLifecycleActive,
ProjectLifecycleHiatus,
ProjectLifecycleDead,
ProjectLifecycleLTSRequired,
ProjectLifecycleLTS,
}
// NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must have flags = 0. // NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must have flags = 0.
var VisibleProjectLifecycles = []ProjectLifecycle{ var VisibleProjectLifecycles = []ProjectLifecycle{
ProjectLifecycleActive, ProjectLifecycleActive,
@ -70,6 +80,12 @@ type Project struct {
LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db
} }
type ProjectWithLogos struct {
Project `db:"project"`
LogoLightAsset *Asset `db:"logolight_asset"`
LogoDarkAsset *Asset `db:"logodark_asset"`
}
func (p *Project) IsHMN() bool { func (p *Project) IsHMN() bool {
return p.ID == HMNProjectID return p.ID == HMNProjectID
} }

View File

@ -59,11 +59,23 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
models.ProjectLifecycleLTS: "Complete", models.ProjectLifecycleLTS: "Complete",
} }
func ProjectToTemplate(p *models.Project, url string, theme string) Project { func ProjectLogoUrl(p *models.ProjectWithLogos, theme string) string {
logo := p.LogoLight
if theme == "dark" { if theme == "dark" {
logo = p.LogoDark if p.LogoDarkAsset != nil {
return hmnurl.BuildS3Asset(p.LogoDarkAsset.S3Key)
} else {
return hmnurl.BuildUserFile(p.Project.LogoDark)
}
} else {
if p.LogoLightAsset != nil {
return hmnurl.BuildS3Asset(p.LogoLightAsset.S3Key)
} else {
return hmnurl.BuildUserFile(p.Project.LogoLight)
}
} }
}
func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Project {
return Project{ return Project{
Name: p.Name, Name: p.Name,
Subdomain: p.Subdomain(), Subdomain: p.Subdomain(),
@ -73,7 +85,7 @@ func ProjectToTemplate(p *models.Project, url string, theme string) Project {
Blurb: p.Blurb, Blurb: p.Blurb,
ParsedDescription: template.HTML(p.ParsedDescription), ParsedDescription: template.HTML(p.ParsedDescription),
Logo: hmnurl.BuildUserFile(logo), Logo: ProjectLogoUrl(p, theme),
LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle], LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle],
LifecycleString: LifecycleBadgeStrings[p.Lifecycle], LifecycleString: LifecycleBadgeStrings[p.Lifecycle],

View File

@ -68,7 +68,7 @@
</div> </div>
</div> </div>
</div> </div>
{{ if .User.IsStaff }} {{ if and .Editing .User.IsStaff }}
<div class="edit-form-row"> <div class="edit-form-row">
<div class="pt-input-ns">Admin settings</div> <div class="pt-input-ns">Admin settings</div>
</div> </div>
@ -109,7 +109,7 @@
<div class="edit-form-row"> <div class="edit-form-row">
<div class="pt-input-ns">Short description:</div> <div class="pt-input-ns">Short description:</div>
<div> <div>
<textarea maxlength="140" name="shortdesc"> <textarea required maxlength="140" name="shortdesc">
{{- .ProjectSettings.Blurb -}} {{- .ProjectSettings.Blurb -}}
</textarea> </textarea>
<div class="c--dim f7">Plaintext only. No links or markdown.</div> <div class="c--dim f7">Plaintext only. No links or markdown.</div>

View File

@ -68,7 +68,11 @@
</div> </div>
{{ end }} {{ end }}
<div class="description ph3 ph0-ns"> <div class="description ph3 ph0-ns">
{{ .Project.ParsedDescription }} {{ if .Project.ParsedDescription }}
{{ .Project.ParsedDescription }}
{{ else }}
{{ .Project.Blurb }}
{{ end }}
</div> </div>
{{ with .RecentActivity }} {{ with .RecentActivity }}
<div class="content-block timeline-container ph3 ph0-ns mv4"> <div class="content-block timeline-container ph3 ph0-ns mv4">

View File

@ -14,7 +14,7 @@ func getBaseDataAutocrumb(c *RequestContext, title string) templates.BaseData {
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary. // NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
// If you pass nil, no breadcrumbs will be created. // If you pass nil, no breadcrumbs will be created.
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData { func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
var project models.Project var project models.ProjectWithLogos
if c.CurrentProject != nil { if c.CurrentProject != nil {
project = *c.CurrentProject project = *c.CurrentProject
} }
@ -58,7 +58,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
ReportIssueMailto: "team@handmade.network", ReportIssueMailto: "team@handmade.network",
OpenGraphItems: buildDefaultOpenGraphItems(&project, title), OpenGraphItems: buildDefaultOpenGraphItems(&project.Project, title),
IsProjectPage: !project.IsHMN(), IsProjectPage: !project.IsHMN(),
Header: templates.Header{ Header: templates.Header{

View File

@ -9,7 +9,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "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"
@ -153,68 +152,30 @@ func AtomFeed(c *RequestContext) ResponseData {
feedData.FeedUrl = hmnurl.BuildProjectIndex(1) feedData.FeedUrl = hmnurl.BuildProjectIndex(1)
c.Perf.StartBlock("SQL", "Fetching projects") c.Perf.StartBlock("SQL", "Fetching projects")
type projectResult struct {
Project models.Project `db:"project"`
}
_, hasAll := c.Req.URL.Query()["all"] _, hasAll := c.Req.URL.Query()["all"]
if hasAll { if hasAll {
itemsPerFeed = 100000 itemsPerFeed = 100000
} }
projects, err := db.Query(c.Context(), c.Conn, projectResult{}, projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, nil, ProjectsQuery{
` Lifecycles: models.VisibleProjectLifecycles,
SELECT $columns Limit: itemsPerFeed,
FROM Types: OfficialProjects,
handmade_project AS project OrderBy: "date_approved DESC",
WHERE })
project.lifecycle = ANY($1)
AND NOT project.hidden
ORDER BY date_approved DESC
LIMIT $2
`,
models.VisibleProjectLifecycles,
itemsPerFeed,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects"))
} }
var projectIds []int for _, p := range projectsAndStuff {
projectMap := make(map[int]int) // map[project id]index in slice templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme)
for _, p := range projects.ToSlice() {
project := p.(*projectResult).Project
templateProject := templates.ProjectToTemplate(&project, UrlContextForProject(&project).BuildHomepage(), c.Theme)
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN() templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
for _, owner := range p.Owners {
templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner, ""))
}
projectIds = append(projectIds, project.ID)
feedData.Projects = append(feedData.Projects, templateProject) feedData.Projects = append(feedData.Projects, templateProject)
projectMap[project.ID] = len(feedData.Projects) - 1
} }
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project owners")
type ownerResult struct {
User models.User `db:"auth_user"`
ProjectID int `db:"uproj.project_id"`
}
owners, err := db.Query(c.Context(), c.Conn, ownerResult{},
`
SELECT $columns
FROM
handmade_user_projects AS uproj
JOIN auth_user ON uproj.user_id = auth_user.id
WHERE
uproj.project_id = ANY($1)
`,
projectIds,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects owners"))
}
for _, res := range owners.ToSlice() {
owner := res.(*ownerResult)
templateProject := &feedData.Projects[projectMap[owner.ProjectID]]
templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(&owner.User, ""))
}
c.Perf.EndBlock()
updated := time.Now() updated := time.Now()
if len(feedData.Projects) > 0 { if len(feedData.Projects) > 0 {
updated = feedData.Projects[0].DateApproved updated = feedData.Projects[0].DateApproved

View File

@ -865,7 +865,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
} }
if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums { if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums) sfId, valid := validateSubforums(lineageBuilder, &c.CurrentProject.Project, subforums)
if !valid { if !valid {
return commonForumData{}, false return commonForumData{}, false
} }

View File

@ -2,6 +2,7 @@ package website
import ( import (
"context" "context"
"fmt"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
@ -19,20 +20,23 @@ const (
type ProjectsQuery struct { type ProjectsQuery struct {
// Available on all project queries // Available on all project queries
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
Types ProjectTypeQuery // bitfield Types ProjectTypeQuery // bitfield
IncludeHidden bool IncludeHidden bool
AlwaysVisibleToOwnerAndStaff bool
// Ignored when using FetchProject // Ignored when using FetchProject
ProjectIDs []int // if empty, all projects ProjectIDs []int // if empty, all projects
Slugs []string // if empty, all projects Slugs []string // if empty, all projects
OwnerIDs []int // 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
OrderBy string
} }
type ProjectAndStuff struct { type ProjectAndStuff struct {
Project models.Project Project models.ProjectWithLogos
Owners []*models.User Owners []*models.User
} }
@ -59,26 +63,39 @@ func FetchProjects(
// Fetch all valid projects (not yet subject to user permission checks) // Fetch all valid projects (not yet subject to user permission checks)
var qb db.QueryBuilder var qb db.QueryBuilder
if len(q.OrderBy) > 0 {
qb.Add(`SELECT * FROM (`)
}
qb.Add(` qb.Add(`
SELECT $columns SELECT DISTINCT ON (project.id) $columns
FROM FROM
handmade_project AS project handmade_project AS project
LEFT JOIN handmade_asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id
LEFT JOIN handmade_asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id
`)
if len(q.OwnerIDs) > 0 {
qb.Add(`
INNER JOIN handmade_user_projects AS owner_filter ON owner_filter.project_id = project.id
`)
}
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil {
qb.Add(`
LEFT JOIN handmade_user_projects AS owner_visibility ON owner_visibility.project_id = project.id
`)
}
qb.Add(`
WHERE WHERE
TRUE TRUE
`) `)
if !q.IncludeHidden { // Filters
qb.Add(`AND NOT hidden`)
}
if len(q.ProjectIDs) > 0 { if len(q.ProjectIDs) > 0 {
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
} }
if len(q.Slugs) > 0 { if len(q.Slugs) > 0 {
qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs) qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs)
} }
if len(q.Lifecycles) > 0 { if len(q.OwnerIDs) > 0 {
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles) qb.Add(`AND (owner_filter.user_id = ANY ($?))`, q.OwnerIDs)
} else {
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
} }
if q.Types != 0 { if q.Types != 0 {
qb.Add(`AND (FALSE`) qb.Add(`AND (FALSE`)
@ -90,10 +107,31 @@ func FetchProjects(
} }
qb.Add(`)`) qb.Add(`)`)
} }
// Visibility
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil {
qb.Add(`AND ($? = TRUE OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID)
}
if !q.IncludeHidden {
qb.Add(`AND 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.AlwaysVisibleToOwnerAndStaff && currentUser != nil {
qb.Add(`))`)
}
// Output
if q.Limit > 0 { if q.Limit > 0 {
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
} }
itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...) if len(q.OrderBy) > 0 {
qb.Add(fmt.Sprintf(`) q ORDER BY %s`, q.OrderBy))
}
itProjects, err := db.Query(ctx, dbConn, models.ProjectWithLogos{}, qb.String(), qb.Args()...)
if err != nil { if err != nil {
return nil, oops.New(err, "failed to fetch projects") return nil, oops.New(err, "failed to fetch projects")
} }
@ -102,7 +140,7 @@ func FetchProjects(
// Fetch project owners to do permission checks // Fetch project owners to do permission checks
projectIds := make([]int, len(iprojects)) projectIds := make([]int, len(iprojects))
for i, iproject := range iprojects { for i, iproject := range iprojects {
projectIds[i] = iproject.(*models.Project).ID projectIds[i] = iproject.(*models.ProjectWithLogos).ID
} }
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
if err != nil { if err != nil {
@ -111,7 +149,7 @@ func FetchProjects(
var res []ProjectAndStuff var res []ProjectAndStuff
for i, iproject := range iprojects { for i, iproject := range iprojects {
project := iproject.(*models.Project) project := iproject.(*models.ProjectWithLogos)
owners := projectOwners[i].Owners owners := projectOwners[i].Owners
/* /*

View File

@ -1,19 +1,26 @@
package website package website
import ( import (
"errors"
"fmt" "fmt"
"image"
"io"
"math" "math"
"math/rand" "math/rand"
"net/http" "net/http"
"sort" "sort"
"strings"
"time" "time"
"git.handmade.network/hmn/hmn/src/assets"
"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/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"
"git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils" "git.handmade.network/hmn/hmn/src/utils"
"github.com/google/uuid"
) )
type ProjectTemplateData struct { type ProjectTemplateData struct {
@ -63,7 +70,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
var restProjects []templates.Project var restProjects []templates.Project
now := time.Now() now := time.Now()
for _, p := range officialProjects { for _, p := range officialProjects {
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage(), c.Theme) templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme)
if p.Project.Slug == "hero" { if p.Project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
handmadeHero = &templateProject handmadeHero = &templateProject
@ -126,7 +133,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
} }
personalProjects = append(personalProjects, templates.ProjectToTemplate( personalProjects = append(personalProjects, templates.ProjectToTemplate(
&p.Project, &p.Project,
UrlContextForProject(&p.Project).BuildHomepage(), UrlContextForProject(&p.Project.Project).BuildHomepage(),
c.Theme, c.Theme,
)) ))
} }
@ -291,7 +298,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
case models.ProjectLifecycleDead: case models.ProjectLifecycleDead:
templateData.BaseData.AddImmediateNotice( templateData.BaseData.AddImmediateNotice(
"dead", "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.", "NOTICE: This project is has been marked dead and is only visible to owners and site admins.",
) )
case models.ProjectLifecycleLTSRequired: case models.ProjectLifecycleLTSRequired:
templateData.BaseData.AddImmediateNotice( templateData.BaseData.AddImmediateNotice(
@ -392,7 +399,166 @@ func ProjectNew(c *RequestContext) ResponseData {
} }
func ProjectNewSubmit(c *RequestContext) ResponseData { func ProjectNewSubmit(c *RequestContext) ResponseData {
return FourOhFour(c) maxBodySize := int64(ProjectLogoMaxFileSize*2 + 1024*1024)
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
err := c.Req.ParseMultipartForm(maxBodySize)
if err != nil {
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
}
projectName := strings.TrimSpace(c.Req.Form.Get("project_name"))
if len(projectName) == 0 {
return RejectRequest(c, "Project name is empty")
}
shortDesc := strings.TrimSpace(c.Req.Form.Get("shortdesc"))
if len(shortDesc) == 0 {
return RejectRequest(c, "Projects must have a short description")
}
description := c.Req.Form.Get("description")
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
lifecycleStr := c.Req.Form.Get("lifecycle")
var lifecycle models.ProjectLifecycle
switch lifecycleStr {
case "active":
lifecycle = models.ProjectLifecycleActive
case "hiatus":
lifecycle = models.ProjectLifecycleHiatus
case "done":
lifecycle = models.ProjectLifecycleLTS
case "dead":
lifecycle = models.ProjectLifecycleDead
default:
return RejectRequest(c, "Project status is invalid")
}
hiddenStr := c.Req.Form.Get("hidden")
hidden := len(hiddenStr) > 0
lightLogo, err := GetFormImage(c, "light_logo")
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to read image from form"))
}
darkLogo, err := GetFormImage(c, "dark_logo")
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to read image from form"))
}
owners := c.Req.Form["owners"]
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
}
defer tx.Rollback(c.Context())
var lightLogoUUID *uuid.UUID
if lightLogo.Exists {
lightLogoAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
Content: lightLogo.Content,
Filename: lightLogo.Filename,
ContentType: lightLogo.Mime,
UploaderID: &c.CurrentUser.ID,
Width: lightLogo.Width,
Height: lightLogo.Height,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save asset"))
}
lightLogoUUID = &lightLogoAsset.ID
}
var darkLogoUUID *uuid.UUID
if darkLogo.Exists {
darkLogoAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
Content: darkLogo.Content,
Filename: darkLogo.Filename,
ContentType: darkLogo.Mime,
UploaderID: &c.CurrentUser.ID,
Width: darkLogo.Width,
Height: darkLogo.Height,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save asset"))
}
darkLogoUUID = &darkLogoAsset.ID
}
hasSelf := false
selfUsername := strings.ToLower(c.CurrentUser.Username)
for i, _ := range owners {
owners[i] = strings.ToLower(owners[i])
if owners[i] == selfUsername {
hasSelf = true
}
}
if !hasSelf {
owners = append(owners, selfUsername)
}
userResult, err := db.Query(c.Context(), c.Conn, models.User{},
`
SELECT $columns
FROM auth_user
WHERE LOWER(username) = ANY ($1)
`,
owners,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to query users"))
}
var projectId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO handmade_project
(name, blurb, description, descparsed, logodark_asset_id, logolight_asset_id, lifecycle, hidden, date_created, all_last_updated)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id
`,
projectName,
shortDesc,
description,
parsedDescription,
darkLogoUUID,
lightLogoUUID,
lifecycle,
hidden,
time.Now(), // NOTE(asaf): Using this param twice.
).Scan(&projectId)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert new project"))
}
for _, ownerRow := range userResult.ToSlice() {
_, err = tx.Exec(c.Context(),
`
INSERT INTO handmade_user_projects
(user_id, project_id)
VALUES
($1, $2)
`,
ownerRow.(*models.User).ID,
projectId,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert project owner"))
}
}
tx.Commit(c.Context())
urlContext := &hmnurl.UrlContext{
PersonalProject: true,
ProjectID: projectId,
ProjectName: projectName,
}
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
} }
func ProjectEdit(c *RequestContext) ResponseData { func ProjectEdit(c *RequestContext) ResponseData {
@ -412,3 +578,48 @@ func ProjectEdit(c *RequestContext) ResponseData {
func ProjectEditSubmit(c *RequestContext) ResponseData { func ProjectEditSubmit(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
type FormImage struct {
Exists bool
Filename string
Mime string
Content []byte
Width int
Height int
Size int64
}
// NOTE(asaf): This assumes that you already called ParseMultipartForm (which is why there's no size limit here).
func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
var res FormImage
res.Exists = false
img, header, err := c.Req.FormFile(fieldName)
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
return res, nil
} else {
return FormImage{}, err
}
}
if header != nil {
res.Exists = true
res.Size = header.Size
res.Filename = header.Filename
res.Content = make([]byte, res.Size)
img.Read(res.Content)
img.Seek(0, io.SeekStart)
config, _, err := image.DecodeConfig(img)
if err != nil {
return FormImage{}, err
}
res.Width = config.Width
res.Height = config.Height
res.Mime = http.DetectContentType(res.Content)
}
return res, nil
}

View File

@ -160,7 +160,7 @@ type RequestContext struct {
Res http.ResponseWriter Res http.ResponseWriter
Conn *pgxpool.Pool Conn *pgxpool.Pool
CurrentProject *models.Project CurrentProject *models.ProjectWithLogos
CurrentUser *models.User CurrentUser *models.User
CurrentSession *models.Session CurrentSession *models.Session
Theme string Theme string

View File

@ -299,7 +299,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
if err != nil { if err != nil {
panic(oops.New(err, "project id was not numeric (bad regex in routing)")) panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
} }
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{}) p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{
AlwaysVisibleToOwnerAndStaff: true,
})
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return FourOhFour(c) return FourOhFour(c)
@ -309,7 +311,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
} }
c.CurrentProject = &p.Project c.CurrentProject = &p.Project
c.UrlContext = UrlContextForProject(c.CurrentProject) c.UrlContext = UrlContextForProject(&c.CurrentProject.Project)
if !p.Project.Personal { if !p.Project.Personal {
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther) return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
@ -461,14 +463,16 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost()) hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
slug := strings.TrimRight(hostPrefix, ".") slug := strings.TrimRight(hostPrefix, ".")
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{}) if len(slug) > 0 {
if err == nil { dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true})
c.CurrentProject = &dbProject.Project if err == nil {
} else { c.CurrentProject = &dbProject.Project
if errors.Is(err, db.NotFound) {
// do nothing, this is fine
} else { } else {
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) if errors.Is(err, db.NotFound) {
// do nothing, this is fine
} else {
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
}
} }
} }
@ -486,7 +490,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
panic("failed to load project data") panic("failed to load project data")
} }
c.UrlContext = UrlContextForProject(c.CurrentProject) c.UrlContext = UrlContextForProject(&c.CurrentProject.Project)
} }
c.Theme = "light" c.Theme = "light"

View File

@ -99,34 +99,19 @@ func UserProfile(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
type projectQuery struct { projectsQuery := ProjectsQuery{
Project models.Project `db:"project"` OwnerIDs: []int{profileUser.ID},
Lifecycles: models.VisibleProjectLifecycles,
AlwaysVisibleToOwnerAndStaff: true,
OrderBy: "all_last_updated DESC",
} }
c.Perf.StartBlock("SQL", "Fetch projects")
projectQueryResult, err := db.Query(c.Context(), c.Conn, projectQuery{}, projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery)
` templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
SELECT $columns for _, p := range projectsAndStuff {
FROM
handmade_project AS project
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
WHERE
uproj.user_id = $1
AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3)))
`,
profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
models.VisibleProjectLifecycles,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for user: %s", username))
}
projectQuerySlice := projectQueryResult.ToSlice()
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
for _, projectRow := range projectQuerySlice {
projectData := projectRow.(*projectQuery)
templateProjects = append(templateProjects, templates.ProjectToTemplate( templateProjects = append(templateProjects, templates.ProjectToTemplate(
&projectData.Project, &p.Project,
UrlContextForProject(&projectData.Project).BuildHomepage(), UrlContextForProject(&p.Project.Project).BuildHomepage(),
c.Theme, c.Theme,
)) ))
} }
@ -197,7 +182,7 @@ func UserProfile(c *RequestContext) ResponseData {
ProfileUserLinks: profileUserLinks, ProfileUserLinks: profileUserLinks,
ProfileUserProjects: templateProjects, ProfileUserProjects: templateProjects,
TimelineItems: timelineItems, TimelineItems: timelineItems,
OwnProfile: c.CurrentUser.ID == profileUser.ID, OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID),
ShowcaseUrl: hmnurl.BuildShowcase(), ShowcaseUrl: hmnurl.BuildShowcase(),
NewProjectUrl: hmnurl.BuildProjectNew(), NewProjectUrl: hmnurl.BuildProjectNew(),
}, c.Perf) }, c.Perf)