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 }}
Short description:
{{ 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)