diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc49c75..05560e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,6 @@ on: pull_request: types: [opened, synchronize, reopened] -env: - CI: true - NODE_ENV: test - NODE_VER: 22 - jobs: lint: permissions: @@ -23,8 +18,6 @@ jobs: - name: Start MySQL run: sudo /etc/init.d/mysql start - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VER }} - uses: actions/checkout@v6 - run: npm install - name: Initialize MySQL @@ -90,7 +83,6 @@ jobs: brew services start mysql@8.4 - uses: actions/checkout@v6 - uses: actions/setup-node@v6 - name: Node ${{ matrix.node-version }} on ${{ matrix.os }} with: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh @@ -130,7 +122,6 @@ jobs: choco install mysql - uses: actions/checkout@v6 - uses: actions/setup-node@v6 - name: Node ${{ matrix.node-version }} with: node-version: ${{ matrix.node-version }} - run: sh sql/init-mysql.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 897946c..7fb82dc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,6 @@ on: env: CI: true - node-version: 22 jobs: build: @@ -17,8 +16,6 @@ jobs: - run: sudo /etc/init.d/mysql start - uses: actions/checkout@v6 - uses: actions/setup-node@v6 - with: - node-version: ${{ env.node-version }} - run: sh sql/init-mysql.sh - run: npm install - run: npm test @@ -31,16 +28,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/setup-node@v6 - name: Node ${{ env.node-version }} with: - node-version: ${{ env.node-version }} registry-url: https://registry.npmjs.org/ - - uses: actions/checkout@v6 with: fetch-depth: 0 # fetch-depth 0 needed by GitHub Release - - name: publish to NPM run: | VERSION=$(node -e 'console.log(require("./package.json").version)') @@ -61,7 +54,6 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: ${{ env.node-version }} registry-url: https://npm.pkg.github.com/ scope: '@nictool' - run: npm publish diff --git a/lib/group/index.js b/lib/group/index.js index 9d4d0bb..5338462 100644 --- a/lib/group/index.js +++ b/lib/group/index.js @@ -1,166 +1,18 @@ -import Mysql from '../mysql.js' -import Permission from '../permission.js' -import { mapToDbColumn } from '../util.js' - -const groupDbMap = { id: 'nt_group_id', parent_gid: 'parent_group_id' } -const boolFields = ['deleted'] - -class Group { - constructor() { - this.mysql = Mysql - } - - async create(args) { - if (args.id) { - const g = await this.get({ id: args.id }) - if (g.length === 1) return g[0].id - } - - const usable_ns = args.usable_ns - delete args.usable_ns - - const parent_gid = args.parent_gid ?? 0 - - const gid = await Mysql.execute(...Mysql.insert(`nt_group`, mapToDbColumn(args, groupDbMap))) - - if (gid && parent_gid !== 0) { - await this.addToSubgroups(gid, parent_gid) - } - - await Permission.create({ - gid, - name: `Group ${args.name} perms`, - nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] }, - }) - - return gid - } - - async addToSubgroups(gid, parent_gid, rank = 1000) { - if (!parent_gid || parent_gid === 0) return - - await Mysql.execute(...Mysql.insert('nt_group_subgroups', { - nt_group_id: parent_gid, - nt_subgroup_id: gid, - rank, - })) - - const parent = await this.get({ id: parent_gid }) - if (parent.length === 1 && parent[0].parent_gid !== 0) { - await this.addToSubgroups(gid, parent[0].parent_gid, rank - 1) - } - } - - async get(args_orig) { - const args = JSON.parse(JSON.stringify(args_orig)) - if (args.deleted === undefined) args.deleted = false - - const include_subgroups = args.include_subgroups === true - delete args.include_subgroups - - let query = `SELECT g.nt_group_id AS id - , g.parent_group_id AS parent_gid - , g.name - , g.deleted - FROM nt_group g` - - const params = [] - const where = [] - - if (args.id) { - if (include_subgroups) { - const subgroupRows = await Mysql.execute( - 'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?', - [args.id] - ) - const gids = [args.id, ...subgroupRows.map(r => r.nt_subgroup_id)] - where.push(`g.nt_group_id IN (${gids.join(',')})`) - } else { - where.push('g.nt_group_id = ?') - params.push(args.id) - } - delete args.id - } - - if (args.parent_gid !== undefined) { - where.push('g.parent_group_id = ?') - params.push(args.parent_gid) - delete args.parent_gid - } - - if (args.name) { - where.push('g.name = ?') - params.push(args.name) - delete args.name - } - - if (args.deleted !== undefined) { - where.push('g.deleted = ?') - params.push(args.deleted ? 1 : 0) - delete args.deleted - } - - if (where.length > 0) { - query += ` WHERE ${where.join(' AND ')}` - } - - const groups = await Mysql.execute(query, params) - - for (const row of groups) { - for (const b of boolFields) { - row[b] = row[b] === 1 - } - if ([false, undefined].includes(args_orig.deleted)) delete row.deleted - - const perm = await Permission.get({ gid: row.id }) - if (perm) { - row.permissions = perm - } - } - return groups - } - - async put(args) { - if (!args.id) return false - const id = args.id - delete args.id - - const usable_ns = args.usable_ns - delete args.usable_ns - - if (usable_ns !== undefined) { - const perm = await Permission.get({ gid: id }) - if (perm) { - await Permission.put({ - id: perm.id, - nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] } - }) - } - } - - if (Object.keys(args).length === 0) return true - - const r = await Mysql.execute( - ...Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)), - ) - return r.changedRows === 1 - } - - async delete(args) { - const r = await Mysql.execute( - ...Mysql.update(`nt_group`, `nt_group_id=${args.id}`, { - deleted: args.deleted ?? 1, - }), - ) - return r.changedRows === 1 - } - - async destroy(args) { - // Clean up associated permission rows before removing the group - await Mysql.execute(`DELETE FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL`, [args.id]) - const r = await Mysql.execute(...Mysql.delete(`nt_group`, { nt_group_id: args.id })) - 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 + case 'mongodb': + RepoClass = (await import('./store/mongodb.js')).default + break + case 'elasticsearch': + RepoClass = (await import('./store/elasticsearch.js')).default + break + default: + RepoClass = (await import('./store/mysql.js')).default } -export default new Group() +export default new RepoClass() diff --git a/lib/group/store/base.js b/lib/group/store/base.js new file mode 100644 index 0000000..88c669e --- /dev/null +++ b/lib/group/store/base.js @@ -0,0 +1,44 @@ +/** + * Group domain class – pure attributes and business logic. + * + * Has zero knowledge of how groups are persisted. All group repository classes + * must extend this class and implement the repo contract methods. + * + * Repo contract: + * get(args) → object[] + * create(args) → number (groupId) + * put(args) → boolean + * delete(args) → boolean + * destroy(args) → boolean + */ +class GroupBase { + constructor(args = {}) { + this.debug = args?.debug ?? false + } + + // ------------------------------------------------------------------------- + // Repo contract – subclasses must implement these + // ------------------------------------------------------------------------- + + async get(_args) { + throw new Error('get() not implemented by this repo') + } + + async create(_args) { + throw new Error('create() not implemented by this repo') + } + + async put(_args) { + throw new Error('put() not implemented by this repo') + } + + async delete(_args) { + throw new Error('delete() not implemented by this repo') + } + + async destroy(_args) { + throw new Error('destroy() not implemented by this repo') + } +} + +export default GroupBase diff --git a/lib/group/store/mongodb.js b/lib/group/store/mongodb.js new file mode 100644 index 0000000..c4c3475 --- /dev/null +++ b/lib/group/store/mongodb.js @@ -0,0 +1,29 @@ +import GroupBase from './base.js' + +class GroupRepoMongoDB extends GroupBase { + async authenticate(_authTry) { + throw new Error('GroupRepoMongoDB is not yet implemented') + } + + async get(_args) { + throw new Error('GroupRepoMongoDB is not yet implemented') + } + + async create(_args) { + throw new Error('GroupRepoMongoDB is not yet implemented') + } + + async put(_args) { + throw new Error('GroupRepoMongoDB is not yet implemented') + } + + async delete(_args) { + throw new Error('GroupRepoMongoDB is not yet implemented') + } + + async destroy(_args) { + throw new Error('GroupRepoMongoDB is not yet implemented') + } +} + +export default GroupRepoMongoDB diff --git a/lib/group/store/mysql.js b/lib/group/store/mysql.js new file mode 100644 index 0000000..095d5a6 --- /dev/null +++ b/lib/group/store/mysql.js @@ -0,0 +1,168 @@ +import Mysql from '../../mysql.js' +import GroupBase from './base.js' +import Permission from '../../permission.js' +import { mapToDbColumn } from '../../util.js' + +const groupDbMap = { id: 'nt_group_id', parent_gid: 'parent_group_id' } +const boolFields = ['deleted'] + +class Group extends GroupBase { + constructor() { + super() + this.mysql = Mysql + } + + async create(args) { + if (args.id) { + const g = await this.get({ id: args.id }) + if (g.length === 1) return g[0].id + } + + const usable_ns = args.usable_ns + delete args.usable_ns + + const parent_gid = args.parent_gid ?? 0 + + const gid = await Mysql.execute(...Mysql.insert(`nt_group`, mapToDbColumn(args, groupDbMap))) + + if (gid && parent_gid !== 0) { + await this.addToSubgroups(gid, parent_gid) + } + + await Permission.create({ + gid, + name: `Group ${args.name} perms`, + nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] }, + }) + + return gid + } + + async addToSubgroups(gid, parent_gid, rank = 1000) { + if (!parent_gid || parent_gid === 0) return + + await Mysql.execute(...Mysql.insert('nt_group_subgroups', { + nt_group_id: parent_gid, + nt_subgroup_id: gid, + rank, + })) + + const parent = await this.get({ id: parent_gid }) + if (parent.length === 1 && parent[0].parent_gid !== 0) { + await this.addToSubgroups(gid, parent[0].parent_gid, rank - 1) + } + } + + async get(args_orig) { + const args = JSON.parse(JSON.stringify(args_orig)) + if (args.deleted === undefined) args.deleted = false + + const include_subgroups = args.include_subgroups === true + delete args.include_subgroups + + let query = `SELECT g.nt_group_id AS id + , g.parent_group_id AS parent_gid + , g.name + , g.deleted + FROM nt_group g` + + const params = [] + const where = [] + + if (args.id) { + if (include_subgroups) { + const subgroupRows = await Mysql.execute( + 'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?', + [args.id] + ) + const gids = [args.id, ...subgroupRows.map(r => r.nt_subgroup_id)] + where.push(`g.nt_group_id IN (${gids.join(',')})`) + } else { + where.push('g.nt_group_id = ?') + params.push(args.id) + } + delete args.id + } + + if (args.parent_gid !== undefined) { + where.push('g.parent_group_id = ?') + params.push(args.parent_gid) + delete args.parent_gid + } + + if (args.name) { + where.push('g.name = ?') + params.push(args.name) + delete args.name + } + + if (args.deleted !== undefined) { + where.push('g.deleted = ?') + params.push(args.deleted ? 1 : 0) + delete args.deleted + } + + if (where.length > 0) { + query += ` WHERE ${where.join(' AND ')}` + } + + const groups = await Mysql.execute(query, params) + + for (const row of groups) { + for (const b of boolFields) { + row[b] = row[b] === 1 + } + if ([false, undefined].includes(args_orig.deleted)) delete row.deleted + + const perm = await Permission.get({ gid: row.id }) + if (perm) { + row.permissions = perm + } + } + return groups + } + + async put(args) { + if (!args.id) return false + const id = args.id + delete args.id + + const usable_ns = args.usable_ns + delete args.usable_ns + + if (usable_ns !== undefined) { + const perm = await Permission.get({ gid: id }) + if (perm) { + await Permission.put({ + id: perm.id, + nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] } + }) + } + } + + if (Object.keys(args).length === 0) return true + + const r = await Mysql.execute( + ...Mysql.update(`nt_group`, `nt_group_id=${id}`, mapToDbColumn(args, groupDbMap)), + ) + return r.changedRows === 1 + } + + async delete(args) { + const r = await Mysql.execute( + ...Mysql.update(`nt_group`, `nt_group_id=${args.id}`, { + deleted: args.deleted ?? 1, + }), + ) + return r.changedRows === 1 + } + + async destroy(args) { + // Clean up associated permission rows before removing the group + await Mysql.execute(`DELETE FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL`, [args.id]) + const r = await Mysql.execute(...Mysql.delete(`nt_group`, { nt_group_id: args.id })) + return r.affectedRows === 1 + } +} + +export default Group diff --git a/lib/user/index.js b/lib/user/index.js index 4ba3df8..5338462 100644 --- a/lib/user/index.js +++ b/lib/user/index.js @@ -3,16 +3,16 @@ const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' let RepoClass switch (storeType) { case 'toml': - RepoClass = (await import('./toml.js')).default + RepoClass = (await import('./store/toml.js')).default break case 'mongodb': - RepoClass = (await import('./mongodb.js')).default + RepoClass = (await import('./store/mongodb.js')).default break case 'elasticsearch': - RepoClass = (await import('./elasticsearch.js')).default + RepoClass = (await import('./store/elasticsearch.js')).default break default: - RepoClass = (await import('./mysql.js')).default + RepoClass = (await import('./store/mysql.js')).default } export default new RepoClass() diff --git a/lib/user/userBase.js b/lib/user/store/base.js similarity index 100% rename from lib/user/userBase.js rename to lib/user/store/base.js diff --git a/lib/user/elasticsearch.js b/lib/user/store/elasticsearch.js similarity index 94% rename from lib/user/elasticsearch.js rename to lib/user/store/elasticsearch.js index 8f60447..9b4514e 100644 --- a/lib/user/elasticsearch.js +++ b/lib/user/store/elasticsearch.js @@ -1,4 +1,4 @@ -import UserBase from './userBase.js' +import UserBase from './base.js' class UserRepoElasticsearch extends UserBase { async authenticate(_authTry) { diff --git a/lib/user/mongodb.js b/lib/user/store/mongodb.js similarity index 94% rename from lib/user/mongodb.js rename to lib/user/store/mongodb.js index bc22658..a5719ef 100644 --- a/lib/user/mongodb.js +++ b/lib/user/store/mongodb.js @@ -1,4 +1,4 @@ -import UserBase from './userBase.js' +import UserBase from './base.js' class UserRepoMongoDB extends UserBase { async authenticate(_authTry) { diff --git a/lib/user/mysql.js b/lib/user/store/mysql.js similarity index 96% rename from lib/user/mysql.js rename to lib/user/store/mysql.js index fb59854..8fb243f 100644 --- a/lib/user/mysql.js +++ b/lib/user/store/mysql.js @@ -1,8 +1,8 @@ -import Mysql from '../mysql.js' -import Config from '../config.js' -import UserBase from './userBase.js' -import Permission from '../permission.js' -import { mapToDbColumn } from '../util.js' +import Mysql from '../../mysql.js' +import Config from '../../config.js' +import UserBase from './base.js' +import Permission from '../../permission.js' +import { mapToDbColumn } from '../../util.js' const userDbMap = { id: 'nt_user_id', gid: 'nt_group_id' } const boolFields = ['is_admin', 'deleted'] diff --git a/lib/user/toml.js b/lib/user/store/toml.js similarity index 96% rename from lib/user/toml.js rename to lib/user/store/toml.js index e3098d4..01a2832 100644 --- a/lib/user/toml.js +++ b/lib/user/store/toml.js @@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url' import { parse, stringify } from 'smol-toml' -import Config from './config.js' -import UserBase from './userBase.js' +import Config from '../../config.js' +import UserBase from './base.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const boolFields = ['is_admin', 'deleted'] @@ -13,7 +13,7 @@ const boolFields = ['is_admin', 'deleted'] 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) + return path.resolve(__dirname, '../../../conf.d', filename) } class UserRepoTOML extends UserBase { diff --git a/lib/zone/index.js b/lib/zone/index.js index 4ba3df8..5338462 100644 --- a/lib/zone/index.js +++ b/lib/zone/index.js @@ -3,16 +3,16 @@ const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' let RepoClass switch (storeType) { case 'toml': - RepoClass = (await import('./toml.js')).default + RepoClass = (await import('./store/toml.js')).default break case 'mongodb': - RepoClass = (await import('./mongodb.js')).default + RepoClass = (await import('./store/mongodb.js')).default break case 'elasticsearch': - RepoClass = (await import('./elasticsearch.js')).default + RepoClass = (await import('./store/elasticsearch.js')).default break default: - RepoClass = (await import('./mysql.js')).default + RepoClass = (await import('./store/mysql.js')).default } export default new RepoClass() diff --git a/lib/zone/zoneBase.js b/lib/zone/store/base.js similarity index 100% rename from lib/zone/zoneBase.js rename to lib/zone/store/base.js diff --git a/lib/zone/elasticsearch.js b/lib/zone/store/elasticsearch.js similarity index 94% rename from lib/zone/elasticsearch.js rename to lib/zone/store/elasticsearch.js index 44917f7..b21970f 100644 --- a/lib/zone/elasticsearch.js +++ b/lib/zone/store/elasticsearch.js @@ -1,4 +1,4 @@ -import ZoneBase from './zoneBase.js' +import ZoneBase from './base.js' class ZoneRepoElasticsearch extends ZoneBase { async get(_args) { diff --git a/lib/zone/mongodb.js b/lib/zone/store/mongodb.js similarity index 94% rename from lib/zone/mongodb.js rename to lib/zone/store/mongodb.js index 7e3309f..1dbc323 100644 --- a/lib/zone/mongodb.js +++ b/lib/zone/store/mongodb.js @@ -1,4 +1,4 @@ -import ZoneBase from './zoneBase.js' +import ZoneBase from './base.js' class ZoneRepoMongoDB extends ZoneBase { async get(_args) { diff --git a/lib/zone/mysql.js b/lib/zone/store/mysql.js similarity index 97% rename from lib/zone/mysql.js rename to lib/zone/store/mysql.js index 40e3984..de30161 100644 --- a/lib/zone/mysql.js +++ b/lib/zone/store/mysql.js @@ -1,6 +1,6 @@ -import Mysql from '../mysql.js' -import ZoneBase from './zoneBase.js' -import { mapToDbColumn } from '../util.js' +import Mysql from '../../mysql.js' +import ZoneBase from './base.js' +import { mapToDbColumn } from '../../util.js' const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' } const boolFields = ['deleted'] diff --git a/lib/zone/toml.js b/lib/zone/store/toml.js similarity index 98% rename from lib/zone/toml.js rename to lib/zone/store/toml.js index e181b0f..97eb85b 100644 --- a/lib/zone/toml.js +++ b/lib/zone/store/toml.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { parse, stringify } from 'smol-toml' -import ZoneBase from './zoneBase.js' +import ZoneBase from './base.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -13,7 +13,7 @@ const zoneDefaults = { minimum: 3600, ttl: 3600, refresh: 86400, retry: 7200, ex 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) + return path.resolve(__dirname, '../../../conf.d', filename) } class ZoneRepoTOML extends ZoneBase { diff --git a/lib/zone_record/index.js b/lib/zone_record/index.js index 4f0e0fa..05433fb 100644 --- a/lib/zone_record/index.js +++ b/lib/zone_record/index.js @@ -3,10 +3,10 @@ const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql' let RepoClass switch (storeType) { case 'toml': - RepoClass = (await import('./toml.js')).default + RepoClass = (await import('./store/toml.js')).default break default: - RepoClass = (await import('./mysql.js')).default + RepoClass = (await import('./store/mysql.js')).default } export default new RepoClass() diff --git a/lib/zone_record/mysql.js b/lib/zone_record/store/mysql.js similarity index 99% rename from lib/zone_record/mysql.js rename to lib/zone_record/store/mysql.js index 8090d8d..ac58b28 100644 --- a/lib/zone_record/mysql.js +++ b/lib/zone_record/store/mysql.js @@ -1,7 +1,7 @@ import * as RR from '@nictool/dns-resource-record' -import Mysql from '../mysql.js' -import { mapToDbColumn } from '../util.js' +import Mysql from '../../mysql.js' +import { mapToDbColumn } from '../../util.js' const zrDbMap = { id: 'nt_zone_record_id', zid: 'nt_zone_id', owner: 'name' } const boolFields = ['deleted'] diff --git a/lib/zone_record/toml.js b/lib/zone_record/store/toml.js similarity index 97% rename from lib/zone_record/toml.js rename to lib/zone_record/store/toml.js index 315a79f..9a6ab59 100644 --- a/lib/zone_record/toml.js +++ b/lib/zone_record/store/toml.js @@ -9,7 +9,7 @@ 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) + return path.resolve(__dirname, '../../../conf.d', filename) } class ZoneRecordRepoTOML {