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)
}
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
*/

View File

@ -52,6 +52,14 @@ func init() {
}
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{
Use: "makemigration <name> <description>...",
Short: "Create a new database migration file",
@ -95,6 +103,7 @@ func init() {
website.WebsiteCommand.AddCommand(dbCommand)
dbCommand.AddCommand(migrateCommand)
dbCommand.AddCommand(rollbackCommand)
dbCommand.AddCommand(makeMigrationCommand)
dbCommand.AddCommand(seedCommand)
dbCommand.AddCommand(seedFromFileCommand)
@ -126,7 +135,7 @@ func getCurrentVersion(ctx context.Context, conn *pgx.Conn) (types.MigrationVers
func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion {
defer func() {
recover()
recover() // NOTE(ben): wat
}()
conn := db.NewConn()
@ -267,8 +276,8 @@ func Migrate(targetVersion types.MigrationVersion) {
}
defer tx.Rollback(ctx)
fmt.Printf("Rolling back migration %v\n", version)
migration := migrations.All[version]
fmt.Printf("Rolling back migration %v (%s)\n", migration.Version(), migration.Name())
err = migration.Down(ctx, tx)
if err != nil {
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
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,
description TEXT NOT NULL,
type INT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
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")
}
_, 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
}
@ -67,5 +79,14 @@ func (m AddEducationResources) Down(ctx context.Context, tx pgx.Tx) error {
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
}

View File

@ -2,26 +2,28 @@ package models
import "time"
type EducationArticle struct {
type EduArticle struct {
ID int `db:"id"`
Title string `db:"title"`
Slug string `db:"slug"`
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 (
EducationArticleTypeArticle EducationArticleType = iota + 1
EducationArticleTypeGlossary
EduArticleTypeArticle EduArticleType = iota + 1
EduArticleTypeGlossary
)
type EducationArticleVersion struct {
type EduArticleVersion struct {
ID int `db:"id"`
ArticleID int `db:"article_id"`
Date time.Time `db:"date"`
@ -30,3 +32,11 @@ type EducationArticleVersion struct {
ContentRaw string `db:"content_raw"`
ContentHTML string `db:"content_html"`
}
type EduRole int
const (
EduRoleNone EduRole = iota
EduRoleBeta
EduRoleAuthor
)

View File

@ -13,9 +13,9 @@ type UserStatus int
const (
UserStatusInactive UserStatus = 1 // Default for new users
UserStatusConfirmed = 2 // Confirmed email address
UserStatusApproved = 3 // Approved by an admin and allowed to publicly post
UserStatusBanned = 4 // BALEETED
UserStatusConfirmed UserStatus = 2 // Confirmed email address
UserStatusApproved UserStatus = 3 // Approved by an admin and allowed to publicly post
UserStatusBanned UserStatus = 4 // BALEETED
)
type User struct {
@ -28,8 +28,9 @@ type User struct {
DateJoined time.Time `db:"date_joined"`
LastLogin *time.Time `db:"last_login"`
IsStaff bool `db:"is_staff"`
Status UserStatus `db:"status"`
IsStaff bool `db:"is_staff"`
Status UserStatus `db:"status"`
EducationRole EduRole `db:"education_role"`
Name string `db:"name"`
Bio string `db:"bio"`
@ -40,8 +41,7 @@ type User struct {
DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"`
ShowEmail bool `db:"showemail"`
CanEditLibrary bool `db:"edit_library"`
ShowEmail bool `db:"showemail"`
DiscordSaveShowcase bool `db:"discord_save_showcase"`
DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
@ -63,3 +63,11 @@ func (u *User) BestName() string {
func (u *User) IsActive() bool {
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,
Timezone: u.Timezone,
CanEditLibrary: u.CanEditLibrary,
DiscordSaveShowcase: u.DiscordSaveShowcase,
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 {
if s == nil {
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
Timezone string
CanEditLibrary bool
DiscordSaveShowcase bool
DiscordDeleteSnippetOnMessageDelete bool
}
@ -385,3 +384,16 @@ type Tag struct {
Text 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"
"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/templates"
)
@ -46,11 +47,11 @@ func EducationArticle(c *RequestContext) ResponseData {
}
type articleResult struct {
Article models.EducationArticle `db:"a"`
CurrentVersion models.EducationArticleVersion `db:"v"`
Article models.EduArticle `db:"a"`
CurrentVersion models.EduArticleVersion `db:"v"`
}
article, err := db.QueryOne[articleResult](c.Context(), c.Conn,
article, err := db.QueryOne[articleResult](c, c.Conn,
`
SELECT $columns
FROM
@ -61,7 +62,7 @@ func EducationArticle(c *RequestContext) ResponseData {
AND type = $2
`,
c.PathParams["slug"],
models.EducationArticleTypeArticle,
models.EduArticleTypeArticle,
)
if errors.Is(err, db.NotFound) {
return FourOhFour(c)
@ -78,3 +79,48 @@ func EducationArticle(c *RequestContext) ResponseData {
res.MustWriteTemplate("education_article.html", tmpl, c.Perf)
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 {
return func(c *RequestContext) (res ResponseData) {
return func(c *RequestContext) ResponseData {
if c.CurrentUser == nil {
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
}
@ -66,7 +66,7 @@ func needsAuth(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 {
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 {
// CSRF mitigation actions per the OWASP cheat sheet:
// 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.RegexEducationIndex, EducationIndex)
hmnOnly.GET(hmnurl.RegexEducationGlossary, EducationGlossary) // Must be above article so `/glossary` does not match as an article slug
hmnOnly.GET(hmnurl.RegexEducationArticle, EducationArticle)
hmnOnly.GET(hmnurl.RegexEducationGlossary, EducationGlossary)
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.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)
// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we