Add some permissions and scaffolding for education CRUD
This commit is contained in:
parent
97d7fa96dc
commit
08d96a1040
|
@ -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
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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 }}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue