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