From a2746b1601cb28ca1391f9764dd7dcd9c6c3c67a Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 12:39:57 -0500 Subject: [PATCH 1/3] mv ./lib/session ./lib/user/session --- .github/copilot-instructions.md | 42 +++++++++++++++++++ lib/{ => user}/session.js | 5 +-- lib/{session.test.js => user/test/session.js} | 7 ++-- routes/session.js | 2 +- test-fixtures.js | 2 +- test.sh | 2 +- 6 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 .github/copilot-instructions.md rename lib/{ => user}/session.js (95%) rename lib/{session.test.js => user/test/session.js} (90%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a314a4b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,42 @@ +# NicTool API Copilot Instructions + +## Commands + +- Install dependencies: `npm install` +- Start the API in production mode: `npm run start` +- Start the API in watch mode: `npm run develop` +- Run the full test suite: `npm test` +- Run one test file with fixture setup/teardown: `npm test -- routes/session.test.js` +- Run one library test file with fixture setup/teardown: `npm test -- lib/config.test.js` +- Run tests in watch mode: `npm run watch` +- Run lint: `npm run lint` +- Run formatting check: `npm run prettier` +- Run coverage: `npm run test:coverage` + +Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql/init-mysql.sh`, and `test.sh` recreates fixtures before each run. + +## Architecture + +- `server.js` only starts the Hapi server from `routes/index.js`. +- `routes/index.js` is the composition root: it loads TOML config via `lib/config.js`, registers Hapi plugins, configures JWT auth as the default auth strategy, enables Swagger docs, and mounts each resource route module. +- `routes/*.js` own HTTP concerns only. They validate requests and responses with `@nictool/validate`, coerce route/query params, call the corresponding `lib/` module, and return NicTool’s standard JSON envelope with resource data plus `meta.api.version`. +- `lib//index.js` is usually a backend selector. The active implementation comes from `NICTOOL_DATA_STORE` and defaults to MySQL; some resources also support TOML, MongoDB, or Elasticsearch backends. +- The real persistence logic lives under `lib//store/*.js`. These stores translate between API-friendly field names and the legacy NicTool schema (`nt_*` columns), using helpers like `mapToDbColumn`, `objectToDb`, and `dbToObject`. +- `lib/mysql.js` is the shared query builder/executor. Repositories rely on it for SQL generation rather than embedding ad hoc parameter handling in route files. +- Auth is session-backed but bearer-token based: `POST /session` authenticates through the user repo, creates an `nt_user_session` row via `lib/user/session.js`, and returns a JWT. Almost every other route runs under the default JWT auth strategy. +- Group, user, and permission behavior is coupled. Group creation also creates a group-level permission row, user reads attach effective permissions, and permission resolution falls back from explicit user permissions to group permissions. +- DNS data has an extra translation layer. `lib/zone_record/store/mysql.js` validates records with `@nictool/dns-resource-record` and maps NicTool’s legacy zone-record columns to RFC-style record fields. +- Config comes from `conf.d/*.toml`, with runtime overrides from `NICTOOL_DB_*` and `NICTOOL_HTTP_*`. `lib/config.js` also auto-loads TLS material from any `.pem` file in `conf.d/`. + +## Conventions + +- Preserve the route/lib split: request parsing, auth, and response shaping stay in `routes/`; DB and domain logic stay in `lib/`. +- Keep request and response schemas in sync with `@nictool/validate`. Route handlers consistently declare both `validate` and `response.schema`. +- Return the existing response envelope shape instead of raw rows. Resource payloads use keys like `user`, `group`, `zone`, `zone_record`, or `nameserver`, and responses include `meta: { api, msg }`. +- Default auth is global. New public routes must opt out explicitly with route-level auth config, like `auth: { mode: 'try' }` on `POST /session`. +- Soft delete is the default behavior across repositories. `delete()` usually sets `deleted = 1`, reads hide deleted rows unless `deleted: true` or `?deleted=true` is passed, and `destroy()` is reserved for hard-delete cleanup in tests or fixtures. +- Reuse the legacy-schema mapping helpers instead of hand-rolling field conversions. Most repos convert booleans, nested permission/export objects, and short API names into the older DB layout before writing and normalize them again on read. +- When changing group or user behavior, check permission side effects too. Group creation/update touches permission rows, and user reads/write paths may change `inherit_group_permissions` handling. +- Route tests use `init()` plus `server.inject()` instead of booting a live server. They usually establish auth by calling `POST /session` and then pass `Authorization: Bearer ` to protected routes. +- The test entrypoint is `test.sh`, not raw `node --test`, when you need DB-backed behavior. It tears fixtures down, recreates them, and then runs the requested test target. +- Zone-record changes must preserve the existing record-field translation logic. Special cases like zero `weight`/`priority` retention for `SRV`, `URI`, `HTTPS`, and `SVCB` are intentional. diff --git a/lib/session.js b/lib/user/session.js similarity index 95% rename from lib/session.js rename to lib/user/session.js index 95efcd6..3fb6527 100644 --- a/lib/session.js +++ b/lib/user/session.js @@ -1,5 +1,5 @@ -import Mysql from './mysql.js' -import { mapToDbColumn } from './util.js' +import Mysql from '../mysql.js' +import { mapToDbColumn } from '../util.js' const sessionDbMap = { id: 'nt_user_session_id', @@ -67,7 +67,6 @@ class Session { const r = await Mysql.execute( ...Mysql.update(`nt_user_session`, `nt_user_session_id=${id}`, mapToDbColumn(args, sessionDbMap)), ) - // console.log(r) return r.changedRows === 1 } diff --git a/lib/session.test.js b/lib/user/test/session.js similarity index 90% rename from lib/session.test.js rename to lib/user/test/session.js index 41d3af3..51c3995 100644 --- a/lib/session.test.js +++ b/lib/user/test/session.js @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' import { describe, it, after, before } from 'node:test' -import User from './user/index.js' -import Session from './session.js' -import userCase from './user/test/user.json' with { type: 'json' } +import User from '../index.js' +import Session from '../session.js' +import userCase from './user.json' with { type: 'json' } const sessionUser = { ...userCase, @@ -23,7 +23,6 @@ after(async () => { }) describe('session', function () { - // Session._mysql.debug(true) let sessionId describe('create', () => { diff --git a/routes/session.js b/routes/session.js index 610bbc3..be764c2 100644 --- a/routes/session.js +++ b/routes/session.js @@ -4,7 +4,7 @@ import Config from '../lib/config.js' import Jwt from '@hapi/jwt' import User from '../lib/user/index.js' -import Session from '../lib/session.js' +import Session from '../lib/user/session.js' import { meta } from '../lib/util.js' diff --git a/test-fixtures.js b/test-fixtures.js index 00a02ff..fedc62b 100644 --- a/test-fixtures.js +++ b/test-fixtures.js @@ -4,7 +4,7 @@ import path from 'node:path' import Group from './lib/group/index.js' import User from './lib/user/index.js' -import Session from './lib/session.js' +import Session from './lib/user/session.js' import Permission from './lib/permission/index.js' import Nameserver from './lib/nameserver/index.js' import Zone from './lib/zone/index.js' diff --git a/test.sh b/test.sh index 0ae5a50..a04ab9d 100755 --- a/test.sh +++ b/test.sh @@ -30,5 +30,5 @@ else # npm i --no-save node-test-github-reporter # $NODE --test --test-reporter=node-test-github-reporter # fi - $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js + $NODE --test --test-reporter=spec lib/*/test/*.js lib/*.test.js routes/*.test.js fi From 0f3a6c81b55908bc8a6b965993b0607ed25ff7a8 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 12:44:59 -0500 Subject: [PATCH 2/3] mv ./lib/session.js ./lib/session/index.js --- .github/copilot-instructions.md | 2 +- lib/{user/session.js => session/index.js} | 0 lib/{user/test/session.js => session/test/index.js} | 6 +++--- routes/session.js | 2 +- test-fixtures.js | 2 +- test.sh | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename lib/{user/session.js => session/index.js} (100%) rename lib/{user/test/session.js => session/test/index.js} (91%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a314a4b..83f1931 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,7 +23,7 @@ Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql - `lib//index.js` is usually a backend selector. The active implementation comes from `NICTOOL_DATA_STORE` and defaults to MySQL; some resources also support TOML, MongoDB, or Elasticsearch backends. - The real persistence logic lives under `lib//store/*.js`. These stores translate between API-friendly field names and the legacy NicTool schema (`nt_*` columns), using helpers like `mapToDbColumn`, `objectToDb`, and `dbToObject`. - `lib/mysql.js` is the shared query builder/executor. Repositories rely on it for SQL generation rather than embedding ad hoc parameter handling in route files. -- Auth is session-backed but bearer-token based: `POST /session` authenticates through the user repo, creates an `nt_user_session` row via `lib/user/session.js`, and returns a JWT. Almost every other route runs under the default JWT auth strategy. +- Auth is session-backed but bearer-token based: `POST /session` authenticates through the session repo, creates an `nt_user_session` row via `lib/session/index.js`, and returns a JWT. Almost every other route runs under the default JWT auth strategy. - Group, user, and permission behavior is coupled. Group creation also creates a group-level permission row, user reads attach effective permissions, and permission resolution falls back from explicit user permissions to group permissions. - DNS data has an extra translation layer. `lib/zone_record/store/mysql.js` validates records with `@nictool/dns-resource-record` and maps NicTool’s legacy zone-record columns to RFC-style record fields. - Config comes from `conf.d/*.toml`, with runtime overrides from `NICTOOL_DB_*` and `NICTOOL_HTTP_*`. `lib/config.js` also auto-loads TLS material from any `.pem` file in `conf.d/`. diff --git a/lib/user/session.js b/lib/session/index.js similarity index 100% rename from lib/user/session.js rename to lib/session/index.js diff --git a/lib/user/test/session.js b/lib/session/test/index.js similarity index 91% rename from lib/user/test/session.js rename to lib/session/test/index.js index 51c3995..f0d0009 100644 --- a/lib/user/test/session.js +++ b/lib/session/test/index.js @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' import { describe, it, after, before } from 'node:test' -import User from '../index.js' -import Session from '../session.js' -import userCase from './user.json' with { type: 'json' } +import User from '../../user/index.js' +import Session from '../index.js' +import userCase from '../../user/test/user.json' with { type: 'json' } const sessionUser = { ...userCase, diff --git a/routes/session.js b/routes/session.js index be764c2..e49ee4e 100644 --- a/routes/session.js +++ b/routes/session.js @@ -4,7 +4,7 @@ import Config from '../lib/config.js' import Jwt from '@hapi/jwt' import User from '../lib/user/index.js' -import Session from '../lib/user/session.js' +import Session from '../lib/session/index.js' import { meta } from '../lib/util.js' diff --git a/test-fixtures.js b/test-fixtures.js index fedc62b..29cb252 100644 --- a/test-fixtures.js +++ b/test-fixtures.js @@ -4,7 +4,7 @@ import path from 'node:path' import Group from './lib/group/index.js' import User from './lib/user/index.js' -import Session from './lib/user/session.js' +import Session from './lib/session/index.js' import Permission from './lib/permission/index.js' import Nameserver from './lib/nameserver/index.js' import Zone from './lib/zone/index.js' diff --git a/test.sh b/test.sh index a04ab9d..0ae5a50 100755 --- a/test.sh +++ b/test.sh @@ -30,5 +30,5 @@ else # npm i --no-save node-test-github-reporter # $NODE --test --test-reporter=node-test-github-reporter # fi - $NODE --test --test-reporter=spec lib/*/test/*.js lib/*.test.js routes/*.test.js + $NODE --test --test-reporter=spec lib/*/test/index.js lib/*.test.js routes/*.test.js fi From bfe179e498cf1d0e6a56bafd6f231a4ff94696a3 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sun, 12 Apr 2026 14:41:24 -0500 Subject: [PATCH 3/3] additional TOML store support --- lib/group/store/toml.js | 182 +++++++++++++++++++++ lib/nameserver/store/toml.js | 124 ++++++++++++++ lib/permission/index.js | 296 ++-------------------------------- lib/permission/store/base.js | 67 ++++++++ lib/permission/store/mysql.js | 250 ++++++++++++++++++++++++++++ lib/permission/store/toml.js | 291 +++++++++++++++++++++++++++++++++ lib/session/index.js | 87 ++-------- lib/session/store/mysql.js | 77 +++++++++ lib/session/store/toml.js | 133 +++++++++++++++ lib/user/store/toml.js | 129 ++++++++++++--- lib/zone_record/store/toml.js | 23 ++- 11 files changed, 1274 insertions(+), 385 deletions(-) create mode 100644 lib/group/store/toml.js create mode 100644 lib/nameserver/store/toml.js create mode 100644 lib/permission/store/base.js create mode 100644 lib/permission/store/mysql.js create mode 100644 lib/permission/store/toml.js create mode 100644 lib/session/store/mysql.js create mode 100644 lib/session/store/toml.js diff --git a/lib/group/store/toml.js b/lib/group/store/toml.js new file mode 100644 index 0000000..9394102 --- /dev/null +++ b/lib/group/store/toml.js @@ -0,0 +1,182 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { parse, stringify } from 'smol-toml' + +import GroupBase from './base.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const defaultPermissions = { + inherit: false, + self_write: false, + group: { create: false, write: false, delete: false }, + nameserver: { usable: [], create: false, write: false, delete: false }, + zone: { create: false, write: false, delete: false, delegate: false }, + zonerecord: { create: false, write: false, delete: false, delegate: false }, + user: { create: false, write: false, delete: false }, +} + +function resolveStorePath(filename) { + const base = process.env.NICTOOL_DATA_STORE_PATH + if (base) return path.join(base, filename) + return path.resolve(__dirname, '../../../conf.d', filename) +} + +class GroupRepoTOML extends GroupBase { + constructor(args = {}) { + super(args) + this._filePath = resolveStorePath('group.toml') + } + + async _load() { + try { + const str = await fs.readFile(this._filePath, 'utf8') + const data = parse(str) + return Array.isArray(data.group) ? data.group : [] + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + } + + async _save(groups) { + await fs.mkdir(path.dirname(this._filePath), { recursive: true }) + await fs.writeFile(this._filePath, stringify({ group: groups })) + } + + _postProcess(row, deletedArg) { + const r = JSON.parse(JSON.stringify(row)) + r.deleted = Boolean(r.deleted) + if (r.permissions?.nameserver && !Array.isArray(r.permissions.nameserver.usable)) { + r.permissions.nameserver.usable = [] + } + if (deletedArg === false) delete r.deleted + return r + } + + // BFS over parent_gid relationships to collect all descendant group ids. + _collectSubgroupIds(groups, gid) { + const ids = [] + const queue = [gid] + while (queue.length) { + const cur = queue.shift() + for (const g of groups) { + if (g.parent_gid === cur && !ids.includes(g.id)) { + ids.push(g.id) + queue.push(g.id) + } + } + } + return ids + } + + async create(args) { + args = JSON.parse(JSON.stringify(args)) + + if (args.id) { + const existing = await this.get({ id: args.id }) + if (existing.length === 1) return existing[0].id + } + + const usable_ns = args.usable_ns ?? [] + delete args.usable_ns + + const gid = args.id + args.permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: gid, + name: `Group ${args.name} perms`, + user: { id: gid, create: false, write: false, delete: false }, + group: { id: gid, create: false, write: false, delete: false }, + nameserver: { + usable: Array.isArray(usable_ns) ? usable_ns : [], + create: false, + write: false, + delete: false, + }, + } + + const groups = await this._load() + groups.push(args) + await this._save(groups) + return gid + } + + async get(args_orig) { + const args = JSON.parse(JSON.stringify(args_orig)) + const deletedArg = args.deleted ?? false + const include_subgroups = args.include_subgroups === true + + let groups = await this._load() + + if (args.id !== undefined) { + if (include_subgroups) { + const subIds = this._collectSubgroupIds(groups, args.id) + const allIds = [args.id, ...subIds] + groups = groups.filter((g) => allIds.includes(g.id)) + } else { + groups = groups.filter((g) => g.id === args.id) + } + } + + if (args.parent_gid !== undefined) groups = groups.filter((g) => g.parent_gid === args.parent_gid) + if (args.name !== undefined) groups = groups.filter((g) => g.name === args.name) + + if (deletedArg === false) groups = groups.filter((g) => !g.deleted) + else if (deletedArg !== undefined) + groups = groups.filter((g) => Boolean(g.deleted) === Boolean(deletedArg)) + + return groups.map((g) => this._postProcess(g, deletedArg)) + } + + async put(args) { + if (!args.id) return false + args = JSON.parse(JSON.stringify(args)) + const id = args.id + delete args.id + + const usable_ns = args.usable_ns + delete args.usable_ns + + const groups = await this._load() + const idx = groups.findIndex((g) => g.id === id) + if (idx === -1) return false + + if (usable_ns !== undefined && groups[idx].permissions) { + groups[idx].permissions.nameserver = { + ...groups[idx].permissions.nameserver, + usable: Array.isArray(usable_ns) ? usable_ns : [], + } + } + + if (Object.keys(args).length > 0) { + groups[idx] = { ...groups[idx], ...args } + } + + await this._save(groups) + return true + } + + async delete(args) { + const groups = await this._load() + const idx = groups.findIndex((g) => g.id === args.id) + if (idx === -1) return false + + groups[idx].deleted = args.deleted ?? true + await this._save(groups) + return true + } + + async destroy(args) { + const groups = await this._load() + const before = groups.length + const filtered = groups.filter((g) => g.id !== args.id) + if (filtered.length === before) return false + await this._save(filtered) + return true + } +} + +export default GroupRepoTOML diff --git a/lib/nameserver/store/toml.js b/lib/nameserver/store/toml.js new file mode 100644 index 0000000..6e90e1a --- /dev/null +++ b/lib/nameserver/store/toml.js @@ -0,0 +1,124 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { parse, stringify } from 'smol-toml' + +import NameserverBase from './base.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const boolFields = ['deleted'] + +// Fields that default to empty string when absent or null +const emptyStringFields = ['description', 'address6', 'remote_login', 'logdir', 'datadir'] + +function resolveStorePath(filename) { + const base = process.env.NICTOOL_DATA_STORE_PATH + if (base) return path.join(base, filename) + return path.resolve(__dirname, '../../../conf.d', filename) +} + +class NameserverRepoTOML extends NameserverBase { + constructor(args = {}) { + super(args) + this._filePath = resolveStorePath('nameserver.toml') + } + + async _load() { + try { + const str = await fs.readFile(this._filePath, 'utf8') + const data = parse(str) + return Array.isArray(data.nameserver) ? data.nameserver : [] + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + } + + async _save(nameservers) { + await fs.mkdir(path.dirname(this._filePath), { recursive: true }) + await fs.writeFile(this._filePath, stringify({ nameserver: nameservers })) + } + + _postProcess(row, deletedArg) { + const r = JSON.parse(JSON.stringify(row)) + + for (const b of boolFields) r[b] = Boolean(r[b]) + for (const f of emptyStringFields) { + if ([null, undefined].includes(r[f])) r[f] = '' + } + + // Ensure the nested export object is always present and well-formed. + // Unlike MySQL (which joins nt_nameserver_export_type), TOML stores the + // type name inline, so no translation is needed. + if (!r.export || typeof r.export !== 'object') r.export = {} + if ([null, undefined].includes(r.export.type)) r.export.type = '' + if ([null, undefined].includes(r.export.interval)) r.export.interval = 0 + if ([null, undefined].includes(r.export.status)) r.export.status = '' + r.export.serials = Boolean(r.export.serials) + + if (deletedArg === false) delete r.deleted + return r + } + + async create(args) { + if (args.id) { + const existing = await this.get({ id: args.id }) + if (existing.length === 1) return existing[0].id + } + + const nameservers = await this._load() + nameservers.push(JSON.parse(JSON.stringify(args))) + await this._save(nameservers) + return args.id + } + + async get(args) { + args = JSON.parse(JSON.stringify(args)) + const deletedArg = args.deleted ?? false + + let nameservers = await this._load() + + if (args.id !== undefined) nameservers = nameservers.filter((n) => n.id === args.id) + if (args.gid !== undefined) nameservers = nameservers.filter((n) => n.gid === args.gid) + if (args.name !== undefined) nameservers = nameservers.filter((n) => n.name === args.name) + if (deletedArg === false) nameservers = nameservers.filter((n) => !n.deleted) + else if (deletedArg !== undefined) + nameservers = nameservers.filter((n) => Boolean(n.deleted) === Boolean(deletedArg)) + + return nameservers.map((n) => this._postProcess(n, deletedArg)) + } + + async put(args) { + if (!args.id) return false + const nameservers = await this._load() + const idx = nameservers.findIndex((n) => n.id === args.id) + if (idx === -1) return false + + nameservers[idx] = { ...nameservers[idx], ...JSON.parse(JSON.stringify(args)) } + await this._save(nameservers) + return true + } + + async delete(args) { + const nameservers = await this._load() + const idx = nameservers.findIndex((n) => n.id === args.id) + if (idx === -1) return false + + nameservers[idx].deleted = args.deleted ?? true + await this._save(nameservers) + return true + } + + async destroy(args) { + const nameservers = await this._load() + const before = nameservers.length + const filtered = nameservers.filter((n) => n.id !== args.id) + if (filtered.length === before) return false + await this._save(filtered) + return true + } +} + +export default NameserverRepoTOML diff --git a/lib/permission/index.js b/lib/permission/index.js index ef3883f..05433fb 100644 --- a/lib/permission/index.js +++ b/lib/permission/index.js @@ -1,290 +1,12 @@ -import Mysql from '../mysql.js' -import { mapToDbColumn } from '../util.js' +const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' -const permDbMap = { - id: 'nt_perm_id', - uid: 'nt_user_id', - gid: 'nt_group_id', - inherit: 'inherit_perm', - name: 'perm_name', +let RepoClass +switch (storeType) { + case 'toml': + RepoClass = (await import('./store/toml.js')).default + break + default: + RepoClass = (await import('./store/mysql.js')).default } -class Permission { - constructor() { - this.mysql = Mysql - } - - async create(args) { - if (args.id) { - const p = await this.get({ id: args.id }) - if (p) return p.id - } - - // Deduplicate group-level permission rows (uid IS NULL) to prevent accumulation - if (args.gid !== undefined && args.uid === undefined) { - const rows = await Mysql.execute( - `SELECT nt_perm_id FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL LIMIT 1`, - [args.gid], - ) - if (rows.length > 0) return rows[0].nt_perm_id - } - - return await Mysql.execute(...Mysql.insert(`nt_perm`, mapToDbColumn(objectToDb(args), permDbMap))) - } - - async get(args) { - args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false - - const baseQuery = `SELECT p.nt_perm_id AS id - , p.nt_user_id AS uid - , p.nt_group_id AS gid - , p.inherit_perm AS inherit - , p.perm_name AS name - ${getPermFields()} - , p.deleted - FROM nt_perm p` - - // Build WHERE manually so we can express IS NULL for group-level lookups. - // When no uid is given (gid-only query), restrict to rows where uid IS NULL - // to avoid matching per-user permission rows in the same group. - const dbArgs = mapToDbColumn(args, permDbMap) - const conditions = [] - const params = [] - for (const [col, val] of Object.entries(dbArgs)) { - conditions.push(`p.${col} = ?`) - params.push(val) - } - if (!('nt_user_id' in dbArgs) && !('nt_perm_id' in dbArgs)) { - conditions.push('p.nt_user_id IS NULL') - } - const query = conditions.length - ? `${baseQuery} WHERE ${conditions.join(' AND ')}` - : baseQuery - - const rows = await Mysql.execute(query, params) - if (rows.length === 0) return - if (rows.length > 1) { - throw new Error(`permissions.get found ${rows.length} rows for uid ${args.uid}`) - } - const row = dbToObject(rows[0]) - if (args.deleted === false) delete row.deleted - return row - } - - async getGroup(args) { - const query = `SELECT p.nt_perm_id AS id - , p.nt_user_id AS uid - , p.nt_group_id AS gid - , p.inherit_perm AS inherit - , p.perm_name AS name - ${getPermFields()} - , p.deleted - FROM nt_perm p - INNER JOIN nt_user u ON p.nt_group_id = u.nt_group_id - WHERE p.nt_user_id IS NULL - AND p.deleted=${args.deleted === true ? 1 : 0} - AND u.deleted=0 - AND u.nt_user_id=?` - const rows = await Mysql.execute(...Mysql.select(query, [args.uid])) - if (rows.length === 0) return - const row = dbToObject(rows[0]) - if ([false, undefined].includes(args.deleted)) delete row.deleted - return row - } - - async put(args) { - if (!args.id) return false - const id = args.id - delete args.id - const r = await Mysql.execute( - ...Mysql.update(`nt_perm`, `nt_perm_id=${id}`, mapToDbColumn(args, permDbMap)), - ) - return r.changedRows === 1 - } - - async delete(args) { - if (!args.id) return false - const r = await Mysql.execute( - ...Mysql.update(`nt_perm`, `nt_perm_id=${args.id}`, { - deleted: args.deleted ?? 1, - }), - ) - return r.changedRows === 1 - } - - async destroy(args) { - const r = await Mysql.execute(...Mysql.delete(`nt_perm`, mapToDbColumn(args, permDbMap))) - return r.affectedRows === 1 - } - - /** - * Returns the effective permissions for a user: - * - If the user has their own nt_perm row with inherit=false, return it. - * - Otherwise return the group-level permissions. - */ - async getEffective(uid) { - const userPerm = await this.get({ uid }) - if (userPerm && userPerm.inherit === false) return userPerm - return this.getGroup({ uid }) - } - - /** - * Returns true if the user is allowed to perform `action` on `resource`. - * resource: 'zone' | 'zonerecord' | 'user' | 'group' | 'nameserver' - * action: 'create' | 'write' | 'delete' | 'delegate' - */ - async canDo(uid, resource, action) { - const perm = await this.getEffective(uid) - if (!perm) return false - return perm[resource]?.[action] === true - } -} - -export default new Permission() - -function getPermFields() { - return ( - `, p.` + - [ - 'group_write', - 'group_create', - 'group_delete', - - 'zone_write', - 'zone_create', - 'zone_delegate', - 'zone_delete', - - 'zonerecord_write', - 'zonerecord_create', - 'zonerecord_delegate', - 'zonerecord_delete', - - 'user_write', - 'user_create', - 'user_delete', - - 'nameserver_write', - 'nameserver_create', - 'nameserver_delete', - - 'self_write', - 'usable_ns', - ].join(`, p.`) - ) -} - -/* the following two functions convert to and from: - -the SQL DB format: -{ - "id": 4096, - "uid": 4096, - "gid": 4096, - "inherit": 1, - "name": "Test Permission", - "group_write": 0, - "group_create": 0, - "group_delete": 0, - "zone_write": 1, - "zone_create": 1, - "zone_delegate": 1, - "zone_delete": 1, - "zonerecord_write": 0, - "zonerecord_create": 0, - "zonerecord_delegate": 0, - "zonerecord_delete": 0, - "user_write": 0, - "user_create": 0, - "user_delete": 0, - "nameserver_write": 0, - "nameserver_create": 0, - "nameserver_delete": 0, - "self_write": 0, - "usable_ns": "", - "deleted": 0 -} - -JSON object format: - -{ - "id": 4096, - "inherit": true, - "name": "Test Permission", - "self_write": false, - "deleted": false, - "group": { "id": 4096, "create": false, "write": false, "delete": false }, - "nameserver": { "usable": [], "create": false, "write": false, "delete": false }, - "zone": { "create": true, "write": true, "delete": true, "delegate": true }, - "zonerecord": { - "create": false, - "write": false, - "delete": false, - "delegate": false - }, - "user": { "id": 4096, "create": false, "write": false, "delete": false } -} -*/ - -const boolFields = ['self_write', 'inherit', 'deleted'] - -function dbToObject(row) { - row = JSON.parse(JSON.stringify(row)) - for (const f of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { - for (const p of ['create', 'write', 'delete', 'delegate']) { - if (row[`${f}_${p}`] !== undefined) { - if (row[f] === undefined) row[f] = {} - row[f][p] = row[`${f}_${p}`] === 1 - delete row[`${f}_${p}`] - } - } - } - for (const b of boolFields) { - row[b] = row[b] === 1 - } - - if (row.uid !== undefined) { - row.user.id = row.uid - delete row.uid - } - if (row.gid !== undefined) { - row.group.id = row.gid - delete row.gid - } - row.nameserver.usable = [] - if (![undefined, null, ''].includes(row.usable_ns)) { - row.nameserver.usable = row.usable_ns?.split(',') - } - delete row.usable_ns - return row -} - -function objectToDb(row) { - row = JSON.parse(JSON.stringify(row)) - if (row?.user?.id !== undefined) { - row.uid = row.user.id - delete row.user.id - } - if (row?.group?.id !== undefined) { - row.gid = row.group.id - delete row.group.id - } - if (row?.nameserver?.usable !== undefined) { - row.usable_ns = row.nameserver.usable.join(',') - delete row.nameserver.usable - } - for (const f of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { - for (const p of ['create', 'write', 'delete', 'delegate']) { - if (row[f] === undefined) continue - if (row[f][p] === undefined) continue - row[`${f}_${p}`] = row[f][p] === true ? 1 : 0 - delete row[f][p] - } - delete row[f] - } - for (const b of boolFields) { - row[b] = row[b] === true ? 1 : 0 - } - return row -} +export default new RepoClass() diff --git a/lib/permission/store/base.js b/lib/permission/store/base.js new file mode 100644 index 0000000..ac59a10 --- /dev/null +++ b/lib/permission/store/base.js @@ -0,0 +1,67 @@ +/** + * Permission domain class – pure contract and cross-cutting logic. + * + * Has zero knowledge of how permissions are persisted. All permission + * repository classes must extend this class and implement the repo contract. + * + * Repo contract: + * create(args) → id + * get(args) → object | undefined + * getGroup(args) → object | undefined + * put(args) → boolean + * delete(args) → boolean + * destroy(args) → boolean + */ +class PermissionBase { + constructor(args = {}) { + this.debug = args?.debug ?? false + } + + async create(_args) { + throw new Error('create() not implemented by this store') + } + + async get(_args) { + throw new Error('get() not implemented by this store') + } + + async getGroup(_args) { + throw new Error('getGroup() not implemented by this store') + } + + async put(_args) { + throw new Error('put() not implemented by this store') + } + + async delete(_args) { + throw new Error('delete() not implemented by this store') + } + + async destroy(_args) { + throw new Error('destroy() not implemented by this store') + } + + /** + * Returns the effective permissions for a user: + * – If the user has their own permission row with inherit=false, return it. + * – Otherwise return the group-level permissions. + */ + async getEffective(uid) { + const userPerm = await this.get({ uid }) + if (userPerm && userPerm.inherit === false) return userPerm + return this.getGroup({ uid }) + } + + /** + * Returns true if the user is allowed to perform `action` on `resource`. + * resource: 'zone' | 'zonerecord' | 'user' | 'group' | 'nameserver' + * action: 'create' | 'write' | 'delete' | 'delegate' + */ + async canDo(uid, resource, action) { + const perm = await this.getEffective(uid) + if (!perm) return false + return perm[resource]?.[action] === true + } +} + +export default PermissionBase diff --git a/lib/permission/store/mysql.js b/lib/permission/store/mysql.js new file mode 100644 index 0000000..e1240bd --- /dev/null +++ b/lib/permission/store/mysql.js @@ -0,0 +1,250 @@ +import Mysql from '../../mysql.js' +import { mapToDbColumn } from '../../util.js' +import PermissionBase from './base.js' + +const permDbMap = { + id: 'nt_perm_id', + uid: 'nt_user_id', + gid: 'nt_group_id', + inherit: 'inherit_perm', + name: 'perm_name', +} + +class PermissionRepoMySQL extends PermissionBase { + constructor(args = {}) { + super(args) + this.mysql = Mysql + } + + async create(args) { + if (args.id) { + const p = await this.get({ id: args.id }) + if (p) return p.id + } + + // Deduplicate group-level permission rows (uid IS NULL) to prevent accumulation + if (args.gid !== undefined && args.uid === undefined) { + const rows = await Mysql.execute( + `SELECT nt_perm_id FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL LIMIT 1`, + [args.gid], + ) + if (rows.length > 0) return rows[0].nt_perm_id + } + + return await Mysql.execute(...Mysql.insert(`nt_perm`, mapToDbColumn(objectToDb(args), permDbMap))) + } + + async get(args) { + args = JSON.parse(JSON.stringify(args)) + if (args.deleted === undefined) args.deleted = false + + const baseQuery = `SELECT p.nt_perm_id AS id + , p.nt_user_id AS uid + , p.nt_group_id AS gid + , p.inherit_perm AS inherit + , p.perm_name AS name + ${getPermFields()} + , p.deleted + FROM nt_perm p` + + // Build WHERE manually so we can express IS NULL for group-level lookups. + // When no uid is given (gid-only query), restrict to rows where uid IS NULL + // to avoid matching per-user permission rows in the same group. + const dbArgs = mapToDbColumn(args, permDbMap) + const conditions = [] + const params = [] + for (const [col, val] of Object.entries(dbArgs)) { + conditions.push(`p.${col} = ?`) + params.push(val) + } + if (!('nt_user_id' in dbArgs) && !('nt_perm_id' in dbArgs)) { + conditions.push('p.nt_user_id IS NULL') + } + const query = conditions.length + ? `${baseQuery} WHERE ${conditions.join(' AND ')}` + : baseQuery + + const rows = await Mysql.execute(query, params) + if (rows.length === 0) return + if (rows.length > 1) { + throw new Error(`permissions.get found ${rows.length} rows for uid ${args.uid}`) + } + const row = dbToObject(rows[0]) + if (args.deleted === false) delete row.deleted + return row + } + + async getGroup(args) { + const query = `SELECT p.nt_perm_id AS id + , p.nt_user_id AS uid + , p.nt_group_id AS gid + , p.inherit_perm AS inherit + , p.perm_name AS name + ${getPermFields()} + , p.deleted + FROM nt_perm p + INNER JOIN nt_user u ON p.nt_group_id = u.nt_group_id + WHERE p.nt_user_id IS NULL + AND p.deleted=${args.deleted === true ? 1 : 0} + AND u.deleted=0 + AND u.nt_user_id=?` + const rows = await Mysql.execute(...Mysql.select(query, [args.uid])) + if (rows.length === 0) return + const row = dbToObject(rows[0]) + if ([false, undefined].includes(args.deleted)) delete row.deleted + return row + } + + async put(args) { + if (!args.id) return false + const id = args.id + delete args.id + const r = await Mysql.execute( + ...Mysql.update(`nt_perm`, `nt_perm_id=${id}`, mapToDbColumn(args, permDbMap)), + ) + return r.changedRows === 1 + } + + async delete(args) { + if (!args.id) return false + const r = await Mysql.execute( + ...Mysql.update(`nt_perm`, `nt_perm_id=${args.id}`, { + deleted: args.deleted ?? 1, + }), + ) + return r.changedRows === 1 + } + + async destroy(args) { + const r = await Mysql.execute(...Mysql.delete(`nt_perm`, mapToDbColumn(args, permDbMap))) + return r.affectedRows === 1 + } +} + +export default PermissionRepoMySQL + +function getPermFields() { + return ( + `, p.` + + [ + 'group_write', + 'group_create', + 'group_delete', + + 'zone_write', + 'zone_create', + 'zone_delegate', + 'zone_delete', + + 'zonerecord_write', + 'zonerecord_create', + 'zonerecord_delegate', + 'zonerecord_delete', + + 'user_write', + 'user_create', + 'user_delete', + + 'nameserver_write', + 'nameserver_create', + 'nameserver_delete', + + 'self_write', + 'usable_ns', + ].join(`, p.`) + ) +} + +/* the following two functions convert to and from: + +the SQL DB format: +{ + "id": 4096, + "uid": 4096, + "gid": 4096, + "inherit": 1, + "name": "Test Permission", + "group_write": 0, + ... + "self_write": 0, + "usable_ns": "", + "deleted": 0 +} + +JSON object format: + +{ + "id": 4096, + "inherit": true, + "name": "Test Permission", + "self_write": false, + "deleted": false, + "group": { "id": 4096, "create": false, "write": false, "delete": false }, + "nameserver": { "usable": [], "create": false, "write": false, "delete": false }, + "zone": { "create": true, "write": true, "delete": true, "delegate": true }, + "zonerecord": { "create": false, "write": false, "delete": false, "delegate": false }, + "user": { "id": 4096, "create": false, "write": false, "delete": false } +} +*/ + +const boolFields = ['self_write', 'inherit', 'deleted'] + +function dbToObject(row) { + row = JSON.parse(JSON.stringify(row)) + for (const f of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { + for (const p of ['create', 'write', 'delete', 'delegate']) { + if (row[`${f}_${p}`] !== undefined) { + if (row[f] === undefined) row[f] = {} + row[f][p] = row[`${f}_${p}`] === 1 + delete row[`${f}_${p}`] + } + } + } + for (const b of boolFields) { + row[b] = row[b] === 1 + } + + if (row.uid !== undefined) { + row.user.id = row.uid + delete row.uid + } + if (row.gid !== undefined) { + row.group.id = row.gid + delete row.gid + } + row.nameserver.usable = [] + if (![undefined, null, ''].includes(row.usable_ns)) { + row.nameserver.usable = row.usable_ns?.split(',') + } + delete row.usable_ns + return row +} + +function objectToDb(row) { + row = JSON.parse(JSON.stringify(row)) + if (row?.user?.id !== undefined) { + row.uid = row.user.id + delete row.user.id + } + if (row?.group?.id !== undefined) { + row.gid = row.group.id + delete row.group.id + } + if (row?.nameserver?.usable !== undefined) { + row.usable_ns = row.nameserver.usable.join(',') + delete row.nameserver.usable + } + for (const f of ['group', 'nameserver', 'zone', 'zonerecord', 'user']) { + for (const p of ['create', 'write', 'delete', 'delegate']) { + if (row[f] === undefined) continue + if (row[f][p] === undefined) continue + row[`${f}_${p}`] = row[f][p] === true ? 1 : 0 + delete row[f][p] + } + delete row[f] + } + for (const b of boolFields) { + row[b] = row[b] === true ? 1 : 0 + } + return row +} diff --git a/lib/permission/store/toml.js b/lib/permission/store/toml.js new file mode 100644 index 0000000..85a0da1 --- /dev/null +++ b/lib/permission/store/toml.js @@ -0,0 +1,291 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { parse, stringify } from 'smol-toml' + +import PermissionBase from './base.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const defaultPermissions = { + inherit: false, + self_write: false, + group: { create: false, write: false, delete: false }, + nameserver: { usable: [], create: false, write: false, delete: false }, + zone: { create: false, write: false, delete: false, delegate: false }, + zonerecord: { create: false, write: false, delete: false, delegate: false }, + user: { create: false, write: false, delete: false }, +} + +function resolveStorePath(filename) { + const base = process.env.NICTOOL_DATA_STORE_PATH + if (base) return path.join(base, filename) + return path.resolve(__dirname, '../../../conf.d', filename) +} + +/** + * TOML permission store — a facade over user.toml and group.toml. + * + * Permissions are stored inline in each user/group record rather than in a + * separate file. This store reads and writes those files directly via + * fs.readFile / fs.writeFile (never via the User or Group modules) to avoid + * circular import cycles. + * + * get({ uid }) → inline permissions of that user + * get({ gid }) → inline permissions of that group (uid absent) + * get({ id }) → search user.toml first, then group.toml by permissions.id + * getGroup({ uid }) → permissions of the group the user belongs to + */ +class PermissionRepoTOML extends PermissionBase { + constructor(args = {}) { + super(args) + this._userPath = resolveStorePath('user.toml') + this._groupPath = resolveStorePath('group.toml') + } + + // --------------------------------------------------------------------------- + // Raw file I/O + // --------------------------------------------------------------------------- + + async _loadUsers() { + try { + const str = await fs.readFile(this._userPath, 'utf8') + const data = parse(str) + return Array.isArray(data.user) ? data.user : [] + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + } + + async _saveUsers(users) { + await fs.mkdir(path.dirname(this._userPath), { recursive: true }) + await fs.writeFile(this._userPath, stringify({ user: users })) + } + + async _loadGroups() { + try { + const str = await fs.readFile(this._groupPath, 'utf8') + const data = parse(str) + return Array.isArray(data.group) ? data.group : [] + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + } + + async _saveGroups(groups) { + await fs.mkdir(path.dirname(this._groupPath), { recursive: true }) + await fs.writeFile(this._groupPath, stringify({ group: groups })) + } + + // --------------------------------------------------------------------------- + // Post-processing + // --------------------------------------------------------------------------- + + _postProcess(perm, deletedArg) { + if (!perm) return undefined + const r = JSON.parse(JSON.stringify(perm)) + r.deleted = Boolean(r.deleted) + if (r.nameserver && !Array.isArray(r.nameserver.usable)) r.nameserver.usable = [] + if (deletedArg === false) delete r.deleted + return r + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + async create(args) { + args = JSON.parse(JSON.stringify(args)) + const uid = args.uid ?? args.user?.id + const gid = args.gid ?? args.group?.id + delete args.uid + delete args.gid + + if (uid !== undefined) { + const users = await this._loadUsers() + const idx = users.findIndex((u) => u.id === uid) + if (idx === -1) return undefined + + if (!users[idx].permissions) { + const usable = Array.isArray(args.nameserver?.usable) ? args.nameserver.usable : [] + users[idx].permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: uid, + inherit: false, + user: { id: uid, create: false, write: false, delete: false }, + group: { id: gid ?? users[idx].gid, create: false, write: false, delete: false }, + nameserver: { usable, create: false, write: false, delete: false }, + } + } + await this._saveUsers(users) + return users[idx].permissions.id + } + + if (gid !== undefined) { + const groups = await this._loadGroups() + const idx = groups.findIndex((g) => g.id === gid) + if (idx === -1) return undefined + + if (!groups[idx].permissions) { + const usable = Array.isArray(args.nameserver?.usable) ? args.nameserver.usable : [] + groups[idx].permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: gid, + name: args.name, + inherit: false, + user: { id: gid, create: false, write: false, delete: false }, + group: { id: gid, create: false, write: false, delete: false }, + nameserver: { usable, create: false, write: false, delete: false }, + } + } + await this._saveGroups(groups) + return groups[idx].permissions.id + } + + return undefined + } + + async get(args) { + args = JSON.parse(JSON.stringify(args)) + const deletedArg = args.deleted ?? false + + if (args.uid !== undefined) { + const users = await this._loadUsers() + const user = users.find((u) => u.id === args.uid) + if (!user?.permissions) return undefined + const perm = this._postProcess(user.permissions, deletedArg) + if (deletedArg === true && perm.deleted !== true) return undefined + return perm + } + + if (args.gid !== undefined) { + // group-level lookup: no uid qualifier + const groups = await this._loadGroups() + const group = groups.find((g) => g.id === args.gid) + if (!group?.permissions) return undefined + const perm = this._postProcess(group.permissions, deletedArg) + if (deletedArg === true && perm.deleted !== true) return undefined + return perm + } + + if (args.id !== undefined) { + // Search user.toml first (user and group ids can collide) + const users = await this._loadUsers() + const user = users.find((u) => u.permissions?.id === args.id) + if (user?.permissions) return this._postProcess(user.permissions, deletedArg) + + const groups = await this._loadGroups() + const group = groups.find((g) => g.permissions?.id === args.id) + if (group?.permissions) return this._postProcess(group.permissions, deletedArg) + } + + return undefined + } + + async getGroup(args) { + const users = await this._loadUsers() + const user = users.find((u) => u.id === args.uid && !u.deleted) + if (!user) return undefined + + const groups = await this._loadGroups() + const group = groups.find((g) => g.id === user.gid) + if (!group?.permissions) return undefined + + const deletedArg = args.deleted ?? false + return this._postProcess(group.permissions, deletedArg) + } + + async put(args) { + args = JSON.parse(JSON.stringify(args)) + if (!args.id) return false + const id = args.id + delete args.id + + const users = await this._loadUsers() + const uidx = users.findIndex((u) => u.permissions?.id === id) + if (uidx !== -1) { + users[uidx].permissions = deepMerge(users[uidx].permissions, args) + await this._saveUsers(users) + return true + } + + const groups = await this._loadGroups() + const gidx = groups.findIndex((g) => g.permissions?.id === id) + if (gidx !== -1) { + groups[gidx].permissions = deepMerge(groups[gidx].permissions, args) + await this._saveGroups(groups) + return true + } + + return false + } + + async delete(args) { + if (!args.id) return false + const deletedVal = args.deleted ?? true + + const users = await this._loadUsers() + const uidx = users.findIndex((u) => u.permissions?.id === args.id) + if (uidx !== -1) { + users[uidx].permissions.deleted = deletedVal + await this._saveUsers(users) + return true + } + + const groups = await this._loadGroups() + const gidx = groups.findIndex((g) => g.permissions?.id === args.id) + if (gidx !== -1) { + groups[gidx].permissions.deleted = deletedVal + await this._saveGroups(groups) + return true + } + + return false + } + + async destroy(args) { + if (!args.id) return false + + const users = await this._loadUsers() + const uidx = users.findIndex((u) => u.permissions?.id === args.id) + if (uidx !== -1) { + delete users[uidx].permissions + await this._saveUsers(users) + return true + } + + const groups = await this._loadGroups() + const gidx = groups.findIndex((g) => g.permissions?.id === args.id) + if (gidx !== -1) { + delete groups[gidx].permissions + await this._saveGroups(groups) + return true + } + + return false + } +} + +// Recursively merge source into a deep clone of target. +// Arrays are replaced (not concatenated). +function deepMerge(target, source) { + const result = JSON.parse(JSON.stringify(target)) + for (const [key, value] of Object.entries(source)) { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof result[key] === 'object' + ) { + result[key] = deepMerge(result[key], value) + } else { + result[key] = value + } + } + return result +} + +export default PermissionRepoTOML diff --git a/lib/session/index.js b/lib/session/index.js index 3fb6527..05433fb 100644 --- a/lib/session/index.js +++ b/lib/session/index.js @@ -1,79 +1,12 @@ -import Mysql from '../mysql.js' -import { mapToDbColumn } from '../util.js' - -const sessionDbMap = { - id: 'nt_user_session_id', - uid: 'nt_user_id', - session: 'nt_user_session', -} - -class Session { - constructor() { - this.mysql = Mysql - } - - async create(args) { - const r = await this.get(args) - if (r) return r.id - - const id = await Mysql.execute(...Mysql.insert(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) - return id - } - - async get(args) { - let query = `SELECT s.nt_user_session_id AS id - , s.nt_user_id AS uid - , s.nt_user_session AS session - FROM nt_user_session s - LEFT JOIN nt_user u ON s.nt_user_id = u.nt_user_id - WHERE u.deleted=0` - - const params = [] - for (const f of ['nt_user_session_id', 'nt_user_id', 'nt_user_session']) { - if (args[f] !== undefined) { - query += ` AND s.${f} = ?` - params.push(args[f]) - } - } - for (const g of ['id', 'uid', 'session']) { - if (args[g] !== undefined) { - query += ` AND s.${sessionDbMap[g]} = ?` - params.push(args[g]) - } - } - - const sessions = await Mysql.execute(query, params) - return sessions[0] - } - - async put(args) { - if (!args.id) return false - - if (args.last_access) { - const p = await this.get({ id: args.id }) - if (!p) return false - - // if less than 60 seconds old, do nothing - const now = parseInt(Date.now() / 1000, 10) - const oneMinuteAgo = now - 60 - - // update only when +1 minute old (save DB writes) - if (p.last_access > oneMinuteAgo) return true - args.last_access = now - } - - const id = args.id - delete args.id - const r = await Mysql.execute( - ...Mysql.update(`nt_user_session`, `nt_user_session_id=${id}`, mapToDbColumn(args, sessionDbMap)), - ) - return r.changedRows === 1 - } - - async delete(args) { - const r = await Mysql.execute(...Mysql.delete(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) - return r.affectedRows === 1 - } +const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' + +let RepoClass +switch (storeType) { + case 'toml': + RepoClass = (await import('./store/toml.js')).default + break + default: + RepoClass = (await import('./store/mysql.js')).default } -export default new Session() +export default new RepoClass() diff --git a/lib/session/store/mysql.js b/lib/session/store/mysql.js new file mode 100644 index 0000000..5e5f547 --- /dev/null +++ b/lib/session/store/mysql.js @@ -0,0 +1,77 @@ +import Mysql from '../../mysql.js' +import { mapToDbColumn } from '../../util.js' + +const sessionDbMap = { + id: 'nt_user_session_id', + uid: 'nt_user_id', + session: 'nt_user_session', +} + +class SessionRepoMySQL { + constructor() { + this.mysql = Mysql + } + + async create(args) { + const r = await this.get(args) + if (r) return r.id + + const id = await Mysql.execute(...Mysql.insert(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) + return id + } + + async get(args) { + let query = `SELECT s.nt_user_session_id AS id + , s.nt_user_id AS uid + , s.nt_user_session AS session + FROM nt_user_session s + LEFT JOIN nt_user u ON s.nt_user_id = u.nt_user_id + WHERE u.deleted=0` + + const params = [] + for (const f of ['nt_user_session_id', 'nt_user_id', 'nt_user_session']) { + if (args[f] !== undefined) { + query += ` AND s.${f} = ?` + params.push(args[f]) + } + } + for (const g of ['id', 'uid', 'session']) { + if (args[g] !== undefined) { + query += ` AND s.${sessionDbMap[g]} = ?` + params.push(args[g]) + } + } + + const sessions = await Mysql.execute(query, params) + return sessions[0] + } + + async put(args) { + if (!args.id) return false + + if (args.last_access) { + const p = await this.get({ id: args.id }) + if (!p) return false + + // update only when +1 minute old (save DB writes) + const now = parseInt(Date.now() / 1000, 10) + const oneMinuteAgo = now - 60 + if (p.last_access > oneMinuteAgo) return true + args.last_access = now + } + + const id = args.id + delete args.id + const r = await Mysql.execute( + ...Mysql.update(`nt_user_session`, `nt_user_session_id=${id}`, mapToDbColumn(args, sessionDbMap)), + ) + return r.changedRows === 1 + } + + async delete(args) { + const r = await Mysql.execute(...Mysql.delete(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) + return r.affectedRows === 1 + } +} + +export default SessionRepoMySQL diff --git a/lib/session/store/toml.js b/lib/session/store/toml.js new file mode 100644 index 0000000..369ae22 --- /dev/null +++ b/lib/session/store/toml.js @@ -0,0 +1,133 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { parse, stringify } from 'smol-toml' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function resolveStorePath(filename) { + const base = process.env.NICTOOL_DATA_STORE_PATH + if (base) return path.join(base, filename) + return path.resolve(__dirname, '../../../conf.d', filename) +} + +// Map legacy nt_* column names to the friendly API names used throughout. +function normalizeArgs(args) { + if (args.nt_user_session_id !== undefined) { + args.id = args.nt_user_session_id + delete args.nt_user_session_id + } + if (args.nt_user_id !== undefined) { + args.uid = args.nt_user_id + delete args.nt_user_id + } + if (args.nt_user_session !== undefined) { + args.session = args.nt_user_session + delete args.nt_user_session + } + return args +} + +class SessionRepoTOML { + constructor() { + this._filePath = resolveStorePath('session.toml') + } + + async _load() { + try { + const str = await fs.readFile(this._filePath, 'utf8') + const data = parse(str) + return Array.isArray(data.session) ? data.session : [] + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } + } + + async _save(sessions) { + await fs.mkdir(path.dirname(this._filePath), { recursive: true }) + await fs.writeFile(this._filePath, stringify({ session: sessions })) + } + + async create(args) { + args = normalizeArgs(JSON.parse(JSON.stringify(args))) + + const existing = await this.get({ uid: args.uid, session: args.session }) + if (existing) return existing.id + + const sessions = await this._load() + const nextId = sessions.reduce((max, s) => Math.max(max, s.id ?? 0), 0) + 1 + args.id = nextId + sessions.push(args) + await this._save(sessions) + return nextId + } + + async get(args) { + args = normalizeArgs(JSON.parse(JSON.stringify(args))) + + const sessions = await this._load() + return sessions.find((s) => { + if (args.id !== undefined && s.id !== args.id) return false + if (args.uid !== undefined && s.uid !== args.uid) return false + if (args.session !== undefined && s.session !== args.session) return false + return true + }) + } + + async put(args) { + if (!args.id) return false + args = normalizeArgs(JSON.parse(JSON.stringify(args))) + + if (args.last_access) { + const s = await this.get({ id: args.id }) + if (!s) return false + + // Only write when last_access is more than 1 minute old (reduce I/O) + const now = parseInt(Date.now() / 1000, 10) + if (s.last_access > now - 60) return true + + const sessions = await this._load() + const idx = sessions.findIndex((s) => s.id === args.id) + if (idx === -1) return false + sessions[idx].last_access = now + await this._save(sessions) + return true + } + + const sessions = await this._load() + const idx = sessions.findIndex((s) => s.id === args.id) + if (idx === -1) return false + const id = args.id + delete args.id + sessions[idx] = { ...sessions[idx], ...args, id } + await this._save(sessions) + return true + } + + /** + * Removes sessions that match ALL provided filters (AND semantics). + * Supports: { id }, { uid }, { id, session }, etc. + */ + async delete(args) { + args = normalizeArgs(JSON.parse(JSON.stringify(args))) + + const sessions = await this._load() + const before = sessions.length + + const filtered = sessions.filter((s) => { + // Keep this session unless every provided filter matches it + if (args.id !== undefined && s.id !== args.id) return true + if (args.uid !== undefined && s.uid !== args.uid) return true + if (args.session !== undefined && s.session !== args.session) return true + return false // all conditions matched → remove + }) + + if (filtered.length === before) return false + await this._save(filtered) + return true + } +} + +export default SessionRepoTOML diff --git a/lib/user/store/toml.js b/lib/user/store/toml.js index 01a2832..211100c 100644 --- a/lib/user/store/toml.js +++ b/lib/user/store/toml.js @@ -10,6 +10,29 @@ import UserBase from './base.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const boolFields = ['is_admin', 'deleted'] +// Read group.toml directly (never via the Group module) to avoid circular imports. +async function loadGroupPerm(groupPath, gid) { + try { + const str = await fs.readFile(groupPath, 'utf8') + const data = parse(str) + const groups = Array.isArray(data.group) ? data.group : [] + return groups.find((g) => g.id === gid)?.permissions ?? null + } catch (err) { + if (err.code === 'ENOENT') return null + throw err + } +} + +const defaultPermissions = { + inherit: false, + self_write: false, + group: { create: false, write: false, delete: false }, + nameserver: { usable: [], create: false, write: false, delete: false }, + zone: { create: false, write: false, delete: false, delegate: false }, + zonerecord: { create: false, write: false, delete: false, delegate: false }, + user: { create: false, write: false, delete: false }, +} + function resolveStorePath(filename) { const base = process.env.NICTOOL_DATA_STORE_PATH if (base) return path.join(base, filename) @@ -21,6 +44,7 @@ class UserRepoTOML extends UserBase { super(args) this.cfg = Config.getSync('http') this._filePath = resolveStorePath('user.toml') + this._groupPath = resolveStorePath('group.toml') } async _load() { @@ -39,6 +63,16 @@ class UserRepoTOML extends UserBase { await fs.writeFile(this._filePath, stringify({ user: users })) } + _postProcess(u, deletedArg) { + const r = { ...u } + for (const b of boolFields) r[b] = Boolean(r[b]) + if (r.permissions) { + r.inherit_group_permissions = r.permissions.inherit !== false + } + if (deletedArg === false) delete r.deleted + return r + } + async authenticate(authTry) { let [username, groupName] = authTry.username.split('@') if (!groupName) groupName = this.cfg.group ?? 'NicTool' @@ -50,7 +84,7 @@ class UserRepoTOML extends UserBase { if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) { const result = { ...u } - for (const f of ['password', 'pass_salt']) delete result[f] + for (const f of ['password', 'pass_salt', 'permissions']) delete result[f] const g = { id: result.gid, name: groupName } delete result.gid return { user: result, group: g } @@ -60,35 +94,72 @@ class UserRepoTOML extends UserBase { async get(args) { args = JSON.parse(JSON.stringify(args)) - if (args.deleted === undefined) args.deleted = false + const deletedArg = args.deleted ?? false - const users = await this._load() - const results = users.filter((u) => { - if (args.id !== undefined && u.id !== args.id) return false - if (args.gid !== undefined && u.gid !== args.gid) return false - if (args.username !== undefined && u.username !== args.username) return false - if (args.deleted === false && u.deleted) return false - return true - }) - - return results.map((u) => { - const r = { ...u } - for (const b of boolFields) r[b] = Boolean(r[b]) - if (args.deleted === false) delete r.deleted - return r - }) + let users = await this._load() + + if (args.id !== undefined) users = users.filter((u) => u.id === args.id) + if (args.gid !== undefined) users = users.filter((u) => u.gid === args.gid) + if (args.username !== undefined) users = users.filter((u) => u.username === args.username) + if (deletedArg === false) users = users.filter((u) => !u.deleted) + else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) + + const result = [] + for (const u of users) { + const r = this._postProcess(u, deletedArg) + if (!r.permissions) { + // Inheriting user: attach the group's inline permissions + const groupPerm = await loadGroupPerm(this._groupPath, u.gid) + if (groupPerm) { + r.permissions = JSON.parse(JSON.stringify(groupPerm)) + r.inherit_group_permissions = true + } + } + result.push(r) + } + return result + } + + async count(args = {}) { + args = JSON.parse(JSON.stringify(args)) + const deletedArg = args.deleted ?? false + + let users = await this._load() + + if (args.id !== undefined) users = users.filter((u) => u.id === args.id) + if (args.gid !== undefined) users = users.filter((u) => u.gid === args.gid) + if (args.username !== undefined) users = users.filter((u) => u.username === args.username) + if (deletedArg === false) users = users.filter((u) => !u.deleted) + else if (deletedArg !== undefined) users = users.filter((u) => Boolean(u.deleted) === Boolean(deletedArg)) + + return users.length } async create(args) { - const existing = await this.get({ id: args.id, gid: args.gid }) - if (existing.length === 1) return existing[0].id + if (args.id) { + const existing = await this.get({ id: args.id }) + if (existing.length === 1) return existing[0].id + } args = JSON.parse(JSON.stringify(args)) + + const inherit = args.inherit_group_permissions + delete args.inherit_group_permissions + if (args.password) { if (!args.pass_salt) args.pass_salt = this.generateSalt() args.password = await this.hashAuthPbkdf2(args.password, args.pass_salt) } + if (inherit === false) { + args.permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: args.id, + user: { id: args.id, create: false, write: false, delete: false }, + group: { id: args.gid, create: false, write: false, delete: false }, + } + } + const users = await this._load() users.push(args) await this._save(users) @@ -97,10 +168,30 @@ class UserRepoTOML extends UserBase { async put(args) { if (!args.id) return false + args = JSON.parse(JSON.stringify(args)) + const users = await this._load() const idx = users.findIndex((u) => u.id === args.id) if (idx === -1) return false + const inherit = args.inherit_group_permissions + delete args.inherit_group_permissions + + if (inherit === true) { + // Switch to inherited: remove explicit permissions + delete users[idx].permissions + } else if (inherit === false && !users[idx].permissions) { + // Switch to explicit: create default permission entry + users[idx].permissions = { + ...JSON.parse(JSON.stringify(defaultPermissions)), + id: users[idx].id, + user: { id: users[idx].id, create: false, write: false, delete: false }, + group: { id: users[idx].gid, create: false, write: false, delete: false }, + } + } else if (inherit === false && users[idx].permissions) { + users[idx].permissions.inherit = false + } + users[idx] = { ...users[idx], ...args } await this._save(users) return true diff --git a/lib/zone_record/store/toml.js b/lib/zone_record/store/toml.js index 9a6ab59..213bc6c 100644 --- a/lib/zone_record/store/toml.js +++ b/lib/zone_record/store/toml.js @@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url' import { parse, stringify } from 'smol-toml' +import ZoneRecordBase from './base.js' + const __dirname = path.dirname(fileURLToPath(import.meta.url)) function resolveStorePath(filename) { @@ -12,8 +14,9 @@ function resolveStorePath(filename) { return path.resolve(__dirname, '../../../conf.d', filename) } -class ZoneRecordRepoTOML { - constructor() { +class ZoneRecordRepoTOML extends ZoneRecordBase { + constructor(args = {}) { + super(args) this._filePath = resolveStorePath('zone_record.toml') } @@ -45,6 +48,22 @@ class ZoneRecordRepoTOML { return args.id } + async count(args = {}) { + args = JSON.parse(JSON.stringify(args)) + const deletedArg = args.deleted ?? false + + let records = await this._load() + + if (args.id !== undefined) records = records.filter((r) => r.id === args.id) + if (args.zid !== undefined) records = records.filter((r) => r.zid === args.zid) + if (args.type !== undefined) records = records.filter((r) => r.type === args.type) + if (deletedArg === false) records = records.filter((r) => !r.deleted) + else if (deletedArg !== undefined) + records = records.filter((r) => Boolean(r.deleted) === Boolean(deletedArg)) + + return records.length + } + async get(args) { args = JSON.parse(JSON.stringify(args)) if (args.deleted === undefined) args.deleted = false