webspresso 0.0.54 → 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 CHANGED
@@ -249,6 +249,25 @@ This command will:
249
249
  - `--short-name <string>` – manifest.json `short_name` (PWA)
250
250
  - `--no-layout` – Do not add include to layout.njk
251
251
 
252
+ ### Admin Panel Commands
253
+
254
+ ```bash
255
+ # Create admin_users table migration
256
+ webspresso admin:setup
257
+
258
+ # List all admin users
259
+ webspresso admin:list
260
+
261
+ # Reset admin password (interactive)
262
+ webspresso admin:password
263
+
264
+ # Reset with options
265
+ webspresso admin:password -e admin@example.com -p yeni_sifre123
266
+ webspresso admin:password -c ./webspresso.db.js -E production
267
+ ```
268
+
269
+ > **Note:** Requires `webspresso.db.js` or `knexfile.js` in project root. Run from project directory.
270
+
252
271
  ## Project Structure
253
272
 
254
273
  Create your app with this structure:
@@ -699,6 +718,36 @@ Options:
699
718
 
700
719
  The `analytics_page_views` table is automatically created on first request.
701
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
+
702
751
  **SEO Checker Plugin:**
703
752
  - Client-side SEO analysis tool (inspired by django-check-seo)
704
753
  - Integrated with dev toolbar
@@ -1339,9 +1388,33 @@ webspresso db:make create_posts_table
1339
1388
  webspresso db:make create_users_table --model User
1340
1389
 
1341
1390
  # Admin Panel Setup
1342
- webspresso admin:setup # Create admin_users migration
1391
+ webspresso admin:setup # Create admin_users migration
1392
+ webspresso admin:list # List all admin users
1393
+ webspresso admin:password # Reset admin password (interactive or -e -p)
1343
1394
  ```
1344
1395
 
1396
+ **Admin CLI Commands:**
1397
+
1398
+ ```bash
1399
+ # Create admin_users table migration
1400
+ webspresso admin:setup
1401
+
1402
+ # List all admin users
1403
+ webspresso admin:list
1404
+
1405
+ # Reset admin password (interactive: prompts for email and password)
1406
+ webspresso admin:password
1407
+
1408
+ # Reset with options
1409
+ webspresso admin:password -e admin@example.com -p yeni_sifre123
1410
+
1411
+ # Use custom config or environment
1412
+ webspresso admin:password -c ./webspresso.db.js -E production
1413
+ webspresso admin:list -c ./webspresso.db.js
1414
+ ```
1415
+
1416
+ > **Note:** Database config is loaded from `webspresso.db.js` or `knexfile.js` in the project root. Run commands from your project directory.
1417
+
1345
1418
  **Database Config File (`webspresso.db.js`):**
1346
1419
 
1347
1420
  ```javascript
@@ -3,9 +3,8 @@
3
3
  * Reset admin user password via CLI
4
4
  */
5
5
 
6
- const fs = require('fs');
7
- const path = require('path');
8
6
  const readline = require('readline');
7
+ const { loadDbConfig, createDbInstance } = require('../utils/db');
9
8
 
10
9
  function registerCommand(program) {
11
10
  program
@@ -13,41 +12,13 @@ function registerCommand(program) {
13
12
  .description('Reset admin user password')
14
13
  .option('-e, --email <email>', 'Admin user email')
15
14
  .option('-p, --password <password>', 'New password (not recommended, use interactive mode)')
16
- .option('-c, --config <path>', 'Path to database config file')
15
+ .option('-c, --config <path>', 'Path to database config file (webspresso.db.js or knexfile.js)')
16
+ .option('-E, --env <environment>', 'Environment (development, production)', 'development')
17
17
  .action(async (options) => {
18
18
  try {
19
- // Find project root and load database
20
- const cwd = process.cwd();
21
-
22
- // Try to find and load the database config
23
- let dbConfig = null;
24
- const configPaths = [
25
- options.config,
26
- path.join(cwd, 'webspresso.config.js'),
27
- path.join(cwd, 'database.config.js'),
28
- path.join(cwd, 'db.config.js'),
29
- ].filter(Boolean);
30
-
31
- for (const configPath of configPaths) {
32
- if (fs.existsSync(configPath)) {
33
- const config = require(configPath);
34
- dbConfig = config.database || config;
35
- break;
36
- }
37
- }
38
-
39
- if (!dbConfig) {
40
- console.error('❌ Error: Could not find database configuration.');
41
- console.error(' Please run this command from your project root.');
42
- process.exit(1);
43
- }
44
-
45
- // Lazy load dependencies
46
- const bcrypt = require('bcrypt');
47
- const knex = require('knex');
48
-
49
- // Initialize database connection
50
- const db = knex(dbConfig);
19
+ const { config, path: configPath } = loadDbConfig(options.config);
20
+ const db = await createDbInstance(config, options.env);
21
+ console.log(`\n📦 Using config: ${configPath}\n`);
51
22
 
52
23
  // Check if admin_users table exists
53
24
  const hasTable = await db.schema.hasTable('admin_users');
@@ -174,35 +145,13 @@ function registerCommand(program) {
174
145
  program
175
146
  .command('admin:list')
176
147
  .description('List all admin users')
177
- .option('-c, --config <path>', 'Path to database config file')
148
+ .option('-c, --config <path>', 'Path to database config file (webspresso.db.js or knexfile.js)')
149
+ .option('-E, --env <environment>', 'Environment (development, production)', 'development')
178
150
  .action(async (options) => {
179
151
  try {
180
- const cwd = process.cwd();
181
-
182
- // Try to find and load the database config
183
- let dbConfig = null;
184
- const configPaths = [
185
- options.config,
186
- path.join(cwd, 'webspresso.config.js'),
187
- path.join(cwd, 'database.config.js'),
188
- path.join(cwd, 'db.config.js'),
189
- ].filter(Boolean);
190
-
191
- for (const configPath of configPaths) {
192
- if (fs.existsSync(configPath)) {
193
- const config = require(configPath);
194
- dbConfig = config.database || config;
195
- break;
196
- }
197
- }
198
-
199
- if (!dbConfig) {
200
- console.error('❌ Error: Could not find database configuration.');
201
- process.exit(1);
202
- }
203
-
204
- const knex = require('knex');
205
- const db = knex(dbConfig);
152
+ const { config, path: configPath } = loadDbConfig(options.config);
153
+ const db = await createDbInstance(config, options.env);
154
+ console.log(`\n📦 Using config: ${configPath}\n`);
206
155
 
207
156
  // Check if admin_users table exists
208
157
  const hasTable = await db.schema.hasTable('admin_users');
@@ -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.54",
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 (!route.path || typeof route.handler !== 'function') {
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
 
@@ -172,34 +172,44 @@ function generatePanelStyles() {
172
172
  .seo-tabs {
173
173
  display: flex;
174
174
  padding: 0 12px;
175
- gap: 2px;
175
+ gap: 4px;
176
176
  background: rgba(0,0,0,0.2);
177
177
  border-bottom: 1px solid rgba(255,255,255,0.06);
178
178
  overflow-x: auto;
179
- scrollbar-width: none;
179
+ overflow-y: hidden;
180
+ scrollbar-width: thin;
181
+ scrollbar-color: #3f3f46 transparent;
180
182
  }
181
183
 
182
184
  .seo-tabs::-webkit-scrollbar {
183
- display: none;
185
+ height: 4px;
186
+ }
187
+
188
+ .seo-tabs::-webkit-scrollbar-track {
189
+ background: transparent;
190
+ }
191
+
192
+ .seo-tabs::-webkit-scrollbar-thumb {
193
+ background: #3f3f46;
194
+ border-radius: 2px;
184
195
  }
185
196
 
186
197
  .seo-tab {
187
- flex: 1;
188
- min-width: 0;
189
- padding: 12px 8px;
198
+ flex: 0 0 auto;
199
+ padding: 10px 10px;
190
200
  background: none;
191
201
  border: none;
192
202
  color: #71717a;
193
203
  font-size: 10px;
194
204
  font-weight: 500;
195
205
  text-transform: uppercase;
196
- letter-spacing: 0.5px;
206
+ letter-spacing: 0.3px;
197
207
  cursor: pointer;
198
208
  transition: all 0.2s;
199
209
  display: flex;
200
210
  flex-direction: column;
201
211
  align-items: center;
202
- gap: 6px;
212
+ gap: 4px;
203
213
  border-bottom: 2px solid transparent;
204
214
  white-space: nowrap;
205
215
  }
@@ -400,7 +410,8 @@ function generatePanelStyles() {
400
410
  }
401
411
 
402
412
  .seo-tab {
403
- padding: 10px 6px;
413
+ flex: 0 0 auto;
414
+ padding: 8px 8px;
404
415
  font-size: 9px;
405
416
  }
406
417