webspresso 0.0.63 → 0.0.64
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 +42 -1
- package/core/orm/model.js +6 -0
- package/core/orm/types.js +9 -0
- package/index.d.ts +482 -0
- package/index.js +2 -1
- package/package.json +7 -1
- package/plugins/admin-panel/components.js +120 -120
- package/plugins/admin-panel/field-renderers/array.js +2 -2
- package/plugins/admin-panel/field-renderers/basic.js +6 -6
- package/plugins/admin-panel/field-renderers/file-upload.js +2 -2
- package/plugins/admin-panel/field-renderers/json.js +1 -1
- package/plugins/admin-panel/field-renderers/relations.js +2 -2
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -3
- package/plugins/admin-panel/index.js +35 -4
- package/plugins/admin-panel/modules/bulk-actions.js +6 -6
- package/plugins/admin-panel/modules/custom-pages.js +7 -7
- package/plugins/admin-panel/modules/dashboard.js +14 -14
- package/plugins/admin-panel/modules/menu.js +71 -26
- package/plugins/index.js +2 -0
- package/plugins/rest-resources/index.js +350 -0
- package/src/server.js +128 -36
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST resource plugin — opt-in CRUD routes from ORM models, eager-loaded includes (no N+1)
|
|
3
|
+
* @module plugins/rest-resources
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { attachDbMiddleware } = require('../../src/app-context');
|
|
7
|
+
const { getAllModels } = require('../../core/orm/model');
|
|
8
|
+
const { omit } = require('../../core/orm/utils');
|
|
9
|
+
|
|
10
|
+
const RESERVED_QUERY_KEYS = new Set(['page', 'perPage', 'sort', 'order', 'include', 'trashed']);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pluralize a PascalCase model name to a URL segment (e.g. User -> users, Company -> companies)
|
|
14
|
+
* @param {string} modelName
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function pluralizeSegment(modelName) {
|
|
18
|
+
const base = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
19
|
+
if (base.length >= 2 && base.endsWith('y') && !'aeiou'.includes(base[base.length - 2])) {
|
|
20
|
+
return `${base.slice(0, -1)}ies`;
|
|
21
|
+
}
|
|
22
|
+
if (/(s|x|z|ch|sh)$/i.test(base)) {
|
|
23
|
+
return `${base}es`;
|
|
24
|
+
}
|
|
25
|
+
return `${base}s`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
30
|
+
* @param {string} raw
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
function parseIncludeParam(model, raw) {
|
|
34
|
+
if (raw == null || raw === '') {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const str = typeof raw === 'string' ? raw : String(raw);
|
|
38
|
+
const names = str
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((s) => s.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
|
|
43
|
+
const allowedRelationNames = model.rest?.allowInclude?.length
|
|
44
|
+
? new Set(model.rest.allowInclude)
|
|
45
|
+
: new Set(Object.keys(model.relations));
|
|
46
|
+
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (name.includes('.')) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!allowedRelationNames.has(name) || !model.relations[name]) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push(name);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Strip hidden columns and recurse into loaded relations (belongsTo / hasOne / hasMany).
|
|
62
|
+
* @param {Object|Object[]|null} record
|
|
63
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
64
|
+
* @returns {Object|Object[]|null}
|
|
65
|
+
*/
|
|
66
|
+
function sanitizeRecordTree(record, model) {
|
|
67
|
+
if (record == null) {
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(record)) {
|
|
71
|
+
return record.map((r) => sanitizeRecordTree(r, model));
|
|
72
|
+
}
|
|
73
|
+
if (typeof record !== 'object') {
|
|
74
|
+
return record;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let base = model.hidden?.length ? omit(record, model.hidden) : { ...record };
|
|
78
|
+
|
|
79
|
+
for (const [relName, rel] of Object.entries(model.relations)) {
|
|
80
|
+
if (!(relName in base)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const relatedModel = rel.model();
|
|
84
|
+
const val = base[relName];
|
|
85
|
+
if (rel.type === 'hasMany') {
|
|
86
|
+
base[relName] = Array.isArray(val) ? val.map((item) => sanitizeRecordTree(item, relatedModel)) : val;
|
|
87
|
+
} else {
|
|
88
|
+
base[relName] = sanitizeRecordTree(val, relatedModel);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Only real table columns; drop relations and hidden (aligned with repo validation).
|
|
97
|
+
* @param {Object} body
|
|
98
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
99
|
+
* @returns {Object}
|
|
100
|
+
*/
|
|
101
|
+
function pickWritableColumns(body, model) {
|
|
102
|
+
const out = {};
|
|
103
|
+
if (!body || typeof body !== 'object') {
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
for (const key of Object.keys(body)) {
|
|
107
|
+
if (!model.columns.has(key)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const meta = model.columns.get(key);
|
|
111
|
+
if (meta.primary && meta.autoIncrement) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
out[key] = body[key];
|
|
115
|
+
}
|
|
116
|
+
for (const h of model.hidden || []) {
|
|
117
|
+
delete out[h];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} db
|
|
124
|
+
* @param {object} opts
|
|
125
|
+
* @param {string[]|null} [opts.models]
|
|
126
|
+
* @param {string[]} [opts.excludeModels]
|
|
127
|
+
* @param {function(import('../../core/orm/types').ModelDefinition): boolean} [opts.filter]
|
|
128
|
+
* @returns {import('../../core/orm/types').ModelDefinition[]}
|
|
129
|
+
*/
|
|
130
|
+
function resolveExposedModels(db, opts) {
|
|
131
|
+
const allModels = db && typeof db.getAllModels === 'function'
|
|
132
|
+
? Array.from(db.getAllModels().values())
|
|
133
|
+
: Array.from(getAllModels().values());
|
|
134
|
+
|
|
135
|
+
const exclude = new Set(opts.excludeModels || []);
|
|
136
|
+
let list = allModels.filter((m) => !exclude.has(m.name));
|
|
137
|
+
|
|
138
|
+
if (opts.models && opts.models.length > 0) {
|
|
139
|
+
const allow = new Set(opts.models);
|
|
140
|
+
list = list.filter((m) => allow.has(m.name));
|
|
141
|
+
} else {
|
|
142
|
+
list = list.filter((m) => m.rest && m.rest.enabled === true);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof opts.filter === 'function') {
|
|
146
|
+
list = list.filter(opts.filter);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return list;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeBasePath(p) {
|
|
153
|
+
return `/${String(p).replace(/^\/+|\/+$/g, '')}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applySoftDeleteScope(query, countQuery, model, trashed) {
|
|
157
|
+
if (!model.scopes?.softDelete) {
|
|
158
|
+
return { query, countQuery };
|
|
159
|
+
}
|
|
160
|
+
if (trashed === 'only') {
|
|
161
|
+
return { query: query.onlyTrashed(), countQuery: countQuery.onlyTrashed() };
|
|
162
|
+
}
|
|
163
|
+
if (trashed === 'include') {
|
|
164
|
+
return { query: query.withTrashed(), countQuery: countQuery.withTrashed() };
|
|
165
|
+
}
|
|
166
|
+
return { query, countQuery };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applyColumnFilters(query, countQuery, model, req) {
|
|
170
|
+
let q = query;
|
|
171
|
+
let c = countQuery;
|
|
172
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
173
|
+
if (RESERVED_QUERY_KEYS.has(key)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!model.columns.has(key)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (value === undefined || value === '') {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
q = q.where(key, value);
|
|
183
|
+
c = c.where(key, value);
|
|
184
|
+
}
|
|
185
|
+
return { query: q, countQuery: c };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {Object} options
|
|
190
|
+
* @param {string} [options.path='/api/rest'] - Base path (no trailing slash)
|
|
191
|
+
* @param {import('express').RequestHandler[]} [options.middleware] - Before attachDbMiddleware
|
|
192
|
+
* @param {string[]} [options.models] - Whitelist model names (ignores rest.enabled when set)
|
|
193
|
+
* @param {string[]} [options.excludeModels] - Exclude model names
|
|
194
|
+
* @param {function(import('../../core/orm/types').ModelDefinition): boolean} [options.filter] - Extra filter
|
|
195
|
+
*/
|
|
196
|
+
function restResourcePlugin(options = {}) {
|
|
197
|
+
const {
|
|
198
|
+
path: basePath = '/api/rest',
|
|
199
|
+
middleware = [],
|
|
200
|
+
models: modelNameWhitelist = null,
|
|
201
|
+
excludeModels = [],
|
|
202
|
+
filter: modelFilter = null,
|
|
203
|
+
} = options;
|
|
204
|
+
|
|
205
|
+
const normalizedBase = normalizeBasePath(basePath);
|
|
206
|
+
const extra = Array.isArray(middleware) ? middleware : [];
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
name: 'rest-resources',
|
|
210
|
+
version: '1.0.0',
|
|
211
|
+
|
|
212
|
+
onRoutesReady(ctx) {
|
|
213
|
+
const db = ctx.db ?? ctx.options?.db;
|
|
214
|
+
if (!db) {
|
|
215
|
+
console.warn('[rest-resources] Skipping routes: createApp({ db }) is required');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const exposed = resolveExposedModels(db, {
|
|
220
|
+
models: modelNameWhitelist,
|
|
221
|
+
excludeModels,
|
|
222
|
+
filter: modelFilter,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const chain = (handler) => [...extra, attachDbMiddleware, handler];
|
|
226
|
+
|
|
227
|
+
for (const model of exposed) {
|
|
228
|
+
const segment = model.rest?.path || pluralizeSegment(model.name);
|
|
229
|
+
const base = `${normalizedBase}/${segment}`;
|
|
230
|
+
|
|
231
|
+
ctx.addRoute(
|
|
232
|
+
'get',
|
|
233
|
+
base,
|
|
234
|
+
...chain(async (req, res) => {
|
|
235
|
+
try {
|
|
236
|
+
const repo = db.getRepository(model.name);
|
|
237
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
238
|
+
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage, 10) || 15));
|
|
239
|
+
const offset = (page - 1) * perPage;
|
|
240
|
+
const include = parseIncludeParam(model, req.query.include);
|
|
241
|
+
|
|
242
|
+
let query = repo.query();
|
|
243
|
+
let countQuery = repo.query();
|
|
244
|
+
({ query, countQuery } = applySoftDeleteScope(query, countQuery, model, req.query.trashed));
|
|
245
|
+
({ query, countQuery } = applyColumnFilters(query, countQuery, model, req));
|
|
246
|
+
|
|
247
|
+
const sortCol = req.query.sort && model.columns.has(req.query.sort) ? req.query.sort : model.primaryKey;
|
|
248
|
+
const order = String(req.query.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
249
|
+
|
|
250
|
+
const total = await countQuery.count();
|
|
251
|
+
let listQ = query.orderBy(sortCol, order).offset(offset).limit(perPage);
|
|
252
|
+
if (include.length > 0) {
|
|
253
|
+
listQ = listQ.with(...include);
|
|
254
|
+
}
|
|
255
|
+
const records = await listQ.list();
|
|
256
|
+
|
|
257
|
+
res.json({
|
|
258
|
+
data: sanitizeRecordTree(records, model),
|
|
259
|
+
pagination: {
|
|
260
|
+
page,
|
|
261
|
+
perPage,
|
|
262
|
+
total,
|
|
263
|
+
totalPages: Math.ceil(total / perPage) || 0,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
} catch (err) {
|
|
267
|
+
res.status(400).json({ error: err.message });
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
ctx.addRoute(
|
|
273
|
+
'get',
|
|
274
|
+
`${base}/:id`,
|
|
275
|
+
...chain(async (req, res) => {
|
|
276
|
+
try {
|
|
277
|
+
const repo = db.getRepository(model.name);
|
|
278
|
+
const include = parseIncludeParam(model, req.query.include);
|
|
279
|
+
const record = await repo.findById(req.params.id, { with: include });
|
|
280
|
+
|
|
281
|
+
if (!record) {
|
|
282
|
+
return res.status(404).json({ error: 'Record not found' });
|
|
283
|
+
}
|
|
284
|
+
res.json({ data: sanitizeRecordTree(record, model) });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
res.status(400).json({ error: err.message });
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
ctx.addRoute(
|
|
292
|
+
'post',
|
|
293
|
+
base,
|
|
294
|
+
...chain(async (req, res) => {
|
|
295
|
+
try {
|
|
296
|
+
const repo = db.getRepository(model.name);
|
|
297
|
+
const payload = pickWritableColumns(req.body, model);
|
|
298
|
+
const record = await repo.create(payload);
|
|
299
|
+
res.status(201).json({ data: sanitizeRecordTree(record, model) });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
res.status(400).json({ error: err.message });
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
ctx.addRoute(
|
|
307
|
+
'patch',
|
|
308
|
+
`${base}/:id`,
|
|
309
|
+
...chain(async (req, res) => {
|
|
310
|
+
try {
|
|
311
|
+
const repo = db.getRepository(model.name);
|
|
312
|
+
const payload = pickWritableColumns(req.body, model);
|
|
313
|
+
const record = await repo.update(req.params.id, payload);
|
|
314
|
+
if (!record) {
|
|
315
|
+
return res.status(404).json({ error: 'Record not found' });
|
|
316
|
+
}
|
|
317
|
+
res.json({ data: sanitizeRecordTree(record, model) });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
res.status(400).json({ error: err.message });
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
ctx.addRoute(
|
|
325
|
+
'delete',
|
|
326
|
+
`${base}/:id`,
|
|
327
|
+
...chain(async (req, res) => {
|
|
328
|
+
try {
|
|
329
|
+
const repo = db.getRepository(model.name);
|
|
330
|
+
const ok = await repo.delete(req.params.id);
|
|
331
|
+
if (!ok) {
|
|
332
|
+
return res.status(404).json({ error: 'Record not found' });
|
|
333
|
+
}
|
|
334
|
+
res.json({ success: true });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
res.status(400).json({ error: err.message });
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = restResourcePlugin;
|
|
346
|
+
module.exports.pluralizeSegment = pluralizeSegment;
|
|
347
|
+
module.exports.parseIncludeParam = parseIncludeParam;
|
|
348
|
+
module.exports.sanitizeRecordTree = sanitizeRecordTree;
|
|
349
|
+
module.exports.pickWritableColumns = pickWritableColumns;
|
|
350
|
+
module.exports.resolveExposedModels = resolveExposedModels;
|
package/src/server.js
CHANGED
|
@@ -55,28 +55,118 @@ function getDefaultHelmetConfig(isDev) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
|
|
60
|
+
*/
|
|
61
|
+
function defaultErrorPageStyles() {
|
|
62
|
+
return `
|
|
63
|
+
:root { color-scheme: light dark; }
|
|
64
|
+
* { box-sizing: border-box; }
|
|
65
|
+
body {
|
|
66
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
|
67
|
+
margin: 0;
|
|
68
|
+
min-height: 100vh;
|
|
69
|
+
min-height: 100dvh;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
|
74
|
+
background: #f5f5f5;
|
|
75
|
+
color: #1a1a1a;
|
|
76
|
+
-webkit-text-size-adjust: 100%;
|
|
77
|
+
}
|
|
78
|
+
@media (prefers-color-scheme: dark) {
|
|
79
|
+
body { background: #121212; color: #e8e8e8; }
|
|
80
|
+
.card { background: #1e1e1e; border-color: #333; box-shadow: 0 1px 3px rgba(0,0,0,.35); }
|
|
81
|
+
h1 { color: #f5f5f5; }
|
|
82
|
+
.muted { color: #a3a3a3; }
|
|
83
|
+
a { color: #7cc4ff; }
|
|
84
|
+
pre { background: #0d0d0d; color: #e5e5e5; border-color: #333; }
|
|
85
|
+
}
|
|
86
|
+
.container {
|
|
87
|
+
width: 100%;
|
|
88
|
+
max-width: min(100%, 26rem);
|
|
89
|
+
text-align: center;
|
|
90
|
+
}
|
|
91
|
+
.card {
|
|
92
|
+
background: #fff;
|
|
93
|
+
border: 1px solid #e5e5e5;
|
|
94
|
+
border-radius: 12px;
|
|
95
|
+
padding: clamp(1.25rem, 5vw, 2rem);
|
|
96
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
|
97
|
+
}
|
|
98
|
+
h1 {
|
|
99
|
+
font-size: clamp(2.5rem, 12vw, 4rem);
|
|
100
|
+
font-weight: 700;
|
|
101
|
+
line-height: 1.05;
|
|
102
|
+
margin: 0 0 0.35rem;
|
|
103
|
+
letter-spacing: -0.02em;
|
|
104
|
+
color: #262626;
|
|
105
|
+
}
|
|
106
|
+
.muted {
|
|
107
|
+
margin: 0 0 1rem;
|
|
108
|
+
line-height: 1.55;
|
|
109
|
+
color: #525252;
|
|
110
|
+
font-size: clamp(0.9375rem, 3.8vw, 1.0625rem);
|
|
111
|
+
}
|
|
112
|
+
a {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
justify-content: center;
|
|
116
|
+
gap: 0.35rem;
|
|
117
|
+
margin-top: 0.25rem;
|
|
118
|
+
color: #0066cc;
|
|
119
|
+
text-decoration: none;
|
|
120
|
+
font-weight: 500;
|
|
121
|
+
font-size: clamp(0.875rem, 3.5vw, 1rem);
|
|
122
|
+
min-height: 44px;
|
|
123
|
+
padding: 0.25rem 0.5rem;
|
|
124
|
+
}
|
|
125
|
+
a:hover { text-decoration: underline; }
|
|
126
|
+
a:focus-visible {
|
|
127
|
+
outline: 2px solid currentColor;
|
|
128
|
+
outline-offset: 3px;
|
|
129
|
+
border-radius: 4px;
|
|
130
|
+
}
|
|
131
|
+
pre {
|
|
132
|
+
margin: 1rem 0 0;
|
|
133
|
+
padding: clamp(0.75rem, 3vw, 1rem);
|
|
134
|
+
border-radius: 8px;
|
|
135
|
+
text-align: left;
|
|
136
|
+
font-size: clamp(0.625rem, 2.75vw, 0.8125rem);
|
|
137
|
+
line-height: 1.45;
|
|
138
|
+
overflow-x: auto;
|
|
139
|
+
max-width: 100%;
|
|
140
|
+
width: 100%;
|
|
141
|
+
white-space: pre-wrap;
|
|
142
|
+
word-break: break-word;
|
|
143
|
+
background: #fff;
|
|
144
|
+
border: 1px solid #e5e5e5;
|
|
145
|
+
-webkit-overflow-scrolling: touch;
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
|
|
58
150
|
/**
|
|
59
151
|
* Default 404 page HTML
|
|
60
152
|
*/
|
|
61
153
|
function default404Html() {
|
|
62
154
|
return `<!DOCTYPE html>
|
|
63
|
-
<html>
|
|
155
|
+
<html lang="en">
|
|
64
156
|
<head>
|
|
157
|
+
<meta charset="UTF-8" />
|
|
158
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
65
159
|
<title>404 - Not Found</title>
|
|
66
|
-
<style
|
|
67
|
-
|
|
68
|
-
.container { text-align: center; }
|
|
69
|
-
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
70
|
-
p { color: #666; margin: 1rem 0; }
|
|
71
|
-
a { color: #0066cc; text-decoration: none; }
|
|
72
|
-
a:hover { text-decoration: underline; }
|
|
73
|
-
</style>
|
|
160
|
+
<style>${defaultErrorPageStyles()}
|
|
161
|
+
</style>
|
|
74
162
|
</head>
|
|
75
163
|
<body>
|
|
76
164
|
<div class="container">
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
165
|
+
<div class="card">
|
|
166
|
+
<h1>404</h1>
|
|
167
|
+
<p class="muted">Page not found</p>
|
|
168
|
+
<a href="/">← Back to Home</a>
|
|
169
|
+
</div>
|
|
80
170
|
</div>
|
|
81
171
|
</body>
|
|
82
172
|
</html>`;
|
|
@@ -86,26 +176,30 @@ function default404Html() {
|
|
|
86
176
|
* Default 500 page HTML
|
|
87
177
|
*/
|
|
88
178
|
function default500Html(err, isDev) {
|
|
179
|
+
const detail =
|
|
180
|
+
isDev && err
|
|
181
|
+
? `<pre>${String(err.stack || err.message)
|
|
182
|
+
.replace(/&/g, '&')
|
|
183
|
+
.replace(/</g, '<')
|
|
184
|
+
.replace(/>/g, '>')}</pre>`
|
|
185
|
+
: '';
|
|
89
186
|
return `<!DOCTYPE html>
|
|
90
|
-
<html>
|
|
187
|
+
<html lang="en">
|
|
91
188
|
<head>
|
|
189
|
+
<meta charset="UTF-8" />
|
|
190
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
92
191
|
<title>500 - Server Error</title>
|
|
93
|
-
<style
|
|
94
|
-
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
95
|
-
.container { text-align: center; }
|
|
96
|
-
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
97
|
-
p { color: #666; margin: 1rem 0; }
|
|
98
|
-
pre { background: #fff; padding: 1rem; border-radius: 4px; text-align: left; overflow: auto; max-width: 600px; }
|
|
99
|
-
a { color: #0066cc; text-decoration: none; }
|
|
100
|
-
a:hover { text-decoration: underline; }
|
|
192
|
+
<style>${defaultErrorPageStyles()}
|
|
101
193
|
</style>
|
|
102
194
|
</head>
|
|
103
195
|
<body>
|
|
104
196
|
<div class="container">
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
197
|
+
<div class="card">
|
|
198
|
+
<h1>500</h1>
|
|
199
|
+
<p class="muted">Internal Server Error</p>
|
|
200
|
+
${detail}
|
|
201
|
+
<a href="/">← Back to Home</a>
|
|
202
|
+
</div>
|
|
109
203
|
</div>
|
|
110
204
|
</body>
|
|
111
205
|
</html>`;
|
|
@@ -116,23 +210,21 @@ function default500Html(err, isDev) {
|
|
|
116
210
|
*/
|
|
117
211
|
function default503Html() {
|
|
118
212
|
return `<!DOCTYPE html>
|
|
119
|
-
<html>
|
|
213
|
+
<html lang="en">
|
|
120
214
|
<head>
|
|
215
|
+
<meta charset="UTF-8" />
|
|
216
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
121
217
|
<title>503 - Service Unavailable</title>
|
|
122
|
-
<style
|
|
123
|
-
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
124
|
-
.container { text-align: center; }
|
|
125
|
-
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
126
|
-
p { color: #666; margin: 1rem 0; }
|
|
127
|
-
a { color: #0066cc; text-decoration: none; }
|
|
128
|
-
a:hover { text-decoration: underline; }
|
|
218
|
+
<style>${defaultErrorPageStyles()}
|
|
129
219
|
</style>
|
|
130
220
|
</head>
|
|
131
221
|
<body>
|
|
132
222
|
<div class="container">
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
223
|
+
<div class="card">
|
|
224
|
+
<h1>503</h1>
|
|
225
|
+
<p class="muted">Request timed out. Please try again.</p>
|
|
226
|
+
<a href="/">← Back to Home</a>
|
|
227
|
+
</div>
|
|
136
228
|
</div>
|
|
137
229
|
</body>
|
|
138
230
|
</html>`;
|