addition of new dashboard
This commit is contained in:
96
backend/http/agreements.js
Normal file
96
backend/http/agreements.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { getPool } from "../db/index.js";
|
||||
import { marked } from "marked";
|
||||
|
||||
async function getAgreementContent(userId, hash) {
|
||||
let content = '';
|
||||
const client = await getPool();
|
||||
try {
|
||||
let sql = `
|
||||
SELECT markdown
|
||||
FROM agreement_content
|
||||
WHERE hash = $1
|
||||
`;
|
||||
let values = [hash];
|
||||
if (userId) {
|
||||
sql += `AND user_id = $2`;
|
||||
values.push(userId);
|
||||
} else {
|
||||
sql += `AND user_id IS NULL`;
|
||||
}
|
||||
const res = await client.query(sql, values);
|
||||
if (res.rows.length > 0 && res.rows[0].markdown) {
|
||||
content = res.rows[0].markdown;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export async function getAgreements(userId) {
|
||||
let agreements = [];
|
||||
const client = await getPool();
|
||||
try {
|
||||
let sql = `
|
||||
SELECT
|
||||
title,
|
||||
hash
|
||||
FROM agreement_content
|
||||
WHERE user_id IS null
|
||||
`;
|
||||
let values = [];
|
||||
if (userId) {
|
||||
sql += `
|
||||
OR user_id = $1
|
||||
`;
|
||||
values.push(userId);
|
||||
}
|
||||
sql += `
|
||||
ORDER BY title ASC
|
||||
`
|
||||
const res = await client.query(sql, values);
|
||||
if (res.rows.length > 0) {
|
||||
agreements = res.rows;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return agreements;
|
||||
}
|
||||
|
||||
export function routeAgreements(app) {
|
||||
|
||||
app.get('/agreements/:hash', async (req, res) => {
|
||||
const { hash } = req.params;
|
||||
const agreement = await getAgreementContent(null, hash);
|
||||
res.render('agreement', {
|
||||
agreement: marked(agreement),
|
||||
rawUrl: `/agreements/${hash}/raw`,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/agreements/:hash/raw', async (req, res) => {
|
||||
const { hash } = req.params;
|
||||
const agreement = await getAgreementContent(null, hash);
|
||||
res.send(`<pre>${agreement}</pre>`);
|
||||
});
|
||||
|
||||
app.get('/agreements/:userId/:hash', async (req, res) => {
|
||||
const { userId, hash } = req.params;
|
||||
const agreement = await getAgreementContent(userId, hash);
|
||||
res.render('agreement', {
|
||||
agreement: marked(agreement),
|
||||
rawUrl: `/agreements/${hash}/raw`,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/agreements/:userId/:hash/raw', async (req, res) => {
|
||||
const { hash } = req.params;
|
||||
const agreement = await getAgreementContent(userId, hash);
|
||||
res.send(`<pre>${agreement}</pre>`);
|
||||
});
|
||||
}
|
||||
108
backend/http/api/v1/swagger.json
Normal file
108
backend/http/api/v1/swagger.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"version": "1",
|
||||
"title": "JLINC API",
|
||||
"description": "Version 1 API for the JLINC server."
|
||||
},
|
||||
"basePath": "/api/v1",
|
||||
"schemes": ["https"],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Synchronization",
|
||||
"description": "Operations related to the synchronization"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/sync/update": {
|
||||
"post": {
|
||||
"tags": ["Synchronization"],
|
||||
"summary": "Update device settings",
|
||||
"description": "Updates the settings of a specified device.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"default": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deviceName": {
|
||||
"type": "string",
|
||||
"description": "The name of the device."
|
||||
},
|
||||
"savedTs": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Timestamp when the settings were saved, in ISO 8601 format."
|
||||
},
|
||||
"settings": {
|
||||
"type": "string",
|
||||
"description": "String representing the device settings."
|
||||
}
|
||||
},
|
||||
"required": ["deviceName", "savedTs", "settings"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful update",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if the update was successful",
|
||||
"example": true
|
||||
},
|
||||
"data": {
|
||||
"type": "string",
|
||||
"description": "The most up to date settings data",
|
||||
"example": "eyAic2V0dGluZ..."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "What action should be taken",
|
||||
"enum": ["created", "none", "existingNewer", "incomingNewer"],
|
||||
"example": "created"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error in the request, such as invalid signature",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if the update was successful",
|
||||
"example": false
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message explaining what went wrong"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
backend/http/apiKeys.js
Normal file
50
backend/http/apiKeys.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { fail, sessionRoute } from "../common/http.js";
|
||||
import {
|
||||
listKeys as defaultList,
|
||||
generateKey as defaultGenerate,
|
||||
revokeKey as defaultRevoke,
|
||||
} from "../modules/core/apiKey.js";
|
||||
|
||||
const requireUser = (fn) => sessionRoute("apikeys", fn);
|
||||
|
||||
// Internal modules have no `app` row and issue no billable calls, so no key.
|
||||
const mintableModules = () =>
|
||||
Object.entries(getConfig().appModules)
|
||||
.filter(([, m]) => !m?.internal)
|
||||
.map(([type]) => type);
|
||||
|
||||
export function apiKeyHandlers({
|
||||
listKeys = defaultList,
|
||||
generateKey = defaultGenerate,
|
||||
revokeKey = defaultRevoke,
|
||||
} = {}) {
|
||||
return {
|
||||
whoami: requireUser(async (req, res) => {
|
||||
res.json({ username: req.user.username, canViewJson: req.user.canViewJson, modules: mintableModules() });
|
||||
}),
|
||||
|
||||
list: requireUser(async (req, res) => {
|
||||
res.json(await listKeys(req.user.id));
|
||||
}),
|
||||
|
||||
create: requireUser(async (req, res) => {
|
||||
const { type, label, expiresTs } = req.body || {};
|
||||
if (!mintableModules().includes(type)) {
|
||||
return fail(res, 400, "bad_request", "unknown module");
|
||||
}
|
||||
if (expiresTs != null && (typeof expiresTs !== "string" || Number.isNaN(Date.parse(expiresTs)))) {
|
||||
return fail(res, 400, "bad_request", "invalid expiresTs (expected an ISO date string)");
|
||||
}
|
||||
// Returns the raw key ONCE — the client must surface it immediately.
|
||||
res.json(await generateKey(req.user.id, type, { label: label || null, expiresTs: expiresTs || null }));
|
||||
}),
|
||||
|
||||
revoke: requireUser(async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return fail(res, 400, "bad_request", "invalid id");
|
||||
const ok = await revokeKey(req.user.id, id);
|
||||
res.status(ok ? 200 : 404).json({ ok });
|
||||
}),
|
||||
};
|
||||
}
|
||||
95
backend/http/apiKeys.test.js
Normal file
95
backend/http/apiKeys.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { apiKeyHandlers } from "./apiKeys.js";
|
||||
|
||||
// getConfig() is a mutable singleton — point its enabled modules at a known set.
|
||||
// `dashboard` is enabled but internal, so it must NOT appear as a mintable module.
|
||||
beforeAll(() => {
|
||||
getConfig().appModules = { core: {}, archive: {}, dashboard: { internal: true } };
|
||||
});
|
||||
|
||||
function makeApp({ user = { id: 1, username: "dev" }, store } = {}) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
if (user) req.user = user;
|
||||
next();
|
||||
});
|
||||
const h = apiKeyHandlers(store);
|
||||
app.get("/api/dashboard/auth/whoami", h.whoami);
|
||||
app.get("/api/dashboard/auth/keys", h.list);
|
||||
app.post("/api/dashboard/auth/keys", express.json(), h.create);
|
||||
app.delete("/api/dashboard/auth/keys/:id", h.revoke);
|
||||
return app;
|
||||
}
|
||||
|
||||
const okStore = () => ({
|
||||
listKeys: jest.fn(async () => [{ id: 1, appType: "core", prefix: "ab12cd34", label: "ci" }]),
|
||||
generateKey: jest.fn(async (_uid, type) => ({ id: 9, key: "rawsecret", prefix: "rawsecre", appType: type })),
|
||||
revokeKey: jest.fn(async (_uid, id) => id === 9),
|
||||
});
|
||||
|
||||
describe("API key management routes", () => {
|
||||
it("401s when not logged in", async () => {
|
||||
const res = await request(makeApp({ user: null, store: okStore() })).get("/api/dashboard/auth/keys");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error.code).toBe("unauthorized");
|
||||
});
|
||||
|
||||
it("whoami returns username + mintable modules (excludes internal dashboard)", async () => {
|
||||
const res = await request(makeApp({ store: okStore() })).get("/api/dashboard/auth/whoami");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.username).toBe("dev");
|
||||
expect(res.body.modules).toEqual(expect.arrayContaining(["core", "archive"]));
|
||||
expect(res.body.modules).not.toContain("dashboard");
|
||||
});
|
||||
|
||||
it("rejects generating a key for an internal module (dashboard)", async () => {
|
||||
const store = okStore();
|
||||
const res = await request(makeApp({ store })).post("/api/dashboard/auth/keys").send({ type: "dashboard" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(store.generateKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the user's keys (metadata only)", async () => {
|
||||
const store = okStore();
|
||||
const res = await request(makeApp({ store })).get("/api/dashboard/auth/keys");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0]).toMatchObject({ prefix: "ab12cd34" });
|
||||
expect(store.listKeys).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("generates a key for an enabled module and returns the raw key once", async () => {
|
||||
const store = okStore();
|
||||
const res = await request(makeApp({ store }))
|
||||
.post("/api/dashboard/auth/keys")
|
||||
.send({ type: "core", label: "ci" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.key).toBe("rawsecret");
|
||||
expect(store.generateKey).toHaveBeenCalledWith(1, "core", { label: "ci", expiresTs: null });
|
||||
});
|
||||
|
||||
it("rejects generating a key for a module that is not enabled", async () => {
|
||||
const store = okStore();
|
||||
const res = await request(makeApp({ store })).post("/api/dashboard/auth/keys").send({ type: "nope" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(store.generateKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a malformed expiresTs with 400 (not a DB-level 500)", async () => {
|
||||
const store = okStore();
|
||||
const res = await request(makeApp({ store }))
|
||||
.post("/api/dashboard/auth/keys")
|
||||
.send({ type: "core", expiresTs: "not-a-date" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(store.generateKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes a key (200 when deleted, 404 when not)", async () => {
|
||||
const store = okStore();
|
||||
const app = makeApp({ store });
|
||||
expect((await request(app).delete("/api/dashboard/auth/keys/9")).status).toBe(200);
|
||||
expect((await request(app).delete("/api/dashboard/auth/keys/5")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
199
backend/http/auth.js
Normal file
199
backend/http/auth.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { getPool } from "../db/index.js";
|
||||
import { verifyKey } from "../modules/core/apiKey.js";
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Bearer-token auth for the central /api/v1/* API. On success it sets
|
||||
// req.session.user_id (which the core router scopes queries by). It does NOT
|
||||
// populate req.user — the browser session routes (passport) own that — so the
|
||||
// session-authed dashboard/key-management routes remain login-only by design.
|
||||
export async function apiMiddleware(req, res, next) {
|
||||
let success = false;
|
||||
try {
|
||||
const apiKey = req.headers['authorization']?.split(' ')[1];
|
||||
if (apiKey) {
|
||||
// Preferred: the hashed multi-key store (with a short in-memory cache).
|
||||
const found = await verifyKey(apiKey);
|
||||
if (found) {
|
||||
req.session.user_id = found.user_id;
|
||||
success = true;
|
||||
} else {
|
||||
// Fallback: the legacy single plaintext key in `auth` (issued at login).
|
||||
const client = await getPool();
|
||||
try {
|
||||
const r = await client.query(
|
||||
`SELECT au.user_id FROM public.auth au WHERE au.api_key = $1`,
|
||||
[apiKey],
|
||||
);
|
||||
if (r.rowCount > 0) {
|
||||
req.session.user_id = r.rows[0].user_id;
|
||||
success = true;
|
||||
}
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
if (!success)
|
||||
return res.status(401).json({ error: 'API key is invalid' });
|
||||
next();
|
||||
}
|
||||
|
||||
export function getNewKey(user) {
|
||||
const seed = `${user.issuer}:${user.identifier}:${user.id}:${crypto.randomBytes(16).toString('hex')}`;
|
||||
const hash = crypto.createHash('sha256').update(seed).digest();
|
||||
const apiKey = hash.toString('hex');
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function createApiKeys(client, user, issuer) {
|
||||
const config = getConfig();
|
||||
for (const type in config.appModules) {
|
||||
// Internal modules (e.g. dashboard) have no `app` row and mint no keys.
|
||||
if (config.appModules[type]?.internal) continue;
|
||||
const apiKey = issuer !== 'https://single'
|
||||
? getNewKey(user)
|
||||
: config.authModules.single[type]
|
||||
await client.query(`
|
||||
INSERT INTO public.auth (
|
||||
user_id,
|
||||
app_id,
|
||||
api_key
|
||||
) VALUES (
|
||||
$1,
|
||||
(SELECT id FROM public.app WHERE type = $2),
|
||||
$3
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
user.id,
|
||||
type,
|
||||
apiKey,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIfUserExists(client, issuer, identifier) {
|
||||
return await client.query(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.photo,
|
||||
u.username
|
||||
FROM public.user u
|
||||
WHERE u.identifier = $1
|
||||
AND u.issuer = $2
|
||||
`,
|
||||
[
|
||||
identifier,
|
||||
issuer,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getUser(client, issuer, identifier) {
|
||||
const res = await client.query(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.photo,
|
||||
u.issuer,
|
||||
u.identifier,
|
||||
COALESCE(ut.can_view_json, FALSE) AS "canViewJson",
|
||||
json_agg(
|
||||
jsonb_build_object(
|
||||
'id', a.id,
|
||||
'type', a.type,
|
||||
'apiKey', au.api_key
|
||||
)
|
||||
) AS apps
|
||||
FROM public.user u
|
||||
INNER JOIN public.auth au ON u.id = au.user_id
|
||||
INNER JOIN public.app a ON au.app_id = a.id
|
||||
LEFT JOIN public.user_type ut ON ut.id = u.user_type_id
|
||||
WHERE u.identifier = $1
|
||||
AND u.issuer = $2
|
||||
GROUP BY
|
||||
u.id,
|
||||
u.username,
|
||||
u.photo,
|
||||
u.issuer,
|
||||
u.identifier,
|
||||
ut.can_view_json
|
||||
`,
|
||||
[
|
||||
identifier,
|
||||
issuer,
|
||||
]);
|
||||
if (res.rowCount > 0) {
|
||||
const user = res.rows[0];
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function checkUser(type, issuer, identifier, username, photo) {
|
||||
const client = await getPool();
|
||||
let userExists = await checkIfUserExists(client, issuer, identifier);
|
||||
if (userExists.rowCount === 0) {
|
||||
if (!username || username === '') {
|
||||
username = identifier;
|
||||
}
|
||||
// New users default to the least-privileged role; an administrator
|
||||
// promotes them. Existing users keep whatever the DB already holds
|
||||
// (see the 000019 backfill) — checkUser never overwrites an existing type.
|
||||
const res = await client.query(`
|
||||
INSERT INTO public.user (
|
||||
username,
|
||||
issuer,
|
||||
identifier,
|
||||
photo,
|
||||
type,
|
||||
user_type_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
(SELECT id FROM public.user_type WHERE value = 'standard')
|
||||
) RETURNING id;
|
||||
`, [
|
||||
username,
|
||||
issuer,
|
||||
identifier,
|
||||
photo,
|
||||
type,
|
||||
]);
|
||||
if (res.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
await createApiKeys(client, res.rows[0], issuer);
|
||||
} else {
|
||||
if (photo !== userExists.rows[0].photo || username != userExists.rows[0].username) {
|
||||
await client.query(`
|
||||
UPDATE public.user SET
|
||||
photo = $1,
|
||||
username = $2,
|
||||
updated_ts = NOW()
|
||||
WHERE id = $3
|
||||
`, [
|
||||
userExists.rows[0].photo,
|
||||
userExists.rows[0].username,
|
||||
userExists.rows[0].id,
|
||||
]);
|
||||
}
|
||||
await createApiKeys(client, userExists.rows[0], issuer);
|
||||
}
|
||||
const user = await getUser(client, issuer, identifier);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function initModules(app, passport) {
|
||||
const config = getConfig();
|
||||
for (const authModule of Object.keys(config.authModules)) {
|
||||
const authModulePath = `../modules/auth/${authModule}.js`;
|
||||
const { initModule } = await import(authModulePath);
|
||||
await initModule(app, passport);
|
||||
}
|
||||
}
|
||||
61
backend/http/auth.test.js
Normal file
61
backend/http/auth.test.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
// Manual mocks: the API-key verifier and the DB pool. No real database, no scrypt
|
||||
// — apiMiddleware's branching is what we exercise here.
|
||||
const verifyKey = jest.fn();
|
||||
const query = jest.fn();
|
||||
const release = jest.fn(async () => {});
|
||||
jest.unstable_mockModule("../modules/core/apiKey.js", () => ({ verifyKey }));
|
||||
jest.unstable_mockModule("../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
|
||||
|
||||
const { apiMiddleware } = await import("./auth.js");
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.session = {}; next(); });
|
||||
app.get("/probe", apiMiddleware, (req, res) => res.json({ userId: req.session.user_id }));
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
verifyKey.mockReset();
|
||||
query.mockReset();
|
||||
release.mockClear();
|
||||
});
|
||||
|
||||
describe("apiMiddleware", () => {
|
||||
it("authenticates a valid hashed key and sets req.session.user_id (no legacy lookup)", async () => {
|
||||
verifyKey.mockResolvedValue({ user_id: 42, app_id: 1 });
|
||||
const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer good-key");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.userId).toBe(42);
|
||||
expect(verifyKey).toHaveBeenCalledWith("good-key");
|
||||
expect(query).not.toHaveBeenCalled(); // hashed path short-circuits the legacy fallback
|
||||
});
|
||||
|
||||
it("falls back to the legacy plaintext auth key when the hashed store misses", async () => {
|
||||
verifyKey.mockResolvedValue(null);
|
||||
query.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 7 }] });
|
||||
const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer legacy-key");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.userId).toBe(7);
|
||||
expect(query.mock.calls[0][1]).toEqual(["legacy-key"]);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an unknown key with 401", async () => {
|
||||
verifyKey.mockResolvedValue(null);
|
||||
query.mockResolvedValue({ rowCount: 0, rows: [] });
|
||||
const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer bogus");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a request with no Authorization header with 401 (no lookups)", async () => {
|
||||
const res = await request(makeApp()).get("/probe");
|
||||
expect(res.status).toBe(401);
|
||||
expect(verifyKey).not.toHaveBeenCalled();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
34
backend/http/dashboard.js
Normal file
34
backend/http/dashboard.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { service as defaultService } from "../modules/dashboard/service.js";
|
||||
import { makeDashboardApi } from "../modules/dashboard/api.js";
|
||||
import { fail, sessionRoute } from "../common/http.js";
|
||||
|
||||
const requireUser = (fn) => sessionRoute("dashboard", fn);
|
||||
|
||||
// The browser gets the bare payload; the { message, data } envelope is for the
|
||||
// API-key router.
|
||||
const send = (res, { status, data }) =>
|
||||
status === 200
|
||||
? res.json(data)
|
||||
: fail(res, status, status === 404 ? "not_found" : "bad_request", data?.error);
|
||||
|
||||
export function dashboardHandlers({ service = defaultService } = {}) {
|
||||
const api = makeDashboardApi(service);
|
||||
const route = (fn) => requireUser(async (req, res) => send(res, await fn(req)));
|
||||
return {
|
||||
summary: route((req) => api.summary(req.query, req.user.id)),
|
||||
transactions: route((req) => api.transactions(req.query, req.user.id)),
|
||||
series: route((req) => api.series(req.query, req.user.id)),
|
||||
verify: route((req) => api.verify(req.query, req.user.id)),
|
||||
// View JSON exposes the raw payload (possibly private); the can_view_json
|
||||
// capability comes from the user's role in the DB (see getUser).
|
||||
event: requireUser(async (req, res) => {
|
||||
if (!req.user.canViewJson) return fail(res, 403, "forbidden", "not permitted to view JSON");
|
||||
send(res, await api.event({ eventUuid: req.params.eventUuid }, req.user.id));
|
||||
}),
|
||||
agreement: route((req) => api.agreement({ agreementUuid: req.params.agreementUuid }, req.user.id)),
|
||||
details: requireUser(async (req, res) => {
|
||||
if (!req.user.canViewJson) return fail(res, 403, "forbidden", "not permitted to view JSON");
|
||||
send(res, await api.details({ eventUuid: req.params.eventUuid }, req.user.id));
|
||||
})
|
||||
};
|
||||
}
|
||||
169
backend/http/dashboard.test.js
Normal file
169
backend/http/dashboard.test.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { dashboardHandlers } from "./dashboard.js";
|
||||
|
||||
// Mount the dashboard routes on a bare app with an injectable fake service and a
|
||||
// settable user, so we can test auth, validation, response shaping, and the
|
||||
// error envelope without a database.
|
||||
function makeApp({ user = { id: 1, canViewJson: true }, service } = {}) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
if (user) req.user = user;
|
||||
next();
|
||||
});
|
||||
const h = dashboardHandlers({ service });
|
||||
app.get("/api/dashboard/summary", h.summary);
|
||||
app.get("/api/dashboard/transactions", h.transactions);
|
||||
app.get("/api/dashboard/series", h.series);
|
||||
app.get("/api/dashboard/verify", h.verify);
|
||||
app.get("/api/dashboard/transactions/:eventUuid", h.event);
|
||||
return app;
|
||||
}
|
||||
|
||||
const okService = () => ({
|
||||
summary: jest.fn(async () => ({ apiUsage: { core: 2, audit: 1, total: 3 }, apiKeys: 4, endpoints: 2, storage: {} })),
|
||||
transactions: jest.fn(async (_uid, opts) => ({ rows: [{ eventUuid: "e1" }], total: 1, limit: opts.limit, offset: opts.offset })),
|
||||
series: jest.fn(async () => ({
|
||||
usage: [{ date: "2026-06-01", core: 1, audit: 0, total: 1 }],
|
||||
storage: [{ date: "2026-06-01", coreDisk: 10, auditDisk: 0, totalDisk: 10 }],
|
||||
})),
|
||||
event: jest.fn(async () => ({ eventId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", data: { a: 1 } })),
|
||||
verify: jest.fn(async (_uid, { eventUuids, auditIds }) => ({
|
||||
results: [
|
||||
...eventUuids.map((eventUuid) => ({ eventUuid, auditId: null, status: "verified", verified: true })),
|
||||
...auditIds.map((auditId) => ({ eventUuid: "e1", auditId, status: "verified", verified: true })),
|
||||
],
|
||||
})),
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/* auth", () => {
|
||||
it("returns 401 with an error envelope when not logged in", async () => {
|
||||
const res = await request(makeApp({ user: null, service: okService() })).get("/api/dashboard/summary");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: { code: "unauthorized", message: expect.any(String) } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary", () => {
|
||||
it("calls the service with the user id + window and returns the payload", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/summary?from=2026-01-01&to=2026-01-31");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.apiUsage).toEqual({ core: 2, audit: 1, total: 3 });
|
||||
expect(service.summary).toHaveBeenCalledWith(1, { from: "2026-01-01", to: "2026-01-31" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/transactions", () => {
|
||||
it("passes validated/normalized options to the service", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/transactions?type=audit&sort=size&dir=asc&limit=10");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ total: 1, limit: 10, offset: 0 });
|
||||
expect(service.transactions).toHaveBeenCalledWith(1, expect.objectContaining({ type: "audit", sort: "size", dir: "asc", limit: 10 }));
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid type and does not call the service", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/transactions?type=evil");
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe("bad_request");
|
||||
expect(service.transactions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for an unknown sort key (injection guard)", async () => {
|
||||
const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions?sort=sender;DROP");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/series (combined usage + storage)", () => {
|
||||
it("returns both usage and storage series for the window in one response", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/series?from=2026-06-01&to=2026-06-03");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.usage).toHaveLength(1);
|
||||
expect(res.body.storage[0]).toMatchObject({ totalDisk: 10 });
|
||||
expect(service.series).toHaveBeenCalledWith(1, { from: "2026-06-01", to: "2026-06-03" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/verify", () => {
|
||||
const good = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
|
||||
|
||||
it("verifies only the valid UUIDs from the ids list (core tab)", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get(`/api/dashboard/verify?ids=${good},junk`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [good], auditIds: [] });
|
||||
});
|
||||
|
||||
// The audit tab targets audit RECORDS: two rows of one event must be two
|
||||
// separate targets, or verifying one reports a verdict on both.
|
||||
it("passes audit ids straight through as their own targets (audit tab)", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/verify?auditIds=9,10");
|
||||
expect(res.status).toBe(200);
|
||||
expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [], auditIds: [9, 10] });
|
||||
expect(res.body.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty results when no valid targets are supplied", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/verify?ids=junk&auditIds=nope");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([]);
|
||||
expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [], auditIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/transactions/:eventUuid", () => {
|
||||
it("400s on a non-uuid id", async () => {
|
||||
const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions/not-a-uuid");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("404s when the event is not found", async () => {
|
||||
const service = okService();
|
||||
service.event = jest.fn(async () => null);
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error.code).toBe("not_found");
|
||||
});
|
||||
|
||||
it("returns the record when found", async () => {
|
||||
const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("403s for a user without View JSON, before touching the service", async () => {
|
||||
const service = okService();
|
||||
const res = await request(makeApp({ user: { id: 1, canViewJson: false }, service }))
|
||||
.get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error.code).toBe("forbidden");
|
||||
expect(service.event).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a user with View JSON", async () => {
|
||||
const res = await request(makeApp({ user: { id: 1, canViewJson: true }, service: okService() }))
|
||||
.get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error envelope on a thrown service error", () => {
|
||||
it("returns 500 with the envelope and logs", async () => {
|
||||
const service = okService();
|
||||
service.summary = jest.fn(async () => { throw new Error("boom"); });
|
||||
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
const res = await request(makeApp({ service })).get("/api/dashboard/summary");
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toEqual({ error: { code: "internal", message: "Internal error" } });
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
100
backend/http/dashboardPassthrough.js
Normal file
100
backend/http/dashboardPassthrough.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { service as defaultService } from "../modules/dashboard/service.js";
|
||||
import { getConfig } from "../common/config.js";
|
||||
|
||||
// Same interface as the local service, so it is a drop-in for it.
|
||||
//
|
||||
// Tenant scoping: locally, every call carries the session's own user id.
|
||||
// Remotely, the API KEY is the scope — we deliberately send NO user id, since a
|
||||
// local primary key is meaningless on another operator and must never be
|
||||
// trusted as a cross-operator identity.
|
||||
export function makePassthrough({ service = defaultService, config = getConfig(), fetchImpl = fetch } = {}) {
|
||||
const sources = () => config.dashboard || { core: {}, audit: {} };
|
||||
const remote = (s) => sources()[s] && sources()[s].url;
|
||||
const federated = () => Boolean(remote("core") || remote("audit"));
|
||||
|
||||
async function fromSource(source, endpoint, params, localCall) {
|
||||
const src = sources()[source];
|
||||
if (!src || !src.url) return localCall();
|
||||
const res = await fetchImpl(`${src.url}/api/v1/data/dashboard/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${src.key}` },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
if (!res.ok) throw new Error(`federation: ${source}/${endpoint} -> ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const num = (v) => Number(v) || 0;
|
||||
|
||||
function mergeSummary(core, audit) {
|
||||
const cu = core.apiUsage || {}, au = audit.apiUsage || {};
|
||||
const cs = core.storage || {}, as = audit.storage || {};
|
||||
const split = (c, a) => ({ core: num(c), audit: num(a), total: num(c) + num(a) });
|
||||
return {
|
||||
apiUsage: split(cu.core, au.audit),
|
||||
apiKeys: core.apiKeys,
|
||||
endpoints: core.endpoints,
|
||||
storage: {
|
||||
onDisk: split(cs.onDisk?.core, as.onDisk?.audit),
|
||||
logical: split(cs.logical?.core, as.logical?.audit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeByDate(coreSeries = [], auditSeries = [], coreKey, auditKey, totalKey) {
|
||||
const auditByDate = new Map(auditSeries.map((r) => [r.date, r]));
|
||||
return coreSeries.map((r) => {
|
||||
const a = auditByDate.get(r.date) || {};
|
||||
const core = num(r[coreKey]);
|
||||
const audit = num(a[auditKey]);
|
||||
return { date: r.date, [coreKey]: core, [auditKey]: audit, [totalKey]: core + audit };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async summary(userId, window) {
|
||||
if (!federated()) return service.summary(userId, window);
|
||||
const [core, audit] = await Promise.all([
|
||||
fromSource("core", "summary", window, () => service.summary(userId, window)),
|
||||
fromSource("audit", "summary", window, () => service.summary(userId, window)),
|
||||
]);
|
||||
return mergeSummary(core, audit);
|
||||
},
|
||||
|
||||
async transactions(userId, opts) {
|
||||
const source = opts.type === "audit" ? "audit" : "core";
|
||||
return fromSource(source, "transactions", opts, () => service.transactions(userId, opts));
|
||||
},
|
||||
|
||||
async series(userId, window) {
|
||||
if (!federated()) return service.series(userId, window);
|
||||
const [core, audit] = await Promise.all([
|
||||
fromSource("core", "series", window, () => service.series(userId, window)),
|
||||
fromSource("audit", "series", window, () => service.series(userId, window)),
|
||||
]);
|
||||
return {
|
||||
usage: mergeByDate(core.usage, audit.usage, "core", "audit", "total"),
|
||||
storage: mergeByDate(core.storage, audit.storage, "coreDisk", "auditDisk", "totalDisk"),
|
||||
};
|
||||
},
|
||||
|
||||
// Audit ids belong to the audit source that served the listing.
|
||||
async verify(userId, targets) {
|
||||
const { eventUuids = [], auditIds = [] } = targets || {};
|
||||
const params = { ids: eventUuids.join(","), auditIds: auditIds.join(",") };
|
||||
return fromSource("audit", "verify", params, () => service.verify(userId, targets));
|
||||
},
|
||||
|
||||
async event(userId, eventUuid) {
|
||||
return fromSource("core", "event", { eventUuid }, () => service.event(userId, eventUuid));
|
||||
},
|
||||
|
||||
async agreement(userId, agreementUuid) {
|
||||
return fromSource("core", "agreement", { agreementUuid }, () => service.agreement(userId, agreementUuid));
|
||||
},
|
||||
|
||||
async details(userId, eventUuid) {
|
||||
return fromSource("core", "details", { eventUuid }, () => service.details(userId, eventUuid))
|
||||
}
|
||||
};
|
||||
}
|
||||
97
backend/http/dashboardPassthrough.test.js
Normal file
97
backend/http/dashboardPassthrough.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { makePassthrough } from "./dashboardPassthrough.js";
|
||||
|
||||
const LOCAL = { core: { url: null, key: null }, audit: { url: null, key: null } };
|
||||
const FED = { core: { url: "http://core", key: "ck" }, audit: { url: "http://audit", key: "ak" } };
|
||||
|
||||
const localService = () => ({
|
||||
summary: jest.fn(async () => ({ apiUsage: { core: 2, audit: 1, total: 3 }, apiKeys: 4, endpoints: 2, storage: {} })),
|
||||
transactions: jest.fn(async () => ({ rows: [], total: 0 })),
|
||||
series: jest.fn(async () => ({ usage: [], storage: [] })),
|
||||
verify: jest.fn(async () => ({ results: [] })),
|
||||
event: jest.fn(async () => ({ eventId: "x" })),
|
||||
});
|
||||
|
||||
describe("passthrough — local mode (no remote sources)", () => {
|
||||
it("serves every endpoint from the local service and never fetches", async () => {
|
||||
const service = localService();
|
||||
const fetchImpl = jest.fn();
|
||||
const p = makePassthrough({ service, config: { dashboard: LOCAL }, fetchImpl });
|
||||
|
||||
expect(await p.summary(7, { from: "a", to: "b" })).toMatchObject({ apiKeys: 4 });
|
||||
await p.transactions(7, { type: "audit" });
|
||||
await p.series(7, {});
|
||||
expect(service.summary).toHaveBeenCalled();
|
||||
expect(service.transactions).toHaveBeenCalled();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes every local call to the caller's own user id (per-tenant isolation)", async () => {
|
||||
const service = localService();
|
||||
const p = makePassthrough({ service, config: { dashboard: LOCAL }, fetchImpl: jest.fn() });
|
||||
|
||||
await p.summary(7, { from: "a", to: "b" });
|
||||
await p.transactions(9, { type: "core" });
|
||||
await p.event(9, "uuid-1");
|
||||
|
||||
// The passthrough never substitutes another user's id — each call carries the
|
||||
// id it was handed (which the route sets from req.user.id), so user 7 can
|
||||
// never be served user 9's rows and vice versa.
|
||||
expect(service.summary).toHaveBeenCalledWith(7, { from: "a", to: "b" });
|
||||
expect(service.transactions.mock.calls[0][0]).toBe(9);
|
||||
expect(service.event).toHaveBeenCalledWith(9, "uuid-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("passthrough — federated mode", () => {
|
||||
const fetchImpl = jest.fn(async (url) => ({
|
||||
ok: true,
|
||||
json: async () =>
|
||||
url.includes("//core")
|
||||
? { apiUsage: { core: 10, audit: 0, total: 10 }, apiKeys: 5, endpoints: 2, storage: { onDisk: { core: 100, audit: 0, total: 100 }, logical: { core: 100, audit: 0, total: 100 } } }
|
||||
: { apiUsage: { core: 0, audit: 7, total: 7 }, apiKeys: 0, endpoints: 0, storage: { onDisk: { core: 0, audit: 50, total: 50 }, logical: { core: 0, audit: 50, total: 50 } } },
|
||||
}));
|
||||
|
||||
beforeEach(() => fetchImpl.mockClear());
|
||||
|
||||
it("merges summary: core fields from core source, audit fields from audit source", async () => {
|
||||
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl });
|
||||
const out = await p.summary(7, { from: "a", to: "b" });
|
||||
expect(out.apiUsage).toEqual({ core: 10, audit: 7, total: 17 });
|
||||
expect(out.storage.onDisk).toEqual({ core: 100, audit: 50, total: 150 });
|
||||
expect(out.apiKeys).toBe(5); // from the core source
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("forwards with the source's API key and NO user id in the body (the key is the tenant scope)", async () => {
|
||||
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl });
|
||||
await p.summary(7, { from: "a", to: "b" });
|
||||
const coreCall = fetchImpl.mock.calls.find(([u]) => u.includes("//core"));
|
||||
expect(coreCall[0]).toBe("http://core/api/v1/data/dashboard/summary");
|
||||
expect(coreCall[1].headers.Authorization).toBe("Bearer ck");
|
||||
const body = JSON.parse(coreCall[1].body);
|
||||
expect(body).toEqual({ from: "a", to: "b" }); // only the window — never a userId
|
||||
expect(body).not.toHaveProperty("userId");
|
||||
});
|
||||
|
||||
it("routes the audit transactions tab to the audit source", async () => {
|
||||
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl });
|
||||
await p.transactions(7, { type: "audit", limit: 50, offset: 0 });
|
||||
const [url] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe("http://audit/api/v1/data/dashboard/transactions");
|
||||
});
|
||||
|
||||
it("merges the combined usage+storage series by date", async () => {
|
||||
const seriesFetch = jest.fn(async (url) => ({
|
||||
ok: true,
|
||||
json: async () =>
|
||||
url.includes("//core")
|
||||
? { usage: [{ date: "2026-06-01", core: 3, audit: 0, total: 3 }], storage: [{ date: "2026-06-01", coreDisk: 100, auditDisk: 0, totalDisk: 100 }] }
|
||||
: { usage: [{ date: "2026-06-01", core: 0, audit: 5, total: 5 }], storage: [{ date: "2026-06-01", coreDisk: 0, auditDisk: 50, totalDisk: 50 }] },
|
||||
}));
|
||||
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl: seriesFetch });
|
||||
const out = await p.series(7, { from: "a", to: "b" });
|
||||
expect(out.usage).toEqual([{ date: "2026-06-01", core: 3, audit: 5, total: 8 }]);
|
||||
expect(out.storage).toEqual([{ date: "2026-06-01", coreDisk: 100, auditDisk: 50, totalDisk: 150 }]);
|
||||
});
|
||||
});
|
||||
130
backend/http/index.js
Normal file
130
backend/http/index.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import bodyParser from "body-parser";
|
||||
import { initModules, apiMiddleware } from "./auth.js";
|
||||
import { loadPep } from "../modules/pep/index.js";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import swaggerDocument from "./api/v1/swagger.json" with { type: "json" };
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { core } from "../modules/core/index.js"
|
||||
import { getAgreements } from "../http/agreements.js"
|
||||
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import { PgSessionStore } from "./sessionStore.js";
|
||||
import passport from "passport";
|
||||
import { refresh } from "./refresh.js";
|
||||
import { logout } from "./logout.js";
|
||||
import { logRequest } from "./logging.js";
|
||||
import { routeAgreements } from "./agreements.js";
|
||||
import { dashboardHandlers } from "./dashboard.js";
|
||||
import { makePassthrough } from "./dashboardPassthrough.js";
|
||||
import { apiKeyHandlers } from "./apiKeys.js";
|
||||
import { getUsage } from "../modules/core/usage.js";
|
||||
import path from "path";
|
||||
|
||||
async function render(view, res, config) {
|
||||
try {
|
||||
res.render(view, {
|
||||
config,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPrivate(view, req, res, config) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
const now = new Date();
|
||||
const begin = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const usage = await getUsage(req.user, begin, end);
|
||||
const agreements = await getAgreements(req?.user?.id);
|
||||
res.render(view, {
|
||||
config,
|
||||
agreements,
|
||||
user: req.user,
|
||||
usage,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPrivateUI(req, res, config) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
const uiPath = path.join(process.cwd(), "ui", "index.html");
|
||||
res.sendFile(uiPath);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initHTTP(app) {
|
||||
const config = getConfig();
|
||||
|
||||
passport.serializeUser(function (user, done) {
|
||||
done(null, user);
|
||||
});
|
||||
passport.deserializeUser(function (user, done) {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
const sess = {
|
||||
secret: config.secureSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new PgSessionStore(),
|
||||
cookie: { httpOnly: true, sameSite: 'lax' }, // sameSite=lax: CSRF defense
|
||||
}
|
||||
if (app.get('env') === 'production') {
|
||||
app.set('trust proxy', 1) // trust first proxy
|
||||
sess.cookie.secure = true // serve secure cookies over HTTPS
|
||||
}
|
||||
app.use(session(sess));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
app.use(express.static("./http/public"));
|
||||
|
||||
app.set('views', './http/views');
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
await initModules(app, passport);
|
||||
logRequest(app);
|
||||
routeAgreements(app);
|
||||
|
||||
await loadPep(app);
|
||||
|
||||
app.get("/", (req, res) => render('login', res, config));
|
||||
app.get("/dashboard", (req, res) => renderPrivate('dashboard', req, res, config));
|
||||
app.get("/refresh", refresh);
|
||||
app.post('/logout', logout);
|
||||
|
||||
// UI
|
||||
app.get("/home", (req, res) => renderPrivateUI(req, res, config));
|
||||
|
||||
const dashboard = dashboardHandlers({ service: makePassthrough() });
|
||||
app.get("/api/dashboard/summary", dashboard.summary);
|
||||
app.get("/api/dashboard/transactions", dashboard.transactions);
|
||||
app.get("/api/dashboard/series", dashboard.series);
|
||||
app.get("/api/dashboard/verify", dashboard.verify);
|
||||
app.get("/api/dashboard/transactions/:eventUuid", dashboard.event);
|
||||
app.get("/api/dashboard/agreement/:agreementUuid", dashboard.agreement);
|
||||
app.get("/api/dashboard/details/:eventUuid", dashboard.details);
|
||||
|
||||
|
||||
const apiKeys = apiKeyHandlers();
|
||||
app.get("/api/dashboard/auth/whoami", apiKeys.whoami);
|
||||
app.get("/api/dashboard/auth/keys", apiKeys.list);
|
||||
app.post("/api/dashboard/auth/keys", express.json(), apiKeys.create);
|
||||
app.delete("/api/dashboard/auth/keys/:id", apiKeys.revoke);
|
||||
|
||||
app.post(/^\/api\/v1\/.*$/, bodyParser.json(), apiMiddleware, core.post);
|
||||
|
||||
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
}
|
||||
|
||||
40
backend/http/logging.js
Normal file
40
backend/http/logging.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getConfig } from "../common/config.js";
|
||||
|
||||
export function logRequest(app) {
|
||||
const config = getConfig();
|
||||
app.use((req, res, next) => {
|
||||
const originalSend = res.send;
|
||||
res.send = function (body) {
|
||||
res.body = body; // Store the response body for logging
|
||||
try {
|
||||
return originalSend.apply(res, arguments); // Proceed with sending the response
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
}
|
||||
};
|
||||
res.on("finish", () => {
|
||||
let output = `${req.method} - ${res.statusCode} - ${req.url}`;
|
||||
if (req.apiMessage) {
|
||||
output += ` - ${req.apiMessage}`;
|
||||
delete req.apiMessage;
|
||||
}
|
||||
console.log(output);
|
||||
if (res.statusCode != 200 || config.debug) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(res.body);
|
||||
} catch (e) {
|
||||
body = {};
|
||||
}
|
||||
if (body?.error) {
|
||||
console.log(`${req.method} - ${res.statusCode} - ${req.url} - ERROR: ${body.error}`);
|
||||
return;
|
||||
}
|
||||
if (config.debug) {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
22
backend/http/logout.js
Normal file
22
backend/http/logout.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getConfig } from "../common/config.js";
|
||||
|
||||
export async function logout(req, res, next) {
|
||||
const strategy = req.session.authStrategy ?`${req.session.authStrategy}` : null;
|
||||
req.logout(function (err) {
|
||||
if (err) { return next(err); }
|
||||
req.session.destroy(function (err) {
|
||||
if (err) { return next(err); }
|
||||
if (strategy) {
|
||||
const config = getConfig();
|
||||
const strategyConfig = config.authModules[strategy];
|
||||
if (strategyConfig && strategyConfig.logoutURL) {
|
||||
res.redirect(strategyConfig.logoutURL);
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
18
backend/http/public/images/icon.svg
Normal file
18
backend/http/public/images/icon.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100.47 100.47">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style>
|
||||
</defs>
|
||||
<title>JLINC Icon</title>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-2"
|
||||
d="M8.52,41.22l9,9L36.46,69.16l7.76-7.76L25.3,42.48l-9.42-9.42q-4.5-4.52-5.14-9.26c-.48-3.11,1-6.36,4.35-9.73Q19.22,9.95,23.64,10t9.26,4.83l4,4,7.2-7.21L38.52,6a20.93,20.93,0,0,0-7.91-4.9A17.1,17.1,0,0,0,20,.61Q14,2,8.05,8a28.56,28.56,0,0,0-6.33,9.5,18.2,18.2,0,0,0-.79,11.4Q2.34,35,8.52,41.22Z" />
|
||||
<path class="cls-2"
|
||||
d="M61.4,56.25,42.48,75.17l-9.42,9.42q-4.52,4.51-9.26,5.14t-9.73-4.35Q9.95,81.27,10,76.83t4.83-9.26l4-3.95-7.21-7.21L6,62a21,21,0,0,0-4.9,7.92A17.09,17.09,0,0,0,.61,80.48q1.43,6,7.36,12a28.87,28.87,0,0,0,9.5,6.33,18.2,18.2,0,0,0,11.4.79Q35,98.13,41.22,92l9-9L69.16,64Z" />
|
||||
<path class="cls-2"
|
||||
d="M92,59.26l-9-9L64,31.31l-7.76,7.76L75.17,58l9.42,9.42q4.51,4.52,5.14,9.26t-4.35,9.74c-2.74,2.74-5.59,4.12-8.55,4.11s-6-1.55-9.26-4.83l-3.95-4-7.21,7.2L62,94.48a21.11,21.11,0,0,0,7.92,4.91,17.06,17.06,0,0,0,10.6.47q6-1.42,12-7.36A28.87,28.87,0,0,0,98.76,83a18.16,18.16,0,0,0,.79-11.39Q98.13,65.43,92,59.26Z" />
|
||||
<path class="cls-2"
|
||||
d="M39.07,44.22,58,25.3l9.42-9.42q4.52-4.5,9.26-5.14c3.12-.48,6.36,1,9.74,4.35,2.74,2.75,4.11,5.59,4.11,8.55s-1.55,6-4.83,9.26l-4,4,7.2,7.2,5.54-5.54a21.07,21.07,0,0,0,4.91-7.91A17.17,17.17,0,0,0,99.86,20Q98.44,14,92.5,8.05A28.56,28.56,0,0,0,83,1.72,18.19,18.19,0,0,0,71.6.93Q65.44,2.34,59.26,8.52l-9,9L31.31,36.46Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
28
backend/http/refresh.js
Normal file
28
backend/http/refresh.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getPool } from "../db/index.js";
|
||||
import { getNewKey, getUser } from "./auth.js";
|
||||
|
||||
export async function refresh(req, res) {
|
||||
try {
|
||||
|
||||
const client = await getPool();
|
||||
const apiKey = getNewKey(req.user);
|
||||
await client.query(`
|
||||
UPDATE public.auth SET
|
||||
api_key = $1
|
||||
WHERE user_id = $2
|
||||
AND app_id = (
|
||||
SELECT id FROM public.app WHERE type = $3
|
||||
);
|
||||
`, [
|
||||
apiKey,
|
||||
req.user.id,
|
||||
req.query.app,
|
||||
]);
|
||||
const user = await getUser(client, req.user.issuer, req.user.identifier);
|
||||
req.session.passport.user = user;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
115
backend/http/sessionStore.js
Normal file
115
backend/http/sessionStore.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import session from "express-session";
|
||||
import { getPool } from "../db/index.js";
|
||||
|
||||
const ONE_DAY_MS = 86_400_000;
|
||||
const DEFAULT_PRUNE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
// Memory is per-process: a session mutated on another worker (e.g. a logout)
|
||||
// can be served stale here for at most one TTL. Keep it short.
|
||||
const DEFAULT_MEMORY_TTL_MS = 60 * 1000; // 60 seconds
|
||||
|
||||
export const expiryOf = (sess) =>
|
||||
new Date(sess?.cookie?.expires ?? Date.now() + ONE_DAY_MS);
|
||||
|
||||
// In-memory cache in front of Postgres: sessions survive restarts and are
|
||||
// shared across workers, while the memory map serves the read-heavy hot path.
|
||||
export class PgSessionStore extends session.Store {
|
||||
constructor({ pruneIntervalMs = DEFAULT_PRUNE_INTERVAL_MS, memoryTtlMs = DEFAULT_MEMORY_TTL_MS } = {}) {
|
||||
super();
|
||||
// sid -> { sess, expire (ms epoch), freshUntil (ms epoch) }
|
||||
this._mem = new Map();
|
||||
this._memoryTtlMs = memoryTtlMs;
|
||||
// unref'd so the timer never keeps the process alive.
|
||||
if (pruneIntervalMs > 0) {
|
||||
this._pruneTimer = setInterval(() => this.prune(), pruneIntervalMs);
|
||||
this._pruneTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async _withPg(fn) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
get(sid, cb) {
|
||||
const now = Date.now();
|
||||
const hit = this._mem.get(sid);
|
||||
if (hit) {
|
||||
if (hit.expire <= now) {
|
||||
this._mem.delete(sid);
|
||||
return cb(null, null);
|
||||
}
|
||||
if (hit.freshUntil > now) return cb(null, hit.sess); // fast path: no DB query
|
||||
}
|
||||
this._withPg((c) =>
|
||||
c.query(`SELECT sess, expire FROM session WHERE sid = $1 AND expire > NOW()`, [sid]),
|
||||
)
|
||||
.then((res) => {
|
||||
const row = res.rows[0];
|
||||
if (!row) {
|
||||
this._mem.delete(sid);
|
||||
return cb(null, null);
|
||||
}
|
||||
this._mem.set(sid, {
|
||||
sess: row.sess,
|
||||
expire: new Date(row.expire).getTime(),
|
||||
freshUntil: now + this._memoryTtlMs,
|
||||
});
|
||||
cb(null, row.sess);
|
||||
})
|
||||
.catch(cb);
|
||||
}
|
||||
|
||||
set(sid, sess, cb = () => {}) {
|
||||
const expire = expiryOf(sess);
|
||||
this._mem.set(sid, { sess, expire: expire.getTime(), freshUntil: Date.now() + this._memoryTtlMs });
|
||||
this._withPg((c) =>
|
||||
c.query(
|
||||
`INSERT INTO session (sid, sess, expire) VALUES ($1, $2::jsonb, $3)
|
||||
ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire, updated_ts = NOW()`,
|
||||
[sid, sess, expire],
|
||||
),
|
||||
)
|
||||
.then(() => cb(null))
|
||||
.catch(cb);
|
||||
}
|
||||
|
||||
destroy(sid, cb = () => {}) {
|
||||
this._mem.delete(sid);
|
||||
this._withPg((c) => c.query(`DELETE FROM session WHERE sid = $1`, [sid]))
|
||||
.then(() => cb(null))
|
||||
.catch(cb);
|
||||
}
|
||||
|
||||
touch(sid, sess, cb = () => {}) {
|
||||
const expire = expiryOf(sess);
|
||||
const hit = this._mem.get(sid);
|
||||
if (hit) {
|
||||
hit.expire = expire.getTime();
|
||||
hit.freshUntil = Date.now() + this._memoryTtlMs;
|
||||
}
|
||||
this._withPg((c) =>
|
||||
c.query(`UPDATE session SET expire = $2, updated_ts = NOW() WHERE sid = $1`, [sid, expire]),
|
||||
)
|
||||
.then(() => cb(null))
|
||||
.catch(cb);
|
||||
}
|
||||
|
||||
// Errors are swallowed: runs on a timer with no caller.
|
||||
async prune() {
|
||||
const now = Date.now();
|
||||
for (const [sid, v] of this._mem) if (v.expire <= now) this._mem.delete(sid);
|
||||
try {
|
||||
await this._withPg((c) => c.query(`DELETE FROM session WHERE expire <= NOW()`));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
stopPruning() {
|
||||
if (this._pruneTimer) clearInterval(this._pruneTimer);
|
||||
}
|
||||
}
|
||||
160
backend/http/sessionStore.test.js
Normal file
160
backend/http/sessionStore.test.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
// Manual DB mock: getPool() returns whatever fake client the current test set.
|
||||
let client;
|
||||
const getPool = jest.fn(async () => client);
|
||||
jest.unstable_mockModule("../db/index.js", () => ({ getPool }));
|
||||
|
||||
const { PgSessionStore, expiryOf } = await import("./sessionStore.js");
|
||||
|
||||
const ONE_DAY_MS = 86_400_000;
|
||||
|
||||
// A fake pooled client whose query() returns `rows` and that records release().
|
||||
function mockClient(rows = []) {
|
||||
return {
|
||||
query: jest.fn(async () => ({ rows })),
|
||||
release: jest.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Store with the prune timer disabled; memoryTtlMs chosen per test.
|
||||
const makeStore = (opts = {}) => new PgSessionStore({ pruneIntervalMs: 0, ...opts });
|
||||
|
||||
// Promisify express-session's node-style callbacks for cleaner assertions.
|
||||
const call = (fn) => new Promise((resolve, reject) =>
|
||||
fn((err, res) => (err ? reject(err) : resolve(res))));
|
||||
|
||||
beforeEach(() => {
|
||||
getPool.mockClear();
|
||||
client = mockClient();
|
||||
});
|
||||
|
||||
describe("expiryOf", () => {
|
||||
it("uses the cookie's expiry when present", () => {
|
||||
const when = new Date("2030-01-01T00:00:00Z");
|
||||
expect(expiryOf({ cookie: { expires: when } }).getTime()).toBe(when.getTime());
|
||||
});
|
||||
|
||||
it("defaults to ~one day out when no cookie expiry", () => {
|
||||
const before = Date.now();
|
||||
const got = expiryOf({}).getTime();
|
||||
expect(got).toBeGreaterThanOrEqual(before + ONE_DAY_MS - 50);
|
||||
expect(got).toBeLessThanOrEqual(Date.now() + ONE_DAY_MS + 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PgSessionStore memory + PG fallback", () => {
|
||||
it("get() falls back to Postgres on a cold cache and filters expired rows in SQL", async () => {
|
||||
client = mockClient([{ sess: { user_id: 7 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]);
|
||||
const store = makeStore();
|
||||
const sess = await call((cb) => store.get("sid-1", cb));
|
||||
|
||||
expect(sess).toEqual({ user_id: 7 });
|
||||
expect(getPool).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = client.query.mock.calls[0];
|
||||
expect(sql).toContain("expire > NOW()");
|
||||
expect(params).toEqual(["sid-1"]);
|
||||
expect(client.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("get() serves a warm in-memory session WITHOUT querying Postgres", async () => {
|
||||
const store = makeStore({ memoryTtlMs: 10_000 });
|
||||
await call((cb) => store.set("sid-2", { user_id: 1, cookie: {} }, cb)); // write-through populates memory
|
||||
getPool.mockClear();
|
||||
|
||||
const sess = await call((cb) => store.get("sid-2", cb));
|
||||
expect(sess).toMatchObject({ user_id: 1 });
|
||||
expect(getPool).not.toHaveBeenCalled(); // the whole point: no per-request query
|
||||
});
|
||||
|
||||
it("get() re-reads Postgres once the in-memory copy goes stale", async () => {
|
||||
client = mockClient([{ sess: { user_id: 5 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]);
|
||||
const store = makeStore({ memoryTtlMs: 0 }); // memory never counts as fresh
|
||||
await call((cb) => store.set("sid", { user_id: 5, cookie: {} }, cb));
|
||||
getPool.mockClear();
|
||||
|
||||
const sess = await call((cb) => store.get("sid", cb));
|
||||
expect(getPool).toHaveBeenCalledTimes(1); // stale -> fell back to Postgres
|
||||
expect(sess).toEqual({ user_id: 5 });
|
||||
});
|
||||
|
||||
it("get() drops an expired in-memory entry without a query", async () => {
|
||||
const store = makeStore({ memoryTtlMs: 10_000 });
|
||||
await call((cb) => store.set("exp", { user_id: 1, cookie: { expires: new Date(Date.now() - 1000) } }, cb));
|
||||
getPool.mockClear();
|
||||
|
||||
const sess = await call((cb) => store.get("exp", cb));
|
||||
expect(sess).toBeNull();
|
||||
expect(getPool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("get() returns null when no row matches", async () => {
|
||||
client = mockClient([]);
|
||||
const sess = await call((cb) => makeStore().get("missing", cb));
|
||||
expect(sess).toBeNull();
|
||||
});
|
||||
|
||||
it("set() write-through upserts to Postgres (bumping updated_ts) and populates memory", async () => {
|
||||
const store = makeStore({ memoryTtlMs: 10_000 });
|
||||
const when = new Date("2030-06-01T00:00:00Z");
|
||||
await call((cb) => store.set("sid-3", { cookie: { expires: when }, user_id: 1 }, cb));
|
||||
|
||||
const [sql, params] = client.query.mock.calls[0];
|
||||
expect(sql).toContain("ON CONFLICT (sid) DO UPDATE");
|
||||
expect(sql).toContain("updated_ts = NOW()");
|
||||
expect(params[0]).toBe("sid-3");
|
||||
expect(params[2]).toEqual(when);
|
||||
|
||||
getPool.mockClear();
|
||||
const sess = await call((cb) => store.get("sid-3", cb));
|
||||
expect(getPool).not.toHaveBeenCalled(); // served from memory after the write-through
|
||||
expect(sess).toMatchObject({ user_id: 1 });
|
||||
});
|
||||
|
||||
it("destroy() removes the session from memory and Postgres", async () => {
|
||||
const store = makeStore({ memoryTtlMs: 10_000 });
|
||||
await call((cb) => store.set("sid-4", { user_id: 1, cookie: {} }, cb));
|
||||
client.query.mockClear();
|
||||
|
||||
await call((cb) => store.destroy("sid-4", cb));
|
||||
expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE sid = $1");
|
||||
expect(client.query.mock.calls[0][1]).toEqual(["sid-4"]);
|
||||
|
||||
// Memory was cleared -> the next get falls back to Postgres (now empty).
|
||||
client = mockClient([]);
|
||||
getPool.mockClear();
|
||||
expect(await call((cb) => store.get("sid-4", cb))).toBeNull();
|
||||
expect(getPool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("touch() bumps only the expiry", async () => {
|
||||
await call((cb) => makeStore().touch("sid-5", {}, cb));
|
||||
expect(client.query.mock.calls[0][0]).toContain("UPDATE session SET expire = $2");
|
||||
});
|
||||
|
||||
it("prune() sweeps expired rows from Postgres", async () => {
|
||||
const store = makeStore();
|
||||
await store.prune();
|
||||
expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE expire <= NOW()");
|
||||
expect(client.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases the client and surfaces the error when a query fails", async () => {
|
||||
client = {
|
||||
query: jest.fn(async () => { throw new Error("db down"); }),
|
||||
release: jest.fn(async () => {}),
|
||||
};
|
||||
await expect(call((cb) => makeStore({ memoryTtlMs: 0 }).get("sid", cb))).rejects.toThrow("db down");
|
||||
expect(client.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces the error and calls back exactly once when acquiring a client fails", async () => {
|
||||
getPool.mockImplementationOnce(async () => { throw new Error("pool exhausted"); });
|
||||
const store = makeStore({ memoryTtlMs: 0 });
|
||||
const cb = jest.fn();
|
||||
await new Promise((resolve) => store.get("sid", (...args) => { cb(...args); resolve(); }));
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(cb.mock.calls[0][0].message).toBe("pool exhausted");
|
||||
});
|
||||
});
|
||||
21
backend/http/views/agreement.ejs
Normal file
21
backend/http/views/agreement.ejs
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<%- include('./include/header.ejs', { title: 'JLINC - MyTerms Agreement' }) %>
|
||||
|
||||
<style>
|
||||
a {
|
||||
color: #31A9BA !important
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mdc-card" style="background: linear-gradient(333deg, rgb(0, 0, 0) 0%, rgb(79, 55, 139) 100%);">
|
||||
|
||||
<%- agreement %>
|
||||
|
||||
</div>
|
||||
<br>
|
||||
View the <a href="<%- rawUrl %>">raw agreement content</a>.
|
||||
|
||||
|
||||
<%- include('./include/footer.ejs') %>
|
||||
22
backend/http/views/dashboard.ejs
Normal file
22
backend/http/views/dashboard.ejs
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %>
|
||||
|
||||
<% for (const type in config.appModules) { %>
|
||||
<% if (config.appModules[type].internal) { continue; } %>
|
||||
<%-
|
||||
include('./include/app.ejs', {
|
||||
app: config.appModules[type],
|
||||
usage: usage ? usage[type] : null
|
||||
})
|
||||
%>
|
||||
<% } %>
|
||||
|
||||
<%-
|
||||
include('./include/agreements.ejs', {
|
||||
app: config.appModules['core']
|
||||
})
|
||||
%>
|
||||
|
||||
<%- include('./include/footer.ejs') %>
|
||||
26
backend/http/views/include/agreements.ejs
Normal file
26
backend/http/views/include/agreements.ejs
Normal file
@@ -0,0 +1,26 @@
|
||||
<% const cardStyle=`"background: linear-gradient(333deg, ${app.background.color1} 0%, ${app.background.color2} 100%);
|
||||
margin-bottom: 30px;"`; const userApp=user.apps.find(ua=> app.type === 'core');
|
||||
const buttonStyle = `"padding: 8px 10px 8px 10px; border-radius: 16px !important; background-color:
|
||||
${app.button.color} !important; border: none !important;"`
|
||||
%>
|
||||
|
||||
<div class="mdc-card mdc-theme--dark" style=<%- cardStyle %>>
|
||||
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="width: 30px">
|
||||
<%- app.logo %>
|
||||
</div>
|
||||
<h2 style="margin-left: 10px; padding-bottom: 10px;">
|
||||
Available Agreements
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center;">
|
||||
<ul style="margin-top: 0px">
|
||||
<% for (const agreement of agreements) { %>
|
||||
<li style="padding-bottom: 1em"><a target="_new" class="agreement-link" href="/agreements/<%- agreement.hash %>"><%- agreement.title %></a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
163
backend/http/views/include/app.ejs
Normal file
163
backend/http/views/include/app.ejs
Normal file
@@ -0,0 +1,163 @@
|
||||
<% const cardStyle=`"background: linear-gradient(333deg, ${app.background.color1} 0%, ${app.background.color2} 100%);
|
||||
margin-bottom: 30px;"`; const userApp=user.apps.find(ua=> app.type === ua.type);
|
||||
const buttonStyle = `"padding: 8px 10px 8px 10px; border-radius: 16px !important; background-color:
|
||||
${app.button.color} !important; border: none !important;"`
|
||||
%>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(label, text) {
|
||||
if (window.clipboardData && window.clipboardData.setData) {
|
||||
// Internet Explorer specific code path to prevent textarea being shown while dialog is visible.
|
||||
return clipboardData.setData('Text', text);
|
||||
} else if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
|
||||
var textarea = document.createElement("textarea");
|
||||
textarea.textContent = text;
|
||||
// Prevent scrolling to bottom of page in Microsoft Edge.
|
||||
textarea.style.position = 'fixed';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const flash = document.createElement('div');
|
||||
flash.style.position = 'fixed';
|
||||
flash.style.top = '0';
|
||||
flash.style.left = '0';
|
||||
flash.style.width = '100%';
|
||||
flash.style.backgroundColor = '#000';
|
||||
flash.style.color = '#ggg';
|
||||
flash.style.opacity = '0.7';
|
||||
flash.style.textAlign = 'center';
|
||||
flash.style.fontSize = '12px';
|
||||
flash.style.padding = '10px';
|
||||
try {
|
||||
const cmd = document.execCommand('copy'); // Security exception may be thrown by some browsers.
|
||||
flash.innerHTML = `${label} copied to clipboard`;
|
||||
document.body.appendChild(flash);
|
||||
setTimeout(function () {
|
||||
document.body.removeChild(flash);
|
||||
}, 1500);
|
||||
return cmd;
|
||||
} catch (ex) {
|
||||
flash.style.backgroundColor = '#f00';
|
||||
flash.innerHTML = 'API key copy failed';
|
||||
document.body.appendChild(flash);
|
||||
setTimeout(function () {
|
||||
document.body.removeChild(flash);
|
||||
}, 1500);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mdc-card mdc-theme--dark" style=<%- cardStyle %>>
|
||||
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="width: 30px">
|
||||
<%- app.logo %>
|
||||
</div>
|
||||
<h2 style="margin-left: 10px; padding-bottom: 10px;">
|
||||
<%= app.title %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<label class="mdc-text-field mdc-text-field--outlined mdc-text-field--focused">
|
||||
<span class="mdc-notched-outline" style="--mdc-theme-primary: rgba(255, 255, 255, 0.3)">
|
||||
<span class="mdc-notched-outline__leading"></span>
|
||||
<span class="mdc-notched-outline__trailing"></span>
|
||||
</span>
|
||||
<input style="color: #fff; text-overflow: ellipsis;" type="text" id="endpoint-input"
|
||||
aria-describedby="api-key-helper" class="mdc-text-field__input" disabled type="text"
|
||||
value="<%= app.endpoint %>">
|
||||
<i style="color: #fff" class="material-icons mdc-text-field__icon mdc-text-field__icon--trailing"
|
||||
tabindex="0" role="button" onclick="copyToClipboard('API Endpoint', '<%= app.endpoint %>')">
|
||||
content_copy
|
||||
</i>
|
||||
</label>
|
||||
<div class="mdc-text-field-helper-line" style="padding-bottom: 20px">
|
||||
<div style="color: #fff" class="mdc-text-field-helper-text" id="endpoint-helper" aria-hidden="false">API
|
||||
Endpoint
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mdc-text-field mdc-text-field--outlined mdc-text-field--focused">
|
||||
<span class="mdc-notched-outline" style="--mdc-theme-primary: rgba(255, 255, 255, 0.3)">
|
||||
<span class="mdc-notched-outline__leading"></span>
|
||||
<span class="mdc-notched-outline__trailing"></span>
|
||||
</span>
|
||||
<input style="color: #fff; text-overflow: ellipsis;" type="text" id="api-key-input"
|
||||
aria-describedby="api-key-helper" class="mdc-text-field__input" disabled type="text"
|
||||
value="<%= userApp.apiKey %>">
|
||||
<i style="color: #fff" class="material-icons mdc-text-field__icon mdc-text-field__icon--trailing"
|
||||
tabindex="0" role="button" onclick="copyToClipboard('API Key', '<%= userApp.apiKey %>')">
|
||||
content_copy
|
||||
</i>
|
||||
</label>
|
||||
<div class="mdc-text-field-helper-line">
|
||||
<div style="color: #fff" class="mdc-text-field-helper-text" id="api-key-helper" aria-hidden="false">API Key
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end;">
|
||||
<button style="width: 140px; --mdc-theme-primary: <%= app.button.color %>"
|
||||
class="mdc-button mdc-button--raised mdc-button--leading"
|
||||
onclick="window.location.href='/refresh?app=<%= app.type %>'">
|
||||
<span class="mdc-button__ripple"></span>
|
||||
<i class="material-icons mdc-button__icon" aria-hidden="true">refresh</i>
|
||||
<span class="mdc-button__label">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<% if (usage) { %>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<h3 style="margin: 0; display: flex; align-items: center; margin-right: 16px;">Hits this month:</h3>
|
||||
<span class="mdc-evolution-chip-set" role="grid">
|
||||
<span class="mdc-evolution-chip-set__chips" role="presentation">
|
||||
<span class="mdc-evolution-chip" role="row">
|
||||
<span class="mdc-evolution-chip__cell mdc-evolution-chip__cell--primary" role="gridcell">
|
||||
<button style=<%- buttonStyle %>
|
||||
class="mdc-evolution-chip__action mdc-evolution-chip__action--primary" type="button"
|
||||
tabindex="0">
|
||||
<span class="mdc-evolution-chip__ripple mdc-evolution-chip__ripple--primary"></span>
|
||||
<span class="mdc-evolution-chip__text-label">
|
||||
<%= usage %>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
|
||||
<% if (userApp.devices?.length> 0) { %>
|
||||
<h3 style="margin-bottom: 0px">Synced devices</h3>
|
||||
<span class="mdc-evolution-chip-set" role="grid" style="padding-top: 16px">
|
||||
<span class="mdc-evolution-chip-set__chips" role="presentation">
|
||||
|
||||
<% for (const device of userApp.devices.sort((a, b)=>
|
||||
a.identifier.localeCompare(b.identifier))) {
|
||||
%>
|
||||
<span style="padding-right: 10px" class="mdc-evolution-chip" role="row"
|
||||
id="device-<%- device.id %>">
|
||||
<span class="mdc-evolution-chip__cell mdc-evolution-chip__cell--primary"
|
||||
role="gridcell">
|
||||
<button style=<%- buttonStyle %> class="mdc-evolution-chip__action
|
||||
mdc-evolution-chip__action--primary"
|
||||
type="button" tabindex="0">
|
||||
<span
|
||||
class="mdc-evolution-chip__ripple mdc-evolution-chip__ripple--primary"></span>
|
||||
<span class="mdc-evolution-chip__text-label">
|
||||
<%= device.identifier %>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<% } %>
|
||||
|
||||
</span>
|
||||
</span>
|
||||
<% } %>
|
||||
|
||||
</div>
|
||||
6
backend/http/views/include/footer.ejs
Normal file
6
backend/http/views/include/footer.ejs
Normal file
@@ -0,0 +1,6 @@
|
||||
</div>
|
||||
</center>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
125
backend/http/views/include/header.ejs
Normal file
125
backend/http/views/include/header.ejs
Normal file
@@ -0,0 +1,125 @@
|
||||
<head>
|
||||
<title>
|
||||
<%= title %>
|
||||
</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="/images/icon.svg" type="image/x-icon">
|
||||
<link rel="stylesheet" href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--md-ref-typeface-brand: 'Open Sans', sans-serif;
|
||||
--md-ref-typeface-plain: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--md-ref-typeface-plain);
|
||||
color: #fff;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
background-image: linear-gradient(135deg, #31A9BA 10%, #0E0618 10%, #231641 90%, #31A9BA 90%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
font-family: var(--md-ref-typeface-plain);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--md-ref-typeface-brand);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mdc-card {
|
||||
padding-bottom: 16px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
background-color: rgb(68, 62, 77);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mdc-button {
|
||||
--mdc-theme-primary: rgb(255, 255, 255, .85);
|
||||
--mdc-theme-on-primary: rgb(68, 62, 77);
|
||||
|
||||
/* @include button.ink-color(#84565E); */
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
font-family: var(--md-ref-typeface-plain);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="profile-icon-container" style="position: absolute; top: 16px; right: 16px;">
|
||||
<% if (typeof user !=='undefined' && user) { %>
|
||||
<div style="position: relative;">
|
||||
<button id="profileButton" class="profile-icon"
|
||||
style="border: none; border-radius: 50%; width: 48px; height: 48px; display: flex; justify-content: center; align-items: center; background-color: #31A9BA; color: #fff; cursor: pointer;">
|
||||
<span style="font-size: 30px">
|
||||
<%= user.username.charAt(0).toUpperCase() %>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div id="profileMenu"
|
||||
style="display: none; position: absolute; top: 60px; right: 0; background-color: #4F378B; box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 8px; padding: 10px; min-width: 160px; z-index: 100;">
|
||||
<div style="padding: 8px; font-weight: bold;">
|
||||
<span style="display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
<%= user.username %>
|
||||
</span>
|
||||
</div>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit"
|
||||
style="width: 100%; padding: 8px; background-color: #31A9BA; color: white; border: none; border-radius: 4px; cursor: pointer;">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const profileButton = document.getElementById('profileButton');
|
||||
const profileMenu = document.getElementById('profileMenu');
|
||||
|
||||
profileButton.addEventListener('click', () => {
|
||||
profileMenu.style.display = profileMenu.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// Optional: Hide menu if clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileButton.contains(event.target) && !profileMenu.contains(event.target)) {
|
||||
profileMenu.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<% } %>
|
||||
</div>
|
||||
<div style="width: 100%; overflow-y: auto; max-height: 100%; word-wrap: break-word;">
|
||||
<center>
|
||||
<div style="width: 90%; max-width: 600px; text-align: left">
|
||||
<div
|
||||
style="padding-top: 20px; display: flex; align-items: center; justify-content: center; gap: 0px; text-align: center; transform: translateX(-0px);">
|
||||
<div style="width: 200px; padding-bottom: 26px">
|
||||
<%- include('./logo-white.svg') %>
|
||||
</div>
|
||||
</div>
|
||||
1
backend/http/views/include/logo-white.svg
Normal file
1
backend/http/views/include/logo-white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362.08 100.47"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style></defs><title>JLINC Logo H White</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M157.32,29.36h8.35V57.2q0,13.9-13.92,13.91H137.84q-14.22,0-14-13.91l0-1.4h8.35v.25q0,6.72,6.68,6.71h11.66q6.7,0,6.71-6.73Z"/><path class="cls-1" d="M223.12,62.76v8.35H181.37V29.36h8.35v33.4Z"/><path class="cls-1" d="M247.18,71.11h-8.35V29.36h8.35Z"/><path class="cls-1" d="M271.23,42V71.11h-8.35V29.36h8.51l24.89,29.09V29.36h8.35V71.11h-8.49Z"/><path class="cls-1" d="M362.08,62.76v8.35H334.25q-13.92,0-13.92-13.91V43.28q0-13.92,13.92-13.92h27.83v8.35H335.39q-6.7,0-6.71,6.68V56.05q0,6.72,6.74,6.71Z"/><path class="cls-2" d="M8.52,41.22l9,9L36.46,69.16l7.76-7.76L25.3,42.48l-9.42-9.42q-4.5-4.52-5.14-9.26c-.48-3.11,1-6.36,4.35-9.73Q19.22,9.95,23.64,10t9.26,4.83l4,4,7.2-7.21L38.52,6a20.93,20.93,0,0,0-7.91-4.9A17.1,17.1,0,0,0,20,.61Q14,2,8.05,8a28.56,28.56,0,0,0-6.33,9.5,18.2,18.2,0,0,0-.79,11.4Q2.34,35,8.52,41.22Z"/><path class="cls-2" d="M61.4,56.25,42.48,75.17l-9.42,9.42q-4.52,4.51-9.26,5.14t-9.73-4.35Q9.95,81.27,10,76.83t4.83-9.26l4-3.95-7.21-7.21L6,62a21,21,0,0,0-4.9,7.92A17.09,17.09,0,0,0,.61,80.48q1.43,6,7.36,12a28.87,28.87,0,0,0,9.5,6.33,18.2,18.2,0,0,0,11.4.79Q35,98.13,41.22,92l9-9L69.16,64Z"/><path class="cls-2" d="M92,59.26l-9-9L64,31.31l-7.76,7.76L75.17,58l9.42,9.42q4.51,4.52,5.14,9.26t-4.35,9.74c-2.74,2.74-5.59,4.12-8.55,4.11s-6-1.55-9.26-4.83l-3.95-4-7.21,7.2L62,94.48a21.11,21.11,0,0,0,7.92,4.91,17.06,17.06,0,0,0,10.6.47q6-1.42,12-7.36A28.87,28.87,0,0,0,98.76,83a18.16,18.16,0,0,0,.79-11.39Q98.13,65.43,92,59.26Z"/><path class="cls-2" d="M39.07,44.22,58,25.3l9.42-9.42q4.52-4.5,9.26-5.14c3.12-.48,6.36,1,9.74,4.35,2.74,2.75,4.11,5.59,4.11,8.55s-1.55,6-4.83,9.26l-4,4,7.2,7.2,5.54-5.54a21.07,21.07,0,0,0,4.91-7.91A17.17,17.17,0,0,0,99.86,20Q98.44,14,92.5,8.05A28.56,28.56,0,0,0,83,1.72,18.19,18.19,0,0,0,71.6.93Q65.44,2.34,59.26,8.52l-9,9L31.31,36.46Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
23
backend/http/views/login.ejs
Normal file
23
backend/http/views/login.ejs
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<%- include('./include/header.ejs', { title: 'JLINC - Login' }) %>
|
||||
|
||||
<div class="mdc-card" style="background: linear-gradient(333deg, rgb(0, 0, 0) 0%, rgb(79, 55, 139) 100%);">
|
||||
|
||||
<h3>Login with:</h3>
|
||||
<% for (const type in config.authModules) { %>
|
||||
<% if (type !== 'single') { %>
|
||||
<div style="width: 100%; padding-bottom: 16px">
|
||||
<button style="width: 100%" class="mdc-button mdc-button--raised mdc-button--leading" onclick="window.location.href='/login/<%= type %>'">
|
||||
<span class="mdc-button__ripple"></span>
|
||||
<i class="material-icons mdc-button__icon" aria-hidden="true"><%= config.authModules[type].icon %></i>
|
||||
<span class="mdc-button__label"><%= config.authModules[type].title %></span>
|
||||
</button>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
</div>
|
||||
|
||||
<%- include('./include/footer.ejs') %>
|
||||
Reference in New Issue
Block a user