Skip to content

Commit 5ac3f34

Browse files
authored
feat: support for MSC4108 rendezvous protocol (#9)
BREAKING CHANGE support for MSC3886 has been dropped Bumped all dependencies
1 parent b24069c commit 5ac3f34

File tree

8 files changed

+2277
-1905
lines changed

8 files changed

+2277
-1905
lines changed

.github/dependabot.yml

+7-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55

66
version: 2
77
updates:
8-
- package-ecosystem: "yarn" # See documentation for possible values
9-
directory: "/" # Location of package manifests
8+
- package-ecosystem: "npm"
9+
directory: "/"
1010
schedule:
11-
interval: "weekly"
11+
interval: "monthly"
12+
- package-ecosystem: "github-actions"
13+
directory: "/"
14+
schedule:
15+
interval: "monthly"

README.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
# Node.js HTTP Rendezvous Server
22

3-
A standalone implementation of [MSC3886: Simple rendezvous capability](https://github.com/matrix-org/matrix-spec-proposals/pull/3886).
3+
A standalone implementation of the rendezvous session API proposed by [MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code](https://github.com/matrix-org/matrix-spec-proposals/pull/4108).
44

55
Functionality constraints:
66

77
- the in progress rendezvous do not need to be persisted between server restarts
88
- the server does not need to work in a clustered/sharded deployment
9-
- no authentication is needed for use of the server

package.json

+22-20
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
"license": "Apache-2.0",
55
"author": "The Matrix.org Foundation C.I.C.",
66
"dependencies": {
7-
"body-parser": "^1.20.0",
7+
"body-parser": "^1.20.2",
8+
"content-type": "^1.0.5",
89
"cors": "^2.8.5",
910
"express": "^4.19.2",
1011
"morgan": "^1.10.0",
1112
"node-cache": "^5.1.2",
12-
"uuid": "^8.3.2"
13+
"uuid": "^9.0.1"
1314
},
1415
"scripts": {
1516
"clean": "rm -rf dist",
@@ -24,28 +25,29 @@
2425
"release": "yarn semantic-release"
2526
},
2627
"devDependencies": {
27-
"@commitlint/cli": "^16.0.2",
28-
"@commitlint/config-conventional": "^16.0.0",
29-
"@eclass/semantic-release-docker": "^3.0.1",
30-
"@semantic-release/changelog": "^6.0.1",
28+
"@commitlint/cli": "^19.2.1",
29+
"@commitlint/config-conventional": "^19.1.0",
30+
"@eclass/semantic-release-docker": "^4.0.0",
31+
"@semantic-release/changelog": "^6.0.3",
3132
"@semantic-release/git": "^10.0.1",
32-
"@types/cors": "^2.8.12",
33-
"@types/express": "^4.17.13",
34-
"@types/morgan": "^1.9.3",
35-
"@types/uuid": "^8.3.4",
36-
"@typescript-eslint/eslint-plugin": "^5.3.0",
37-
"@typescript-eslint/parser": "^5.3.0",
38-
"eslint": "^8.54.0",
33+
"@types/content-type": "^1.1.8",
34+
"@types/cors": "^2.8.17",
35+
"@types/express": "^4.17.21",
36+
"@types/morgan": "^1.9.9",
37+
"@types/uuid": "^9.0.8",
38+
"@typescript-eslint/eslint-plugin": "^7.5.0",
39+
"@typescript-eslint/parser": "^7.5.0",
40+
"eslint": "^8.57.0",
3941
"eslint-config-google": "^0.14.0",
4042
"eslint-config-prettier": "^9.1.0",
4143
"eslint-plugin-import": "^2.29.1",
4244
"eslint-plugin-matrix-org": "^1.2.1",
43-
"eslint-plugin-unicorn": "^50.0.1",
44-
"husky": "^7.0.4",
45-
"nodemon": "^3.0.2",
46-
"prettier": "^3.1.1",
47-
"semantic-release": "^19.0.2",
48-
"ts-node": "^10.8.1",
49-
"typescript": "^4.7.4"
45+
"eslint-plugin-unicorn": "^52.0.0",
46+
"husky": "^9.0.11",
47+
"nodemon": "^3.1.0",
48+
"prettier": "^3.2.5",
49+
"semantic-release": "^23.0.7",
50+
"ts-node": "^10.9.2",
51+
"typescript": "^5.4.4"
5052
}
5153
}

src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ limitations under the License.
1717
export const ttlSeconds = parseInt(process.env.RZ_TIMEOUT ?? "60");
1818
export const maxBytes = parseInt(process.env.RZ_MAX_BYTES ?? "10240");
1919
export const port = parseInt(process.env.RZ_PORT ?? "8080");
20+
export const trustProxy = process.env.RZ_TRUST_PROXY === "1" || false;

src/index.ts

+75-40
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,17 @@ import cors from "cors";
2323
import http from "http";
2424
import https from "https";
2525
import { readFileSync } from "fs";
26+
import { parse as parseContentType } from "content-type";
2627

2728
import { Rendezvous } from "./rendezvous";
28-
import { maxBytes, ttlSeconds, port } from "./config";
29+
import { maxBytes, ttlSeconds, port, trustProxy } from "./config";
2930

3031
const app = express();
31-
app.use(cors({
32-
allowedHeaders: ["Content-Type", "If-Match", "If-None-Match"],
33-
exposedHeaders: ["ETag", "Location", "X-Max-Bytes"],
34-
}));
3532
app.use(morgan("common"));
3633
app.set("env", "production");
3734
app.set("x-powered-by", false);
3835
app.set("etag", false);
36+
app.set("trust proxy", trustProxy);
3937

4038
// treat everything as raw
4139
app.use(bodyParser.raw({
@@ -51,59 +49,96 @@ const rvs = new NodeCache({
5149
useClones: false,
5250
});
5351

54-
app.post("/", (req, res) => {
55-
let id: string | undefined;
56-
while (!id || id in rvs) {
57-
id = v4();
58-
}
59-
const rv = new Rendezvous(id, ttlSeconds, maxBytes, req);
60-
rvs.set(id, rv, rv.ttlSeconds);
52+
app.options("/", cors({
53+
origin: "*",
54+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
55+
allowedHeaders: ["X-Requested-With", "Content-Type", "Authorization"], // https://spec.matrix.org/v1.10/client-server-api/#web-browser-clients
56+
exposedHeaders: ["ETag"],
57+
}));
6158

62-
rv.setHeaders(res);
59+
function notFound(res: express.Response): express.Response {
60+
return res.status(404).json({ "errcode": "M_NOT_FOUND", "error": "Rendezvous not found" });
61+
}
6362

64-
res.setHeader("Location", id);
65-
res.setHeader("X-Max-Bytes", maxBytes);
63+
function withContentType(req: express.Request, res: express.Response) {
64+
return (fn: (contentType: string) => express.Response): express.Response => {
65+
const contentType = req.get("content-type");
66+
if (!contentType) {
67+
return res.status(400).json({ "errcode": "M_MISSING_PARAM", "error": "Missing Content-Type header" });
68+
}
69+
try {
70+
parseContentType(contentType);
71+
} catch (e) {
72+
return res.status(400).json({ "errcode": "M_INVALID_PARAM", "error": "Invalid Content-Type header" });
73+
}
74+
return fn(contentType);
75+
};
76+
}
77+
app.post("/", (req, res) => {
78+
withContentType(req, res)((contentType) => {
6679

67-
return res.sendStatus(201);
68-
});
80+
let id: string | undefined;
81+
while (!id || id in rvs) {
82+
id = v4();
83+
}
84+
const rv = new Rendezvous(id, ttlSeconds, maxBytes, req.body, contentType);
85+
rvs.set(id, rv, rv.ttlSeconds);
6986

70-
app.put("/:id", (req, res) => {
71-
const { id } = req.params;
72-
const rv = rvs.get<Rendezvous>(id);
87+
rv.setHeaders(res);
7388

74-
if (!rv) {
75-
return res.sendStatus(404);
76-
}
89+
const url = `${req.protocol}://${req.hostname}/${id}`;
7790

78-
if (rv.expired()) {
79-
rvs.del(id);
80-
return res.sendStatus(404);
81-
}
91+
return res.status(201).json({ url });
92+
});
93+
});
8294

83-
const ifMatch = req.headers["if-match"];
95+
app.options("/:id", cors({
96+
origin: "*",
97+
methods: ["GET", "PUT", "DELETE", "OPTIONS"],
98+
allowedHeaders: ["If-Match", "If-None-Match"],
99+
exposedHeaders: ["ETag"],
100+
}));
84101

85-
if (ifMatch && ifMatch !== rv.etag) {
102+
app.put("/:id", (req, res) => {
103+
withContentType(req, res)((contentType) => {
104+
const { id } = req.params;
105+
const rv = rvs.get<Rendezvous>(id);
106+
107+
if (!rv) {
108+
return notFound(res);
109+
}
110+
111+
if (rv.expired()) {
112+
rvs.del(id);
113+
return notFound(res);
114+
}
115+
116+
const ifMatch = req.headers["if-match"];
117+
if (!ifMatch) {
118+
return res.status(400).json({ "errcode": "M_MISSING_PARAM", "error": "Missing If-Match header" });
119+
} else if (ifMatch !== rv.etag) {
120+
rv.setHeaders(res);
121+
return res.send(412).json({ "errcode": "M_CONCURRENT_WRITE", "error": "Rendezvous has been modified" });
122+
}
123+
124+
rv.update(req.body, contentType);
86125
rv.setHeaders(res);
87-
return res.sendStatus(412);
88-
}
89126

90-
rv.update(req);
91-
rv.setHeaders(res);
92-
93-
return res.sendStatus(202);
127+
return res.sendStatus(202);
128+
});
94129
});
95130

96131
app.get("/:id", (req, res) => {
97132
const { id } = req.params;
98133
const rv = rvs.get<Rendezvous>(id);
99134

100135
if (!rv) {
101-
return res.sendStatus(404);
136+
return notFound(res);
102137
}
103138

104139
if (rv.expired()) {
105140
rvs.del(id);
106-
return res.sendStatus(404);
141+
return notFound(res);
107142
}
108143

109144
rv.setHeaders(res);
@@ -118,7 +153,7 @@ app.get("/:id", (req, res) => {
118153
app.delete("/:id", (req, res) => {
119154
const { id } = req.params;
120155
if (!rvs.has(id)) {
121-
return res.sendStatus(404);
156+
return notFound(res);
122157
}
123158
rvs.del(id);
124159
return res.sendStatus(204);
@@ -130,11 +165,11 @@ if (process.env.DEV_SSL === "yes") {
130165
cert: readFileSync("./devssl/cert.pem"),
131166
}, app);
132167
httpsServer.listen(port, () => {
133-
console.log(`Starting rendezvous server at https://0.0.0.0:${port} with self-signed certificate`);
168+
console.log(`Starting rendezvous server at https://0.0.0.0:${port} with self-signed certificate${trustProxy ? " behind trusted proxy" : ""}`);
134169
});
135170
} else {
136171
const httpServer = http.createServer(app);
137172
httpServer.listen(port, () => {
138-
console.log(`Starting rendezvous server at http://0.0.0.0:${port}`);
173+
console.log(`Starting rendezvous server at http://0.0.0.0:${port}${trustProxy ? " behind trusted proxy" : ""}`);
139174
});
140175
}

src/rendezvous.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,18 @@ export class Rendezvous {
2727
private lastModified!: Date;
2828
public etag!: string;
2929

30-
public constructor(id: string, ttlSeconds: number, maxBytes: number, initialRequest: express.Request) {
30+
public constructor(id: string, ttlSeconds: number, maxBytes: number, data: Buffer, contentType: string) {
3131
const now = new Date();
3232
this.id = id;
3333
this.ttlSeconds = ttlSeconds;
3434
this.expiresAt = new Date(now.getTime() + ttlSeconds * 1000);
3535
this.maxBytes = maxBytes;
36-
this.update(initialRequest);
36+
this.update(data, contentType);
3737
}
3838

39-
public update(req: express.Request): void {
40-
this.data = req.body;
41-
this.contentType = req.get("Content-Type") ?? "application/octet-stream";
39+
public update(data: Buffer, contentType: string): void {
40+
this.data = data;
41+
this.contentType = contentType;
4242
this.lastModified = new Date();
4343
const hash = createHash("sha256");
4444
// include ID so that it is rendezvous-specific

tsconfig.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
"strictNullChecks": true,
1414
"noImplicitReturns": true,
1515
"preserveConstEnums": true,
16-
"suppressImplicitAnyIndexErrors": true,
1716
"forceConsistentCasingInFileNames": true,
1817
"outDir": "./dist",
1918
"rootDir": "./src"
20-
}
19+
},
20+
"include": ["src/**/*"]
2121
}

0 commit comments

Comments
 (0)