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
|
// Use of this source code is governed by a BSD-style
|
||||||
// license that can be found in the LICENSE file.
|
// 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 enosys = () => {
|
||||||
const err = new Error("not implemented");
|
const err = new Error("not implemented");
|
||||||
err.code = "ENOSYS";
|
err.code = "ENOSYS";
|
||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!global.fs) {
|
if (!globalThis.fs) {
|
||||||
let outputBuf = "";
|
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
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
|
||||||
writeSync(fd, buf) {
|
writeSync(fd, buf) {
|
||||||
outputBuf += decoder.decode(buf);
|
outputBuf += decoder.decode(buf);
|
||||||
|
@ -87,8 +58,8 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.process) {
|
if (!globalThis.process) {
|
||||||
global.process = {
|
globalThis.process = {
|
||||||
getuid() { return -1; },
|
getuid() { return -1; },
|
||||||
getgid() { return -1; },
|
getgid() { return -1; },
|
||||||
geteuid() { return -1; },
|
geteuid() { return -1; },
|
||||||
|
@ -102,47 +73,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.crypto && global.require) {
|
if (!globalThis.crypto) {
|
||||||
const nodeCrypto = require("crypto");
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
global.crypto = {
|
|
||||||
getRandomValues(b) {
|
|
||||||
nodeCrypto.randomFillSync(b);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!global.crypto) {
|
|
||||||
throw new Error("global.crypto is not available, polyfill required (getRandomValues only)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.performance) {
|
if (!globalThis.performance) {
|
||||||
global.performance = {
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
now() {
|
|
||||||
const [sec, nsec] = process.hrtime();
|
|
||||||
return sec * 1000 + nsec / 1000000;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.TextEncoder && global.require) {
|
if (!globalThis.TextEncoder) {
|
||||||
global.TextEncoder = require("util").TextEncoder;
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
}
|
|
||||||
if (!global.TextEncoder) {
|
|
||||||
throw new Error("global.TextEncoder is not available, polyfill required");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.TextDecoder && global.require) {
|
if (!globalThis.TextDecoder) {
|
||||||
global.TextDecoder = require("util").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 encoder = new TextEncoder("utf-8");
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
global.Go = class {
|
globalThis.Go = class {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.argv = ["js"];
|
this.argv = ["js"];
|
||||||
this.env = {};
|
this.env = {};
|
||||||
|
@ -296,8 +246,8 @@
|
||||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
},
|
},
|
||||||
|
|
||||||
// func walltime1() (sec int64, nsec int32)
|
// func walltime() (sec int64, nsec int32)
|
||||||
"runtime.walltime1": (sp) => {
|
"runtime.walltime": (sp) => {
|
||||||
sp >>>= 0;
|
sp >>>= 0;
|
||||||
const msec = (new Date).getTime();
|
const msec = (new Date).getTime();
|
||||||
setInt64(sp + 8, msec / 1000);
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
@ -401,6 +351,7 @@
|
||||||
storeValue(sp + 56, result);
|
storeValue(sp + 56, result);
|
||||||
this.mem.setUint8(sp + 64, 1);
|
this.mem.setUint8(sp + 64, 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
storeValue(sp + 56, err);
|
storeValue(sp + 56, err);
|
||||||
this.mem.setUint8(sp + 64, 0);
|
this.mem.setUint8(sp + 64, 0);
|
||||||
}
|
}
|
||||||
|
@ -417,6 +368,7 @@
|
||||||
storeValue(sp + 40, result);
|
storeValue(sp + 40, result);
|
||||||
this.mem.setUint8(sp + 48, 1);
|
this.mem.setUint8(sp + 48, 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
storeValue(sp + 40, err);
|
storeValue(sp + 40, err);
|
||||||
this.mem.setUint8(sp + 48, 0);
|
this.mem.setUint8(sp + 48, 0);
|
||||||
}
|
}
|
||||||
|
@ -433,6 +385,7 @@
|
||||||
storeValue(sp + 40, result);
|
storeValue(sp + 40, result);
|
||||||
this.mem.setUint8(sp + 48, 1);
|
this.mem.setUint8(sp + 48, 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
storeValue(sp + 40, err);
|
storeValue(sp + 40, err);
|
||||||
this.mem.setUint8(sp + 48, 0);
|
this.mem.setUint8(sp + 48, 0);
|
||||||
}
|
}
|
||||||
|
@ -514,7 +467,7 @@
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
global,
|
globalThis,
|
||||||
this,
|
this,
|
||||||
];
|
];
|
||||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
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],
|
[null, 2],
|
||||||
[true, 3],
|
[true, 3],
|
||||||
[false, 4],
|
[false, 4],
|
||||||
[global, 5],
|
[globalThis, 5],
|
||||||
[this, 6],
|
[this, 6],
|
||||||
]);
|
]);
|
||||||
this._idPool = []; // unused ids that have been garbage collected
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
@ -564,6 +517,13 @@
|
||||||
offset += 8;
|
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);
|
this._inst.exports.run(argc, argv);
|
||||||
if (this.exited) {
|
if (this.exited) {
|
||||||
this._resolveExitPromise();
|
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 {
|
.br2, .post-content code, .post-content pre > code, .post-content pre.hmn-code {
|
||||||
border-radius: 0.25rem; }
|
border-radius: 0.25rem; }
|
||||||
|
|
||||||
.br3 {
|
.br3, .edu-article .edu-resource {
|
||||||
border-radius: 0.5rem; }
|
border-radius: 0.5rem; }
|
||||||
|
|
||||||
.br4 {
|
.br4 {
|
||||||
|
@ -4602,7 +4602,7 @@ code, .code {
|
||||||
.pa2, .tab, header .root-item > a, header .submenu > a {
|
.pa2, .tab, header .root-item > a, header .submenu > a {
|
||||||
padding: 0.5rem; }
|
padding: 0.5rem; }
|
||||||
|
|
||||||
.pa3, header #login-popup {
|
.pa3, .edu-article .edu-resource, header #login-popup {
|
||||||
padding: 1rem; }
|
padding: 1rem; }
|
||||||
|
|
||||||
.pa4 {
|
.pa4 {
|
||||||
|
@ -7369,6 +7369,11 @@ article code {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-shrink: 1; } }
|
flex-shrink: 1; } }
|
||||||
|
|
||||||
|
.c--inherit {
|
||||||
|
color: inherit; }
|
||||||
|
.c--inherit:hover, .c--inherit:active {
|
||||||
|
color: inherit; }
|
||||||
|
|
||||||
.b--theme {
|
.b--theme {
|
||||||
border-color: #666;
|
border-color: #666;
|
||||||
border-color: var(--theme-color); }
|
border-color: var(--theme-color); }
|
||||||
|
@ -7421,7 +7426,7 @@ article code {
|
||||||
border-color: #ccc;
|
border-color: #ccc;
|
||||||
border-color: var(--theme-color-dimmest); }
|
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: #f0f0f0;
|
||||||
background-color: var(--dim-background); }
|
background-color: var(--dim-background); }
|
||||||
|
|
||||||
|
@ -8296,6 +8301,9 @@ nav.timecodes {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 10px 0; }
|
margin: 10px 0; }
|
||||||
|
|
||||||
|
.edu-article .note {
|
||||||
|
color: red; }
|
||||||
|
|
||||||
form {
|
form {
|
||||||
margin: 0; }
|
margin: 0; }
|
||||||
|
|
||||||
|
|
12
src/db/db.go
12
src/db/db.go
|
@ -158,8 +158,12 @@ func QueryOne[T any](
|
||||||
|
|
||||||
result, hasRow := rows.Next()
|
result, hasRow := rows.Next()
|
||||||
if !hasRow {
|
if !hasRow {
|
||||||
|
if readErr := rows.Err(); readErr != nil {
|
||||||
|
return nil, readErr
|
||||||
|
} else {
|
||||||
return nil, NotFound
|
return nil, NotFound
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
@ -244,8 +248,12 @@ func QueryOneScalar[T any](
|
||||||
result, hasRow := rows.Next()
|
result, hasRow := rows.Next()
|
||||||
if !hasRow {
|
if !hasRow {
|
||||||
var zero T
|
var zero T
|
||||||
|
if readErr := rows.Err(); readErr != nil {
|
||||||
|
return zero, readErr
|
||||||
|
} else {
|
||||||
return zero, NotFound
|
return zero, NotFound
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return *result, nil
|
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
|
// 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
|
// destination. If the destination is a pointer, and the value is non-nil, it
|
||||||
// will initialize the destination before assigning.
|
// will initialize the destination before assigning.
|
||||||
|
|
|
@ -29,7 +29,7 @@ func StartServer(ctx context.Context) jobs.Job {
|
||||||
return jobs.Noop()
|
return jobs.Noop()
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Must0(os.MkdirAll(dir, fs.ModePerm))
|
utils.Must(os.MkdirAll(dir, fs.ModePerm))
|
||||||
|
|
||||||
s := server{
|
s := server{
|
||||||
log: logging.ExtractLogger(ctx).With().
|
log: logging.ExtractLogger(ctx).With().
|
||||||
|
@ -84,7 +84,7 @@ func (s *server) putObject(w http.ResponseWriter, r *http.Request) {
|
||||||
bucket, key := bucketKey(r)
|
bucket, key := bucketKey(r)
|
||||||
|
|
||||||
w.Header().Set("Location", fmt.Sprintf("/%s", bucket))
|
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 != "" {
|
if key != "" {
|
||||||
file := utils.Must1(os.Create(filepath.Join(dir, bucket, key)))
|
file := utils.Must1(os.Create(filepath.Join(dir, bucket, key)))
|
||||||
io.Copy(file, r.Body)
|
io.Copy(file, r.Body)
|
||||||
|
|
|
@ -28,13 +28,13 @@ func QFromURL(u *url.URL) []Q {
|
||||||
}
|
}
|
||||||
|
|
||||||
var baseUrlParsed url.URL
|
var baseUrlParsed url.URL
|
||||||
var cacheBust string
|
var cacheBustVersion string
|
||||||
var S3BaseUrl string
|
var S3BaseUrl string
|
||||||
var isTest bool
|
var isTest bool
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
SetGlobalBaseUrl(config.Config.BaseUrl)
|
SetGlobalBaseUrl(config.Config.BaseUrl)
|
||||||
SetCacheBust(fmt.Sprint(time.Now().Unix()))
|
SetCacheBustVersion(fmt.Sprint(time.Now().Unix()))
|
||||||
SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot)
|
SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,8 +50,8 @@ func SetGlobalBaseUrl(fullBaseUrl string) {
|
||||||
baseUrlParsed = *parsed
|
baseUrlParsed = *parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetCacheBust(newCacheBust string) {
|
func SetCacheBustVersion(newCacheBustVersion string) {
|
||||||
cacheBust = newCacheBust
|
cacheBustVersion = newCacheBustVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetS3BaseUrl(base string) {
|
func SetS3BaseUrl(base string) {
|
||||||
|
|
|
@ -75,7 +75,7 @@ func TestLogoutAction(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegister(t *testing.T) {
|
func TestRegister(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildRegister(), RegexRegister, nil)
|
AssertRegexMatch(t, BuildRegister(""), RegexRegister, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistrationSuccess(t *testing.T) {
|
func TestRegistrationSuccess(t *testing.T) {
|
||||||
|
@ -83,7 +83,7 @@ func TestRegistrationSuccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmailConfirmation(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) {
|
func TestPasswordReset(t *testing.T) {
|
||||||
|
@ -185,6 +185,32 @@ func TestFishbowl(t *testing.T) {
|
||||||
AssertRegexNoMatch(t, BuildFishbowl("oop")+"/otherfiles/whatever", RegexFishbowl)
|
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) {
|
func TestForum(t *testing.T) {
|
||||||
AssertRegexMatch(t, hmn.BuildForum(nil, 1), RegexForum, nil)
|
AssertRegexMatch(t, hmn.BuildForum(nil, 1), RegexForum, nil)
|
||||||
AssertRegexMatch(t, hmn.BuildForum([]string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
|
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")
|
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) {
|
func TestEpisodeGuide(t *testing.T) {
|
||||||
AssertRegexMatch(t, hero.BuildEpisodeList(""), RegexEpisodeList, map[string]string{"topic": ""})
|
AssertRegexMatch(t, hero.BuildEpisodeList(""), RegexEpisodeList, map[string]string{"topic": ""})
|
||||||
AssertRegexMatch(t, hero.BuildEpisodeList("code"), RegexEpisodeList, map[string]string{"topic": "code"})
|
AssertRegexMatch(t, hero.BuildEpisodeList("code"), RegexEpisodeList, map[string]string{"topic": "code"})
|
||||||
|
@ -360,6 +370,26 @@ func TestJamIndex(t *testing.T) {
|
||||||
AssertSubdomain(t, BuildJamIndex(), "")
|
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) {
|
func TestDiscordOAuthCallback(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildDiscordOAuthCallback(), RegexDiscordOAuthCallback, nil)
|
AssertRegexMatch(t, BuildDiscordOAuthCallback(), RegexDiscordOAuthCallback, nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -434,6 +434,53 @@ func BuildFishbowl(slug string) string {
|
||||||
|
|
||||||
var RegexFishbowlFiles = regexp.MustCompile(`^/fishbowl/(?P<slug>[^/]+)(?P<path>/.+)$`)
|
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
|
* Forums
|
||||||
*/
|
*/
|
||||||
|
@ -651,47 +698,8 @@ func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string {
|
||||||
* Library
|
* Library
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Any library route. Remove after we port the library.
|
|
||||||
var RegexLibraryAny = regexp.MustCompile(`^/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
|
* Episode Guide
|
||||||
*/
|
*/
|
||||||
|
@ -829,7 +837,7 @@ func BuildPublic(filepath string, cachebust bool) string {
|
||||||
}
|
}
|
||||||
var query []Q
|
var query []Q
|
||||||
if cachebust {
|
if cachebust {
|
||||||
query = []Q{{"v", cacheBust}}
|
query = []Q{{"v", cacheBustVersion}}
|
||||||
}
|
}
|
||||||
return Url(builder.String(), query)
|
return Url(builder.String(), query)
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,6 +52,14 @@ func init() {
|
||||||
}
|
}
|
||||||
migrateCommand.Flags().BoolVar(&listMigrations, "list", false, "List available migrations")
|
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{
|
makeMigrationCommand := &cobra.Command{
|
||||||
Use: "makemigration <name> <description>...",
|
Use: "makemigration <name> <description>...",
|
||||||
Short: "Create a new database migration file",
|
Short: "Create a new database migration file",
|
||||||
|
@ -95,6 +103,7 @@ func init() {
|
||||||
|
|
||||||
website.WebsiteCommand.AddCommand(dbCommand)
|
website.WebsiteCommand.AddCommand(dbCommand)
|
||||||
dbCommand.AddCommand(migrateCommand)
|
dbCommand.AddCommand(migrateCommand)
|
||||||
|
dbCommand.AddCommand(rollbackCommand)
|
||||||
dbCommand.AddCommand(makeMigrationCommand)
|
dbCommand.AddCommand(makeMigrationCommand)
|
||||||
dbCommand.AddCommand(seedCommand)
|
dbCommand.AddCommand(seedCommand)
|
||||||
dbCommand.AddCommand(seedFromFileCommand)
|
dbCommand.AddCommand(seedFromFileCommand)
|
||||||
|
@ -126,7 +135,7 @@ func getCurrentVersion(ctx context.Context, conn *pgx.Conn) (types.MigrationVers
|
||||||
|
|
||||||
func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion {
|
func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion {
|
||||||
defer func() {
|
defer func() {
|
||||||
recover()
|
recover() // NOTE(ben): wat
|
||||||
}()
|
}()
|
||||||
|
|
||||||
conn := db.NewConn()
|
conn := db.NewConn()
|
||||||
|
@ -267,8 +276,8 @@ func Migrate(targetVersion types.MigrationVersion) {
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
fmt.Printf("Rolling back migration %v\n", version)
|
|
||||||
migration := migrations.All[version]
|
migration := migrations.All[version]
|
||||||
|
fmt.Printf("Rolling back migration %v (%s)\n", migration.Version(), migration.Name())
|
||||||
err = migration.Down(ctx, tx)
|
err = migration.Down(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("MIGRATION FAILED for migration %v.\n", version)
|
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
|
//go:embed migrationTemplate.txt
|
||||||
var migrationTemplate string
|
var migrationTemplate string
|
||||||
|
|
||||||
|
@ -323,14 +365,13 @@ func ResetDB() {
|
||||||
|
|
||||||
// Create the HMN database user
|
// Create the HMN database user
|
||||||
{
|
{
|
||||||
type pgCredentials struct {
|
credentials := append(
|
||||||
User string
|
[]pgCredentials{
|
||||||
Password string
|
{config.Config.Postgres.User, config.Config.Postgres.Password, false}, // Existing HMN user
|
||||||
}
|
{getSystemUsername(), "", true}, // Postgres.app on Mac
|
||||||
credentials := []pgCredentials{
|
},
|
||||||
{config.Config.Postgres.User, config.Config.Postgres.Password}, // Existing HMN user
|
guessCredentials()...,
|
||||||
{getSystemUsername(), ""}, // Postgres.app on Mac
|
)
|
||||||
}
|
|
||||||
|
|
||||||
var workingCred pgCredentials
|
var workingCred pgCredentials
|
||||||
var createUserConn *pgconn.PgConn
|
var createUserConn *pgconn.PgConn
|
||||||
|
@ -341,6 +382,9 @@ func ResetDB() {
|
||||||
createUserConn, err = connectLowLevel(ctx, cred.User, cred.Password)
|
createUserConn, err = connectLowLevel(ctx, cred.User, cred.Password)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
workingCred = cred
|
workingCred = cred
|
||||||
|
if cred.SafeToPrint {
|
||||||
|
fmt.Printf("Connected by guessing username \"%s\" and password \"%s\".\n", cred.User, cred.Password)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
connErrors = append(connErrors, err)
|
connErrors = append(connErrors, err)
|
||||||
|
@ -448,3 +492,22 @@ func getSystemUsername() string {
|
||||||
}
|
}
|
||||||
return u.Username
|
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...")
|
fmt.Println("Creating HMN project...")
|
||||||
hmn := seedProject(ctx, tx, seedHMN, nil)
|
hmn := seedProject(ctx, tx, seedHMN, nil)
|
||||||
|
|
||||||
utils.Must0(tx.Commit(ctx))
|
utils.Must(tx.Commit(ctx))
|
||||||
|
|
||||||
return hmn
|
return hmn
|
||||||
}
|
}
|
||||||
|
@ -166,7 +166,7 @@ func SampleSeed() {
|
||||||
// Finally, set sequence numbers to things that won't conflict
|
// Finally, set sequence numbers to things that won't conflict
|
||||||
utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);"))
|
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 {
|
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,
|
status,
|
||||||
name, bio, blurb, signature,
|
name, bio, blurb, signature,
|
||||||
darktheme,
|
darktheme,
|
||||||
showemail, edit_library,
|
showemail,
|
||||||
date_joined, registration_ip, avatar_asset_id
|
date_joined, registration_ip, avatar_asset_id
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
@ -187,7 +187,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.
|
||||||
$5,
|
$5,
|
||||||
$6, $7, $8, $9,
|
$6, $7, $8, $9,
|
||||||
TRUE,
|
TRUE,
|
||||||
$10, FALSE,
|
$10,
|
||||||
'2017-01-01T00:00:00Z', '192.168.2.1', null
|
'2017-01-01T00:00:00Z', '192.168.2.1', null
|
||||||
)
|
)
|
||||||
RETURNING $columns
|
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)),
|
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,
|
input.ShowEmail,
|
||||||
)
|
)
|
||||||
utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password"))
|
utils.Must(auth.SetPassword(ctx, conn, input.Username, "password"))
|
||||||
|
|
||||||
return user
|
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 (
|
const (
|
||||||
UserStatusInactive UserStatus = 1 // Default for new users
|
UserStatusInactive UserStatus = 1 // Default for new users
|
||||||
UserStatusConfirmed = 2 // Confirmed email address
|
UserStatusConfirmed UserStatus = 2 // Confirmed email address
|
||||||
UserStatusApproved = 3 // Approved by an admin and allowed to publicly post
|
UserStatusApproved UserStatus = 3 // Approved by an admin and allowed to publicly post
|
||||||
UserStatusBanned = 4 // BALEETED
|
UserStatusBanned UserStatus = 4 // BALEETED
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
@ -30,6 +30,7 @@ type User struct {
|
||||||
|
|
||||||
IsStaff bool `db:"is_staff"`
|
IsStaff bool `db:"is_staff"`
|
||||||
Status UserStatus `db:"status"`
|
Status UserStatus `db:"status"`
|
||||||
|
EducationRole EduRole `db:"education_role"`
|
||||||
|
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Bio string `db:"bio"`
|
Bio string `db:"bio"`
|
||||||
|
@ -41,7 +42,6 @@ type User struct {
|
||||||
Timezone string `db:"timezone"`
|
Timezone string `db:"timezone"`
|
||||||
|
|
||||||
ShowEmail bool `db:"showemail"`
|
ShowEmail bool `db:"showemail"`
|
||||||
CanEditLibrary bool `db:"edit_library"`
|
|
||||||
|
|
||||||
DiscordSaveShowcase bool `db:"discord_save_showcase"`
|
DiscordSaveShowcase bool `db:"discord_save_showcase"`
|
||||||
DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
|
DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
|
||||||
|
@ -63,3 +63,11 @@ func (u *User) BestName() string {
|
||||||
func (u *User) IsActive() bool {
|
func (u *User) IsActive() bool {
|
||||||
return u.Status == UserStatusConfirmed
|
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
|
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 {
|
func makeYoutubeEmbed(vid string, preview bool) string {
|
||||||
if preview {
|
if preview {
|
||||||
return `
|
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()),
|
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 {
|
func ParseMarkdown(source string, md goldmark.Markdown) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := md.Convert([]byte(source), &buf); err != nil {
|
if err := md.Convert([]byte(source), &buf); err != nil {
|
||||||
|
@ -58,6 +76,7 @@ func ParseMarkdown(source string, md goldmark.Markdown) string {
|
||||||
type MarkdownOptions struct {
|
type MarkdownOptions struct {
|
||||||
Previews bool
|
Previews bool
|
||||||
Embeds bool
|
Embeds bool
|
||||||
|
Education bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeGoldmark(opts ...goldmark.Option) goldmark.Markdown {
|
func makeGoldmark(opts ...goldmark.Option) goldmark.Markdown {
|
||||||
|
@ -116,6 +135,9 @@ func makeGoldmarkExtensions(opts MarkdownOptions) []goldmark.Extender {
|
||||||
BBCodeExtension{
|
BBCodeExtension{
|
||||||
Preview: opts.Previews,
|
Preview: opts.Previews,
|
||||||
},
|
},
|
||||||
|
ggcodeExtension{
|
||||||
|
Opts: opts,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return extenders
|
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
|
package main
|
||||||
|
|
||||||
|
@ -12,6 +12,9 @@ func main() {
|
||||||
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown)
|
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
|
var done chan bool
|
||||||
<-done // block forever
|
<-done // block forever
|
||||||
|
|
|
@ -197,6 +197,14 @@ article code {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c--inherit {
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.b--theme {
|
.b--theme {
|
||||||
@include usevar(border-color, theme-color);
|
@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 'content';
|
||||||
@import 'editor';
|
@import 'editor';
|
||||||
@import 'episodes';
|
@import 'episodes';
|
||||||
|
@import 'education';
|
||||||
@import 'forms';
|
@import 'forms';
|
||||||
@import 'forum';
|
@import 'forum';
|
||||||
@import 'header';
|
@import 'header';
|
||||||
|
|
|
@ -211,9 +211,11 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
DarkTheme: u.DarkTheme,
|
DarkTheme: u.DarkTheme,
|
||||||
Timezone: u.Timezone,
|
Timezone: u.Timezone,
|
||||||
|
|
||||||
CanEditLibrary: u.CanEditLibrary,
|
|
||||||
DiscordSaveShowcase: u.DiscordSaveShowcase,
|
DiscordSaveShowcase: u.DiscordSaveShowcase,
|
||||||
DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete,
|
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 {
|
func maybeString(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -19,15 +19,15 @@
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="content-block ph3 ph0-ns">
|
<div class="content-block ph3 ph0-ns">
|
||||||
{{ if not .CanEditTitle }}
|
{{ if not .CanEditPostTitle }}
|
||||||
<h2>{{ .Title }}</h2>
|
<h2>{{ .PostTitle }}</h2>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="flex flex-column flex-row-ns">
|
<div class="flex flex-column flex-row-ns">
|
||||||
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns overflow-hidden">
|
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns overflow-hidden">
|
||||||
{{ csrftoken .Session }}
|
{{ csrftoken .Session }}
|
||||||
|
|
||||||
{{ if .CanEditTitle }}
|
{{ if .CanEditPostTitle }}
|
||||||
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .Title }}" />
|
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}" />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
|
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
|
||||||
{{/*
|
{{/*
|
||||||
|
@ -79,8 +79,36 @@
|
||||||
{{ template "forum_post_standalone.html" . }}
|
{{ template "forum_post_standalone.html" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ 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>
|
</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 id="preview" class="post-content"></div>
|
||||||
</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 */}}
|
<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">
|
<div class="root-item">
|
||||||
<a>Resources <div class="dib svgicon ml1">{{ svg "chevron-down-thick" }}</div></a>
|
<a>Resources <div class="dib svgicon ml1">{{ svg "chevron-down-thick" }}</div></a>
|
||||||
<div class="submenu b--theme-dark">
|
<div class="submenu b--theme-dark">
|
||||||
<a href="{{ .Header.LibraryUrl }}">Library</a>
|
<a href="{{ .Header.EducationUrl }}">Education</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -97,6 +97,7 @@
|
||||||
previewWorker.postMessage({
|
previewWorker.postMessage({
|
||||||
elementID: inputEl.id,
|
elementID: inputEl.id,
|
||||||
markdown: inputEl.value,
|
markdown: inputEl.value,
|
||||||
|
parserName: '{{ or .ParserName "parseMarkdown" }}',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<!DOCTYPE html{{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
|
<!DOCTYPE html>
|
||||||
<html lang="en-US">
|
<html lang="en-US" {{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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
|
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 = {};
|
let jobs = {};
|
||||||
|
|
||||||
onmessage = ({ data }) => {
|
onmessage = ({ data }) => {
|
||||||
const { elementID, markdown } = data;
|
const { elementID, markdown, parserName } = data;
|
||||||
jobs[elementID] = markdown;
|
jobs[elementID] = { markdown, parserName };
|
||||||
setTimeout(doPreview, 0);
|
setTimeout(doPreview, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const go = new Go();
|
const go = new Go();
|
||||||
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
|
WebAssembly.instantiateStreaming(fetch('{{ static "parsing.wasm" }}'), go.importObject)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
go.run(result.instance); // don't await this; we want it to be continuously running
|
go.run(result.instance); // don't await this; we want it to be continuously running
|
||||||
wasmLoaded = true;
|
wasmLoaded = true;
|
||||||
|
@ -27,8 +30,8 @@ const doPreview = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [elementID, markdown] of Object.entries(jobs)) {
|
for (const [elementID, { markdown, parserName }] of Object.entries(jobs)) {
|
||||||
const html = parseMarkdown(markdown);
|
const html = global[parserName](markdown);
|
||||||
postMessage({
|
postMessage({
|
||||||
elementID: elementID,
|
elementID: elementID,
|
||||||
html: html,
|
html: html,
|
||||||
|
|
|
@ -51,8 +51,8 @@ type Header struct {
|
||||||
PodcastUrl string
|
PodcastUrl string
|
||||||
FishbowlUrl string
|
FishbowlUrl string
|
||||||
ForumsUrl string
|
ForumsUrl string
|
||||||
LibraryUrl string
|
|
||||||
ConferencesUrl string
|
ConferencesUrl string
|
||||||
|
EducationUrl string
|
||||||
|
|
||||||
Project *ProjectHeader
|
Project *ProjectHeader
|
||||||
}
|
}
|
||||||
|
@ -189,9 +189,11 @@ type User struct {
|
||||||
ShowEmail bool
|
ShowEmail bool
|
||||||
Timezone string
|
Timezone string
|
||||||
|
|
||||||
CanEditLibrary bool
|
|
||||||
DiscordSaveShowcase bool
|
DiscordSaveShowcase bool
|
||||||
DiscordDeleteSnippetOnMessageDelete bool
|
DiscordDeleteSnippetOnMessageDelete bool
|
||||||
|
|
||||||
|
IsEduTester bool
|
||||||
|
IsEduAuthor bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
|
@ -385,3 +387,17 @@ type Tag struct {
|
||||||
Text string
|
Text string
|
||||||
Url 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.
|
// Takes an (error) return and panics if there is an error.
|
||||||
// Helps avoid `if err != nil` in scripts. Use sparingly in real code.
|
// Helps avoid `if err != nil` in scripts. Use sparingly in real code.
|
||||||
func Must0(err error) {
|
func Must(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,8 +74,8 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
PodcastUrl: hmnurl.BuildPodcast(),
|
PodcastUrl: hmnurl.BuildPodcast(),
|
||||||
FishbowlUrl: hmnurl.BuildFishbowlIndex(),
|
FishbowlUrl: hmnurl.BuildFishbowlIndex(),
|
||||||
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
|
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
|
||||||
LibraryUrl: hmnurl.BuildLibrary(),
|
|
||||||
ConferencesUrl: hmnurl.BuildConferences(),
|
ConferencesUrl: hmnurl.BuildConferences(),
|
||||||
|
EducationUrl: hmnurl.BuildEducationIndex(),
|
||||||
},
|
},
|
||||||
Footer: templates.Footer{
|
Footer: templates.Footer{
|
||||||
HomepageUrl: hmnurl.BuildHomepage(),
|
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,12 +43,15 @@ type editorData struct {
|
||||||
|
|
||||||
// The following are filled out automatically by the
|
// The following are filled out automatically by the
|
||||||
// getEditorDataFor* functions.
|
// getEditorDataFor* functions.
|
||||||
Title string
|
PostTitle string
|
||||||
CanEditTitle bool
|
CanEditPostTitle bool
|
||||||
IsEditing bool
|
IsEditing bool
|
||||||
EditInitialContents string
|
EditInitialContents string
|
||||||
PostReplyingTo *templates.Post
|
PostReplyingTo *templates.Post
|
||||||
|
ShowEduOptions bool
|
||||||
|
PreviewClass string
|
||||||
|
|
||||||
|
ParserName string
|
||||||
MaxFileSize int
|
MaxFileSize int
|
||||||
UploadUrl string
|
UploadUrl string
|
||||||
}
|
}
|
||||||
|
@ -56,14 +59,14 @@ type editorData struct {
|
||||||
func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
|
func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
|
||||||
result := editorData{
|
result := editorData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
CanEditTitle: replyPost == nil,
|
CanEditPostTitle: replyPost == nil,
|
||||||
PostReplyingTo: replyPost,
|
PostReplyingTo: replyPost,
|
||||||
MaxFileSize: AssetMaxSize(currentUser),
|
MaxFileSize: AssetMaxSize(currentUser),
|
||||||
UploadUrl: urlContext.BuildAssetUpload(),
|
UploadUrl: urlContext.BuildAssetUpload(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if replyPost != nil {
|
if replyPost != nil {
|
||||||
result.Title = "Replying to post"
|
result.PostTitle = "Replying to post"
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
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 {
|
func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p hmndata.PostAndStuff) editorData {
|
||||||
return editorData{
|
return editorData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
Title: p.Thread.Title,
|
PostTitle: p.Thread.Title,
|
||||||
CanEditTitle: p.Thread.FirstID == p.Post.ID,
|
CanEditPostTitle: p.Thread.FirstID == p.Post.ID,
|
||||||
IsEditing: true,
|
IsEditing: true,
|
||||||
EditInitialContents: p.CurrentVersion.TextRaw,
|
EditInitialContents: p.CurrentVersion.TextRaw,
|
||||||
MaxFileSize: AssetMaxSize(currentUser),
|
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 {
|
func needsAuth(h Handler) Handler {
|
||||||
return func(c *RequestContext) (res ResponseData) {
|
return func(c *RequestContext) ResponseData {
|
||||||
if c.CurrentUser == nil {
|
if c.CurrentUser == nil {
|
||||||
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ func needsAuth(h Handler) Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminsOnly(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 {
|
if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
|
||||||
return FourOhFour(c)
|
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 {
|
func csrfMiddleware(h Handler) Handler {
|
||||||
// CSRF mitigation actions per the OWASP cheat sheet:
|
// CSRF mitigation actions per the OWASP cheat sheet:
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
// 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.RegexFishbowlIndex, FishbowlIndex)
|
||||||
hmnOnly.GET(hmnurl.RegexFishbowl, Fishbowl)
|
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.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)
|
// 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
|
// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we
|
||||||
|
|
|
@ -28,13 +28,15 @@ func TestLogContextErrors(t *testing.T) {
|
||||||
router := &Router{}
|
router := &Router{}
|
||||||
routes := RouteBuilder{
|
routes := RouteBuilder{
|
||||||
Router: router,
|
Router: router,
|
||||||
Middleware: func(h Handler) Handler {
|
Middlewares: []Middleware{
|
||||||
|
func(h Handler) Handler {
|
||||||
return func(c *RequestContext) (res ResponseData) {
|
return func(c *RequestContext) (res ResponseData) {
|
||||||
c.Logger = &logger
|
c.Logger = &logger
|
||||||
defer logContextErrorsMiddleware(c, &res)
|
defer logContextErrorsMiddleware(h)
|
||||||
return h(c)
|
return h(c)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
routes.GET(regexp.MustCompile("^/test$"), func(c *RequestContext) ResponseData {
|
routes.GET(regexp.MustCompile("^/test$"), func(c *RequestContext) ResponseData {
|
||||||
|
|
Loading…
Reference in New Issue