diff --git a/src/hmndata/education_helper.go b/src/hmndata/education_helper.go new file mode 100644 index 0000000..55afccd --- /dev/null +++ b/src/hmndata/education_helper.go @@ -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 +} diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index b1221b2..867caa5 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -463,6 +463,25 @@ func BuildEducationArticle(slug string) string { return Url(fmt.Sprintf("/education/%s", slug), nil) } +var RegexEducationArticleEdit = regexp.MustCompile(`^/education/(?P[^/]+)/edit$`) + +func BuildEducationArticleEdit(slug string) string { + return Url(fmt.Sprintf("/education/%s/edit", slug), nil) +} + +var RegexEducationArticleDelete = regexp.MustCompile(`^/education/(?P[^/]+)/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 */ diff --git a/src/migration/migration.go b/src/migration/migration.go index efef1f7..50cd5c1 100644 --- a/src/migration/migration.go +++ b/src/migration/migration.go @@ -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 ...", 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 diff --git a/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go b/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go index 7e7746d..5b190ff 100644 --- a/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go +++ b/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go @@ -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 } diff --git a/src/models/education.go b/src/models/education.go index d44292e..afa4f28 100644 --- a/src/models/education.go +++ b/src/models/education.go @@ -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 +) diff --git a/src/models/user.go b/src/models/user.go index 758f346..5caaf6d 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -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 +} diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 837927f..24a17ff 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -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 "" diff --git a/src/templates/src/education_admin.html b/src/templates/src/education_admin.html new file mode 100644 index 0000000..427a473 --- /dev/null +++ b/src/templates/src/education_admin.html @@ -0,0 +1,21 @@ +{{ template "base.html" . }} + +{{ define "content" }} +

Articles

+
    + {{ range .Articles }} +
  • + {{ .Title }} +
  • + {{ end }} +
+ +

Glossary Terms

+
    + {{ range .GlossaryTerms }} +
  • + {{ .Title }} +
  • + {{ end }} +
+{{ end }} diff --git a/src/templates/types.go b/src/templates/types.go index 2699a61..3977704 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -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 +} diff --git a/src/website/education.go b/src/website/education.go index c8066d6..b41b5e5 100644 --- a/src/website/education.go +++ b/src/website/education.go @@ -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") +} diff --git a/src/website/middlewares.go b/src/website/middlewares.go index 12939af..d5191fc 100644 --- a/src/website/middlewares.go +++ b/src/website/middlewares.go @@ -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 diff --git a/src/website/routes.go b/src/website/routes.go index fe286b1..5bd82a5 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -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