This commit is contained in:
tint 2023-05-24 22:04:06 -04:00
commit 92ac630a45
12 changed files with 4862 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
Dockerfile
node_modules
*.db
data

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
*.db
data

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:lts-slim
RUN apt update && apt install -y tini
ENTRYPOINT ["tini", "--", "node", "."]
COPY --chown=node:node . /appservice
WORKDIR /appservice
USER node
RUN npm i --no-audit --no-fund
CMD ["-p", "8000", "-c", "data/xss.yaml"]

7
LICENSE Normal file
View file

@ -0,0 +1,7 @@
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
The Software shall be used for Evil, not Good.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

81
README.md Normal file
View file

@ -0,0 +1,81 @@
# xssbook to matrix bridge
references:
- [matrix-appservice-bridge howto](https://github.com/matrix-org/matrix-appservice-bridge/blob/develop/HOWTO.md)
- [the slack bridge](https://github.com/matrix-org/matrix-appservice-slack)
## usage
```sh
mkdir data # if it doesn't already exist
node . -r -u "http://url-to-appservice:8000" # the url here is how the homeserver will try to access the appservice, the port is your choice
```
this generates a registration file `data/xss-registration.yaml` that you use to register the bridge with a matrix homeserver
install the app service like [this](https://docs.mau.fi/bridges/general/registering-appservices.html)
then make a `data/xss.yaml` file:
```yaml
xssbook:
url: http://localhost:8080 # url to xssbook instance
bridge_username: bridge # name of the bridge user (used to poll for new posts)
room: "!roomid:localhost" # matrix room to post to
matrix:
homeserver_url: http://localhost:8008 # http(s) url to your homeserver
homeserver: localhost # what appears in the second half of matrix IDs
```
and run
```sh
node . -p 8000 -c data/xss.yaml
# the port needs to be the same one from the previous node command, if bridging to matrix
```
that should be it.
## if you want to use docker instead
be aware of networking shenanigans, especially if you're running this on the same machine as the homeserver.
```sh
docker build -t matrixssbook . # make sure the image is built
mkdir data # you *may* need to chmod a+w data to allow the node user inside the container to write to the folder
docker run --rm -v $PWD/data:/appservice/data matrixssbook -r -u "http://url-to-appservice:8000"
```
create the `data/xss.yaml` as before
```sh
docker run -v $PWD/data:/appservice/data matrixssbook # note: the default cmd is `-p 8000 -c data/xss.yaml`, if you used a different port you'll need to include parameters here
```
## docker-compose
if you're running [synapse through docker-compose](https://github.com/matrix-org/synapse/blob/master/contrib/docker/docker-compose.yml), you can add this appservice to your `docker-compose.yaml` file:
```sh
docker build -t matrixssbook /path/to/this/repo # make sure the image is built
# run in the same folder as your docker-compose file
mkdir data # you *may* need to chmod a+w data to allow the node user inside the container to write to the folder
docker run --rm -v $PWD/data:/appservice/data matrixssbook -r -u "http://matrixssbook:8000" # this url can be used verbatim
```
create the `data/xss.yaml` file as before, but use `homeserver_url: http://synapse:8008`
then insert this snippet into your `docker-compose.yaml`
```yaml
# in synapse's volumes section, use this (optional, you can also just copy the file into synapse's data directory manually)
- ./data/xss-registration.yaml:/data/xss-registration.yaml
# add a new service
matrixssbook:
image: matrixssbook
volumes:
- ./data:/appservice/data
```
make sure to edit synapse's config to add
```yaml
app_service_config_files:
- /data/xss-registration.yaml
```
## using with matrix
- make a new room, make sure `xss.room` in the bridge's config is set to the room's ID
- invite `@xssbook:homeserver`, it should automatically accept the invite
- start posting

72
index.js Normal file
View file

@ -0,0 +1,72 @@
const { MxBridge } = require("./mxbridge");
const { store } = require("./store");
const { XSSBook } = require("./xssbook.js");
// create the matrix bridge
const mxbridge = new MxBridge(
"data/xss-registration.yaml",
"xss-config-schema.yaml",
{},
{
localpart: "xssbook",
regex: [
{ type: "users", regex: "@xss_.*", exclusive: true },
{ type: "aliases", regex: "#xss_.*", exclusive: true },
]
},
config => ({
homeserver: config.matrix?.homeserver,
homeserver_url: config.matrix?.homeserver_url,
})
);
// persist uploaded avatar urls
const avatarStore = store("data/avatars.json");
mxbridge.avatars = avatarStore.store;
// save on change
mxbridge.on("avatarupload", () => {
avatarStore.save().catch(err => {
console.error("Error writing avatar store", err);
});
});
mxbridge.on("config", config => {
const xss = new XSSBook({
...config.xssbook,
});
// store associations for messages sent by the bridge so it can mirror edits and deletes
const matrixToXss = {};
const xssToMatrix = {};
// xssbook -> matrix
xss.on("post", async (post, user) => {
if(post.post_id in xssToMatrix) {
return;
}
xssToMatrix[post.post_id] = await mxbridge.sendMessage(
xss.room,
{
localpart: "xss_" + post.user_id,
username: user.firstname + " " + user.lastname,
avatar: xss.url + "/image/avatar?user_id=" + post.user_id,
},
post.content,
post.content,
);
});
// matrix -> xssbook
mxbridge.on("message", async (content, evt, bridge) => {
if(evt.room_id !== xss.room) {
return;
}
matrixToXss[evt.event_id] = await xss.post(evt.sender, content.formatted_body || content.body);
xssToMatrix[matrixToXss[evt.event_id]] = evt.event_id;
});
});
process.on("unhandledRejection", reason => {
console.log("Unhandled rejection:", reason);
});

306
mxbridge.js Normal file
View file

@ -0,0 +1,306 @@
const { Cli, Bridge, WeakEvent, Intent, AppServiceRegistration } = require("matrix-appservice-bridge");
const { LogService } = require("matrix-bot-sdk");
const EventEmitter = require("events");
/**
* @typedef {{
* localpart?: string,
* regex?: {type: "users"|"rooms"|"aliases", regex: string, exclusive?: boolean}[],
* }} Registration
*/
/**
* @typedef {{
* localpart: string,
* username?: string,
* avatar?: string,
* }} User
*/
/**
* @typedef {(config: Record<string, number>) => {
* homeserver: string,
* homeserver_url: string,
* }} EvalConfig
*/
const defaultEvalConfig = config => ({
enabled: true,
homeserver: config.homeserver,
homeserver_url: config.homeserver_url,
})
module.exports.MxBridge = class MxBridge extends EventEmitter {
/**
* @param {string} registrationPath
* @param {string} schema
* @param {Record<string, unknown>} defaults
* @param {Registration} registration
* @param {EvalConfig} getValues
*/
constructor(registrationPath, schema, defaults, registration, getValues = defaultEvalConfig) {
super();
let bridgeResolve;
/** @type {Promise<Bridge>} */
this.bridge = new Promise(r => bridgeResolve = r);
this.avatars = {};
new Cli({
registrationPath,
bridgeConfig: {
schema,
defaults,
},
generateRegistration(reg, cb) {
reg.setId(AppServiceRegistration.generateToken());
reg.setHomeserverToken(AppServiceRegistration.generateToken());
reg.setAppServiceToken(AppServiceRegistration.generateToken());
if(registration.localpart) {
reg.setSenderLocalpart(registration.localpart);
}
if(Array.isArray(registration.regex)) {
registration.regex.forEach(r => {
reg.addRegexPattern(r.type, r.regex, r.exclusive);
});
}
cb(reg);
},
run: async (port, config) => {
// don't emit "config" synchronously, give a consumer a chance to attach a listener for it
await new Promise(r => setImmediate(r));
this.emit("config", config);
const values = getValues(config);
const bridge = new Bridge({
homeserverUrl: values.homeserver_url,
domain: values.homeserver,
registration: registrationPath,
disableStores: true,
controller: {
onUserQuery(u) {
return {};
},
onEvent: async (req, ctx) => {
const evt = req.getData();
// console.log("got event", evt);
/**
* @event MxBridge#evt
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("evt", evt, bridge);
},
}
});
console.log("Matrix appservice listening on port", port);
await bridge.run(port);
bridgeResolve(bridge);
/**
* @event MxBridge#login
* @param {Bridge} bridge
* @param {Record<string, unknown>} config
*/
this.emit("login", bridge, config);
}
}).run();
this.on("evt", async (evt, bridge) => {
// the bridge user should autojoin on invites
if(
evt.type === "m.room.member" &&
evt.state_key === bridge.getBot().getUserId() &&
evt.content?.membership === "invite"
) {
console.log("got invite! joining")
await bridge.getIntent().join(evt.room_id);
/**
* @event MxBridge#room
* @param {string} room_id
* @param {Bridge} bridge
*/
this.emit("room", evt.room_id, bridge);
}
if(
evt.type === "m.room.message" &&
evt.content
) {
if(evt.content["m.relates_to"]?.["rel_type"] === "m.replace" && evt.content["m.new_content"]) {
/**
* @event MxBridge#edit
* @param {object} content
* @param {string} replaces
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("edit", evt.content["m.new_content"], evt.content["m.relates_to"].event_id, evt, bridge);
} else {
/**
* @event MxBridge#message
* @param {object} content
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("message", evt.content, evt, bridge);
}
}
if(
evt.type === "m.room.redaction" &&
evt.redacts
) {
/**
* @event MxBridge#redact
* @param {string} redacts
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("redact", evt.redacts, evt, bridge);
}
if(
evt.type === "m.reaction" &&
evt.content?.["m.relates_to"]?.rel_type === "m.annotation"
) {
/**
* @event MxBridge#react
* @param {string} reacts_to
* @param {string} reaction
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("react", evt.content["m.relates_to"].event_id, evt.content["m.relates_to"].key, evt, bridge);
}
})
}
/**
* Uploads a user's avatar to the content repository if it isn't already
* @param {string} url
* @param {Intent} intent
*/
async ensureUploaded(url, intent) {
if(url in this.avatars) {
return this.avatars[url];
}
console.log("fetching file", url);
const res = await fetch(url);
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
console.log("uploading to matrix: content type", blob.type);
const mxc = await intent.uploadContent(buffer, blob.type);
console.log("got mxc url", mxc);
this.avatars[url] = mxc;
/**
* @event MxBridge#avatarupload
* @param {string} mxc Matrix content repository URL
* @param {string} url Original HTTP URL
* @param {{[key: string]: string}} avatars The current avatar cache
*/
this.emit("avatarupload", mxc, url, this.avatars);
return mxc;
}
/**
* @param {string} room
* @param {User} user
* @param {string} body
* @param {string?} formatted_body
*/
async sendMessage(room, user, body, formatted_body) {
const bridge = await this.bridge;
const intent = bridge.getIntentFromLocalpart(user.localpart);
let avatar;
if(user.avatar) {
avatar = await this.ensureUploaded(user.avatar, intent);
}
await intent.setRoomUserProfile(room, {
displayname: user.username,
avatar_url: avatar,
});
// look. this error. it's annoying. and it is of no consequence.
// the user already existing is the *goal*. but there's no switch to turn it off.
// so i'm making one, ok?
const oldError = LogService.error;
LogService.error = function(logType, msg, err) {
if(logType === "Appservice" && msg === "Error registering user: User ID is in use") {
return;
}
if(logType === "MatrixHttpClient" && msg.startsWith("(REQ-") && err.errcode === "M_USER_IN_USE") {
return;
}
return oldError.apply(this, arguments);
}
const { event_id } = await intent.sendMessage(room, {
msgtype: "m.text",
body,
...(formatted_body !== undefined ? {
format: "org.matrix.custom.html",
formatted_body
} : {})
})
// put that back where it came from or so help me
LogService.error = oldError;
return event_id;
}
/**
* @param {string} room
* @param {User} user
* @param {string} eventId
* @param {string} body
* @param {string?} formatted_body
*/
async editMessage(room, user, eventId, body, formatted_body) {
const bridge = await this.bridge;
const intent = bridge.getIntentFromLocalpart(user.localpart);
const { event_id } = intent.sendMessage(room, {
body: "* " + body,
msgtype: "m.text",
"m.new_content": {
msgtype: "m.text",
body,
...(formatted_body !== undefined ? {
format: "org.matrix.custom.html",
formatted_body
} : {})
},
"m.relates_to": {
event_id: eventId,
rel_type: "m.replace",
}
})
return event_id;
}
/**
* @param {string} room
* @param {User} user
* @param {string} event_id
*/
async redact(room, user, event_id) {
const bridge = await this.bridge;
const intent = bridge.getIntentFromLocalpart(user.localpart);
return await intent.matrixClient.redactEvent(room, event_id);
}
async getMxcToHttp() {
const bridge = await this.bridge;
return url => bridge.getBot().getClient().mxcToHttp(url);
}
}

4201
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "matrixssbook",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"matrix-appservice-bridge": "^8.0.0"
}
}

18
store.js Normal file
View file

@ -0,0 +1,18 @@
const fs = require("fs");
const path = require("path");
module.exports.store = fname => {
const basename = fname;
fname = path.join(__dirname, fname);
let store = {};
try {
store = JSON.parse(fs.readFileSync(fname).toString());
} catch {}
return {
store,
save: () => {
return fs.promises.writeFile(fname, JSON.stringify(store));
}
}
}

21
xss-config-schema.yaml Normal file
View file

@ -0,0 +1,21 @@
type: object
requires: ["xssbook"]
properties:
xssbook:
type: object
requires: ["url", "bridge_username", "room"]
properties:
url:
type: string
bridge_username:
type: string
room:
type: string
matrix:
type: object
requires: ["homeserver", "homeserver_url"]
properties:
homeserver:
type: string
homeserver_url:
type: string

122
xssbook.js Normal file
View file

@ -0,0 +1,122 @@
const EventEmitter = require("events");
module.exports.XSSBook = class XSSBook extends EventEmitter {
constructor({ url, bridge_username, room }) {
super();
this.url = url;
this.bridge_username = bridge_username;
this.room = room;
this.tokens = {};
this.posts = {};
this.pollLoop();
}
async api(url, method, data, token) {
return await fetch(this.url + url, {
method,
headers: {
"Cookie": token ? "auth=" + encodeURIComponent(token) : undefined,
"Content-Type": data ? "application/json" : undefined,
},
body: data ? JSON.stringify(data) : undefined,
});
}
async getUserToken(username) {
if(username in this.tokens) {
return this.tokens[username];
}
let req = await this.api("/api/auth/register", "POST", {
firstname: username,
lastname: username === this.bridge_username ? "[bridge]" : "[matrix]",
email: username,
password: username,
gender: username === this.bridge_username ? "matrix bridge" : "matrix user",
day: 1,
month: 1,
year: 1970,
});
if(req.ok) {
const token = decodeURIComponent(/auth=([^;]+);/.exec(req.headers.get("set-cookie"))[1]);
return this.tokens[username] = token;
}
console.log("couldn't register " + username + ":", await req.text());
req = await this.api("/api/auth/login", "POST", {
email: username,
password: username,
});
if(req.ok) {
const token = decodeURIComponent(/auth=([^;]+);/.exec(req.headers.get("set-cookie"))[1]);
return this.tokens[username] = token;
}
console.log("couldn't login " + username + ":", await req.text());
throw new Error("Couldn't get token for " + username);
}
async apiUser(url, method, data, username) {
const token = await this.getUserToken(username);
return await this.api(url, method, data, token);
}
async getUsers(ids) {
const req = await this.apiUser("/api/users/load", "POST", {
ids,
}, this.bridge_username);
return await req.json();
}
async post(username, content) {
const res = await this.apiUser("/api/posts/create", "POST", {
content,
}, username);
return (await res.json()).post_id;
}
async pollLoop() {
let latest_post_id = 0, first = true;
while(true) {
await new Promise(r => setTimeout(r, 5000));
const req = await this.apiUser("/api/posts/page", "POST", {
page: 0,
}, this.bridge_username);
const posts = await req.json();
if(!Array.isArray(posts) || !posts.length) {
continue;
}
const new_posts = posts.filter(n => n.post_id > latest_post_id);
if(!new_posts.length) {
continue;
}
latest_post_id = Math.max(latest_post_id, ...new_posts.map(n => n.post_id));
if(first) { // ignore posts from the first sync
first = false;
continue;
}
const users_list = await this.getUsers(new_posts.map(n => n.user_id));
const users = {};
users_list.forEach(user => {
users[user.user_id] = user;
});
new_posts.forEach(post => {
const user = users[post.user_id];
this.emit("post", post, user);
});
}
}
}