Add some permissions and scaffolding for education CRUD

This commit is contained in:
Ben Visness 2022-06-25 18:31:11 -05:00
parent 97d7fa96dc
commit 08d96a1040
12 changed files with 298 additions and 27 deletions

View File

@ -0,0 +1,57 @@
package hmndata
import (
"context"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
)
type EduArticleQuery struct {
Slugs []string
Types []models.EduArticleType
}
type EduArticleAndStuff struct {
Article models.EduArticle `db:"a"`
CurrentVersion models.EduArticleVersion `db:"v"`
}
func FetchEduArticles(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
q EduArticleQuery,
) ([]*EduArticleAndStuff, error) {
perf := perf.ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch education articles")
defer perf.EndBlock()
var qb db.QueryBuilder
qb.Add(`
SELECT $columns
FROM
education_article AS a
JOIN education_article_version AS v ON a.current_version = v.id
WHERE
TRUE
`)
if len(q.Slugs) > 0 {
qb.Add(`AND a.slug = ANY ($?)`, q.Slugs)
}
if len(q.Types) > 0 {
qb.Add(`AND a.type = ANY ($?)`, q.Types)
}
if currentUser == nil || !currentUser.CanSeeUnpublishedEducationContent() {
qb.Add(`AND a.published`)
}
articles, err := db.Query[EduArticleAndStuff](ctx, dbConn, qb.String(), qb.Args()...)
if err != nil {
return nil, oops.New(err, "failed to fetch education articles")
}
return articles, nil
}

View File

@ -463,6 +463,25 @@ func BuildEducationArticle(slug string) string {
return Url(fmt.Sprintf("/education/%s", slug), nil) return Url(fmt.Sprintf("/education/%s", slug), nil)
} }
var RegexEducationArticleEdit = regexp.MustCompile(`^/education/(?P<slug>[^/]+)/edit$`)
func BuildEducationArticleEdit(slug string) string {
return Url(fmt.Sprintf("/education/%s/edit", slug), nil)
}
var RegexEducationArticleDelete = regexp.MustCompile(`^/education/(?P<slug>[^/]+)/delete$`)
func BuildEducationArticleDelete(slug string) string {
return Url(fmt.Sprintf("/education/%s/delete", slug), nil)
}
var RegexEducationAdmin = regexp.MustCompile(`^/education/admin$`)
func BuildEducationAdmin() string {
defer CatchPanic()
return Url("/education/admin", nil)
}
/* /*
* Forums * Forums
*/ */

View File

@ -52,6 +52,14 @@ func init() {
} }
migrateCommand.Flags().BoolVar(&listMigrations, "list", false, "List available migrations") migrateCommand.Flags().BoolVar(&listMigrations, "list", false, "List available migrations")
rollbackCommand := &cobra.Command{
Use: "rollback",
Short: "Roll back the most recent completed migration",
Run: func(cmd *cobra.Command, args []string) {
Rollback()
},
}
makeMigrationCommand := &cobra.Command{ makeMigrationCommand := &cobra.Command{
Use: "makemigration <name> <description>...", Use: "makemigration <name> <description>...",
Short: "Create a new database migration file", Short: "Create a new database migration file",
@ -95,6 +103,7 @@ func init() {
website.WebsiteCommand.AddCommand(dbCommand) website.WebsiteCommand.AddCommand(dbCommand)
dbCommand.AddCommand(migrateCommand) dbCommand.AddCommand(migrateCommand)
dbCommand.AddCommand(rollbackCommand)
dbCommand.AddCommand(makeMigrationCommand) dbCommand.AddCommand(makeMigrationCommand)
dbCommand.AddCommand(seedCommand) dbCommand.AddCommand(seedCommand)
dbCommand.AddCommand(seedFromFileCommand) dbCommand.AddCommand(seedFromFileCommand)
@ -126,7 +135,7 @@ func getCurrentVersion(ctx context.Context, conn *pgx.Conn) (types.MigrationVers
func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion { func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion {
defer func() { defer func() {
recover() recover() // NOTE(ben): wat
}() }()
conn := db.NewConn() conn := db.NewConn()
@ -267,8 +276,8 @@ func Migrate(targetVersion types.MigrationVersion) {
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
fmt.Printf("Rolling back migration %v\n", version)
migration := migrations.All[version] migration := migrations.All[version]
fmt.Printf("Rolling back migration %v (%s)\n", migration.Version(), migration.Name())
err = migration.Down(ctx, tx) err = migration.Down(ctx, tx)
if err != nil { if err != nil {
fmt.Printf("MIGRATION FAILED for migration %v.\n", version) fmt.Printf("MIGRATION FAILED for migration %v.\n", version)
@ -291,6 +300,39 @@ func Migrate(targetVersion types.MigrationVersion) {
} }
} }
func Rollback() {
ctx := context.Background()
conn := db.NewConnWithConfig(config.PostgresConfig{
LogLevel: pgx.LogLevelWarn,
})
defer conn.Close(ctx)
currentVersion := tryGetCurrentVersion(ctx)
if currentVersion.IsZero() {
fmt.Println("You have never run migrations; nothing to do.")
return
}
var target types.MigrationVersion
versions := getSortedMigrationVersions()
for i := 1; i < len(versions); i++ {
if versions[i].Equal(currentVersion) {
target = versions[i-1]
}
}
// NOTE(ben): It occurs to me that we don't have a way to roll back the initial migration, ever.
// Not that we would ever want to....?
if target.IsZero() {
fmt.Println("You are already at the earliest migration; nothing to do.")
return
}
Migrate(target)
}
//go:embed migrationTemplate.txt //go:embed migrationTemplate.txt
var migrationTemplate string var migrationTemplate string

View File

@ -40,6 +40,7 @@ func (m AddEducationResources) Up(ctx context.Context, tx pgx.Tx) error {
slug VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
type INT NOT NULL, type INT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
current_version INT NOT NULL REFERENCES education_article_version (id) current_version INT NOT NULL REFERENCES education_article_version (id)
); );
@ -55,6 +56,17 @@ func (m AddEducationResources) Up(ctx context.Context, tx pgx.Tx) error {
return oops.New(err, "failed to create education tables") return oops.New(err, "failed to create education tables")
} }
_, err = tx.Exec(ctx,
`
ALTER TABLE hmn_user
DROP edit_library,
ADD education_role INT NOT NULL DEFAULT 0;
`,
)
if err != nil {
return oops.New(err, "failed to update user stuff")
}
return nil return nil
} }
@ -67,5 +79,14 @@ func (m AddEducationResources) Down(ctx context.Context, tx pgx.Tx) error {
return oops.New(err, "failed to delete education tables") return oops.New(err, "failed to delete education tables")
} }
_, err = tx.Exec(ctx, `
ALTER TABLE hmn_user
DROP education_role,
ADD edit_library BOOLEAN NOT NULL DEFAULT FALSE;
`)
if err != nil {
return oops.New(err, "failed to delete education tables")
}
return nil return nil
} }

View File

@ -2,26 +2,28 @@ package models
import "time" import "time"
type EducationArticle struct { type EduArticle struct {
ID int `db:"id"` ID int `db:"id"`
Title string `db:"title"` Title string `db:"title"`
Slug string `db:"slug"` Slug string `db:"slug"`
Description string `db:"description"` Description string `db:"description"`
Published bool `db:"published"` // Unpublished articles are visible to authors and beta testers.
Type EducationArticleType `db:"type"` Type EduArticleType `db:"type"`
CurrentVersion int `db:"current_version"` CurrentVersionID int `db:"current_version"`
CurrentVersion *EduArticleVersion // not in DB, set by helpers
} }
type EducationArticleType int type EduArticleType int
const ( const (
EducationArticleTypeArticle EducationArticleType = iota + 1 EduArticleTypeArticle EduArticleType = iota + 1
EducationArticleTypeGlossary EduArticleTypeGlossary
) )
type EducationArticleVersion struct { type EduArticleVersion struct {
ID int `db:"id"` ID int `db:"id"`
ArticleID int `db:"article_id"` ArticleID int `db:"article_id"`
Date time.Time `db:"date"` Date time.Time `db:"date"`
@ -30,3 +32,11 @@ type EducationArticleVersion struct {
ContentRaw string `db:"content_raw"` ContentRaw string `db:"content_raw"`
ContentHTML string `db:"content_html"` ContentHTML string `db:"content_html"`
} }
type EduRole int
const (
EduRoleNone EduRole = iota
EduRoleBeta
EduRoleAuthor
)

View File

@ -13,9 +13,9 @@ type UserStatus int
const ( const (
UserStatusInactive UserStatus = 1 // Default for new users UserStatusInactive UserStatus = 1 // Default for new users
UserStatusConfirmed = 2 // Confirmed email address UserStatusConfirmed UserStatus = 2 // Confirmed email address
UserStatusApproved = 3 // Approved by an admin and allowed to publicly post UserStatusApproved UserStatus = 3 // Approved by an admin and allowed to publicly post
UserStatusBanned = 4 // BALEETED UserStatusBanned UserStatus = 4 // BALEETED
) )
type User struct { type User struct {
@ -28,8 +28,9 @@ type User struct {
DateJoined time.Time `db:"date_joined"` DateJoined time.Time `db:"date_joined"`
LastLogin *time.Time `db:"last_login"` LastLogin *time.Time `db:"last_login"`
IsStaff bool `db:"is_staff"` IsStaff bool `db:"is_staff"`
Status UserStatus `db:"status"` Status UserStatus `db:"status"`
EducationRole EduRole `db:"education_role"`
Name string `db:"name"` Name string `db:"name"`
Bio string `db:"bio"` Bio string `db:"bio"`
@ -40,8 +41,7 @@ type User struct {
DarkTheme bool `db:"darktheme"` DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"` Timezone string `db:"timezone"`
ShowEmail bool `db:"showemail"` ShowEmail bool `db:"showemail"`
CanEditLibrary bool `db:"edit_library"`
DiscordSaveShowcase bool `db:"discord_save_showcase"` DiscordSaveShowcase bool `db:"discord_save_showcase"`
DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"` DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
@ -63,3 +63,11 @@ func (u *User) BestName() string {
func (u *User) IsActive() bool { func (u *User) IsActive() bool {
return u.Status == UserStatusConfirmed return u.Status == UserStatusConfirmed
} }
func (u *User) CanSeeUnpublishedEducationContent() bool {
return u.IsStaff || u.EducationRole == EduRoleBeta || u.EducationRole == EduRoleAuthor
}
func (u *User) CanAuthorEducation() bool {
return u.IsStaff || u.EducationRole == EduRoleAuthor
}

View File

@ -211,7 +211,6 @@ func UserToTemplate(u *models.User, currentTheme string) User {
DarkTheme: u.DarkTheme, DarkTheme: u.DarkTheme,
Timezone: u.Timezone, Timezone: u.Timezone,
CanEditLibrary: u.CanEditLibrary,
DiscordSaveShowcase: u.DiscordSaveShowcase, DiscordSaveShowcase: u.DiscordSaveShowcase,
DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete, DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete,
} }
@ -493,6 +492,27 @@ func TagToTemplate(t *models.Tag) Tag {
} }
} }
func EducationArticleToTemplate(a *models.EduArticle, currentVersion *models.EduArticleVersion) EduArticle {
res := EduArticle{
Title: a.Title,
Slug: a.Slug,
Description: a.Description,
Published: a.Published,
Url: hmnurl.BuildEducationArticle(a.Slug),
EditUrl: hmnurl.BuildEducationArticleEdit(a.Slug),
DeleteUrl: hmnurl.BuildEducationArticleDelete(a.Slug),
Content: "NO CONTENT HERE FOLKS YOU DID A BUG",
}
if currentVersion != nil {
res.Content = template.HTML(currentVersion.ContentHTML)
}
return res
}
func maybeString(s *string) string { func maybeString(s *string) string {
if s == nil { if s == nil {
return "" return ""

View File

@ -0,0 +1,21 @@
{{ template "base.html" . }}
{{ define "content" }}
<h2>Articles</h2>
<ul>
{{ range .Articles }}
<li>
{{ .Title }}
</li>
{{ end }}
</ul>
<h2>Glossary Terms</h2>
<ul>
{{ range .GlossaryTerms }}
<li>
{{ .Title }}
</li>
{{ end }}
</ul>
{{ end }}

View File

@ -189,7 +189,6 @@ type User struct {
ShowEmail bool ShowEmail bool
Timezone string Timezone string
CanEditLibrary bool
DiscordSaveShowcase bool DiscordSaveShowcase bool
DiscordDeleteSnippetOnMessageDelete bool DiscordDeleteSnippetOnMessageDelete bool
} }
@ -385,3 +384,16 @@ type Tag struct {
Text string Text string
Url string Url string
} }
type EduArticle struct {
Title string
Slug string
Description string
Published bool
Url string
EditUrl string
DeleteUrl string
Content template.HTML
}

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
) )
@ -46,11 +47,11 @@ func EducationArticle(c *RequestContext) ResponseData {
} }
type articleResult struct { type articleResult struct {
Article models.EducationArticle `db:"a"` Article models.EduArticle `db:"a"`
CurrentVersion models.EducationArticleVersion `db:"v"` CurrentVersion models.EduArticleVersion `db:"v"`
} }
article, err := db.QueryOne[articleResult](c.Context(), c.Conn, article, err := db.QueryOne[articleResult](c, c.Conn,
` `
SELECT $columns SELECT $columns
FROM FROM
@ -61,7 +62,7 @@ func EducationArticle(c *RequestContext) ResponseData {
AND type = $2 AND type = $2
`, `,
c.PathParams["slug"], c.PathParams["slug"],
models.EducationArticleTypeArticle, models.EduArticleTypeArticle,
) )
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return FourOhFour(c) return FourOhFour(c)
@ -78,3 +79,48 @@ func EducationArticle(c *RequestContext) ResponseData {
res.MustWriteTemplate("education_article.html", tmpl, c.Perf) res.MustWriteTemplate("education_article.html", tmpl, c.Perf)
return res return res
} }
func EducationAdmin(c *RequestContext) ResponseData {
articles, err := hmndata.FetchEduArticles(c, c.Conn, c.CurrentUser, hmndata.EduArticleQuery{})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
var tmplArticles []templates.EduArticle
var tmplGlossaryTerms []templates.EduArticle
for _, a := range articles {
tmpl := templates.EducationArticleToTemplate(&a.Article, &a.CurrentVersion)
switch a.Article.Type {
case models.EduArticleTypeArticle:
tmplArticles = append(tmplArticles, tmpl)
case models.EduArticleTypeGlossary:
tmplGlossaryTerms = append(tmplGlossaryTerms, tmpl)
}
}
type adminData struct {
templates.BaseData
Articles []templates.EduArticle
GlossaryTerms []templates.EduArticle
}
tmpl := adminData{
BaseData: getBaseData(c, "Education Admin", nil),
Articles: tmplArticles,
GlossaryTerms: tmplGlossaryTerms,
}
var res ResponseData
res.MustWriteTemplate("education_admin.html", tmpl, c.Perf)
return res
}
func EducationArticleEdit(c *RequestContext) ResponseData {
// TODO
panic("not implemented yet")
}
func EducationArticleDelete(c *RequestContext) ResponseData {
// TODO
panic("not implemented yet")
}

View File

@ -56,7 +56,7 @@ func trackRequestPerf(h Handler) Handler {
} }
func needsAuth(h Handler) Handler { func needsAuth(h Handler) Handler {
return func(c *RequestContext) (res ResponseData) { return func(c *RequestContext) ResponseData {
if c.CurrentUser == nil { if c.CurrentUser == nil {
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther) return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
} }
@ -66,7 +66,7 @@ func needsAuth(h Handler) Handler {
} }
func adminsOnly(h Handler) Handler { func adminsOnly(h Handler) Handler {
return func(c *RequestContext) (res ResponseData) { return func(c *RequestContext) ResponseData {
if c.CurrentUser == nil || !c.CurrentUser.IsStaff { if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
return FourOhFour(c) return FourOhFour(c)
} }
@ -75,6 +75,16 @@ func adminsOnly(h Handler) Handler {
} }
} }
func educationAuthorsOnly(h Handler) Handler {
return func(c *RequestContext) ResponseData {
if c.CurrentUser == nil || !c.CurrentUser.CanAuthorEducation() {
return FourOhFour(c)
}
return h(c)
}
}
func csrfMiddleware(h Handler) Handler { func csrfMiddleware(h Handler) Handler {
// CSRF mitigation actions per the OWASP cheat sheet: // CSRF mitigation actions per the OWASP cheat sheet:
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html // https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

View File

@ -118,12 +118,17 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl) hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl)
hmnOnly.GET(hmnurl.RegexEducationIndex, EducationIndex) hmnOnly.GET(hmnurl.RegexEducationIndex, EducationIndex)
hmnOnly.GET(hmnurl.RegexEducationGlossary, EducationGlossary) // Must be above article so `/glossary` does not match as an article slug hmnOnly.GET(hmnurl.RegexEducationGlossary, EducationGlossary)
hmnOnly.GET(hmnurl.RegexEducationArticle, EducationArticle) hmnOnly.GET(hmnurl.RegexEducationAdmin, educationAuthorsOnly(EducationAdmin))
hmnOnly.GET(hmnurl.RegexEducationArticle, EducationArticle) // Article stuff must be last so `/glossary` and others do not match as an article slug
hmnOnly.GET(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEdit))
hmnOnly.GET(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(EducationArticleDelete))
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername)) hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet) hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildEducationIndex(), http.StatusFound)
})
// Project routes can appear either at the root (e.g. hero.handmade.network/edit) // Project routes can appear either at the root (e.g. hero.handmade.network/edit)
// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we // or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we