chadstart 1.0.0
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/.dockerignore +10 -0
- package/.env.example +46 -0
- package/.github/workflows/browser-test.yml +34 -0
- package/.github/workflows/docker-publish.yml +54 -0
- package/.github/workflows/docs.yml +31 -0
- package/.github/workflows/npm-chadstart.yml +27 -0
- package/.github/workflows/npm-sdk.yml +38 -0
- package/.github/workflows/test.yml +85 -0
- package/.weblate +9 -0
- package/Dockerfile +23 -0
- package/README.md +348 -0
- package/admin/index.html +2802 -0
- package/admin/login.html +207 -0
- package/chadstart.example.yml +416 -0
- package/chadstart.schema.json +367 -0
- package/chadstart.yaml +53 -0
- package/cli/cli.js +295 -0
- package/core/api-generator.js +606 -0
- package/core/auth.js +298 -0
- package/core/db.js +384 -0
- package/core/entity-engine.js +166 -0
- package/core/error-reporter.js +132 -0
- package/core/file-storage.js +97 -0
- package/core/functions-engine.js +353 -0
- package/core/openapi.js +171 -0
- package/core/plugin-loader.js +92 -0
- package/core/realtime.js +93 -0
- package/core/schema-validator.js +50 -0
- package/core/seeder.js +231 -0
- package/core/telemetry.js +119 -0
- package/core/upload.js +372 -0
- package/core/workers/php_worker.php +19 -0
- package/core/workers/python_worker.py +33 -0
- package/core/workers/ruby_worker.rb +21 -0
- package/core/yaml-loader.js +64 -0
- package/demo/chadstart.yaml +178 -0
- package/demo/docker-compose.yml +31 -0
- package/demo/functions/greet.go +39 -0
- package/demo/functions/hello.cpp +18 -0
- package/demo/functions/hello.py +13 -0
- package/demo/functions/hello.rb +10 -0
- package/demo/functions/onTodoCreated.js +13 -0
- package/demo/functions/ping.sh +13 -0
- package/demo/functions/stats.js +22 -0
- package/demo/public/index.html +522 -0
- package/docker-compose.yml +17 -0
- package/docs/access-policies.md +155 -0
- package/docs/admin-ui.md +29 -0
- package/docs/angular.md +69 -0
- package/docs/astro.md +71 -0
- package/docs/auth.md +160 -0
- package/docs/cli.md +56 -0
- package/docs/config.md +127 -0
- package/docs/crud.md +627 -0
- package/docs/deploy.md +113 -0
- package/docs/docker.md +59 -0
- package/docs/entities.md +385 -0
- package/docs/functions.md +196 -0
- package/docs/getting-started.md +79 -0
- package/docs/groups.md +85 -0
- package/docs/index.md +5 -0
- package/docs/llm-rules.md +81 -0
- package/docs/middlewares.md +78 -0
- package/docs/overrides/home.html +350 -0
- package/docs/plugins.md +59 -0
- package/docs/react.md +75 -0
- package/docs/realtime.md +43 -0
- package/docs/s3-storage.md +40 -0
- package/docs/security.md +23 -0
- package/docs/stylesheets/extra.css +375 -0
- package/docs/svelte.md +71 -0
- package/docs/telemetry.md +97 -0
- package/docs/upload.md +168 -0
- package/docs/validation.md +115 -0
- package/docs/vue.md +86 -0
- package/docs/webhooks.md +87 -0
- package/index.js +11 -0
- package/locales/en/admin.json +169 -0
- package/mkdocs.yml +82 -0
- package/package.json +65 -0
- package/playwright.config.js +24 -0
- package/public/.gitkeep +0 -0
- package/sdk/README.md +284 -0
- package/sdk/package.json +39 -0
- package/sdk/scripts/build.js +58 -0
- package/sdk/src/index.js +368 -0
- package/sdk/test/sdk.test.cjs +340 -0
- package/sdk/types/index.d.ts +217 -0
- package/server/express-server.js +734 -0
- package/test/access-policies.test.js +96 -0
- package/test/ai.test.js +81 -0
- package/test/api-keys.test.js +361 -0
- package/test/auth.test.js +122 -0
- package/test/browser/admin-ui.spec.js +127 -0
- package/test/browser/global-setup.js +71 -0
- package/test/browser/global-teardown.js +11 -0
- package/test/db.test.js +227 -0
- package/test/entity-engine.test.js +193 -0
- package/test/error-reporter.test.js +140 -0
- package/test/functions-engine.test.js +240 -0
- package/test/groups.test.js +212 -0
- package/test/hot-reload.test.js +153 -0
- package/test/i18n.test.js +173 -0
- package/test/middleware.test.js +76 -0
- package/test/openapi.test.js +67 -0
- package/test/schema-validator.test.js +83 -0
- package/test/sdk.test.js +90 -0
- package/test/seeder.test.js +279 -0
- package/test/settings.test.js +109 -0
- package/test/telemetry.test.js +254 -0
- package/test/test.js +17 -0
- package/test/upload.test.js +265 -0
- package/test/validation.test.js +96 -0
- package/test/yaml-loader.test.js +93 -0
- package/utils/logger.js +24 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert the raw YAML config into normalized internal models.
|
|
5
|
+
*
|
|
6
|
+
* Authenticable entities (user collections) are entities with
|
|
7
|
+
* `authenticable: true` — no separate userCollections section.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const EMOJI_ACCESS = { '🌐': 'public', '🔒': 'restricted', '👨🏻💻': 'admin', '🚫': 'forbidden' };
|
|
11
|
+
|
|
12
|
+
function normalizePolicies(raw) {
|
|
13
|
+
if (!raw) return {};
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [rule, list] of Object.entries(raw)) {
|
|
16
|
+
out[rule] = list.map((p) => ({
|
|
17
|
+
access: EMOJI_ACCESS[p.access] || p.access,
|
|
18
|
+
allow: p.allow || null,
|
|
19
|
+
condition: p.condition || null,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeRelation(rel) {
|
|
26
|
+
if (typeof rel === 'string') return { name: rel, entity: rel };
|
|
27
|
+
return { name: rel.name || rel.entity, entity: rel.entity || rel.name, ...rel };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeProperty(prop) {
|
|
31
|
+
if (typeof prop === 'string') return { name: prop, type: 'string' };
|
|
32
|
+
return {
|
|
33
|
+
name: prop.name,
|
|
34
|
+
type: prop.type || 'string',
|
|
35
|
+
hidden: prop.hidden === true,
|
|
36
|
+
default: prop.default !== undefined ? prop.default : undefined,
|
|
37
|
+
options: prop.options || undefined,
|
|
38
|
+
helpText: prop.helpText || undefined,
|
|
39
|
+
validation: prop.validation || undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildEntities(config) {
|
|
44
|
+
const entities = {};
|
|
45
|
+
|
|
46
|
+
for (const [name, def] of Object.entries(config.entities || {})) {
|
|
47
|
+
const properties = (def.properties || []).map(normalizeProperty);
|
|
48
|
+
|
|
49
|
+
// Merge inline property validation into entity-level validation.
|
|
50
|
+
// Inline declarations prevail over block-level on conflict.
|
|
51
|
+
const validation = { ...(def.validation || {}) };
|
|
52
|
+
for (const p of properties) {
|
|
53
|
+
if (p.validation) {
|
|
54
|
+
validation[p.name] = { ...(validation[p.name] || {}), ...p.validation };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
entities[name] = {
|
|
59
|
+
name,
|
|
60
|
+
tableName: toSnakeCase(name),
|
|
61
|
+
slug: def.slug || toKebabCase(name),
|
|
62
|
+
authenticable: def.authenticable === true,
|
|
63
|
+
single: def.single === true,
|
|
64
|
+
mainProp: def.mainProp || null,
|
|
65
|
+
nameSingular: def.nameSingular || null,
|
|
66
|
+
namePlural: def.namePlural || null,
|
|
67
|
+
seedCount: def.seedCount || 50,
|
|
68
|
+
properties,
|
|
69
|
+
belongsTo: (def.belongsTo || []).map(normalizeRelation),
|
|
70
|
+
belongsToMany: (def.belongsToMany || []).map(normalizeRelation),
|
|
71
|
+
policies: normalizePolicies(def.policies),
|
|
72
|
+
validation,
|
|
73
|
+
hooks: def.hooks || {},
|
|
74
|
+
middlewares: def.middlewares || {},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return entities;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getAuthenticableEntities(entities) {
|
|
82
|
+
return Object.fromEntries(
|
|
83
|
+
Object.entries(entities).filter(([, e]) => e.authenticable)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toSnakeCase(str) {
|
|
88
|
+
return str.replace(/([A-Z])/g, (m, p, o) => (o > 0 ? '_' : '') + p.toLowerCase()).replace(/^_/, '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toKebabCase(str) {
|
|
92
|
+
return str.replace(/([A-Z])/g, (m, p, o) => (o > 0 ? '-' : '') + p.toLowerCase()).replace(/^-/, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Default Admin entity definition — email + password authenticable entity. */
|
|
96
|
+
const DEFAULT_ADMIN_ENTITY = {
|
|
97
|
+
authenticable: true,
|
|
98
|
+
mainProp: 'email',
|
|
99
|
+
properties: [],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the Admin entity for the built-in admin access.
|
|
104
|
+
* Always included unless admin.enable_entity is explicitly false.
|
|
105
|
+
* If the user defines an "Admin" entity in their YAML, it is merged with the default.
|
|
106
|
+
*/
|
|
107
|
+
function buildAdminEntity(config) {
|
|
108
|
+
const adminCfg = config.admin || {};
|
|
109
|
+
if (adminCfg.enable_entity === false) return null;
|
|
110
|
+
|
|
111
|
+
const userDef = (config.entities || {}).Admin || {};
|
|
112
|
+
const merged = {
|
|
113
|
+
...DEFAULT_ADMIN_ENTITY,
|
|
114
|
+
...userDef,
|
|
115
|
+
// Always authenticable
|
|
116
|
+
authenticable: true,
|
|
117
|
+
// Merge properties: user-defined takes precedence (email/password are handled by auth system)
|
|
118
|
+
properties: userDef.properties || DEFAULT_ADMIN_ENTITY.properties,
|
|
119
|
+
};
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildCore(config) {
|
|
124
|
+
const adminCfg = config.admin || {};
|
|
125
|
+
|
|
126
|
+
// Inject the default Admin entity into entities map before building
|
|
127
|
+
const rawEntities = { ...(config.entities || {}) };
|
|
128
|
+
const adminEntityDef = buildAdminEntity(config);
|
|
129
|
+
if (adminEntityDef && !rawEntities.Admin) {
|
|
130
|
+
rawEntities.Admin = adminEntityDef;
|
|
131
|
+
} else if (adminEntityDef && rawEntities.Admin) {
|
|
132
|
+
// Merge: user-defined fields override defaults, but authenticable is always true
|
|
133
|
+
rawEntities.Admin = {
|
|
134
|
+
...DEFAULT_ADMIN_ENTITY,
|
|
135
|
+
...rawEntities.Admin,
|
|
136
|
+
authenticable: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const entities = buildEntities({ ...config, entities: rawEntities });
|
|
141
|
+
|
|
142
|
+
const rateLimits = config.rateLimits || null;
|
|
143
|
+
const telemetry = config.telemetry || null;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
name: config.name,
|
|
147
|
+
database: config.database || null,
|
|
148
|
+
entities,
|
|
149
|
+
authenticableEntities: getAuthenticableEntities(entities),
|
|
150
|
+
functions: config.functions || {},
|
|
151
|
+
groups: config.groups || {},
|
|
152
|
+
plugins: config.plugins || [],
|
|
153
|
+
files: config.files || {},
|
|
154
|
+
public: config.public || null,
|
|
155
|
+
port: parseInt(process.env.CHADSTART_PORT || process.env.PORT || config.port || 3000, 10),
|
|
156
|
+
rateLimits,
|
|
157
|
+
telemetry,
|
|
158
|
+
admin: {
|
|
159
|
+
enable_app: adminCfg.enable_app !== false,
|
|
160
|
+
enable_entity: adminCfg.enable_entity !== false,
|
|
161
|
+
policies: adminCfg.policies || [{ access: 'admin' }],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { buildCore, buildEntities, getAuthenticableEntities, toSnakeCase, toKebabCase };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error Reporting integration for ChadStart.
|
|
5
|
+
*
|
|
6
|
+
* Supports Sentry (https://sentry.io) for automatic exception tracking.
|
|
7
|
+
* To enable, set the SENTRY_DSN environment variable — it is treated as a
|
|
8
|
+
* secret and must NOT be placed in the YAML config file.
|
|
9
|
+
*
|
|
10
|
+
* Non-sensitive settings (environment label, sample rates) can be provided
|
|
11
|
+
* via the `sentry` section of your chadstart.yaml or via environment variables.
|
|
12
|
+
*
|
|
13
|
+
* 💡 Self-hosted alternative: Bugsink (https://www.bugsink.com) is a
|
|
14
|
+
* lightweight, privacy-first alternative to Sentry that is fully
|
|
15
|
+
* compatible with the Sentry SDK. Simply point SENTRY_DSN at your
|
|
16
|
+
* Bugsink instance — no other code changes required.
|
|
17
|
+
*
|
|
18
|
+
* Example chadstart.yaml:
|
|
19
|
+
* sentry:
|
|
20
|
+
* environment: production # optional (defaults to NODE_ENV)
|
|
21
|
+
* tracesSampleRate: 0.2 # optional (default: 1.0)
|
|
22
|
+
* debug: false # optional (default: false)
|
|
23
|
+
*
|
|
24
|
+
* Example .env:
|
|
25
|
+
* SENTRY_DSN=https://xxxxx@oXXXXX.ingest.sentry.io/XXXXXXX
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const logger = require('../utils/logger');
|
|
29
|
+
|
|
30
|
+
let _sentry = null;
|
|
31
|
+
let _initialized = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialise the Sentry SDK if SENTRY_DSN is set.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} core The parsed chadstart config (core object).
|
|
37
|
+
*/
|
|
38
|
+
function initErrorReporter(core) {
|
|
39
|
+
const dsn = process.env.SENTRY_DSN;
|
|
40
|
+
if (!dsn) return;
|
|
41
|
+
|
|
42
|
+
/* istanbul ignore next */
|
|
43
|
+
try {
|
|
44
|
+
_sentry = require('@sentry/node');
|
|
45
|
+
} catch {
|
|
46
|
+
logger.warn('[error-reporter] SENTRY_DSN is set but @sentry/node is not installed. Run: npm install @sentry/node');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sentryConfig = (core && core.sentry) || {};
|
|
51
|
+
|
|
52
|
+
_sentry.init({
|
|
53
|
+
dsn,
|
|
54
|
+
environment: sentryConfig.environment || process.env.NODE_ENV || 'development',
|
|
55
|
+
tracesSampleRate: sentryConfig.tracesSampleRate !== undefined
|
|
56
|
+
? sentryConfig.tracesSampleRate
|
|
57
|
+
: 1.0,
|
|
58
|
+
debug: sentryConfig.debug === true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
_initialized = true;
|
|
62
|
+
logger.info('[error-reporter] Error reporting enabled via Sentry');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns the Sentry Express request handler middleware, or null when Sentry
|
|
67
|
+
* is not configured. Must be added as the *first* middleware in the chain.
|
|
68
|
+
*
|
|
69
|
+
* @returns {import('express').RequestHandler|null}
|
|
70
|
+
*/
|
|
71
|
+
function getRequestHandler() {
|
|
72
|
+
if (!_initialized || !_sentry) return null;
|
|
73
|
+
return _sentry.expressIntegration
|
|
74
|
+
? null // v8+: request capture is automatic
|
|
75
|
+
: _sentry.Handlers.requestHandler();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns the Sentry Express error handler middleware, or null when Sentry
|
|
80
|
+
* is not configured. Must be added *after* all routes and before any other
|
|
81
|
+
* error-handling middleware.
|
|
82
|
+
*
|
|
83
|
+
* For Sentry v8+, use `attachErrorHandler(app)` instead — this function
|
|
84
|
+
* returns null for v8 since error handling is registered via
|
|
85
|
+
* `setupExpressErrorHandler`.
|
|
86
|
+
*
|
|
87
|
+
* @returns {import('express').ErrorRequestHandler|null}
|
|
88
|
+
*/
|
|
89
|
+
function getErrorHandler() {
|
|
90
|
+
if (!_initialized || !_sentry) return null;
|
|
91
|
+
// v8+ registers error handling differently via setupExpressErrorHandler;
|
|
92
|
+
// use attachErrorHandler(app) for v8.
|
|
93
|
+
if (typeof _sentry.setupExpressErrorHandler === 'function') return null;
|
|
94
|
+
/* istanbul ignore next */
|
|
95
|
+
return _sentry.Handlers.errorHandler();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Attach Sentry error-handler middleware to an Express app (v8 compatible).
|
|
100
|
+
* Call this after registering all routes.
|
|
101
|
+
*
|
|
102
|
+
* @param {import('express').Application} app
|
|
103
|
+
*/
|
|
104
|
+
function attachErrorHandler(app) {
|
|
105
|
+
if (!_initialized || !_sentry) return;
|
|
106
|
+
if (typeof _sentry.setupExpressErrorHandler === 'function') {
|
|
107
|
+
_sentry.setupExpressErrorHandler(app);
|
|
108
|
+
} else {
|
|
109
|
+
/* istanbul ignore next */
|
|
110
|
+
app.use(_sentry.Handlers.errorHandler());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Expose the initialised Sentry instance (useful for manual captures). */
|
|
115
|
+
function getSentry() {
|
|
116
|
+
return _initialized ? _sentry : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reset state (used in tests). */
|
|
120
|
+
function _reset() {
|
|
121
|
+
_sentry = null;
|
|
122
|
+
_initialized = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
initErrorReporter,
|
|
127
|
+
getRequestHandler,
|
|
128
|
+
getErrorHandler,
|
|
129
|
+
attachErrorHandler,
|
|
130
|
+
getSentry,
|
|
131
|
+
_reset,
|
|
132
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const logger = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register file storage routes for all buckets defined in core.files.
|
|
10
|
+
* Each bucket exposes:
|
|
11
|
+
* POST /files/<bucket> — upload (multipart/form-data, field name "file")
|
|
12
|
+
* GET /files/<bucket>/:file — download
|
|
13
|
+
*/
|
|
14
|
+
function registerFileRoutes(app, core) {
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
|
|
17
|
+
for (const [bucketName, bucketDef] of Object.entries(core.files)) {
|
|
18
|
+
const bucketPath = path.resolve(bucketDef.path);
|
|
19
|
+
|
|
20
|
+
// Validate that the bucket path stays within the working directory
|
|
21
|
+
if (!bucketPath.startsWith(cwd + path.sep) && bucketPath !== cwd) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`File bucket "${bucketName}" path "${bucketDef.path}" resolves outside the working directory.`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Ensure the upload directory exists
|
|
28
|
+
fs.mkdirSync(bucketPath, { recursive: true });
|
|
29
|
+
|
|
30
|
+
// Serve files statically if public
|
|
31
|
+
if (bucketDef.public !== false) {
|
|
32
|
+
app.use(`/files/${bucketName}`, express.static(bucketPath));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Upload endpoint — uses raw multipart parsing via busboy
|
|
36
|
+
app.post(`/files/${bucketName}`, (req, res) => {
|
|
37
|
+
const contentType = req.headers['content-type'] || '';
|
|
38
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
39
|
+
return res.status(400).json({ error: 'Expected multipart/form-data' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Busboy = getBusboy();
|
|
43
|
+
const busboy = Busboy({ headers: req.headers });
|
|
44
|
+
let saved = false;
|
|
45
|
+
let savedName = null;
|
|
46
|
+
|
|
47
|
+
busboy.on('file', (fieldname, file, info) => {
|
|
48
|
+
const { filename } = info;
|
|
49
|
+
if (!filename) {
|
|
50
|
+
file.resume();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Sanitize filename — strip directory traversal and disallow problematic characters
|
|
54
|
+
const safeName = path
|
|
55
|
+
.basename(filename)
|
|
56
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
57
|
+
.replace(/^\.+/, '_');
|
|
58
|
+
const dest = path.join(bucketPath, safeName);
|
|
59
|
+
const writeStream = fs.createWriteStream(dest);
|
|
60
|
+
file.pipe(writeStream);
|
|
61
|
+
writeStream.on('finish', () => {
|
|
62
|
+
saved = true;
|
|
63
|
+
savedName = safeName;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
busboy.on('finish', () => {
|
|
68
|
+
if (saved) {
|
|
69
|
+
res.json({ file: savedName, url: `/files/${bucketName}/${savedName}` });
|
|
70
|
+
} else {
|
|
71
|
+
res.status(400).json({ error: 'No file field found in upload' });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
busboy.on('error', (err) => {
|
|
76
|
+
logger.error('Upload error', err.message);
|
|
77
|
+
res.status(500).json({ error: err.message });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.pipe(busboy);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
logger.info(` Registered file bucket "${bucketName}" at /files/${bucketName} -> ${bucketPath}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getBusboy() {
|
|
88
|
+
try {
|
|
89
|
+
return require('busboy');
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'busboy is required for file uploads. Install it with: npm install busboy'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { registerFileRoutes };
|