replace rest api with rpc system

This commit is contained in:
hippoz 2023-02-21 23:52:23 +02:00
parent 8ba70833f3
commit bca4280afb
Signed by: hippoz
GPG key ID: 56C4E02A85F2FBED
26 changed files with 636 additions and 595 deletions

View file

@ -1,7 +1,6 @@
<script>
import { overlayStore, OverlayType } from "../../stores";
import request from "../../request";
import { apiRoute } from "../../storage";
import { methods, remoteCall } from "../../request";
import { maybeModalScale } from "../../animations";
let username = "";
@ -12,10 +11,7 @@
const create = async () => {
buttonsEnabled = false;
const { ok } = await request("POST", apiRoute("users/register"), false, {
username,
password
});
const { ok } = await remoteCall(methods.createUser, username, password);
if (ok) {
overlayStore.toast("Account created");
loginInstead();

View file

@ -1,7 +1,6 @@
<script>
import { overlayStore } from "../../stores";
import request from "../../request";
import { apiRoute } from "../../storage";
import { methods, remoteCall } from "../../request";
import { maybeModalFade, maybeModalScale } from "../../animations";
let channelName = "";
@ -10,9 +9,7 @@
const create = async () => {
createButtonEnabled = false;
const { ok } = await request("POST", apiRoute("channels"), true, {
name: channelName
});
const { ok } = await remoteCall(methods.createChannel, channelName);
if (!ok) {
overlayStore.toast("Couldn't create channel");
}

View file

@ -1,8 +1,7 @@
<script>
import { maybeModalFade, maybeModalScale } from "../../animations";
import { overlayStore } from "../../stores";
import request from "../../request";
import { apiRoute } from "../../storage";
import { methods, remoteCall } from "../../request";
export let channel;
@ -12,9 +11,7 @@
const save = async () => {
buttonsEnabled = false;
const { ok } = await request("PUT", apiRoute(`channels/${channel.id}`), true, {
name: channelName
});
const { ok } = await remoteCall(methods.updateChannelName, channel.id, channelName);
if (!ok) {
overlayStore.toast("Couldn't edit channel");
}
@ -22,7 +19,7 @@
};
const deleteChannel = async () => {
buttonsEnabled = false;
const { ok } = await request("DELETE", apiRoute(`channels/${channel.id}`), true);
const { ok } = await remoteCall(methods.deleteChannel, channel.id);
if (!ok) {
overlayStore.toast("Couldn't delete channel");
}

View file

@ -1,7 +1,6 @@
<script>
import { overlayStore } from "../../stores";
import request from "../../request";
import { apiRoute } from "../../storage";
import { methods, remoteCall } from "../../request";
import { maybeModalFade, maybeModalScale } from "../../animations";
export let message;
@ -12,9 +11,7 @@
const save = async () => {
buttonsEnabled = false;
const { ok } = await request("PUT", apiRoute(`messages/${message.id}`), true, {
content: messageContent
});
const { ok } = await remoteCall(methods.updateMessageContent, message.id, messageContent);
if (!ok) {
overlayStore.toast("Couldn't edit message");
}
@ -22,7 +19,7 @@
};
const deleteMessage = async () => {
buttonsEnabled = false;
const { ok } = await request("DELETE", apiRoute(`messages/${message.id}`), true);
const { ok } = await remoteCall(methods.deleteMessage, message.id);
if (!ok) {
overlayStore.toast("Couldn't delete message");
}

View file

@ -1,9 +1,9 @@
<script>
import { overlayStore, OverlayType } from "../../stores";
import request from "../../request";
import { apiRoute } from "../../storage";
import { remoteCall } from "../../request";
import { authWithToken } from "../../auth";
import { maybeModalScale } from "../../animations";
import { methods } from "../../request";
let username = "";
let password = "";
@ -13,10 +13,7 @@
const login = async () => {
buttonsEnabled = false;
const { ok, json } = await request("POST", apiRoute("users/login"), false, {
username,
password
});
const { ok, json } = await remoteCall(methods.loginUser, username, password);
if (ok && json && json.token) {
authWithToken(json.token, true);
} else {

View file

@ -18,6 +18,8 @@ export const GatewayPayloadType = {
Authenticate: 1,
Ready: 2,
Ping: 3,
RPCRequest: 4, // client
RPCResponse: 5,
ChannelCreate: 110,
ChannelUpdate: 111,
@ -58,6 +60,8 @@ export default {
reconnectTimeout: null,
handlers: new Map(),
disableReconnect: false,
serial: 0,
waitingSerials: new Map(),
init(token) {
timeline.addCheckpoint("Gateway connection start");
if (!token) {
@ -106,12 +110,19 @@ export default {
this.user = payload.d.user;
this.channels = payload.d.channels;
this.authenticated = true;
this.reconnectDelay = 400;
log("ready");
break;
}
case GatewayPayloadType.RPCResponse: {
if (this.waitingSerials.get(payload.s)) {
(this.waitingSerials.get(payload.s))(payload.d);
this.waitingSerials.delete(payload.s);
}
break;
}
}
this.dispatch(payload.t, payload.d);
@ -183,6 +194,17 @@ export default {
this.handlers.delete(event);
}
},
sendRPCRequest(calls) {
return new Promise((resolve, _reject) => {
this.waitingSerials.set(this.serial, resolve);
this.send({
t: GatewayPayloadType.RPCRequest,
d: calls,
s: this.serial
});
this.serial++;
});
},
close() {
this.disableReconnect = true;
if (this.ws)

View file

@ -1,7 +1,27 @@
import { getItem } from "./storage";
import gateway from "./gateway";
import { apiRoute, getItem } from "./storage";
// TODO: circular dependency
import { overlayStore, OverlayType } from "./stores";
export const methods = {
// methodName: [ methodId, requiresAuthentication ]
createUser: [ 0, false ],
loginUser: [ 1, false ],
getUserSelf: [ 2, true ],
promoteUserSelf: [ 3, true ],
createChannel: [ 4, true ],
updateChannelName: [ 5, true ],
deleteChannel: [ 6, true ],
getChannel: [ 7, true ],
getChannels: [ 8, true ],
createChannelMessage: [ 9, true ],
getChannelMessages: [ 10, true ],
putChannelTyping: [ 11, true ],
deleteMessage: [ 12, true ],
updateMessageContent: [ 13, true ],
getMessage: [ 14, true ]
};
export function compatibleFetch(endpoint, options) {
if (window.fetch && typeof window.fetch === "function") {
return fetch(endpoint, options);
@ -32,8 +52,8 @@ export function compatibleFetch(endpoint, options) {
}
}
export default function doRequest(method, endpoint, auth=true, body=null, _keyEntryDepth=false) {
return new Promise(async (resolve, reject) => {
export default function doRequest(method, endpoint, auth=true, body=null) {
return new Promise(async (resolve, _reject) => {
const options = {
method,
};
@ -60,43 +80,32 @@ export default function doRequest(method, endpoint, auth=true, body=null, _keyEn
const res = await compatibleFetch(endpoint, options);
const json = res.status === 204 ? {} : await res.json();
if (res.status === 403 && json.code && json.code === 6006 && !_keyEntryDepth) {
// This endpoint is password-protected
overlayStore.push(OverlayType.Prompt, {
heading: "Enter Key For Resource",
valueName: "Key",
async onSubmit(value) {
const response = await doRequest(method, endpoint, auth, {
...(body || {}),
requestKey: value
}, true);
resolve(response);
},
onClose() {
resolve({
success: true,
json,
ok: res.ok,
status: res.status
});
}
});
return;
}
return resolve({
success: true,
json,
ok: res.ok,
status: res.status
});
} catch (e) {
return resolve({
success: false,
json: null,
ok: false,
status: null
});
}
});
}
export async function remoteCall([methodId, requiresAuthentication], ...args) {
const calls = [[methodId, ...args]];
if (requiresAuthentication && gateway.authenticated) {
const replies = await gateway.sendRPCRequest(calls);
const ok = Array.isArray(replies) && replies[0] && !replies[0].code;
return {
json: ok ? replies[0] : null,
ok
};
}
const response = await doRequest("POST", apiRoute("rpc"), requiresAuthentication, calls);
response.ok = response.ok && Array.isArray(response.json) && response.json[0] && !response.json[0].code;
response.json = response.ok ? response.json[0] : null;
return response;
}

View file

@ -1,7 +1,7 @@
import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway";
import logger from "./logging";
import request from "./request";
import { apiRoute, getItem, setItem } from "./storage";
import { methods, remoteCall } from "./request";
import { getItem, setItem } from "./storage";
const storeLog = logger("Store");
@ -339,9 +339,8 @@ class MessageStore extends Store {
return;
const oldestMessage = this.value[0];
const endpoint = oldestMessage ? `channels/${this.channelId}/messages/?before=${oldestMessage.id}` : `channels/${this.channelId}/messages`;
const res = await request("GET", apiRoute(endpoint), true, null);
if (res.success && res.ok && res.json) {
const res = await remoteCall(methods.getChannelMessages, this.channelId, null, oldestMessage ? oldestMessage.id : null);
if (res.ok) {
if (res.json.length < 1)
return;
if (beforeCommitToStore)
@ -540,7 +539,7 @@ class TypingStore extends Store {
this.startedTyping(userInfoStore.value, selectedChannel.value.id, 6500);
if (this.ownNeedsUpdate) {
this.ownNeedsUpdate = false;
await request("POST", apiRoute(`channels/${selectedChannel.value.id}/typing`), true, {});
await remoteCall(methods.putChannelTyping, selectedChannel.value.id);
}
}
}
@ -696,12 +695,9 @@ export const sendMessageAction = createAction("sendMessageAction", async ({chann
const messagesStoreForChannel = messagesStoreProvider.getStore(channelId);
messagesStoreForChannel.addMessage(optimisticMessage);
const res = await request("POST", apiRoute(`channels/${channelId}/messages`), true, {
content: optimisticMessage.content,
optimistic_id: optimisticMessageId
});
const res = await remoteCall(methods.createChannelMessage, channelId, content, optimisticMessageId, null);
if (res.success && res.ok) {
if (res.ok) {
messagesStoreForChannel.setMessage(optimisticMessageId, res.json);
} else {
messagesStoreForChannel.deleteMessage({

View file

@ -1,4 +1,4 @@
import { NextFunction, Request, Response } from "express";
import e, { NextFunction, Request, Response } from "express";
import { sign, verify } from "jsonwebtoken";
import { query } from "./database";
import { errors } from "./errors";
@ -116,18 +116,23 @@ export async function loginAttempt(username: string, password: string): Promise<
return await signToken(existingUser.rows[0].id);
}
export function authenticateRoute() {
export function authenticateRoute(errorOnBadAuth = true) {
return async (req: Request, res: Response, next: NextFunction) => {
const pass = (user: User | null = null) => {
if (!user) {
res.status(403).send({
...errors.BAD_AUTH
});
return;
if (errorOnBadAuth) {
res.status(403).send(errors.BAD_AUTH);
return;
} else {
req.authenticated = false;
next();
return;
}
}
req.user = user;
req.publicUser = getPublicUserObject(user);
req.authenticated = true;
next();
};

View file

@ -1,4 +1,5 @@
export const errors = {
INVALID_RPC_CALL: { code: 6000, message: "Invalid RPC call. Please see 'detail' property." },
INVALID_DATA: { code: 6001, message: "Invalid data" },
BAD_LOGIN_CREDENTIALS: { code: 6002, message: "Bad login credentials provided" },
BAD_AUTH: { code: 6003, message: "Bad authentication" },
@ -7,7 +8,7 @@ export const errors = {
BAD_REQUEST_KEY: { code: 6006, message: "Bad request key" },
GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" },
FEATURE_DISABLED: { code: 7002, message: "This feature is disabled" },
INTERNAL_ERROR: { code: 7003, message: "Internal server error" }
INTERNAL_ERROR: { code: 7003, message: "Internal server error" },
};
export const gatewayErrors = {
@ -21,4 +22,5 @@ export const gatewayErrors = {
TOO_MANY_SESSIONS: { code: 4008, message: "Too many sessions" },
NOT_AUTHENTICATED: { code: 4009, message: "Not authenticated" },
GOT_NO_DATABASE_DATA: { code: 4010, message: "Unexpectedly got no data from database" },
INTERNAL_ERROR: { code: 4011, message: "Internal server error" },
};

View file

@ -3,6 +3,8 @@ export enum GatewayPayloadType {
Authenticate, // client
Ready,
Ping, // client
RPCRequest, // client
RPCResponse,
ChannelCreate = 110,
ChannelUpdate,

View file

@ -6,11 +6,12 @@ import { query } from "../database";
import { gatewayErrors } from "../errors";
import { GatewayPayload } from "../types/gatewaypayload";
import { GatewayPayloadType, GatewayPresenceStatus } from "./gatewaypayloadtype";
import { GatewayPresenceEntry } from "./gatewaypresence";
import { GatewayPresenceEntry } from "../types/gatewaypresence";
import { processMethodBatch } from "../rpc/rpc";
const GATEWAY_BATCH_INTERVAL = 50000;
const GATEWAY_PING_INTERVAL = 40000;
const MAX_CLIENT_MESSAGES_PER_BATCH = 6; // TODO: how well does this work for weak connections?
const MAX_CLIENT_MESSAGES_PER_BATCH = 30; // TODO: how well does this work for weak connections?
const MAX_GATEWAY_SESSIONS_PER_USER = 5;
// mapping between a dispatch id and a websocket client
@ -167,6 +168,9 @@ function ensureFormattedGatewayPayload(payload: any): GatewayPayload | null {
foundT = true;
} else if (k === "d") {
foundD = true;
} else if (k === "s" && typeof v === "number") {
// found serial
continue;
} else {
return null;
}
@ -303,7 +307,7 @@ export default function(server: Server) {
}
const stringData = rawData.toString();
if (stringData.length > 2048) {
if (stringData.length > 4500) {
return closeWithError(ws, gatewayErrors.PAYLOAD_TOO_LARGE);
}
@ -405,6 +409,23 @@ export default function(server: Server) {
ws.state.alive = true;
break;
}
case GatewayPayloadType.RPCRequest: {
if (!ws.state.ready || !ws.state.user) {
return closeWithError(ws, gatewayErrors.NOT_AUTHENTICATED);
}
processMethodBatch(ws.state.user, payload.d).then((results) => {
sendPayload(ws, {
t: GatewayPayloadType.RPCResponse,
d: results,
s: payload.s
});
}).catch(e => {
console.error("gateway: unexpected error while handling RPCRequest", e);
return closeWithError(ws, gatewayErrors.INTERNAL_ERROR);
});
break;
}
default: {
return closeWithBadPayload(ws, "t: unknown type");
}

View file

@ -1,253 +0,0 @@
import express from "express";
import { body, param, validationResult } from "express-validator";
import { authenticateRoute } from "../../../auth";
import { query } from "../../../database";
import { getMessageById, getMessagesByChannelFirstPage, getMessagesByChannelPage } from "../../../database/templates";
import { errors } from "../../../errors";
import { dispatch, dispatchChannelSubscribe } from "../../../gateway";
import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype";
import sendMessage from "../../../impl";
import serverConfig from "../../../serverconfig";
const router = express.Router();
router.post(
"/",
authenticateRoute(),
body("name").isLength({ min: 1, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
if (serverConfig.superuserRequirement.createChannel && !req.user.is_superuser) {
return res.status(403).json({ ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS });
}
const { name } = req.body;
const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, req.user.id]);
if (!result || result.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
dispatch("*", {
t: GatewayPayloadType.ChannelCreate,
d: result.rows[0]
});
// When a new channel is created, we will currently subscribe every client
// on the gateway (this will be changed when the concept of "communities" is added)
dispatchChannelSubscribe("*", `channel:${result.rows[0].id}`);
res.status(201).send(result.rows[0]);
}
);
router.put(
"/:id",
authenticateRoute(),
body("name").isLength({ min: 1, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const { name } = req.body;
const id = parseInt(req.params.id); // TODO: ??
const permissionCheckResult = await query("SELECT owner_id FROM channels WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return res.status(404).json({
...errors.NOT_FOUND
});
}
if (permissionCheckResult.rows[0].owner_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
});
}
const result = await query("UPDATE channels SET name = $1 WHERE id = $2", [name, id]);
if (!result || result.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
const updatePayload = {
id,
name,
owner_id: permissionCheckResult.rows[0].owner_id
};
dispatch(`channel:${id}`, {
t: GatewayPayloadType.ChannelUpdate,
d: updatePayload
});
return res.status(200).send(updatePayload);
}
);
router.delete(
"/:id",
authenticateRoute(),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const id = parseInt(req.params.id); // TODO: ??
const permissionCheckResult = await query("SELECT owner_id FROM channels WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return res.status(404).json({
...errors.NOT_FOUND
});
}
if (permissionCheckResult.rows[0].owner_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
});
}
const result = await query("DELETE FROM channels WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
dispatch(`channel:${id}`, {
t: GatewayPayloadType.ChannelDelete,
d: {
id
}
});
return res.status(204).send("");
}
);
router.get(
"/:id",
authenticateRoute(),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const { id } = req.params;
const result = await query("SELECT id, name, owner_id FROM channels WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return res.status(404).json({
...errors.NOT_FOUND
});
}
return res.status(200).send(result.rows[0]);
}
);
router.get(
"/",
authenticateRoute(),
async (req, res) => {
const result = await query("SELECT id, name, owner_id FROM channels");
return res.status(200).send(result ? result.rows : []);
}
);
router.post(
"/:id/messages",
authenticateRoute(),
param("id").isNumeric(),
body("content").isLength({ min: 1, max: 4000 }),
body("optimistic_id").optional().isNumeric(),
body("nick_username").optional().isString().isLength({ min: 1, max: 64 }),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
return res.status(201).send(await sendMessage(req.user, parseInt(req.params.id), parseInt(req.body.optimistic_id), req.body.content, req.body.nick_username));
}
);
router.get(
"/:id/messages",
authenticateRoute(),
param("id").isNumeric(),
param("count").optional().isInt({ min: 10, max: 50 }),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const { before, count } = req.query;
let limit = typeof count === "string" ? parseInt(count || "25") : 25;
if (Number.isNaN(limit)) {
return res.status(400).json({ ...errors.INVALID_DATA });
}
const channelId = parseInt(req.params.id);
let finalRows = [];
if (before) {
const result = await query(getMessagesByChannelPage(limit), [before, channelId]);
finalRows = result ? result.rows : [];
} else {
const result = await query(getMessagesByChannelFirstPage(limit), [channelId]);
finalRows = result ? result.rows : [];
}
return res.status(200).send(finalRows);
}
);
router.post(
"/:id/typing",
authenticateRoute(),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const channelId = parseInt(req.params.id);
dispatch(`channel:${channelId}`, {
t: GatewayPayloadType.TypingStart,
d: {
user: {
id: req.publicUser.id,
username: req.publicUser.username
},
channel: {
id: channelId
},
time: 7500
}
});
return res.status(201).send("");
}
);
export default router;

View file

@ -1,125 +0,0 @@
import express from "express";
import { body, param, validationResult } from "express-validator";
import { authenticateRoute } from "../../../auth";
import { query } from "../../../database";
import { getMessageById } from "../../../database/templates";
import { errors } from "../../../errors";
import { dispatch } from "../../../gateway";
import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype";
const router = express.Router();
router.delete(
"/:id",
authenticateRoute(),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const id = parseInt(req.params.id); // TODO: ??
const permissionCheckResult = await query("SELECT author_id, channel_id FROM messages WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return res.status(404).json({
...errors.NOT_FOUND
});
}
if (permissionCheckResult.rows[0].author_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
});
}
const result = await query("DELETE FROM messages WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, {
t: GatewayPayloadType.MessageDelete,
d: {
id,
channel_id: permissionCheckResult.rows[0].channel_id
}
});
return res.status(204).send("");
}
);
router.put(
"/:id",
authenticateRoute(),
body("content").isLength({ min: 1, max: 4000 }),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const { content } = req.body;
const id = parseInt(req.params.id); // TODO: ??
const permissionCheckResult = await query(getMessageById, [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return res.status(404).json({
...errors.NOT_FOUND
});
}
if (permissionCheckResult.rows[0].author_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
});
}
const result = await query("UPDATE messages SET content = $1 WHERE id = $2", [content, id]);
if (!result || result.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
const returnObject = {
...permissionCheckResult.rows[0],
content
};
dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, {
t: GatewayPayloadType.MessageUpdate,
d: returnObject
});
return res.status(200).send(returnObject);
}
);
router.get(
"/:id",
authenticateRoute(),
param("id").isNumeric(),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const { id } = req.params;
const result = await query(getMessageById, [id]);
if (!result || result.rowCount < 1) {
return res.status(404).json({
...errors.NOT_FOUND
});
}
return res.status(200).send(result.rows[0]);
}
);
export default router;

16
src/routes/api/v1/rpc.ts Normal file
View file

@ -0,0 +1,16 @@
import { errors } from "../../../errors";
import express from "express";
import { authenticateRoute } from "../../../auth";
import { processMethodBatch } from "../../../rpc/rpc";
const router = express.Router();
router.post(
"/",
authenticateRoute(false),
async (req, res) => {
res.json(await processMethodBatch(req.authenticated ? req.user : null, req.body));
}
);
export default router;

View file

@ -1,126 +0,0 @@
import { errors } from "../../../errors";
import { query } from "../../../database";
import express from "express";
import { body, validationResult } from "express-validator";
import { compare, hash, hashSync } from "bcrypt";
import { authenticateRoute, loginAttempt, signToken } from "../../../auth";
const router = express.Router();
const superuserKey = process.env.SUPERUSER_KEY ? hashSync(process.env.SUPERUSER_KEY, 10) : null;
const authRequestKey = process.env.AUTH_REQUEST_KEY ? hashSync(process.env.AUTH_REQUEST_KEY, 10) : null;
router.post(
"/register",
body("username").isLength({ min: 3, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
body("password").isLength({ min: 8, max: 1000 }),
async (req, res) => {
if (process.env.DISABLE_ACCOUNT_CREATION === "true") {
return res.status(403).json({ ...errors.FEATURE_DISABLED });
}
if (authRequestKey) {
if (!req.body.requestKey) {
return res.status(403).json({ ...errors.BAD_REQUEST_KEY });
}
const result = await compare(req.body.requestKey, authRequestKey);
if (!result) {
return res.status(403).json({ ...errors.BAD_REQUEST_KEY });
}
}
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
}
const { username, password } = req.body;
const existingUser = await query("SELECT * FROM users WHERE username = $1", [username]);
if (existingUser && existingUser.rowCount > 0) {
return res.status(400).json({
...errors.INVALID_DATA,
errors: [ { location: "body", msg: "Username already exists", param: "username" } ]
});
}
const hashedPassword = await hash(password, 10);
const insertedUser = await query("INSERT INTO users(username, password, is_superuser) VALUES ($1, $2, $3) RETURNING id, username, is_superuser", [username, hashedPassword, false]);
if (!insertedUser || insertedUser.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
return res.status(201).send(insertedUser.rows[0]);
}
);
router.post(
"/login",
body("username").isLength({ min: 3, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
body("password").isLength({ min: 8, max: 1000 }),
async (req, res) => {
if (authRequestKey) {
if (!req.body.requestKey) {
return res.status(403).json({ ...errors.BAD_REQUEST_KEY });
}
const result = await compare(req.body.requestKey, authRequestKey);
if (!result) {
return res.status(403).json({ ...errors.BAD_REQUEST_KEY });
}
}
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(400).json({ ...errors.BAD_LOGIN_CREDENTIALS });
}
const token = await loginAttempt(req.body.username, req.body.password);
if (!token) {
return res.status(400).json({ ...errors.BAD_LOGIN_CREDENTIALS });
}
return res.status(200).send({ token });
}
);
router.get(
"/self",
authenticateRoute(),
(req, res) => {
return res.status(200).send(req.publicUser);
}
);
router.post(
"/self/promote",
authenticateRoute(),
body("requestKey").isLength({ min: 1, max: 3000 }),
async (req, res) => {
if (!superuserKey) {
return res.status(403).json({ ...errors.FEATURE_DISABLED });
}
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(403).json({ ...errors.BAD_REQUEST_KEY });
}
const matches = await compare(req.body.requestKey, superuserKey);
if (matches) {
const updateUserResult = await query("UPDATE users SET is_superuser = true WHERE id = $1", [req.user.id]);
if (!updateUserResult || updateUserResult.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
return res.status(200).json({});
}
return res.status(403).json({ ...errors.BAD_REQUEST_KEY });
}
);
export default router;

166
src/rpc/apis/channels.ts Normal file
View file

@ -0,0 +1,166 @@
import express from "express";
import { channelNameRegex, method, number, string, unsignedNumber, withOptional, withRegexp } from "../rpc";
import { query } from "../../database";
import { getMessagesByChannelFirstPage, getMessagesByChannelPage } from "../../database/templates";
import { errors } from "../../errors";
import { dispatch, dispatchChannelSubscribe } from "../../gateway";
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
import sendMessage from "../../impl";
import serverConfig from "../../serverconfig";
method(
"createChannel",
[withRegexp(channelNameRegex, string(1, 32))],
async (user: User, name: string) => {
if (serverConfig.superuserRequirement.createChannel && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, user.id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
dispatch("*", {
t: GatewayPayloadType.ChannelCreate,
d: result.rows[0]
});
// When a new channel is created, we will currently subscribe every client
// on the gateway (this will be changed when the concept of "communities" is added)
dispatchChannelSubscribe("*", `channel:${result.rows[0].id}`);
return result.rows[0];
}
);
method(
"updateChannelName",
[unsignedNumber(), withRegexp(channelNameRegex, string(1, 32))],
async (user: User, id: number, name: string) => {
const permissionCheckResult = await query("SELECT owner_id FROM channels WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return errors.NOT_FOUND;
}
if (permissionCheckResult.rows[0].owner_id !== user.id && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("UPDATE channels SET name = $1 WHERE id = $2", [name, id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
const updatePayload = {
id,
name,
owner_id: permissionCheckResult.rows[0].owner_id
};
dispatch(`channel:${id}`, {
t: GatewayPayloadType.ChannelUpdate,
d: updatePayload
});
return updatePayload;
}
);
method(
"deleteChannel",
[unsignedNumber()],
async (user: User, id: number) => {
const permissionCheckResult = await query("SELECT owner_id FROM channels WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return errors.NOT_FOUND;
}
if (permissionCheckResult.rows[0].owner_id !== user.id && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("DELETE FROM channels WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
dispatch(`channel:${id}`, {
t: GatewayPayloadType.ChannelDelete,
d: {id}
});
return {id};
}
);
method(
"getChannel",
[unsignedNumber()],
async (_user: User, id: number) => {
const result = await query("SELECT id, name, owner_id FROM channels WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return errors.NOT_FOUND;
}
return result.rows[0];
}
);
method(
"getChannels",
[],
async (_user: User) => {
const result = await query("SELECT id, name, owner_id FROM channels");
return (result && result.rows) ? result.rows : [];
}
);
method(
"createChannelMessage",
[unsignedNumber(), string(1, 4000), withOptional(unsignedNumber()), withOptional(string(1, 64))],
async (user: User, id: number, content: string, optimistic_id: number | null, nick_username: string | null) => {
return await sendMessage(user, id, optimistic_id, content, nick_username);
}
);
method(
"getChannelMessages",
[unsignedNumber(), withOptional(number(5, 100)), withOptional(unsignedNumber())],
async (_user: User, channelId: number, count: number | null, before: number | null) => {
let limit = count ?? 25;
let finalRows = [];
if (before !== null) {
const result = await query(getMessagesByChannelPage(limit), [before, channelId]);
finalRows = result && result.rows ? result.rows : [];
} else {
const result = await query(getMessagesByChannelFirstPage(limit), [channelId]);
finalRows = result && result.rows ? result.rows : [];
}
return finalRows;
}
);
method(
"putChannelTyping",
[unsignedNumber()],
async (user: User, channelId: number) => {
dispatch(`channel:${channelId}`, {
t: GatewayPayloadType.TypingStart,
d: {
user: {
id: user.id,
username: user.username
},
channel: {
id: channelId
},
time: 7500
}
});
return { time: 7500 };
}
);

79
src/rpc/apis/messages.ts Normal file
View file

@ -0,0 +1,79 @@
import { method, string, unsignedNumber } from "./../rpc";
import { query } from "../../database";
import { getMessageById } from "../../database/templates";
import { errors } from "../../errors";
import { dispatch } from "../../gateway";
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
method(
"deleteMessage",
[unsignedNumber()],
async (user: User, id: number) => {
const permissionCheckResult = await query("SELECT author_id, channel_id FROM messages WHERE id = $1", [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return errors.NOT_FOUND;
}
if (permissionCheckResult.rows[0].author_id !== user.id && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("DELETE FROM messages WHERE id = $1", [id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, {
t: GatewayPayloadType.MessageDelete,
d: {
id,
channel_id: permissionCheckResult.rows[0].channel_id
}
});
return {};
}
)
method(
"updateMessageContent",
[unsignedNumber(), string(1, 4000)],
async (user: User, id: number, content: string) => {
const permissionCheckResult = await query(getMessageById, [id]);
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
return errors.NOT_FOUND;
}
if (permissionCheckResult.rows[0].author_id !== user.id && !user.is_superuser) {
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
}
const result = await query("UPDATE messages SET content = $1 WHERE id = $2", [content, id]);
if (!result || result.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
const returnObject = {
...permissionCheckResult.rows[0],
content
};
dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, {
t: GatewayPayloadType.MessageUpdate,
d: returnObject
});
return returnObject;
}
)
method(
"getMessage",
[unsignedNumber()],
async (user: User, id: number) => {
const result = await query(getMessageById, [id]);
if (!result || result.rowCount < 1) {
return errors.NOT_FOUND;
}
return result.rows[0];
}
)

76
src/rpc/apis/users.ts Normal file
View file

@ -0,0 +1,76 @@
import { errors } from "../../errors";
import { query } from "../../database";
import { compare, hash, hashSync } from "bcrypt";
import { getPublicUserObject, loginAttempt } from "../../auth";
import { method, methodButWarningDoesNotAuthenticate, string, usernameRegex, withRegexp } from "./../rpc";
const superuserKey = process.env.SUPERUSER_KEY ? hashSync(process.env.SUPERUSER_KEY, 10) : null;
methodButWarningDoesNotAuthenticate(
"createUser",
[withRegexp(usernameRegex, string(3, 32)), string(8, 1000)],
async (username: string, password: string) => {
if (process.env.DISABLE_ACCOUNT_CREATION === "true") {
return errors.FEATURE_DISABLED;
}
const existingUser = await query("SELECT * FROM users WHERE username = $1", [username]);
if (existingUser && existingUser.rowCount > 0) {
return {
...errors.INVALID_DATA,
errors: [ { index: 0, msg: "Username already exists" } ]
};
}
const hashedPassword = await hash(password, 10);
const insertedUser = await query("INSERT INTO users(username, password, is_superuser) VALUES ($1, $2, $3) RETURNING id, username, is_superuser", [username, hashedPassword, false]);
if (!insertedUser || insertedUser.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
return insertedUser.rows[0];
}
);
methodButWarningDoesNotAuthenticate(
"loginUser",
[withRegexp(usernameRegex, string(3, 32)), string(8, 1000)],
async (username: string, password: string) => {
const token = await loginAttempt(username, password);
if (!token) {
return errors.BAD_LOGIN_CREDENTIALS;
}
return { token };
}
);
method(
"getUserSelf",
[],
(user: User) => {
return getPublicUserObject(user);
}
);
method(
"promoteUserSelf",
[string(1, 1000)],
async (user: User, key: string) => {
if (!superuserKey) {
return errors.FEATURE_DISABLED;
}
const matches = await compare(key, superuserKey);
if (matches) {
const updateUserResult = await query("UPDATE users SET is_superuser = true WHERE id = $1", [user.id]);
if (!updateUserResult || updateUserResult.rowCount < 1) {
return errors.GOT_NO_DATABASE_DATA;
}
return {};
}
return errors.BAD_REQUEST_KEY;
}
)

13
src/rpc/index.ts Normal file
View file

@ -0,0 +1,13 @@
import "./rpc";
import "./apis/users";
import "./apis/channels";
import "./apis/messages";
import { methodNameToId, methods } from "./rpc";
console.log("--- begin rpc method map ---")
const methodMap: any = Object.fromEntries(methodNameToId);
for (const key of Object.keys(methodMap)) {
methodMap[key] = [methodMap[key], methods.get(methodMap[key])?.requiresAuthentication];
}
console.log(methodMap);
console.log("--- end rpc method map ---");

155
src/rpc/rpc.ts Normal file
View file

@ -0,0 +1,155 @@
import { errors } from "../errors";
export const alphanumericRegex = new RegExp(/^[a-z0-9]+$/i);
export const usernameRegex = new RegExp(/^[a-z0-9_]+$/i);
export const channelNameRegex = new RegExp(/^[a-z0-9_\- ]+$/i);
const defaultStringMaxLength = 3000;
export const unsignedNumber = (): RPCArgument => ({ type: RPCArgumentType.Number, minValue: 0 });
export const number = (minValue?: number, maxValue?: number): RPCArgument => ({ type: RPCArgumentType.Number, minValue, maxValue });
export const string = (minLength = 0, maxLength = defaultStringMaxLength): RPCArgument => ({ type: RPCArgumentType.String, minLength, maxLength });
export const withRegexp = (regexp: RegExp, arg: RPCArgument): RPCArgument => ({ minLength: 0, maxLength: defaultStringMaxLength, ...arg, regexp });
export const withOptional = (arg: RPCArgument): RPCArgument => ({ ...arg, isOptional: true });
enum RPCArgumentType {
Number,
String
}
interface RPCArgument {
type: RPCArgumentType
isOptional?: boolean
// strings
minLength?: number
maxLength?: number
regexp?: RegExp
// numbers
minValue?: number
maxValue?: number
}
interface RPCMethod {
args: RPCArgument[],
func: ((...args: any[]) => any)
requiresAuthentication: boolean
}
export const methods: Map<number, RPCMethod> = new Map();
export const methodNameToId: Map<string, number> = new Map();
let lastMethodId = 0;
export const method = (name: string, args: RPCArgument[], func: ((...args: any[]) => any), requiresAuthentication: boolean = true) => {
let id = lastMethodId++;
methodNameToId.set(name, id);
methods.set(id, { args, func, requiresAuthentication });
return id;
};
export const methodButWarningDoesNotAuthenticate = (name: string, args: RPCArgument[], func: ((...args: any[]) => any)) => {
return method(name, args, func, false);
};
export const userInvokeMethod = async (user: User | null, methodId: number, args: any[]) => {
const methodData = methods.get(methodId);
if (!methodData) return {
...errors.INVALID_RPC_CALL,
detail: "The method was not found."
};
const argSchema = methodData.args;
if (argSchema.length !== args.length) return {
...errors.INVALID_RPC_CALL,
detail: "Invalid number of arguments provided to method."
};
const validationErrors = [];
for (let i = 0; i < argSchema.length; i++) {
const argument = args[i];
const schema = argSchema[i];
if (schema.isOptional && (argument === undefined || argument === null)) {
continue;
}
switch (schema.type) {
case RPCArgumentType.Number: {
if (typeof argument !== "number") {
validationErrors.push({ index: i, msg: `Expected type number, got type ${typeof argument}.` });
continue;
}
if (schema.minValue !== undefined && argument < schema.minValue) {
validationErrors.push({ index: i, msg: `Provided number is below minimum value of ${schema.minValue}.` });
}
if (schema.maxValue !== undefined && argument > schema.maxValue) {
validationErrors.push({ index: i, msg: `Provided number is above maximum value of ${schema.maxValue}.` });
}
break;
}
case RPCArgumentType.String: {
if (typeof argument !== "string") {
validationErrors.push({ index: i, msg: `Expected type string, got type ${typeof argument}.` });
continue;
}
if ((schema.minLength !== undefined && argument.length < schema.minLength) || (schema.maxLength !== undefined && argument.length > schema.maxLength)) {
validationErrors.push({ index: i, msg: `Must be between ${schema.minLength} and ${schema.maxLength} characters long.` });
continue;
}
if (schema.regexp && !schema.regexp?.test(argument)) {
validationErrors.push({ index: i, msg: `Contains invalid characters.` });
}
break;
}
}
}
if (validationErrors.length !== 0) {
return {
...errors.INVALID_DATA,
errors: validationErrors
};
}
if (user) {
return await ((methodData.func)(user, ...args));
} else if (!user && !methodData.requiresAuthentication) {
return await ((methodData.func)(...args));
} else {
return errors.BAD_AUTH;
}
};
export const processMethodBatch = async (user: User | null, calls: any) => {
if (!Array.isArray(calls) || !calls.length || calls.length > 5) {
return {
...errors.INVALID_RPC_CALL,
detail: "Expected RPC batch: an array of arrays with at least a single element and at most 5 elements, where each inner array represents a method call."
};
}
const responses = new Array(calls.length);
const promises = new Array(calls.length);
calls.forEach((call, index) => {
if (!Array.isArray(call) || !call.length || call.length > 8) {
responses[index] = {
...errors.INVALID_RPC_CALL,
detail: "Invalid method call. Expected inner array with at least one element and at most 8 elements."
};
return;
}
const promise = userInvokeMethod(user, call[0], call.slice(1, call.length));
promise.then(value => responses[index] = value);
promises[index] = promise;
});
try {
await Promise.all(promises);
return responses;
} catch(e) {
console.error("exception while invoking RPC method", e);
return errors.INTERNAL_ERROR;
}
}

View file

@ -1,7 +1,6 @@
import express, { Application, ErrorRequestHandler, json } from "express";
import usersRouter from "./routes/api/v1/users";
import channelsRouter from "./routes/api/v1/channels";
import messagesRouter from "./routes/api/v1/messages";
import "./rpc";
import rpcRouter from "./routes/api/v1/rpc";
import matrixRouter from "./routes/matrix";
import { errors } from "./errors";
@ -9,9 +8,7 @@ const ENABLE_MATRIX_LAYER = false;
export default function(app: Application) {
app.use(json());
app.use("/api/v1/users", usersRouter);
app.use("/api/v1/channels", channelsRouter);
app.use("/api/v1/messages", messagesRouter);
app.use("/api/v1/rpc", rpcRouter);
app.use("/", express.static("frontend/public"));
if (ENABLE_MATRIX_LAYER) {
app.use("/", matrixRouter);

View file

@ -1,5 +1,5 @@
export default {
superuserRequirement: {
createChannel: true
createChannel: false
},
};

View file

@ -1,6 +1,7 @@
declare namespace Express {
export interface Request {
user: User,
publicUser: User
publicUser: User,
authenticated: boolean
}
}

View file

@ -3,4 +3,5 @@ import { GatewayPayloadType } from "../gateway/gatewaypayloadtype";
declare interface GatewayPayload {
t: GatewayPayloadType;
d: any;
s?: number; // Serial. Used for RPCRequest/RPCResponse
}

View file

@ -1,4 +1,4 @@
import { GatewayPresenceStatus } from "./gatewaypayloadtype"
import { GatewayPresenceStatus } from "../gateway/gatewaypayloadtype"
export interface GatewayPresenceEntry {
user: {