webspresso 0.0.50 → 0.0.52
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 +38 -0
- package/bin/commands/favicon-generate.js +155 -0
- package/bin/webspresso.js +2 -0
- package/core/orm/index.js +4 -0
- package/core/orm/model.js +2 -0
- package/core/orm/types.js +2 -0
- package/core/orm/utils.js +28 -0
- package/package.json +2 -1
- package/plugins/admin-panel/admin-user-model.js +1 -0
- package/plugins/admin-panel/api.js +12 -8
- package/plugins/admin-panel/components.js +6 -3
- package/plugins/admin-panel/core/api-extensions.js +7 -4
- package/plugins/admin-panel/index.js +4 -2
package/README.md
CHANGED
|
@@ -215,6 +215,40 @@ npm run watch:css # Watch and rebuild CSS on changes
|
|
|
215
215
|
npm run dev # Starts both CSS watch and dev server
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
### `webspresso favicon:generate <source.png>`
|
|
219
|
+
|
|
220
|
+
Generate favicon PNG files and `favicons.njk` partial from a single source PNG.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# Basic usage (creates files in public/, views/partials/)
|
|
224
|
+
webspresso favicon:generate logo.png
|
|
225
|
+
|
|
226
|
+
# With PWA manifest options
|
|
227
|
+
webspresso favicon:generate logo.png --name "My App" --short-name "App" --theme-color "#22c55e"
|
|
228
|
+
|
|
229
|
+
# Custom output directory
|
|
230
|
+
webspresso favicon:generate logo.png -o static
|
|
231
|
+
|
|
232
|
+
# Skip adding include to layout.njk
|
|
233
|
+
webspresso favicon:generate logo.png --no-layout
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
This command will:
|
|
237
|
+
- Resize the source PNG to all required sizes (Apple touch 57–180px, Android 192px, favicon 16/32/96px, MS Tile 144px)
|
|
238
|
+
- Write PNGs to `public/` (or `-o` path)
|
|
239
|
+
- Create `public/manifest.json` (PWA format)
|
|
240
|
+
- Create `views/partials/favicons.njk` with `<link>` and `<meta>` tags
|
|
241
|
+
- Add `{% include "partials/favicons.njk" %}` to `views/layout.njk` (unless `--no-layout`)
|
|
242
|
+
|
|
243
|
+
**Options:**
|
|
244
|
+
- `-o, --output-dir <path>` – Output directory for PNGs (default: `public`)
|
|
245
|
+
- `--partial-dir <path>` – Directory for favicons.njk (default: `views/partials`)
|
|
246
|
+
- `--layout-file <path>` – Layout file to update (default: `views/layout.njk`)
|
|
247
|
+
- `--theme-color <hex>` – theme-color and msapplication-TileColor (default: `#ffffff`)
|
|
248
|
+
- `--name <string>` – manifest.json `name` (PWA)
|
|
249
|
+
- `--short-name <string>` – manifest.json `short_name` (PWA)
|
|
250
|
+
- `--no-layout` – Do not add include to layout.njk
|
|
251
|
+
|
|
218
252
|
## Project Structure
|
|
219
253
|
|
|
220
254
|
Create your app with this structure:
|
|
@@ -1130,9 +1164,13 @@ const User = defineModel({
|
|
|
1130
1164
|
timestamps: true, // Auto-manage created_at/updated_at
|
|
1131
1165
|
tenant: 'tenant_id', // Multi-tenant column (optional)
|
|
1132
1166
|
},
|
|
1167
|
+
|
|
1168
|
+
hidden: ['password_hash', 'api_token'], // Never expose in API/templates (security)
|
|
1133
1169
|
});
|
|
1134
1170
|
```
|
|
1135
1171
|
|
|
1172
|
+
**Hidden columns:** Add column names to `hidden` so they are never exposed in admin API responses, exports, or when passing to templates. Use for sensitive data like `password_hash`, `api_token`, `secret_key`. The admin panel will exclude these from list views and forms automatically.
|
|
1173
|
+
|
|
1136
1174
|
### Auto-Loading Models
|
|
1137
1175
|
|
|
1138
1176
|
Models are automatically loaded from the `models/` directory when you create a database instance:
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favicon Generate Command
|
|
3
|
+
* Generate favicon PNGs and favicons.njk partial from a single source PNG
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const sharp = require('sharp');
|
|
9
|
+
|
|
10
|
+
const APPLE_SIZES = [57, 60, 72, 76, 114, 120, 144, 152, 180];
|
|
11
|
+
const ANDROID_SIZE = 192;
|
|
12
|
+
const FAVICON_SIZES = [16, 32, 96];
|
|
13
|
+
const MS_TILE_SIZE = 144;
|
|
14
|
+
|
|
15
|
+
function registerCommand(program) {
|
|
16
|
+
program
|
|
17
|
+
.command('favicon:generate <source>')
|
|
18
|
+
.description('Generate favicon PNGs and favicons.njk partial from a single source PNG')
|
|
19
|
+
.option('-o, --output-dir <path>', 'Output directory for PNGs', 'public')
|
|
20
|
+
.option('--partial-dir <path>', 'Directory for favicons.njk partial', 'views/partials')
|
|
21
|
+
.option('--layout-file <path>', 'Layout file to update', 'views/layout.njk')
|
|
22
|
+
.option('--theme-color <hex>', 'theme-color and msapplication-TileColor', '#ffffff')
|
|
23
|
+
.option('--name <string>', 'manifest.json name (PWA)')
|
|
24
|
+
.option('--short-name <string>', 'manifest.json short_name (PWA)')
|
|
25
|
+
.option('--no-layout', 'Skip adding include to layout.njk')
|
|
26
|
+
.action(async (source, options) => {
|
|
27
|
+
try {
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
const sourcePath = path.resolve(cwd, source);
|
|
30
|
+
const outputDir = path.resolve(cwd, options.outputDir);
|
|
31
|
+
const partialDir = path.resolve(cwd, options.partialDir);
|
|
32
|
+
const partialPath = path.join(partialDir, 'favicons.njk');
|
|
33
|
+
const layoutPath = path.resolve(cwd, options.layoutFile || 'views/layout.njk');
|
|
34
|
+
const themeColor = options.themeColor || '#ffffff';
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(sourcePath)) {
|
|
37
|
+
console.error(`\n❌ Source file not found: ${sourcePath}\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ext = path.extname(sourcePath).toLowerCase();
|
|
42
|
+
if (ext !== '.png') {
|
|
43
|
+
console.error(`\n❌ Source must be a PNG file. Got: ${ext}\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('\n🖼️ Generating favicons from:', source);
|
|
48
|
+
|
|
49
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
50
|
+
fs.mkdirSync(partialDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const allSizes = [...new Set([...APPLE_SIZES, ANDROID_SIZE, ...FAVICON_SIZES, MS_TILE_SIZE])].sort((a, b) => a - b);
|
|
53
|
+
|
|
54
|
+
for (const size of allSizes) {
|
|
55
|
+
const buffer = await sharp(sourcePath).resize(size, size).png().toBuffer();
|
|
56
|
+
|
|
57
|
+
if (APPLE_SIZES.includes(size)) {
|
|
58
|
+
const filename = `apple-icon-${size}x${size}.png`;
|
|
59
|
+
fs.writeFileSync(path.join(outputDir, filename), buffer);
|
|
60
|
+
console.log(` ✓ ${filename}`);
|
|
61
|
+
}
|
|
62
|
+
if (size === ANDROID_SIZE) {
|
|
63
|
+
const filename = `android-icon-${size}x${size}.png`;
|
|
64
|
+
fs.writeFileSync(path.join(outputDir, filename), buffer);
|
|
65
|
+
console.log(` ✓ ${filename}`);
|
|
66
|
+
}
|
|
67
|
+
if (size === MS_TILE_SIZE) {
|
|
68
|
+
const filename = `ms-icon-${size}x${size}.png`;
|
|
69
|
+
fs.writeFileSync(path.join(outputDir, filename), buffer);
|
|
70
|
+
console.log(` ✓ ${filename}`);
|
|
71
|
+
}
|
|
72
|
+
if (FAVICON_SIZES.includes(size)) {
|
|
73
|
+
const filename = `favicon-${size}x${size}.png`;
|
|
74
|
+
fs.writeFileSync(path.join(outputDir, filename), buffer);
|
|
75
|
+
console.log(` ✓ ${filename}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const manifest = {
|
|
80
|
+
name: options.name || 'Web App',
|
|
81
|
+
short_name: options.shortName || options.name || 'App',
|
|
82
|
+
icons: [
|
|
83
|
+
{ src: '/android-icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
|
84
|
+
{ src: '/apple-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
|
85
|
+
],
|
|
86
|
+
theme_color: themeColor,
|
|
87
|
+
background_color: themeColor,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
fs.writeFileSync(
|
|
91
|
+
path.join(outputDir, 'manifest.json'),
|
|
92
|
+
JSON.stringify(manifest, null, 2)
|
|
93
|
+
);
|
|
94
|
+
console.log(' ✓ manifest.json');
|
|
95
|
+
|
|
96
|
+
const faviconsNjk = generateFaviconsNjk(themeColor, options.outputDir);
|
|
97
|
+
fs.writeFileSync(partialPath, faviconsNjk);
|
|
98
|
+
console.log(` ✓ ${path.relative(cwd, partialPath)}`);
|
|
99
|
+
|
|
100
|
+
if (options.layout !== false) {
|
|
101
|
+
const updated = addIncludeToLayout(layoutPath, options.outputDir);
|
|
102
|
+
if (updated) {
|
|
103
|
+
console.log(` ✓ Updated ${path.relative(cwd, layoutPath)}`);
|
|
104
|
+
} else {
|
|
105
|
+
console.log(` - Layout already includes favicons or not found`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log('\n✅ Favicons generated successfully.\n');
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error('\n❌ Error:', err.message);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function generateFaviconsNjk(themeColor, outputDir) {
|
|
118
|
+
const pathPrefix = outputDir === 'public' ? '/' : `/${outputDir.replace(/^\/|\/$/g, '')}/`;
|
|
119
|
+
const lines = [
|
|
120
|
+
'{# Favicons - Generated by webspresso favicon:generate #}',
|
|
121
|
+
...APPLE_SIZES.map((s) => `<link rel="apple-touch-icon" sizes="${s}x${s}" href="${pathPrefix}apple-icon-${s}x${s}.png">`),
|
|
122
|
+
`<link rel="icon" type="image/png" sizes="192x192" href="${pathPrefix}android-icon-192x192.png">`,
|
|
123
|
+
...FAVICON_SIZES.map((s) => `<link rel="icon" type="image/png" sizes="${s}x${s}" href="${pathPrefix}favicon-${s}x${s}.png">`),
|
|
124
|
+
`<link rel="manifest" href="${pathPrefix}manifest.json">`,
|
|
125
|
+
`<meta name="msapplication-TileColor" content="${themeColor}">`,
|
|
126
|
+
`<meta name="msapplication-TileImage" content="${pathPrefix}ms-icon-144x144.png">`,
|
|
127
|
+
`<meta name="theme-color" content="${themeColor}">`,
|
|
128
|
+
];
|
|
129
|
+
return lines.join('\n') + '\n';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function addIncludeToLayout(layoutPath, outputDir) {
|
|
133
|
+
if (!fs.existsSync(layoutPath)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const content = fs.readFileSync(layoutPath, 'utf8');
|
|
138
|
+
const includePattern = /favicons\.njk/;
|
|
139
|
+
if (includePattern.test(content)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const includeLine = ' {% include "partials/favicons.njk" %}';
|
|
144
|
+
const headClose = '</head>';
|
|
145
|
+
|
|
146
|
+
if (content.includes(headClose)) {
|
|
147
|
+
const newContent = content.replace(headClose, `${includeLine}\n${headClose}`);
|
|
148
|
+
fs.writeFileSync(layoutPath, newContent);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { registerCommand };
|
package/bin/webspresso.js
CHANGED
|
@@ -26,6 +26,7 @@ const { registerCommand: registerDbMake } = require('./commands/db-make');
|
|
|
26
26
|
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
|
+
const { registerCommand: registerFaviconGenerate } = require('./commands/favicon-generate');
|
|
29
30
|
|
|
30
31
|
registerNew(program);
|
|
31
32
|
registerPage(program);
|
|
@@ -40,6 +41,7 @@ registerDbMake(program);
|
|
|
40
41
|
registerSeed(program);
|
|
41
42
|
registerAdminSetup(program);
|
|
42
43
|
registerAdminPassword(program);
|
|
44
|
+
registerFaviconGenerate(program);
|
|
43
45
|
|
|
44
46
|
// Parse arguments
|
|
45
47
|
program.parse();
|
package/core/orm/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const { createMigrationManager } = require('./migrations');
|
|
|
13
13
|
const { createSeeder } = require('./seeder');
|
|
14
14
|
const { createScopeContext } = require('./scopes');
|
|
15
15
|
const { ModelEvents, Hooks, HookCancellationError, createEventContext } = require('./events');
|
|
16
|
+
const { omitHiddenColumns, sanitizeForOutput } = require('./utils');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Create a database instance
|
|
@@ -272,6 +273,9 @@ module.exports = {
|
|
|
272
273
|
// Column utilities
|
|
273
274
|
extractColumnsFromSchema,
|
|
274
275
|
getColumnMeta,
|
|
276
|
+
// Output sanitization (exclude hidden columns from API/templates)
|
|
277
|
+
omitHiddenColumns,
|
|
278
|
+
sanitizeForOutput,
|
|
275
279
|
// Events/Signals
|
|
276
280
|
ModelEvents,
|
|
277
281
|
Hooks,
|
package/core/orm/model.js
CHANGED
|
@@ -28,6 +28,7 @@ function defineModel(options) {
|
|
|
28
28
|
scopes = {},
|
|
29
29
|
admin = {},
|
|
30
30
|
hooks = {},
|
|
31
|
+
hidden = [],
|
|
31
32
|
} = options;
|
|
32
33
|
|
|
33
34
|
// Validate required fields
|
|
@@ -88,6 +89,7 @@ function defineModel(options) {
|
|
|
88
89
|
customFields: admin.customFields || {},
|
|
89
90
|
queries: admin.queries || {},
|
|
90
91
|
},
|
|
92
|
+
hidden: Array.isArray(hidden) ? hidden : [],
|
|
91
93
|
hooks: {},
|
|
92
94
|
};
|
|
93
95
|
|
package/core/orm/types.js
CHANGED
|
@@ -147,6 +147,7 @@
|
|
|
147
147
|
* @property {RelationsMap} [relations={}] - Relation definitions
|
|
148
148
|
* @property {ScopeOptions} [scopes={}] - Scope options
|
|
149
149
|
* @property {AdminMetadata} [admin] - Admin panel metadata
|
|
150
|
+
* @property {string[]} [hidden=[]] - Column names to never expose in API/templates (e.g. password_hash, api_token)
|
|
150
151
|
*/
|
|
151
152
|
|
|
152
153
|
/**
|
|
@@ -159,6 +160,7 @@
|
|
|
159
160
|
* @property {ScopeOptions} scopes - Scope options
|
|
160
161
|
* @property {Map<string, ColumnMeta>} columns - Parsed column metadata
|
|
161
162
|
* @property {AdminMetadata} [admin] - Admin panel metadata
|
|
163
|
+
* @property {string[]} hidden - Column names never exposed in API/templates
|
|
162
164
|
*/
|
|
163
165
|
|
|
164
166
|
// ============================================================================
|
package/core/orm/utils.js
CHANGED
|
@@ -114,9 +114,37 @@ function deepClone(obj) {
|
|
|
114
114
|
return cloned;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Remove hidden columns from a record for safe API/template output
|
|
119
|
+
* @param {Object} record - Record from database
|
|
120
|
+
* @param {import('./types').ModelDefinition} model - Model definition with hidden columns
|
|
121
|
+
* @returns {Object} Record without hidden columns
|
|
122
|
+
*/
|
|
123
|
+
function omitHiddenColumns(record, model) {
|
|
124
|
+
if (!record) return record;
|
|
125
|
+
if (!model?.hidden?.length) return record;
|
|
126
|
+
return omit(record, model.hidden);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove hidden columns from records (array or single) for safe output
|
|
131
|
+
* @param {Object|Object[]} records - Record(s) from database
|
|
132
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
133
|
+
* @returns {Object|Object[]} Sanitized record(s)
|
|
134
|
+
*/
|
|
135
|
+
function sanitizeForOutput(records, model) {
|
|
136
|
+
if (!model?.hidden?.length) return records;
|
|
137
|
+
if (Array.isArray(records)) {
|
|
138
|
+
return records.map((r) => omit(r, model.hidden));
|
|
139
|
+
}
|
|
140
|
+
return omit(records, model.hidden);
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
module.exports = {
|
|
118
144
|
pick,
|
|
119
145
|
omit,
|
|
146
|
+
omitHiddenColumns,
|
|
147
|
+
sanitizeForOutput,
|
|
120
148
|
formatDateForDb,
|
|
121
149
|
generateMigrationTimestamp,
|
|
122
150
|
snakeToCamel,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.52",
|
|
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": {
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"inquirer": "^8.2.6",
|
|
56
56
|
"knex": "^3.1.0",
|
|
57
57
|
"nunjucks": "^3.2.4",
|
|
58
|
+
"sharp": "^0.33.5",
|
|
58
59
|
"zod": "^3.23.0"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { getAllModels, getModel } = require('../../core/orm/model');
|
|
8
|
+
const { sanitizeForOutput } = require('../../core/orm/utils');
|
|
8
9
|
const { checkAdminExists, setupAdmin, login, logout, requireAuth } = require('./auth');
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -198,9 +199,11 @@ function createApiHandlers(options) {
|
|
|
198
199
|
return res.status(403).json({ error: 'Model not enabled in admin panel' });
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
// Build column metadata
|
|
202
|
+
// Build column metadata (hidden columns excluded from list/forms for security)
|
|
203
|
+
const hiddenSet = new Set(model.hidden || []);
|
|
202
204
|
const columns = [];
|
|
203
205
|
for (const [name, meta] of model.columns) {
|
|
206
|
+
const isHidden = hiddenSet.has(name);
|
|
204
207
|
columns.push({
|
|
205
208
|
name,
|
|
206
209
|
type: meta.type,
|
|
@@ -214,7 +217,8 @@ function createApiHandlers(options) {
|
|
|
214
217
|
autoIncrement: meta.autoIncrement || false,
|
|
215
218
|
customField: model.admin.customFields?.[name] || null,
|
|
216
219
|
validations: meta.validations || null,
|
|
217
|
-
ui: meta.ui || null,
|
|
220
|
+
ui: meta.ui ? { ...meta.ui, hidden: isHidden || meta.ui.hidden } : (isHidden ? { hidden: true } : null),
|
|
221
|
+
hidden: isHidden, // Excluded from list display and API responses
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
|
|
@@ -394,7 +398,7 @@ function createApiHandlers(options) {
|
|
|
394
398
|
const records = await query.list();
|
|
395
399
|
|
|
396
400
|
res.json({
|
|
397
|
-
data: records,
|
|
401
|
+
data: sanitizeForOutput(records, model),
|
|
398
402
|
pagination: {
|
|
399
403
|
page,
|
|
400
404
|
perPage,
|
|
@@ -426,7 +430,7 @@ function createApiHandlers(options) {
|
|
|
426
430
|
return res.status(404).json({ error: 'Record not found' });
|
|
427
431
|
}
|
|
428
432
|
|
|
429
|
-
res.json({ data: record });
|
|
433
|
+
res.json({ data: sanitizeForOutput(record, model) });
|
|
430
434
|
} catch (error) {
|
|
431
435
|
res.status(500).json({ error: error.message });
|
|
432
436
|
}
|
|
@@ -459,7 +463,7 @@ function createApiHandlers(options) {
|
|
|
459
463
|
const repo = db.getRepository(model.name);
|
|
460
464
|
const record = await repo.create(req.body);
|
|
461
465
|
|
|
462
|
-
res.status(201).json({ data: record });
|
|
466
|
+
res.status(201).json({ data: sanitizeForOutput(record, model) });
|
|
463
467
|
} catch (error) {
|
|
464
468
|
res.status(400).json({ error: error.message });
|
|
465
469
|
}
|
|
@@ -496,7 +500,7 @@ function createApiHandlers(options) {
|
|
|
496
500
|
return res.status(404).json({ error: 'Record not found' });
|
|
497
501
|
}
|
|
498
502
|
|
|
499
|
-
res.json({ data: record });
|
|
503
|
+
res.json({ data: sanitizeForOutput(record, model) });
|
|
500
504
|
} catch (error) {
|
|
501
505
|
res.status(400).json({ error: error.message });
|
|
502
506
|
}
|
|
@@ -550,7 +554,7 @@ function createApiHandlers(options) {
|
|
|
550
554
|
return res.status(404).json({ error: 'Record not found in trash' });
|
|
551
555
|
}
|
|
552
556
|
|
|
553
|
-
res.json({ success: true, data: record });
|
|
557
|
+
res.json({ success: true, data: sanitizeForOutput(record, model) });
|
|
554
558
|
} catch (error) {
|
|
555
559
|
res.status(500).json({ error: error.message });
|
|
556
560
|
}
|
|
@@ -579,7 +583,7 @@ function createApiHandlers(options) {
|
|
|
579
583
|
// Get all related records (for dropdown/select)
|
|
580
584
|
const records = await relatedRepo.findAll();
|
|
581
585
|
|
|
582
|
-
res.json({ data: records });
|
|
586
|
+
res.json({ data: sanitizeForOutput(records, relatedModel) });
|
|
583
587
|
} catch (error) {
|
|
584
588
|
res.status(500).json({ error: error.message });
|
|
585
589
|
}
|
|
@@ -1484,12 +1484,15 @@ const BulkFieldUpdateDropdown = {
|
|
|
1484
1484
|
// Get columns to display in table (limit to reasonable number)
|
|
1485
1485
|
function getDisplayColumns(columns) {
|
|
1486
1486
|
if (!columns || columns.length === 0) return [];
|
|
1487
|
-
|
|
1487
|
+
|
|
1488
|
+
// Filter out hidden columns (password_hash, api_token, etc.)
|
|
1489
|
+
const visible = [...columns].filter((col) => !col.hidden);
|
|
1490
|
+
|
|
1488
1491
|
// Prioritize: id, name/title, then others (excluding long text/json fields)
|
|
1489
1492
|
const priority = ['id', 'name', 'title', 'email', 'slug', 'status', 'published', 'created_at'];
|
|
1490
1493
|
const exclude = ['password', 'content', 'body', 'description']; // Usually too long
|
|
1491
|
-
|
|
1492
|
-
const sorted =
|
|
1494
|
+
|
|
1495
|
+
const sorted = visible.sort((a, b) => {
|
|
1493
1496
|
const aIdx = priority.indexOf(a.name);
|
|
1494
1497
|
const bIdx = priority.indexOf(b.name);
|
|
1495
1498
|
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module plugins/admin-panel/core/api-extensions
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const { sanitizeForOutput } = require('../../../core/orm/utils');
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Build query with filters applied
|
|
9
11
|
* @param {Object} repo - Repository instance
|
|
@@ -573,8 +575,9 @@ function createExtensionApiHandlers(options) {
|
|
|
573
575
|
}
|
|
574
576
|
|
|
575
577
|
if (format === 'csv') {
|
|
576
|
-
// CSV export
|
|
577
|
-
const
|
|
578
|
+
// CSV export (exclude hidden columns)
|
|
579
|
+
const hiddenSet = new Set(model.hidden || []);
|
|
580
|
+
const columns = Array.from(model.columns.keys()).filter((c) => !hiddenSet.has(c));
|
|
578
581
|
const header = columns.join(',');
|
|
579
582
|
const rows = records.map(record => {
|
|
580
583
|
return columns.map(col => {
|
|
@@ -595,8 +598,8 @@ function createExtensionApiHandlers(options) {
|
|
|
595
598
|
res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.csv"`);
|
|
596
599
|
res.json({ data: csvContent, format: 'csv' });
|
|
597
600
|
} else {
|
|
598
|
-
// JSON export
|
|
599
|
-
res.json({ data: records, model: modelName, exportedAt: new Date().toISOString() });
|
|
601
|
+
// JSON export (exclude hidden columns)
|
|
602
|
+
res.json({ data: sanitizeForOutput(records, model), model: modelName, exportedAt: new Date().toISOString() });
|
|
600
603
|
}
|
|
601
604
|
} catch (error) {
|
|
602
605
|
console.error('Export error:', error);
|
|
@@ -85,13 +85,15 @@ function adminPanelPlugin(options = {}) {
|
|
|
85
85
|
requireAuth,
|
|
86
86
|
optionalAuth,
|
|
87
87
|
registerModule(config) {
|
|
88
|
-
|
|
88
|
+
// When bound to plugin, "this" is the plugin; _ctx lives on this.api
|
|
89
|
+
const ctx = this.api?._ctx ?? this._ctx;
|
|
90
|
+
if (!ctx) {
|
|
89
91
|
throw new Error('registerModule can only be called during or after onRoutesReady');
|
|
90
92
|
}
|
|
91
93
|
return registerModule(config, {
|
|
92
94
|
registry,
|
|
93
95
|
adminPath,
|
|
94
|
-
ctx
|
|
96
|
+
ctx,
|
|
95
97
|
requireAuth,
|
|
96
98
|
optionalAuth,
|
|
97
99
|
serveAdminPanel,
|