2021-07-23 03:09:46 +00:00
|
|
|
package website
|
|
|
|
|
|
|
|
import (
|
2021-11-06 20:25:31 +00:00
|
|
|
"context"
|
|
|
|
|
2021-07-23 03:09:46 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/db"
|
2021-11-08 19:16:54 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
2021-07-23 03:09:46 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/models"
|
|
|
|
"git.handmade.network/hmn/hmn/src/oops"
|
|
|
|
)
|
|
|
|
|
2021-11-06 20:25:31 +00:00
|
|
|
type ProjectTypeQuery int
|
|
|
|
|
|
|
|
const (
|
|
|
|
PersonalProjects ProjectTypeQuery = 1 << iota
|
|
|
|
OfficialProjects
|
|
|
|
)
|
|
|
|
|
|
|
|
type ProjectsQuery struct {
|
2021-11-08 19:16:54 +00:00
|
|
|
// Available on all project queries
|
|
|
|
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
|
|
|
Types ProjectTypeQuery // bitfield
|
|
|
|
|
|
|
|
// Ignored when using FetchProject
|
|
|
|
ProjectIDs []int // if empty, all projects
|
|
|
|
Slugs []string // if empty, all projects
|
2021-11-06 20:25:31 +00:00
|
|
|
|
|
|
|
// Ignored when using CountProjects
|
|
|
|
Limit, Offset int // if empty, no pagination
|
|
|
|
}
|
|
|
|
|
|
|
|
type ProjectAndStuff struct {
|
|
|
|
Project models.Project
|
|
|
|
Owners []*models.User
|
|
|
|
}
|
|
|
|
|
|
|
|
func FetchProjects(
|
|
|
|
ctx context.Context,
|
|
|
|
dbConn db.ConnOrTx,
|
|
|
|
currentUser *models.User,
|
|
|
|
q ProjectsQuery,
|
|
|
|
) ([]ProjectAndStuff, error) {
|
|
|
|
perf := ExtractPerf(ctx)
|
|
|
|
perf.StartBlock("SQL", "Fetch projects")
|
|
|
|
defer perf.EndBlock()
|
|
|
|
|
|
|
|
var currentUserID *int
|
|
|
|
if currentUser != nil {
|
|
|
|
currentUserID = ¤tUser.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
tx, err := dbConn.Begin(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to start transaction")
|
|
|
|
}
|
|
|
|
defer tx.Rollback(ctx)
|
|
|
|
|
|
|
|
// Fetch all valid projects (not yet subject to user permission checks)
|
|
|
|
var qb db.QueryBuilder
|
|
|
|
qb.Add(`
|
|
|
|
SELECT $columns
|
|
|
|
FROM
|
|
|
|
handmade_project AS project
|
|
|
|
WHERE
|
|
|
|
NOT hidden
|
|
|
|
`)
|
2021-11-08 19:16:54 +00:00
|
|
|
if len(q.ProjectIDs) > 0 {
|
|
|
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
|
|
|
}
|
|
|
|
if len(q.Slugs) > 0 {
|
|
|
|
qb.Add(`AND project.slug = ANY ($?)`, q.Slugs)
|
|
|
|
}
|
2021-11-06 20:25:31 +00:00
|
|
|
if len(q.Lifecycles) > 0 {
|
|
|
|
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
|
|
|
|
} else {
|
|
|
|
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
|
|
|
|
}
|
|
|
|
if q.Types != 0 {
|
|
|
|
qb.Add(`AND (FALSE`)
|
|
|
|
if q.Types&PersonalProjects != 0 {
|
|
|
|
qb.Add(`OR project.personal`)
|
|
|
|
}
|
|
|
|
if q.Types&OfficialProjects != 0 {
|
|
|
|
qb.Add(`OR NOT project.personal`)
|
|
|
|
}
|
|
|
|
qb.Add(`)`)
|
|
|
|
}
|
|
|
|
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 err != nil {
|
|
|
|
return nil, oops.New(err, "failed to fetch projects")
|
|
|
|
}
|
|
|
|
iprojects := itProjects.ToSlice()
|
|
|
|
|
|
|
|
// Fetch project owners to do permission checks
|
|
|
|
projectIds := make([]int, len(iprojects))
|
|
|
|
for i, iproject := range iprojects {
|
|
|
|
projectIds[i] = iproject.(*models.Project).ID
|
|
|
|
}
|
|
|
|
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
var res []ProjectAndStuff
|
|
|
|
for i, iproject := range iprojects {
|
|
|
|
project := iproject.(*models.Project)
|
|
|
|
owners := projectOwners[i].Owners
|
|
|
|
|
|
|
|
/*
|
|
|
|
Per our spec, a user can see a project if:
|
|
|
|
- The project is official
|
|
|
|
- The project is personal and all of the project's owners are approved
|
|
|
|
- The project is personal and the current user is a collaborator (regardless of user status)
|
|
|
|
|
|
|
|
See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c
|
|
|
|
*/
|
|
|
|
|
|
|
|
var projectVisible bool
|
|
|
|
if project.Personal {
|
|
|
|
allOwnersApproved := true
|
|
|
|
for _, owner := range owners {
|
|
|
|
if owner.Status != models.UserStatusApproved {
|
|
|
|
allOwnersApproved = false
|
|
|
|
}
|
|
|
|
if currentUserID != nil && *currentUserID == owner.ID {
|
|
|
|
projectVisible = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if allOwnersApproved {
|
|
|
|
projectVisible = true
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
projectVisible = true
|
|
|
|
}
|
|
|
|
|
|
|
|
if projectVisible {
|
|
|
|
res = append(res, ProjectAndStuff{
|
|
|
|
Project: *project,
|
|
|
|
Owners: owners,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
err = tx.Commit(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to commit transaction")
|
|
|
|
}
|
|
|
|
|
|
|
|
return res, nil
|
|
|
|
}
|
|
|
|
|
2021-11-08 19:16:54 +00:00
|
|
|
/*
|
|
|
|
Fetches a single project. A wrapper around FetchProjects.
|
|
|
|
|
|
|
|
Returns db.NotFound if no result is found.
|
|
|
|
*/
|
|
|
|
func FetchProject(
|
|
|
|
ctx context.Context,
|
|
|
|
dbConn db.ConnOrTx,
|
|
|
|
currentUser *models.User,
|
|
|
|
projectID int,
|
|
|
|
q ProjectsQuery,
|
|
|
|
) (ProjectAndStuff, error) {
|
|
|
|
q.ProjectIDs = []int{projectID}
|
|
|
|
q.Limit = 1
|
|
|
|
q.Offset = 0
|
|
|
|
|
|
|
|
res, err := FetchProjects(ctx, dbConn, currentUser, q)
|
|
|
|
if err != nil {
|
|
|
|
return ProjectAndStuff{}, oops.New(err, "failed to fetch project")
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(res) == 0 {
|
|
|
|
return ProjectAndStuff{}, db.NotFound
|
|
|
|
}
|
|
|
|
|
|
|
|
return res[0], nil
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
Fetches a single project by slug. A wrapper around FetchProjects.
|
|
|
|
|
|
|
|
Returns db.NotFound if no result is found.
|
|
|
|
*/
|
|
|
|
func FetchProjectBySlug(
|
|
|
|
ctx context.Context,
|
|
|
|
dbConn db.ConnOrTx,
|
|
|
|
currentUser *models.User,
|
|
|
|
projectSlug string,
|
|
|
|
q ProjectsQuery,
|
|
|
|
) (ProjectAndStuff, error) {
|
|
|
|
q.Slugs = []string{projectSlug}
|
|
|
|
q.Limit = 1
|
|
|
|
q.Offset = 0
|
|
|
|
|
|
|
|
res, err := FetchProjects(ctx, dbConn, currentUser, q)
|
|
|
|
if err != nil {
|
|
|
|
return ProjectAndStuff{}, oops.New(err, "failed to fetch project")
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(res) == 0 {
|
|
|
|
return ProjectAndStuff{}, db.NotFound
|
|
|
|
}
|
|
|
|
|
|
|
|
return res[0], nil
|
|
|
|
}
|
|
|
|
|
2021-11-06 20:25:31 +00:00
|
|
|
func CountProjects(
|
|
|
|
ctx context.Context,
|
|
|
|
dbConn db.ConnOrTx,
|
|
|
|
currentUser *models.User,
|
|
|
|
q ProjectsQuery,
|
|
|
|
) (int, error) {
|
|
|
|
perf := ExtractPerf(ctx)
|
|
|
|
perf.StartBlock("SQL", "Count projects")
|
|
|
|
defer perf.EndBlock()
|
|
|
|
|
|
|
|
q.Limit = 0
|
|
|
|
q.Offset = 0
|
|
|
|
|
|
|
|
// I'm lazy and there probably won't ever be that many projects.
|
|
|
|
projects, err := FetchProjects(ctx, dbConn, currentUser, q)
|
|
|
|
if err != nil {
|
|
|
|
return 0, oops.New(err, "failed to fetch projects")
|
|
|
|
}
|
|
|
|
|
|
|
|
return len(projects), nil
|
|
|
|
}
|
|
|
|
|
2021-07-23 03:09:46 +00:00
|
|
|
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
|
|
|
|
if user != nil {
|
|
|
|
if user.IsStaff {
|
|
|
|
return true, nil
|
|
|
|
} else {
|
2021-11-06 20:25:31 +00:00
|
|
|
owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId)
|
2021-07-23 03:09:46 +00:00
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
for _, owner := range owners {
|
|
|
|
if owner.ID == user.ID {
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
|
2021-11-06 20:25:31 +00:00
|
|
|
type ProjectOwners struct {
|
|
|
|
ProjectID int
|
|
|
|
Owners []*models.User
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fetches all owners for multiple projects. Does NOT check permissions on the
|
|
|
|
// project IDs, since the assumption is that you will check permissions on the
|
|
|
|
// projects themselves before using any of this data.
|
|
|
|
//
|
|
|
|
// The returned slice will always have one entry for each project ID given, in
|
|
|
|
// the same order as they were provided. If there are duplicate project IDs in
|
|
|
|
// projectIds, the results will be wrong, so don't do that.
|
|
|
|
//
|
|
|
|
// This function does not verify that the requested projects do in fact exist.
|
|
|
|
func FetchMultipleProjectsOwners(
|
|
|
|
ctx context.Context,
|
|
|
|
dbConn db.ConnOrTx,
|
|
|
|
projectIds []int,
|
|
|
|
) ([]ProjectOwners, error) {
|
|
|
|
perf := ExtractPerf(ctx)
|
|
|
|
perf.StartBlock("SQL", "Fetch owners for multiple projects")
|
|
|
|
defer perf.EndBlock()
|
|
|
|
|
|
|
|
tx, err := dbConn.Begin(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to start transaction")
|
|
|
|
}
|
|
|
|
defer tx.Rollback(ctx)
|
|
|
|
|
|
|
|
// Fetch all user/project pairs for the given projects
|
|
|
|
type userProject struct {
|
|
|
|
UserID int `db:"user_id"`
|
|
|
|
ProjectID int `db:"project_id"`
|
2021-07-23 03:09:46 +00:00
|
|
|
}
|
2021-11-06 20:25:31 +00:00
|
|
|
it, err := db.Query(ctx, tx, userProject{},
|
2021-07-23 03:09:46 +00:00
|
|
|
`
|
|
|
|
SELECT $columns
|
2021-11-06 20:25:31 +00:00
|
|
|
FROM handmade_user_projects
|
|
|
|
WHERE project_id = ANY($1)
|
|
|
|
`,
|
|
|
|
projectIds,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to fetch project IDs")
|
|
|
|
}
|
|
|
|
iuserprojects := it.ToSlice()
|
|
|
|
|
|
|
|
// Get the unique user IDs from this set and fetch the users from the db
|
|
|
|
var userIds []int
|
|
|
|
for _, iuserproject := range iuserprojects {
|
|
|
|
userProject := iuserproject.(*userProject)
|
|
|
|
|
|
|
|
addUserId := true
|
|
|
|
for _, uid := range userIds {
|
|
|
|
if uid == userProject.UserID {
|
|
|
|
addUserId = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if addUserId {
|
|
|
|
userIds = append(userIds, userProject.UserID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
it, err = db.Query(ctx, tx, models.User{},
|
|
|
|
`
|
|
|
|
SELECT $columns
|
|
|
|
FROM auth_user
|
2021-07-23 03:09:46 +00:00
|
|
|
WHERE
|
2021-11-06 20:25:31 +00:00
|
|
|
id = ANY($1)
|
2021-07-23 03:09:46 +00:00
|
|
|
`,
|
2021-11-06 20:25:31 +00:00
|
|
|
userIds,
|
2021-07-23 03:09:46 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
2021-11-06 20:25:31 +00:00
|
|
|
return nil, oops.New(err, "failed to fetch users for projects")
|
2021-07-23 03:09:46 +00:00
|
|
|
}
|
2021-11-06 20:25:31 +00:00
|
|
|
iusers := it.ToSlice()
|
|
|
|
|
|
|
|
// Build the final result set with real user data
|
|
|
|
res := make([]ProjectOwners, len(projectIds))
|
|
|
|
for i, pid := range projectIds {
|
|
|
|
res[i] = ProjectOwners{ProjectID: pid}
|
2021-07-23 03:09:46 +00:00
|
|
|
}
|
2021-11-06 20:25:31 +00:00
|
|
|
for _, iuserproject := range iuserprojects {
|
|
|
|
userProject := iuserproject.(*userProject)
|
|
|
|
|
|
|
|
// Get a pointer to the existing record in the result
|
|
|
|
var projectOwners *ProjectOwners
|
|
|
|
for i := range res {
|
|
|
|
if res[i].ProjectID == userProject.ProjectID {
|
|
|
|
projectOwners = &res[i]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the full user record we fetched
|
|
|
|
var user *models.User
|
|
|
|
for _, iuser := range iusers {
|
|
|
|
u := iuser.(*models.User)
|
|
|
|
if u.ID == userProject.UserID {
|
|
|
|
user = u
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if user == nil {
|
|
|
|
panic("we apparently failed to fetch a project's owner")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Slam 'em together
|
|
|
|
projectOwners.Owners = append(projectOwners.Owners, user)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = tx.Commit(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return nil, oops.New(err, "failed to commit transaction")
|
|
|
|
}
|
|
|
|
|
|
|
|
return res, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fetches project owners for a single project. It is subject to all the same
|
|
|
|
// restrictions as FetchMultipleProjectsOwners.
|
|
|
|
func FetchProjectOwners(
|
|
|
|
ctx context.Context,
|
|
|
|
dbConn db.ConnOrTx,
|
|
|
|
projectId int,
|
|
|
|
) ([]*models.User, error) {
|
|
|
|
perf := ExtractPerf(ctx)
|
|
|
|
perf.StartBlock("SQL", "Fetch owners for project")
|
|
|
|
defer perf.EndBlock()
|
|
|
|
|
|
|
|
projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return projectOwners[0].Owners, nil
|
2021-07-23 03:09:46 +00:00
|
|
|
}
|
2021-11-08 19:16:54 +00:00
|
|
|
|
|
|
|
func UrlForProject(p *models.Project) string {
|
|
|
|
if p.Personal {
|
|
|
|
return hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name))
|
|
|
|
} else {
|
|
|
|
return hmnurl.BuildOfficialProjectHomepage(p.Slug)
|
|
|
|
}
|
|
|
|
}
|