@bhooai/nexus-core 2.0.12 → 2.0.15
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/package.json +3 -2
- package/src/app/Storage.ts +616 -9
- package/src/app/Upload.ts +114 -0
- package/src/app/adminModule.ts +256 -52
- package/src/app/authModule.ts +56 -30
- package/src/app/createNexusApp.ts +199 -32
- package/src/app/databaseAdminModule.ts +171 -2
- package/src/app/discover.ts +4 -2
- package/src/app/errorPages.ts +21 -5
- package/src/app/fusionEngine.ts +53 -0
- package/src/app/index.ts +3 -0
- package/src/app/preflightModule.ts +41 -4
- package/src/app/userStore.ts +321 -0
- package/src/config/dbAccess.ts +26 -0
- package/src/config/defaults.ts +34 -4
- package/src/config/index.ts +1 -0
- package/src/config/runtimeJson.ts +6 -0
- package/src/config/schema.ts +72 -6
- package/src/config/types.ts +94 -1
- package/src/http/Server.ts +27 -3
- package/src/http/static.ts +1 -1
- package/tests/config.test.ts +24 -1
- package/tests/fusion-userstore.test.ts +68 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload — single DB entry per unique image (sha256), multiple logical placements.
|
|
3
|
+
*
|
|
4
|
+
* Physical file is stored once (canonical) + symlinked for duplicates across
|
|
5
|
+
* directories/disks. DB is source of truth for millions of images.
|
|
6
|
+
*
|
|
7
|
+
* Schema: one document per sha256, placements[] holds every logical path.
|
|
8
|
+
* Separate directories (media, avatars, products) are just placements entries.
|
|
9
|
+
*/
|
|
10
|
+
import { Schema, model } from '@bhooai/nexus-data';
|
|
11
|
+
import type { DocumentInstance } from '@bhooai/nexus-data';
|
|
12
|
+
|
|
13
|
+
export interface ImageVariantDoc {
|
|
14
|
+
path: string;
|
|
15
|
+
url: string;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
format: string;
|
|
19
|
+
size: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PlacementDoc {
|
|
24
|
+
id: string;
|
|
25
|
+
disk: string;
|
|
26
|
+
path: string;
|
|
27
|
+
url: string;
|
|
28
|
+
dir: string;
|
|
29
|
+
userId?: string | null;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UploadDoc extends DocumentInstance {
|
|
34
|
+
sha256: string;
|
|
35
|
+
originalName: string;
|
|
36
|
+
mime: string;
|
|
37
|
+
size: number;
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
canonical: { disk: string; path: string; url: string };
|
|
41
|
+
placements: PlacementDoc[];
|
|
42
|
+
variants?: Record<string, ImageVariantDoc>;
|
|
43
|
+
createdAt: Date;
|
|
44
|
+
updatedAt: Date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const uploadSchema = new Schema(
|
|
48
|
+
{
|
|
49
|
+
sha256: { type: String, required: true, unique: true, index: true },
|
|
50
|
+
originalName: { type: String, required: true },
|
|
51
|
+
mime: { type: String, required: true },
|
|
52
|
+
size: { type: Number, required: true },
|
|
53
|
+
width: { type: Number },
|
|
54
|
+
height: { type: Number },
|
|
55
|
+
canonical: { type: Object, required: true }, // {disk, path, url}
|
|
56
|
+
placements: { type: Array, default: [] },
|
|
57
|
+
variants: { type: Object, default: {} },
|
|
58
|
+
createdAt: { type: Date, default: () => new Date() },
|
|
59
|
+
updatedAt: { type: Date, default: () => new Date() },
|
|
60
|
+
},
|
|
61
|
+
{ timestamps: false },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Additional indexes for millions-scale queries + search
|
|
65
|
+
// sha256 unique is already defined via field `unique:true` → index sha256_1 (unique), do not add duplicate
|
|
66
|
+
uploadSchema.indexes.push({ spec: { 'placements.path': 1 }, options: {} });
|
|
67
|
+
uploadSchema.indexes.push({ spec: { mime: 1 }, options: {} });
|
|
68
|
+
uploadSchema.indexes.push({ spec: { createdAt: -1 }, options: {} });
|
|
69
|
+
// Text index for admin search (originalName, mime, sha256) — single text index per collection
|
|
70
|
+
uploadSchema.indexes.push({
|
|
71
|
+
spec: { originalName: 'text', mime: 'text', sha256: 'text' } as any,
|
|
72
|
+
options: { name: 'uploads_text_idx', weights: { originalName: 10, sha256: 5, mime: 2 } as any, default_language: 'english' },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let _cached: any = null;
|
|
76
|
+
|
|
77
|
+
export function getUploadModel(): any {
|
|
78
|
+
if (_cached) return _cached;
|
|
79
|
+
try {
|
|
80
|
+
_cached = model<UploadDoc>('uploads', uploadSchema);
|
|
81
|
+
// Ensure indexes in background (non-blocking)
|
|
82
|
+
void _cached.createIndexes().catch(() => {});
|
|
83
|
+
return _cached;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// Not connected yet — caller should fallback to filesystem-only
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toFileView(doc: any, placementPath?: string) {
|
|
91
|
+
if (!doc) return null;
|
|
92
|
+
const obj = typeof doc.toObject === 'function' ? doc.toObject() : doc;
|
|
93
|
+
const placements: PlacementDoc[] = obj.placements ?? [];
|
|
94
|
+
// Pick placement matching requested path or first
|
|
95
|
+
const placement = placementPath ? placements.find((p: any) => p.path === placementPath) ?? placements[0] : placements[0];
|
|
96
|
+
const variants: Record<string, ImageVariantDoc> = obj.variants ?? {};
|
|
97
|
+
return {
|
|
98
|
+
id: obj.sha256, // single entry id is sha256 (stable dedup)
|
|
99
|
+
_id: obj._id,
|
|
100
|
+
sha256: obj.sha256,
|
|
101
|
+
originalName: obj.originalName,
|
|
102
|
+
mime: obj.mime,
|
|
103
|
+
size: obj.size,
|
|
104
|
+
width: obj.width,
|
|
105
|
+
height: obj.height,
|
|
106
|
+
disk: placement?.disk ?? obj.canonical?.disk,
|
|
107
|
+
path: placement?.path ?? obj.canonical?.path,
|
|
108
|
+
url: placement?.url ?? obj.canonical?.url,
|
|
109
|
+
canonical: obj.canonical,
|
|
110
|
+
placements,
|
|
111
|
+
variants,
|
|
112
|
+
createdAt: obj.createdAt,
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/app/adminModule.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type { Router } from '../http/Router.js';
|
|
|
40
40
|
import type { Middleware, RequestContext } from '../http/context.js';
|
|
41
41
|
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
42
42
|
import { discoverUserConfigPath, frameworkDefaultConfigPath, mergeConfig } from '../config/ConfigLoader.js';
|
|
43
|
+
import { mongoEnabled, mongoUri } from '../config/dbAccess.js';
|
|
43
44
|
import { readRuntimeJson, writeRuntimeJson, mergeRuntimeJson } from '../config/runtimeJson.js';
|
|
44
45
|
import { readEnvFile, writeEnvEntries, writeEnvKey, deleteEnvKey } from '../config/envFile.js';
|
|
45
46
|
import {
|
|
@@ -50,9 +51,11 @@ import {
|
|
|
50
51
|
getProjectInfoCollection,
|
|
51
52
|
upsertProjectInfo,
|
|
52
53
|
deleteProjectInfo,
|
|
53
|
-
ObjectId,
|
|
54
54
|
type ProjectInfo,
|
|
55
55
|
} from '@bhooai/nexus-data';
|
|
56
|
+
import { getUserStore } from './userStore.js';
|
|
57
|
+
import { getAppFusion } from './fusionEngine.js';
|
|
58
|
+
import { activeDatabase } from '../config/dbAccess.js';
|
|
56
59
|
import { ROLE_CATALOG, findRole } from './roleCatalog.js';
|
|
57
60
|
import { registerPreflightRoutes, aiHealthUrl } from './preflightModule.js';
|
|
58
61
|
import { registerLintRoutes } from './lintModule.js';
|
|
@@ -140,7 +143,10 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
140
143
|
// Open the shared project-info connection so /admin/config, /admin/project
|
|
141
144
|
// and the payment provider persistence can read/write the registry. Failure
|
|
142
145
|
// is non-fatal — the affected endpoints degrade gracefully.
|
|
143
|
-
|
|
146
|
+
// Skipped when mongodb is disabled (fusion-active backends).
|
|
147
|
+
if (mongoEnabled(config)) {
|
|
148
|
+
try { connectProjectInfo(mongoUri(config)); } catch { /* Mongo may be down */ }
|
|
149
|
+
}
|
|
144
150
|
|
|
145
151
|
// ------------------------------------------------------------------
|
|
146
152
|
// /admin/supervisor — report where `nexus dev`'s control API is bound so the
|
|
@@ -162,8 +168,8 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
162
168
|
// /admin/health/services — TCP probes for infra services
|
|
163
169
|
// ------------------------------------------------------------------
|
|
164
170
|
router.get('/admin/health/services', async (ctx) => {
|
|
165
|
-
const mongoHost = hostOf(config
|
|
166
|
-
const mongoPort = portOf(config
|
|
171
|
+
const mongoHost = hostOf(mongoUri(config), 'localhost');
|
|
172
|
+
const mongoPort = portOf(mongoUri(config), 27017);
|
|
167
173
|
const redisHost = hostOf(config.redis.url, 'localhost');
|
|
168
174
|
const redisPort = portOf(config.redis.url, 6379);
|
|
169
175
|
const aiPort = portOf(config.ai.serverUrl, 8000);
|
|
@@ -185,12 +191,12 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
185
191
|
});
|
|
186
192
|
|
|
187
193
|
// ------------------------------------------------------------------
|
|
188
|
-
// /admin/apps —
|
|
194
|
+
// /admin/apps — nexus.config.ts `apps[]` + per-app /health probe
|
|
189
195
|
// ------------------------------------------------------------------
|
|
190
196
|
router.get('/admin/apps', async (ctx) => {
|
|
191
|
-
const
|
|
197
|
+
const entries = (config.apps ?? []).filter((a) => a.enabled !== false);
|
|
192
198
|
const apps = await Promise.all(
|
|
193
|
-
|
|
199
|
+
entries.map(async ({ name: appName, port }) => {
|
|
194
200
|
const kind = kindOf(appName);
|
|
195
201
|
const probe = await probeHealth(port, kind);
|
|
196
202
|
return {
|
|
@@ -219,6 +225,42 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
219
225
|
ctx.json({ ok: true });
|
|
220
226
|
});
|
|
221
227
|
|
|
228
|
+
// ------------------------------------------------------------------
|
|
229
|
+
// /admin/certs — TLS cert status + self-signed generation
|
|
230
|
+
// ------------------------------------------------------------------
|
|
231
|
+
router.get('/admin/certs/status', async (ctx) => {
|
|
232
|
+
try {
|
|
233
|
+
const { getCertStatus } = await import('../certs/generator.js');
|
|
234
|
+
ctx.json(await getCertStatus(projectRoot, config));
|
|
235
|
+
} catch (err) {
|
|
236
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
router.post('/admin/certs/generate', async (ctx) => {
|
|
241
|
+
const body = (ctx.body ?? {}) as { force?: unknown; keyType?: unknown; rsaModulus?: unknown; validityDays?: unknown; commonName?: unknown };
|
|
242
|
+
const force = body.force === true;
|
|
243
|
+
const keyType = body.keyType === 'ec' || body.keyType === 'rsa' ? (body.keyType as 'ec' | 'rsa') : undefined;
|
|
244
|
+
const rsaModulus = typeof body.rsaModulus === 'number' && Number.isFinite(body.rsaModulus) ? body.rsaModulus : undefined;
|
|
245
|
+
const validityDays = typeof body.validityDays === 'number' && Number.isFinite(body.validityDays) ? body.validityDays : undefined;
|
|
246
|
+
const commonName = typeof body.commonName === 'string' && body.commonName.trim() ? body.commonName.trim() : undefined;
|
|
247
|
+
try {
|
|
248
|
+
const { generateSelfSignedCerts, getCertStatus } = await import('../certs/generator.js');
|
|
249
|
+
const result = await generateSelfSignedCerts(projectRoot, config, { force, keyType, rsaModulus, validityDays, commonName });
|
|
250
|
+
// Persist the generated paths so the next boot can use them when https is toggled on
|
|
251
|
+
try {
|
|
252
|
+
await mergeRuntimeJson(projectRoot, { server: { certFile: result.certFile, keyFile: result.keyFile } });
|
|
253
|
+
// keep in-memory config in sync so status reflects new files without restart
|
|
254
|
+
(config.server as unknown as Record<string, unknown>).certFile = result.certFile;
|
|
255
|
+
(config.server as unknown as Record<string, unknown>).keyFile = result.keyFile;
|
|
256
|
+
} catch { /* non-fatal */ }
|
|
257
|
+
const status = await getCertStatus(projectRoot, config);
|
|
258
|
+
ctx.json({ ok: true, method: result.method, overwritten: result.overwritten, certFile: result.certFile, keyFile: result.keyFile, status });
|
|
259
|
+
} catch (err) {
|
|
260
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
222
264
|
// ------------------------------------------------------------------
|
|
223
265
|
// /admin/config — read-only, redacted (+ runtime overrides, config file, project)
|
|
224
266
|
// ------------------------------------------------------------------
|
|
@@ -238,16 +280,11 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
238
280
|
});
|
|
239
281
|
|
|
240
282
|
// ------------------------------------------------------------------
|
|
241
|
-
// /admin/users + /admin/payments —
|
|
283
|
+
// /admin/users + /admin/payments — via the user store / orders collection
|
|
242
284
|
// ------------------------------------------------------------------
|
|
243
285
|
router.get('/admin/users', async (ctx) => {
|
|
244
|
-
const db = await lazy.db();
|
|
245
|
-
if (!db) {
|
|
246
|
-
ctx.json({ ok: false, reason: 'MongoDB not reachable' });
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
286
|
try {
|
|
250
|
-
const users = await
|
|
287
|
+
const users = await getUserStore().list(200);
|
|
251
288
|
ctx.json({
|
|
252
289
|
ok: true,
|
|
253
290
|
users: users.map((u) => ({
|
|
@@ -266,24 +303,11 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
266
303
|
});
|
|
267
304
|
|
|
268
305
|
router.get('/admin/payments', async (ctx) => {
|
|
269
|
-
const db = await lazy.db();
|
|
270
|
-
if (!db) {
|
|
271
|
-
ctx.json({ ok: false, reason: 'MongoDB not reachable' });
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
306
|
try {
|
|
275
|
-
const orders = await
|
|
307
|
+
const orders = await listOrders(config, lazy, 50);
|
|
276
308
|
ctx.json({
|
|
277
309
|
ok: true,
|
|
278
|
-
orders
|
|
279
|
-
id: String(o._id),
|
|
280
|
-
userId: o.userId,
|
|
281
|
-
provider: o.provider,
|
|
282
|
-
amount: o.amount,
|
|
283
|
-
currency: o.currency,
|
|
284
|
-
status: o.status,
|
|
285
|
-
createdAt: o.createdAt,
|
|
286
|
-
})),
|
|
310
|
+
orders,
|
|
287
311
|
providers: providerStatus(config),
|
|
288
312
|
});
|
|
289
313
|
} catch (err) {
|
|
@@ -569,8 +593,6 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
569
593
|
});
|
|
570
594
|
|
|
571
595
|
router.put('/admin/users/:id', async (ctx) => {
|
|
572
|
-
const db = await lazy.db();
|
|
573
|
-
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
574
596
|
const body = (ctx.body ?? {}) as { roles?: unknown };
|
|
575
597
|
const incoming = body.roles;
|
|
576
598
|
if (!Array.isArray(incoming) || !incoming.length || incoming.some((r) => typeof r !== 'string')) {
|
|
@@ -585,24 +607,23 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
585
607
|
}
|
|
586
608
|
|
|
587
609
|
const id = String(ctx.params.id ?? '');
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const target = await
|
|
610
|
+
if (!id) { ctx.json({ error: 'invalid user id' }, 400); return; }
|
|
611
|
+
const store = getUserStore();
|
|
612
|
+
const target = await store.findById(id);
|
|
591
613
|
if (!target) { ctx.json({ error: 'user not found' }, 404); return; }
|
|
592
614
|
|
|
593
|
-
const currentRoles =
|
|
615
|
+
const currentRoles = target.roles ?? [];
|
|
594
616
|
const isAdmin = currentRoles.includes('admin');
|
|
595
617
|
const keepsAdmin = nextRoles.includes('admin');
|
|
596
618
|
const caller = ctx.state.user as { id?: string; roles?: string[] } | undefined;
|
|
597
619
|
|
|
598
620
|
if (isAdmin && !keepsAdmin) {
|
|
599
|
-
const adminCount = await
|
|
621
|
+
const adminCount = await store.countAdmins();
|
|
600
622
|
if (adminCount <= 1) { ctx.json({ error: 'cannot remove the last admin from the system' }, 400); return; }
|
|
601
623
|
if (caller?.id === id) { ctx.json({ error: 'you cannot remove your own admin role' }, 400); return; }
|
|
602
624
|
}
|
|
603
625
|
|
|
604
|
-
await
|
|
605
|
-
const updated = await db.collection('users').findOne({ _id: oid });
|
|
626
|
+
const updated = await store.setRoles(id, nextRoles);
|
|
606
627
|
ctx.json({ ok: true, user: updated });
|
|
607
628
|
});
|
|
608
629
|
|
|
@@ -856,6 +877,163 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
856
877
|
ctx.json({ series: { range: r, points } });
|
|
857
878
|
});
|
|
858
879
|
|
|
880
|
+
// ------------------------------------------------------------------
|
|
881
|
+
// Uploads — search index admin (millions-scale)
|
|
882
|
+
// ------------------------------------------------------------------
|
|
883
|
+
router.get('/admin/uploads', async (ctx) => {
|
|
884
|
+
const q = typeof ctx.query.q === 'string' ? ctx.query.q.trim() : '';
|
|
885
|
+
const mime = typeof ctx.query.mime === 'string' ? ctx.query.mime.trim() : '';
|
|
886
|
+
const page = Math.max(1, parseInt(String(ctx.query.page ?? '1'), 10) || 1);
|
|
887
|
+
const limit = clampInt(ctx.query.limit, 50);
|
|
888
|
+
const sort = typeof ctx.query.sort === 'string' ? ctx.query.sort : 'recent';
|
|
889
|
+
const skip = (page - 1) * limit;
|
|
890
|
+
try {
|
|
891
|
+
const { getUploadModel } = await import('./Upload.js');
|
|
892
|
+
const M = getUploadModel();
|
|
893
|
+
if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
|
|
894
|
+
const filter: Record<string, unknown> = {};
|
|
895
|
+
if (mime) filter.mime = mime;
|
|
896
|
+
let cursor: any;
|
|
897
|
+
let useText = false;
|
|
898
|
+
if (q) {
|
|
899
|
+
// Try text search first, fallback to regex if no index or no results
|
|
900
|
+
const textFilter = { ...filter, $text: { $search: q } };
|
|
901
|
+
const countText = await M.find(textFilter).countDocuments().catch(() => 0);
|
|
902
|
+
if (countText > 0) {
|
|
903
|
+
filter.$text = { $search: q };
|
|
904
|
+
useText = true;
|
|
905
|
+
} else {
|
|
906
|
+
const rx = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
907
|
+
filter.$or = [{ originalName: rx }, { sha256: rx }, { mime: rx }, { 'placements.dir': rx }, { 'placements.path': rx }];
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const sortSpec: Record<string, 1 | -1> = sort === 'oldest' ? { createdAt: 1 } : sort === 'size' ? { size: -1 } : sort === 'name' ? { originalName: 1 } : { createdAt: -1 };
|
|
911
|
+
if (useText) (sortSpec as any).score = { $meta: 'textScore' } as any;
|
|
912
|
+
const docs = await M.find(filter).sort(sortSpec as any).skip(skip).limit(limit).exec().catch(async () => {
|
|
913
|
+
// Fallback if $text fails (no index)
|
|
914
|
+
const fallbackFilter: Record<string, unknown> = { ...filter };
|
|
915
|
+
if (fallbackFilter.$text) delete fallbackFilter.$text;
|
|
916
|
+
if (q && !fallbackFilter.$or) {
|
|
917
|
+
const rx = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
918
|
+
fallbackFilter.$or = [{ originalName: rx }, { sha256: rx }, { mime: rx }];
|
|
919
|
+
}
|
|
920
|
+
return M.find(fallbackFilter).sort({ createdAt: -1 }).skip(skip).limit(limit).exec();
|
|
921
|
+
});
|
|
922
|
+
const total = await M.countDocuments(filter).catch(() => docs.length);
|
|
923
|
+
ctx.json({
|
|
924
|
+
ok: true,
|
|
925
|
+
page,
|
|
926
|
+
limit,
|
|
927
|
+
total,
|
|
928
|
+
pages: Math.ceil(total / limit),
|
|
929
|
+
q,
|
|
930
|
+
mime,
|
|
931
|
+
sort,
|
|
932
|
+
uploads: docs.map((d: any) => {
|
|
933
|
+
const o = typeof d.toObject === 'function' ? d.toObject() : d;
|
|
934
|
+
const p = o.placements?.[0] ?? o.canonical;
|
|
935
|
+
return {
|
|
936
|
+
id: o.sha256,
|
|
937
|
+
sha256: o.sha256,
|
|
938
|
+
originalName: o.originalName,
|
|
939
|
+
mime: o.mime,
|
|
940
|
+
size: o.size,
|
|
941
|
+
width: o.width,
|
|
942
|
+
height: o.height,
|
|
943
|
+
disk: p?.disk ?? o.canonical?.disk,
|
|
944
|
+
path: p?.path ?? o.canonical?.path,
|
|
945
|
+
url: p?.url ?? o.canonical?.url,
|
|
946
|
+
canonical: o.canonical,
|
|
947
|
+
placements: o.placements,
|
|
948
|
+
placementsCount: o.placements?.length ?? 0,
|
|
949
|
+
variants: o.variants,
|
|
950
|
+
createdAt: o.createdAt,
|
|
951
|
+
};
|
|
952
|
+
}),
|
|
953
|
+
});
|
|
954
|
+
} catch (err) {
|
|
955
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
router.get('/admin/uploads/stats', async (ctx) => {
|
|
960
|
+
try {
|
|
961
|
+
const { getUploadModel } = await import('./Upload.js');
|
|
962
|
+
const M = getUploadModel();
|
|
963
|
+
if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
|
|
964
|
+
const [total, totalSizeAgg, mimeAgg, recent] = await Promise.all([
|
|
965
|
+
M.countDocuments({}),
|
|
966
|
+
M.aggregate([{ $group: { _id: null, totalSize: { $sum: '$size' } } }]).catch(() => []),
|
|
967
|
+
M.aggregate([{ $group: { _id: '$mime', count: { $sum: 1 } } }, { $sort: { count: -1 } }, { $limit: 10 }]).catch(() => []),
|
|
968
|
+
M.find({}).sort({ createdAt: -1 }).limit(5).exec().catch(() => []),
|
|
969
|
+
]);
|
|
970
|
+
const totalSize = (totalSizeAgg[0] as any)?.totalSize ?? 0;
|
|
971
|
+
ctx.json({
|
|
972
|
+
ok: true,
|
|
973
|
+
total,
|
|
974
|
+
totalSize,
|
|
975
|
+
mimeBreakdown: mimeAgg,
|
|
976
|
+
recent: (recent as any[]).map((d: any) => {
|
|
977
|
+
const o = typeof d.toObject === 'function' ? d.toObject() : d;
|
|
978
|
+
return { sha256: o.sha256, originalName: o.originalName, mime: o.mime, size: o.size, createdAt: o.createdAt };
|
|
979
|
+
}),
|
|
980
|
+
});
|
|
981
|
+
} catch (err) {
|
|
982
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
router.get('/admin/uploads/index', async (ctx) => {
|
|
987
|
+
try {
|
|
988
|
+
const { getUploadModel } = await import('./Upload.js');
|
|
989
|
+
const M = getUploadModel();
|
|
990
|
+
if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
|
|
991
|
+
const coll = await (M as any).collection;
|
|
992
|
+
const indexes = await coll.indexes().catch(() => []);
|
|
993
|
+
const hasText = indexes.some((idx: any) => idx.name === 'uploads_text_idx');
|
|
994
|
+
const hasSha256 = indexes.some((idx: any) => idx.key?.sha256);
|
|
995
|
+
ctx.json({ ok: true, indexes, hasText, hasSha256, count: await M.countDocuments({}).catch(() => 0) });
|
|
996
|
+
} catch (err) {
|
|
997
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
router.post('/admin/uploads/index/rebuild', async (ctx) => {
|
|
1002
|
+
try {
|
|
1003
|
+
const { getUploadModel } = await import('./Upload.js');
|
|
1004
|
+
const M = getUploadModel();
|
|
1005
|
+
if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
|
|
1006
|
+
const coll = await (M as any).collection;
|
|
1007
|
+
// Drop existing text index if present, then recreate all
|
|
1008
|
+
try {
|
|
1009
|
+
await coll.dropIndex('uploads_text_idx');
|
|
1010
|
+
} catch {}
|
|
1011
|
+
await (M as any).createIndexes().catch(() => {});
|
|
1012
|
+
// Ensure text index
|
|
1013
|
+
try {
|
|
1014
|
+
await coll.createIndex({ originalName: 'text', mime: 'text', sha256: 'text', 'placements.dir': 'text' } as any, { name: 'uploads_text_idx', weights: { originalName: 10, sha256: 5, mime: 2 } } as any);
|
|
1015
|
+
} catch {}
|
|
1016
|
+
const indexes = await coll.indexes().catch(() => []);
|
|
1017
|
+
ctx.json({ ok: true, indexes });
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
router.post('/admin/uploads/reindex', async (ctx) => {
|
|
1024
|
+
// Alias for rebuild
|
|
1025
|
+
try {
|
|
1026
|
+
const { getUploadModel } = await import('./Upload.js');
|
|
1027
|
+
const M = getUploadModel();
|
|
1028
|
+
if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
|
|
1029
|
+
const coll = await (M as any).collection;
|
|
1030
|
+
await (M as any).createIndexes().catch(() => {});
|
|
1031
|
+
ctx.json({ ok: true, reindexed: true, count: await M.countDocuments({}).catch(() => 0) });
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
859
1037
|
// ------------------------------------------------------------------
|
|
860
1038
|
// Wire the rest of the admin surface modules (guarded via /admin).
|
|
861
1039
|
// ------------------------------------------------------------------
|
|
@@ -866,7 +1044,7 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
866
1044
|
});
|
|
867
1045
|
registerPreflightRoutes(router, config, { graphqlMounted: opts.graphqlMounted });
|
|
868
1046
|
registerLintRoutes(router, config, projectRoot);
|
|
869
|
-
registerDatabaseAdminRoutes(router, lazy);
|
|
1047
|
+
registerDatabaseAdminRoutes(router, lazy, activeDatabase(config));
|
|
870
1048
|
registerAiSchemaRoutes(router, {
|
|
871
1049
|
serverUrl: config.ai.serverUrl,
|
|
872
1050
|
timeoutMs: config.ai.timeoutMs,
|
|
@@ -909,16 +1087,6 @@ async function probeHealth(port: number, kind: string): Promise<{ ok: boolean; u
|
|
|
909
1087
|
}
|
|
910
1088
|
}
|
|
911
1089
|
|
|
912
|
-
async function readRegistry(projectRoot: string): Promise<Record<string, number>> {
|
|
913
|
-
const p = resolve(projectRoot, '.nexus-ports.json');
|
|
914
|
-
if (!existsSync(p)) return {};
|
|
915
|
-
try {
|
|
916
|
-
return JSON.parse(await readFile(p, 'utf-8')) as Record<string, number>;
|
|
917
|
-
} catch {
|
|
918
|
-
return {};
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
1090
|
function hostOf(uri: string, fallback: string): string {
|
|
923
1091
|
try {
|
|
924
1092
|
const u = new URL(uri);
|
|
@@ -978,15 +1146,51 @@ function providerEnvKey(providerId: string): string {
|
|
|
978
1146
|
return `NEXUS_AI_${providerId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
|
|
979
1147
|
}
|
|
980
1148
|
|
|
1149
|
+
/** Orders for /admin/payments — Fusion `orders` collection or legacy Mongo. */
|
|
1150
|
+
async function listOrders(
|
|
1151
|
+
config: NexusConfig,
|
|
1152
|
+
lazy: LazyDb,
|
|
1153
|
+
limit: number,
|
|
1154
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
1155
|
+
if (activeDatabase(config) === 'fusion') {
|
|
1156
|
+
const snap = await getAppFusion()
|
|
1157
|
+
.collection<Record<string, unknown>>('orders')
|
|
1158
|
+
.orderByField('createdAt', 'desc')
|
|
1159
|
+
.limit(limit)
|
|
1160
|
+
.get();
|
|
1161
|
+
return snap.docs.map((d) => ({
|
|
1162
|
+
id: d.id,
|
|
1163
|
+
userId: d.data.userId,
|
|
1164
|
+
provider: d.data.provider,
|
|
1165
|
+
amount: d.data.amount,
|
|
1166
|
+
currency: d.data.currency,
|
|
1167
|
+
status: d.data.status,
|
|
1168
|
+
createdAt: d.data.createdAt,
|
|
1169
|
+
}));
|
|
1170
|
+
}
|
|
1171
|
+
const db = await lazy.db();
|
|
1172
|
+
if (!db) throw new Error('MongoDB not reachable');
|
|
1173
|
+
const orders = await db.collection('orders').find({}).sort({ createdAt: -1 }).limit(limit).toArray();
|
|
1174
|
+
return orders.map((o) => ({
|
|
1175
|
+
id: String(o._id),
|
|
1176
|
+
userId: o.userId,
|
|
1177
|
+
provider: o.provider,
|
|
1178
|
+
amount: o.amount,
|
|
1179
|
+
currency: o.currency,
|
|
1180
|
+
status: o.status,
|
|
1181
|
+
createdAt: o.createdAt,
|
|
1182
|
+
}));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
981
1185
|
/** Convenience for wiring: create a lazy Mongo db resolver from a config. */
|
|
982
|
-
export function createLazyDb(config: NexusConfig): LazyDb {
|
|
983
|
-
let cached: import('mongodb').Db | null | undefined;
|
|
1186
|
+
export function createLazyDb(config: NexusConfig): LazyDb { let cached: import('mongodb').Db | null | undefined;
|
|
984
1187
|
return {
|
|
985
1188
|
async db() {
|
|
986
1189
|
if (cached !== undefined) return cached;
|
|
1190
|
+
if (!mongoEnabled(config)) { cached = null; return null; }
|
|
987
1191
|
try {
|
|
988
1192
|
const { connect } = await import('@bhooai/nexus-data');
|
|
989
|
-
const connection = connect(config
|
|
1193
|
+
const connection = connect(mongoUri(config), { maxPoolSize: 2 });
|
|
990
1194
|
cached = await connection.db;
|
|
991
1195
|
return cached;
|
|
992
1196
|
} catch (err) {
|