diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9f1e183..61015ef 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,11 @@
{
"editor.insertSpaces": false,
"editor.detectIndentation": false,
- "editor.tabSize": 3
+ "editor.tabSize": 3,
+ "[jade]": {
+ "editor.stickyScroll.enabled": true,
+ "editor.stickyScroll.maxLineCount": 6,
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 9e35392..d9ff5cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1148,6 +1148,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
+ "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
diff --git a/scripts/start-server.js b/scripts/start-server.js
index 2cfee68..883333d 100755
--- a/scripts/start-server.js
+++ b/scripts/start-server.js
@@ -13,6 +13,10 @@ const sync = new HeatSync()
Object.assign(passthrough, {sync, db})
+const orm = sync.require("../src/db/orm")
+passthrough.from = orm.from
+passthrough.select = orm.select
+
const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "half")
@@ -21,10 +25,6 @@ passthrough.discord = discord
const {as} = require("../src/matrix/appservice")
passthrough.as = as
-const orm = sync.require("../src/db/orm")
-passthrough.from = orm.from
-passthrough.select = orm.select
-
;(async () => {
await migrate.migrate(db)
await discord.cloud.connect()
diff --git a/src/js/errors.js b/src/js/errors.js
new file mode 100644
index 0000000..7770772
--- /dev/null
+++ b/src/js/errors.js
@@ -0,0 +1,53 @@
+/**
+ * This function is adapted from Evan Kaufman's fantastic work.
+ * The original function and my adapted function are both MIT licensed.
+ * @url https://github.com/EvanK/npm-loggable-error/
+ * @param {number} [depth]
+ * @returns {string}
+*/
+function stringifyErrorStack(err, depth = 0) {
+ let collapsed = " ".repeat(depth);
+ if (!(err instanceof Error)) {
+ return collapsed + err
+ }
+
+ // add full stack trace if one exists, otherwise convert to string
+ let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
+ let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
+ if (cloudstormLine !== -1) {
+ stackLines = stackLines.slice(0, cloudstormLine - 2)
+ }
+ collapsed += stackLines.join("\n")
+
+ const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
+
+ // only break into object notation if we have additional props to dump
+ if (props.length) {
+ const dedent = " ".repeat(depth);
+ const indent = " ".repeat(depth + 2);
+
+ collapsed += " {\n";
+
+ // loop and print each (indented) prop name
+ for (let property of props) {
+ collapsed += `${indent}[${property}]: `;
+
+ // if another error object, stringify it too
+ if (err[property] instanceof Error) {
+ collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
+ }
+ // otherwise stringify as JSON
+ else {
+ collapsed += JSON.stringify(err[property]);
+ }
+
+ collapsed += "\n";
+ }
+
+ collapsed += `${dedent}}\n`;
+ }
+
+ return collapsed;
+}
+
+module.exports.stringifyErrorStack = stringifyErrorStack
diff --git a/src/js/errors.test.js b/src/js/errors.test.js
new file mode 100644
index 0000000..ddff7d3
--- /dev/null
+++ b/src/js/errors.test.js
@@ -0,0 +1,23 @@
+// @ts-check
+
+const {test} = require("supertape")
+const {stringifyErrorStack} = require("./errors")
+
+test("stringify error stack: works", t => {
+ function a() {
+ const e = new Error("message", {cause: new Error("inner")})
+ // @ts-ignore
+ e.prop = 2.1
+ throw e
+ }
+ try {
+ a()
+ t.fail("shouldn't get here")
+ } catch (e) {
+ const str = stringifyErrorStack(e)
+ t.match(str, /^Error: message$/m)
+ t.match(str, /^ at a \(.*errors\.test\.js/m)
+ t.match(str, /^ \[cause\]: Error: inner$/m)
+ t.match(str, /^ \[prop\]: 2.1$/m)
+ }
+})
diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js
index 0560f46..f6f414a 100644
--- a/src/m2d/event-dispatcher.js
+++ b/src/m2d/event-dispatcher.js
@@ -38,62 +38,12 @@ const retrigger = sync.require("../d2m/actions/retrigger")
const homeserverStatus = sync.require("../matrix/homeserver-status")
/** @type {import("./actions/typing")} */
const typing = sync.require("./actions/typing")
+/** @type {import("../js/errors")} */
+const errors = sync.require("../js/errors")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
-/**
- * This function is adapted from Evan Kaufman's fantastic work.
- * The original function and my adapted function are both MIT licensed.
- * @url https://github.com/EvanK/npm-loggable-error/
- * @param {number} [depth]
- * @returns {string}
-*/
-function stringifyErrorStack(err, depth = 0) {
- let collapsed = " ".repeat(depth);
- if (!(err instanceof Error)) {
- return collapsed + err
- }
-
- // add full stack trace if one exists, otherwise convert to string
- let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
- let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
- if (cloudstormLine !== -1) {
- stackLines = stackLines.slice(0, cloudstormLine - 2)
- }
- collapsed += stackLines.join("\n")
-
- const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
-
- // only break into object notation if we have additional props to dump
- if (props.length) {
- const dedent = " ".repeat(depth);
- const indent = " ".repeat(depth + 2);
-
- collapsed += " {\n";
-
- // loop and print each (indented) prop name
- for (let property of props) {
- collapsed += `${indent}[${property}]: `;
-
- // if another error object, stringify it too
- if (err[property] instanceof Error) {
- collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
- }
- // otherwise stringify as JSON
- else {
- collapsed += JSON.stringify(err[property]);
- }
-
- collapsed += "\n";
- }
-
- collapsed += `${dedent}}\n`;
- }
-
- return collapsed;
-}
-
function printError(type, source, e, payload) {
console.error(`Error while processing a ${type} ${source} event:`)
console.error(e)
@@ -145,7 +95,7 @@ async function sendError(roomID, source, type, e, payload) {
builder.addLine(errorIntroLine)
// Where
- const stack = cleanErrorStack(stringifyErrorStack(e))
+ const stack = cleanErrorStack(errors.stringifyErrorStack(e))
builder.addLine(`Error trace:\n${stack}`, tag`Error trace
${stack} `)
// How
@@ -543,7 +493,6 @@ async event => {
}
}))
-module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError
module.exports.printError = printError
diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js
index 2de6381..302b33b 100644
--- a/src/m2d/event-dispatcher.test.js
+++ b/src/m2d/event-dispatcher.test.js
@@ -1,26 +1,7 @@
// @ts-check
const {test} = require("supertape")
-const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher")
-
-test("stringify error stack: works", t => {
- function a() {
- const e = new Error("message", {cause: new Error("inner")})
- // @ts-ignore
- e.prop = 2.1
- throw e
- }
- try {
- a()
- t.fail("shouldn't get here")
- } catch (e) {
- const str = stringifyErrorStack(e)
- t.match(str, /^Error: message$/m)
- t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m)
- t.match(str, /^ \[cause\]: Error: inner$/m)
- t.match(str, /^ \[prop\]: 2.1$/m)
- }
-})
+const {cleanErrorStack} = require("./event-dispatcher")
test("clean error stack: removes webhook token", t => {
t.notMatch(
diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js
index e61c53b..cca90c2 100644
--- a/src/web/pug-sync.js
+++ b/src/web/pug-sync.js
@@ -2,20 +2,24 @@
const assert = require("assert/strict")
const fs = require("fs")
+const util = require("util")
const {join} = require("path")
const getRelativePath = require("get-relative-path")
const h3 = require("h3")
-const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
+const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestURL} = h3
const {compileFile} = require("@cloudrac3r/pug")
+const walkAST = require("pug-walk")
const pretty = process.argv.join(" ").includes("test")
const {sync} = require("../passthrough")
/** @type {import("./auth")} */
const auth = sync.require("./auth")
+/** @type {import("../js/errors")} */
+const errors = sync.require("../js/errors")
// Pug
-let globals = {}
+let globals = sync.remember(() => ({}))
/** @type {Map) => Promise>} */
const pugCache = new Map()
@@ -30,21 +34,60 @@ function addGlobals(obj) {
* @param {Record} locals
*/
function render(event, filename, locals) {
+ return renderFragments(event, filename, null, locals)
+}
+
+/**
+ * @param {import("h3").H3Event} event
+ * @param {string} filename
+ * @param {string[] | null} fragments
+ * @param {Record} locals
+ */
+function renderFragments(event, filename, fragments, locals) {
const path = join(__dirname, "pug", filename)
- return renderPath(event, path, locals)
+ return renderPath(event, path, fragments, locals)
+}
+
+/**
+ * @param {string[] | null} fragments
+ * @param {string[]} requiresOut
+ */
+function fragmentSelectorPlugins(fragments, requiresOut) {
+ if (!fragments) return []
+ return [{
+ parse: true,
+ postParse(ast, options) {
+ const found = []
+ walkAST(ast, (node, replace) => {
+ if (node.type === "NamedBlock") {
+ const [name, requires] = node.name.split("requires").map(x => x.trim())
+ // console.log(name, "|", requires)
+ if (fragments?.includes(name)) {
+ if (requires) requiresOut.push(...requires.split(",").map(x => x.trim()))
+ found.push(node)
+ }
+ }
+ })
+ // console.log(util.inspect(found, false, 9, true))
+ return {type: "Block", nodes: found, line: 0, filename: found[0].filename}
+ }
+ }]
}
/**
* @param {import("h3").H3Event} event
* @param {string} path
+ * @param {string[] | null} fragments
* @param {Record} locals
*/
-function renderPath(event, path, locals) {
+function renderPath(event, path, fragments, locals) {
+ const cacheKey = path + (fragments ? fragments.map(f => `#${f}`).join("") : "")
+
function compile() {
try {
- const template = compileFile(path, {pretty})
- pugCache.set(path, async (event, locals) => {
- defaultContentType(event, "text/html; charset=utf-8")
+ const requiredVars = []
+ const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)})
+ pugCache.set(cacheKey, async (event, locals) => {
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const rel = (to, paramsObject) => {
@@ -55,16 +98,36 @@ function renderPath(event, path, locals) {
}
return result
}
- return template(Object.assign({},
+ const templateVars = Object.assign({},
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
globals, // Globals
locals, // Explicit locals overwrite globals in case we need to DI something
{session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
- ))
+ )
+ // Verify all fragment block required variables are present
+ for (const key of requiredVars) {
+ if (templateVars[key] === undefined) {
+ setResponseStatus(event, 500, "Internal Template Variables Error")
+ defaultContentType(event, "text/plain")
+ return `requested fragments ${JSON.stringify(fragments)}\nrequire variables ${JSON.stringify(requiredVars)}\nbut "${key}" is ${templateVars[key]} (present: ${key in templateVars})`
+ }
+ }
+ try {
+ const content = template(templateVars)
+ defaultContentType(event, "text/html; charset=utf-8")
+ return content
+ } catch (e) {
+ console.error(e)
+ setResponseStatus(event, 500, "Internal Template Rendering Error")
+ defaultContentType(event, "text/plain")
+ e.url = getRequestURL(event).toString()
+ return errors.stringifyErrorStack(e)
+ }
})
/* c8 ignore start */
} catch (e) {
- pugCache.set(path, async (event) => {
+ pugCache.set(cacheKey, async (event) => {
+ console.error(e)
setResponseStatus(event, 500, "Internal Template Error")
defaultContentType(event, "text/plain")
return e.toString()
@@ -73,14 +136,16 @@ function renderPath(event, path, locals) {
/* c8 ignore stop */
}
- if (!pugCache.has(path)) {
+ if (!pugCache.has(cacheKey)) {
compile()
- fs.watch(path, {persistent: false}, compile)
- fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
- fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
+ const ac = new AbortController()
+ const {signal} = ac
+ fs.watch(path, {persistent: false, signal}, compile)
+ fs.watch(join(__dirname, "pug", "includes"), {persistent: false, signal}, compile)
+ sync.events.once(__filename, () => ac.abort())
}
- const cb = pugCache.get(path)
+ const cb = pugCache.get(cacheKey)
assert(cb)
return cb(event, locals)
}
@@ -98,5 +163,6 @@ function createRoute(router, url, filename) {
module.exports.addGlobals = addGlobals
module.exports.render = render
+module.exports.renderFragments = renderFragments
module.exports.renderPath = renderPath
module.exports.createRoute = createRoute
diff --git a/src/web/pug/fragments/default-roles-list.pug b/src/web/pug/fragments/default-roles-list.pug
deleted file mode 100644
index 3b36549..0000000
--- a/src/web/pug/fragments/default-roles-list.pug
+++ /dev/null
@@ -1,5 +0,0 @@
-//- locals: guild, guild_id
-
-include ../includes/default-roles-list.pug
-+default-roles-list(guild, guild_id)
-+add-roles-menu(guild, guild_id)
diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug
index 7411a1e..c464954 100644
--- a/src/web/pug/guild.pug
+++ b/src/web/pug/guild.pug
@@ -1,5 +1,4 @@
extends includes/template.pug
-include includes/default-roles-list.pug
mixin badge-readonly
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
@@ -113,15 +112,33 @@ block body
.d-flex.fw-wrap.g4
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
- +default-roles-list(guild, guild_id)
+ block default-roles-list requires guild, guild_id
+ #default-roles-list(style="display: contents")
+ each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
+ - let r = guild.roles.find(r => r.id === roleID)
+ if r
+ .s-tag.s-tag__md.fs-body1= r.name
+ span(id=`role-loading-${roleID}`)
+ button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
+ != icons.Icons.IconClearSm
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
.s-tag--dismiss.m1
!= icons.Icons.IconPlusSm
- #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
+ #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible.box-border.lh-sm
.s-popover--arrow.s-popover--arrow__tc
- +add-roles-menu(guild, guild_id)
+
+ block add-roles-menu requires guild, guild_id
+ ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
+ li.s-menu--title.d-flex(role="separator") Select role
+ span#add-role-loading
+ each r in guild.roles.sort((a, b) => b.position - a.position)
+ if r.id !== guild_id && !r.managed
+ - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
+ li(role="menuitem")
+ button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
+
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features
diff --git a/src/web/pug/includes/default-roles-list.pug b/src/web/pug/includes/default-roles-list.pug
deleted file mode 100644
index 8c0a4e0..0000000
--- a/src/web/pug/includes/default-roles-list.pug
+++ /dev/null
@@ -1,19 +0,0 @@
-mixin default-roles-list(guild, guild_id)
- #default-roles-list(style="display: contents")
- each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
- - let r = guild.roles.find(r => r.id === roleID)
- if r
- .s-tag.s-tag__md.fs-body1= r.name
- span(id=`role-loading-${roleID}`)
- button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
- != icons.Icons.IconClearSm
-
-mixin add-roles-menu(guild, guild_id)
- ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
- li.s-menu--title.d-flex(role="separator") Select role
- span#add-role-loading
- each r in guild.roles.sort((a, b) => b.position - a.position)
- if r.id !== guild_id && !r.managed
- - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
- li(role="menuitem")
- button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug
index 81a428d..0596d58 100644
--- a/src/web/pug/invite.pug
+++ b/src/web/pug/invite.pug
@@ -17,10 +17,10 @@ block body
.fl-grow1
h2.fs-headline1 Invite a Matrix user
- form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
+ form.d-flex.gy16.wmx3.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column
label.s-label(for="mxid") Matrix ID
- input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
+ input.fl-grow1.s-input#mxid(name="mxid" required placeholder="@user:example.org")
.d-flex.gy4.fd-column
label.s-label(for="permissions") Permissions
.s-select
diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js
index 8119f93..9bfff3d 100644
--- a/src/web/routes/guild-settings.js
+++ b/src/web/routes/guild-settings.js
@@ -135,7 +135,7 @@ as.router.post("/api/default-roles", defineEventHandler(async event => {
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
if (getRequestHeader(event, "HX-Request")) {
- return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
+ return pugSync.renderFragments(event, "guild.pug", ["default-roles-list", "add-roles-menu"], {guild, guild_id: guildID})
} else {
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
}
diff --git a/src/web/server.js b/src/web/server.js
index e2ce1b5..3c06ede 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -98,7 +98,7 @@ function tryStatic(event, fallthrough) {
getContents: id => {
if (id.match(/\.pug$/)) {
const path = join(publicDir, id)
- return pugSync.renderPath(event, path, {})
+ return pugSync.renderPath(event, path, null, {})
} else {
return fs.createReadStream(join(publicDir, id))
}
diff --git a/test/test.js b/test/test.js
index 4cd9627..5079ed3 100644
--- a/test/test.js
+++ b/test/test.js
@@ -130,6 +130,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("./addbot.test")
require("../src/db/orm.test")
+ require("../src/js/errors.test")
require("../src/web/server.test")
require("../src/discord/utils.test")
require("../src/matrix/kstate.test")