addition of new dashboard

This commit is contained in:
2026-08-20 15:08:32 +00:00
parent 0622018d95
commit bf59249ed7
161 changed files with 13170 additions and 119 deletions

View File

@@ -0,0 +1,193 @@
import crypto from "crypto";
import { promisify } from "util";
import { getPool } from "../../db/index.js";
import { getConfig } from "../../common/config.js";
const scrypt = promisify(crypto.scrypt);
const KEYLEN = 64;
// Read per call rather than captured at import, so loadConfig() governs these
// the same way it governs the rest of config.dashboard.
//
// Per-process, so a revoked key can linger in another worker for one TTL.
const cacheTtlMs = () => getConfig().dashboard.keyCache.ttlMs;
// The TTL alone does not bound the cache: an expired entry is only dropped when
// that same key is looked up again, so idle keys would accumulate forever.
const cacheMaxEntries = () => getConfig().dashboard.keyCache.max;
// Keyed by a digest of the raw key, never the raw key. Insertion order is
// recency order, so the front of the Map is the least-recently-used entry.
const cache = new Map();
const fastDigest = (raw) => crypto.createHash("sha256").update(raw).digest("hex");
// `expiresAt` is deliberately untouched, so a hammered key still expires on
// time and revocation staleness stays bounded at one TTL.
function touch(dg, entry) {
cache.delete(dg);
cache.set(dg, entry);
}
// Sweeps only when full, so the common insert stays O(1).
function remember(dg, entry) {
const max = cacheMaxEntries();
if (cacheTtlMs() <= 0) return;
if (cache.size >= max) {
const now = Date.now();
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
}
cache.set(dg, entry);
while (cache.size > max) {
const lru = cache.keys().next().value;
if (lru === undefined) break;
cache.delete(lru);
}
}
export function _resetKeyCache() {
cache.clear();
}
// "<saltHex>:<derivedHex>". Guards a DB dump, not a weak-password guess.
async function scryptHash(raw) {
const salt = crypto.randomBytes(16);
const derived = await scrypt(raw, salt, KEYLEN);
return `${salt.toString("hex")}:${derived.toString("hex")}`;
}
async function scryptVerify(raw, stored) {
const [saltHex, derivedHex] = String(stored).split(":");
if (!saltHex || !derivedHex) return false;
const expected = Buffer.from(derivedHex, "hex");
const derived = await scrypt(raw, Buffer.from(saltHex, "hex"), KEYLEN);
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected);
}
const newRawKey = () => crypto.randomBytes(32).toString("hex"); // 64 hex chars
const prefixOf = (raw) => raw.slice(0, 8);
export async function verifyKey(rawKey) {
if (!rawKey) return null;
const dg = fastDigest(rawKey);
const cached = cache.get(dg);
if (cached && cached.expiresAt > Date.now()) {
touch(dg, cached);
return { user_id: cached.user_id, app_id: cached.app_id };
}
if (cached) cache.delete(dg);
const client = await getPool();
try {
const rows = (
await client.query(
`SELECT id, user_id, app_id, key_hash FROM api_key
WHERE key_prefix = $1 AND (expires_ts IS NULL OR expires_ts > NOW())`,
[prefixOf(rawKey)],
)
).rows;
for (const row of rows) {
if (await scryptVerify(rawKey, row.key_hash)) {
client.query(`UPDATE api_key SET last_used_ts = NOW() WHERE id = $1`, [row.id]).catch(() => {});
remember(dg, { user_id: row.user_id, app_id: row.app_id, id: row.id, expiresAt: Date.now() + cacheTtlMs() });
return { user_id: row.user_id, app_id: row.app_id };
}
}
return null;
} finally {
await client.release();
}
}
// Returns the RAW key ONCE; only the salted hash + prefix are persisted.
export async function generateKey(userId, appType, { label = null, expiresTs = null } = {}) {
const client = await getPool();
try {
const app = (await client.query(`SELECT id FROM app WHERE type = $1`, [appType])).rows[0];
if (!app) throw new Error(`unknown app type: ${appType}`);
const raw = newRawKey();
const keyHash = await scryptHash(raw);
const res = await client.query(
`INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix, expires_ts)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_ts`,
[userId, app.id, label, keyHash, prefixOf(raw), expiresTs],
);
return {
id: res.rows[0].id,
key: raw,
prefix: prefixOf(raw),
appType,
label,
expiresTs,
createdTs: res.rows[0].created_ts,
};
} finally {
await client.release();
}
}
// Idempotent import of the legacy plaintext keys in the `auth` table into the
// hashed api_key store, so the same keys keep authenticating via verifyKey and
// now appear in the /home API-key screen.
export async function migrateLegacyApiKeys() {
const client = await getPool();
try {
const { rows } = await client.query(
`SELECT user_id, app_id, api_key FROM public.auth WHERE api_key IS NOT NULL`,
);
let imported = 0;
for (const { user_id, app_id, api_key } of rows) {
const prefix = prefixOf(api_key);
const exists = await client.query(
`SELECT 1 FROM api_key WHERE user_id = $1 AND app_id = $2 AND key_prefix = $3 LIMIT 1`,
[user_id, app_id, prefix],
);
if (exists.rowCount > 0) continue;
const keyHash = await scryptHash(api_key);
await client.query(
`INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix)
VALUES ($1, $2, $3, $4, $5)`,
[user_id, app_id, "Imported from /dashboard", keyHash, prefix],
);
imported++;
}
if (imported > 0) console.log(`Imported ${imported} legacy API key(s) into the hashed key store`);
return imported;
} finally {
await client.release();
}
}
export async function listKeys(userId) {
const client = await getPool();
try {
return (
await client.query(
`SELECT k.id, ap.type AS "appType", k.label, k.key_prefix AS "prefix",
k.expires_ts AS "expiresTs", k.last_used_ts AS "lastUsedTs", k.created_ts AS "createdTs"
FROM api_key k JOIN app ap ON ap.id = k.app_id
WHERE k.user_id = $1
ORDER BY k.created_ts DESC`,
[userId],
)
).rows;
} finally {
await client.release();
}
}
export async function revokeKey(userId, id) {
const client = await getPool();
try {
const res = await client.query(
`DELETE FROM api_key WHERE id = $1 AND user_id = $2 RETURNING id`,
[id, userId],
);
if (res.rowCount > 0) {
for (const [k, v] of cache) if (v.id === id) cache.delete(k);
}
return res.rowCount > 0;
} finally {
await client.release();
}
}