@vex-chat/spire 0.7.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -26
- package/dist/ClientManager.d.ts +24 -25
- package/dist/ClientManager.js +232 -509
- package/dist/ClientManager.js.map +1 -0
- package/dist/Database.d.ts +49 -41
- package/dist/Database.js +698 -716
- package/dist/Database.js.map +1 -0
- package/dist/Spire.d.ts +23 -15
- package/dist/Spire.js +518 -218
- package/dist/Spire.js.map +1 -0
- package/dist/__tests__/Database.spec.js +113 -73
- package/dist/__tests__/Database.spec.js.map +1 -0
- package/dist/db/schema.d.ts +134 -0
- package/dist/db/schema.js +2 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -0
- package/dist/middleware/validate.d.ts +12 -0
- package/dist/middleware/validate.js +35 -0
- package/dist/middleware/validate.js.map +1 -0
- package/dist/migrations/2026-04-06_initial-schema.d.ts +3 -0
- package/dist/migrations/2026-04-06_initial-schema.js +192 -0
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -0
- package/dist/run.js +26 -21
- package/dist/run.js.map +1 -0
- package/dist/server/avatar.d.ts +3 -4
- package/dist/server/avatar.js +85 -67
- package/dist/server/avatar.js.map +1 -0
- package/dist/server/errors.d.ts +59 -0
- package/dist/server/errors.js +94 -0
- package/dist/server/errors.js.map +1 -0
- package/dist/server/file.d.ts +3 -4
- package/dist/server/file.js +101 -61
- package/dist/server/file.js.map +1 -0
- package/dist/server/index.d.ts +9 -6
- package/dist/server/index.js +595 -70
- package/dist/server/index.js.map +1 -0
- package/dist/server/invite.d.ts +4 -5
- package/dist/server/invite.js +21 -103
- package/dist/server/invite.js.map +1 -0
- package/dist/server/openapi.d.ts +2 -0
- package/dist/server/openapi.js +40 -0
- package/dist/server/openapi.js.map +1 -0
- package/dist/server/permissions.d.ts +16 -0
- package/dist/server/permissions.js +22 -0
- package/dist/server/permissions.js.map +1 -0
- package/dist/server/rateLimit.d.ts +28 -0
- package/dist/server/rateLimit.js +58 -0
- package/dist/server/rateLimit.js.map +1 -0
- package/dist/server/user.d.ts +4 -7
- package/dist/server/user.js +66 -76
- package/dist/server/user.js.map +1 -0
- package/dist/server/utils.d.ts +35 -7
- package/dist/server/utils.js +50 -6
- package/dist/server/utils.js.map +1 -0
- package/dist/types/express.d.ts +20 -0
- package/dist/types/express.js +2 -0
- package/dist/types/express.js.map +1 -0
- package/dist/utils/createLogger.js +13 -19
- package/dist/utils/createLogger.js.map +1 -0
- package/dist/utils/createUint8UUID.js +6 -10
- package/dist/utils/createUint8UUID.js.map +1 -0
- package/dist/utils/jwtSecret.d.ts +7 -0
- package/dist/utils/jwtSecret.js +15 -0
- package/dist/utils/jwtSecret.js.map +1 -0
- package/dist/utils/loadEnv.js +7 -22
- package/dist/utils/loadEnv.js.map +1 -0
- package/dist/utils/msgpack.d.ts +2 -0
- package/dist/utils/msgpack.js +4 -0
- package/dist/utils/msgpack.js.map +1 -0
- package/package.json +91 -63
- package/src/ClientManager.ts +434 -0
- package/src/Database.ts +925 -0
- package/src/Spire.ts +878 -0
- package/src/__tests__/Database.spec.ts +167 -0
- package/src/ambient-modules.d.ts +1 -0
- package/src/db/schema.ts +165 -0
- package/src/index.ts +3 -0
- package/src/middleware/validate.ts +38 -0
- package/src/migrations/2026-04-06_initial-schema.ts +218 -0
- package/src/run.ts +37 -0
- package/src/server/avatar.ts +141 -0
- package/src/server/errors.ts +133 -0
- package/src/server/file.ts +172 -0
- package/src/server/index.ts +855 -0
- package/src/server/invite.ts +65 -0
- package/src/server/openapi.ts +51 -0
- package/src/server/permissions.ts +40 -0
- package/src/server/rateLimit.ts +86 -0
- package/src/server/user.ts +125 -0
- package/src/server/utils.ts +59 -0
- package/src/types/express.ts +23 -0
- package/src/utils/createLogger.ts +47 -0
- package/src/utils/createUint8UUID.ts +9 -0
- package/src/utils/jwtSecret.ts +16 -0
- package/src/utils/loadEnv.ts +15 -0
- package/src/utils/msgpack.ts +4 -0
- package/avatars/169d76cb-6e7c-4e24-8224-017673eed8ff +0 -0
- package/avatars/1cae8d0b-0c6b-4c73-b25c-2d2349a57122 +0 -0
- package/avatars/1d87bc2b-71fc-4818-8004-40d04093e5fc +0 -0
- package/avatars/1f2c3d62-8b4d-465a-9895-51a24caef00d +0 -0
- package/avatars/245ee7fc-1004-41ab-adab-a04c9ceb9d7a +0 -0
- package/avatars/2465c28c-bdaf-4fa2-b42a-d054f04dc39b +0 -0
- package/avatars/3900a674-a2dd-4996-a61d-8c29b3270f41 +0 -0
- package/avatars/3c3b9c77-ea50-45e7-bb25-65d6f3d2a219 +0 -0
- package/avatars/414a2ff4-ad2f-4a7d-aa27-3b09ad3522b2 +0 -0
- package/avatars/522fe504-f0ad-4ed4-9dc6-e5dc2338e531 +0 -0
- package/avatars/53e5eb29-e7d1-4d58-add9-d44f39f0cfb7 +0 -0
- package/avatars/54d3f757-1038-41c8-bfb9-efd37b6e8ebe +0 -0
- package/avatars/623e86d7-c49c-46f6-9b76-ca70c9dbc43b +0 -0
- package/avatars/66e2abae-60f5-4dd1-b9fb-297a4bedfeb0 +0 -0
- package/avatars/6f37980e-f6fa-4d6d-9206-24050b403f45 +0 -0
- package/avatars/80138ece-eb5c-4b20-817b-903d6b0f54ae +0 -0
- package/avatars/841c77d3-37c4-431b-be22-672888062874 +0 -0
- package/avatars/88051a61-2bda-4750-95a3-5fb0b4918149 +0 -0
- package/avatars/89540973-c421-4bf1-8b89-9f1eaa51c1b5 +0 -0
- package/avatars/8a802fad-8c99-4942-8f80-47cec600149c +0 -0
- package/avatars/90531d8a-907a-4a1a-ac45-c85e4acb0df9 +0 -0
- package/avatars/9b7d0da9-b8d6-4801-b128-9993f79f464a +0 -0
- package/avatars/9bc456f1-c4c4-48a1-b9e6-fd44dd744a72 +0 -0
- package/avatars/9cf878bf-7430-49ec-a47a-78f3c93793dd +0 -0
- package/avatars/9ee82847-6ad3-45e5-92b9-f474a6c54d96 +0 -0
- package/avatars/ab44c857-d81d-4c88-85db-32f9532e5376 +0 -0
- package/avatars/b396a8d2-dc14-48d2-aac4-3755dc637051 +0 -0
- package/avatars/b6ac11c5-a8b2-4e0a-995a-9c87f1e58787 +0 -0
- package/avatars/b79d6855-b738-434c-be32-809637e62b9b +0 -0
- package/avatars/bbcd0188-d6a5-48ae-90fb-be5ff30599ab +0 -0
- package/avatars/bc7a9e0e-4720-4a6e-a90d-c11fec94d380 +0 -0
- package/avatars/c1c4889f-8383-4041-8bdd-9fded4046f37 +0 -0
- package/avatars/c4c7203c-d93a-4749-ade2-17053acf1d2a +0 -0
- package/avatars/ca974a70-0a23-4668-8b80-c4304dc7f793 +0 -0
- package/avatars/cf119a0d-eb3f-4bed-905b-f14a876c3535 +0 -0
- package/avatars/d464b03d-30c2-49e3-a666-80aefa8a1b35 +0 -0
- package/avatars/da0eee89-82f0-4d45-ab48-7d2786b634c5 +0 -0
- package/avatars/de4c77a5-68e9-4bb2-b40d-d964bf377d61 +0 -0
- package/avatars/dea95395-7d0b-42aa-a9ed-40c7d4fb4c48 +0 -0
- package/avatars/edb30749-59ba-4aa2-9c52-0fb22048f4cf +0 -0
- package/avatars/f17c245a-af7d-445b-9365-49f7f54b1eeb +0 -0
- package/avatars/f1ee6a35-b262-4dbf-99f5-3d011e3b98ec +0 -0
- package/avatars/f802bdd0-345d-41f6-9184-0f30e1258fb3 +0 -0
- package/dist/migrations/20210103192527_users.d.ts +0 -3
- package/dist/migrations/20210103192527_users.js +0 -30
- package/dist/migrations/20210103193502_mail.d.ts +0 -3
- package/dist/migrations/20210103193502_mail.js +0 -35
- package/dist/migrations/20210103193525_preKeys.d.ts +0 -3
- package/dist/migrations/20210103193525_preKeys.js +0 -30
- package/dist/migrations/20210103193553_oneTimeKeys.d.ts +0 -3
- package/dist/migrations/20210103193553_oneTimeKeys.js +0 -30
- package/dist/migrations/20210103193615_servers.d.ts +0 -3
- package/dist/migrations/20210103193615_servers.js +0 -28
- package/dist/migrations/20210103193729_channels.d.ts +0 -3
- package/dist/migrations/20210103193729_channels.js +0 -28
- package/dist/migrations/20210103193749_permissions.d.ts +0 -3
- package/dist/migrations/20210103193749_permissions.js +0 -30
- package/dist/migrations/20210103193801_files.d.ts +0 -3
- package/dist/migrations/20210103193801_files.js +0 -28
- package/files/00ea7368-45a7-4f6a-a199-974da14be1a0 +0 -0
- package/files/039503d2-a170-4962-b921-c97994ba64ff +0 -0
- package/files/14b6fa02-4cbb-40df-be4f-a07187cb619e +0 -0
- package/files/15c04cb1-dc6a-4f19-aa6f-4a3b92d05bf7 +0 -0
- package/files/2a8d411c-8b92-4532-b84d-d64c638d6293 +0 -0
- package/files/37de2cd3-08a8-4044-9c37-e13386765f3d +0 -0
- package/files/42452029-284e-4c81-9f18-feb6ce309eed +0 -0
- package/files/43d09f2f-29c8-415f-8c4a-23f2a32eb79e +0 -0
- package/files/52992923-33ab-44a1-8118-605e9b4856a7 +0 -0
- package/files/53180681-36e2-49c0-8382-94dca0da09bf +0 -0
- package/files/5a56cd7b-1d04-4619-b60f-1e5515b9164a +0 -0
- package/files/5ced7676-20c4-4219-a4f2-70a25eb7eea8 +0 -0
- package/files/60e787ff-8ec5-444d-b963-0aaf5313b53e +0 -0
- package/files/67a17729-fcb7-4339-9499-1fc08fea72ca +0 -0
- package/files/68d09565-908d-4a67-8f09-e183f8708eb4 +0 -0
- package/files/70e587c5-56e8-47f0-bd36-691efcb0cc2e +0 -4
- package/files/7a227619-b715-4e2c-a79d-b2158d56a799 +0 -0
- package/files/7e3cc3ea-b706-4835-994d-65d33acaf369 +0 -0
- package/files/816f72a0-65dc-40c1-8862-1d22065cca3c +0 -0
- package/files/8bf84972-5086-4631-a752-093d7b1a098b +0 -0
- package/files/8c46e3bc-3f2e-441d-b8cc-17428fc3d219 +0 -0
- package/files/8eb83364-8826-4eee-895a-ba5cd3ab85e9 +0 -0
- package/files/8faefaea-14e3-49e4-ac74-9710622bfae9 +0 -0
- package/files/91af08c2-9dca-41f4-b6c6-b7bf33505df9 +0 -0
- package/files/9349dffa-35dd-49a0-a651-10b438038f95 +0 -0
- package/files/b0a12a41-6283-4f27-b2c8-66774991d96c +0 -0
- package/files/b28afb08-d18b-48a8-93c3-6a59c66d8fee +0 -0
- package/files/be60fe01-1578-4363-a908-41500092b77d +0 -0
- package/files/cf7b90e3-734a-4453-9ff3-9a331404b5ef +0 -0
- package/files/d1be30aa-8ff4-41c1-b360-f65922029047 +0 -0
- package/files/d2d31a57-a413-443c-9b97-fa9ca394ef0b +0 -0
- package/files/d357f223-b786-478d-a113-b53cb62acdab +0 -0
- package/files/d4a69ee0-05c4-4ff0-8753-624cdd0f24e9 +0 -0
- package/files/d57f411b-1874-4f00-a6b0-2f86de6b229d +0 -0
- package/files/d85aaa33-7f70-4a70-b3d2-cad667beb38c +0 -0
- package/files/db2fc236-dbe0-4d24-bfaf-884829fd090a +0 -0
- package/files/df2e20ec-6dcc-4402-94ee-7d1163f84197 +0 -0
- package/files/e0880ab5-6d49-4dc0-a5ec-0d3793a8a7b8 +0 -0
- package/files/e59ee9c6-5aea-48ea-b3d9-94a324dc2260 +0 -0
- package/files/e6d7aad6-4220-4ce7-85f8-89d0b1f0cc89 +0 -0
- package/files/f0bfc98c-3534-459d-931a-7c6e77bde153 +0 -0
- package/files/f8443740-050c-4714-9bf6-334783ae6ffd +0 -0
- package/files/fc405ae7-f9fb-455d-ac8c-0ca0d88d4115 +0 -0
- package/jest.config.js +0 -13
- package/spire.sqlite +0 -0
package/dist/Spire.js
CHANGED
|
@@ -1,127 +1,166 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
23
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
24
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
25
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
26
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
27
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
28
|
-
});
|
|
29
|
-
};
|
|
30
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
31
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
32
|
-
};
|
|
33
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
-
exports.Spire = exports.JWT_EXPIRY = exports.TOKEN_EXPIRY = void 0;
|
|
35
|
-
const fs_1 = __importDefault(require("fs"));
|
|
36
|
-
const crypto_1 = require("@vex-chat/crypto");
|
|
37
|
-
const types_1 = require("@vex-chat/types");
|
|
38
|
-
const events_1 = require("events");
|
|
39
|
-
const express_1 = __importDefault(require("express"));
|
|
40
|
-
const express_ws_1 = __importDefault(require("express-ws"));
|
|
41
|
-
const tweetnacl_1 = __importDefault(require("tweetnacl"));
|
|
42
|
-
const uuid = __importStar(require("uuid"));
|
|
43
|
-
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
44
|
-
const ClientManager_1 = require("./ClientManager");
|
|
45
|
-
const Database_1 = require("./Database");
|
|
46
|
-
const server_1 = require("./server");
|
|
47
|
-
const utils_1 = require("./server/utils");
|
|
48
|
-
const createLogger_1 = require("./utils/createLogger");
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
8
|
+
import { xRandomBytes, xSignKeyPairFromSecret, xSignOpen, } from "@vex-chat/crypto";
|
|
9
|
+
import { MailWSSchema, RegistrationPayloadSchema, TokenScopes, UserSchema, } from "@vex-chat/types";
|
|
10
|
+
import jwt from "jsonwebtoken";
|
|
11
|
+
import { stringify as uuidStringify } from "uuid";
|
|
12
|
+
import { WebSocketServer } from "ws";
|
|
13
|
+
import { z } from "zod/v4";
|
|
14
|
+
import { ClientManager } from "./ClientManager.js";
|
|
15
|
+
import { Database, hashPassword } from "./Database.js";
|
|
16
|
+
import { initApp, protect } from "./server/index.js";
|
|
17
|
+
import { authLimiter } from "./server/rateLimit.js";
|
|
18
|
+
import { censorUser, getParam, getUser } from "./server/utils.js";
|
|
19
|
+
import { createLogger } from "./utils/createLogger.js";
|
|
20
|
+
import { getJwtSecret } from "./utils/jwtSecret.js";
|
|
21
|
+
import { msgpack } from "./utils/msgpack.js";
|
|
49
22
|
// expiry of regkeys = 24hr
|
|
50
|
-
|
|
51
|
-
|
|
23
|
+
export const TOKEN_EXPIRY = 1000 * 60 * 10;
|
|
24
|
+
export const JWT_EXPIRY = "7d";
|
|
25
|
+
export const DEVICE_AUTH_JWT_EXPIRY = "1h";
|
|
26
|
+
const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
|
|
27
|
+
const STATUS_LATENCY_BUDGET_MS = 250;
|
|
52
28
|
// 3-19 chars long
|
|
53
29
|
const usernameRegex = /^(\w{3,19})$/;
|
|
54
|
-
|
|
30
|
+
// ── Zod schemas for trust-boundary validation ──────────────────────────
|
|
31
|
+
const wsAuthMsg = z.object({
|
|
32
|
+
token: z.string().min(1),
|
|
33
|
+
type: z.literal("auth"),
|
|
34
|
+
});
|
|
35
|
+
const jwtPayload = z.object({
|
|
36
|
+
bearerToken: z.string().optional(),
|
|
37
|
+
exp: z.number().optional(),
|
|
38
|
+
user: UserSchema,
|
|
39
|
+
});
|
|
40
|
+
const authPayload = z.object({
|
|
41
|
+
password: z.string().min(1),
|
|
42
|
+
username: z.string().min(1),
|
|
43
|
+
});
|
|
44
|
+
const deviceAuthPayload = z.object({
|
|
45
|
+
deviceID: z.string().min(1),
|
|
46
|
+
signKey: z.string().min(1),
|
|
47
|
+
});
|
|
48
|
+
const deviceVerifyPayload = z.object({
|
|
49
|
+
challengeID: z.string().min(1),
|
|
50
|
+
signed: z.string().min(1),
|
|
51
|
+
});
|
|
52
|
+
const mailPostPayload = z.object({
|
|
53
|
+
header: z.custom((val) => val instanceof Uint8Array),
|
|
54
|
+
mail: MailWSSchema,
|
|
55
|
+
});
|
|
56
|
+
const directories = ["files", "avatars", "emoji"];
|
|
55
57
|
for (const dir of directories) {
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
fs.mkdirSync(dir);
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
+
const getAppVersion = () => {
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(new URL("../package.json", import.meta.url), {
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
});
|
|
67
|
+
const pkg = JSON.parse(raw);
|
|
68
|
+
if (typeof pkg === "object" &&
|
|
69
|
+
pkg !== null &&
|
|
70
|
+
"version" in pkg &&
|
|
71
|
+
typeof pkg.version === "string") {
|
|
72
|
+
return pkg.version;
|
|
73
|
+
}
|
|
74
|
+
return "unknown";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "unknown";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const getCommitSha = () => {
|
|
81
|
+
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
82
|
+
const repoRoot = path.resolve(sourceDir, "..");
|
|
83
|
+
try {
|
|
84
|
+
const sha = execSync("git rev-parse --verify --short=12 HEAD", {
|
|
85
|
+
cwd: repoRoot,
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
88
|
+
timeout: 1500,
|
|
89
|
+
}).trim();
|
|
90
|
+
return sha || "unknown";
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return "unknown";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
export class Spire extends EventEmitter {
|
|
97
|
+
actionTokens = [];
|
|
98
|
+
api = express();
|
|
99
|
+
clients = [];
|
|
100
|
+
commitSha = getCommitSha();
|
|
101
|
+
db;
|
|
102
|
+
dbReady = false;
|
|
103
|
+
deviceChallenges = new Map();
|
|
104
|
+
log;
|
|
105
|
+
options;
|
|
106
|
+
queuedRequestIncrements = 0;
|
|
107
|
+
requestsTotal = 0;
|
|
108
|
+
requestsTotalLoaded = false;
|
|
109
|
+
server = null;
|
|
110
|
+
signKeys;
|
|
111
|
+
startedAt = new Date();
|
|
112
|
+
version = getAppVersion();
|
|
113
|
+
wss = new WebSocketServer({ noServer: true });
|
|
62
114
|
constructor(SK, options) {
|
|
63
115
|
super();
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this
|
|
70
|
-
this.
|
|
71
|
-
this.db = new
|
|
72
|
-
this.
|
|
73
|
-
|
|
116
|
+
this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
|
|
117
|
+
// Trust a single proxy hop (nginx / cloudflare / load balancer).
|
|
118
|
+
// Required so `req.ip` and `express-rate-limit`'s keyGenerator see
|
|
119
|
+
// the real client address via X-Forwarded-For. Never use `true` —
|
|
120
|
+
// that lets attackers spoof the header and bypass rate limiting.
|
|
121
|
+
// If spire is deployed without a proxy, set this to 0 instead.
|
|
122
|
+
this.api.set("trust proxy", 1);
|
|
123
|
+
this.db = new Database(options);
|
|
124
|
+
this.db.on("ready", () => {
|
|
125
|
+
this.dbReady = true;
|
|
126
|
+
this.bootstrapRequestCounter().catch((err) => {
|
|
127
|
+
this.log.error("Failed to load persisted request counter: " + String(err));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
this.log = createLogger("spire", options?.logLevel || "error");
|
|
131
|
+
this.init(options?.apiPort || 16777);
|
|
74
132
|
this.options = options;
|
|
75
133
|
}
|
|
76
|
-
close() {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
(_a = this.server) === null || _a === void 0 ? void 0 : _a.on("close", () => {
|
|
86
|
-
this.log.info("http: closed.");
|
|
87
|
-
});
|
|
88
|
-
(_b = this.server) === null || _b === void 0 ? void 0 : _b.close();
|
|
89
|
-
this.wss.close();
|
|
90
|
-
yield this.db.close();
|
|
91
|
-
return;
|
|
134
|
+
async close() {
|
|
135
|
+
this.wss.clients.forEach((ws) => {
|
|
136
|
+
ws.terminate();
|
|
137
|
+
});
|
|
138
|
+
this.wss.on("close", () => {
|
|
139
|
+
this.log.info("ws: closed.");
|
|
140
|
+
});
|
|
141
|
+
this.server?.on("close", () => {
|
|
142
|
+
this.log.info("http: closed.");
|
|
92
143
|
});
|
|
144
|
+
this.server?.close();
|
|
145
|
+
this.wss.close();
|
|
146
|
+
await this.db.close();
|
|
147
|
+
return;
|
|
93
148
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
event,
|
|
102
|
-
data,
|
|
103
|
-
};
|
|
104
|
-
client.send(msg);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
if (client.getUser().userID === userID) {
|
|
109
|
-
const msg = {
|
|
110
|
-
transmissionID,
|
|
111
|
-
type: "notify",
|
|
112
|
-
event,
|
|
113
|
-
data,
|
|
114
|
-
};
|
|
115
|
-
client.send(msg);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
149
|
+
async bootstrapRequestCounter() {
|
|
150
|
+
const persistedTotal = await this.db.getRequestsTotal();
|
|
151
|
+
const startupIncrements = this.queuedRequestIncrements;
|
|
152
|
+
this.queuedRequestIncrements = 0;
|
|
153
|
+
this.requestsTotal = persistedTotal + startupIncrements;
|
|
154
|
+
if (startupIncrements > 0) {
|
|
155
|
+
await this.db.incrementRequestsTotal(startupIncrements);
|
|
118
156
|
}
|
|
157
|
+
this.requestsTotalLoaded = true;
|
|
119
158
|
}
|
|
120
159
|
createActionToken(scope) {
|
|
121
160
|
const token = {
|
|
122
|
-
key:
|
|
123
|
-
time: new Date(Date.now()),
|
|
161
|
+
key: crypto.randomUUID(),
|
|
124
162
|
scope,
|
|
163
|
+
time: new Date().toISOString(),
|
|
125
164
|
};
|
|
126
165
|
this.actionTokens.push(token);
|
|
127
166
|
return token;
|
|
@@ -131,93 +170,130 @@ class Spire extends events_1.EventEmitter {
|
|
|
131
170
|
this.actionTokens.splice(this.actionTokens.indexOf(key), 1);
|
|
132
171
|
}
|
|
133
172
|
}
|
|
134
|
-
validateToken(key, scope) {
|
|
135
|
-
this.log.info("Validating token: " + key);
|
|
136
|
-
for (const rKey of this.actionTokens) {
|
|
137
|
-
if (rKey.key === key) {
|
|
138
|
-
if (rKey.scope !== scope) {
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
const age = new Date(Date.now()).getTime() - rKey.time.getTime();
|
|
142
|
-
this.log.info("Token found, " + age + " ms old.");
|
|
143
|
-
if (age < exports.TOKEN_EXPIRY) {
|
|
144
|
-
this.log.info("Token is valid.");
|
|
145
|
-
this.deleteActionToken(rKey);
|
|
146
|
-
return true;
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
this.log.info("Token is expired.");
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
this.log.info("Token not found.");
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
173
|
init(apiPort) {
|
|
174
|
+
this.api.use((_req, _res, next) => {
|
|
175
|
+
this.requestsTotal += 1;
|
|
176
|
+
if (!this.requestsTotalLoaded) {
|
|
177
|
+
this.queuedRequestIncrements += 1;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.db.incrementRequestsTotal(1).catch((err) => {
|
|
181
|
+
this.log.warn("Failed to persist request counter increment: " +
|
|
182
|
+
String(err));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
next();
|
|
186
|
+
});
|
|
157
187
|
// initialize the expression app configuration with loose routes/handlers
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
this.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
type: "unauthorized",
|
|
166
|
-
transmissionID: uuid.v4(),
|
|
167
|
-
};
|
|
168
|
-
const msg = crypto_1.XUtils.packMessage(err);
|
|
169
|
-
ws.send(msg);
|
|
188
|
+
initApp(this.api, this.db, this.log, this.validateToken.bind(this), this.signKeys, this.notify.bind(this));
|
|
189
|
+
// WS auth: client sends { type: "auth", token } as first message
|
|
190
|
+
this.wss.on("connection", (ws) => {
|
|
191
|
+
this.log.info("WS connection established, waiting for auth...");
|
|
192
|
+
const AUTH_TIMEOUT = 10_000;
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
this.log.warn("WS auth timeout — closing.");
|
|
170
195
|
ws.close();
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
196
|
+
}, AUTH_TIMEOUT);
|
|
197
|
+
const onFirstMessage = (data) => {
|
|
198
|
+
const str = Buffer.isBuffer(data)
|
|
199
|
+
? data.toString()
|
|
200
|
+
: data instanceof ArrayBuffer
|
|
201
|
+
? Buffer.from(data).toString()
|
|
202
|
+
: Buffer.concat(data).toString();
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
ws.off("message", onFirstMessage);
|
|
205
|
+
try {
|
|
206
|
+
const rawParsed = JSON.parse(str);
|
|
207
|
+
const authResult = wsAuthMsg.safeParse(rawParsed);
|
|
208
|
+
if (!authResult.success) {
|
|
209
|
+
throw new Error("Expected { type: 'auth', token }, got: " +
|
|
210
|
+
JSON.stringify(authResult.error.issues));
|
|
211
|
+
}
|
|
212
|
+
const result = jwt.verify(authResult.data.token, getJwtSecret());
|
|
213
|
+
const jwtResult = jwtPayload.safeParse(result);
|
|
214
|
+
if (!jwtResult.success) {
|
|
215
|
+
throw new Error("Invalid JWT payload: " +
|
|
216
|
+
JSON.stringify(jwtResult.error.issues));
|
|
217
|
+
}
|
|
218
|
+
const userDetails = jwtResult.data.user;
|
|
219
|
+
this.log.info("WS auth succeeded for " + userDetails.username);
|
|
220
|
+
const client = new ClientManager(ws, this.db, this.notify.bind(this), userDetails, this.options);
|
|
221
|
+
client.on("fail", () => {
|
|
222
|
+
this.log.info("Client connection is down, removing: " +
|
|
223
|
+
client.toString());
|
|
224
|
+
if (this.clients.includes(client)) {
|
|
225
|
+
this.clients.splice(this.clients.indexOf(client), 1);
|
|
226
|
+
}
|
|
227
|
+
this.log.info("Current authorized clients: " +
|
|
228
|
+
String(this.clients.length));
|
|
229
|
+
});
|
|
230
|
+
client.on("authed", () => {
|
|
231
|
+
this.log.info("New client authorized: " + client.toString());
|
|
232
|
+
this.clients.push(client);
|
|
233
|
+
this.log.info("Current authorized clients: " +
|
|
234
|
+
String(this.clients.length));
|
|
235
|
+
});
|
|
181
236
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
237
|
+
catch (err) {
|
|
238
|
+
this.log.warn("WS auth failed: " + String(err));
|
|
239
|
+
const errMsg = {
|
|
240
|
+
transmissionID: crypto.randomUUID(),
|
|
241
|
+
type: "unauthorized",
|
|
242
|
+
};
|
|
243
|
+
ws.send(XUtils.packMessage(errMsg));
|
|
244
|
+
ws.close();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
ws.on("message", onFirstMessage);
|
|
248
|
+
ws.on("close", () => {
|
|
249
|
+
clearTimeout(timer);
|
|
188
250
|
});
|
|
189
251
|
});
|
|
190
|
-
this.api.get("/token/:tokenType", (req, res) =>
|
|
252
|
+
this.api.get("/token/:tokenType", (req, res, next) => {
|
|
253
|
+
if (getParam(req, "tokenType") !== "register") {
|
|
254
|
+
protect(req, res, next);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
next();
|
|
258
|
+
}
|
|
259
|
+
}, (req, res) => {
|
|
191
260
|
const allowedTokens = [
|
|
192
261
|
"file",
|
|
193
262
|
"register",
|
|
194
263
|
"avatar",
|
|
195
264
|
"device",
|
|
196
265
|
"invite",
|
|
266
|
+
"emoji",
|
|
267
|
+
"connect",
|
|
197
268
|
];
|
|
198
|
-
const
|
|
199
|
-
console.log(tokenType);
|
|
269
|
+
const tokenType = getParam(req, "tokenType");
|
|
200
270
|
if (!allowedTokens.includes(tokenType)) {
|
|
201
271
|
res.sendStatus(400);
|
|
202
272
|
return;
|
|
203
273
|
}
|
|
204
274
|
let scope;
|
|
205
275
|
switch (tokenType) {
|
|
206
|
-
case "file":
|
|
207
|
-
scope = TokenScopes.File;
|
|
208
|
-
break;
|
|
209
|
-
case "register":
|
|
210
|
-
scope = TokenScopes.Register;
|
|
211
|
-
break;
|
|
212
276
|
case "avatar":
|
|
213
277
|
scope = TokenScopes.Avatar;
|
|
214
278
|
break;
|
|
279
|
+
case "connect":
|
|
280
|
+
scope = TokenScopes.Connect;
|
|
281
|
+
break;
|
|
215
282
|
case "device":
|
|
216
283
|
scope = TokenScopes.Device;
|
|
217
284
|
break;
|
|
285
|
+
case "emoji":
|
|
286
|
+
scope = TokenScopes.Emoji;
|
|
287
|
+
break;
|
|
288
|
+
case "file":
|
|
289
|
+
scope = TokenScopes.File;
|
|
290
|
+
break;
|
|
218
291
|
case "invite":
|
|
219
292
|
scope = TokenScopes.Invite;
|
|
220
293
|
break;
|
|
294
|
+
case "register":
|
|
295
|
+
scope = TokenScopes.Register;
|
|
296
|
+
break;
|
|
221
297
|
default:
|
|
222
298
|
res.sendStatus(400);
|
|
223
299
|
return;
|
|
@@ -228,84 +304,250 @@ class Spire extends events_1.EventEmitter {
|
|
|
228
304
|
this.log.info("New token created: " + token.key);
|
|
229
305
|
setTimeout(() => {
|
|
230
306
|
this.deleteActionToken(token);
|
|
231
|
-
},
|
|
232
|
-
|
|
307
|
+
}, TOKEN_EXPIRY);
|
|
308
|
+
const acceptHeader = req.get("accept")?.toLowerCase() || "";
|
|
309
|
+
const wantsJson = acceptHeader.includes("application/json") &&
|
|
310
|
+
!acceptHeader.includes("application/msgpack") &&
|
|
311
|
+
!acceptHeader.includes("*/*");
|
|
312
|
+
if (wantsJson) {
|
|
313
|
+
return res.json(token);
|
|
314
|
+
}
|
|
315
|
+
res.set("Content-Type", "application/msgpack");
|
|
316
|
+
return res.send(msgpack.encode(token));
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
this.log.error(String(err));
|
|
320
|
+
return res.sendStatus(500);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
this.api.post("/whoami", (req, res) => {
|
|
324
|
+
if (!req.user) {
|
|
325
|
+
res.sendStatus(401);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
res.send(msgpack.encode({
|
|
329
|
+
exp: req.exp,
|
|
330
|
+
token: req.bearerToken,
|
|
331
|
+
user: req.user,
|
|
332
|
+
}));
|
|
333
|
+
});
|
|
334
|
+
this.api.get("/healthz", (_req, res) => {
|
|
335
|
+
if (!this.dbReady) {
|
|
336
|
+
res.status(503).json({ dbReady: false, ok: false });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
res.json({ dbReady: true, ok: true });
|
|
340
|
+
});
|
|
341
|
+
this.api.get("/status", async (_req, res) => {
|
|
342
|
+
const started = Date.now();
|
|
343
|
+
const dbHealthy = this.dbReady ? await this.db.isHealthy() : false;
|
|
344
|
+
const checkDurationMs = Date.now() - started;
|
|
345
|
+
const ok = dbHealthy;
|
|
346
|
+
res.json({
|
|
347
|
+
checkDurationMs,
|
|
348
|
+
commitSha: this.commitSha,
|
|
349
|
+
dbHealthy,
|
|
350
|
+
dbReady: this.dbReady,
|
|
351
|
+
latencyBudgetMs: STATUS_LATENCY_BUDGET_MS,
|
|
352
|
+
metrics: {
|
|
353
|
+
requestsTotal: this.requestsTotal,
|
|
354
|
+
},
|
|
355
|
+
now: new Date(),
|
|
356
|
+
ok,
|
|
357
|
+
startedAt: this.startedAt.toISOString(),
|
|
358
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
359
|
+
version: this.version,
|
|
360
|
+
withinLatencyBudget: checkDurationMs <= STATUS_LATENCY_BUDGET_MS,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
this.api.post("/goodbye", protect, (req, res) => {
|
|
364
|
+
jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
|
|
365
|
+
res.sendStatus(200);
|
|
366
|
+
});
|
|
367
|
+
// ── Device-key auth ──────────────────────────────────────────
|
|
368
|
+
this.api.post("/auth/device", authLimiter, async (req, res) => {
|
|
369
|
+
try {
|
|
370
|
+
const parsed = deviceAuthPayload.safeParse(req.body);
|
|
371
|
+
if (!parsed.success) {
|
|
372
|
+
return res
|
|
373
|
+
.status(400)
|
|
374
|
+
.send({ error: "deviceID and signKey required." });
|
|
375
|
+
}
|
|
376
|
+
const { deviceID, signKey } = parsed.data;
|
|
377
|
+
const device = await this.db.retrieveDevice(deviceID);
|
|
378
|
+
if (!device || device.signKey !== signKey) {
|
|
379
|
+
return res.status(404).send({ error: "Device not found." });
|
|
380
|
+
}
|
|
381
|
+
// Generate challenge nonce (32 bytes)
|
|
382
|
+
const nonce = XUtils.encodeHex(xRandomBytes(32));
|
|
383
|
+
const challengeID = crypto.randomUUID();
|
|
384
|
+
this.deviceChallenges.set(challengeID, {
|
|
385
|
+
deviceID,
|
|
386
|
+
nonce,
|
|
387
|
+
time: Date.now(),
|
|
388
|
+
});
|
|
389
|
+
// Clean up expired challenges
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
this.deviceChallenges.delete(challengeID);
|
|
392
|
+
}, DEVICE_CHALLENGE_EXPIRY);
|
|
393
|
+
this.log.info("Device challenge issued for " + deviceID);
|
|
394
|
+
return res.send(msgpack.encode({ challenge: nonce, challengeID }));
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
this.log.error("Device challenge error: " + String(err));
|
|
398
|
+
return res.sendStatus(500);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
this.api.post("/auth/device/verify", authLimiter, async (req, res) => {
|
|
402
|
+
try {
|
|
403
|
+
const parsed = deviceVerifyPayload.safeParse(req.body);
|
|
404
|
+
if (!parsed.success) {
|
|
405
|
+
return res.status(400).send({
|
|
406
|
+
error: "challengeID and signed required.",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const { challengeID, signed } = parsed.data;
|
|
410
|
+
const challenge = this.deviceChallenges.get(challengeID);
|
|
411
|
+
if (!challenge) {
|
|
412
|
+
return res.status(401).send({
|
|
413
|
+
error: "Challenge expired or not found.",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// Consume the challenge (single-use)
|
|
417
|
+
this.deviceChallenges.delete(challengeID);
|
|
418
|
+
// Check expiry
|
|
419
|
+
if (Date.now() - challenge.time > DEVICE_CHALLENGE_EXPIRY) {
|
|
420
|
+
return res
|
|
421
|
+
.status(401)
|
|
422
|
+
.send({ error: "Challenge expired." });
|
|
423
|
+
}
|
|
424
|
+
// Look up the device to get its public signKey
|
|
425
|
+
const device = await this.db.retrieveDevice(challenge.deviceID);
|
|
426
|
+
if (!device) {
|
|
427
|
+
return res.status(404).send({ error: "Device not found." });
|
|
428
|
+
}
|
|
429
|
+
// Verify the Ed25519 signature
|
|
430
|
+
const opened = xSignOpen(XUtils.decodeHex(signed), XUtils.decodeHex(device.signKey));
|
|
431
|
+
if (!opened) {
|
|
432
|
+
return res
|
|
433
|
+
.status(401)
|
|
434
|
+
.send({ error: "Signature verification failed." });
|
|
435
|
+
}
|
|
436
|
+
// Verify the signed content matches the challenge nonce
|
|
437
|
+
const signedNonce = XUtils.encodeHex(opened);
|
|
438
|
+
if (signedNonce !== challenge.nonce) {
|
|
439
|
+
return res
|
|
440
|
+
.status(401)
|
|
441
|
+
.send({ error: "Challenge mismatch." });
|
|
442
|
+
}
|
|
443
|
+
// Look up device owner
|
|
444
|
+
const user = await this.db.retrieveUser(device.owner);
|
|
445
|
+
if (!user) {
|
|
446
|
+
return res
|
|
447
|
+
.status(404)
|
|
448
|
+
.send({ error: "Device owner not found." });
|
|
449
|
+
}
|
|
450
|
+
// Issue short-lived JWT (1 hour, not 7 days)
|
|
451
|
+
const token = jwt.sign({ user: censorUser(user) }, getJwtSecret(), { expiresIn: DEVICE_AUTH_JWT_EXPIRY });
|
|
452
|
+
this.log.info("Device-key auth succeeded for " +
|
|
453
|
+
user.username +
|
|
454
|
+
" (device " +
|
|
455
|
+
device.deviceID +
|
|
456
|
+
")");
|
|
457
|
+
return res.send(msgpack.encode({ token, user: censorUser(user) }));
|
|
233
458
|
}
|
|
234
459
|
catch (err) {
|
|
235
|
-
|
|
460
|
+
this.log.error("Device verify error: " + String(err));
|
|
236
461
|
return res.sendStatus(500);
|
|
237
462
|
}
|
|
238
|
-
})
|
|
239
|
-
this.api.post("/
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
res.
|
|
463
|
+
});
|
|
464
|
+
this.api.post("/mail", protect, async (req, res) => {
|
|
465
|
+
const senderDeviceDetails = req.device;
|
|
466
|
+
if (!senderDeviceDetails) {
|
|
467
|
+
res.sendStatus(401);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const authorUserDetails = getUser(req);
|
|
471
|
+
const parsed = mailPostPayload.safeParse(req.body);
|
|
472
|
+
if (!parsed.success) {
|
|
473
|
+
res.status(400).json({
|
|
474
|
+
error: "Invalid mail payload",
|
|
475
|
+
issues: parsed.error.issues,
|
|
476
|
+
});
|
|
243
477
|
return;
|
|
244
478
|
}
|
|
245
|
-
|
|
246
|
-
|
|
479
|
+
const { header, mail } = parsed.data;
|
|
480
|
+
await this.db.saveMail(mail, header, senderDeviceDetails.deviceID, authorUserDetails.userID);
|
|
481
|
+
this.log.info("Received mail for " + mail.recipient);
|
|
482
|
+
const recipientDeviceDetails = await this.db.retrieveDevice(mail.recipient);
|
|
483
|
+
if (!recipientDeviceDetails) {
|
|
484
|
+
res.sendStatus(400);
|
|
247
485
|
return;
|
|
248
486
|
}
|
|
487
|
+
res.sendStatus(200);
|
|
488
|
+
this.notify(recipientDeviceDetails.owner, "mail", crypto.randomUUID(), null, mail.recipient);
|
|
489
|
+
});
|
|
490
|
+
this.api.post("/auth", authLimiter, async (req, res) => {
|
|
491
|
+
const parsed = authPayload.safeParse(req.body);
|
|
492
|
+
if (!parsed.success) {
|
|
493
|
+
res.status(400).json({
|
|
494
|
+
error: "Invalid credentials format",
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const { password, username } = parsed.data;
|
|
249
499
|
try {
|
|
250
|
-
const userEntry =
|
|
500
|
+
const userEntry = await this.db.retrieveUser(username);
|
|
251
501
|
if (!userEntry) {
|
|
252
502
|
res.sendStatus(404);
|
|
253
503
|
this.log.warn("User does not exist.");
|
|
254
504
|
return;
|
|
255
505
|
}
|
|
256
|
-
const salt =
|
|
257
|
-
const payloadHash =
|
|
506
|
+
const salt = XUtils.decodeHex(userEntry.passwordSalt);
|
|
507
|
+
const payloadHash = XUtils.encodeHex(hashPassword(password, salt));
|
|
258
508
|
if (payloadHash !== userEntry.passwordHash) {
|
|
259
509
|
res.sendStatus(401);
|
|
260
510
|
return;
|
|
261
511
|
}
|
|
262
|
-
const token =
|
|
263
|
-
|
|
264
|
-
|
|
512
|
+
const token = jwt.sign({ user: censorUser(userEntry) }, getJwtSecret(), { expiresIn: JWT_EXPIRY });
|
|
513
|
+
// just to make sure
|
|
514
|
+
jwt.verify(token, getJwtSecret());
|
|
515
|
+
res.send(msgpack.encode({ token, user: censorUser(userEntry) }));
|
|
265
516
|
}
|
|
266
517
|
catch (err) {
|
|
267
|
-
|
|
518
|
+
this.log.error(String(err));
|
|
268
519
|
res.sendStatus(500);
|
|
269
520
|
}
|
|
270
|
-
}));
|
|
271
|
-
this.api.post("/register/key", (req, res) => {
|
|
272
|
-
try {
|
|
273
|
-
this.log.info("New regkey requested.");
|
|
274
|
-
const regKey = this.createActionToken(TokenScopes.Register);
|
|
275
|
-
this.log.info("New regkey created: " + regKey.key);
|
|
276
|
-
setTimeout(() => {
|
|
277
|
-
this.deleteActionToken(regKey);
|
|
278
|
-
}, exports.TOKEN_EXPIRY);
|
|
279
|
-
return res.status(201).send(regKey);
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
this.log.error(err.toString());
|
|
283
|
-
return res.sendStatus(500);
|
|
284
|
-
}
|
|
285
521
|
});
|
|
286
|
-
|
|
287
|
-
this.api.post("/register/new", (req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
522
|
+
this.api.post("/register", authLimiter, async (req, res) => {
|
|
288
523
|
try {
|
|
289
|
-
const
|
|
524
|
+
const regParsed = RegistrationPayloadSchema.safeParse(req.body);
|
|
525
|
+
if (!regParsed.success) {
|
|
526
|
+
res.status(400).json({
|
|
527
|
+
error: "Invalid registration payload",
|
|
528
|
+
issues: regParsed.error.issues,
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const regPayload = regParsed.data;
|
|
290
533
|
if (!usernameRegex.test(regPayload.username)) {
|
|
291
534
|
res.status(400).send({
|
|
292
535
|
error: "Username must be between three and nineteen letters, digits, or underscores.",
|
|
293
536
|
});
|
|
294
537
|
return;
|
|
295
538
|
}
|
|
296
|
-
const regKey =
|
|
539
|
+
const regKey = xSignOpen(XUtils.decodeHex(regPayload.signed), XUtils.decodeHex(regPayload.signKey));
|
|
297
540
|
if (regKey &&
|
|
298
|
-
this.validateToken(
|
|
299
|
-
const [user, err] =
|
|
541
|
+
this.validateToken(uuidStringify(regKey), TokenScopes.Register)) {
|
|
542
|
+
const [user, err] = await this.db.createUser(regKey, regPayload);
|
|
300
543
|
if (err !== null) {
|
|
301
|
-
|
|
544
|
+
const errCode = "code" in err && typeof err.code === "string"
|
|
545
|
+
? err.code
|
|
546
|
+
: undefined;
|
|
547
|
+
switch (errCode) {
|
|
302
548
|
case "ER_DUP_ENTRY":
|
|
303
|
-
const usernameConflict = err
|
|
304
|
-
|
|
305
|
-
.includes("users_username_unique");
|
|
306
|
-
const signKeyConflict = err
|
|
307
|
-
.toString()
|
|
308
|
-
.includes("users_signkey_unique");
|
|
549
|
+
const usernameConflict = String(err).includes("users_username_unique");
|
|
550
|
+
const signKeyConflict = String(err).includes("users_signkey_unique");
|
|
309
551
|
this.log.warn("User attempted to register duplicate account.");
|
|
310
552
|
if (usernameConflict) {
|
|
311
553
|
res.status(400).send({
|
|
@@ -325,15 +567,19 @@ class Spire extends events_1.EventEmitter {
|
|
|
325
567
|
break;
|
|
326
568
|
default:
|
|
327
569
|
this.log.info("Unsupported sql error type: " +
|
|
328
|
-
|
|
329
|
-
this.log.error(err);
|
|
570
|
+
String(errCode));
|
|
571
|
+
this.log.error(String(err));
|
|
330
572
|
res.sendStatus(500);
|
|
331
573
|
break;
|
|
332
574
|
}
|
|
333
575
|
}
|
|
334
576
|
else {
|
|
335
577
|
this.log.info("Registration success.");
|
|
336
|
-
|
|
578
|
+
if (!user) {
|
|
579
|
+
res.sendStatus(500);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
res.send(msgpack.encode(censorUser(user)));
|
|
337
583
|
}
|
|
338
584
|
}
|
|
339
585
|
else {
|
|
@@ -343,13 +589,67 @@ class Spire extends events_1.EventEmitter {
|
|
|
343
589
|
}
|
|
344
590
|
}
|
|
345
591
|
catch (err) {
|
|
346
|
-
this.log.error("error registering user: " + err
|
|
592
|
+
this.log.error("error registering user: " + String(err));
|
|
347
593
|
res.sendStatus(500);
|
|
348
594
|
}
|
|
349
|
-
})
|
|
595
|
+
});
|
|
350
596
|
this.server = this.api.listen(apiPort, () => {
|
|
351
|
-
this.log.info("API started on port " + apiPort
|
|
597
|
+
this.log.info("API started on port " + String(apiPort));
|
|
598
|
+
});
|
|
599
|
+
// Accept all WS upgrades — auth happens post-connection.
|
|
600
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
601
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
602
|
+
this.wss.emit("connection", ws);
|
|
603
|
+
});
|
|
352
604
|
});
|
|
353
605
|
}
|
|
606
|
+
notify(userID, event, transmissionID, data, deviceID) {
|
|
607
|
+
for (const client of this.clients) {
|
|
608
|
+
if (deviceID) {
|
|
609
|
+
if (client.getDevice().deviceID === deviceID) {
|
|
610
|
+
const msg = {
|
|
611
|
+
data,
|
|
612
|
+
event,
|
|
613
|
+
transmissionID,
|
|
614
|
+
type: "notify",
|
|
615
|
+
};
|
|
616
|
+
client.send(msg);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
if (client.getUser().userID === userID) {
|
|
621
|
+
const msg = {
|
|
622
|
+
data,
|
|
623
|
+
event,
|
|
624
|
+
transmissionID,
|
|
625
|
+
type: "notify",
|
|
626
|
+
};
|
|
627
|
+
client.send(msg);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
validateToken(key, scope) {
|
|
633
|
+
this.log.info("Validating token: " + key);
|
|
634
|
+
for (const rKey of this.actionTokens) {
|
|
635
|
+
if (rKey.key === key) {
|
|
636
|
+
if (rKey.scope !== scope) {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const age = Date.now() - new Date(rKey.time).getTime();
|
|
640
|
+
this.log.info("Token found, " + String(age) + " ms old.");
|
|
641
|
+
if (age < TOKEN_EXPIRY) {
|
|
642
|
+
this.log.info("Token is valid.");
|
|
643
|
+
this.deleteActionToken(rKey);
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
this.log.info("Token is expired.");
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
this.log.info("Token not found.");
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
354
654
|
}
|
|
355
|
-
|
|
655
|
+
//# sourceMappingURL=Spire.js.map
|