Initial version of education content (#90)

Co-authored-by: Ben Visness <bvisness@gmail.com>
Reviewed-on: #90
This commit is contained in:
bvisness 2022-09-10 16:29:57 +00:00
parent 42e1ed95fb
commit d2b34cb87d
42 changed files with 1508 additions and 256 deletions

View File

@ -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);
});
}
})();

Binary file not shown.

View File

@ -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; }

View File

@ -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.

View File

@ -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)

View File

@ -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) {

View File

@ -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)
}

View File

@ -434,6 +434,53 @@ func BuildFishbowl(slug string) string {
var RegexFishbowlFiles = regexp.MustCompile(`^/fishbowl/(?P<slug>[^/]+)(?P<path>/.+)$`)
/*
* Education
*/
var RegexEducationIndex = regexp.MustCompile(`^/education$`)
func BuildEducationIndex() string {
defer CatchPanic()
return Url("/education", nil)
}
var RegexEducationGlossary = regexp.MustCompile(`^/education/glossary(/(?P<slug>[^/]+))?$`)
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<slug>[^/]+)$`)
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<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)
}
/*
* 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<topicid>\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<resourceid>\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)
}

View File

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

View File

@ -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
}

View File

@ -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
}

44
src/models/education.go Normal file
View File

@ -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
)

View File

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

View File

@ -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 `

316
src/parsing/ggcode.go Normal file
View File

@ -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(
`<a href="%s" class="glossary-term" data-term="%s">`,
hmnurl.BuildEducationGlossary(term),
term,
))
} else {
c.W.WriteString("</a>")
}
return nil
},
},
"resource": {
Filter: ggcodeFilterEdu,
Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
if entering {
c.W.WriteString(`<div class="edu-resource">`)
c.W.WriteString(fmt.Sprintf(` <a href="%s" target="_blank"><h2>%s</h2></a>`, n.Args["url"], utils.OrDefault(n.Args["name"], "[missing `name`]")))
} else {
c.W.WriteString("</div>")
}
return nil
},
},
"note": {
Filter: ggcodeFilterEdu,
Renderer: func(c ggcodeRendererContext, n *ggcodeNode, entering bool) error {
if entering {
c.W.WriteString(`<span class="note">`)
} else {
c.W.WriteString(`</span>`)
}
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<name>[a-zA-Z0-9-_]+)(\{(?P<args>.*?)\})?$`)
var reGGCodeInline = regexp.MustCompile(`^!!(?P<name>[a-zA-Z0-9-_]+)(\{(?P<args>.*?)\})?(\((?P<content>.*?)\))?`)
var reGGCodeArgs = regexp.MustCompile(`(?P<arg>[a-zA-Z0-9-_]+)="(?P<val>.*?)"`)
// 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),
))
}

View File

@ -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

54
src/parsing/util.go Normal file
View File

@ -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
}

57
src/parsing/wasm/build.go Normal file
View File

@ -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()
}

View File

@ -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

View File

@ -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

View File

@ -197,6 +197,14 @@ article code {
}
}
.c--inherit {
color: inherit;
&:hover, &:active {
color: inherit;
}
}
.b--theme {
@include usevar(border-color, theme-color);
}

View File

@ -0,0 +1,9 @@
.edu-article {
.edu-resource {
@extend .pa3, .bg--dim, .br3;
}
.note {
color: red;
}
}

View File

@ -12,6 +12,7 @@
@import 'content';
@import 'editor';
@import 'episodes';
@import 'education';
@import 'forms';
@import 'forum';
@import 'header';

View File

@ -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 ""

View File

@ -19,15 +19,15 @@
{{ define "content" }}
<div class="content-block ph3 ph0-ns">
{{ if not .CanEditTitle }}
<h2>{{ .Title }}</h2>
{{ if not .CanEditPostTitle }}
<h2>{{ .PostTitle }}</h2>
{{ end }}
<div class="flex flex-column flex-row-ns">
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns overflow-hidden">
{{ csrftoken .Session }}
{{ if .CanEditTitle }}
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .Title }}" />
{{ if .CanEditPostTitle }}
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}" />
{{ end }}
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
{{/*
@ -79,8 +79,36 @@
{{ template "forum_post_standalone.html" . }}
</div>
{{ end }}
{{ if .ShowEduOptions }}
{{/* Hope you have a .Article field! */}}
<div class="bg--dim br3 pa3 mt3">
<h4>Education Options</h4>
<div class="mb2">
<label for="slug">Slug:</label>
<input name="slug" maxlength="255" type="text" id="slug" required value="{{ .Article.Slug }}" />
</div>
<div class="mb2">
<label for="type">Type:</label>
<select name="type" id="type">
<option value="article" {{ if eq .Article.Type "article" }}selected{{ end }}>Article</option>
<option value="glossary" {{ if eq .Article.Type "glossary" }}selected{{ end }}>Glossary Term</option>
</select>
</div>
<div class="mb2">
<label for="description">Description:</label>
<div>
<textarea name="description" id="slug" required>{{ .Article.Description }}</textarea>
</div>
</div>
<div class="mb2">
<label for="published">Published:</label>
<input name="published" id="published" type="checkbox" {{ if .Article.Published }}checked{{ end }}>
</div>
</div>
{{ end }}
</form>
<div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim">
<div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim {{ .PreviewClass }}">
<div id="preview" class="post-content"></div>
</div>
<input type="file" multiple name="file_input" id="file_input" class="dn" />{{/* NOTE(asaf): Placing this outside the form to avoid submitting it to the server by accident */}}

View File

@ -0,0 +1,19 @@
{{ template "base.html" . }}
{{ define "content" }}
<h1>{{ .Title }}</h1>
{{ if and .User .User.IsEduAuthor }}
<div class="mb3">
<a href="{{ .EditUrl }}" title="Edit">&#9998; Edit</a>
<a href="{{ .DeleteUrl }}" title="Delete">&#10006; Delete</a>
</div>
{{ end }}
<div class="flex">
<div class="edu-article flex-grow-1 post-content">
{{ .Article.Content }}
</div>
<div class="ml3 flex-shrink-0 w5">
I'm a sidebar!
</div>
</div>
{{ end }}

View File

@ -0,0 +1,15 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="mw7 margin-center">
<h3 class="mb3">Are you sure you want to delete this article?</h3>
<div class="bg--dim pa3 br3 tl post-content">
<h1>{{ .Article.Title }}</h1>
{{ .Article.Content }}
</div>
<form action="{{ .SubmitUrl }}" method="POST" class="pv3 flex justify-end">
{{ csrftoken .Session }}
<input type="submit" value="Delete Article">
</form>
</div>
{{ end }}

View File

@ -0,0 +1,5 @@
{{ template "base.html" . }}
{{ define "content" }}
O YES
{{ end }}

View File

@ -0,0 +1,43 @@
{{ template "base.html" . }}
{{ define "content" }}
<h1>Learn the Handmade way.</h1>
<h2>Guides</h2>
{{ if .User.IsEduAuthor }}
<div class="mb2">
<a href="{{ .NewArticleUrl }}"><span class="big pr1">+</span> New Article</a>
</div>
{{ end }}
<div class="flex flex-column g3 mb3">
{{ range .Articles }}
<a class="c--inherit flex flex-column pa3 bg--dim br2" href="{{ .Url }}" >
<h3 class="mb1 link">{{ .Title }}</h3>
<div>{{ .Description }}</div>
</a>
{{ end }}
</div>
<h2>What makes us different?</h2>
<div class="flex flex-column flex-row-ns g3">
<div class="flex-fair bg--dim pa3 br2">
<h3>Real material.</h3>
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.
</div>
<div class="flex-fair bg--dim pa3 br3">
<h3>For any skill level.</h3>
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.
</div>
<div class="flex-fair bg--dim pa3 br3">
<h3>Designed for programmers.</h3>
We're not here to teach you how to program. We're here to teach you a specific topic.
</div>
</div>
{{ end }}

View File

@ -84,7 +84,7 @@
<div class="root-item">
<a>Resources <div class="dib svgicon ml1">{{ svg "chevron-down-thick" }}</div></a>
<div class="submenu b--theme-dark">
<a href="{{ .Header.LibraryUrl }}">Library</a>
<a href="{{ .Header.EducationUrl }}">Education</a>
</div>
</div>
</div>

View File

@ -97,6 +97,7 @@
previewWorker.postMessage({
elementID: inputEl.id,
markdown: inputEl.value,
parserName: '{{ or .ParserName "parseMarkdown" }}',
});
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html{{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
<html lang="en-US">
<!DOCTYPE html>
<html lang="en-US" {{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

View File

@ -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,

View File

@ -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
}

View File

@ -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)
}

View File

@ -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(),

426
src/website/education.go Normal file
View File

@ -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(`<span\s*class="note".*?>.*?</span>`)
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")
}
}

View File

@ -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),

View File

@ -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
}

View File

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

View File

@ -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

View File

@ -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)
}
},
},
}