From 950e84d53af5b0068b889076f2c2e643dc9cf833 Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Thu, 2 Dec 2021 12:53:36 +0200 Subject: [PATCH] Personal project creation --- src/admintools/adminproject.go | 2 + src/db/db.go | 26 +++ ...1-11-28T162300Z_AddLogoAssetsToProjects.go | 49 ++++ ...021-11-28T170218Z_AddDefaultsToProjects.go | 73 ++++++ src/models/project.go | 16 ++ src/templates/mapping.go | 20 +- src/templates/src/project_edit.html | 4 +- src/templates/src/project_homepage.html | 6 +- src/website/base_data.go | 4 +- src/website/feed.go | 61 +---- src/website/forums.go | 2 +- src/website/project_helper.go | 68 ++++-- src/website/projects.go | 219 +++++++++++++++++- src/website/requesthandling.go | 2 +- src/website/routes.go | 24 +- src/website/user.go | 39 +--- 16 files changed, 498 insertions(+), 117 deletions(-) create mode 100644 src/migration/migrations/2021-11-28T162300Z_AddLogoAssetsToProjects.go create mode 100644 src/migration/migrations/2021-11-28T170218Z_AddDefaultsToProjects.go diff --git a/src/admintools/adminproject.go b/src/admintools/adminproject.go index a6234226..a32dec4e 100644 --- a/src/admintools/adminproject.go +++ b/src/admintools/adminproject.go @@ -48,6 +48,7 @@ func addCreateProjectCommand(projectCommand *cobra.Command) { p, err := website.FetchProject(ctx, tx, nil, models.HMNProjectID, website.ProjectsQuery{ IncludeHidden: true, + Lifecycles: models.AllProjectLifecycles, }) if err != nil { panic(err) @@ -170,6 +171,7 @@ func addProjectTagCommand(projectCommand *cobra.Command) { p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{ IncludeHidden: true, + Lifecycles: models.AllProjectLifecycles, }) if err != nil { panic(err) diff --git a/src/db/db.go b/src/db/db.go index 01c20c96..7c211190 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -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) } + type AnonPrefix struct { + Path []int + Prefix string + } + var anonPrefixes []AnonPrefix + for _, field := range reflect.VisibleFields(destType) { path := append(pathSoFar, field.Index...) 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 if fieldType.Kind() == reflect.Ptr { fieldType = fieldType.Elem() diff --git a/src/migration/migrations/2021-11-28T162300Z_AddLogoAssetsToProjects.go b/src/migration/migrations/2021-11-28T162300Z_AddLogoAssetsToProjects.go new file mode 100644 index 00000000..7be1c644 --- /dev/null +++ b/src/migration/migrations/2021-11-28T162300Z_AddLogoAssetsToProjects.go @@ -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 +} diff --git a/src/migration/migrations/2021-11-28T170218Z_AddDefaultsToProjects.go b/src/migration/migrations/2021-11-28T170218Z_AddDefaultsToProjects.go new file mode 100644 index 00000000..53624647 --- /dev/null +++ b/src/migration/migrations/2021-11-28T170218Z_AddDefaultsToProjects.go @@ -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 +} diff --git a/src/models/project.go b/src/models/project.go index 62824928..9ca4be12 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -26,6 +26,16 @@ const ( 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. var VisibleProjectLifecycles = []ProjectLifecycle{ ProjectLifecycleActive, @@ -70,6 +80,12 @@ type Project struct { 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 { return p.ID == HMNProjectID } diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 0c402a04..fe681e6e 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -59,11 +59,23 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ models.ProjectLifecycleLTS: "Complete", } -func ProjectToTemplate(p *models.Project, url string, theme string) Project { - logo := p.LogoLight +func ProjectLogoUrl(p *models.ProjectWithLogos, theme string) string { 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{ Name: p.Name, Subdomain: p.Subdomain(), @@ -73,7 +85,7 @@ func ProjectToTemplate(p *models.Project, url string, theme string) Project { Blurb: p.Blurb, ParsedDescription: template.HTML(p.ParsedDescription), - Logo: hmnurl.BuildUserFile(logo), + Logo: ProjectLogoUrl(p, theme), LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle], LifecycleString: LifecycleBadgeStrings[p.Lifecycle], diff --git a/src/templates/src/project_edit.html b/src/templates/src/project_edit.html index 1e27cb4c..48ea884a 100644 --- a/src/templates/src/project_edit.html +++ b/src/templates/src/project_edit.html @@ -68,7 +68,7 @@ - {{ if .User.IsStaff }} + {{ if and .Editing .User.IsStaff }}
Admin settings
@@ -109,7 +109,7 @@
Short description:
-
Plaintext only. No links or markdown.
diff --git a/src/templates/src/project_homepage.html b/src/templates/src/project_homepage.html index ca6e834c..58745e86 100644 --- a/src/templates/src/project_homepage.html +++ b/src/templates/src/project_homepage.html @@ -68,7 +68,11 @@
{{ end }}
- {{ .Project.ParsedDescription }} + {{ if .Project.ParsedDescription }} + {{ .Project.ParsedDescription }} + {{ else }} + {{ .Project.Blurb }} + {{ end }}
{{ with .RecentActivity }}
diff --git a/src/website/base_data.go b/src/website/base_data.go index fa4bc574..199effbc 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -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. // If you pass nil, no breadcrumbs will be created. func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData { - var project models.Project + var project models.ProjectWithLogos if c.CurrentProject != nil { project = *c.CurrentProject } @@ -58,7 +58,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc ReportIssueMailto: "team@handmade.network", - OpenGraphItems: buildDefaultOpenGraphItems(&project, title), + OpenGraphItems: buildDefaultOpenGraphItems(&project.Project, title), IsProjectPage: !project.IsHMN(), Header: templates.Header{ diff --git a/src/website/feed.go b/src/website/feed.go index 4baa3ea1..f5bdd08c 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" - "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" @@ -153,68 +152,30 @@ func AtomFeed(c *RequestContext) ResponseData { feedData.FeedUrl = hmnurl.BuildProjectIndex(1) c.Perf.StartBlock("SQL", "Fetching projects") - type projectResult struct { - Project models.Project `db:"project"` - } _, hasAll := c.Req.URL.Query()["all"] if hasAll { itemsPerFeed = 100000 } - projects, err := db.Query(c.Context(), c.Conn, projectResult{}, - ` - SELECT $columns - FROM - handmade_project AS project - WHERE - project.lifecycle = ANY($1) - AND NOT project.hidden - ORDER BY date_approved DESC - LIMIT $2 - `, - models.VisibleProjectLifecycles, - itemsPerFeed, - ) + projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, nil, ProjectsQuery{ + Lifecycles: models.VisibleProjectLifecycles, + Limit: itemsPerFeed, + Types: OfficialProjects, + OrderBy: "date_approved DESC", + }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects")) } - var projectIds []int - projectMap := make(map[int]int) // map[project id]index in slice - for _, p := range projects.ToSlice() { - project := p.(*projectResult).Project - templateProject := templates.ProjectToTemplate(&project, UrlContextForProject(&project).BuildHomepage(), c.Theme) + for _, p := range projectsAndStuff { + templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme) 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) - projectMap[project.ID] = len(feedData.Projects) - 1 } 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() if len(feedData.Projects) > 0 { updated = feedData.Projects[0].DateApproved diff --git a/src/website/forums.go b/src/website/forums.go index efc68db1..69dfd443 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -865,7 +865,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) { } if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums { - sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums) + sfId, valid := validateSubforums(lineageBuilder, &c.CurrentProject.Project, subforums) if !valid { return commonForumData{}, false } diff --git a/src/website/project_helper.go b/src/website/project_helper.go index f74ca983..754e1435 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -2,6 +2,7 @@ package website import ( "context" + "fmt" "git.handmade.network/hmn/hmn/src/hmnurl" @@ -19,20 +20,23 @@ const ( type ProjectsQuery struct { // Available on all project queries - Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles - Types ProjectTypeQuery // bitfield - IncludeHidden bool + Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles + Types ProjectTypeQuery // bitfield + IncludeHidden bool + AlwaysVisibleToOwnerAndStaff bool // Ignored when using FetchProject ProjectIDs []int // if empty, all projects Slugs []string // if empty, all projects + OwnerIDs []int // if empty, all projects // Ignored when using CountProjects Limit, Offset int // if empty, no pagination + OrderBy string } type ProjectAndStuff struct { - Project models.Project + Project models.ProjectWithLogos Owners []*models.User } @@ -59,26 +63,39 @@ func FetchProjects( // Fetch all valid projects (not yet subject to user permission checks) var qb db.QueryBuilder + if len(q.OrderBy) > 0 { + qb.Add(`SELECT * FROM (`) + } qb.Add(` - SELECT $columns + SELECT DISTINCT ON (project.id) $columns FROM 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 TRUE `) - if !q.IncludeHidden { - qb.Add(`AND NOT hidden`) - } + // Filters if len(q.ProjectIDs) > 0 { qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) } if len(q.Slugs) > 0 { qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs) } - if len(q.Lifecycles) > 0 { - qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles) - } else { - qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles) + if len(q.OwnerIDs) > 0 { + qb.Add(`AND (owner_filter.user_id = ANY ($?))`, q.OwnerIDs) } if q.Types != 0 { qb.Add(`AND (FALSE`) @@ -90,10 +107,31 @@ func FetchProjects( } 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 { 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 { return nil, oops.New(err, "failed to fetch projects") } @@ -102,7 +140,7 @@ func FetchProjects( // Fetch project owners to do permission checks projectIds := make([]int, len(iprojects)) for i, iproject := range iprojects { - projectIds[i] = iproject.(*models.Project).ID + projectIds[i] = iproject.(*models.ProjectWithLogos).ID } projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) if err != nil { @@ -111,7 +149,7 @@ func FetchProjects( var res []ProjectAndStuff for i, iproject := range iprojects { - project := iproject.(*models.Project) + project := iproject.(*models.ProjectWithLogos) owners := projectOwners[i].Owners /* diff --git a/src/website/projects.go b/src/website/projects.go index 7855e8f2..d9759583 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -1,19 +1,26 @@ package website import ( + "errors" "fmt" + "image" + "io" "math" "math/rand" "net/http" "sort" + "strings" "time" + "git.handmade.network/hmn/hmn/src/assets" "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" + "git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" + "github.com/google/uuid" ) type ProjectTemplateData struct { @@ -63,7 +70,7 @@ func ProjectIndex(c *RequestContext) ResponseData { var restProjects []templates.Project now := time.Now() 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" { // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. handmadeHero = &templateProject @@ -126,7 +133,7 @@ func ProjectIndex(c *RequestContext) ResponseData { } personalProjects = append(personalProjects, templates.ProjectToTemplate( &p.Project, - UrlContextForProject(&p.Project).BuildHomepage(), + UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme, )) } @@ -291,7 +298,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { case models.ProjectLifecycleDead: templateData.BaseData.AddImmediateNotice( "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: templateData.BaseData.AddImmediateNotice( @@ -392,7 +399,166 @@ func ProjectNew(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 { @@ -412,3 +578,48 @@ func ProjectEdit(c *RequestContext) ResponseData { func ProjectEditSubmit(c *RequestContext) ResponseData { 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 +} diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 0ada4e98..cf8966ec 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -160,7 +160,7 @@ type RequestContext struct { Res http.ResponseWriter Conn *pgxpool.Pool - CurrentProject *models.Project + CurrentProject *models.ProjectWithLogos CurrentUser *models.User CurrentSession *models.Session Theme string diff --git a/src/website/routes.go b/src/website/routes.go index c652a9bc..cb6ad396 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -299,7 +299,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht if err != nil { 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 errors.Is(err, db.NotFound) { return FourOhFour(c) @@ -309,7 +311,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht } c.CurrentProject = &p.Project - c.UrlContext = UrlContextForProject(c.CurrentProject) + c.UrlContext = UrlContextForProject(&c.CurrentProject.Project) if !p.Project.Personal { 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()) slug := strings.TrimRight(hostPrefix, ".") - dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{}) - if err == nil { - c.CurrentProject = &dbProject.Project - } else { - if errors.Is(err, db.NotFound) { - // do nothing, this is fine + if len(slug) > 0 { + dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true}) + if err == nil { + c.CurrentProject = &dbProject.Project } 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") } - c.UrlContext = UrlContextForProject(c.CurrentProject) + c.UrlContext = UrlContextForProject(&c.CurrentProject.Project) } c.Theme = "light" diff --git a/src/website/user.go b/src/website/user.go index 56083fac..5f6300a7 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -99,34 +99,19 @@ func UserProfile(c *RequestContext) ResponseData { } c.Perf.EndBlock() - type projectQuery struct { - Project models.Project `db:"project"` + projectsQuery := ProjectsQuery{ + 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{}, - ` - SELECT $columns - 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) + + projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery) + templateProjects := make([]templates.Project, 0, len(projectsAndStuff)) + for _, p := range projectsAndStuff { templateProjects = append(templateProjects, templates.ProjectToTemplate( - &projectData.Project, - UrlContextForProject(&projectData.Project).BuildHomepage(), + &p.Project, + UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme, )) } @@ -197,7 +182,7 @@ func UserProfile(c *RequestContext) ResponseData { ProfileUserLinks: profileUserLinks, ProfileUserProjects: templateProjects, TimelineItems: timelineItems, - OwnProfile: c.CurrentUser.ID == profileUser.ID, + OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID), ShowcaseUrl: hmnurl.BuildShowcase(), NewProjectUrl: hmnurl.BuildProjectNew(), }, c.Perf)