Rework project visibility

This commit is contained in:
Ben Visness 2021-12-11 13:08:10 -06:00
parent 7cb6869fcb
commit 415ce8db43
8 changed files with 100 additions and 105 deletions

View File

@ -47,8 +47,8 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
p, err := hmndata.FetchProject(ctx, tx, nil, models.HMNProjectID, hmndata.ProjectsQuery{ p, err := hmndata.FetchProject(ctx, tx, nil, models.HMNProjectID, hmndata.ProjectsQuery{
IncludeHidden: true,
Lifecycles: models.AllProjectLifecycles, Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
}) })
if err != nil { if err != nil {
panic(err) panic(err)

View File

@ -20,8 +20,9 @@ const (
) )
type ProjectsQuery struct { type ProjectsQuery struct {
// Available on all project queries // Available on all project queries. By default, you will get projects that
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles // are generally visible to all users.
Lifecycles []models.ProjectLifecycle // If empty, defaults to visible lifecycles. Do not conflate this with permissions; those are checked separately.
Types ProjectTypeQuery // bitfield Types ProjectTypeQuery // bitfield
IncludeHidden bool IncludeHidden bool
@ -65,11 +66,6 @@ func FetchProjects(
perf.StartBlock("SQL", "Fetch projects") perf.StartBlock("SQL", "Fetch projects")
defer perf.EndBlock() defer perf.EndBlock()
var currentUserID *int
if currentUser != nil {
currentUserID = &currentUser.ID
}
tx, err := dbConn.Begin(ctx) tx, err := dbConn.Begin(ctx)
if err != nil { if err != nil {
return nil, oops.New(err, "failed to start transaction") return nil, oops.New(err, "failed to start transaction")
@ -80,11 +76,9 @@ func FetchProjects(
Project models.Project `db:"project"` Project models.Project `db:"project"`
LogoLightAsset *models.Asset `db:"logolight_asset"` LogoLightAsset *models.Asset `db:"logolight_asset"`
LogoDarkAsset *models.Asset `db:"logodark_asset"` LogoDarkAsset *models.Asset `db:"logodark_asset"`
Tag *models.Tag `db:"tags"`
} }
// If true, join against the project owners table and check visibility permissions
checkOwnerVisibility := q.IncludeHidden && currentUser != nil
// Fetch all valid projects (not yet subject to user permission checks) // Fetch all valid projects (not yet subject to user permission checks)
var qb db.QueryBuilder var qb db.QueryBuilder
if len(q.OrderBy) > 0 { if len(q.OrderBy) > 0 {
@ -96,31 +90,31 @@ func FetchProjects(
handmade_project AS project handmade_project AS project
LEFT JOIN handmade_asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id LEFT JOIN handmade_asset AS 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 LEFT JOIN handmade_asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id
LEFT JOIN tags ON project.tag = tags.id
`) `)
if len(q.OwnerIDs) > 0 { if len(q.OwnerIDs) > 0 {
qb.Add(` qb.Add(
INNER JOIN handmade_user_projects AS owner_filter ON owner_filter.project_id = project.id `
`) JOIN (
} SELECT project_id, array_agg(user_id) AS owner_ids
if checkOwnerVisibility { FROM handmade_user_projects
qb.Add(` WHERE user_id = ANY ($?)
LEFT JOIN handmade_user_projects AS owner_visibility ON owner_visibility.project_id = project.id GROUP BY project_id
`) ) AS owner_filter ON project.id = owner_filter.project_id
`,
q.OwnerIDs,
)
} }
// Filters (permissions are checked after the query, in Go)
qb.Add(` qb.Add(`
WHERE WHERE
TRUE TRUE
`) `)
if len(q.Lifecycles) > 0 {
// Filters qb.Add(`AND project.lifecycle = ANY ($?)`, q.Lifecycles)
if len(q.ProjectIDs) > 0 { } else {
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) qb.Add(`AND project.lifecycle = ANY ($?)`, models.VisibleProjectLifecycles)
}
if len(q.Slugs) > 0 {
qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs)
}
if len(q.OwnerIDs) > 0 {
qb.Add(`AND (owner_filter.user_id = ANY ($?))`, q.OwnerIDs)
} }
if q.Types != 0 { if q.Types != 0 {
qb.Add(`AND (FALSE`) qb.Add(`AND (FALSE`)
@ -132,21 +126,14 @@ func FetchProjects(
} }
qb.Add(`)`) qb.Add(`)`)
} }
// Visibility
if checkOwnerVisibility {
qb.Add(`AND ($? OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID)
}
if !q.IncludeHidden { if !q.IncludeHidden {
qb.Add(`AND NOT hidden`) qb.Add(`AND NOT project.hidden`)
} }
if len(q.Lifecycles) > 0 { if len(q.ProjectIDs) > 0 {
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles) qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
} else {
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
} }
if checkOwnerVisibility { if len(q.Slugs) > 0 {
qb.Add(`))`) qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs)
} }
// Output // Output
@ -164,21 +151,6 @@ func FetchProjects(
} }
iprojects := itProjects.ToSlice() iprojects := itProjects.ToSlice()
// Fetch project tags
var tagIDs []int
for _, iproject := range iprojects {
tagID := iproject.(*projectRow).Project.TagID
if tagID != nil {
tagIDs = append(tagIDs, *tagID)
}
}
tags, err := FetchTags(ctx, tx, TagQuery{
IDs: tagIDs,
})
if err != nil {
return nil, err
}
// Fetch project owners to do permission checks // Fetch project owners to do permission checks
projectIds := make([]int, len(iprojects)) projectIds := make([]int, len(iprojects))
for i, iproject := range iprojects { for i, iproject := range iprojects {
@ -195,49 +167,55 @@ func FetchProjects(
owners := projectOwners[i].Owners owners := projectOwners[i].Owners
/* /*
Per our spec, a user can see a project if: Here's the rundown on project permissions:
- In general, users can only see projects that are Generally Visible.
- As an exception, users can always see projects that they own.
- As an exception, staff can always see every project.
A project is Generally Visible if all the following conditions are true:
- The project has a "visible" lifecycle (per models.VisibleProjectLifecycles)
- The project is not hidden
- One of the following is true:
- The project is official - The project is official
- The project is personal and all of the project's owners are approved - 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)
As an exception, the HMN project is always generally visible.
See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c
*/ */
var projectVisible bool currentUserIsOwner := false
if row.Project.Personal {
allOwnersApproved := true allOwnersApproved := true
for _, owner := range owners { for _, owner := range owners {
if owner.Status != models.UserStatusApproved { if owner.Status != models.UserStatusApproved {
allOwnersApproved = false allOwnersApproved = false
} }
if currentUserID != nil && *currentUserID == owner.ID { if currentUser != nil && owner.ID == currentUser.ID {
projectVisible = true currentUserIsOwner = true
} }
} }
if allOwnersApproved {
projectVisible = true projectGenerallyVisible := true &&
} row.Project.Lifecycle.In(models.VisibleProjectLifecycles) &&
} else { !row.Project.Hidden &&
projectVisible = true (!row.Project.Personal || allOwnersApproved || row.Project.IsHMN())
if row.Project.IsHMN() {
projectGenerallyVisible = true // hard override
} }
projectVisible := false ||
projectGenerallyVisible ||
currentUserIsOwner ||
(currentUser != nil && currentUser.IsStaff)
if projectVisible { if projectVisible {
var projectTag *models.Tag
if row.Project.TagID != nil {
for _, tag := range tags {
if tag.ID == *row.Project.TagID {
projectTag = tag
break
}
}
}
res = append(res, ProjectAndStuff{ res = append(res, ProjectAndStuff{
Project: row.Project, Project: row.Project,
LogoLightAsset: row.LogoLightAsset, LogoLightAsset: row.LogoLightAsset,
LogoDarkAsset: row.LogoDarkAsset, LogoDarkAsset: row.LogoDarkAsset,
Owners: owners, Owners: owners,
Tag: projectTag, Tag: row.Tag,
}) })
} }
} }
@ -485,8 +463,8 @@ func SetProjectTag(
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
p, err := FetchProject(ctx, tx, nil, projectID, ProjectsQuery{ p, err := FetchProject(ctx, tx, nil, projectID, ProjectsQuery{
IncludeHidden: true,
Lifecycles: models.AllProjectLifecycles, Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -18,7 +18,7 @@ type TagQuery struct {
func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.Tag, error) { func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.Tag, error) {
perf := perf.ExtractPerf(ctx) perf := perf.ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch snippets") perf.StartBlock("SQL", "Fetch tags")
defer perf.EndBlock() defer perf.EndBlock()
var qb db.QueryBuilder var qb db.QueryBuilder

View File

@ -36,7 +36,7 @@ var AllProjectLifecycles = []ProjectLifecycle{
ProjectLifecycleLTS, ProjectLifecycleLTS,
} }
// NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must have flags = 0. // NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must not be hidden.
var VisibleProjectLifecycles = []ProjectLifecycle{ var VisibleProjectLifecycles = []ProjectLifecycle{
ProjectLifecycleActive, ProjectLifecycleActive,
ProjectLifecycleHiatus, ProjectLifecycleHiatus,
@ -44,6 +44,15 @@ var VisibleProjectLifecycles = []ProjectLifecycle{
ProjectLifecycleLTS, ProjectLifecycleLTS,
} }
func (lc ProjectLifecycle) In(lcs []ProjectLifecycle) bool {
for _, v := range lcs {
if lc == v {
return true
}
}
return false
}
const RecentProjectUpdateTimespanSec = 60 * 60 * 24 * 28 // NOTE(asaf): Four weeks const RecentProjectUpdateTimespanSec = 60 * 60 * 24 * 28 // NOTE(asaf): Four weeks
type Project struct { type Project struct {

View File

@ -157,7 +157,6 @@ func AtomFeed(c *RequestContext) ResponseData {
itemsPerFeed = 100000 itemsPerFeed = 100000
} }
projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, nil, hmndata.ProjectsQuery{ projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, nil, hmndata.ProjectsQuery{
Lifecycles: models.VisibleProjectLifecycles,
Limit: itemsPerFeed, Limit: itemsPerFeed,
Types: hmndata.OfficialProjects, Types: hmndata.OfficialProjects,
OrderBy: "date_approved DESC", OrderBy: "date_approved DESC",

View File

@ -265,7 +265,10 @@ func ProjectHomepage(c *RequestContext) ResponseData {
Value: c.CurrentProject.Blurb, Value: c.CurrentProject.Blurb,
}) })
p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID, hmndata.ProjectsQuery{}) p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
})
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
} }
@ -491,16 +494,16 @@ func ProjectEdit(c *RequestContext) ResponseData {
c.Context(), c.Conn, c.Context(), c.Conn,
c.CurrentUser, c.CurrentProject.ID, c.CurrentUser, c.CurrentProject.ID,
hmndata.ProjectsQuery{ hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true, IncludeHidden: true,
}, },
) )
owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, p.Project.ID)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch project owners")) return c.ErrorResponse(http.StatusInternalServerError, err)
} }
projectSettings := templates.ProjectToProjectSettings( projectSettings := templates.ProjectToProjectSettings(
&p.Project, &p.Project,
owners, p.Owners,
p.TagText(), p.TagText(),
p.LogoURL("light"), p.LogoURL("dark"), p.LogoURL("light"), p.LogoURL("dark"),
c.Theme, c.Theme,

View File

@ -300,7 +300,10 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
if err != nil { if err != nil {
panic(oops.New(err, "project id was not numeric (bad regex in routing)")) panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
} }
p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, id, hmndata.ProjectsQuery{}) p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, id, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
})
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return FourOhFour(c) return FourOhFour(c)
@ -465,7 +468,10 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
var owners []*models.User var owners []*models.User
if len(slug) > 0 { if len(slug) > 0 {
dbProject, err := hmndata.FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, hmndata.ProjectsQuery{}) dbProject, err := hmndata.FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
})
if err == nil { if err == nil {
c.CurrentProject = &dbProject.Project c.CurrentProject = &dbProject.Project
owners = dbProject.Owners owners = dbProject.Owners
@ -480,6 +486,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
if c.CurrentProject == nil { if c.CurrentProject == nil {
dbProject, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, hmndata.ProjectsQuery{ dbProject, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, hmndata.ProjectsQuery{
Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true, IncludeHidden: true,
}) })
if err != nil { if err != nil {

View File

@ -102,13 +102,12 @@ func UserProfile(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
projectsQuery := hmndata.ProjectsQuery{ projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{profileUser.ID}, OwnerIDs: []int{profileUser.ID},
Lifecycles: models.VisibleProjectLifecycles, Lifecycles: models.AllProjectLifecycles,
IncludeHidden: true,
OrderBy: "all_last_updated DESC", OrderBy: "all_last_updated DESC",
} })
projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery)
templateProjects := make([]templates.Project, 0, len(projectsAndStuff)) templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
numPersonalProjects := 0 numPersonalProjects := 0
for _, p := range projectsAndStuff { for _, p := range projectsAndStuff {