initial commit
This commit is contained in:
6
src/.prettierrc
Normal file
6
src/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"printWidth": 180
|
||||
}
|
||||
18
src/apps.js
Normal file
18
src/apps.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getConfig } from "./common/config.js";
|
||||
import { getPool } from "./db/index.js";
|
||||
|
||||
export async function loadApps() {
|
||||
const config = getConfig();
|
||||
const client = await getPool();
|
||||
for (const type in config.appModules) {
|
||||
await client.query(`
|
||||
INSERT INTO public.app (
|
||||
type
|
||||
) VALUES (
|
||||
$1
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
type,
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
src/common/config.js
Normal file
39
src/common/config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const config = {
|
||||
debug: false,
|
||||
};
|
||||
|
||||
export async function loadConfig() {
|
||||
if (process.env.DEBUG) config.debug = process.env.DEBUG;
|
||||
if (process.env.POSTGRES_URL) config.postgresUrl = process.env.POSTGRES_URL;
|
||||
if (process.env.PUBLIC_CORE_URL) config.publicCoreUrl = process.env.PUBLIC_CORE_URL;
|
||||
if (process.env.PUBLIC_ARCHIVE_URL) config.publicArchiveUrl = process.env.PUBLIC_ARCHIVE_URL;
|
||||
if (process.env.PUBLIC_CALLBACK_URL) config.publicCallbackUrl = process.env.PUBLIC_CALLBACK_URL;
|
||||
if (process.env.SECURE_SECRET) config.secureSecret = process.env.SECURE_SECRET;
|
||||
if (process.env.DEFAULT_FEDID_URL) config.defaultFedidUrl = process.env.DEFAULT_FEDID_URL;
|
||||
if (process.env.AUTH_MODULES) {
|
||||
config.authModules = {};
|
||||
const authModules = process.env.AUTH_MODULES.split(',').map(a => a.trim());
|
||||
for (const type of authModules) {
|
||||
const authModulePath = `../modules/auth/${type}.js`;
|
||||
const { getModuleConfig } = await import(authModulePath);
|
||||
config.authModules[type] = getModuleConfig();
|
||||
}
|
||||
}
|
||||
config.appModules = {};
|
||||
let appModules = [
|
||||
'core',
|
||||
'archive',
|
||||
];
|
||||
if (process.env.APP_MODULES) {
|
||||
appModules = process.env.APP_MODULES.split(',').map(a => a.trim());
|
||||
}
|
||||
for (const type of appModules) {
|
||||
const appModulePath = `../modules/app/${type}.js`;
|
||||
const { getModuleConfig } = await import(appModulePath);
|
||||
config.appModules[type] = getModuleConfig();
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return config;
|
||||
}
|
||||
1
src/common/sleep.js
Normal file
1
src/common/sleep.js
Normal file
@@ -0,0 +1 @@
|
||||
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -0,0 +1,18 @@
|
||||
JLINC General Audit Agreement
|
||||
-----------------------------
|
||||
|
||||
This document outlines the terms and conditions for using the JLINC protocol for auditing purposes. The purpose of this agreement is to clarify that no formal legal agreements are established between parties beyond their use of the JLINC protocol for data communication auditing.
|
||||
|
||||
**Purpose:** This agreement exists solely to provide a framework for using the JLINC protocol to audit data communication among parties. It does not create any legal obligations or enforceable commitments beyond its scope.
|
||||
|
||||
**Awareness of Agreement:** Recognizing that some parties may not be aware of this agreement, it is understood that by utilizing the JLINC protocol under this agreement, they are not deemed to have accepted these terms.
|
||||
|
||||
**Use of Protocol:** Under this agreement, the JLINC protocol is employed exclusively for auditing data communication, ensuring transparency and efficiency in transactions without establishing any formal agreements beyond this document.
|
||||
|
||||
**No Data Provenance/Protection:** It is clarified that no data provenance or protection measures are provided under this agreement. Entities leveraging this agreement are responsible for their own data integrity and security outside the protocol's use.
|
||||
|
||||
**Liability:** Liability is limited to actions taken within the context of using the JLINC protocol. No party assumes responsibility for others' actions beyond their direct involvement with the JLINC protocol.
|
||||
|
||||
**Governing Law:** This agreement is governed by the laws of the system operator's and user's jurisdictions. Any legal matters arising from this agreement must be resolved in courts within those jurisdictions.
|
||||
|
||||
By adhering to these terms, parties acknowledge their responsibilities and agree to use the specified protocol solely for auditing purposes without implied legal obligations beyond those stated herein.
|
||||
190
src/db/index.js
Normal file
190
src/db/index.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createHash } from "crypto";
|
||||
import pkg from "pg";
|
||||
const { Pool } = pkg;
|
||||
|
||||
import { sleep } from "../common/sleep.js";
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { data } from "../modules/core/data/index.js";
|
||||
|
||||
let pool;
|
||||
|
||||
export async function init() {
|
||||
const config = getConfig();
|
||||
let ready = false;
|
||||
let client;
|
||||
while (!ready) {
|
||||
try {
|
||||
pool = new Pool({
|
||||
connectionString: config.postgresUrl,
|
||||
});
|
||||
client = await pool.connect();
|
||||
const res = await client.query(`SELECT 1`);
|
||||
if (res.rows.length < 1) {
|
||||
throw new Error("");
|
||||
}
|
||||
ready = true;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("DB not ready, waiting...");
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
await client.release();
|
||||
}
|
||||
|
||||
export async function getPool() {
|
||||
return await pool.connect();
|
||||
}
|
||||
|
||||
export async function close() {
|
||||
console.log(`Closing DB`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
export async function migrate() {
|
||||
console.log(`Starting migration`);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const migrationExists = await client.query(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN (SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = 'system' AND table_name = 'migrate') > 0
|
||||
THEN TRUE
|
||||
ELSE FALSE
|
||||
END AS exists
|
||||
`);
|
||||
if (!migrationExists.rows[0].exists) {
|
||||
await client.query(`
|
||||
DROP SCHEMA IF EXISTS system CASCADE;
|
||||
CREATE SCHEMA system;
|
||||
|
||||
-- Migrations
|
||||
DROP TABLE IF EXISTS system.migrate CASCADE;
|
||||
CREATE TABLE system.migrate (
|
||||
id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__system__migrate__id ON system.migrate (id);
|
||||
CREATE INDEX idx__system__migrate__status ON system.migrate (status);
|
||||
CREATE INDEX idx__system__migrate__created_ts ON system.migrate (created_ts);
|
||||
`);
|
||||
}
|
||||
let lastMigration = "0";
|
||||
const lastMigrationResult = await client.query(`
|
||||
SELECT id
|
||||
FROM system.migrate
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
`);
|
||||
if (lastMigrationResult.rows.length > 0 && lastMigrationResult.rows[0].id) {
|
||||
lastMigration = lastMigrationResult.rows[0].id;
|
||||
}
|
||||
const files = fs.readdirSync("./db/migrations", {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for await (const file of files) {
|
||||
const migrationId = file.name.slice(0, 6);
|
||||
if (migrationId > lastMigration) {
|
||||
const label = file.name.slice(7, file.name.length - 4);
|
||||
console.log(`Running migration ${label} (${migrationId})`);
|
||||
const sqlStr = fs.readFileSync(path.join("./db/migrations", file.name), "utf8");
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(sqlStr);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO system.migrate (
|
||||
id,
|
||||
status
|
||||
) VALUES (
|
||||
$1,
|
||||
'complete'
|
||||
);
|
||||
`,
|
||||
[migrationId],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await client.query("ROLLBACK");
|
||||
throw new Error("migration error");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
console.log(`Ending migration`);
|
||||
}
|
||||
|
||||
export async function populateAgreements() {
|
||||
console.log(`Starting agreement population`);
|
||||
const config = getConfig();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const files = fs.readdirSync("./db/agreements", {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for await (const file of files) {
|
||||
const agreementId = file.name.slice(0, 36);
|
||||
const title = file.name.slice(39, file.name.length - 3);
|
||||
const markdown = fs.readFileSync(path.join("./db/agreements", file.name), "utf8").trim();
|
||||
const hash = createHash('sha256')
|
||||
.update(markdown)
|
||||
.digest('hex')
|
||||
try {
|
||||
const agreementExists = await client.query(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN (SELECT COUNT(1) FROM agreement WHERE agreement_id_uuid = $1 AND user_id IS NULL) > 0
|
||||
THEN TRUE
|
||||
ELSE FALSE
|
||||
END AS exists
|
||||
`,
|
||||
[
|
||||
agreementId,
|
||||
]
|
||||
);
|
||||
if (!agreementExists.rows[0].exists) {
|
||||
console.log(`Adding agreement '${title}' (${agreementId})`);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_content (
|
||||
title,
|
||||
markdown,
|
||||
hash
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
title,
|
||||
markdown,
|
||||
hash,
|
||||
]);
|
||||
const agreement = {
|
||||
uri: `${config.publicCoreUrl}/agreements/${hash}`,
|
||||
purposes: [],
|
||||
caveats: [],
|
||||
shortNames: [],
|
||||
validRoles: [],
|
||||
}
|
||||
await data.agreement.create(agreement, null, client, agreementId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error("agreement population error");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
console.log(`Ending agreement population`);
|
||||
}
|
||||
45
src/db/migrations/000000 - init.sql
Normal file
45
src/db/migrations/000000 - init.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- DROP TABLE IF EXISTS public.user CASCADE;
|
||||
CREATE TABLE public.user (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT,
|
||||
photo TEXT,
|
||||
issuer TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
identifier TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__user__issuer_identifier UNIQUE (issuer, identifier)
|
||||
);
|
||||
CREATE INDEX idx__user__username ON public.user (username);
|
||||
CREATE INDEX idx__user__identifier ON public.user (identifier);
|
||||
CREATE INDEX idx__user__type ON public.user (type);
|
||||
CREATE INDEX idx__user__issuer ON public.user (issuer);
|
||||
CREATE INDEX idx__user__created_ts ON public.user (created_ts);
|
||||
CREATE INDEX idx__user__updated_ts ON public.user (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.app CASCADE;
|
||||
CREATE TABLE public.app (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
type TEXT UNIQUE NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__app__type ON public.app (type);
|
||||
CREATE INDEX idx__app__created_ts ON public.app (created_ts);
|
||||
CREATE INDEX idx__app__updated_ts ON public.app (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.auth CASCADE;
|
||||
CREATE TABLE public.auth (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
app_id BIGINT NOT NULL REFERENCES public.app(id),
|
||||
api_key TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__auth__user_id_app_id UNIQUE (user_id, app_id)
|
||||
);
|
||||
CREATE INDEX idx__auth__user_id ON public.auth (user_id);
|
||||
CREATE INDEX idx__auth__app_id ON public.auth (app_id);
|
||||
CREATE INDEX idx__auth__api_key ON public.auth (api_key);
|
||||
CREATE INDEX idx__auth__created_ts ON public.auth (created_ts);
|
||||
CREATE INDEX idx__auth__updated_ts ON public.auth (updated_ts);
|
||||
42
src/db/migrations/000001 - archive.sql
Normal file
42
src/db/migrations/000001 - archive.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
CREATE TYPE HASH_TYPE AS ENUM ('SHA256');
|
||||
CREATE TYPE SIGNATURE_TYPE AS ENUM ('JWS/JCS');
|
||||
|
||||
-- DROP TABLE IF EXISTS public.audit CASCADE;
|
||||
CREATE TABLE public.audit (
|
||||
audit_id BIGSERIAL PRIMARY KEY,
|
||||
version SMALLINT NOT NULL,
|
||||
event_id UUID,
|
||||
agreement_id UUID,
|
||||
hash_type HASH_TYPE NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__audit__event_id ON public.audit (event_id);
|
||||
CREATE INDEX idx__public__audit__agreement_id ON public.audit (agreement_id);
|
||||
CREATE INDEX idx__public__audit__created ON public.audit (created);
|
||||
CREATE INDEX idx__public__audit__created_ts ON public.audit (created_ts);
|
||||
CREATE INDEX idx__public__audit__updated_ts ON public.audit (updated_ts);
|
||||
ALTER TABLE public.audit
|
||||
ADD CONSTRAINT cnst__audit__event_or_agreement
|
||||
CHECK (num_nonnulls(event_id, agreement_id) = 1);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.audit_signature CASCADE;
|
||||
CREATE TABLE public.audit_signature (
|
||||
signature_id BIGSERIAL PRIMARY KEY,
|
||||
audit_id BIGINT NOT NULL REFERENCES public.audit(audit_id),
|
||||
version SMALLINT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
signedOn BIGINT NOT NULL,
|
||||
type SIGNATURE_TYPE NOT NULL,
|
||||
jws TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__audit_signature__id ON public.audit_signature (id);
|
||||
CREATE INDEX idx__public__audit_signature__signedOn ON public.audit_signature (signedOn);
|
||||
CREATE INDEX idx__public__audit_signature__type ON public.audit_signature (type);
|
||||
CREATE INDEX idx__public__audit_signature__created_ts ON public.audit_signature (created_ts);
|
||||
CREATE INDEX idx__public__audit_signature__updated_ts ON public.audit_signature (updated_ts);
|
||||
28
src/db/migrations/000002 - usage.sql
Normal file
28
src/db/migrations/000002 - usage.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- DROP TABLE IF EXISTS public.usage_url CASCADE;
|
||||
CREATE TABLE public.usage_url (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
module TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__usage_url__url_module UNIQUE (url, module)
|
||||
);
|
||||
CREATE INDEX idx__public__usage_url__url ON public.usage_url (url);
|
||||
CREATE INDEX idx__public__usage_url__module ON public.usage_url (module);
|
||||
CREATE INDEX idx__public__usage_url__created_ts ON public.usage_url (created_ts);
|
||||
CREATE INDEX idx__public__usage_url__updated_ts ON public.usage_url (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.usage CASCADE;
|
||||
CREATE TABLE public.usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
usage_url_id BIGINT NOT NULL REFERENCES public.usage_url(id),
|
||||
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__usage__user_id ON public.usage (user_id);
|
||||
CREATE INDEX idx__public__usage__usage_url_id ON public.usage (usage_url_id);
|
||||
CREATE INDEX idx__public__usage__success ON public.usage (success);
|
||||
CREATE INDEX idx__public__usage__created_ts ON public.usage (created_ts);
|
||||
CREATE INDEX idx__public__usage__updated_ts ON public.usage (updated_ts);
|
||||
220
src/db/migrations/000003 - data.sql
Normal file
220
src/db/migrations/000003 - data.sql
Normal file
@@ -0,0 +1,220 @@
|
||||
-- Agreements
|
||||
-- DROP TABLE IF EXISTS public.agreement CASCADE;
|
||||
CREATE TABLE public.agreement (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES public.user(id),
|
||||
version SMALLINT NOT NULL,
|
||||
parent TEXT NOT NULL,
|
||||
agreement_id_uuid UUID UNIQUE NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
created_as_ts TIMESTAMPTZ NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__agreement__user_id ON public.agreement (user_id);
|
||||
CREATE INDEX idx__public__agreement__version ON public.agreement (version);
|
||||
CREATE INDEX idx__public__agreement__parent ON public.agreement (parent);
|
||||
CREATE INDEX idx__public__agreement__agreement_id_uuid ON public.agreement (agreement_id_uuid);
|
||||
CREATE INDEX idx__public__agreement__created ON public.agreement (created);
|
||||
CREATE INDEX idx__public__agreement__created_as_ts ON public.agreement (created_as_ts);
|
||||
CREATE INDEX idx__public__agreement__created_ts ON public.agreement (created_ts);
|
||||
CREATE INDEX idx__public__agreement__updated_ts ON public.agreement (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.agreement_required_id CASCADE;
|
||||
CREATE TABLE public.agreement_required_id (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
|
||||
required_id TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__agreement_required_id UNIQUE (user_id, agreement_id, required_id)
|
||||
);
|
||||
CREATE INDEX idx__public__agreement_required_id__user_id ON public.agreement_required_id (user_id);
|
||||
CREATE INDEX idx__public__agreement_required_id__agreement_id ON public.agreement_required_id (agreement_id);
|
||||
CREATE INDEX idx__public__agreement_required_id__created_ts ON public.agreement_required_id (created_ts);
|
||||
CREATE INDEX idx__public__agreement_required_id__updated_ts ON public.agreement_required_id (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.purpose CASCADE;
|
||||
CREATE TABLE public.purpose (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
value TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__purpose UNIQUE (user_id, value)
|
||||
);
|
||||
CREATE INDEX idx__public__purpose__user_id ON public.purpose (user_id);
|
||||
CREATE INDEX idx__public__purpose__value ON public.purpose (value);
|
||||
CREATE INDEX idx__public__purpose__created_ts ON public.purpose (created_ts);
|
||||
CREATE INDEX idx__public__purpose__updated_ts ON public.purpose (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.agreement_purpose CASCADE;
|
||||
CREATE TABLE public.agreement_purpose (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
|
||||
purpose_id BIGINT NOT NULL REFERENCES public.purpose(id),
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__agreement_purpose UNIQUE (user_id, agreement_id, purpose_id)
|
||||
);
|
||||
CREATE INDEX idx__public__agreement_purpose__user_id ON public.agreement_purpose (user_id);
|
||||
CREATE INDEX idx__public__agreement_purpose__agreement_id ON public.agreement_purpose (agreement_id);
|
||||
CREATE INDEX idx__public__agreement_purpose__purpose_id ON public.agreement_purpose (purpose_id);
|
||||
CREATE INDEX idx__public__agreement_purpose__created_ts ON public.agreement_purpose (created_ts);
|
||||
CREATE INDEX idx__public__agreement_purpose__updated_ts ON public.agreement_purpose (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.caveat CASCADE;
|
||||
CREATE TABLE public.caveat (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
value TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__caveat UNIQUE (user_id, value)
|
||||
);
|
||||
CREATE INDEX idx__public__caveat__user_id ON public.caveat (user_id);
|
||||
CREATE INDEX idx__public__caveat__value ON public.caveat (value);
|
||||
CREATE INDEX idx__public__caveat__created_ts ON public.caveat (created_ts);
|
||||
CREATE INDEX idx__public__caveat__updated_ts ON public.caveat (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.agreement_caveat CASCADE;
|
||||
CREATE TABLE public.agreement_caveat (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
|
||||
caveat_id BIGINT NOT NULL REFERENCES public.caveat(id),
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__agreement_caveat UNIQUE (user_id, agreement_id, caveat_id)
|
||||
);
|
||||
CREATE INDEX idx__public__agreement_caveat__user_id ON public.agreement_caveat (user_id);
|
||||
CREATE INDEX idx__public__agreement_caveat__agreement_id ON public.agreement_caveat (agreement_id);
|
||||
CREATE INDEX idx__public__agreement_caveat__caveat_id ON public.agreement_caveat (caveat_id);
|
||||
CREATE INDEX idx__public__agreement_caveat__created_ts ON public.agreement_caveat (created_ts);
|
||||
CREATE INDEX idx__public__agreement_caveat__updated_ts ON public.agreement_caveat (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.role CASCADE;
|
||||
CREATE TABLE public.role (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
value TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__role UNIQUE (user_id, value)
|
||||
);
|
||||
CREATE INDEX idx__public__role__user_id ON public.role (user_id);
|
||||
CREATE INDEX idx__public__role__value ON public.role (value);
|
||||
CREATE INDEX idx__public__role__created_ts ON public.role (created_ts);
|
||||
CREATE INDEX idx__public__role__updated_ts ON public.role (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.agreement_role CASCADE;
|
||||
CREATE TABLE public.agreement_role (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
|
||||
role_id BIGINT NOT NULL REFERENCES public.role(id),
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__agreement_role UNIQUE (user_id, agreement_id, role_id)
|
||||
);
|
||||
CREATE INDEX idx__public__agreement_role__user_id ON public.agreement_role (user_id);
|
||||
CREATE INDEX idx__public__agreement_role__agreement_id ON public.agreement_role (agreement_id);
|
||||
CREATE INDEX idx__public__agreement_role__role_id ON public.agreement_role (role_id);
|
||||
CREATE INDEX idx__public__agreement_role__created_ts ON public.agreement_role (created_ts);
|
||||
CREATE INDEX idx__public__agreement_role__updated_ts ON public.agreement_role (updated_ts);
|
||||
|
||||
|
||||
-- Events
|
||||
-- DROP TABLE IF EXISTS public.event_type CASCADE;
|
||||
CREATE TABLE public.event_type (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__event_type__value ON public.event_type (value);
|
||||
CREATE INDEX idx__public__event_type__created_ts ON public.event_type (created_ts);
|
||||
CREATE INDEX idx__public__event_type__updated_ts ON public.event_type (updated_ts);
|
||||
|
||||
INSERT INTO public.event_type (value) VALUES ('data');
|
||||
|
||||
-- DROP TABLE IF EXISTS public.event CASCADE;
|
||||
CREATE TABLE public.event (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
version SMALLINT NOT NULL,
|
||||
event_id_uuid UUID UNIQUE NOT NULL,
|
||||
event_type_id BIGINT NOT NULL REFERENCES public.event_type(id),
|
||||
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
|
||||
sender_id TEXT NOT NULL,
|
||||
recipient_id TEXT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
created_as_ts TIMESTAMPTZ NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__event__user_id ON public.event (user_id);
|
||||
CREATE INDEX idx__public__event__version ON public.event (version);
|
||||
CREATE INDEX idx__public__event__event_id_uuid ON public.event (event_id_uuid);
|
||||
CREATE INDEX idx__public__event__event_type_id ON public.event (event_type_id);
|
||||
CREATE INDEX idx__public__event__agreement_id ON public.event (agreement_id);
|
||||
CREATE INDEX idx__public__event__sender_id ON public.event (sender_id);
|
||||
CREATE INDEX idx__public__event__recipient_id ON public.event (recipient_id);
|
||||
CREATE INDEX idx__public__event__created_ts ON public.event (created_ts);
|
||||
CREATE INDEX idx__public__event__updated_ts ON public.event (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.event_data CASCADE;
|
||||
-- DROP SEQUENCE IF EXISTS seq__event_data__id;
|
||||
-- CREATE SEQUENCE seq__event_data__id;
|
||||
-- CREATE TABLE public.event_data (
|
||||
-- id BIGINT NOT NULL DEFAULT nextval('seq__event_data__id'),
|
||||
-- user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
-- event_id BIGINT NOT NULL REFERENCES public.event(id),
|
||||
-- data TEXT NOT NULL,
|
||||
-- created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- PRIMARY KEY (user_id, id)
|
||||
-- ) PARTITION BY LIST (user_id);
|
||||
CREATE TABLE public.event_data (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
event_id BIGINT NOT NULL REFERENCES public.event(id),
|
||||
data TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__event_data__event_id ON public.event_data (event_id);
|
||||
CREATE INDEX idx__public__event_data__user_id ON public.event_data (user_id);
|
||||
CREATE INDEX idx__public__event_data__created_ts ON public.event_data (created_ts);
|
||||
CREATE INDEX idx__public__event_data__updated_ts ON public.event_data (updated_ts);
|
||||
|
||||
-- Signatures
|
||||
-- DROP TABLE IF EXISTS public.signature CASCADE;
|
||||
CREATE TABLE public.signature (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
version SMALLINT NOT NULL,
|
||||
signer_id TEXT NOT NULL,
|
||||
signed_on BIGINT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
jws TEXT NOT NULL,
|
||||
role_id BIGINT REFERENCES public.role(id),
|
||||
agreement_id BIGINT REFERENCES public.agreement(id),
|
||||
event_id BIGINT REFERENCES public.event(id),
|
||||
signed_on_as_ts TIMESTAMPTZ NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__signature__agreement UNIQUE (user_id, signer_id, agreement_id),
|
||||
CONSTRAINT uniq__signature__event UNIQUE (user_id, signer_id, event_id)
|
||||
);
|
||||
CREATE INDEX idx__public__signature__user_id ON public.signature (user_id);
|
||||
CREATE INDEX idx__public__signature__version ON public.signature (version);
|
||||
CREATE INDEX idx__public__signature__signed_on ON public.signature (signed_on);
|
||||
CREATE INDEX idx__public__signature__role_id ON public.signature (role_id);
|
||||
CREATE INDEX idx__public__signature__agreement_id ON public.signature (agreement_id);
|
||||
CREATE INDEX idx__public__signature__event_id ON public.signature (event_id);
|
||||
CREATE INDEX idx__public__signature__signed_on_as_ts ON public.signature (signed_on_as_ts);
|
||||
CREATE INDEX idx__public__signature__created_ts ON public.signature (created_ts);
|
||||
CREATE INDEX idx__public__signature__updated_ts ON public.signature (updated_ts);
|
||||
19
src/db/migrations/000004 - entity.sql
Normal file
19
src/db/migrations/000004 - entity.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- DROP TABLE IF EXISTS public.entity CASCADE;
|
||||
CREATE TABLE public.entity (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
fedid_url TEXT NOT NULL,
|
||||
short_name TEXT NOT NULL,
|
||||
did_id TEXT NOT NULL,
|
||||
control_private_key_b64u TEXT NOT NULL,
|
||||
recovery_private_key_b64u TEXT NOT NULL,
|
||||
did_doc JSONB NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__entity__user_id ON public.entity (user_id);
|
||||
CREATE INDEX idx__public__entity__fedid_url ON public.entity (fedid_url);
|
||||
CREATE INDEX idx__public__entity__short_name ON public.entity (short_name);
|
||||
CREATE INDEX idx__public__entity__did_id ON public.entity (did_id);
|
||||
CREATE INDEX idx__public__entity__created_ts ON public.entity (created_ts);
|
||||
CREATE INDEX idx__public__entity__updated_ts ON public.entity (updated_ts);
|
||||
14
src/db/migrations/000005 - agreement-content.sql
Normal file
14
src/db/migrations/000005 - agreement-content.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- DROP TABLE IF EXISTS public.agreement_content CASCADE;
|
||||
CREATE TABLE public.agreement_content (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES public.user(id),
|
||||
title TEXT NOT NULL,
|
||||
markdown TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uniq__agreement_content UNIQUE NULLS NOT DISTINCT (user_id, title)
|
||||
);
|
||||
CREATE INDEX idx__public__agreement_content__user_id ON public.agreement_content (user_id);
|
||||
CREATE INDEX idx__public__agreement_content__created_ts ON public.agreement_content (created_ts);
|
||||
CREATE INDEX idx__public__agreement_content__updated_ts ON public.agreement_content (updated_ts);
|
||||
1
src/db/migrations/000006 - audit-index.sql
Normal file
1
src/db/migrations/000006 - audit-index.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE INDEX idx__public__audit_signature__audit_id ON public.audit_signature (audit_id);
|
||||
31
src/db/migrations/000007 - meta.sql
Normal file
31
src/db/migrations/000007 - meta.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- DROP TABLE IF EXISTS public.event_meta CASCADE;
|
||||
CREATE TABLE public.event_meta (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES public.user(id),
|
||||
event_id BIGINT NOT NULL REFERENCES public.event(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__event_meta__event_id ON public.event_meta (event_id);
|
||||
CREATE INDEX idx__public__event_meta__user_id ON public.event_meta (user_id);
|
||||
CREATE INDEX idx__public__event_meta__key ON public.event_meta (key);
|
||||
CREATE INDEX idx__public__event_meta__value ON public.event_meta (value);
|
||||
CREATE INDEX idx__public__event_meta__created_ts ON public.event_meta (created_ts);
|
||||
CREATE INDEX idx__public__event_meta__updated_ts ON public.event_meta (updated_ts);
|
||||
|
||||
-- DROP TABLE IF EXISTS public.audit_meta CASCADE;
|
||||
CREATE TABLE public.audit_meta (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
audit_id BIGINT NOT NULL REFERENCES public.audit(audit_id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx__public__audit_meta__audit_id ON public.audit_meta (audit_id);
|
||||
CREATE INDEX idx__public__audit_meta__key ON public.audit_meta (key);
|
||||
CREATE INDEX idx__public__audit_meta__value ON public.audit_meta (value);
|
||||
CREATE INDEX idx__public__audit_meta__created_ts ON public.audit_meta (created_ts);
|
||||
CREATE INDEX idx__public__audit_meta__updated_ts ON public.audit_meta (updated_ts);
|
||||
25
src/eslint.config.js
Normal file
25
src/eslint.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import babelParser from "@babel/eslint-parser";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
|
||||
export default [
|
||||
{
|
||||
languageOptions: {
|
||||
parser: babelParser, // Reference the @babel/eslint-parser directly
|
||||
parserOptions: {
|
||||
requireConfigFile: false, // Optional: set to false if you don't have a babel config file
|
||||
babelOptions: {
|
||||
plugins: ["@babel/plugin-syntax-import-assertions"], // Add Babel plugin for import assertions
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node, // Include Node.js global variables here
|
||||
...globals.jest, // Jest globals
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended, // ESLint recommended rules
|
||||
eslintConfigPrettier, // Enable Prettier integration
|
||||
];
|
||||
47
src/http/agreements.js
Normal file
47
src/http/agreements.js
Normal file
@@ -0,0 +1,47 @@
|
||||
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 marked(content);
|
||||
}
|
||||
|
||||
export function routeAgreements(app) {
|
||||
|
||||
app.get('/agreements/:hash', async (req, res) => {
|
||||
const { hash } = req.params;
|
||||
const agreement = await getAgreementContent(null, hash);
|
||||
res.send(agreement);
|
||||
});
|
||||
|
||||
// Route for /agreements/:userid/:hash
|
||||
app.get('/agreements/:userId/:hash', async (req, res) => {
|
||||
const { userId, hash } = req.params;
|
||||
const agreement = await getAgreementContent(userId, hash);
|
||||
res.send(agreement);
|
||||
});
|
||||
|
||||
}
|
||||
108
src/http/api/v1/swagger.json
Normal file
108
src/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/http/auth.js
Normal file
184
src/http/auth.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { getPool } from "../db/index.js";
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function apiMiddleware(req, res, next) {
|
||||
let success = false;
|
||||
const client = await getPool();
|
||||
try {
|
||||
const config = getConfig();
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader) {
|
||||
const apiKey = authHeader.split(' ')[1];
|
||||
if (apiKey) {
|
||||
const validRes = await client.query(`
|
||||
SELECT
|
||||
au.user_id,
|
||||
ap.type
|
||||
FROM public.auth au
|
||||
INNER JOIN public.app ap ON ap.id = au.app_id
|
||||
WHERE au.api_key = $1
|
||||
`, [
|
||||
apiKey,
|
||||
]);
|
||||
if (validRes.rowCount > 0) {
|
||||
// if (config.debug) console.log({apiKey});
|
||||
req.session.user_id = validRes.rows[0].user_id;
|
||||
success = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
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) {
|
||||
const config = getConfig();
|
||||
for (const type in config.appModules) {
|
||||
const apiKey = getNewKey(user);
|
||||
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,
|
||||
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
|
||||
WHERE u.identifier = $1
|
||||
AND u.issuer = $2
|
||||
GROUP BY
|
||||
u.id,
|
||||
u.username,
|
||||
u.photo,
|
||||
u.issuer,
|
||||
u.identifier
|
||||
`,
|
||||
[
|
||||
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;
|
||||
}
|
||||
const res = await client.query(`
|
||||
INSERT INTO public.user (
|
||||
username,
|
||||
issuer,
|
||||
identifier,
|
||||
photo,
|
||||
type
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5
|
||||
) RETURNING id;
|
||||
`, [
|
||||
username,
|
||||
issuer,
|
||||
identifier,
|
||||
photo,
|
||||
type,
|
||||
]);
|
||||
console.log(res.rows[0])
|
||||
if (res.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
await createApiKeys(client, res.rows[0]);
|
||||
} 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]);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
91
src/http/index.js
Normal file
91
src/http/index.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import bodyParser from "body-parser";
|
||||
import { initModules, apiMiddleware } from "./auth.js";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import swaggerDocument from "./api/v1/swagger.json" assert { type: "json" };
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { core } from "../modules/core/index.js"
|
||||
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import MemoryStore from "memorystore";
|
||||
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 { getUsage } from "../modules/core/usage.js";
|
||||
|
||||
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);
|
||||
res.render(view, {
|
||||
config,
|
||||
user: req.user,
|
||||
usage,
|
||||
});
|
||||
} 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 memoryStore = MemoryStore(session);
|
||||
const sess = {
|
||||
secret: config.secureSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new memoryStore({
|
||||
checkPeriod: 86400000 // prune expired entries every 24h
|
||||
}),
|
||||
cookie: {},
|
||||
}
|
||||
if (app.get('env') === 'production') {
|
||||
app.set('trust proxy', 1) // trust first proxy
|
||||
sess.cookie.secure = true // serve secure cookies
|
||||
}
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
app.post("/api/v1/*", bodyParser.json(), apiMiddleware, core.post);
|
||||
|
||||
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
}
|
||||
40
src/http/logging.js
Normal file
40
src/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
src/http/logout.js
Normal file
22
src/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
src/http/public/images/icon.svg
Normal file
18
src/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
src/http/refresh.js
Normal file
28
src/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');
|
||||
}
|
||||
}
|
||||
15
src/http/views/dashboard.ejs
Normal file
15
src/http/views/dashboard.ejs
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %>
|
||||
|
||||
<% for (const type in config.appModules) { %>
|
||||
<%-
|
||||
include('./include/app.ejs', {
|
||||
app: config.appModules[type],
|
||||
usage: usage ? usage[type] : null
|
||||
})
|
||||
%>
|
||||
<% } %>
|
||||
|
||||
<%- include('./include/footer.ejs') %>
|
||||
163
src/http/views/include/app.ejs
Normal file
163
src/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>
|
||||
4
src/http/views/include/footer.ejs
Normal file
4
src/http/views/include/footer.ejs
Normal file
@@ -0,0 +1,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
118
src/http/views/include/header.ejs
Normal file
118
src/http/views/include/header.ejs
Normal file
@@ -0,0 +1,118 @@
|
||||
<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); */
|
||||
}
|
||||
</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: 90%; max-width: 600px;">
|
||||
<div
|
||||
style="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
src/http/views/include/logo-white.svg
Normal file
1
src/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 |
21
src/http/views/login.ejs
Normal file
21
src/http/views/login.ejs
Normal file
@@ -0,0 +1,21 @@
|
||||
<!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) { %>
|
||||
<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') %>
|
||||
24
src/index.js
Normal file
24
src/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from "express";
|
||||
import { init, close, migrate, populateAgreements } from "./db/index.js";
|
||||
import { loadConfig } from "./common/config.js";
|
||||
import { loadApps } from "./apps.js";
|
||||
import { initHTTP } from "./http/index.js";
|
||||
|
||||
const app = express();
|
||||
app.set("env", "development");
|
||||
app.use(express.static("./public"));
|
||||
|
||||
async function main() {
|
||||
await loadConfig();
|
||||
await init();
|
||||
await migrate();
|
||||
await populateAgreements();
|
||||
await loadApps();
|
||||
await initHTTP(app);
|
||||
const server = await app.listen(9090, () => {
|
||||
console.log(`Listening on 0.0.0.0:9090`);
|
||||
});
|
||||
server.on("beforeExit", close);
|
||||
}
|
||||
|
||||
main();
|
||||
41
src/modules/app/archive.js
Normal file
41
src/modules/app/archive.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'archive';
|
||||
|
||||
const logo = `
|
||||
<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>
|
||||
`
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'JLINC Archive/Audit',
|
||||
logo,
|
||||
type: key,
|
||||
endpoint: config.publicArchiveUrl,
|
||||
background: {
|
||||
color1: 'rgb(79, 55, 139)',
|
||||
color2: 'rgb(19, 2, 28)',
|
||||
},
|
||||
button: {
|
||||
color: 'rgb(255, 205, 57)',
|
||||
},
|
||||
}
|
||||
}
|
||||
41
src/modules/app/core.js
Normal file
41
src/modules/app/core.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'core';
|
||||
|
||||
const logo = `
|
||||
<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>
|
||||
`
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'JLINC Protocol',
|
||||
logo,
|
||||
type: key,
|
||||
endpoint: config.publicCoreUrl,
|
||||
background: {
|
||||
color1: 'rgb(79, 55, 139)',
|
||||
color2: 'rgb(19, 2, 28)',
|
||||
},
|
||||
button: {
|
||||
color: 'rgb(255, 205, 57)',
|
||||
},
|
||||
}
|
||||
}
|
||||
46
src/modules/auth/github.js
Normal file
46
src/modules/auth/github.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import GitHubStrategy from "passport-github";
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'github';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'GitHub',
|
||||
icon: 'code',
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: `${config.publicCallbackUrl}/callback/${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
const config = getConfig();
|
||||
|
||||
passport.use(new GitHubStrategy(
|
||||
config.authModules[key],
|
||||
function (accessToken, refreshToken, profile, cb) {
|
||||
(async () => {
|
||||
try {
|
||||
const user = await checkUser(key, 'https://github.com', profile.id, profile.username);
|
||||
return cb(null, user);
|
||||
} catch (e) {
|
||||
if (config.debug)
|
||||
console.error(e);
|
||||
return cb(null, null);
|
||||
}
|
||||
})();
|
||||
}
|
||||
));
|
||||
app.get(`/login/${key}`, passport.authenticate('github'));
|
||||
app.get(`/callback/${key}`,
|
||||
passport.authenticate('github', {
|
||||
failureRedirect: '/',
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
);
|
||||
}
|
||||
46
src/modules/auth/google.js
Normal file
46
src/modules/auth/google.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import GoogleStrategy from "passport-google-oauth20";
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'google';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'Google',
|
||||
icon: 'language',
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${config.publicCallbackUrl}/callback/${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
const config = getConfig();
|
||||
passport.use(new GoogleStrategy.Strategy(
|
||||
config.authModules[key],
|
||||
function (accessToken, refreshToken, profile, cb) {
|
||||
(async () => {
|
||||
try {
|
||||
const photo = profile.photos?.length > 0 ? profile.photos[0].value : null;
|
||||
const user = await checkUser(key, 'https://google.com', profile.id, profile.displayName, photo);
|
||||
return cb(null, user);
|
||||
} catch (e) {
|
||||
if (config.debug)
|
||||
console.error(e);
|
||||
return cb(null, null);
|
||||
}
|
||||
})();
|
||||
}
|
||||
));
|
||||
app.get(`/login/${key}`, passport.authenticate('google', { scope: ['profile'] }));
|
||||
app.get(`/callback/${key}`,
|
||||
passport.authenticate('google', {
|
||||
failureRedirect: '/',
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
);
|
||||
}
|
||||
52
src/modules/auth/oidc.js
Normal file
52
src/modules/auth/oidc.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import OpenIDConnectStrategy from "passport-openidconnect";
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'oidc';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'FedID',
|
||||
icon: 'login',
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
|
||||
tokenURL: process.env.OIDC_TOKEN_URL,
|
||||
userInfoURL: process.env.OIDC_USERINFO_URL,
|
||||
logoutURL: process.env.OIDC_LOGOUT_URL,
|
||||
clientID: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
callbackURL: `${config.publicCallbackUrl}/callback/${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
const config = getConfig();
|
||||
|
||||
passport.use(new OpenIDConnectStrategy(
|
||||
config.authModules[key],
|
||||
function verify(issuer, profile, cb) {
|
||||
(async () => {
|
||||
try {
|
||||
const user = await checkUser(key, issuer, profile.id, profile.username);
|
||||
return cb(null, user);
|
||||
} catch (e) {
|
||||
if (config.debug)
|
||||
console.error(e);
|
||||
return cb(null, null);
|
||||
}
|
||||
})();
|
||||
}
|
||||
));
|
||||
app.get(`/login/${key}`,passport.authenticate('openidconnect'));
|
||||
app.get(`/callback/${key}`,
|
||||
passport.authenticate('openidconnect', {
|
||||
failureRedirect: '/',
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
req.session.authStrategy = 'oidc';
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
);
|
||||
}
|
||||
36
src/modules/core/agreement.js
Normal file
36
src/modules/core/agreement.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAgreement } = pkg;
|
||||
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincAgreement.create(input);
|
||||
const message = data?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(input) {
|
||||
const data = await JlincAgreement.sign(input);
|
||||
const message = data?.agreement?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincAgreement.send(input);
|
||||
const message = data?.agreement?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const agreement = {
|
||||
create,
|
||||
sign,
|
||||
send,
|
||||
}
|
||||
433
src/modules/core/archive.js
Normal file
433
src/modules/core/archive.js
Normal file
@@ -0,0 +1,433 @@
|
||||
import { getPool } from "../../db/index.js";
|
||||
|
||||
function isUuid(uuid) {
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getAuditErrors(audit) {
|
||||
if (typeof audit !== 'object') {
|
||||
return 'invalid audit object format';
|
||||
}
|
||||
|
||||
if (audit.version != 1) {
|
||||
return 'audit version invalid';
|
||||
}
|
||||
|
||||
if (audit.hashType != 'SHA256') {
|
||||
return 'audit hash type invalid';
|
||||
}
|
||||
|
||||
if (!audit.digest) {
|
||||
return 'audit digest should exist';
|
||||
}
|
||||
|
||||
if (isNaN(audit.created)) {
|
||||
return 'audit created type invalid';
|
||||
}
|
||||
|
||||
if (!audit.eventId && !audit.agreementId) {
|
||||
return 'eventId or agreementId should exist';
|
||||
}
|
||||
|
||||
if (audit.eventId && !isUuid(audit.eventId)) {
|
||||
return 'eventId invalid';
|
||||
}
|
||||
|
||||
if (audit.agreementId && !isUuid(audit.agreementId)) {
|
||||
return 'agreementId invalid';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processAudit(data) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
let type = data.audit.eventId ? 'event' : 'agreement';
|
||||
const digest = data.audit.digest;
|
||||
let id = data.audit.eventId ? data.audit.eventId : data.audit.agreementId;
|
||||
await client.query(`BEGIN`);
|
||||
let res = await client.query(`
|
||||
SELECT 1
|
||||
FROM audit a
|
||||
WHERE a.${type}_id = $1
|
||||
AND digest = $2
|
||||
LIMIT 1;
|
||||
`, [
|
||||
id,
|
||||
digest,
|
||||
]);
|
||||
if (res.rows.length > 0) {
|
||||
throw new Error('audit record with id and digest exists');
|
||||
}
|
||||
res = await client.query(`
|
||||
INSERT INTO audit (
|
||||
version,
|
||||
event_id,
|
||||
agreement_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
) RETURNING audit_id;
|
||||
`, [
|
||||
data.audit.version,
|
||||
data.audit.eventId,
|
||||
data.audit.agreementId,
|
||||
data.audit.hashType,
|
||||
data.audit.digest,
|
||||
data.audit.created,
|
||||
]);
|
||||
for (const signature of data.signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO audit_signature (
|
||||
audit_id,
|
||||
version,
|
||||
id,
|
||||
signedOn,
|
||||
type,
|
||||
jws
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
);
|
||||
`, [
|
||||
res.rows[0].audit_id,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
]);
|
||||
}
|
||||
if (data.meta) {
|
||||
for await (const [key, value] of Object.entries(data.meta)) {
|
||||
await client.query(`
|
||||
INSERT INTO audit_meta (
|
||||
audit_id,
|
||||
key,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
res.rows[0].audit_id,
|
||||
key,
|
||||
value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuditsByAgreementId(client, id, offset, limit) {
|
||||
let ret = await client.query(`
|
||||
WITH origAudits AS (
|
||||
SELECT
|
||||
audit_id,
|
||||
version,
|
||||
agreement_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
FROM audit
|
||||
WHERE agreement_id = $1
|
||||
ORDER BY created DESC
|
||||
OFFSET $2
|
||||
LIMIT $3
|
||||
),
|
||||
audits AS (
|
||||
SELECT
|
||||
a.*,
|
||||
JSON_AGG(json_build_object(
|
||||
'version', s.version,
|
||||
'id', s.id,
|
||||
'signedOn', s.signedOn,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)) AS signatures
|
||||
FROM
|
||||
origAudits a,
|
||||
audit_signature s
|
||||
WHERE s.audit_id = a.audit_id
|
||||
GROUP BY
|
||||
a.audit_id,
|
||||
a.version,
|
||||
a.agreement_id,
|
||||
a.hash_type,
|
||||
a.digest,
|
||||
a.created
|
||||
)
|
||||
SELECT
|
||||
JSON_AGG(json_build_object(
|
||||
'audit', json_build_object(
|
||||
'version', a.version,
|
||||
'agreementId', a.agreement_id,
|
||||
'hashType', a.hash_type,
|
||||
'digest', a.digest,
|
||||
'created', a.created
|
||||
),
|
||||
'signatures', a.signatures
|
||||
)) AS records
|
||||
FROM audits a
|
||||
`, [
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
]);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getAuditsByEventId(client, id, offset, limit) {
|
||||
let ret = await client.query(`
|
||||
WITH origAudits AS (
|
||||
SELECT
|
||||
audit_id,
|
||||
version,
|
||||
event_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
FROM audit
|
||||
WHERE event_id = $1
|
||||
ORDER BY created DESC
|
||||
OFFSET $2
|
||||
LIMIT $3
|
||||
),
|
||||
audits AS (
|
||||
SELECT
|
||||
a.*,
|
||||
JSON_AGG(json_build_object(
|
||||
'version', s.version,
|
||||
'id', s.id,
|
||||
'signedOn', s.signedOn,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)) AS signatures
|
||||
FROM
|
||||
origAudits a,
|
||||
audit_signature s
|
||||
WHERE s.audit_id = a.audit_id
|
||||
GROUP BY
|
||||
a.audit_id,
|
||||
a.version,
|
||||
a.event_id,
|
||||
a.hash_type,
|
||||
a.digest,
|
||||
a.created
|
||||
)
|
||||
SELECT
|
||||
JSON_AGG(json_build_object(
|
||||
'audit', json_build_object(
|
||||
'version', a.version,
|
||||
'eventId', a.event_id,
|
||||
'hashType', a.hash_type,
|
||||
'digest', a.digest,
|
||||
'created', a.created
|
||||
),
|
||||
'signatures', a.signatures
|
||||
)) AS records
|
||||
FROM audits a
|
||||
`, [
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
]);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getAuditsByMeta(client, meta, offset, limit) {
|
||||
const fields = [offset, limit];
|
||||
let count = fields.length + 1;
|
||||
let whereInVals = ``
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
if (whereInVals != ``)
|
||||
whereInVals = ` AND `
|
||||
whereInVals += `(key = $${count++} AND value = $${count++})`;
|
||||
fields.push(key);
|
||||
fields.push(value);
|
||||
}
|
||||
let ret = await client.query(`
|
||||
WITH origAudits AS (
|
||||
SELECT
|
||||
audit_id,
|
||||
version,
|
||||
event_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
FROM audit
|
||||
WHERE audit_id IN (
|
||||
SELECT audit_id
|
||||
FROM audit_meta
|
||||
WHERE ${whereInVals}
|
||||
)
|
||||
ORDER BY created DESC
|
||||
OFFSET $1
|
||||
LIMIT $2
|
||||
),
|
||||
audits AS (
|
||||
SELECT
|
||||
a.*,
|
||||
JSON_AGG(json_build_object(
|
||||
'version', s.version,
|
||||
'id', s.id,
|
||||
'signedOn', s.signedOn,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)) AS signatures
|
||||
FROM
|
||||
origAudits a,
|
||||
audit_signature s
|
||||
WHERE s.audit_id = a.audit_id
|
||||
GROUP BY
|
||||
a.audit_id,
|
||||
a.version,
|
||||
a.event_id,
|
||||
a.hash_type,
|
||||
a.digest,
|
||||
a.created
|
||||
)
|
||||
SELECT
|
||||
JSON_AGG(json_build_object(
|
||||
'audit', json_build_object(
|
||||
'version', a.version,
|
||||
'eventId', a.event_id,
|
||||
'hashType', a.hash_type,
|
||||
'digest', a.digest,
|
||||
'created', a.created
|
||||
),
|
||||
'signatures', a.signatures
|
||||
)) AS records
|
||||
FROM audits a
|
||||
`, fields);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getAudits(eventId, agreementId, meta, offset) {
|
||||
let ret;
|
||||
let res;
|
||||
const limit = 100;
|
||||
const client = await getPool();
|
||||
try {
|
||||
if (eventId) {
|
||||
res = await getAuditsByEventId(client, eventId, offset, limit);
|
||||
} else if (agreementId) {
|
||||
res = await getAuditsByAgreementId(client, agreementId, offset, limit);
|
||||
} else {
|
||||
res = await getAuditsByMeta(client, meta, offset, limit);
|
||||
}
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
ret = {
|
||||
auditRecords: res.rows[0].records
|
||||
};
|
||||
if (ret.auditRecords.length === limit) {
|
||||
ret.nextPageToken = Buffer.from(`${offset + limit}`).toString("base64");
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function put(input) {
|
||||
let res = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
const { audit, meta } = input;
|
||||
const error = getAuditErrors(audit);
|
||||
if (error) {
|
||||
res.error = error;
|
||||
console.log(`${prefix} - Bad Audit: ${error}`);
|
||||
} else {
|
||||
const id = audit.eventId ? audit.eventId : audit.agreementId;
|
||||
if (audit.eventId) {
|
||||
res.message = `event: ${audit.eventId}`;
|
||||
} else {
|
||||
res.message = `agreement: ${audit.agreementId}`;
|
||||
}
|
||||
await processAudit(input);
|
||||
res.success = true;
|
||||
delete res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message != 'audit record with id and digest exists')
|
||||
console.error(e);
|
||||
res.error = e.message;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function get(input) {
|
||||
let res = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
let error;
|
||||
let id;
|
||||
let type;
|
||||
if (input.eventId) {
|
||||
id = input.eventId;
|
||||
type = 'event';
|
||||
error = isUuid(input.eventId) ? null : 'eventId invalid';
|
||||
} else if (input.agreementId) {
|
||||
id = input.agreementId;
|
||||
type = 'agreement';
|
||||
error = isUuid(input.agreementId) ? null : 'agreementId invalid';
|
||||
} else if (input.meta) {
|
||||
id = `<meta key/value>`;
|
||||
type = 'meta';
|
||||
} else {
|
||||
error = 'eventId, agreementId, or meta required';
|
||||
}
|
||||
if (error) {
|
||||
res.error = error;
|
||||
res.message = `bad request: ${error}`;
|
||||
} else {
|
||||
res.message = `requested ${type}: ${id}`;
|
||||
const offset = input.pageToken ? parseInt(Buffer.from(`${input.pageToken}`, "base64").toString('ascii')) : 0;
|
||||
let ret = await getAudits(input.eventId, input.agreementId, input.meta, offset);
|
||||
if (ret) {
|
||||
res.data = ret;
|
||||
res.success = true;
|
||||
delete res.error;
|
||||
} else {
|
||||
res.error = "not found"
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message != 'audit record with id and digest exists')
|
||||
console.error(e);
|
||||
res.error = e.message;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const archive = {
|
||||
put,
|
||||
get,
|
||||
}
|
||||
51
src/modules/core/audit.js
Normal file
51
src/modules/core/audit.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAudit } = pkg;
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincAudit.create(input);
|
||||
const message = data?.eventId
|
||||
? data?.eventId
|
||||
: data?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(input) {
|
||||
const data = await JlincAudit.sign(input);
|
||||
const message = data?.audit?.eventId
|
||||
? data?.audit?.eventId
|
||||
: data?.audit?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincAudit.send(input);
|
||||
const message = data?.audit?.eventId
|
||||
? data?.audit?.eventId
|
||||
: data?.audit?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function get(input) {
|
||||
const data = await JlincAudit.get(input);
|
||||
const message = 'auditGet';
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const audit = {
|
||||
create,
|
||||
sign,
|
||||
send,
|
||||
get,
|
||||
}
|
||||
460
src/modules/core/data/agreement.js
Normal file
460
src/modules/core/data/agreement.js
Normal file
@@ -0,0 +1,460 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAgreement, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import axios from 'axios';
|
||||
|
||||
async function getAgreement(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', a.version,
|
||||
'parent', a.parent,
|
||||
'agreementId', a.agreement_id_uuid,
|
||||
'created', a.created,
|
||||
'ids', (
|
||||
SELECT JSON_AGG(ari.required_id)
|
||||
FROM agreement_required_id ari
|
||||
WHERE ari.agreement_id = a.id
|
||||
),
|
||||
'purposes', (
|
||||
SELECT JSON_AGG(p.value ORDER BY p.value)
|
||||
FROM purpose p
|
||||
LEFT JOIN agreement_purpose ap
|
||||
ON p.id = ap.purpose_id
|
||||
AND ap.agreement_id = a.id
|
||||
AND p.user_id = a.user_id
|
||||
AND ap.user_id = a.user_id
|
||||
),
|
||||
'caveats', (
|
||||
SELECT JSON_AGG(c.value ORDER BY c.value)
|
||||
FROM caveat c
|
||||
LEFT JOIN agreement_caveat ac
|
||||
ON c.id = ac.caveat_id
|
||||
AND ac.agreement_id = a.id
|
||||
AND c.user_id = a.user_id
|
||||
AND ac.user_id = a.user_id
|
||||
),
|
||||
'validRoles', (
|
||||
SELECT JSON_AGG(r.value ORDER BY r.value)
|
||||
FROM role r
|
||||
LEFT JOIN agreement_role ar
|
||||
ON r.id = ar.role_id
|
||||
AND ar.agreement_id = a.id
|
||||
AND r.user_id = a.user_id
|
||||
AND ar.user_id = a.user_id
|
||||
)
|
||||
) AS record
|
||||
FROM agreement a
|
||||
WHERE a.agreement_id_uuid = $1
|
||||
AND a.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
return res.rows[0].record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSignatures(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', s.version,
|
||||
'id', s.signer_id,
|
||||
'signedOn', s.signed_on,
|
||||
'type', s.type,
|
||||
'jws', s.jws,
|
||||
'role', (
|
||||
SELECT r.value
|
||||
FROM role r
|
||||
WHERE r.id = s.role_id
|
||||
AND r.user_id = $2
|
||||
)
|
||||
)
|
||||
) AS records
|
||||
FROM signature s
|
||||
INNER JOIN agreement a ON s.agreement_id = a.id
|
||||
WHERE a.agreement_id_uuid = $1
|
||||
AND a.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
return res.rows[0].records;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingAgreement = await getAgreement(client, userId, input.agreementId)
|
||||
if (!existingAgreement) {
|
||||
response.error = 'agreement not found'
|
||||
} else {
|
||||
const data = {
|
||||
agreement: existingAgreement
|
||||
}
|
||||
if (input.includeSignatures) {
|
||||
data.signatures = await getSignatures(client, userId, input.agreementId)
|
||||
for (let x = 0; x < data.signatures.length; x++) {
|
||||
if (data.signatures[x].role === null) {
|
||||
delete data.signatures[x].role;
|
||||
}
|
||||
}
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${input.agreementId}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, agreement) {
|
||||
await client.query(`BEGIN`);
|
||||
const res = await client.query(`
|
||||
INSERT INTO agreement (
|
||||
user_id,
|
||||
version,
|
||||
parent,
|
||||
agreement_id_uuid,
|
||||
created,
|
||||
created_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
agreement.version,
|
||||
agreement.parent,
|
||||
agreement.agreementId,
|
||||
agreement.created,
|
||||
new Date(agreement.created).toISOString(),
|
||||
]);
|
||||
for (const id of agreement.ids) {
|
||||
await client.query(`
|
||||
INSERT INTO agreement_required_id (
|
||||
user_id,
|
||||
agreement_id,
|
||||
required_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
id,
|
||||
]);
|
||||
}
|
||||
for (const purpose of agreement.purposes) {
|
||||
await client.query(`
|
||||
INSERT INTO purpose (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
purpose,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_purpose (
|
||||
user_id,
|
||||
agreement_id,
|
||||
purpose_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM purpose
|
||||
WHERE value = $3
|
||||
AND user_id = $1
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
purpose,
|
||||
]);
|
||||
}
|
||||
for (const caveat of agreement.caveats) {
|
||||
await client.query(`
|
||||
INSERT INTO caveat (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
caveat,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_caveat (
|
||||
user_id,
|
||||
agreement_id,
|
||||
caveat_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM caveat
|
||||
WHERE value = $3
|
||||
AND user_id = $1
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
caveat,
|
||||
]);
|
||||
}
|
||||
for (const role of agreement.validRoles) {
|
||||
await client.query(`
|
||||
INSERT INTO role (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
role,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_role (
|
||||
user_id,
|
||||
agreement_id,
|
||||
role_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $3
|
||||
AND user_id = $1
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
role,
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function saveSignatures(client, userId, agreementId, signatures) {
|
||||
await client.query(`BEGIN`);
|
||||
for (const signature of signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO signature (
|
||||
user_id,
|
||||
version,
|
||||
signer_id,
|
||||
signed_on,
|
||||
type,
|
||||
jws,
|
||||
role_id,
|
||||
agreement_id,
|
||||
event_id,
|
||||
signed_on_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $7
|
||||
AND user_id = $1
|
||||
),
|
||||
(
|
||||
SELECT id
|
||||
FROM agreement
|
||||
WHERE agreement_id_uuid = $8
|
||||
AND user_id = $1
|
||||
),
|
||||
$9,
|
||||
$10
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
signature.role,
|
||||
agreementId,
|
||||
null,
|
||||
new Date(signature.signedOn).toISOString(),
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function create(input, userId, _client, uuid) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
delete input.shortNames;
|
||||
const agreement = await JlincAgreement.create(input);
|
||||
if (uuid) {
|
||||
agreement.agreementId = uuid;
|
||||
}
|
||||
await save(client, userId, agreement);
|
||||
response = {
|
||||
message: `created and saved: ${agreement.agreementId}`,
|
||||
data: agreement,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function process(input, userId, _client, _agreement) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
const existingAgreement = _agreement || await getAgreement(client, userId, input.agreementId)
|
||||
if (!existingAgreement) {
|
||||
throw new Error('agreement does not exist')
|
||||
}
|
||||
const inputEntity = input.shortName ? await entity.getEntity(client, userId, input.shortName) : null;
|
||||
const didDoc = inputEntity ? inputEntity.didDoc : input.didDoc;
|
||||
const signingKey = inputEntity ? inputEntity.controlPrivateKeyB64U : input.signingKey;
|
||||
const signingPublicKey = inputEntity ? inputEntity.didDoc.verificationMethod[0].key : input.signingPublicKey;
|
||||
const signingInput = {
|
||||
agreement: existingAgreement,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
role: input.role,
|
||||
}
|
||||
const agreementData = await JlincAgreement.sign(signingInput);
|
||||
await saveSignatures(client, userId, existingAgreement.agreementId, agreementData.signatures);
|
||||
const audit = await JlincAudit.create(agreementData);
|
||||
const auditInput = {
|
||||
audit,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const auditData = await JlincAudit.sign(auditInput);
|
||||
response = {
|
||||
message: `signed and saved: ${agreementData?.agreement?.agreementId}`,
|
||||
data: {
|
||||
auditData,
|
||||
},
|
||||
}
|
||||
if (input.archive) {
|
||||
await axios.post(
|
||||
`${input.archive.url}/api/v1/audit/put`,
|
||||
response.data.auditData,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${input.archive.key}`,
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function produce(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const created = (await create(input.data, userId, client)).data;
|
||||
const processed = (await process(
|
||||
{
|
||||
agreementId: created.agreementId,
|
||||
shortName: input.shortName,
|
||||
role: input.role,
|
||||
archive: input.archive,
|
||||
},
|
||||
userId,
|
||||
client,
|
||||
created,
|
||||
)).data;
|
||||
response = {
|
||||
message: `created and processed: ${created.agreementId}`,
|
||||
data: {
|
||||
created,
|
||||
processed,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const agreement = {
|
||||
getAgreement,
|
||||
getSignatures,
|
||||
get,
|
||||
create,
|
||||
process,
|
||||
produce,
|
||||
}
|
||||
203
src/modules/core/data/audit.js
Normal file
203
src/modules/core/data/audit.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { stringify, configure } from "safe-stable-stringify";
|
||||
import { createHash } from "crypto";
|
||||
import { entity } from "./entity.js";
|
||||
import sodium from "sodium-native";
|
||||
|
||||
|
||||
function splitJws(jws) {
|
||||
const sections = jws.split('.');
|
||||
if (sections.length !== 3) {
|
||||
throw ('Input must be a JWS.');
|
||||
}
|
||||
const jwt = JSON.parse(Buffer.from(sections[0], 'base64url').toString());
|
||||
if (jwt.alg !== 'EdDSA') {
|
||||
throw ('JWT does not indicate EdDSA');
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(sections[1], 'base64url').toString());
|
||||
const wasSigned = Buffer.from(sections[0] + '.' + sections[1]);
|
||||
const signature = Buffer.from(sections[2], 'base64url');
|
||||
return {
|
||||
jwt,
|
||||
payload,
|
||||
wasSigned,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
function verifyJws(input) {
|
||||
let ret = false;
|
||||
try {
|
||||
if (!input.jws) {
|
||||
throw ('No JWS provided.');
|
||||
}
|
||||
if (!input.publicKey) {
|
||||
throw ('No publicKey provided.');
|
||||
}
|
||||
const publicKey = Buffer.from(input.publicKey, 'base64url');
|
||||
if (publicKey.length !== sodium.crypto_sign_PUBLICKEYBYTES) {
|
||||
throw ('publicKey length must be crypto_sign_PUBLICKEYBYTES (32).');
|
||||
}
|
||||
const providedPublicKey = Buffer.from(input.jws.jwt.jwk.x, 'base64url');
|
||||
if (publicKey.compare(providedPublicKey) !== 0) {
|
||||
ret = false;
|
||||
} else {
|
||||
ret = sodium.crypto_sign_verify_detached(input.jws.signature, input.jws.wasSigned, Buffer.from(input.publicKey, 'base64url'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
function validateSignatures(item, signatures, didDocs) {
|
||||
let res = false;
|
||||
try {
|
||||
for (const signature of signatures) {
|
||||
const didDoc = didDocs.find((didDoc) => didDoc.id === signature.id);
|
||||
if (!didDoc) throw ('DID Document not provided');
|
||||
const split = splitJws(signature.jws);
|
||||
if (stringify(item) !== stringify(split.payload)) throw ('Payload does not match');
|
||||
const validJws = verifyJws({
|
||||
jws: split,
|
||||
publicKey: didDoc.verificationMethod[0].key,
|
||||
});
|
||||
if (!validJws) throw ('Signature is not valid');
|
||||
res = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function generateDigest(content, length) {
|
||||
if (typeof content === 'object') {
|
||||
content = stringify(content);
|
||||
}
|
||||
const hash = createHash('sha256')
|
||||
.update(content)
|
||||
.digest('hex')
|
||||
.slice(0, length);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function verify(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const data = {
|
||||
valid: [],
|
||||
invalid: [],
|
||||
};
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
delete input.shortNames;
|
||||
const organizedById = [];
|
||||
for (const item of input.audits) {
|
||||
const found = organizedById.find((obi) =>
|
||||
item.audit.agreementId && obi.agreementId === item.audit.agreementId ||
|
||||
item.audit.eventId && obi.eventId === item.audit.eventId
|
||||
);
|
||||
if (!found) {
|
||||
const newItem = item.audit.agreementId
|
||||
? {
|
||||
agreementId: item.audit.agreementId,
|
||||
auditRecords: [item],
|
||||
}
|
||||
: {
|
||||
eventId: item.audit.eventId,
|
||||
auditRecords: [item],
|
||||
}
|
||||
organizedById.push(newItem)
|
||||
} else {
|
||||
found.auditRecords.push(item);
|
||||
}
|
||||
}
|
||||
for (const item of organizedById) {
|
||||
const existingItem = item.eventId
|
||||
? await event.getEvent(client, userId, item.eventId, true)
|
||||
: await agreement.getAgreement(client, userId, item.agreementId)
|
||||
const existingSignatures = item.eventId
|
||||
? await event.getSignatures(client, userId, item.eventId)
|
||||
: await agreement.getSignatures(client, userId, item.agreementId)
|
||||
// Does the agreement signature verify?
|
||||
let validSignature = false;
|
||||
if (validateSignatures(existingItem, existingSignatures, input.didDocs)) {
|
||||
validSignature = true;
|
||||
}
|
||||
for (const auditRecord of item.auditRecords) {
|
||||
const res = {
|
||||
audit: auditRecord,
|
||||
results: {
|
||||
validId: false,
|
||||
validSignature,
|
||||
validAuditHash: false,
|
||||
validAuditSignature: false,
|
||||
}
|
||||
}
|
||||
// Do the agreement IDs match?
|
||||
if (
|
||||
(item.agreementId !== null && auditRecord.audit.agreementId === item.agreementId) ||
|
||||
(item.eventId !== null && auditRecord.audit.eventId === item.eventId)
|
||||
)
|
||||
res.results.validId = true;
|
||||
// Does the audit hash match?
|
||||
// The digest was created from whichever signatures this audit record has
|
||||
const signatures = [];
|
||||
for (const s of auditRecord.signatures) {
|
||||
const existingSignature = existingSignatures.find((es) => es.id === s.id);
|
||||
if (existingSignature) {
|
||||
signatures.push(existingSignature)
|
||||
}
|
||||
}
|
||||
const check = item.eventId
|
||||
? {
|
||||
event: existingItem,
|
||||
signatures,
|
||||
}
|
||||
: {
|
||||
agreement: existingItem,
|
||||
signatures,
|
||||
}
|
||||
const digest = generateDigest(check);
|
||||
if (digest === auditRecord.audit.digest)
|
||||
res.results.validAuditHash = true;
|
||||
// Does the audit signature verify?
|
||||
if (validateSignatures(auditRecord.audit, auditRecord.signatures, input.didDocs)) {
|
||||
res.results.validAuditSignature = true;
|
||||
}
|
||||
const isValid =
|
||||
res.results.validId === true &&
|
||||
res.results.validSignature === true &&
|
||||
res.results.validAuditHash === true &&
|
||||
res.results.validAuditSignature === true;
|
||||
if (isValid) {
|
||||
data.valid.push(res);
|
||||
} else {
|
||||
data.invalid.push(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
response = {
|
||||
message: 'validation complete',
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const audit = {
|
||||
verify,
|
||||
}
|
||||
172
src/modules/core/data/entity.js
Normal file
172
src/modules/core/data/entity.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { createHash } from "crypto";
|
||||
import { getConfig } from "../../../common/config.js";
|
||||
import axios from "axios";
|
||||
import sodium from "sodium-native";
|
||||
|
||||
async function getEntity(client, userId, shortName) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'fedidUrl', e.fedid_url,
|
||||
'shortName', e.short_name,
|
||||
'didId', e.did_id,
|
||||
'controlPrivateKeyB64U', e.control_private_key_b64u,
|
||||
'recoveryPrivateKeyB64U', e.recovery_private_key_b64u,
|
||||
'didDoc', e.did_doc
|
||||
) AS record
|
||||
FROM entity e
|
||||
WHERE LOWER(e.short_name) = LOWER($1)
|
||||
AND e.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
shortName,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
return res.rows[0].record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingEntity = await getEntity(client, userId, input.shortName)
|
||||
if (!existingEntity) {
|
||||
response.error = 'entity not found'
|
||||
} else {
|
||||
const data = {
|
||||
didDoc: existingEntity.didDoc,
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${input.shortName}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function getDomains(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
const config = getConfig();
|
||||
const fedidUrl = input.fedidUrl ?? config.defaultFedidUrl;
|
||||
const data = (await axios.get(
|
||||
`${fedidUrl}/api/v2/domains`,
|
||||
)).data.data.domains;
|
||||
response = {
|
||||
message: `retrieved domains`,
|
||||
data,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, entity) {
|
||||
const res = await client.query(`
|
||||
INSERT INTO entity (
|
||||
user_id,
|
||||
fedid_url,
|
||||
short_name,
|
||||
did_id,
|
||||
control_private_key_b64u,
|
||||
recovery_private_key_b64u,
|
||||
did_doc
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
entity.fedidUrl,
|
||||
entity.shortName,
|
||||
entity.didDoc.id,
|
||||
entity.controlPrivateKeyB64U,
|
||||
entity.recoveryPrivateKeyB64U,
|
||||
entity.didDoc,
|
||||
]);
|
||||
}
|
||||
|
||||
async function create(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const config = getConfig();
|
||||
const fedidUrl = input.fedidUrl ?? config.defaultFedidUrl;
|
||||
const domains = (await axios.get(
|
||||
`${fedidUrl}/api/v2/domains`,
|
||||
)).data.data.domains;
|
||||
const shortName = input.shortName.split('@')
|
||||
const domain = shortName[1]
|
||||
if (!domains.includes(domain)) {
|
||||
throw new Error('domain is not available');
|
||||
}
|
||||
const entity = {
|
||||
shortName: input.shortName,
|
||||
fedidUrl,
|
||||
}
|
||||
// Generate a control key
|
||||
entity.controlPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
||||
entity.controlPrivateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
|
||||
sodium.crypto_sign_keypair(entity.controlPublicKey, entity.controlPrivateKey);
|
||||
entity.controlPublicKeyB64U = entity.controlPublicKey.toString("base64url");
|
||||
entity.controlPrivateKeyB64U = entity.controlPrivateKey.toString("base64url");
|
||||
// Generate a recovery key
|
||||
entity.recoveryPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
||||
entity.recoveryPrivateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
|
||||
entity.recoveryPrivateKeyB64U = entity.recoveryPrivateKey.toString("base64url");
|
||||
sodium.crypto_sign_keypair(entity.recoveryPublicKey, entity.recoveryPrivateKey);
|
||||
entity.recoveryHash = createHash("sha256").update(entity.recoveryPublicKey).digest("hex").slice(0, 48);
|
||||
entity.didDoc = (await axios.post(
|
||||
`${fedidUrl}/api/v2/did/create`,
|
||||
{
|
||||
shortName: entity.shortName,
|
||||
control: entity.controlPublicKeyB64U,
|
||||
recoveryHash: entity.recoveryHash,
|
||||
},
|
||||
)).data.data.didDoc;
|
||||
await save(client, userId, entity);
|
||||
response = {
|
||||
message: `created and saved: ${entity.didDoc.id}`,
|
||||
data: {
|
||||
didDoc: entity.didDoc,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
response.error = e.message;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const entity = {
|
||||
getEntity,
|
||||
getDomains,
|
||||
get,
|
||||
create,
|
||||
}
|
||||
406
src/modules/core/data/event.js
Normal file
406
src/modules/core/data/event.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincEvent, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import axios from 'axios';
|
||||
|
||||
async function getEvent(client, userId, id, includeData, meta) {
|
||||
const dataSql = includeData
|
||||
? `
|
||||
'data', (
|
||||
SELECT ed.data
|
||||
FROM event_data ed
|
||||
WHERE ed.event_id = e.id
|
||||
),
|
||||
`
|
||||
: ``;
|
||||
let fields = [userId];
|
||||
let count = fields.length + 1;
|
||||
let whereAnd = ``
|
||||
if (id) {
|
||||
whereAnd += ` AND e.event_id_uuid = $${count++}`;
|
||||
fields.push(id);
|
||||
}
|
||||
if (meta) {
|
||||
let whereInVals = ``
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
if (whereInVals != ``)
|
||||
whereInVals = ` AND `
|
||||
whereInVals += `(em.key = $${count++} AND em.value = $${count++})`;
|
||||
fields.push(key);
|
||||
fields.push(value);
|
||||
}
|
||||
whereAnd += `
|
||||
AND e.id IN (
|
||||
SELECT em.event_id
|
||||
FROM event_meta em
|
||||
WHERE em.user_id = $1
|
||||
AND ${whereInVals}
|
||||
)
|
||||
`
|
||||
}
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', e.version,
|
||||
'eventId', e.event_id_uuid,
|
||||
'type', (
|
||||
SELECT et.value
|
||||
FROM event_type et
|
||||
WHERE et.id = e.event_type_id
|
||||
),
|
||||
'senderId', e.sender_id,
|
||||
'recipientId', e.recipient_id,
|
||||
'created', e.created,
|
||||
'agreementId', (
|
||||
SELECT a.agreement_id_uuid
|
||||
FROM agreement a
|
||||
WHERE a.id = e.agreement_id
|
||||
AND a.user_id = $1
|
||||
),
|
||||
${dataSql}
|
||||
'created', e.created
|
||||
) AS record
|
||||
FROM event e
|
||||
WHERE e.user_id = $1
|
||||
${whereAnd};
|
||||
`
|
||||
const res = await client.query(sql, fields);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
const ret = res.rows[0].record;
|
||||
if (ret.data) {
|
||||
try {
|
||||
ret.data = JSON.parse(ret.data);
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSignatures(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', s.version,
|
||||
'id', s.signer_id,
|
||||
'signedOn', s.signed_on,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)
|
||||
) AS records
|
||||
FROM signature s
|
||||
INNER JOIN event e ON s.event_id = e.id
|
||||
WHERE e.event_id_uuid = $1
|
||||
AND e.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
return res.rows[0].records;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingEvent = await getEvent(client, userId, input.eventId, true, input.meta)
|
||||
if (!existingEvent) {
|
||||
response.error = 'event not found'
|
||||
} else {
|
||||
const data = {
|
||||
event: existingEvent
|
||||
}
|
||||
if (input.includeSignatures) {
|
||||
data.signatures = await getSignatures(client, userId, input.eventId)
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${existingEvent.eventId}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, event, meta) {
|
||||
await client.query(`BEGIN`);
|
||||
const res = await client.query(`
|
||||
INSERT INTO event (
|
||||
user_id,
|
||||
version,
|
||||
event_id_uuid,
|
||||
event_type_id,
|
||||
agreement_id,
|
||||
sender_id,
|
||||
recipient_id,
|
||||
created,
|
||||
created_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
(
|
||||
SELECT id
|
||||
FROM event_type
|
||||
WHERE value = $4
|
||||
),
|
||||
(
|
||||
SELECT id
|
||||
FROM agreement
|
||||
WHERE agreement_id_uuid = $5
|
||||
),
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
event.version,
|
||||
event.eventId,
|
||||
event.type,
|
||||
event.agreementId,
|
||||
event.senderId,
|
||||
event.recipientId,
|
||||
event.created,
|
||||
new Date(event.created).toISOString(),
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO event_data (
|
||||
user_id,
|
||||
event_id,
|
||||
data
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
event.data,
|
||||
]);
|
||||
if (meta) {
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
await client.query(`
|
||||
INSERT INTO event_meta (
|
||||
user_id,
|
||||
event_id,
|
||||
key,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
key,
|
||||
value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function saveSignatures(client, userId, eventId, signatures) {
|
||||
await client.query(`BEGIN`);
|
||||
for (const signature of signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO signature (
|
||||
user_id,
|
||||
version,
|
||||
signer_id,
|
||||
signed_on,
|
||||
type,
|
||||
jws,
|
||||
role_id,
|
||||
agreement_id,
|
||||
event_id,
|
||||
signed_on_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $7
|
||||
AND user_id = $1
|
||||
),
|
||||
$8,
|
||||
(
|
||||
SELECT id
|
||||
FROM event
|
||||
WHERE event_id_uuid = $9
|
||||
AND user_id = $1
|
||||
),
|
||||
$10
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
signature.role,
|
||||
null,
|
||||
eventId,
|
||||
new Date(signature.signedOn).toISOString(),
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function create(input, userId, _client, _sender) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
input.senderId = _sender ? _sender.didDoc.id : (await entity.getEntity(client, userId, input.senderShortName)).didDoc.id
|
||||
input.recipientId = (await entity.getEntity(client, userId, input.recipientShortName)).didDoc.id
|
||||
delete input.senderShortName
|
||||
delete input.recipientShortName
|
||||
const event = await JlincEvent.create(input);
|
||||
await save(client, userId, event, input.meta);
|
||||
response = {
|
||||
message: `created and saved: ${event.eventId}`,
|
||||
data: event,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function process(input, userId, _client, _event, _sender, meta) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
const existingEvent = _event || await getEvent(client, userId, input.eventId, true)
|
||||
if (!existingEvent) {
|
||||
throw new Error('event does not exist')
|
||||
}
|
||||
const inputEntity = _sender || input.shortName ? await entity.getEntity(client, userId, input.shortName) : null;
|
||||
const didDoc = inputEntity ? inputEntity.didDoc : input.didDoc;
|
||||
const signingKey = inputEntity ? inputEntity.controlPrivateKeyB64U : input.signingKey;
|
||||
const signingPublicKey = inputEntity ? inputEntity.didDoc.verificationMethod[0].key : input.signingPublicKey;
|
||||
const signingInput = {
|
||||
event: existingEvent,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const eventData = await JlincEvent.sign(signingInput);
|
||||
await saveSignatures(client, userId, existingEvent.eventId, eventData.signatures);
|
||||
const audit = await JlincAudit.create(eventData);
|
||||
const auditInput = {
|
||||
audit,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const auditData = await JlincAudit.sign(auditInput);
|
||||
response = {
|
||||
message: `signed and saved: ${eventData?.event?.eventId}`,
|
||||
data: {
|
||||
auditData,
|
||||
},
|
||||
}
|
||||
if (input.archive) {
|
||||
if (meta) {
|
||||
response.data.auditData.meta = meta;
|
||||
}
|
||||
await axios.post(
|
||||
`${input.archive.url}/api/v1/audit/put`,
|
||||
response.data.auditData,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${input.archive.key}`,
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function produce(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const shortName = input.senderShortName;
|
||||
const sender = (await entity.getEntity(client, userId, input.senderShortName))
|
||||
const created = (await create(input, userId, client, sender)).data;
|
||||
const processed = (await process(
|
||||
{
|
||||
eventId: created.eventId,
|
||||
shortName,
|
||||
archive: input.archive,
|
||||
},
|
||||
userId,
|
||||
client,
|
||||
created,
|
||||
sender,
|
||||
input.meta,
|
||||
)).data;
|
||||
response = {
|
||||
message: `created and processed: ${created.eventId}`,
|
||||
data: {
|
||||
created,
|
||||
processed,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const event = {
|
||||
getEvent,
|
||||
getSignatures,
|
||||
get,
|
||||
create,
|
||||
process,
|
||||
produce,
|
||||
}
|
||||
12
src/modules/core/data/index.js
Normal file
12
src/modules/core/data/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { audit } from "./audit.js";
|
||||
|
||||
|
||||
export const data = {
|
||||
agreement,
|
||||
event,
|
||||
entity,
|
||||
audit,
|
||||
}
|
||||
55
src/modules/core/did.js
Normal file
55
src/modules/core/did.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincDid } = pkg;
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincDid.create(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function createKeys() {
|
||||
const data = await JlincDid.createKeys();
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function rotate(input) {
|
||||
const data = await JlincDid.rotate(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincDid.send(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(input) {
|
||||
const data = await JlincDid.resolve(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const did = {
|
||||
create,
|
||||
createKeys,
|
||||
rotate,
|
||||
send,
|
||||
resolve,
|
||||
}
|
||||
35
src/modules/core/event.js
Normal file
35
src/modules/core/event.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincEvent } = pkg;
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincEvent.create(input);
|
||||
const message = data?.eventId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(input) {
|
||||
const data = await JlincEvent.sign(input);
|
||||
const message = data?.event?.eventId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincEvent.send(input);
|
||||
const message = data?.event?.eventId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const event = {
|
||||
create,
|
||||
sign,
|
||||
send,
|
||||
}
|
||||
171
src/modules/core/index.js
Normal file
171
src/modules/core/index.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import { did } from "./did.js";
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { audit } from "./audit.js";
|
||||
import { data } from "./data/index.js";
|
||||
import { archive } from "./archive.js";
|
||||
import { trackUsage } from "./usage.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
async function post(req, res) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
let type;
|
||||
const prefix = `${req.method} ${req.url}`;
|
||||
try {
|
||||
const input = req.body;
|
||||
const config = getConfig();
|
||||
if (Object.keys(config.appModules).includes('core')) {
|
||||
switch (req.url) {
|
||||
case '/api/v1/did/create':
|
||||
response = await did.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/rotate':
|
||||
response = await did.rotate(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/updateServices':
|
||||
response = await did.updateServices(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/send':
|
||||
response = await did.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/resolve':
|
||||
response = await did.resolve(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/agreement/create':
|
||||
response = await agreement.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/agreement/sign':
|
||||
response = await agreement.sign(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/agreement/send':
|
||||
response = await agreement.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/event/create':
|
||||
response = await event.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/event/sign':
|
||||
response = await event.sign(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/event/send':
|
||||
response = await event.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/audit/create':
|
||||
response = await audit.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/audit/sign':
|
||||
response = await audit.sign(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/audit/send':
|
||||
response = await audit.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/entity/get':
|
||||
response = await data.entity.get(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/entity/domains/get':
|
||||
response = await data.entity.getDomains(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/entity/create':
|
||||
response = await data.entity.create(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/get':
|
||||
response = await data.agreement.get(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/create':
|
||||
response = await data.agreement.create(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/process':
|
||||
response = await data.agreement.process(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/produce':
|
||||
response = await data.agreement.produce(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/get':
|
||||
response = await data.event.get(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/create':
|
||||
response = await data.event.create(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/process':
|
||||
response = await data.event.process(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/produce':
|
||||
response = await data.event.produce(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/audit/verify':
|
||||
response = await data.audit.verify(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (Object.keys(config.appModules).includes('archive')) {
|
||||
switch (req.url) {
|
||||
case '/api/v1/audit/put':
|
||||
response = await archive.put(input);
|
||||
type = 'archive';
|
||||
break;
|
||||
case '/api/v1/audit/get':
|
||||
response = await archive.get(input);
|
||||
type = 'archive';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!type) {
|
||||
response.error = 'Page not found';
|
||||
res.status(404).send(response);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.error = e.message;
|
||||
} finally {
|
||||
if (response?.message) {
|
||||
req.apiMessage = response.message;
|
||||
} else if (response?.data?.error) {
|
||||
req.apiMessage = `ERROR: ${response.data.error}`;
|
||||
} else {
|
||||
req.apiMessage = `ERROR: unknown error`;
|
||||
}
|
||||
if (response?.data)
|
||||
response = response.data;
|
||||
await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true);
|
||||
res.status(response?.error ? 400 : 200).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
export const core = {
|
||||
did,
|
||||
agreement,
|
||||
event,
|
||||
audit,
|
||||
archive,
|
||||
post,
|
||||
};
|
||||
|
||||
|
||||
80
src/modules/core/usage.js
Normal file
80
src/modules/core/usage.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getPool } from "../../db/index.js";
|
||||
|
||||
export async function trackUsage(user_id, url, type, success) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
await client.query(`
|
||||
INSERT INTO usage_url (
|
||||
url,
|
||||
module
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
url,
|
||||
type,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO usage (
|
||||
user_id,
|
||||
usage_url_id,
|
||||
success
|
||||
) VALUES (
|
||||
$1,
|
||||
(
|
||||
SELECT id
|
||||
FROM usage_url
|
||||
WHERE url = $2
|
||||
),
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
user_id,
|
||||
url,
|
||||
success,
|
||||
]);
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsage(user, begin, end) {
|
||||
const client = await getPool();
|
||||
let ret = [];
|
||||
try {
|
||||
let res = await client.query(`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
uu.module,
|
||||
COUNT(*) AS num
|
||||
FROM usage u
|
||||
JOIN usage_url uu ON uu.id = u.usage_url_id
|
||||
WHERE u.created_ts >= $1
|
||||
AND u.created_ts < $2
|
||||
AND u.user_id = $3
|
||||
GROUP BY uu.module
|
||||
)
|
||||
SELECT
|
||||
json_object_agg(
|
||||
module,
|
||||
num
|
||||
) AS usage_counts
|
||||
FROM base
|
||||
`, [
|
||||
begin,
|
||||
end,
|
||||
user.id,
|
||||
]);
|
||||
if (res.rows.length > 0) {
|
||||
ret = res.rows[0].usage_counts;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
11381
src/package-lock.json
generated
Normal file
11381
src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
src/package.json
Normal file
50
src/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "jlinc-server",
|
||||
"version": "1.0.0",
|
||||
"description": "JLINC Server",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
||||
"e2e": "node testing/e2e.js",
|
||||
"start": "node index.js",
|
||||
"start-dev": "./node_modules/.bin/nodemon index.js",
|
||||
"start-dev-debug": "./node_modules/.bin/nodemon --inspect=0.0.0.0:9694 index.js",
|
||||
"lint": "npx eslint .",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"prettier": "npx prettier . --check",
|
||||
"prettier:fix": "npm run prettier -- --write",
|
||||
"format": "npm run lint:fix && npm run prettier:fix"
|
||||
},
|
||||
"author": "JLINC <nospam@jlinc.com>",
|
||||
"license": "SSPLv1",
|
||||
"dependencies": {
|
||||
"@jlinc/core": "file:./packages/core",
|
||||
"axios": "^1.10.0",
|
||||
"body-parser": "^1.20.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"marked": "^16.1.1",
|
||||
"memorystore": "^1.6.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-github": "^1.1.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-openidconnect": "^0.1.2",
|
||||
"pg": "^8.13.1",
|
||||
"safe-stable-stringify": "^2.5.0",
|
||||
"sodium-native": "^5.0.6",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.25.9",
|
||||
"@babel/plugin-syntax-import-assertions": "^7.26.0",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"prettier": "^3.4.2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user