Personal project creation
This commit is contained in:
		
							parent
							
								
									03c82c9d1a
								
							
						
					
					
						commit
						950e84d53a
					
				|  | @ -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) | ||||
|  |  | |||
							
								
								
									
										26
									
								
								src/db/db.go
								
								
								
								
							
							
						
						
									
										26
									
								
								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() | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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], | ||||
|  |  | |||
|  | @ -68,7 +68,7 @@ | |||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				{{ if .User.IsStaff }} | ||||
| 				{{ if and .Editing .User.IsStaff }} | ||||
| 					<div class="edit-form-row"> | ||||
| 						<div class="pt-input-ns">Admin settings</div> | ||||
| 					</div> | ||||
|  | @ -109,7 +109,7 @@ | |||
| 				<div class="edit-form-row"> | ||||
| 					<div class="pt-input-ns">Short description:</div> | ||||
| 					<div> | ||||
| 						<textarea maxlength="140" name="shortdesc"> | ||||
| 						<textarea required maxlength="140" name="shortdesc"> | ||||
| 							{{- .ProjectSettings.Blurb -}} | ||||
| 						</textarea> | ||||
| 						<div class="c--dim f7">Plaintext only. No links or markdown.</div> | ||||
|  |  | |||
|  | @ -68,7 +68,11 @@ | |||
| 			</div> | ||||
| 		{{ end }} | ||||
| 		<div class="description ph3 ph0-ns"> | ||||
| 			{{ if .Project.ParsedDescription }} | ||||
| 				{{ .Project.ParsedDescription }} | ||||
| 			{{ else }} | ||||
| 				{{ .Project.Blurb }} | ||||
| 			{{ end }} | ||||
| 		</div> | ||||
| 		{{ with .RecentActivity }} | ||||
| 			<div class="content-block timeline-container ph3 ph0-ns mv4"> | ||||
|  |  | |||
|  | @ -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{ | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
| 		} | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ package website | |||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"git.handmade.network/hmn/hmn/src/hmnurl" | ||||
| 
 | ||||
|  | @ -22,17 +23,20 @@ type ProjectsQuery struct { | |||
| 	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 | ||||
| 
 | ||||
| 		/* | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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,7 +463,8 @@ 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 len(slug) > 0 { | ||||
| 			dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true}) | ||||
| 			if err == nil { | ||||
| 				c.CurrentProject = &dbProject.Project | ||||
| 			} else { | ||||
|  | @ -471,6 +474,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { | |||
| 					return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if c.CurrentProject == nil { | ||||
| 			dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{ | ||||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue