Compare commits
No commits in common. "main" and "v3.7" have entirely different histories.
26 changed files with 634 additions and 529 deletions
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
|
@ -1,11 +1,5 @@
|
||||||
{
|
{
|
||||||
"editor.insertSpaces": false,
|
"editor.insertSpaces": false,
|
||||||
"editor.detectIndentation": 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,14 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
|
||||||
|
|
||||||
# Dependency justification
|
# Dependency justification
|
||||||
|
|
||||||
Total transitive production dependencies: 112
|
Total transitive production dependencies: 144
|
||||||
|
|
||||||
### <font size="+2">🦕</font>
|
### <font size="+2">🦕</font>
|
||||||
|
|
||||||
|
* (35) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
||||||
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
|
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
|
||||||
* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
||||||
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
|
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
|
||||||
* (1) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
|
||||||
|
|
||||||
### <font size="-1">🪱</font>
|
### <font size="-1">🪱</font>
|
||||||
|
|
||||||
|
|
@ -120,7 +120,6 @@ Total transitive production dependencies: 112
|
||||||
* (0) entities: Looks fine. No dependencies.
|
* (0) entities: Looks fine. No dependencies.
|
||||||
* (0) get-relative-path: Looks fine. No dependencies.
|
* (0) get-relative-path: Looks fine. No dependencies.
|
||||||
* (1) heatsync: Module hot-reloader that I trust.
|
* (1) heatsync: Module hot-reloader that I trust.
|
||||||
* (0) htmx.org: Declarative partial page reloads that do just enough. I'm not totally happy with this one, but not sure what I'd do instead.
|
|
||||||
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
||||||
* (1) mime-types: List of mime type mappings. Needed to serve static files.
|
* (1) mime-types: List of mime type mappings. Needed to serve static files.
|
||||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||||
|
|
@ -129,18 +128,3 @@ Total transitive production dependencies: 112
|
||||||
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
|
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
|
||||||
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
||||||
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
||||||
|
|
||||||
### Outdated versions
|
|
||||||
|
|
||||||
* @chriscdn/promise-semaphore
|
|
||||||
* Highest usable version is 3.1.3 because the author **dropped CJS** in 4.0.0.
|
|
||||||
* @stackoverflow/stacks
|
|
||||||
* Highest usable version is 2.9.0 (and 6.9.0 for stacks-icons) because the company **ruined their design** in 3.0.0.
|
|
||||||
* cross-env
|
|
||||||
* Highest usable version is 7.0.3 because the author **dropped CJS** in 10.0.0.
|
|
||||||
* entities
|
|
||||||
* Using version 5.0.0. The author messed things up in 6.0.0, started using LLMs in 7.0.0, and **dropped CJS** in 8.0.0.
|
|
||||||
* h3
|
|
||||||
* Using major version 1. Version 2 has been stuck as **release candidates for months**, and all the code comments are Claude.
|
|
||||||
* htmx.org
|
|
||||||
* Using major version 2. Version 4 exists, and **I'd be okay updating to it**, but the author changed a lot and the entire site would need to be re-tested.
|
|
||||||
|
|
|
||||||
752
package-lock.json
generated
752
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -31,7 +31,7 @@
|
||||||
"@stackoverflow/stacks": "^2.5.4",
|
"@stackoverflow/stacks": "^2.5.4",
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
"ansi-colors": "^4.1.3",
|
"ansi-colors": "^4.1.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^12.2.0",
|
||||||
"chunk-text": "^2.0.1",
|
"chunk-text": "^2.0.1",
|
||||||
"cloudstorm": "^0.19.0",
|
"cloudstorm": "^0.19.0",
|
||||||
"denque": "^2.1.0",
|
"denque": "^2.1.0",
|
||||||
|
|
@ -41,10 +41,10 @@
|
||||||
"entities": "^5.0.0",
|
"entities": "^5.0.0",
|
||||||
"get-relative-path": "^1.0.2",
|
"get-relative-path": "^1.0.2",
|
||||||
"h3": "^1.15.10",
|
"h3": "^1.15.10",
|
||||||
"heatsync": "^3.3.0",
|
"heatsync": "^2.7.2",
|
||||||
"htmx.org": "^2.0.4",
|
"htmx.org": "^2.0.4",
|
||||||
"lru-cache": "^11.0.2",
|
"lru-cache": "^11.0.2",
|
||||||
"mime-types": "^3.0.2",
|
"mime-types": "^2.1.35",
|
||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"snowtransfer": "^0.19.0",
|
"snowtransfer": "^0.19.0",
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudrac3r/tap-dot": "^2.0.3",
|
"@cloudrac3r/tap-dot": "^2.0.3",
|
||||||
"@types/node": "^22.17.1",
|
"@types/node": "^22.17.1",
|
||||||
"c8": "^12.0.0",
|
"c8": "^11.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"supertape": "^13.2.0"
|
"supertape": "^13.2.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const {createServer} = require("http")
|
||||||
|
const EventEmitter = require("events")
|
||||||
|
const {createApp, createRouter, toNodeListener} = require("h3")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const migrate = require("../src/db/migrate")
|
const migrate = require("../src/db/migrate")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
@ -13,10 +16,6 @@ const sync = new HeatSync()
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
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 DiscordClient = require("../src/d2m/discord-client")
|
||||||
|
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
||||||
|
|
@ -25,6 +24,10 @@ passthrough.discord = discord
|
||||||
const {as} = require("../src/matrix/appservice")
|
const {as} = require("../src/matrix/appservice")
|
||||||
passthrough.as = as
|
passthrough.as = as
|
||||||
|
|
||||||
|
const orm = sync.require("../src/db/orm")
|
||||||
|
passthrough.from = orm.from
|
||||||
|
passthrough.select = orm.select
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await migrate.migrate(db)
|
await migrate.migrate(db)
|
||||||
await discord.cloud.connect()
|
await discord.cloud.connect()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
|
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const stream = require("stream")
|
const stream = require("stream")
|
||||||
const {PNG} = require("@cloudrac3r/pngjs")
|
const {PNG} = require("@cloudrac3r/pngjs")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ function userToSimName(user) {
|
||||||
|
|
||||||
// 1. Is sim user already registered?
|
// 1. Is sim user already registered?
|
||||||
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
|
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
|
||||||
assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
|
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||||
|
|
||||||
// 2. Register based on username (could be new or old format)
|
// 2. Register based on username (could be new or old format)
|
||||||
// (Unless it's a special user, in which case copy their provided mappings.)
|
// (Unless it's a special user, in which case copy their provided mappings.)
|
||||||
|
|
@ -113,7 +113,7 @@ function webhookAuthorToSimName(author) {
|
||||||
// 1. Is sim user already registered?
|
// 1. Is sim user already registered?
|
||||||
const fakeUserID = webhookAuthorToFakeUserID(author)
|
const fakeUserID = webhookAuthorToFakeUserID(author)
|
||||||
const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get()
|
const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get()
|
||||||
assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
|
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||||
|
|
||||||
// 2. Register based on username (could be new or old format)
|
// 2. Register based on username (could be new or old format)
|
||||||
const downcased = "webhook_" + downcaseUsername(author)
|
const downcased = "webhook_" + downcaseUsername(author)
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,6 @@ async function onPacket(client, message, listen) {
|
||||||
const firstReady = guildReadyStatus.allReady()
|
const firstReady = guildReadyStatus.allReady()
|
||||||
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
|
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
|
||||||
|
|
||||||
if (lastGuildReady) {
|
|
||||||
console.log(`ok, ${client.guilds.size} available.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const arr = []
|
const arr = []
|
||||||
client.guildChannelMap.set(message.d.id, arr)
|
client.guildChannelMap.set(message.d.id, arr)
|
||||||
for (const channel of message.d.channels || []) {
|
for (const channel of message.d.channels || []) {
|
||||||
|
|
@ -97,7 +93,7 @@ async function onPacket(client, message, listen) {
|
||||||
try {
|
try {
|
||||||
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
|
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
|
||||||
if (lastGuildReady) {
|
if (lastGuildReady) {
|
||||||
process.stdout.write(`Connecting to homeserver... `)
|
process.stdout.write(`ok, ${client.guilds.size} available.\nConnecting to homeserver... `)
|
||||||
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
|
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
|
||||||
await homeserverStatus.homeserverStatus.waitForOnline(true)
|
await homeserverStatus.homeserverStatus.waitForOnline(true)
|
||||||
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
|
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,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;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.stringifyErrorStack = stringifyErrorStack
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
// @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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -620,7 +620,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
let filename = event.content.filename || event.content.body
|
let filename = event.content.filename || event.content.body
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
const extension = mimeTypes.extension(event.content.info?.mimetype)
|
const extension = mimeTypes.extension(event.content.info?.mimetype)
|
||||||
assert(extension, "Filename and mimetype missing. Please report this bug to your client.")
|
if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.")
|
||||||
filename = `file.${extension}`
|
filename = `file.${extension}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4816,39 +4816,6 @@ test("event2message: image attachments can have a formatted caption", async t =>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("event2message: attachments have a generated filename if it was missing", async t => {
|
|
||||||
t.deepEqual(
|
|
||||||
await eventToMessage({
|
|
||||||
type: "m.room.message",
|
|
||||||
sender: "@cadence:cadence.moe",
|
|
||||||
content: {
|
|
||||||
body: "",
|
|
||||||
filename: "",
|
|
||||||
info: {
|
|
||||||
mimetype: "image/png",
|
|
||||||
size: 50832
|
|
||||||
},
|
|
||||||
msgtype: "m.image",
|
|
||||||
url: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"
|
|
||||||
},
|
|
||||||
event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
|
|
||||||
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
ensureJoined: [],
|
|
||||||
messagesToDelete: [],
|
|
||||||
messagesToEdit: [],
|
|
||||||
messagesToSend: [{
|
|
||||||
username: "cadence [they]",
|
|
||||||
content: "",
|
|
||||||
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar",
|
|
||||||
attachments: [{id: "0", filename: "file.png"}],
|
|
||||||
pendingFiles: [{name: "file.png", mxc: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("event2message: encrypted image attachments work", async t => {
|
test("event2message: encrypted image attachments work", async t => {
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
await eventToMessage({
|
await eventToMessage({
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,62 @@ const retrigger = sync.require("../d2m/actions/retrigger")
|
||||||
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
||||||
/** @type {import("./actions/typing")} */
|
/** @type {import("./actions/typing")} */
|
||||||
const typing = sync.require("./actions/typing")
|
const typing = sync.require("./actions/typing")
|
||||||
/** @type {import("../js/errors")} */
|
|
||||||
const errors = sync.require("../js/errors")
|
|
||||||
const {reg} = require("../matrix/read-registration")
|
const {reg} = require("../matrix/read-registration")
|
||||||
|
|
||||||
let lastReportedEvent = 0
|
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) {
|
function printError(type, source, e, payload) {
|
||||||
console.error(`Error while processing a ${type} ${source} event:`)
|
console.error(`Error while processing a ${type} ${source} event:`)
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -95,7 +145,7 @@ async function sendError(roomID, source, type, e, payload) {
|
||||||
builder.addLine(errorIntroLine)
|
builder.addLine(errorIntroLine)
|
||||||
|
|
||||||
// Where
|
// Where
|
||||||
const stack = cleanErrorStack(errors.stringifyErrorStack(e))
|
const stack = cleanErrorStack(stringifyErrorStack(e))
|
||||||
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||||
|
|
||||||
// How
|
// How
|
||||||
|
|
@ -493,6 +543,7 @@ async event => {
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
module.exports.stringifyErrorStack = stringifyErrorStack
|
||||||
module.exports.cleanErrorStack = cleanErrorStack
|
module.exports.cleanErrorStack = cleanErrorStack
|
||||||
module.exports.sendError = sendError
|
module.exports.sendError = sendError
|
||||||
module.exports.printError = printError
|
module.exports.printError = printError
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,26 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {cleanErrorStack} = require("./event-dispatcher")
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("clean error stack: removes webhook token", t => {
|
test("clean error stack: removes webhook token", t => {
|
||||||
t.notMatch(
|
t.notMatch(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const Denque = require("denque")
|
const Denque = require("denque")
|
||||||
const StateMachine = require("snowtransfer").StateMachine
|
const StateMachine = require("snowtransfer").StateMachine
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ const {addbot} = require("../addbot")
|
||||||
const passthrough = require("./passthrough")
|
const passthrough = require("./passthrough")
|
||||||
const {discord, sync, db, select, from, as} = passthrough
|
const {discord, sync, db, select, from, as} = passthrough
|
||||||
|
|
||||||
const reg = require("./matrix/read-registration")
|
|
||||||
const data = sync.require("../test/data")
|
const data = sync.require("../test/data")
|
||||||
const createSpace = sync.require("./d2m/actions/create-space")
|
const createSpace = sync.require("./d2m/actions/create-space")
|
||||||
const createRoom = sync.require("./d2m/actions/create-room")
|
const createRoom = sync.require("./d2m/actions/create-room")
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,20 @@
|
||||||
|
|
||||||
const assert = require("assert/strict")
|
const assert = require("assert/strict")
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const util = require("util")
|
|
||||||
const {join} = require("path")
|
const {join} = require("path")
|
||||||
const getRelativePath = require("get-relative-path")
|
const getRelativePath = require("get-relative-path")
|
||||||
const h3 = require("h3")
|
const h3 = require("h3")
|
||||||
const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestURL} = h3
|
const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
|
||||||
const {compileFile} = require("@cloudrac3r/pug")
|
const {compileFile} = require("@cloudrac3r/pug")
|
||||||
const walkAST = require("pug-walk")
|
|
||||||
const pretty = process.argv.join(" ").includes("test")
|
const pretty = process.argv.join(" ").includes("test")
|
||||||
|
|
||||||
const {sync} = require("../passthrough")
|
const {sync} = require("../passthrough")
|
||||||
/** @type {import("./auth")} */
|
/** @type {import("./auth")} */
|
||||||
const auth = sync.require("./auth")
|
const auth = sync.require("./auth")
|
||||||
/** @type {import("../js/errors")} */
|
|
||||||
const errors = sync.require("../js/errors")
|
|
||||||
|
|
||||||
// Pug
|
// Pug
|
||||||
|
|
||||||
let globals = sync.remember(() => ({}))
|
let globals = {}
|
||||||
|
|
||||||
/** @type {Map<string, (event: import("h3").H3Event, locals: Record<string, any>) => Promise<string>>} */
|
/** @type {Map<string, (event: import("h3").H3Event, locals: Record<string, any>) => Promise<string>>} */
|
||||||
const pugCache = new Map()
|
const pugCache = new Map()
|
||||||
|
|
@ -34,60 +30,21 @@ function addGlobals(obj) {
|
||||||
* @param {Record<string, any>} locals
|
* @param {Record<string, any>} locals
|
||||||
*/
|
*/
|
||||||
function render(event, filename, 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<string, any>} locals
|
|
||||||
*/
|
|
||||||
function renderFragments(event, filename, fragments, locals) {
|
|
||||||
const path = join(__dirname, "pug", filename)
|
const path = join(__dirname, "pug", filename)
|
||||||
return renderPath(event, path, fragments, locals)
|
return renderPath(event, path, 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 {import("h3").H3Event} event
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @param {string[] | null} fragments
|
|
||||||
* @param {Record<string, any>} locals
|
* @param {Record<string, any>} locals
|
||||||
*/
|
*/
|
||||||
function renderPath(event, path, fragments, locals) {
|
function renderPath(event, path, locals) {
|
||||||
const cacheKey = path + (fragments ? fragments.map(f => `#${f}`).join("") : "")
|
|
||||||
|
|
||||||
function compile() {
|
function compile() {
|
||||||
try {
|
try {
|
||||||
const requiredVars = []
|
const template = compileFile(path, {pretty})
|
||||||
const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)})
|
pugCache.set(path, async (event, locals) => {
|
||||||
pugCache.set(cacheKey, async (event, locals) => {
|
defaultContentType(event, "text/html; charset=utf-8")
|
||||||
const session = await auth.useSession(event)
|
const session = await auth.useSession(event)
|
||||||
const managed = await auth.getManagedGuilds(event)
|
const managed = await auth.getManagedGuilds(event)
|
||||||
const rel = (to, paramsObject) => {
|
const rel = (to, paramsObject) => {
|
||||||
|
|
@ -98,36 +55,16 @@ function renderPath(event, path, fragments, locals) {
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const templateVars = Object.assign({},
|
return template(Object.assign({},
|
||||||
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
|
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
|
||||||
globals, // Globals
|
globals, // Globals
|
||||||
locals, // Explicit locals overwrite globals in case we need to DI something
|
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.
|
{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 */
|
/* c8 ignore start */
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pugCache.set(cacheKey, async (event) => {
|
pugCache.set(path, async (event) => {
|
||||||
console.error(e)
|
|
||||||
setResponseStatus(event, 500, "Internal Template Error")
|
setResponseStatus(event, 500, "Internal Template Error")
|
||||||
defaultContentType(event, "text/plain")
|
defaultContentType(event, "text/plain")
|
||||||
return e.toString()
|
return e.toString()
|
||||||
|
|
@ -136,16 +73,14 @@ function renderPath(event, path, fragments, locals) {
|
||||||
/* c8 ignore stop */
|
/* c8 ignore stop */
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pugCache.has(cacheKey)) {
|
if (!pugCache.has(path)) {
|
||||||
compile()
|
compile()
|
||||||
const ac = new AbortController()
|
fs.watch(path, {persistent: false}, compile)
|
||||||
const {signal} = ac
|
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
|
||||||
fs.watch(path, {persistent: false, signal}, compile)
|
fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
|
||||||
fs.watch(join(__dirname, "pug", "includes"), {persistent: false, signal}, compile)
|
|
||||||
sync.events.once(__filename, () => ac.abort())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cb = pugCache.get(cacheKey)
|
const cb = pugCache.get(path)
|
||||||
assert(cb)
|
assert(cb)
|
||||||
return cb(event, locals)
|
return cb(event, locals)
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +98,5 @@ function createRoute(router, url, filename) {
|
||||||
|
|
||||||
module.exports.addGlobals = addGlobals
|
module.exports.addGlobals = addGlobals
|
||||||
module.exports.render = render
|
module.exports.render = render
|
||||||
module.exports.renderFragments = renderFragments
|
|
||||||
module.exports.renderPath = renderPath
|
module.exports.renderPath = renderPath
|
||||||
module.exports.createRoute = createRoute
|
module.exports.createRoute = createRoute
|
||||||
|
|
|
||||||
5
src/web/pug/fragments/default-roles-list.pug
Normal file
5
src/web/pug/fragments/default-roles-list.pug
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
//- locals: guild, guild_id
|
||||||
|
|
||||||
|
include ../includes/default-roles-list.pug
|
||||||
|
+default-roles-list(guild, guild_id)
|
||||||
|
+add-roles-menu(guild, guild_id)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
extends includes/template.pug
|
extends includes/template.pug
|
||||||
|
include includes/default-roles-list.pug
|
||||||
|
|
||||||
mixin badge-readonly
|
mixin badge-readonly
|
||||||
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
|
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
|
||||||
|
|
@ -112,33 +113,15 @@ block body
|
||||||
.d-flex.fw-wrap.g4
|
.d-flex.fw-wrap.g4
|
||||||
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
|
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
|
||||||
|
|
||||||
block default-roles-list requires guild, guild_id
|
+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
|
|
||||||
|
|
||||||
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
|
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
|
||||||
.s-tag--dismiss.m1
|
.s-tag--dismiss.m1
|
||||||
!= icons.Icons.IconPlusSm
|
!= icons.Icons.IconPlusSm
|
||||||
|
|
||||||
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible.box-border.lh-sm
|
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
|
||||||
.s-popover--arrow.s-popover--arrow__tc
|
.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.
|
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
|
h3.mt32.fs-category Features
|
||||||
|
|
|
||||||
19
src/web/pug/includes/default-roles-list.pug
Normal file
19
src/web/pug/includes/default-roles-list.pug
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
|
@ -17,10 +17,10 @@ block body
|
||||||
.fl-grow1
|
.fl-grow1
|
||||||
h2.fs-headline1 Invite a Matrix user
|
h2.fs-headline1 Invite a Matrix user
|
||||||
|
|
||||||
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")
|
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")
|
||||||
.d-flex.gy4.fd-column
|
.d-flex.gy4.fd-column
|
||||||
label.s-label(for="mxid") Matrix ID
|
label.s-label(for="mxid") Matrix ID
|
||||||
input.fl-grow1.s-input#mxid(name="mxid" required placeholder="@user:example.org")
|
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
|
||||||
.d-flex.gy4.fd-column
|
.d-flex.gy4.fd-column
|
||||||
label.s-label(for="permissions") Permissions
|
label.s-label(for="permissions") Permissions
|
||||||
.s-select
|
.s-select
|
||||||
|
|
|
||||||
|
|
@ -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
|
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
|
||||||
|
|
||||||
if (getRequestHeader(event, "HX-Request")) {
|
if (getRequestHeader(event, "HX-Request")) {
|
||||||
return pugSync.renderFragments(event, "guild.pug", ["default-roles-list", "add-roles-menu"], {guild, guild_id: guildID})
|
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
|
||||||
} else {
|
} else {
|
||||||
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
|
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const {join} = require("path")
|
const {join} = require("path")
|
||||||
const h3 = require("h3")
|
const h3 = require("h3")
|
||||||
|
|
@ -98,7 +98,7 @@ function tryStatic(event, fallthrough) {
|
||||||
getContents: id => {
|
getContents: id => {
|
||||||
if (id.match(/\.pug$/)) {
|
if (id.match(/\.pug$/)) {
|
||||||
const path = join(publicDir, id)
|
const path = join(publicDir, id)
|
||||||
return pugSync.renderPath(event, path, null, {})
|
return pugSync.renderPath(event, path, {})
|
||||||
} else {
|
} else {
|
||||||
return fs.createReadStream(join(publicDir, id))
|
return fs.createReadStream(join(publicDir, id))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,6 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
||||||
|
|
||||||
require("./addbot.test")
|
require("./addbot.test")
|
||||||
require("../src/db/orm.test")
|
require("../src/db/orm.test")
|
||||||
require("../src/js/errors.test")
|
|
||||||
require("../src/web/server.test")
|
require("../src/web/server.test")
|
||||||
require("../src/discord/utils.test")
|
require("../src/discord/utils.test")
|
||||||
require("../src/matrix/kstate.test")
|
require("../src/matrix/kstate.test")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue