Initial version of education content (#90)
Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: hmn/hmn#90
This commit is contained in:
parent
42e1ed95fb
commit
d2b34cb87d
|
@ -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.
|
@ -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; }
|
||||
|
||||
|
|
16
src/db/db.go
16
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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 `
|
||||
|
|
|
@ -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),
|
||||
))
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -197,6 +197,14 @@ article code {
|
|||
}
|
||||
}
|
||||
|
||||
.c--inherit {
|
||||
color: inherit;
|
||||
|
||||
&:hover, &:active {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.b--theme {
|
||||
@include usevar(border-color, theme-color);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.edu-article {
|
||||
.edu-resource {
|
||||
@extend .pa3, .bg--dim, .br3;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: red;
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@
|
|||
@import 'content';
|
||||
@import 'editor';
|
||||
@import 'episodes';
|
||||
@import 'education';
|
||||
@import 'forms';
|
||||
@import 'forum';
|
||||
@import 'header';
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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 */}}
|
||||
|
|
|
@ -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">✎ Edit</a>
|
||||
<a href="{{ .DeleteUrl }}" title="Delete">✖ 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 }}
|
|
@ -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 }}
|
|
@ -0,0 +1,5 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
O YES
|
||||
{{ end }}
|
|
@ -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 }}
|
|
@ -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>
|
||||
|
|
|
@ -97,6 +97,7 @@
|
|||
previewWorker.postMessage({
|
||||
elementID: inputEl.id,
|
||||
markdown: inputEl.value,
|
||||
parserName: '{{ or .ParserName "parseMarkdown" }}',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue