From 50d5509c074e884e3f8210de0d2d60df0d1be4c8 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 11:43:11 +1200 Subject: [PATCH 1/3] Rename files for consistency --- .../{guild_access_denied.pug => guild-access-denied.pug} | 0 src/web/pug/{guild_not_linked.pug => guild-not-linked.pug} | 0 src/web/routes/guild.js | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/web/pug/{guild_access_denied.pug => guild-access-denied.pug} (100%) rename src/web/pug/{guild_not_linked.pug => guild-not-linked.pug} (100%) diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild-access-denied.pug similarity index 100% rename from src/web/pug/guild_access_denied.pug rename to src/web/pug/guild-access-denied.pug diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild-not-linked.pug similarity index 100% rename from src/web/pug/guild_not_linked.pug rename to src/web/pug/guild-not-linked.pug diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 70092d5..c9561e5 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -158,13 +158,13 @@ as.router.get("/guild", defineEventHandler(async event => { // Permission problems if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) } // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow if (!row.space_id && row.autocreate === 0) { const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] - return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) + return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) } const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] @@ -191,7 +191,7 @@ as.router.get("/qr", defineEventHandler(async event => { // Permission problems if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) } const nonce = randomUUID() From 63a6d37ec25504376a2b3d001f70632624718a13 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 14:31:33 +1200 Subject: [PATCH 2/3] Support redirecting out of oauth --- src/web/auth.js | 2 +- src/web/routes/oauth.js | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/web/auth.js b/src/web/auth.js index c14dcd8..45d308d 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -23,7 +23,7 @@ async function getManagedGuilds(event) { /** * @param {h3.H3Event} event - * @returns {ReturnType>} + * @returns {ReturnType>} */ function useSession(event) { return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index f4bb61f..fc31f97 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -17,7 +17,8 @@ const redirect_uri = `${reg.ooye.bridge_origin}/oauth` const schema = { first: z.object({ - action: z.string().optional() + action: z.string().optional(), + next: z.string().optional() }), code: z.object({ state: z.string(), @@ -53,10 +54,10 @@ function getOauth2Token(event) { as.router.get("/oauth", defineEventHandler(async event => { const session = await auth.useSession(event) + const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) let scope = "guilds" if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { - const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) if (parsedFirstQuery.data?.action === "add") { scope = "bot+guilds" await session.update({selfService: false}) @@ -69,6 +70,9 @@ as.router.get("/oauth", defineEventHandler(async event => { async function tryAgain() { const newState = randomUUID() await session.update({state: newState}) + if (parsedFirstQuery.data?.next) { + await session.update({next: parsedFirstQuery.data?.next}) + } return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) } @@ -86,8 +90,10 @@ as.router.get("/oauth", defineEventHandler(async event => { const client = getClient(event)(parsedToken.access_token) const guilds = await client.user.getGuilds() - var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) - await session.update({managedGuilds, userID, state: undefined}) + const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) + + const savedNext = session.data.next + await session.update({managedGuilds, userID, state: undefined, next: undefined}) // Set auto-create for the guild // @ts-ignore @@ -100,5 +106,5 @@ as.router.get("/oauth", defineEventHandler(async event => { return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302) } - return sendRedirect(event, getRelativePath(event.path, "/"), 302) + return sendRedirect(event, getRelativePath(event.path, savedNext || "/"), 302) })) From 0034193953a5862d7da456d07bd609b0718ceb46 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 14:32:40 +1200 Subject: [PATCH 3/3] Add privacy policy & opt out (requires contact) --- src/d2m/event-dispatcher.js | 2 + src/db/migrations/0040-opt-out.sql | 9 +++++ src/db/orm-defs.d.ts | 5 +++ src/types.d.ts | 1 + src/web/pug/includes/template.pug | 4 ++ src/web/pug/opt-out.pug | 31 ++++++++++++++ src/web/pug/privacy-policy.pug | 65 ++++++++++++++++++++++++++++++ src/web/routes/opt-out.js | 43 ++++++++++++++++++++ src/web/server.js | 7 +++- 9 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/db/migrations/0040-opt-out.sql create mode 100644 src/web/pug/opt-out.pug create mode 100644 src/web/pug/privacy-policy.pug create mode 100644 src/web/routes/opt-out.js diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index a49453a..ef74755 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -297,6 +297,7 @@ module.exports = { */ async MESSAGE_CREATE(client, message) { if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. + if (select("opt_out", "user_id", {user_id: message.author.id}).pluck().get()) return // This user opted out. const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. @@ -361,6 +362,7 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ async MESSAGE_REACTION_ADD(client, data) { + if (select("opt_out", "user_id", {user_id: data.user_id}).pluck().get()) return // This user opted out. if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] diff --git a/src/db/migrations/0040-opt-out.sql b/src/db/migrations/0040-opt-out.sql new file mode 100644 index 0000000..094dff9 --- /dev/null +++ b/src/db/migrations/0040-opt-out.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE "opt_out" ( + "user_id" TEXT NOT NULL, + "opted_out_at" INTEGER NOT NULL, + PRIMARY KEY("user_id") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 14f320a..ff63d32 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -115,6 +115,11 @@ export type Models = { historical_room_index: number } + opt_out: { + user_id: string + opted_out_at: number + } + role_default: { guild_id: string role_id: string diff --git a/src/types.d.ts b/src/types.d.ts index f65ce59..7b94e3c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -37,6 +37,7 @@ export type AppServiceRegistrationConfig = { time_zone?: string receive_presences: boolean plu_ral_api_key?: string + bridge_admin_contact?: string } old_bridge?: { as_token: string diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 86680eb..02e02f0 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -157,6 +157,10 @@ html(lang="en") //- Body .mx-auto.w100.wmx9.py24.px8.fs-body1#content block body + + if reg.ooye.bridge_admin_contact + aside.fs-body0.mt48.pt8.bt.bc-black-350.d-inline-block + a(href=rel("/privacy-policy")) Privacy policy //- Guild list popover script. document.querySelectorAll("[popovertarget]").forEach(e => { diff --git a/src/web/pug/opt-out.pug b/src/web/pug/opt-out.pug new file mode 100644 index 0000000..f7233f4 --- /dev/null +++ b/src/web/pug/opt-out.pug @@ -0,0 +1,31 @@ +extends includes/template.pug + +block body + if !session.data.userID + .s-empty-state.wmx4.p48 + != icons.Spots.SpotKeyXL + p Please log in to access the opt-out page. + .d-flex.jc-center.g8 + a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth?next=/opt-out")) + != icons.Icons.IconDiscord + = ` Log in with Discord` + + else + .s-page-title.mb24 + h1.s-page-title--header Opt Out + + - let value = !select("opt_out", "user_id", {user_id: session.data.userID}).pluck().get() + + #opt-in-msg(hx-swap-oob="true") + if msg + .s-notice.s-notice__info.mb16= msg + else if !value + .s-notice.s-notice__warning.mb16 You are currently opted out. + + .s-card.d-grid.px0.g16 + form.d-flex.ai-center.g16 + #opt-in-loading.p8 + input.s-toggle-switch#opt-in(name="opt_in" type="checkbox" hx-post=rel("/api/opt-out") hx-indicator="#opt-in-loading" hx-disabled-elt="this" checked=value autocomplete="off" hx-swap="none") + label.s-label.fl-grow1(for="opt-in") + | Bridge my messages to Matrix + p.s-description Turn it off to opt out. This applies to all servers. diff --git a/src/web/pug/privacy-policy.pug b/src/web/pug/privacy-policy.pug new file mode 100644 index 0000000..adff8bd --- /dev/null +++ b/src/web/pug/privacy-policy.pug @@ -0,0 +1,65 @@ +extends includes/template.pug + +block body + .s-page-title.mb24 + h1.s-page-title--header Privacy Policy + p.s-page-title--description Last updated 4 August 2026 + + .s-prose + h2 Summary + + p Out Of Your Element bridges messages between Matrix and Discord. It does this by forwarding all the messages from one platform to the other. Your messages aren't used for any other purpose. + + p Matrix and Discord will store these messages until you delete them, but Out Of Your Element doesn't keep its own copy. + + h2 Important information about the Matrix network + + p The public Matrix Network is a decentralised and openly federated communication network. This means that user messages are replicated on each participant's homeserver, and messages posted to a room are visible to all participants, including new joiners. Anybody can run their own homeserver, so your messages may be widely replicated onto many servers. Out Of Your Element does not control these servers. + + p Out Of Your Element does endeavour to remove the corresponding data from all of these servers when you delete a particular message, opt out, or request erasure. Due to the nature of decentralised systems, deletion might not be immediate or thorough. For example, some servers could be offline and catch up later, or they may have backups of old data. + + p Please keep this in mind when using chatrooms with Out Of Your Element, or any other bridge software. + + h2 What data is collected + + p Data provided by you in the chatroom: Your messages, edits, uploaded files, username, profile picture, emojis, reactions, commands used, online indicator, typing indicator, and generally any other information written in chat will be copied to the other platform. This happens in all chatrooms where you see the Out Of Your Element bot. + + p There is also a non-identifiable #[a(href=rel("/api/stats")) counter of how many people and chatrooms are active]. + + h2 What data isn't collected + + p No tracking here. Out Of Your Element #[strong does not] automatically access or share your IP address, device fingerprint, location, read receipts, or your real-world identity. + + h2 How long data is stored for + + p Out Of Your Element doesn't store your message content directly. It only stores non-identifiable metadata, such as message IDs and room IDs. + + p However, the messages are stored forever by Discord and Matrix, or until you delete them. Your messages will remain in the chat even if you leave the chat later. + + p Your online indicator and typing indicator are only stored briefly to show your real-time status. They can't be viewed as history. + + h2 Who can access this data + + ul + li Anybody participating in the chatroom (on either platform) can see your messages attributed to your username + li Discord, Inc., and their hosting providers, can access that too + li People running a Matrix homeserver in the room, and their hosting providers, can access that too (see #[a(href="https://matrix.org/legal/privacy-notice/") Matrix.org privacy policy]) + li Your data isn't shared with any other third parties (no analytics or monitoring) + + h2 How to control access to your data + + p To remove data in a specific message, you can delete that message. Deletions are bridged across all platforms. Discord and Matrix will discard the message. + + p However, we can't stop people in the chatroom from independently saving or sharing a screenshot of your messages. + + h3 Broad opt-out for Discord account holders + p If you don't want any future messages to be bridged across platforms, #[a(href=rel("/opt-out")) opt out (of your element) here]. Keep in mind this could make conversations confusing because people will see different things. + p Your old messages will stick around, but if you want those deleted as well, please contact the operator !{reg.ooye.bridge_admin_contact} to request erasure. + + h2 Questions + + p Out Of Your Element is open source, freely available software that anyone can run. The person running this version you're using is probably a different person from the software author. + + p For questions about #[em your] data and how #[em this] version handles it, please contact the operator !{reg.ooye.bridge_admin_contact} to discuss. + + p For general questions and clarifications about how the software handles data more broadly, please contact #[a(href="https://cadence.moe/contact") the author of the Out Of Your Element software.] diff --git a/src/web/routes/opt-out.js b/src/web/routes/opt-out.js new file mode 100644 index 0000000..412e25a --- /dev/null +++ b/src/web/routes/opt-out.js @@ -0,0 +1,43 @@ +// @ts-check + +const assert = require("assert/strict") +const {z} = require("zod") +const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") + +const {as, db, sync, select, discord} = require("../../passthrough") + +/** @type {import("../auth")} */ +const auth = sync.require("../auth") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") + +const schema = { + optOut: z.object({ + opt_in: z.string().optional() // switch is reversed, you turn it on to opt in + }) +} + +as.router.post("/api/opt-out", defineEventHandler(async event => { + // CSRF prevention for a more important endpoint + if (!getRequestHeader(event, "HX-Request")) { + throw createError({status: 403, message: "Forbidden", data: "JavaScript is required for the opt-out form."}) + } + + const session = await auth.useSession(event) + if (!session.data.userID) { + throw createError({status: 401, message: "Unauthorised", data: "Log in first."}) + } + + const parsedBody = await readValidatedBody(event, schema.optOut.parse) + const isOptIn = !!parsedBody.opt_in + + if (isOptIn) { + db.prepare("DELETE FROM opt_out WHERE user_id = ?").run(session.data.userID) + var msg = "You have opted in." + } else { + db.prepare("INSERT OR IGNORE INTO opt_out (user_id, opted_out_at) VALUES (?, ?)").run(session.data.userID, Date.now()) + var msg = "You have opted out." + } + + return sendRedirect(event, `../opt-out?${new URLSearchParams({msg})}`, 302) +})) diff --git a/src/web/server.js b/src/web/server.js index e28060d..c8d363d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -123,7 +123,11 @@ as.router.get("/icon.png", defineEventHandler(async event => { // Routes -pugSync.createRoute(as.router, "/ok", "ok.pug") +if (reg.reg.ooye.bridge_admin_contact) { + pugSync.createRoute(as.router, "/ok", "ok.pug") +} +pugSync.createRoute(as.router, "/opt-out", "opt-out.pug") +pugSync.createRoute(as.router, "/privacy-policy", "privacy-policy.pug") sync.require("./routes/download-matrix") sync.require("./routes/download-discord") @@ -134,5 +138,6 @@ sync.require("./routes/letter-avatar") sync.require("./routes/link") sync.require("./routes/log-in-with-matrix") sync.require("./routes/oauth") +sync.require("./routes/opt-out") sync.require("./routes/password") sync.require("./routes/stats")