replace rest api with rpc system
This commit is contained in:
parent
8ba70833f3
commit
bca4280afb
26 changed files with 636 additions and 595 deletions
|
@ -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();
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
15
src/auth.ts
15
src/auth.ts
|
@ -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
|
||||
});
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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" },
|
||||
};
|
||||
|
|
|
@ -3,6 +3,8 @@ export enum GatewayPayloadType {
|
|||
Authenticate, // client
|
||||
Ready,
|
||||
Ping, // client
|
||||
RPCRequest, // client
|
||||
RPCResponse,
|
||||
|
||||
ChannelCreate = 110,
|
||||
ChannelUpdate,
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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
16
src/routes/api/v1/rpc.ts
Normal 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;
|
|
@ -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
166
src/rpc/apis/channels.ts
Normal 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
79
src/rpc/apis/messages.ts
Normal 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
76
src/rpc/apis/users.ts
Normal 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
13
src/rpc/index.ts
Normal 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
155
src/rpc/rpc.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export default {
|
||||
superuserRequirement: {
|
||||
createChannel: true
|
||||
createChannel: false
|
||||
},
|
||||
};
|
||||
|
|
3
src/types/express.d.ts
vendored
3
src/types/express.d.ts
vendored
|
@ -1,6 +1,7 @@
|
|||
declare namespace Express {
|
||||
export interface Request {
|
||||
user: User,
|
||||
publicUser: User
|
||||
publicUser: User,
|
||||
authenticated: boolean
|
||||
}
|
||||
}
|
||||
|
|
1
src/types/gatewaypayload.d.ts
vendored
1
src/types/gatewaypayload.d.ts
vendored
|
@ -3,4 +3,5 @@ import { GatewayPayloadType } from "../gateway/gatewaypayloadtype";
|
|||
declare interface GatewayPayload {
|
||||
t: GatewayPayloadType;
|
||||
d: any;
|
||||
s?: number; // Serial. Used for RPCRequest/RPCResponse
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { GatewayPresenceStatus } from "./gatewaypayloadtype"
|
||||
import { GatewayPresenceStatus } from "../gateway/gatewaypayloadtype"
|
||||
|
||||
export interface GatewayPresenceEntry {
|
||||
user: {
|
Loading…
Reference in a new issue