diff --git a/public/go_wasm_exec.js b/public/go_wasm_exec.js index 82041e6..9ce6a20 100644 --- a/public/go_wasm_exec.js +++ b/public/go_wasm_exec.js @@ -2,47 +2,18 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +"use strict"; + (() => { - // Map multiple JavaScript environments to a single common API, - // preferring web standards over Node.js API. - // - // Environments considered: - // - Browsers - // - Node.js - // - Electron - // - Parcel - // - Webpack - - if (typeof global !== "undefined") { - // global already exists - } else if (typeof window !== "undefined") { - window.global = window; - } else if (typeof self !== "undefined") { - self.global = self; - } else { - throw new Error("cannot export Go (neither global, window nor self is defined)"); - } - - if (!global.require && typeof require !== "undefined") { - global.require = require; - } - - if (!global.fs && global.require) { - const fs = require("fs"); - if (typeof fs === "object" && fs !== null && Object.keys(fs).length !== 0) { - global.fs = fs; - } - } - const enosys = () => { const err = new Error("not implemented"); err.code = "ENOSYS"; return err; }; - if (!global.fs) { + if (!globalThis.fs) { let outputBuf = ""; - global.fs = { + globalThis.fs = { constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused writeSync(fd, buf) { outputBuf += decoder.decode(buf); @@ -87,8 +58,8 @@ }; } - if (!global.process) { - global.process = { + if (!globalThis.process) { + globalThis.process = { getuid() { return -1; }, getgid() { return -1; }, geteuid() { return -1; }, @@ -102,47 +73,26 @@ } } - if (!global.crypto && global.require) { - const nodeCrypto = require("crypto"); - global.crypto = { - getRandomValues(b) { - nodeCrypto.randomFillSync(b); - }, - }; - } - if (!global.crypto) { - throw new Error("global.crypto is not available, polyfill required (getRandomValues only)"); + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); } - if (!global.performance) { - global.performance = { - now() { - const [sec, nsec] = process.hrtime(); - return sec * 1000 + nsec / 1000000; - }, - }; + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); } - if (!global.TextEncoder && global.require) { - global.TextEncoder = require("util").TextEncoder; - } - if (!global.TextEncoder) { - throw new Error("global.TextEncoder is not available, polyfill required"); + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); } - if (!global.TextDecoder && global.require) { - global.TextDecoder = require("util").TextDecoder; + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); } - if (!global.TextDecoder) { - throw new Error("global.TextDecoder is not available, polyfill required"); - } - - // End of polyfills for common API. const encoder = new TextEncoder("utf-8"); const decoder = new TextDecoder("utf-8"); - global.Go = class { + globalThis.Go = class { constructor() { this.argv = ["js"]; this.env = {}; @@ -296,8 +246,8 @@ setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); }, - // func walltime1() (sec int64, nsec int32) - "runtime.walltime1": (sp) => { + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { sp >>>= 0; const msec = (new Date).getTime(); setInt64(sp + 8, msec / 1000); @@ -401,6 +351,7 @@ storeValue(sp + 56, result); this.mem.setUint8(sp + 64, 1); } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above storeValue(sp + 56, err); this.mem.setUint8(sp + 64, 0); } @@ -417,6 +368,7 @@ storeValue(sp + 40, result); this.mem.setUint8(sp + 48, 1); } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above storeValue(sp + 40, err); this.mem.setUint8(sp + 48, 0); } @@ -433,6 +385,7 @@ storeValue(sp + 40, result); this.mem.setUint8(sp + 48, 1); } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above storeValue(sp + 40, err); this.mem.setUint8(sp + 48, 0); } @@ -514,7 +467,7 @@ null, true, false, - global, + globalThis, this, ]; this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id @@ -523,7 +476,7 @@ [null, 2], [true, 3], [false, 4], - [global, 5], + [globalThis, 5], [this, 6], ]); this._idPool = []; // unused ids that have been garbage collected @@ -564,6 +517,13 @@ offset += 8; }); + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + this._inst.exports.run(argc, argv); if (this.exited) { this._resolveExitPromise(); @@ -591,36 +551,4 @@ }; } } - - if ( - typeof module !== "undefined" && - global.require && - global.require.main === module && - global.process && - global.process.versions && - !global.process.versions.electron - ) { - if (process.argv.length < 3) { - console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); - process.exit(1); - } - - const go = new Go(); - go.argv = process.argv.slice(2); - go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env); - go.exit = process.exit; - WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { - process.on("exit", (code) => { // Node.js exits if no event handler is pending - if (code === 0 && !go.exited) { - // deadlock, make Go print error and stack traces - go._pendingEvent = { id: 0 }; - go._resume(); - } - }); - return go.run(result.instance); - }).catch((err) => { - console.error(err); - process.exit(1); - }); - } })(); diff --git a/public/parsing.wasm b/public/parsing.wasm index 5a848f4..646301a 100755 Binary files a/public/parsing.wasm and b/public/parsing.wasm differ diff --git a/public/style.css b/public/style.css index da157f3..4a0eada 100644 --- a/public/style.css +++ b/public/style.css @@ -1176,7 +1176,7 @@ img, video { .br2, .post-content code, .post-content pre > code, .post-content pre.hmn-code { border-radius: 0.25rem; } -.br3 { +.br3, .edu-article .edu-resource { border-radius: 0.5rem; } .br4 { @@ -4602,7 +4602,7 @@ code, .code { .pa2, .tab, header .root-item > a, header .submenu > a { padding: 0.5rem; } -.pa3, header #login-popup { +.pa3, .edu-article .edu-resource, header #login-popup { padding: 1rem; } .pa4 { @@ -7369,6 +7369,11 @@ article code { flex-grow: 1; flex-shrink: 1; } } +.c--inherit { + color: inherit; } + .c--inherit:hover, .c--inherit:active { + color: inherit; } + .b--theme { border-color: #666; border-color: var(--theme-color); } @@ -7421,7 +7426,7 @@ article code { border-color: #ccc; border-color: var(--theme-color-dimmest); } -.bg--dim, .post-content code, .post-content pre > code, .post-content pre.hmn-code { +.bg--dim, .post-content code, .post-content pre > code, .post-content pre.hmn-code, .edu-article .edu-resource { background-color: #f0f0f0; background-color: var(--dim-background); } @@ -8296,6 +8301,9 @@ nav.timecodes { text-align: center; margin: 10px 0; } +.edu-article .note { + color: red; } + form { margin: 0; } diff --git a/src/db/db.go b/src/db/db.go index 19791ce..fb25b19 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -158,7 +158,11 @@ func QueryOne[T any]( result, hasRow := rows.Next() if !hasRow { - return nil, NotFound + if readErr := rows.Err(); readErr != nil { + return nil, readErr + } else { + return nil, NotFound + } } return result, nil @@ -244,7 +248,11 @@ func QueryOneScalar[T any]( result, hasRow := rows.Next() if !hasRow { var zero T - return zero, NotFound + if readErr := rows.Err(); readErr != nil { + return zero, readErr + } else { + return zero, NotFound + } } return *result, nil @@ -585,6 +593,10 @@ func (it *Iterator[T]) Next() (*T, bool) { } } +func (it *Iterator[T]) Err() error { + return it.rows.Err() +} + // Takes a value from a database query (reflected) and assigns it to the // destination. If the destination is a pointer, and the value is non-nil, it // will initialize the destination before assigning. diff --git a/src/hmns3/hmns3.go b/src/hmns3/hmns3.go index 91db660..a39a5e6 100644 --- a/src/hmns3/hmns3.go +++ b/src/hmns3/hmns3.go @@ -29,7 +29,7 @@ func StartServer(ctx context.Context) jobs.Job { return jobs.Noop() } - utils.Must0(os.MkdirAll(dir, fs.ModePerm)) + utils.Must(os.MkdirAll(dir, fs.ModePerm)) s := server{ log: logging.ExtractLogger(ctx).With(). @@ -84,7 +84,7 @@ func (s *server) putObject(w http.ResponseWriter, r *http.Request) { bucket, key := bucketKey(r) w.Header().Set("Location", fmt.Sprintf("/%s", bucket)) - utils.Must0(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm)) + utils.Must(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm)) if key != "" { file := utils.Must1(os.Create(filepath.Join(dir, bucket, key))) io.Copy(file, r.Body) diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index a502944..bd02736 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -28,13 +28,13 @@ func QFromURL(u *url.URL) []Q { } var baseUrlParsed url.URL -var cacheBust string +var cacheBustVersion string var S3BaseUrl string var isTest bool func init() { SetGlobalBaseUrl(config.Config.BaseUrl) - SetCacheBust(fmt.Sprint(time.Now().Unix())) + SetCacheBustVersion(fmt.Sprint(time.Now().Unix())) SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot) } @@ -50,8 +50,8 @@ func SetGlobalBaseUrl(fullBaseUrl string) { baseUrlParsed = *parsed } -func SetCacheBust(newCacheBust string) { - cacheBust = newCacheBust +func SetCacheBustVersion(newCacheBustVersion string) { + cacheBustVersion = newCacheBustVersion } func SetS3BaseUrl(base string) { diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index a295743..ab3c890 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -75,7 +75,7 @@ func TestLogoutAction(t *testing.T) { } func TestRegister(t *testing.T) { - AssertRegexMatch(t, BuildRegister(), RegexRegister, nil) + AssertRegexMatch(t, BuildRegister(""), RegexRegister, nil) } func TestRegistrationSuccess(t *testing.T) { @@ -83,7 +83,7 @@ func TestRegistrationSuccess(t *testing.T) { } func TestEmailConfirmation(t *testing.T) { - AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"}) + AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token", ""), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"}) } func TestPasswordReset(t *testing.T) { @@ -185,6 +185,32 @@ func TestFishbowl(t *testing.T) { AssertRegexNoMatch(t, BuildFishbowl("oop")+"/otherfiles/whatever", RegexFishbowl) } +func TestEducationIndex(t *testing.T) { + AssertRegexMatch(t, BuildEducationIndex(), RegexEducationIndex, nil) + AssertRegexNoMatch(t, BuildEducationArticle("foo"), RegexEducationIndex) +} + +func TestEducationGlossary(t *testing.T) { + AssertRegexMatch(t, BuildEducationGlossary(""), RegexEducationGlossary, map[string]string{"slug": ""}) + AssertRegexMatch(t, BuildEducationGlossary("foo"), RegexEducationGlossary, map[string]string{"slug": "foo"}) +} + +func TestEducationArticle(t *testing.T) { + AssertRegexMatch(t, BuildEducationArticle("foo"), RegexEducationArticle, map[string]string{"slug": "foo"}) +} + +func TestEducationArticleNew(t *testing.T) { + AssertRegexMatch(t, BuildEducationArticleNew(), RegexEducationArticleNew, nil) +} + +func TestEducationArticleEdit(t *testing.T) { + AssertRegexMatch(t, BuildEducationArticleEdit("foo"), RegexEducationArticleEdit, map[string]string{"slug": "foo"}) +} + +func TestEducationArticleDelete(t *testing.T) { + AssertRegexMatch(t, BuildEducationArticleDelete("foo"), RegexEducationArticleDelete, map[string]string{"slug": "foo"}) +} + func TestForum(t *testing.T) { AssertRegexMatch(t, hmn.BuildForum(nil, 1), RegexForum, nil) AssertRegexMatch(t, hmn.BuildForum([]string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"}) @@ -282,22 +308,6 @@ func TestBlogPostReply(t *testing.T) { AssertSubdomain(t, hero.BuildBlogPostReply(1, 2), "hero") } -func TestLibrary(t *testing.T) { - AssertRegexMatch(t, BuildLibrary(), RegexLibrary, nil) -} - -func TestLibraryAll(t *testing.T) { - AssertRegexMatch(t, BuildLibraryAll(), RegexLibraryAll, nil) -} - -func TestLibraryTopic(t *testing.T) { - AssertRegexMatch(t, BuildLibraryTopic(1), RegexLibraryTopic, map[string]string{"topicid": "1"}) -} - -func TestLibraryResource(t *testing.T) { - AssertRegexMatch(t, BuildLibraryResource(1), RegexLibraryResource, map[string]string{"resourceid": "1"}) -} - func TestEpisodeGuide(t *testing.T) { AssertRegexMatch(t, hero.BuildEpisodeList(""), RegexEpisodeList, map[string]string{"topic": ""}) AssertRegexMatch(t, hero.BuildEpisodeList("code"), RegexEpisodeList, map[string]string{"topic": "code"}) @@ -360,6 +370,26 @@ func TestJamIndex(t *testing.T) { AssertSubdomain(t, BuildJamIndex(), "") } +func TestJamIndex2021(t *testing.T) { + AssertRegexMatch(t, BuildJamIndex2021(), RegexJamIndex2021, nil) + AssertSubdomain(t, BuildJamIndex2021(), "") +} + +func TestJamIndex2022(t *testing.T) { + AssertRegexMatch(t, BuildJamIndex2022(), RegexJamIndex2022, nil) + AssertSubdomain(t, BuildJamIndex2022(), "") +} + +func TestJamFeed2022(t *testing.T) { + AssertRegexMatch(t, BuildJamFeed2022(), RegexJamFeed2022, nil) + AssertSubdomain(t, BuildJamFeed2022(), "") +} + +func TestProjectNewJam(t *testing.T) { + AssertRegexMatch(t, BuildProjectNewJam(), RegexProjectNew, nil) + AssertSubdomain(t, BuildProjectNewJam(), "") +} + func TestDiscordOAuthCallback(t *testing.T) { AssertRegexMatch(t, BuildDiscordOAuthCallback(), RegexDiscordOAuthCallback, nil) } diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index ff76258..2789fff 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -434,6 +434,53 @@ func BuildFishbowl(slug string) string { var RegexFishbowlFiles = regexp.MustCompile(`^/fishbowl/(?P[^/]+)(?P/.+)$`) +/* + * Education + */ + +var RegexEducationIndex = regexp.MustCompile(`^/education$`) + +func BuildEducationIndex() string { + defer CatchPanic() + return Url("/education", nil) +} + +var RegexEducationGlossary = regexp.MustCompile(`^/education/glossary(/(?P[^/]+))?$`) + +func BuildEducationGlossary(termSlug string) string { + defer CatchPanic() + + if termSlug == "" { + return Url("/education/glossary", nil) + } else { + return Url(fmt.Sprintf("/education/glossary/%s", termSlug), nil) + } +} + +var RegexEducationArticle = regexp.MustCompile(`^/education/(?P[^/]+)$`) + +func BuildEducationArticle(slug string) string { + return Url(fmt.Sprintf("/education/%s", slug), nil) +} + +var RegexEducationArticleNew = regexp.MustCompile(`^/education/new$`) + +func BuildEducationArticleNew() string { + return Url("/education/new", 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) +} + /* * Forums */ @@ -651,47 +698,8 @@ func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string { * Library */ -// Any library route. Remove after we port the library. var RegexLibraryAny = regexp.MustCompile(`^/library`) -var RegexLibrary = regexp.MustCompile(`^/library$`) - -func BuildLibrary() string { - defer CatchPanic() - return Url("/library", nil) -} - -var RegexLibraryAll = regexp.MustCompile(`^/library/all$`) - -func BuildLibraryAll() string { - defer CatchPanic() - return Url("/library/all", nil) -} - -var RegexLibraryTopic = regexp.MustCompile(`^/library/topic/(?P\d+)$`) - -func BuildLibraryTopic(topicId int) string { - defer CatchPanic() - if topicId < 1 { - panic(oops.New(nil, "Invalid library topic ID (%d), must be >= 1", topicId)) - } - - var builder strings.Builder - builder.WriteString("/library/topic/") - builder.WriteString(strconv.Itoa(topicId)) - - return Url(builder.String(), nil) -} - -var RegexLibraryResource = regexp.MustCompile(`^/library/resource/(?P\d+)$`) - -func BuildLibraryResource(resourceId int) string { - defer CatchPanic() - builder := buildLibraryResourcePath(resourceId) - - return Url(builder.String(), nil) -} - /* * Episode Guide */ @@ -829,7 +837,7 @@ func BuildPublic(filepath string, cachebust bool) string { } var query []Q if cachebust { - query = []Q{{"v", cacheBust}} + query = []Q{{"v", cacheBustVersion}} } return Url(builder.String(), query) } diff --git a/src/migration/migration.go b/src/migration/migration.go index efef1f7..41ce9d0 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 @@ -323,14 +365,13 @@ func ResetDB() { // Create the HMN database user { - type pgCredentials struct { - User string - Password string - } - credentials := []pgCredentials{ - {config.Config.Postgres.User, config.Config.Postgres.Password}, // Existing HMN user - {getSystemUsername(), ""}, // Postgres.app on Mac - } + credentials := append( + []pgCredentials{ + {config.Config.Postgres.User, config.Config.Postgres.Password, false}, // Existing HMN user + {getSystemUsername(), "", true}, // Postgres.app on Mac + }, + guessCredentials()..., + ) var workingCred pgCredentials var createUserConn *pgconn.PgConn @@ -341,6 +382,9 @@ func ResetDB() { createUserConn, err = connectLowLevel(ctx, cred.User, cred.Password) if err == nil { workingCred = cred + if cred.SafeToPrint { + fmt.Printf("Connected by guessing username \"%s\" and password \"%s\".\n", cred.User, cred.Password) + } break } else { connErrors = append(connErrors, err) @@ -448,3 +492,22 @@ func getSystemUsername() string { } return u.Username } + +type pgCredentials struct { + User string + Password string + SafeToPrint bool +} + +var commonRootUsernames = []string{getSystemUsername(), "postgres", "root"} +var commonRootPasswords = []string{"", "password", "postgres"} + +func guessCredentials() []pgCredentials { + var result []pgCredentials + for _, username := range commonRootUsernames { + for _, password := range commonRootPasswords { + result = append(result, pgCredentials{username, password, true}) + } + } + return result +} diff --git a/src/migration/migrations/2022-09-10T000000Z_AddEducationResources.go b/src/migration/migrations/2022-09-10T000000Z_AddEducationResources.go new file mode 100644 index 0000000..45535c1 --- /dev/null +++ b/src/migration/migrations/2022-09-10T000000Z_AddEducationResources.go @@ -0,0 +1,92 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(AddEducationResources{}) +} + +type AddEducationResources struct{} + +func (m AddEducationResources) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2022, 9, 10, 0, 0, 0, 0, time.UTC)) +} + +func (m AddEducationResources) Name() string { + return "AddEducationResources" +} + +func (m AddEducationResources) Description() string { + return "Adds the tables needed for the 2022 education initiative" +} + +func (m AddEducationResources) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + CREATE TABLE education_article_version ( + id SERIAL NOT NULL PRIMARY KEY + ); + + CREATE TABLE education_article ( + id SERIAL NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT NOT NULL, + type INT NOT NULL, + published BOOLEAN NOT NULL DEFAULT FALSE, + current_version INT NOT NULL REFERENCES education_article_version (id) DEFERRABLE INITIALLY DEFERRED + ); + + ALTER TABLE education_article_version + ADD article_id INT NOT NULL REFERENCES education_article (id) ON DELETE CASCADE, + ADD date TIMESTAMP WITH TIME ZONE NOT NULL, + ADD content_raw TEXT NOT NULL, + ADD content_html TEXT NOT NULL, + ADD editor_id INT REFERENCES hmn_user (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED; + `, + ) + if err != nil { + 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 +} + +func (m AddEducationResources) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + DROP TABLE education_article CASCADE; + DROP TABLE education_article_version CASCADE; + `) + if err != nil { + 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/migration/seed.go b/src/migration/seed.go index c41253c..e4bc8df 100644 --- a/src/migration/seed.go +++ b/src/migration/seed.go @@ -64,7 +64,7 @@ func BareMinimumSeed() *models.Project { fmt.Println("Creating HMN project...") hmn := seedProject(ctx, tx, seedHMN, nil) - utils.Must0(tx.Commit(ctx)) + utils.Must(tx.Commit(ctx)) return hmn } @@ -166,7 +166,7 @@ func SampleSeed() { // Finally, set sequence numbers to things that won't conflict utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);")) - utils.Must0(tx.Commit(ctx)) + utils.Must(tx.Commit(ctx)) } func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User { @@ -178,7 +178,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models. status, name, bio, blurb, signature, darktheme, - showemail, edit_library, + showemail, date_joined, registration_ip, avatar_asset_id ) VALUES ( @@ -187,7 +187,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models. $5, $6, $7, $8, $9, TRUE, - $10, FALSE, + $10, '2017-01-01T00:00:00Z', '192.168.2.1', null ) RETURNING $columns @@ -198,7 +198,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models. utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)), input.ShowEmail, ) - utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password")) + utils.Must(auth.SetPassword(ctx, conn, input.Username, "password")) return user } diff --git a/src/models/education.go b/src/models/education.go new file mode 100644 index 0000000..26a4bb9 --- /dev/null +++ b/src/models/education.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" +) + +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 EduArticleType `db:"type"` + + CurrentVersionID int `db:"current_version"` + CurrentVersion *EduArticleVersion // not in DB, set by helpers +} + +type EduArticleType int + +const ( + EduArticleTypeArticle EduArticleType = iota + 1 + EduArticleTypeGlossary +) + +type EduArticleVersion struct { + ID int `db:"id"` + ArticleID int `db:"article_id"` + Date time.Time `db:"date"` + EditorID *int `db:"editor_id"` + + 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/parsing/embed.go b/src/parsing/embed.go index d97d7d2..66d633d 100644 --- a/src/parsing/embed.go +++ b/src/parsing/embed.go @@ -96,14 +96,6 @@ func (s embedParser) previewOrLegitEmbed(name string, legitHtml string) string { return legitHtml } -func extract(re *regexp.Regexp, src []byte, subexpName string) []byte { - m := re.FindSubmatch(src) - if m == nil { - return nil - } - return m[re.SubexpIndex(subexpName)] -} - func makeYoutubeEmbed(vid string, preview bool) string { if preview { return ` diff --git a/src/parsing/ggcode.go b/src/parsing/ggcode.go new file mode 100644 index 0000000..e974909 --- /dev/null +++ b/src/parsing/ggcode.go @@ -0,0 +1,316 @@ +package parsing + +import ( + "bytes" + "fmt" + "regexp" + + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/utils" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + gast "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// NOTE(ben): ggcode is my cute name for our custom extension syntax because I got fed up with +// bbcode. It's designed to be a more natural fit for Goldmark's method of parsing, while still +// being a general-purpose tag-like syntax that's easy for us to add instances of without writing +// new Goldmark parsers. +// +// Inline ggcode is delimited by two exclamation marks. Block ggcode is delimited by three. Inline +// ggcode uses parentheses to delimit the start and end of the affected content. Block ggcode is +// like a fenced code block and ends with !!!. ggcode sections can optionally have named string +// arguments inside braces. Quotes around the value are mandatory. +// +// Inline example: +// +// See our article on !!glossary{slug="tcp"}(TCP) for more details. +// +// Block example: +// +// !!!resource{name="Beej's Guide to Network Programming" url="https://beej.us/guide/bgnet/html/"} +// This is a _fantastic_ resource on network programming, suitable for beginners. +// !!! +// + +var ggcodeTags = map[string]ggcodeTag{ + "glossary": { + Filter: ggcodeFilterEdu, + Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error { + if entering { + term, _ := n.Args["term"] + c.W.WriteString(fmt.Sprintf( + ``, + hmnurl.BuildEducationGlossary(term), + term, + )) + } else { + c.W.WriteString("") + } + return nil + }, + }, + "resource": { + Filter: ggcodeFilterEdu, + Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error { + if entering { + c.W.WriteString(`
`) + c.W.WriteString(fmt.Sprintf(`

%s

`, n.Args["url"], utils.OrDefault(n.Args["name"], "[missing `name`]"))) + } else { + c.W.WriteString("
") + } + return nil + }, + }, + "note": { + Filter: ggcodeFilterEdu, + Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error { + if entering { + c.W.WriteString(``) + } else { + c.W.WriteString(``) + } + return nil + }, + }, +} + +// ---------------------- +// Types +// ---------------------- + +type ggcodeRendererContext struct { + W util.BufWriter + Source []byte + Opts MarkdownOptions +} + +type ggcodeTagFilter func(opts MarkdownOptions) bool +type ggcodeRenderer func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error + +type ggcodeTag struct { + Filter ggcodeTagFilter + Renderer ggcodeRenderer +} + +var ggcodeFilterEdu ggcodeTagFilter = func(opts MarkdownOptions) bool { + return opts.Education +} + +// ---------------------- +// Parsers and delimiters +// ---------------------- + +var reGGCodeBlockOpen = regexp.MustCompile(`^!!!(?P[a-zA-Z0-9-_]+)(\{(?P.*?)\})?$`) +var reGGCodeInline = regexp.MustCompile(`^!!(?P[a-zA-Z0-9-_]+)(\{(?P.*?)\})?(\((?P.*?)\))?`) +var reGGCodeArgs = regexp.MustCompile(`(?P[a-zA-Z0-9-_]+)="(?P.*?)"`) + +// Block parser stuff + +type ggcodeBlockParser struct{} + +var _ parser.BlockParser = ggcodeBlockParser{} + +func (s ggcodeBlockParser) Trigger() []byte { + return []byte("!") +} + +func (s ggcodeBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + restOfLine, _ := reader.PeekLine() + + if match := extractMap(reGGCodeBlockOpen, bytes.TrimSpace(restOfLine)); match != nil { + name := string(match["name"]) + var args map[string]string + if argsMatch := extractAllMap(reGGCodeArgs, match["args"]); argsMatch != nil { + args = make(map[string]string) + for i := range argsMatch["arg"] { + arg := string(argsMatch["arg"][i]) + val := string(argsMatch["val"][i]) + args[arg] = val + } + } + + reader.Advance(len(restOfLine)) + return &ggcodeNode{ + Name: name, + Args: args, + }, parser.Continue | parser.HasChildren + } else { + return nil, parser.NoChildren + } +} + +func (s ggcodeBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + line, _ := reader.PeekLine() + if string(bytes.TrimSpace(line)) == "!!!" { + reader.Advance(3) + return parser.Close + } + return parser.Continue | parser.HasChildren +} + +func (s ggcodeBlockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {} + +func (s ggcodeBlockParser) CanInterruptParagraph() bool { + return false +} + +func (s ggcodeBlockParser) CanAcceptIndentedLine() bool { + return false +} + +// Inline parser stuff + +type ggcodeInlineParser struct{} + +var _ parser.InlineParser = ggcodeInlineParser{} + +func (s ggcodeInlineParser) Trigger() []byte { + return []byte("!()") +} + +func (s ggcodeInlineParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { + restOfLine, segment := block.PeekLine() // Gets the rest of the line (starting at the current parser cursor index), and the segment representing the indices in the source text. + if match := extractMap(reGGCodeInline, restOfLine); match != nil { + name := string(match["name"]) + var args map[string]string + if argsMatch := extractAllMap(reGGCodeArgs, match["args"]); argsMatch != nil { + args = make(map[string]string) + for i := range argsMatch["arg"] { + arg := string(argsMatch["arg"][i]) + val := string(argsMatch["val"][i]) + args[arg] = val + } + } + + node := &ggcodeNode{ + Name: name, + Args: args, + } + contentLength := len(match["content"]) + if contentLength > 0 { + contentSegmentStart := segment.Start + len(match["all"]) - (contentLength + 1) // the 1 is for the end parenthesis + contentSegmentEnd := contentSegmentStart + contentLength + contentSegment := text.NewSegment(contentSegmentStart, contentSegmentEnd) + node.AppendChild(node, ast.NewTextSegment(contentSegment)) + } + + block.Advance(len(match["all"])) + return node + } else { + return nil + } +} + +type ggcodeDelimiterParser struct { + Node *ggcodeNode // We need to pass this through 🙄 +} + +func (p ggcodeDelimiterParser) IsDelimiter(b byte) bool { + fmt.Println("delmit", string(b)) + return b == '(' || b == ')' +} + +func (p ggcodeDelimiterParser) CanOpenCloser(opener, closer *parser.Delimiter) bool { + fmt.Println("oopen") + return opener.Char == '(' && closer.Char == ')' +} + +func (p ggcodeDelimiterParser) OnMatch(consumes int) gast.Node { + fmt.Println("out!") + return p.Node +} + +// ---------------------- +// AST node +// ---------------------- + +type ggcodeNode struct { + gast.BaseBlock + Name string + Args map[string]string +} + +var _ ast.Node = &ggcodeNode{} + +func (n *ggcodeNode) Dump(source []byte, level int) { + gast.DumpHelper(n, source, level, n.Args, nil) +} + +var kindGGCode = gast.NewNodeKind("ggcode") + +func (n *ggcodeNode) Kind() gast.NodeKind { + return kindGGCode +} + +// ---------------------- +// Renderer +// ---------------------- + +type ggcodeHTMLRenderer struct { + html.Config + Opts MarkdownOptions +} + +func newGGCodeHTMLRenderer(markdownOpts MarkdownOptions, opts ...html.Option) renderer.NodeRenderer { + r := &ggcodeHTMLRenderer{ + Opts: markdownOpts, + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *ggcodeHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(kindGGCode, r.render) +} + +func (r *ggcodeHTMLRenderer) render(w util.BufWriter, source []byte, n gast.Node, entering bool) (gast.WalkStatus, error) { + node := n.(*ggcodeNode) + var renderer ggcodeRenderer = defaultGGCodeRenderer + if tag, ok := ggcodeTags[node.Name]; ok { + if tag.Filter == nil || tag.Filter(r.Opts) { + renderer = tag.Renderer + } + } + err := renderer(ggcodeRendererContext{ + W: w, + Source: source, + Opts: r.Opts, + }, node, entering) + return gast.WalkContinue, err +} + +func defaultGGCodeRenderer(c ggcodeRendererContext, n *ggcodeNode, entering bool) error { + if entering { + c.W.WriteString("[unknown ggcode tag]") + } + return nil +} + +// ---------------------- +// Extension +// ---------------------- + +type ggcodeExtension struct { + Opts MarkdownOptions +} + +func (e ggcodeExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(ggcodeBlockParser{}, 500), + )) + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(ggcodeInlineParser{}, 500), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(newGGCodeHTMLRenderer(e.Opts), 500), + )) +} diff --git a/src/parsing/parsing.go b/src/parsing/parsing.go index 9a74740..b2dae1e 100644 --- a/src/parsing/parsing.go +++ b/src/parsing/parsing.go @@ -46,6 +46,24 @@ var DiscordMarkdown = makeGoldmark( goldmark.WithRendererOptions(html.WithHardWraps()), ) +// Used for rendering real-time previews of post content. +var EducationPreviewMarkdown = makeGoldmark( + goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{ + Previews: true, + Embeds: true, + Education: true, + })...), +) + +// Used for generating the final HTML for a post. +var EducationRealMarkdown = makeGoldmark( + goldmark.WithExtensions(makeGoldmarkExtensions(MarkdownOptions{ + Previews: false, + Embeds: true, + Education: true, + })...), +) + func ParseMarkdown(source string, md goldmark.Markdown) string { var buf bytes.Buffer if err := md.Convert([]byte(source), &buf); err != nil { @@ -56,8 +74,9 @@ func ParseMarkdown(source string, md goldmark.Markdown) string { } type MarkdownOptions struct { - Previews bool - Embeds bool + Previews bool + Embeds bool + Education bool } func makeGoldmark(opts ...goldmark.Option) goldmark.Markdown { @@ -116,6 +135,9 @@ func makeGoldmarkExtensions(opts MarkdownOptions) []goldmark.Extender { BBCodeExtension{ Preview: opts.Previews, }, + ggcodeExtension{ + Opts: opts, + }, ) return extenders diff --git a/src/parsing/util.go b/src/parsing/util.go new file mode 100644 index 0000000..b1ec985 --- /dev/null +++ b/src/parsing/util.go @@ -0,0 +1,54 @@ +package parsing + +import "regexp" + +func extract(re *regexp.Regexp, src []byte, subexpName string) []byte { + m := re.FindSubmatch(src) + if m == nil { + return nil + } + return m[re.SubexpIndex(subexpName)] +} + +func extractMap(re *regexp.Regexp, src []byte) map[string][]byte { + m := re.FindSubmatch(src) + if m == nil { + return nil + } + res := make(map[string][]byte) + for _, name := range re.SubexpNames() { + if name != "" { + i := re.SubexpIndex(name) + res[name] = m[i] + } + } + res["all"] = m[0] + return res +} + +func extractAll(re *regexp.Regexp, src []byte, subexpName string) [][]byte { + m := re.FindAllSubmatch(src, -1) + if m == nil { + return nil + } + return m[re.SubexpIndex(subexpName)] +} + +func extractAllMap(re *regexp.Regexp, src []byte) map[string][][]byte { + m := re.FindAllSubmatch(src, -1) + if m == nil { + return nil + } + res := make(map[string][][]byte) + for i, name := range re.SubexpNames() { + if name != "" { + var vals [][]byte + for _, specificMatch := range m { + vals = append(vals, specificMatch[i]) + } + res[name] = vals + } + } + res["all"] = m[0] + return res +} diff --git a/src/parsing/wasm/build.go b/src/parsing/wasm/build.go new file mode 100644 index 0000000..22b8248 --- /dev/null +++ b/src/parsing/wasm/build.go @@ -0,0 +1,57 @@ +//go:build !js + +package main + +import ( + "fmt" + "go/build" + "io" + "os" + "os/exec" + "path/filepath" + + "git.handmade.network/hmn/hmn/src/utils" +) + +func main() { + const publicDir = "../../../public" + compile := exec.Command("go", "build", "-o", filepath.Join(publicDir, "parsing.wasm")) + compile.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + run(compile) + + utils.Must(copy( + fmt.Sprintf("%s/misc/wasm/wasm_exec.js", build.Default.GOROOT), + filepath.Join(publicDir, "go_wasm_exec.js"), + )) +} + +func run(cmd *exec.Cmd) { + output, err := cmd.CombinedOutput() + fmt.Print(string(output)) + if err != nil { + fmt.Println(err) + if exit, ok := err.(*exec.ExitError); ok { + os.Exit(exit.ExitCode()) + } + } +} + +func copy(src string, dst string) error { + s, err := os.Open(src) + if err != nil { + return err + } + defer s.Close() + + d, err := os.Create(dst) + if err != nil { + return err + } + + if _, err := io.Copy(d, s); err != nil { + d.Close() + return err + } + + return d.Close() +} diff --git a/src/parsing/wasm/build.sh b/src/parsing/wasm/build.sh deleted file mode 100755 index a331e83..0000000 --- a/src/parsing/wasm/build.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -PUBLIC_DIR=../../../public - -GOOS=js GOARCH=wasm go build -o $PUBLIC_DIR/parsing.wasm -cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" $PUBLIC_DIR/go_wasm_exec.js diff --git a/src/parsing/wasm/parsingmain.go b/src/parsing/wasm/parsingmain.go index 6bec65d..5bfea35 100644 --- a/src/parsing/wasm/parsingmain.go +++ b/src/parsing/wasm/parsingmain.go @@ -1,4 +1,4 @@ -// +build js +//go:build js package main @@ -12,6 +12,9 @@ func main() { js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} { return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown) })) + js.Global().Set("parseMarkdownEdu", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return parsing.ParseMarkdown(args[0].String(), parsing.EducationPreviewMarkdown) + })) var done chan bool <-done // block forever diff --git a/src/rawdata/scss/_core.scss b/src/rawdata/scss/_core.scss index bb05d4c..594fa37 100644 --- a/src/rawdata/scss/_core.scss +++ b/src/rawdata/scss/_core.scss @@ -197,6 +197,14 @@ article code { } } +.c--inherit { + color: inherit; + + &:hover, &:active { + color: inherit; + } +} + .b--theme { @include usevar(border-color, theme-color); } diff --git a/src/rawdata/scss/_education.scss b/src/rawdata/scss/_education.scss new file mode 100644 index 0000000..f3c7d71 --- /dev/null +++ b/src/rawdata/scss/_education.scss @@ -0,0 +1,9 @@ +.edu-article { + .edu-resource { + @extend .pa3, .bg--dim, .br3; + } + + .note { + color: red; + } +} diff --git a/src/rawdata/scss/style.scss b/src/rawdata/scss/style.scss index 461ec3a..6470c91 100644 --- a/src/rawdata/scss/style.scss +++ b/src/rawdata/scss/style.scss @@ -12,6 +12,7 @@ @import 'content'; @import 'editor'; @import 'episodes'; +@import 'education'; @import 'forms'; @import 'forum'; @import 'header'; diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 837927f..43db646 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -211,9 +211,11 @@ func UserToTemplate(u *models.User, currentTheme string) User { DarkTheme: u.DarkTheme, Timezone: u.Timezone, - CanEditLibrary: u.CanEditLibrary, DiscordSaveShowcase: u.DiscordSaveShowcase, DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete, + + IsEduTester: u.CanSeeUnpublishedEducationContent(), + IsEduAuthor: u.CanAuthorEducation(), } } @@ -493,6 +495,33 @@ func TagToTemplate(t *models.Tag) Tag { } } +func EducationArticleToTemplate(a *models.EduArticle) 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", + } + switch a.Type { + case models.EduArticleTypeArticle: + res.Type = "article" + case models.EduArticleTypeGlossary: + res.Type = "glossary" + } + + if a.CurrentVersion != nil { + res.Content = template.HTML(a.CurrentVersion.ContentHTML) + } + + return res +} + func maybeString(s *string) string { if s == nil { return "" diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html index 5a83170..dbabc5f 100644 --- a/src/templates/src/editor.html +++ b/src/templates/src/editor.html @@ -19,15 +19,15 @@ {{ define "content" }}
- {{ if not .CanEditTitle }} -

{{ .Title }}

+ {{ if not .CanEditPostTitle }} +

{{ .PostTitle }}

{{ end }}
{{ csrftoken .Session }} - {{ if .CanEditTitle }} - + {{ if .CanEditPostTitle }} + {{ end }} {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}} {{/* @@ -79,8 +79,36 @@ {{ template "forum_post_standalone.html" . }}
{{ end }} + + {{ if .ShowEduOptions }} + {{/* Hope you have a .Article field! */}} +
+

Education Options

+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ {{ end }} -
+
{{/* NOTE(asaf): Placing this outside the form to avoid submitting it to the server by accident */}} diff --git a/src/templates/src/education_article.html b/src/templates/src/education_article.html new file mode 100644 index 0000000..cb8ce9d --- /dev/null +++ b/src/templates/src/education_article.html @@ -0,0 +1,19 @@ +{{ template "base.html" . }} + +{{ define "content" }} +

{{ .Title }}

+ {{ if and .User .User.IsEduAuthor }} + + {{ end }} +
+
+ {{ .Article.Content }} +
+
+ I'm a sidebar! +
+
+{{ end }} diff --git a/src/templates/src/education_article_delete.html b/src/templates/src/education_article_delete.html new file mode 100644 index 0000000..95c7edb --- /dev/null +++ b/src/templates/src/education_article_delete.html @@ -0,0 +1,15 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Are you sure you want to delete this article?

+
+

{{ .Article.Title }}

+ {{ .Article.Content }} +
+
+ {{ csrftoken .Session }} + +
+
+{{ end }} diff --git a/src/templates/src/education_glossary.html b/src/templates/src/education_glossary.html new file mode 100644 index 0000000..a76feeb --- /dev/null +++ b/src/templates/src/education_glossary.html @@ -0,0 +1,5 @@ +{{ template "base.html" . }} + +{{ define "content" }} + O YES +{{ end }} diff --git a/src/templates/src/education_index.html b/src/templates/src/education_index.html new file mode 100644 index 0000000..3ecd2e8 --- /dev/null +++ b/src/templates/src/education_index.html @@ -0,0 +1,43 @@ +{{ template "base.html" . }} + +{{ define "content" }} +

Learn the Handmade way.

+ +

Guides

+ + {{ if .User.IsEduAuthor }} + + {{ end }} + +
+ {{ range .Articles }} + + +
{{ .Description }}
+
+ {{ end }} +
+ +

What makes us different?

+ +
+
+

Real material.

+ + We equip you to go straight to the source. Our guides are structured around books and articles written by experts. We give you high-quality material to read, and the context to understand it. You do the rest. +
+
+

For any skill level.

+ + Each guide runs the gamut from beginner to advanced. Whether you're new to a topic or have been practicing it for years, read through our guides and you'll find something new. +
+
+

Designed for programmers.

+ + We're not here to teach you how to program. We're here to teach you a specific topic. +
+
+ +{{ end }} diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 75183a7..8346ec0 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -84,7 +84,7 @@
diff --git a/src/templates/src/include/markdown_previews.html b/src/templates/src/include/markdown_previews.html index 2b8b32a..aa7dca9 100644 --- a/src/templates/src/include/markdown_previews.html +++ b/src/templates/src/include/markdown_previews.html @@ -97,6 +97,7 @@ previewWorker.postMessage({ elementID: inputEl.id, markdown: inputEl.value, + parserName: '{{ or .ParserName "parseMarkdown" }}', }); } diff --git a/src/templates/src/layouts/base.html b/src/templates/src/layouts/base.html index 0f52f43..f84ab2a 100644 --- a/src/templates/src/layouts/base.html +++ b/src/templates/src/layouts/base.html @@ -1,5 +1,5 @@ - - + + diff --git a/src/templates/src/markdown_worker.js b/src/templates/src/markdown_worker.js index c602abc..357e925 100644 --- a/src/templates/src/markdown_worker.js +++ b/src/templates/src/markdown_worker.js @@ -1,4 +1,7 @@ -importScripts('/public/go_wasm_exec.js'); +importScripts('{{ static "go_wasm_exec.js" }}'); + +// wowee good javascript yeah +const global = Function('return this')(); /* NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages @@ -9,13 +12,13 @@ let wasmLoaded = false; let jobs = {}; onmessage = ({ data }) => { - const { elementID, markdown } = data; - jobs[elementID] = markdown; + const { elementID, markdown, parserName } = data; + jobs[elementID] = { markdown, parserName }; setTimeout(doPreview, 0); } const go = new Go(); -WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject) +WebAssembly.instantiateStreaming(fetch('{{ static "parsing.wasm" }}'), go.importObject) .then(result => { go.run(result.instance); // don't await this; we want it to be continuously running wasmLoaded = true; @@ -27,8 +30,8 @@ const doPreview = () => { return; } - for (const [elementID, markdown] of Object.entries(jobs)) { - const html = parseMarkdown(markdown); + for (const [elementID, { markdown, parserName }] of Object.entries(jobs)) { + const html = global[parserName](markdown); postMessage({ elementID: elementID, html: html, diff --git a/src/templates/types.go b/src/templates/types.go index 2699a61..0173c88 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -51,8 +51,8 @@ type Header struct { PodcastUrl string FishbowlUrl string ForumsUrl string - LibraryUrl string ConferencesUrl string + EducationUrl string Project *ProjectHeader } @@ -189,9 +189,11 @@ type User struct { ShowEmail bool Timezone string - CanEditLibrary bool DiscordSaveShowcase bool DiscordDeleteSnippetOnMessageDelete bool + + IsEduTester bool + IsEduAuthor bool } type Link struct { @@ -385,3 +387,17 @@ type Tag struct { Text string Url string } + +type EduArticle struct { + Title string + Slug string + Description string + Published bool + Type string + + Url string + EditUrl string + DeleteUrl string + + Content template.HTML +} diff --git a/src/utils/utils.go b/src/utils/utils.go index b8d73f7..6df2c0d 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -22,7 +22,7 @@ func OrDefault[T comparable](v T, def T) T { // Takes an (error) return and panics if there is an error. // Helps avoid `if err != nil` in scripts. Use sparingly in real code. -func Must0(err error) { +func Must(err error) { if err != nil { panic(err) } diff --git a/src/website/base_data.go b/src/website/base_data.go index 7926a83..a813c74 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -74,8 +74,8 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc PodcastUrl: hmnurl.BuildPodcast(), FishbowlUrl: hmnurl.BuildFishbowlIndex(), ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1), - LibraryUrl: hmnurl.BuildLibrary(), ConferencesUrl: hmnurl.BuildConferences(), + EducationUrl: hmnurl.BuildEducationIndex(), }, Footer: templates.Footer{ HomepageUrl: hmnurl.BuildHomepage(), diff --git a/src/website/education.go b/src/website/education.go new file mode 100644 index 0000000..c27d22f --- /dev/null +++ b/src/website/education.go @@ -0,0 +1,426 @@ +package website + +import ( + "context" + "errors" + "fmt" + "html/template" + "net/http" + "net/url" + "regexp" + "time" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/parsing" + "git.handmade.network/hmn/hmn/src/templates" + "git.handmade.network/hmn/hmn/src/utils" +) + +func EducationIndex(c *RequestContext) ResponseData { + type indexData struct { + templates.BaseData + Articles []templates.EduArticle + NewArticleUrl string + } + + articles, err := fetchEduArticles(c, c.Conn, models.EduArticleTypeArticle, c.CurrentUser) + if err != nil { + panic(err) + } + + var tmplArticles []templates.EduArticle + for _, article := range articles { + tmplArticles = append(tmplArticles, templates.EducationArticleToTemplate(&article)) + } + + tmpl := indexData{ + BaseData: getBaseData(c, "Handmade Education", nil), + Articles: tmplArticles, + NewArticleUrl: hmnurl.BuildEducationArticleNew(), + } + + var res ResponseData + res.MustWriteTemplate("education_index.html", tmpl, c.Perf) + return res +} + +func EducationGlossary(c *RequestContext) ResponseData { + type glossaryData struct { + templates.BaseData + } + + tmpl := glossaryData{ + BaseData: getBaseData(c, "Handmade Education", nil), + } + + var res ResponseData + res.MustWriteTemplate("education_glossary.html", tmpl, c.Perf) + return res +} + +var reEduEditorsNote = regexp.MustCompile(`.*?`) + +func EducationArticle(c *RequestContext) ResponseData { + type articleData struct { + templates.BaseData + Article templates.EduArticle + EditUrl string + DeleteUrl string + } + + article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], models.EduArticleTypeArticle, c.CurrentUser) + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, err) + } + + tmpl := articleData{ + BaseData: getBaseData(c, article.Title, nil), + Article: templates.EducationArticleToTemplate(article), + EditUrl: hmnurl.BuildEducationArticleEdit(article.Slug), + DeleteUrl: hmnurl.BuildEducationArticleDelete(article.Slug), + } + tmpl.OpenGraphItems = append(tmpl.OpenGraphItems, + templates.OpenGraphItem{Property: "og:description", Value: string(article.Description)}, + ) + tmpl.Breadcrumbs = []templates.Breadcrumb{ + {Name: "Education", Url: hmnurl.BuildEducationIndex()}, + {Name: article.Title, Url: hmnurl.BuildEducationArticle(article.Slug)}, + } + + // Remove editor's notes + if c.CurrentUser == nil || !c.CurrentUser.CanAuthorEducation() { + tmpl.Article.Content = template.HTML(reEduEditorsNote.ReplaceAllLiteralString(string(tmpl.Article.Content), "")) + } + + var res ResponseData + res.MustWriteTemplate("education_article.html", tmpl, c.Perf) + return res +} + +func EducationArticleNew(c *RequestContext) ResponseData { + type adminData struct { + editorData + Article map[string]interface{} + } + + tmpl := adminData{ + editorData: getEditorDataForEduArticle(c.UrlContext, c.CurrentUser, getBaseData(c, "New Education Article", nil), nil), + } + tmpl.editorData.SubmitUrl = hmnurl.BuildEducationArticleNew() + + var res ResponseData + res.MustWriteTemplate("editor.html", tmpl, c.Perf) + return res +} + +func EducationArticleNewSubmit(c *RequestContext) ResponseData { + form, err := c.GetFormValues() + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, err) + } + + art, ver := getEduArticleFromForm(form) + + dupe := 0 < db.MustQueryOneScalar[int](c, c.Conn, + ` + SELECT COUNT(*) FROM education_article + WHERE slug = $1 + `, + art.Slug, + ) + if dupe { + return c.RejectRequest("A resource already exists with that slug.") + } + + createEduArticle(c, art, ver) + + res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther) + res.AddFutureNotice("success", "Created new education article.") + return res +} + +func EducationArticleEdit(c *RequestContext) ResponseData { + type adminData struct { + editorData + Article templates.EduArticle + } + + article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0, c.CurrentUser) + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + panic(err) + } + + tmpl := adminData{ + editorData: getEditorDataForEduArticle(c.UrlContext, c.CurrentUser, getBaseData(c, "Edit Education Article", nil), article), + Article: templates.EducationArticleToTemplate(article), + } + tmpl.editorData.SubmitUrl = hmnurl.BuildEducationArticleEdit(c.PathParams["slug"]) + + var res ResponseData + res.MustWriteTemplate("editor.html", tmpl, c.Perf) + return res +} + +func EducationArticleEditSubmit(c *RequestContext) ResponseData { + form, err := c.GetFormValues() + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, err) + } + + _, err = fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0, c.CurrentUser) + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + panic(err) + } + + art, ver := getEduArticleFromForm(form) + updateEduArticle(c, c.PathParams["slug"], art, ver) + + res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther) + res.AddFutureNotice("success", "Edited education article.") + return res +} + +func EducationArticleDelete(c *RequestContext) ResponseData { + article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0, c.CurrentUser) + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + panic(err) + } + + type deleteData struct { + templates.BaseData + Article templates.EduArticle + SubmitUrl string + } + + baseData := getBaseData(c, fmt.Sprintf("Deleting \"%s\"", article.Title), nil) + + var res ResponseData + res.MustWriteTemplate("education_article_delete.html", deleteData{ + BaseData: baseData, + Article: templates.EducationArticleToTemplate(article), + SubmitUrl: hmnurl.BuildEducationArticleDelete(article.Slug), + }, c.Perf) + return res +} + +func EducationArticleDeleteSubmit(c *RequestContext) ResponseData { + _, err := c.Conn.Exec(c, `DELETE FROM education_article WHERE slug = $1`, c.PathParams["slug"]) + if err != nil { + panic(err) + } + + res := c.Redirect(hmnurl.BuildEducationIndex(), http.StatusSeeOther) + res.AddFutureNotice("success", "Article deleted.") + return res +} + +func fetchEduArticles( + ctx context.Context, + dbConn db.ConnOrTx, + t models.EduArticleType, + currentUser *models.User, +) ([]models.EduArticle, error) { + type eduArticleResult struct { + Article models.EduArticle `db:"a"` + CurrentVersion models.EduArticleVersion `db:"v"` + } + + 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 t != 0 { + qb.Add(`AND a.type = $?`, t) + } + if currentUser == nil || !currentUser.CanSeeUnpublishedEducationContent() { + qb.Add(`AND NOT a.published`) + } + + articles, err := db.Query[eduArticleResult](ctx, dbConn, qb.String(), qb.Args()...) + if err != nil { + return nil, err + } + + var res []models.EduArticle + for _, article := range articles { + ver := article.CurrentVersion + article.Article.CurrentVersion = &ver + res = append(res, article.Article) + } + + return res, nil +} + +func fetchEduArticle( + ctx context.Context, + dbConn db.ConnOrTx, + slug string, + t models.EduArticleType, + currentUser *models.User, +) (*models.EduArticle, error) { + type eduArticleResult struct { + Article models.EduArticle `db:"a"` + CurrentVersion models.EduArticleVersion `db:"v"` + } + + 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 + a.slug = $? + `, + slug, + ) + if t != 0 { + qb.Add(`AND a.type = $?`, t) + } + if currentUser == nil || !currentUser.CanSeeUnpublishedEducationContent() { + qb.Add(`AND NOT a.published`) + } + + res, err := db.QueryOne[eduArticleResult](ctx, dbConn, qb.String(), qb.Args()...) + if err != nil { + return nil, err + } + + res.Article.CurrentVersion = &res.CurrentVersion + return &res.Article, nil +} + +func getEditorDataForEduArticle( + urlContext *hmnurl.UrlContext, + currentUser *models.User, + baseData templates.BaseData, + article *models.EduArticle, +) editorData { + result := editorData{ + BaseData: baseData, + SubmitLabel: "Submit", + + CanEditPostTitle: true, + MaxFileSize: AssetMaxSize(currentUser), + UploadUrl: urlContext.BuildAssetUpload(), + ShowEduOptions: true, + PreviewClass: "edu-article", + + ParserName: "parseMarkdownEdu", + } + + if article != nil { + result.PostTitle = article.Title + result.EditInitialContents = article.CurrentVersion.ContentRaw + } + + return result +} + +func getEduArticleFromForm(form url.Values) (art models.EduArticle, ver models.EduArticleVersion) { + art.Title = form.Get("title") + art.Slug = form.Get("slug") + art.Description = form.Get("description") + switch form.Get("type") { + case "article": + art.Type = models.EduArticleTypeArticle + case "glossary": + art.Type = models.EduArticleTypeGlossary + default: + panic(fmt.Errorf("unknown education article type: %s", form.Get("type"))) + } + art.Published = form.Get("published") != "" + + ver.ContentRaw = form.Get("body") + ver.ContentHTML = parsing.ParseMarkdown(ver.ContentRaw, parsing.EducationRealMarkdown) + + return +} + +func createEduArticle(c *RequestContext, art models.EduArticle, ver models.EduArticleVersion) { + tx := utils.Must1(c.Conn.Begin(c)) + defer tx.Rollback(c) + { + articleID := db.MustQueryOneScalar[int](c, tx, + ` + INSERT INTO education_article (title, slug, description, published, type, current_version) + VALUES ($1, $2, $3, $4, $5, -1) + RETURNING id + `, + art.Title, art.Slug, art.Description, art.Published, art.Type, + ) + versionID := db.MustQueryOneScalar[int](c, tx, + ` + INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html) + VALUES ($1, $2, $3, $4, $5 ) + RETURNING id + `, + articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML, + ) + tx.Exec(c, + `UPDATE education_article SET current_version = $1 WHERE id = $2`, + versionID, articleID, + ) + } + utils.Must(tx.Commit(c)) +} + +func updateEduArticle(c *RequestContext, slug string, art models.EduArticle, ver models.EduArticleVersion) { + tx := utils.Must1(c.Conn.Begin(c)) + defer tx.Rollback(c) + { + articleID := db.MustQueryOneScalar[int](c, tx, + `SELECT id FROM education_article WHERE slug = $1`, + slug, + ) + versionID := db.MustQueryOneScalar[int](c, tx, + ` + INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html) + VALUES ($1, $2, $3, $4, $5 ) + RETURNING id + `, + articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML, + ) + tx.Exec(c, + ` + UPDATE education_article + SET + title = $1, slug = $2, description = $3, published = $4, type = $5, + current_version = $6 + WHERE + id = $7 + `, + art.Title, art.Slug, art.Description, art.Published, art.Type, + versionID, + articleID, + ) + } + utils.Must(tx.Commit(c)) +} + +func eduArticleURL(a *models.EduArticle) string { + switch a.Type { + case models.EduArticleTypeArticle: + return hmnurl.BuildEducationArticle(a.Slug) + case models.EduArticleTypeGlossary: + return hmnurl.BuildEducationGlossary(a.Slug) + default: + panic("unknown education article type") + } +} diff --git a/src/website/forums.go b/src/website/forums.go index 18e06b0..dbf44ab 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -43,27 +43,30 @@ type editorData struct { // The following are filled out automatically by the // getEditorDataFor* functions. - Title string - CanEditTitle bool + PostTitle string + CanEditPostTitle bool IsEditing bool EditInitialContents string PostReplyingTo *templates.Post + ShowEduOptions bool + PreviewClass string + ParserName string MaxFileSize int UploadUrl string } func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData { result := editorData{ - BaseData: baseData, - CanEditTitle: replyPost == nil, - PostReplyingTo: replyPost, - MaxFileSize: AssetMaxSize(currentUser), - UploadUrl: urlContext.BuildAssetUpload(), + BaseData: baseData, + CanEditPostTitle: replyPost == nil, + PostReplyingTo: replyPost, + MaxFileSize: AssetMaxSize(currentUser), + UploadUrl: urlContext.BuildAssetUpload(), } if replyPost != nil { - result.Title = "Replying to post" + result.PostTitle = "Replying to post" } return result @@ -72,8 +75,8 @@ func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p hmndata.PostAndStuff) editorData { return editorData{ BaseData: baseData, - Title: p.Thread.Title, - CanEditTitle: p.Thread.FirstID == p.Post.ID, + PostTitle: p.Thread.Title, + CanEditPostTitle: p.Thread.FirstID == p.Post.ID, IsEditing: true, EditInitialContents: p.CurrentVersion.TextRaw, MaxFileSize: AssetMaxSize(currentUser), diff --git a/src/website/library.go b/src/website/library.go deleted file mode 100644 index 17d58c8..0000000 --- a/src/website/library.go +++ /dev/null @@ -1,9 +0,0 @@ -package website - -func LibraryNotPortedYet(c *RequestContext) ResponseData { - baseData := getBaseData(c, "Library", nil) - - var res ResponseData - res.MustWriteTemplate("library_not_ported_yet.html", baseData, c.Perf) - return res -} 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 4693160..a9c165b 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -117,9 +117,21 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { hmnOnly.GET(hmnurl.RegexFishbowlIndex, FishbowlIndex) hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl) + hmnOnly.GET(hmnurl.RegexEducationIndex, EducationIndex) + hmnOnly.GET(hmnurl.RegexEducationGlossary, EducationGlossary) + hmnOnly.GET(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNew)) + hmnOnly.POST(hmnurl.RegexEducationArticleNew, educationAuthorsOnly(EducationArticleNewSubmit)) + 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.POST(hmnurl.RegexEducationArticleEdit, educationAuthorsOnly(EducationArticleEditSubmit)) + hmnOnly.GET(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(EducationArticleDelete)) + hmnOnly.POST(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(csrfMiddleware(EducationArticleDeleteSubmit))) + 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 diff --git a/src/website/routes_test.go b/src/website/routes_test.go index 959c33d..1954245 100644 --- a/src/website/routes_test.go +++ b/src/website/routes_test.go @@ -28,12 +28,14 @@ func TestLogContextErrors(t *testing.T) { router := &Router{} routes := RouteBuilder{ Router: router, - Middleware: func(h Handler) Handler { - return func(c *RequestContext) (res ResponseData) { - c.Logger = &logger - defer logContextErrorsMiddleware(c, &res) - return h(c) - } + Middlewares: []Middleware{ + func(h Handler) Handler { + return func(c *RequestContext) (res ResponseData) { + c.Logger = &logger + defer logContextErrorsMiddleware(h) + return h(c) + } + }, }, }