diff --git a/package.json b/package.json index e79a6b0..fb7fbc9 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "start": "node start.js", "setup": "node scripts/setup.js", "addbot": "node addbot.js", - "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", + "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/matrix/mreq.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" } diff --git a/src/discord/interactions/permissions.js b/src/discord/interactions/permissions.js index 0a573e3..fea9ce0 100644 --- a/src/discord/interactions/permissions.js +++ b/src/discord/interactions/permissions.js @@ -82,10 +82,6 @@ async function* _interact({data, guild_id}, {api}) { label: "Moderator", value: "moderator", default: userPower >= 50 && userPower < 100 - }, { - label: "Admin (you cannot undo this!)", - value: "admin", - default: userPower === 100 } ] } @@ -107,10 +103,7 @@ async function* _interactEdit({data, guild_id, message}, {api}) { assert(mxid) const permission = data.values[0] - const power = - ( permission === "admin" ? 100 - : permission === "moderator" ? 50 - : 0) + const power = permission === "moderator" ? 50 : 0 yield {createInteractionResponse: { type: DiscordTypes.InteractionResponseType.UpdateMessage, diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index ebbe9e1..346d8c9 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -600,6 +600,7 @@ async function eventToMessage(event, guild, di) { contentPreview = ": " + contentPreviewChunks[0] if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { + console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent) contentPreview = "" } } diff --git a/src/matrix/api.js b/src/matrix/api.js index 532e326..a75f959 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -301,15 +301,14 @@ async function setUserPower(roomID, mxid, power) { /** * Set a user's power level for a whole room hierarchy. - * @param {string} spaceID + * @param {string} roomID * @param {string} mxid * @param {number} power */ -async function setUserPowerCascade(spaceID, mxid, power) { - assert(spaceID[0] === "!") +async function setUserPowerCascade(roomID, mxid, power) { + assert(roomID[0] === "!") assert(mxid[0] === "@") - const rooms = await getFullHierarchy(spaceID) - await setUserPower(spaceID, mxid, power) + const rooms = await getFullHierarchy(roomID) for (const room of rooms) { await setUserPower(room.room_id, mxid, power) } diff --git a/src/types.d.ts b/src/types.d.ts index 178a560..576bb59 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -114,7 +114,7 @@ export namespace Event { sender: string content: T origin_server_ts: number - unsigned?: any + unsigned: any event_id: string } @@ -137,7 +137,7 @@ export namespace Event { content: any state_key: string origin_server_ts: number - unsigned?: any + unsigned: any event_id: string user_id: string age: number diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index aa1a945..32e7acc 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -42,7 +42,6 @@ function render(event, filename, locals) { {session} // Session is always session because it has to be trusted )) }) - /* c8 ignore start */ } catch (e) { pugCache.set(path, async (event) => { setResponseStatus(event, 500, "Internal Template Error") @@ -50,7 +49,6 @@ function render(event, filename, locals) { return e.toString() }) } - /* c8 ignore stop */ } if (!pugCache.has(path)) { diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index d9ece95..29ad9ad 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -11,7 +11,7 @@ mixin badge-private | Private mixin discord(channel, radio=false) - - let permissions = dUtils.getPermissions([], discord.guilds.get(channel.guild_id).roles, null, channel.permission_overwrites) + - let permissions = dUtils.getPermissions([], discord.guilds.get(channel.guild_id).roles, "", channel.permission_overwrites) .s-user-card.s-user-card__small if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) != icons.Icons.IconLock @@ -49,111 +49,153 @@ mixin matrix(row, radio=false, badge="") +badge-private block body - .s-page-title.mb24 - h1.s-page-title--header= guild.name + if !guild_id && session.data.managedGuilds + .s-empty-state.wmx4.p48 + != icons.Spots.SpotEmptyXL + p Select a server from the top right corner to continue. + p If the server you're looking for isn't there, try #[a(href="/oauth?action=add") logging in again.] - .d-flex.g16 - .fl-grow1 - h2.fs-headline1 Invite a Matrix user + else if !session.data.managedGuilds + .s-empty-state.wmx4.p48 + != icons.Spots.SpotEmptyXL + p You need to log in to manage your servers. + a.s-btn.s-btn__icon.s-btn__filled(href="/oauth") + != icons.Icons.IconDiscord + = ` Log in with Discord` - form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") - label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") - label.s-label(for="permissions") Permissions - .s-select - select#permissions(name="permissions") - option(value="default") Default - option(value="moderator") Moderator - option(value="admin") Admin + else if !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id) + .s-empty-state.wmx4.p48 + != icons.Spots.SpotAlertXL + p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord. + p If you've checked your permissions, try #[a(href="/oauth") logging in again.] + + else + - let guild = discord.guilds.get(guild_id) + + .s-page-title.mb24 + h1.s-page-title--header= guild.name + + .d-flex.g16 + .fl-grow1 + h2.fs-headline1 Invite a Matrix user + + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + input(type="hidden" name="guild_id" value=guild_id) + .grid--row-start2 + button.s-btn.s-btn__filled.htmx-indicator Invite + div + - + let size = 105 + let p = new URLSearchParams() + p.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`) + img(width=size height=size src=`/qr?${p}`) + + h2.mt48.fs-headline1 Moderation + + h2.mt48.fs-headline1 Matrix setup + + h3.mt32.fs-category Linked channels + + .s-card.bs-sm.p0 + .s-table-container + table.s-table.s-table__bx-simple + each row in linkedChannelsWithDetails + tr + td.w40: +discord(row.channel) + td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm + td: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... + + h3.mt32.fs-category Auto-create + .s-card + form.d-flex.ai-center.g8 + label.s-label.fl-grow1(for="autocreate") + | Create new Matrix rooms automatically + p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() input(type="hidden" name="guild_id" value=guild_id) - .grid--row-start2 - button.s-btn.s-btn__filled.htmx-indicator Invite - div - - - let size = 105 - let p = new URLSearchParams() - p.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`) - img(width=size height=size src=`/qr?${p}`) + input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value) + .is-loading#autocreate-loading - h2.mt48.fs-headline1 Moderation + h3.mt32.fs-category Privacy level + .s-card + form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this") + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.ai-center.mb4 + label.s-label.fl-grow1 + | How people can join on Matrix + span.is-loading#privacy-level-loading + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory - h2.mt48.fs-headline1 Matrix setup + input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link - h3.mt32.fs-category Linked channels + input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite - .s-card.bs-sm.p0 - .s-table-container - table.s-table.s-table__bx-simple - each row in linkedChannelsWithDetails - tr - td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm - td: +matrix(row) + p.s-description.m0 In-app direct invite from another user + p.s-description.m0 Shareable invite links, like Discord + p.s-description.m0 Publicly listed in directory, like Discord server discovery + + + //- + fieldset.s-check-group + legend.s-label How people can join on Matrix + .s-check-control + input.s-radio(type="radio" name="privacy-level" id="privacy-level-invite" value="invite" checked) + label.s-label(for="privacy-level-invite") + | Invite + p.s-description In-app direct invite on Matrix; invite command on Discord; invite form on web + .s-check-control + input.s-radio(type="radio" name="privacy-level" id="privacy-level-link" value="link") + label.s-label(for="privacy-level-link") + | Link + p.s-description All of the above, and shareable invite links (like Discord) + .s-check-control + input.s-radio(type="radio" name="privacy-level" id="privacy-level-directory" value="directory") + label.s-label(for="privacy-level-directory") + | Public + p.s-description All of the above, and publicly visible in the Matrix space directory (like Server Discovery) + + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post="/api/privacy-level" hx-trigger="submit" hx-disabled-elt="this") + .fl-grow2.s-btn-group.fd-column.w40 + each channel in unlinkedChannels + input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id) + label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) + +discord(channel, true, "Announcement") else - tr - td(colspan="3") - .s-empty-state No channels linked between Discord and Matrix yet... - - h3.mt32.fs-category Auto-create - .s-card - form.d-flex.ai-center.g8 - label.s-label.fl-grow1(for="autocreate") - | Create new Matrix rooms automatically - p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. - - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() + .s-empty-state.p8 All Discord channels are linked. + .fl-grow1.s-btn-group.fd-column.w30 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" id=room.room_id value=room.room_id) + label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) + +matrix(room, true) + else + .s-empty-state.p8 All Matrix rooms are linked. input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value) - .is-loading#autocreate-loading - - h3.mt32.fs-category Privacy level - .s-card - form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this") - input(type="hidden" name="guild_id" value=guild_id) - .d-flex.ai-center.mb4 - label.s-label.fl-grow1 - | How people can join on Matrix - span.is-loading#privacy-level-loading - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory - - input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link - - input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite - - p.s-description.m0 In-app direct invite from another user - p.s-description.m0 Shareable invite links, like Discord - p.s-description.m0 Publicly listed in directory, like Discord server discovery - - h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post="/api/privacy-level" hx-trigger="submit" hx-disabled-elt="this") - .fl-grow2.s-btn-group.fd-column.w40 - each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id) - label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) - +discord(channel, true, "Announcement") - else - .s-empty-state.p8 All Discord channels are linked. - .fl-grow1.s-btn-group.fd-column.w30 - each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" id=room.room_id value=room.room_id) - label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) - +matrix(room, true) - else - .s-empty-state.p8 All Matrix rooms are linked. - input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator - != icons.Icons.IconMerge - = ` Link` + div + button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator + != icons.Icons.IconMerge + = ` Link` diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug deleted file mode 100644 index 2305605..0000000 --- a/src/web/pug/guild_access_denied.pug +++ /dev/null @@ -1,22 +0,0 @@ -extends includes/template.pug - -block body - if !session.data.managedGuilds - .s-empty-state.wmx4.p48 - != icons.Spots.SpotEmptyXL - p You need to log in to manage your servers. - a.s-btn.s-btn__icon.s-btn__filled(href="/oauth") - != icons.Icons.IconDiscord - = ` Log in with Discord` - - else if !guild_id - .s-empty-state.wmx4.p48 - != icons.Spots.SpotEmptyXL - p Select a server from the top right corner to continue. - p If the server you're looking for isn't there, try #[a(href="/oauth?action=add") logging in again.] - - else if !discord.guilds.has(guild_id) || !session.data.managedGuilds.includes(guild_id) - .s-empty-state.wmx4.p48 - != icons.Spots.SpotAlertXL - p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord. - p If you've checked your permissions, try #[a(href="/oauth") logging in again.] diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug index 7f7ff2e..52b3ab2 100644 --- a/src/web/pug/invite.pug +++ b/src/web/pug/invite.pug @@ -27,7 +27,6 @@ block body select#permissions(name="permissions") option(value="default") Default option(value="moderator") Moderator - option(value="admin") Admin input(type="hidden" name="nonce" value=nonce) div button.s-btn.s-btn__filled.htmx-indicator Invite diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index bbf33b0..ee64074 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -19,15 +19,6 @@ const schema = { }) } -/** - * @param {H3Event} event - * @returns {import("snowtransfer").SnowTransfer} - */ -function getSnow(event) { - /* c8 ignore next */ - return event.context.snow || discord.snow -} - /** @type {Map>} */ const cache = new Map() @@ -65,8 +56,7 @@ function defineMediaProxyHandler(domain) { if (!timeUntilExpiry(refreshed)) promise = undefined } if (!promise) { - const snow = getSnow(event) - promise = snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) + promise = discord.snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) cache.set(url, promise) refreshed = await promise const time = timeUntilExpiry(refreshed) diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js deleted file mode 100644 index b0b0077..0000000 --- a/src/web/routes/download-discord.test.js +++ /dev/null @@ -1,49 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {test} = require("supertape") -const {router} = require("../../../test/web") -const {MatrixServerError} = require("../../matrix/mreq") - -const snow = { - channel: { - async refreshAttachmentURLs(attachments) { - if (typeof attachments === "string") attachments = [attachments] - return { - refreshed_urls: attachments.map(a => ({ - original: a, - refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` - })) - } - } - } -} - -test("web download discord: access denied if not a known attachment", async t => { - const [error] = await tryToCatch(() => - router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { - params: { - channel_id: "1", - attachment_id: "2", - file_name: "image.png" - }, - snow - }) - ) - t.ok(error) -}) - -test("web download discord: works if a known attachment", async t => { - const event = {} - await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { - params: { - channel_id: "655216173696286746", - attachment_id: "1314358913482621010", - file_name: "image.png" - }, - event, - snow - }) - t.equal(event.node.res.statusCode, 302) - t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) -}) diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 8f790c5..f996716 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -11,6 +11,9 @@ require("xxhash-wasm")().then(h => hasher = h) const {sync, as, select} = require("../../passthrough") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + const schema = { params: z.object({ server_name: z.string(), @@ -18,15 +21,6 @@ const schema = { }) } -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { const params = await getValidatedRouterParams(event, schema.params.parse) @@ -42,7 +36,6 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn }) } - const api = getAPI(event) const res = await api.getMedia(`mxc://${params.server_name}/${params.media_id}`) const contentType = res.headers.get("content-type") diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js deleted file mode 100644 index d44271a..0000000 --- a/src/web/routes/download-matrix.test.js +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {test} = require("supertape") -const {router} = require("../../../test/web") -const fetch = require("node-fetch") - -test("web download matrix: access denied if not a known attachment", async t => { - const [error] = await tryToCatch(() => - router.test("get", "/download/matrix/:server_name/:media_id", { - params: { - server_name: "cadence.moe", - media_id: "1" - } - }) - ) - t.ok(error) -}) - -test("web download matrix: works if a known attachment", async t => { - const event = {} - await router.test("get", "/download/matrix/:server_name/:media_id", { - params: { - server_name: "cadence.moe", - media_id: "KrwlqopRyMxnEBcWDgpJZPxh", - }, - event, - api: { - async getMedia(mxc, init) { - return new fetch.Response("", {status: 200, headers: {"content-type": "image/png"}}) - } - } - }) - t.equal(event.node.res.statusCode, 200) - t.equal(event.node.res.getHeader("content-type"), "image/png") -}) diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js deleted file mode 100644 index b5b4080..0000000 --- a/src/web/routes/guild.test.js +++ /dev/null @@ -1,199 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {test} = require("supertape") -const {router} = require("../../../test/web") -const {MatrixServerError} = require("../../matrix/mreq") - -let nonce - -test("web guild: access denied when not logged in", async t => { - const content = await router.test("get", "/guild?guild_id=112760669178241024", { - sessionData: { - }, - }) - t.match(content, /You need to log in to manage your servers./) -}) - -test("web guild: asks to select guild if not selected", async t => { - const content = await router.test("get", "/guild", { - sessionData: { - managedGuilds: [] - }, - }) - t.match(content, /Select a server from the top right corner to continue./) -}) - -test("web guild: access denied when guild id messed up", async t => { - const content = await router.test("get", "/guild?guild_id=1", { - sessionData: { - managedGuilds: [] - }, - }) - t.match(content, /the selected server doesn't exist/) -}) - - - - -test("web invite: access denied with invalid nonce", async t => { - const content = await router.test("get", "/invite?nonce=1") - t.match(content, /This QR code has expired./) -}) - -test("web guild: can view guild", async t => { - const content = await router.test("get", "/guild?guild_id=112760669178241024", { - sessionData: { - managedGuilds: ["112760669178241024"] - }, - api: { - async getStateEvent(roomID, type, key) { - return {} - }, - async getMembers(roomID, membership) { - return {chunk: []} - }, - async getFullHierarchy(roomID) { - return [] - } - } - }) - t.match(content, / { - const content = await router.test("get", `/invite?nonce=${nonce}`) - t.match(content, /Invite a Matrix user/) -}) - - - - -test("api invite: access denied with nothing", async t => { - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator" - } - }) - ) - t.equal(error.message, "Missing guild ID") -}) - -test("api invite: access denied when not in guild", async t => { - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator", - guild_id: "112760669178241024" - } - }) - ) - t.equal(error.message, "Forbidden") -}) - -test("api invite: can invite with valid nonce", async t => { - let called = 0 - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator", - nonce - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - return {membership: "leave"} - }, - async inviteToRoom(roomID, mxidToInvite, mxid) { - t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") - called++ - }, - async setUserPowerCascade(roomID, mxid, power) { - t.equal(power, 50) // moderator - called++ - } - } - }) - ) - t.notOk(error) - t.equal(called, 3) -}) - -test("api invite: access denied when nonce has been used", async t => { - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator", - nonce - } - }) - ) - t.equal(error.message, "Nonce expired") -}) - -test("api invite: can invite to a moderated guild", async t => { - let called = 0 - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "default", - guild_id: "112760669178241024" - }, - sessionData: { - managedGuilds: ["112760669178241024"] - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "Event not found or something"}) - }, - async inviteToRoom(roomID, mxidToInvite, mxid) { - t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") - called++ - }, - async setUserPowerCascade(roomID, mxid, power) { - t.equal(power, 0) - called++ - } - } - }) - ) - t.notOk(error) - t.equal(called, 3) -}) - -test("api invite: does not reinvite joined users", async t => { - let called = 0 - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "default", - guild_id: "112760669178241024" - }, - sessionData: { - managedGuilds: ["112760669178241024"] - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - return {membership: "join"} - }, - async setUserPowerCascade(roomID, mxid, power) { - t.equal(power, 0) - called++ - } - } - }) - ) - t.notOk(error) - t.equal(called, 2) -}) diff --git a/src/web/routes/guild.js b/src/web/routes/invite.js similarity index 83% rename from src/web/routes/guild.js rename to src/web/routes/invite.js index 716314c..83973c0 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/invite.js @@ -2,7 +2,7 @@ const assert = require("assert/strict") const {z} = require("zod") -const {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3") +const {defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3") const {randomUUID} = require("crypto") const {LRUCache} = require("lru-cache") const Ty = require("../../types") @@ -14,13 +14,16 @@ const pugSync = sync.require("../pug-sync") const createSpace = sync.require("../../d2m/actions/create-space") const {reg} = require("../../matrix/read-registration") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + const schema = { guild: z.object({ guild_id: z.string().optional() }), invite: z.object({ mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), - permissions: z.enum(["default", "moderator", "admin"]), + permissions: z.enum(["default", "moderator"]), guild_id: z.string().optional(), nonce: z.string().optional() }), @@ -29,15 +32,6 @@ const schema = { }) } -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - /** @type {LRUCache} nonce to guild id */ const validNonce = new LRUCache({max: 200}) @@ -82,12 +76,10 @@ as.router.get("/guild", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.guild.parse) const session = await useSession(event, {password: reg.as_token}) const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) // Permission problems - if (!guild_id || !guild || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id}) + if (!guild_id || !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) { + return pugSync.render(event, "guild.pug", {guild_id}) } const nonce = randomUUID() @@ -100,12 +92,11 @@ as.router.get("/guild", defineEventHandler(async event => { } // Linked guild - const api = getAPI(event) const mods = await api.getStateEvent(row.space_id, "m.room.power_levels", "") const banned = await api.getMembers(row.space_id, "ban") const rooms = await api.getFullHierarchy(row.space_id) const links = getChannelRoomsLinks(guild_id, rooms) - return pugSync.render(event, "guild.pug", {guild, guild_id, nonce, mods, banned, ...links, ...row}) + return pugSync.render(event, "guild.pug", {guild_id, nonce, mods, banned, ...links, ...row}) })) as.router.get("/invite", defineEventHandler(async event => { @@ -119,7 +110,6 @@ as.router.get("/invite", defineEventHandler(async event => { as.router.post("/api/invite", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.invite.parse) const session = await useSession(event, {password: reg.as_token}) - const api = getAPI(event) // Check guild ID or nonce if (parsedBody.guild_id) { @@ -146,17 +136,15 @@ as.router.post("/api/invite", defineEventHandler(async event => { spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) } catch (e) {} - if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { + if (!spaceMember || spaceMember.membership !== "invite" || spaceMember.membership !== "join") { // Invite await api.inviteToRoom(spaceID, parsedBody.mxid) } // Permissions - const powerLevel = - ( parsedBody.permissions === "admin" ? 100 - : parsedBody.permissions === "moderator" ? 50 - : 0) - await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel) + if (parsedBody.permissions === "moderator") { + await api.setUserPowerCascade(spaceID, parsedBody.mxid, 50) + } if (parsedBody.guild_id) { return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) diff --git a/src/web/routes/qr.test.js b/src/web/routes/qr.test.js deleted file mode 100644 index 5b17f87..0000000 --- a/src/web/routes/qr.test.js +++ /dev/null @@ -1,17 +0,0 @@ -const {test} = require("supertape") -const {router} = require("../../../test/web") -const getStream = require("get-stream") - -test("web qr: returns svg", async t => { - /** @type {Response} */ - const res = await router.test("get", "/qr?data=hello+world", { - params: { - server_name: "cadence.moe", - media_id: "1" - } - }) - t.equal(res.status, 200) - t.equal(res.headers.get("content-type"), "image/svg+xml") - const content = await getStream(res.body) - t.match(content, / { - t.match(await router.test("get", "/", {}), /Add the bot to your Discord server./) -}) - -test("web server: can get htmx", async t => { - t.match(await router.test("get", "/static/htmx.min.js", {}), /htmx=/) -}) - -test("web server: can get css", async t => { - t.match(await router.test("get", "/static/stacks.min.css", {}), /--stacks-/) -}) - -test("web server: can get icon", async t => { - const content = await router.test("get", "/icon.png", {}) - t.ok(content instanceof Buffer) -}) - -test("web server: compresses static resources", async t => { - const content = await router.test("get", "/static/stacks.min.css", { - headers: { - "accept-encoding": "gzip" - } - }) - t.ok(content instanceof ReadableStream) -}) diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 38fed25..af7ea7b 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -163,13 +163,9 @@ INSERT INTO member_power (mxid, room_id, power_level) VALUES INSERT INTO lottie (sticker_id, mxc_url) VALUES ('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR'); -INSERT INTO auto_emoji (name, emoji_id, guild_id) VALUES -('L1', '1144820033948762203', '529176156398682115'), -('L2', '1144820084079087647', '529176156398682115'), -('_', '_', '529176156398682115'); - -INSERT INTO media_proxy (permitted_hash) VALUES -(-429802515645771439), -(4558604729745184757); +INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES +('L1','1144820033948762203','529176156398682115'), +('L2','1144820084079087647','529176156398682115'), +('_','_','529176156398682115'); COMMIT; diff --git a/test/test.js b/test/test.js index 9e595ae..5f20a80 100644 --- a/test/test.js +++ b/test/test.js @@ -20,9 +20,9 @@ const {reg} = require("../src/matrix/read-registration") reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" -reg.id = "baby" -reg.as_token = "don't actually take authenticated actions on the server" -reg.hs_token = "don't actually take authenticated actions on the server" +reg.id = "baby" // don't actually take authenticated actions on the server +reg.as_token = "baby" +reg.hs_token = "baby" reg.ooye.bridge_origin = "https://bridge.example.org" const sync = new HeatSync({watchFS: false}) @@ -31,9 +31,6 @@ const discord = { guilds: new Map([ [data.guild.general.id, data.guild.general] ]), - guildChannelMap: new Map([ - [data.guild.general.id, [data.channel.general.id]], - ]), application: { id: "684280192553844747" }, @@ -100,7 +97,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not ]) }, {timeout: 60000}) } - /* c8 ignore stop */ + /* c8 ignore end */ const p = migrate.migrate(db) test("migrate: migration works", async t => { @@ -145,9 +142,4 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/discord/interactions/permissions.test") require("../src/discord/interactions/privacy.test") require("../src/discord/interactions/reactions.test") - require("../src/web/server.test") - require("../src/web/routes/download-discord.test") - require("../src/web/routes/download-matrix.test") - require("../src/web/routes/guild.test") - require("../src/web/routes/qr.test") })() diff --git a/test/web.js b/test/web.js deleted file mode 100644 index e22a97c..0000000 --- a/test/web.js +++ /dev/null @@ -1,69 +0,0 @@ -const passthrough = require("../src/passthrough") -const h3 = require("h3") -const http = require("http") -const {SnowTransfer} = require("snowtransfer") - -class Router { - constructor() { - /** @type {Map} */ - this.routes = new Map() - for (const method of ["get", "post", "put", "patch", "delete"]) { - this[method] = function(url, handler) { - const key = `${method} ${url}` - this.routes.set(`${key}`, handler) - } - } - } - - /** - * @param {string} method - * @param {string} inputUrl - * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial, snow?: {[k in keyof SnowTransfer]?: Partial}, headers?: any}} [options] - */ - test(method, inputUrl, options = {}) { - const url = new URL(inputUrl, "http://a") - const key = `${method} ${options.route || url.pathname}` - /* c8 ignore next */ - if (!this.routes.has(key)) throw new Error(`Route not found: "${key}"`) - - const req = { - method: method.toUpperCase(), - headers: options.headers || {}, - url - } - const event = options.event || {} - - if (typeof options.body === "object" && options.body.constructor === Object) { - options.body = JSON.stringify(options.body) - req.headers["content-type"] = "application/json" - } - - return this.routes.get(key)(Object.assign(event, { - method: method.toUpperCase(), - path: `${url.pathname}${url.search}`, - _requestBody: options.body, - node: { - req, - res: new http.ServerResponse(req) - }, - context: { - api: options.api, - params: options.params, - snow: options.snow, - sessions: { - h3: { - id: "h3", - createdAt: 0, - data: options.sessionData || {} - } - } - } - })) - } -} - -const router = new Router() - -passthrough.as = {router} - -module.exports.router = router