webspresso 0.0.56 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/bin/commands/audit-prune.js +45 -0
- package/bin/webspresso.js +2 -0
- package/index.js +2 -2
- package/package.json +2 -5
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/audit-log/admin-component.js +129 -0
- package/plugins/audit-log/api-handlers.js +83 -0
- package/plugins/audit-log/index.js +99 -0
- package/plugins/audit-log/middleware.js +112 -0
- package/plugins/audit-log/migration-template.js +45 -0
- package/plugins/audit-log/parse.js +58 -0
- package/plugins/audit-log/purge.js +28 -0
- package/plugins/index.js +2 -0
package/README.md
CHANGED
|
@@ -718,6 +718,36 @@ Options:
|
|
|
718
718
|
|
|
719
719
|
The `analytics_page_views` table is automatically created on first request.
|
|
720
720
|
|
|
721
|
+
**Audit log plugin:**
|
|
722
|
+
- Records successful (`2xx`) admin panel model mutations: `create`, `update`, `delete`, `restore` on `${adminPath}/api/models/:model/records…`
|
|
723
|
+
- Actor from `req.session.adminUser` after login; optional IP / user-agent; update metadata stores changed field names only (not full body)
|
|
724
|
+
- `GET ${adminPath}/api/audit-logs` with pagination and filters (`page`, `perPage`, `model`, `action`, `from`, `to`) — use from custom admin pages or the bundled Mithril list (`includeDefaultPage` default `true`)
|
|
725
|
+
- Run `webspresso db:migrate` after adding the migration (see `plugins/audit-log/migration-template.js` or the example under `migrations/`). Prune old rows with the CLI (recommended on a schedule):
|
|
726
|
+
|
|
727
|
+
```bash
|
|
728
|
+
npx webspresso audit:prune --days 90
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
```javascript
|
|
732
|
+
const { adminPanelPlugin, auditLogPlugin } = require('webspresso/plugins');
|
|
733
|
+
|
|
734
|
+
const { app } = createApp({
|
|
735
|
+
pagesDir: './pages',
|
|
736
|
+
plugins: [
|
|
737
|
+
adminPanelPlugin({ db }),
|
|
738
|
+
auditLogPlugin({
|
|
739
|
+
db,
|
|
740
|
+
// adminPath: '/_admin', // must match admin panel `path`
|
|
741
|
+
// tableName: 'audit_logs',
|
|
742
|
+
// includeDefaultPage: true,
|
|
743
|
+
// apiPrefix: '/audit-logs',
|
|
744
|
+
}),
|
|
745
|
+
],
|
|
746
|
+
});
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
|
|
750
|
+
|
|
721
751
|
**SEO Checker Plugin:**
|
|
722
752
|
- Client-side SEO analysis tool (inspired by django-check-seo)
|
|
723
753
|
- Integrated with dev toolbar
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune old audit log rows (CLI)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
6
|
+
const { purgeAuditLogs } = require('../../plugins/audit-log/purge');
|
|
7
|
+
|
|
8
|
+
function registerCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('audit:prune')
|
|
11
|
+
.description('Delete audit log rows older than the given retention window')
|
|
12
|
+
.option('--days <n>', 'Delete rows older than this many days', '90')
|
|
13
|
+
.option('--table <name>', 'Table name', 'audit_logs')
|
|
14
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
15
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
const days = parseInt(options.days, 10);
|
|
18
|
+
if (Number.isNaN(days) || days < 1) {
|
|
19
|
+
console.error('❌ --days must be a positive integer');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
24
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
25
|
+
console.log(` Environment: ${options.env}\n`);
|
|
26
|
+
|
|
27
|
+
const knex = await createDbInstance(config, options.env);
|
|
28
|
+
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const deleted = await purgeAuditLogs(knex, {
|
|
32
|
+
tableName: options.table,
|
|
33
|
+
olderThan,
|
|
34
|
+
});
|
|
35
|
+
console.log(`✅ Deleted ${deleted} row(s) with created_at before ${olderThan.toISOString()}.\n`);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('❌ audit:prune failed:', err.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
} finally {
|
|
40
|
+
await knex.destroy();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { registerCommand };
|
package/bin/webspresso.js
CHANGED
|
@@ -27,6 +27,7 @@ const { registerCommand: registerSeed } = require('./commands/seed');
|
|
|
27
27
|
const { registerCommand: registerAdminSetup } = require('./commands/admin-setup');
|
|
28
28
|
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
29
29
|
const { registerCommand: registerFaviconGenerate } = require('./commands/favicon-generate');
|
|
30
|
+
const { registerCommand: registerAuditPrune } = require('./commands/audit-prune');
|
|
30
31
|
|
|
31
32
|
registerNew(program);
|
|
32
33
|
registerPage(program);
|
|
@@ -42,6 +43,7 @@ registerSeed(program);
|
|
|
42
43
|
registerAdminSetup(program);
|
|
43
44
|
registerAdminPassword(program);
|
|
44
45
|
registerFaviconGenerate(program);
|
|
46
|
+
registerAuditPrune(program);
|
|
45
47
|
|
|
46
48
|
// Parse arguments
|
|
47
49
|
program.parse();
|
package/index.js
CHANGED
|
@@ -30,7 +30,7 @@ const {
|
|
|
30
30
|
const orm = require('./core/orm');
|
|
31
31
|
|
|
32
32
|
// Built-in plugins
|
|
33
|
-
const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin } = require('./plugins');
|
|
33
|
+
const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin } = require('./plugins');
|
|
34
34
|
|
|
35
35
|
module.exports = {
|
|
36
36
|
// Main API
|
|
@@ -70,5 +70,5 @@ module.exports = {
|
|
|
70
70
|
schemaExplorerPlugin,
|
|
71
71
|
adminPanelPlugin,
|
|
72
72
|
siteAnalyticsPlugin,
|
|
73
|
+
auditLogPlugin,
|
|
73
74
|
};
|
|
74
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.57",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -14,10 +14,7 @@
|
|
|
14
14
|
"test:e2e:ui": "playwright test --ui",
|
|
15
15
|
"test:e2e:debug": "playwright test --debug",
|
|
16
16
|
"test:e2e:headed": "playwright test --headed",
|
|
17
|
-
"release": "release-it"
|
|
18
|
-
"docs:dev": "cd docs && npm run start",
|
|
19
|
-
"docs:build": "cd docs && npm run build",
|
|
20
|
-
"docs:serve": "cd docs && npm run serve"
|
|
17
|
+
"release": "release-it"
|
|
21
18
|
},
|
|
22
19
|
"keywords": [
|
|
23
20
|
"ssr",
|
|
@@ -117,8 +117,8 @@ function registerApiRoutes(moduleId, apiConfig, deps) {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
for (const route of apiConfig.routes) {
|
|
120
|
-
if (
|
|
121
|
-
throw new Error(`Module "${moduleId}": each API route requires path and handler`);
|
|
120
|
+
if (typeof route.path !== 'string' || typeof route.handler !== 'function') {
|
|
121
|
+
throw new Error(`Module "${moduleId}": each API route requires path (string, use "" for prefix root) and handler`);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
const method = (route.method || 'get').toLowerCase();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log admin page — Mithril component source (string)
|
|
3
|
+
* @module plugins/audit-log/admin-component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {string} [options.apiPrefix='/audit-logs']
|
|
9
|
+
*/
|
|
10
|
+
function generateAuditLogComponent(options = {}) {
|
|
11
|
+
const apiPrefix = options.apiPrefix || '/audit-logs';
|
|
12
|
+
|
|
13
|
+
return `
|
|
14
|
+
(function() {
|
|
15
|
+
var API = '${apiPrefix}';
|
|
16
|
+
|
|
17
|
+
function AuditLogPage() {
|
|
18
|
+
var rows = [];
|
|
19
|
+
var loading = true;
|
|
20
|
+
var error = null;
|
|
21
|
+
var page = 1;
|
|
22
|
+
var perPage = 25;
|
|
23
|
+
var total = 0;
|
|
24
|
+
var filterModel = '';
|
|
25
|
+
var filterAction = '';
|
|
26
|
+
|
|
27
|
+
function load() {
|
|
28
|
+
loading = true;
|
|
29
|
+
error = null;
|
|
30
|
+
var q = '?page=' + page + '&perPage=' + perPage;
|
|
31
|
+
if (filterModel) q += '&model=' + encodeURIComponent(filterModel);
|
|
32
|
+
if (filterAction) q += '&action=' + encodeURIComponent(filterAction);
|
|
33
|
+
api.get(API + q).then(function(res) {
|
|
34
|
+
rows = res.data || [];
|
|
35
|
+
total = (res.meta && res.meta.total) || 0;
|
|
36
|
+
loading = false;
|
|
37
|
+
}).catch(function(e) {
|
|
38
|
+
error = e.message || String(e);
|
|
39
|
+
loading = false;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
oninit: load,
|
|
45
|
+
view: function() {
|
|
46
|
+
if (loading) {
|
|
47
|
+
return m('div.p-8.text-gray-500', 'Loading…');
|
|
48
|
+
}
|
|
49
|
+
if (error) {
|
|
50
|
+
return m('div.p-8.text-red-600', error);
|
|
51
|
+
}
|
|
52
|
+
var maxPage = Math.max(1, Math.ceil(total / perPage) || 1);
|
|
53
|
+
return m('div.p-6.space-y-4', [
|
|
54
|
+
m('div.flex.flex-wrap.items-end.gap-4', [
|
|
55
|
+
m('div', [
|
|
56
|
+
m('label.block.text-xs.text-gray-500.mb-1', 'Model'),
|
|
57
|
+
m('input.border.rounded.px-2.py-1', {
|
|
58
|
+
value: filterModel,
|
|
59
|
+
oninput: function(e) { filterModel = e.target.value; },
|
|
60
|
+
placeholder: 'Post',
|
|
61
|
+
}),
|
|
62
|
+
]),
|
|
63
|
+
m('div', [
|
|
64
|
+
m('label.block.text-xs.text-gray-500.mb-1', 'Action'),
|
|
65
|
+
m('select.border.rounded.px-2.py-1', {
|
|
66
|
+
value: filterAction,
|
|
67
|
+
onchange: function(e) { filterAction = e.target.value; },
|
|
68
|
+
}, [
|
|
69
|
+
m('option', { value: '' }, 'All'),
|
|
70
|
+
m('option', { value: 'create' }, 'create'),
|
|
71
|
+
m('option', { value: 'update' }, 'update'),
|
|
72
|
+
m('option', { value: 'delete' }, 'delete'),
|
|
73
|
+
m('option', { value: 'restore' }, 'restore'),
|
|
74
|
+
]),
|
|
75
|
+
]),
|
|
76
|
+
m('button.bg-blue-600.text-white.px-4.py-1.rounded', {
|
|
77
|
+
onclick: function() { page = 1; load(); },
|
|
78
|
+
}, 'Apply'),
|
|
79
|
+
]),
|
|
80
|
+
m('div.text-sm.text-gray-500', 'Total: ' + total),
|
|
81
|
+
m('div.overflow-x-auto.border.rounded', [
|
|
82
|
+
m('table.min-w-full.text-sm', [
|
|
83
|
+
m('thead.bg-gray-50', [
|
|
84
|
+
m('tr', [
|
|
85
|
+
m('th.text-left.p-2', 'Time'),
|
|
86
|
+
m('th.text-left.p-2', 'Actor'),
|
|
87
|
+
m('th.text-left.p-2', 'Action'),
|
|
88
|
+
m('th.text-left.p-2', 'Model'),
|
|
89
|
+
m('th.text-left.p-2', 'Id'),
|
|
90
|
+
m('th.text-left.p-2', 'Path'),
|
|
91
|
+
]),
|
|
92
|
+
]),
|
|
93
|
+
m('tbody', rows.map(function(r) {
|
|
94
|
+
return m('tr.border-t', [
|
|
95
|
+
m('td.p-2.whitespace-nowrap', r.created_at || ''),
|
|
96
|
+
m('td.p-2', (r.actor_email || '') + (r.actor_id != null ? ' #' + r.actor_id : '')),
|
|
97
|
+
m('td.p-2', r.action),
|
|
98
|
+
m('td.p-2', r.resource_model),
|
|
99
|
+
m('td.p-2', r.resource_id != null ? String(r.resource_id) : '—'),
|
|
100
|
+
m('td.p-2.max-w-md.truncate', { title: r.path || '' }, r.path || ''),
|
|
101
|
+
]);
|
|
102
|
+
})),
|
|
103
|
+
]),
|
|
104
|
+
]),
|
|
105
|
+
m('div.flex.items-center.gap-2', [
|
|
106
|
+
m('button.px-3.py-1.border.rounded', {
|
|
107
|
+
disabled: page <= 1,
|
|
108
|
+
onclick: function() { if (page > 1) { page--; load(); } },
|
|
109
|
+
}, 'Prev'),
|
|
110
|
+
m('span.text-sm', 'Page ' + page + ' / ' + maxPage),
|
|
111
|
+
m('button.px-3.py-1.border.rounded', {
|
|
112
|
+
disabled: page >= maxPage,
|
|
113
|
+
onclick: function() { if (page < maxPage) { page++; load(); } },
|
|
114
|
+
}, 'Next'),
|
|
115
|
+
]),
|
|
116
|
+
]);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
window.__customPages = window.__customPages || {};
|
|
122
|
+
window.__customPages['audit-log'] = AuditLogPage;
|
|
123
|
+
})();
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
generateAuditLogComponent,
|
|
129
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API handlers for audit log listing
|
|
3
|
+
* @module plugins/audit-log/api-handlers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {import('knex').Knex} options.knex
|
|
9
|
+
* @param {string} [options.tableName='audit_logs']
|
|
10
|
+
*/
|
|
11
|
+
function createAuditLogHandlers(options) {
|
|
12
|
+
const knex = options.knex;
|
|
13
|
+
const tableName = options.tableName || 'audit_logs';
|
|
14
|
+
|
|
15
|
+
function applyFilters(qb, query) {
|
|
16
|
+
if (query.model) {
|
|
17
|
+
qb.where('resource_model', String(query.model));
|
|
18
|
+
}
|
|
19
|
+
if (query.action) {
|
|
20
|
+
qb.where('action', String(query.action));
|
|
21
|
+
}
|
|
22
|
+
if (query.from) {
|
|
23
|
+
qb.where('created_at', '>=', String(query.from));
|
|
24
|
+
}
|
|
25
|
+
if (query.to) {
|
|
26
|
+
qb.where('created_at', '<=', String(query.to));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function listHandler(req, res) {
|
|
31
|
+
try {
|
|
32
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
33
|
+
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage, 10) || 25));
|
|
34
|
+
|
|
35
|
+
const countQ = knex(tableName).modify((qb) => applyFilters(qb, req.query));
|
|
36
|
+
const countRow = await countQ.count('* as cnt').first();
|
|
37
|
+
const total = Number(countRow?.cnt ?? Object.values(countRow || {})[0] ?? 0);
|
|
38
|
+
|
|
39
|
+
const rows = await knex(tableName)
|
|
40
|
+
.modify((qb) => applyFilters(qb, req.query))
|
|
41
|
+
.orderBy('created_at', 'desc')
|
|
42
|
+
.offset((page - 1) * perPage)
|
|
43
|
+
.limit(perPage);
|
|
44
|
+
|
|
45
|
+
res.json({
|
|
46
|
+
data: rows,
|
|
47
|
+
meta: { page, perPage, total },
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
res.status(500).json({ error: err.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Programmatic list (for other plugins)
|
|
56
|
+
* @param {Object} [filters]
|
|
57
|
+
* @param {string} [filters.model]
|
|
58
|
+
* @param {string} [filters.action]
|
|
59
|
+
* @param {string} [filters.from]
|
|
60
|
+
* @param {string} [filters.to]
|
|
61
|
+
* @param {number} [filters.limit=100]
|
|
62
|
+
* @param {number} [filters.offset=0]
|
|
63
|
+
*/
|
|
64
|
+
async function queryLogs(filters = {}) {
|
|
65
|
+
const limit = Math.min(500, Math.max(1, filters.limit ?? 100));
|
|
66
|
+
const offset = Math.max(0, filters.offset ?? 0);
|
|
67
|
+
|
|
68
|
+
return knex(tableName)
|
|
69
|
+
.modify((qb) => applyFilters(qb, filters))
|
|
70
|
+
.orderBy('created_at', 'desc')
|
|
71
|
+
.offset(offset)
|
|
72
|
+
.limit(limit);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
listHandler,
|
|
77
|
+
queryLogs,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
createAuditLogHandlers,
|
|
83
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log plugin — admin model CRUD audit trail
|
|
3
|
+
* @module plugins/audit-log
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createAuditMiddleware } = require('./middleware');
|
|
7
|
+
const { createAuditLogHandlers } = require('./api-handlers');
|
|
8
|
+
const { generateAuditLogComponent } = require('./admin-component');
|
|
9
|
+
const { generateAuditLogsMigration } = require('./migration-template');
|
|
10
|
+
const { purgeAuditLogs } = require('./purge');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {Object} options.db - Database instance (must expose .knex)
|
|
15
|
+
* @param {string} [options.adminPath='/_admin'] - Must match admin panel `path` option
|
|
16
|
+
* @param {string} [options.tableName='audit_logs']
|
|
17
|
+
* @param {boolean} [options.includeDefaultPage=true] - Register Mithril list page + menu
|
|
18
|
+
* @param {string} [options.apiPrefix='/audit-logs'] - GET list under admin API
|
|
19
|
+
*/
|
|
20
|
+
function auditLogPlugin(options = {}) {
|
|
21
|
+
const {
|
|
22
|
+
db,
|
|
23
|
+
adminPath: adminPathOpt,
|
|
24
|
+
tableName = 'audit_logs',
|
|
25
|
+
includeDefaultPage = true,
|
|
26
|
+
apiPrefix = '/audit-logs',
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
if (!db) {
|
|
30
|
+
throw new Error('audit-log plugin requires a database instance. Pass `db` in options.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const knex = db.knex || db;
|
|
34
|
+
|
|
35
|
+
const handlers = createAuditLogHandlers({ knex, tableName });
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: 'audit-log',
|
|
39
|
+
version: '1.0.0',
|
|
40
|
+
description: 'Audit trail for admin panel model CRUD API',
|
|
41
|
+
dependencies: { 'admin-panel': '*' },
|
|
42
|
+
|
|
43
|
+
api: {
|
|
44
|
+
purgeAuditLogs: (kx, opts = {}) =>
|
|
45
|
+
purgeAuditLogs(kx || knex, { tableName: opts.tableName || tableName, olderThan: opts.olderThan }),
|
|
46
|
+
queryLogs: (filters) => handlers.queryLogs(filters),
|
|
47
|
+
getMigrationTemplate: (name) => generateAuditLogsMigration(name || tableName),
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
register(ctx) {
|
|
51
|
+
const adminPath = adminPathOpt || '/_admin';
|
|
52
|
+
ctx.app.use(createAuditMiddleware({ knex, adminPath, tableName }));
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
onRoutesReady(ctx) {
|
|
56
|
+
const adminApi = ctx.usePlugin('admin-panel');
|
|
57
|
+
if (!adminApi) {
|
|
58
|
+
console.warn('[audit-log] admin-panel plugin not found, skipping list API / UI');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
adminApi.registerModule({
|
|
63
|
+
id: 'audit-log',
|
|
64
|
+
|
|
65
|
+
pages: includeDefaultPage
|
|
66
|
+
? [{
|
|
67
|
+
id: 'audit-log',
|
|
68
|
+
title: 'Audit log',
|
|
69
|
+
path: '/audit-log',
|
|
70
|
+
icon: 'database',
|
|
71
|
+
component: generateAuditLogComponent({ apiPrefix }),
|
|
72
|
+
}]
|
|
73
|
+
: [],
|
|
74
|
+
|
|
75
|
+
menu: includeDefaultPage
|
|
76
|
+
? [{
|
|
77
|
+
id: 'audit-log',
|
|
78
|
+
label: 'Audit log',
|
|
79
|
+
path: '/audit-log',
|
|
80
|
+
icon: 'database',
|
|
81
|
+
order: 95,
|
|
82
|
+
}]
|
|
83
|
+
: [],
|
|
84
|
+
|
|
85
|
+
api: {
|
|
86
|
+
prefix: apiPrefix,
|
|
87
|
+
routes: [
|
|
88
|
+
{ method: 'get', path: '', handler: handlers.listHandler },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = auditLogPlugin;
|
|
97
|
+
module.exports.auditLogPlugin = auditLogPlugin;
|
|
98
|
+
module.exports.generateAuditLogsMigration = generateAuditLogsMigration;
|
|
99
|
+
module.exports.purgeAuditLogs = purgeAuditLogs;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware: log successful admin model CRUD to audit_logs
|
|
3
|
+
* @module plugins/audit-log/middleware
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { parseAdminModelAudit } = require('./parse');
|
|
7
|
+
|
|
8
|
+
function stringifyId(val) {
|
|
9
|
+
if (val === undefined || val === null) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
if (typeof val === 'bigint') {
|
|
13
|
+
return val.toString();
|
|
14
|
+
}
|
|
15
|
+
return String(val);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractResourceIdFromJsonBody(body, action) {
|
|
19
|
+
if (!body || typeof body !== 'object') {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (action === 'create' && body.data && body.data.id !== undefined) {
|
|
23
|
+
return stringifyId(body.data.id);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildMetadata(action, req) {
|
|
29
|
+
if (action === 'update' && req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
|
|
30
|
+
const changedFields = Object.keys(req.body);
|
|
31
|
+
if (changedFields.length) {
|
|
32
|
+
return { changedFields };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} options
|
|
40
|
+
* @param {import('knex').Knex} options.knex
|
|
41
|
+
* @param {string} options.adminPath
|
|
42
|
+
* @param {string} [options.tableName='audit_logs']
|
|
43
|
+
* @returns {import('express').RequestHandler}
|
|
44
|
+
*/
|
|
45
|
+
function createAuditMiddleware(options) {
|
|
46
|
+
const knex = options.knex;
|
|
47
|
+
const adminPath = options.adminPath || '/_admin';
|
|
48
|
+
const tableName = options.tableName || 'audit_logs';
|
|
49
|
+
|
|
50
|
+
return function auditLogMiddleware(req, res, next) {
|
|
51
|
+
const parsed = parseAdminModelAudit(adminPath, req.method, req.path);
|
|
52
|
+
if (!parsed) {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const origJson = res.json.bind(res);
|
|
57
|
+
let lastJsonBody = null;
|
|
58
|
+
|
|
59
|
+
res.json = function auditWrappedJson(body) {
|
|
60
|
+
if (body !== undefined && body !== null && typeof body === 'object') {
|
|
61
|
+
lastJsonBody = body;
|
|
62
|
+
}
|
|
63
|
+
return origJson(body);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
res.on('finish', () => {
|
|
67
|
+
try {
|
|
68
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const session = req.session;
|
|
72
|
+
const actor = session && session.adminUser;
|
|
73
|
+
if (!actor) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { action, resourceModel, resourceId: pathId } = parsed;
|
|
78
|
+
let resourceId = pathId;
|
|
79
|
+
if (action === 'create') {
|
|
80
|
+
resourceId = extractResourceIdFromJsonBody(lastJsonBody, action) || resourceId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const metadata = buildMetadata(action, req);
|
|
84
|
+
const pathStr = (req.originalUrl || req.url || req.path || '').slice(0, 2000);
|
|
85
|
+
const row = {
|
|
86
|
+
actor_id: actor.id != null ? Number(actor.id) : null,
|
|
87
|
+
actor_email: actor.email || null,
|
|
88
|
+
action,
|
|
89
|
+
resource_model: resourceModel,
|
|
90
|
+
resource_id: resourceId,
|
|
91
|
+
http_method: req.method,
|
|
92
|
+
path: pathStr,
|
|
93
|
+
ip: req.ip || (req.connection && req.connection.remoteAddress) || null,
|
|
94
|
+
user_agent: req.get && req.get('user-agent') ? req.get('user-agent').slice(0, 2000) : null,
|
|
95
|
+
metadata: metadata || null,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
knex(tableName).insert(row).catch((err) => {
|
|
99
|
+
console.warn('[audit-log] Failed to insert audit row:', err.message);
|
|
100
|
+
});
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.warn('[audit-log] finish handler error:', e.message);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
next();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
createAuditMiddleware,
|
|
112
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration template for audit_logs table
|
|
3
|
+
* @module plugins/audit-log/migration-template
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate migration file content for audit_logs
|
|
8
|
+
* @param {string} [tableName='audit_logs']
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
function generateAuditLogsMigration(tableName = 'audit_logs') {
|
|
12
|
+
return `/**
|
|
13
|
+
* Migration: Create ${tableName} table
|
|
14
|
+
* Auto-generated by audit-log plugin
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
exports.up = function(knex) {
|
|
18
|
+
return knex.schema.createTable('${tableName}', (table) => {
|
|
19
|
+
table.bigIncrements('id').primary();
|
|
20
|
+
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable().index();
|
|
21
|
+
table.bigInteger('actor_id').nullable().index();
|
|
22
|
+
table.string('actor_email', 255).nullable();
|
|
23
|
+
table.string('action', 32).notNullable();
|
|
24
|
+
table.string('resource_model', 255).notNullable();
|
|
25
|
+
table.string('resource_id', 255).nullable();
|
|
26
|
+
table.string('http_method', 16).notNullable();
|
|
27
|
+
table.string('path', 2000).notNullable();
|
|
28
|
+
table.string('ip', 64).nullable();
|
|
29
|
+
table.text('user_agent').nullable();
|
|
30
|
+
table.json('metadata').nullable();
|
|
31
|
+
|
|
32
|
+
table.index(['resource_model', 'created_at']);
|
|
33
|
+
table.index(['action', 'created_at']);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
exports.down = function(knex) {
|
|
38
|
+
return knex.schema.dropTableIfExists('${tableName}');
|
|
39
|
+
};
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
generateAuditLogsMigration,
|
|
45
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse admin model CRUD paths for audit logging
|
|
3
|
+
* @module plugins/audit-log/parse
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Escape string for use in RegExp
|
|
8
|
+
* @param {string} s
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
function escapeRegex(s) {
|
|
12
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse mutation target from request path relative to admin base (no trailing slash).
|
|
17
|
+
* @param {string} adminPath - e.g. /_admin
|
|
18
|
+
* @param {string} method - HTTP method uppercased
|
|
19
|
+
* @param {string} reqPath - req.path
|
|
20
|
+
* @returns {{ action: string, resourceModel: string, resourceId: string|null }|null}
|
|
21
|
+
*/
|
|
22
|
+
function parseAdminModelAudit(adminPath, method, reqPath) {
|
|
23
|
+
const base = adminPath.replace(/\/$/, '');
|
|
24
|
+
if (!reqPath.startsWith(base)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const rel = reqPath.slice(base.length);
|
|
28
|
+
const re = /^\/api\/models\/([^/]+)\/records(?:\/([^/]+))?(?:\/(restore))?$/;
|
|
29
|
+
const m = re.exec(rel);
|
|
30
|
+
if (!m) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const resourceModel = m[1];
|
|
35
|
+
const idPart = m[2];
|
|
36
|
+
const isRestore = m[3] === 'restore';
|
|
37
|
+
const M = method.toUpperCase();
|
|
38
|
+
|
|
39
|
+
if (M === 'POST' && !idPart) {
|
|
40
|
+
return { action: 'create', resourceModel, resourceId: null };
|
|
41
|
+
}
|
|
42
|
+
if (M === 'POST' && idPart && isRestore) {
|
|
43
|
+
return { action: 'restore', resourceModel, resourceId: idPart };
|
|
44
|
+
}
|
|
45
|
+
if (M === 'PUT' && idPart && !isRestore) {
|
|
46
|
+
return { action: 'update', resourceModel, resourceId: idPart };
|
|
47
|
+
}
|
|
48
|
+
if (M === 'DELETE' && idPart && !isRestore) {
|
|
49
|
+
return { action: 'delete', resourceModel, resourceId: idPart };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
escapeRegex,
|
|
57
|
+
parseAdminModelAudit,
|
|
58
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete audit log rows older than a cutoff
|
|
3
|
+
* @module plugins/audit-log/purge
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('knex').Knex} knex
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} [options.tableName='audit_logs']
|
|
10
|
+
* @param {Date|string} options.olderThan - Rows with created_at strictly before this are removed
|
|
11
|
+
* @returns {Promise<number>} Deleted row count (driver-dependent; 0 if unknown)
|
|
12
|
+
*/
|
|
13
|
+
async function purgeAuditLogs(knex, options) {
|
|
14
|
+
const tableName = options.tableName || 'audit_logs';
|
|
15
|
+
const olderThan = options.olderThan instanceof Date
|
|
16
|
+
? options.olderThan
|
|
17
|
+
: new Date(options.olderThan);
|
|
18
|
+
|
|
19
|
+
if (Number.isNaN(olderThan.getTime())) {
|
|
20
|
+
throw new Error('purgeAuditLogs: invalid olderThan date');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return knex(tableName).where('created_at', '<', olderThan).delete();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
purgeAuditLogs,
|
|
28
|
+
};
|
package/plugins/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const schemaExplorerPlugin = require('./schema-explorer');
|
|
|
10
10
|
const adminPanelPlugin = require('./admin-panel');
|
|
11
11
|
const seoCheckerPlugin = require('./seo-checker');
|
|
12
12
|
const siteAnalyticsPlugin = require('./site-analytics');
|
|
13
|
+
const auditLogPlugin = require('./audit-log');
|
|
13
14
|
|
|
14
15
|
module.exports = {
|
|
15
16
|
sitemapPlugin,
|
|
@@ -19,5 +20,6 @@ module.exports = {
|
|
|
19
20
|
adminPanelPlugin,
|
|
20
21
|
seoCheckerPlugin,
|
|
21
22
|
siteAnalyticsPlugin,
|
|
23
|
+
auditLogPlugin,
|
|
22
24
|
};
|
|
23
25
|
|