@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha.2
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/dist/index.d.ts +1584 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1806 -0
- package/dist/index.js.map +1 -0
- package/package.json +26 -11
- package/prisma/migrations/20250526183023_init/migration.sql +71 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +121 -0
- package/src/db/client.ts +34 -0
- package/src/db/index.ts +17 -0
- package/src/db/syncAppCatalog.ts +67 -0
- package/src/db/tableSyncMagazine.ts +22 -0
- package/src/db/tableSyncPrismaAdapter.ts +202 -0
- package/src/index.ts +96 -3
- package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
- package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
- package/src/modules/appCatalog/service.ts +79 -0
- package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +113 -0
- package/src/modules/assets/assetRestController.ts +309 -0
- package/src/modules/assets/assetUtils.ts +81 -0
- package/src/modules/assets/screenshotRestController.ts +195 -0
- package/src/modules/assets/screenshotRouter.ts +116 -0
- package/src/modules/assets/syncAssets.ts +261 -0
- package/src/modules/auth/auth.ts +51 -0
- package/src/modules/auth/authProviders.ts +108 -0
- package/src/modules/auth/authRouter.ts +77 -0
- package/src/modules/auth/authorizationUtils.ts +114 -0
- package/src/modules/auth/registerAuthRoutes.ts +33 -0
- package/src/modules/icons/iconRestController.ts +190 -0
- package/src/modules/icons/iconRouter.ts +157 -0
- package/src/modules/icons/iconService.ts +73 -0
- package/src/server/controller.ts +102 -29
- package/src/server/ehStaticControllerContract.ts +8 -1
- package/src/server/ehTrpcContext.ts +0 -6
- package/src/types/backend/api.ts +1 -14
- package/src/types/backend/companySpecificBackend.ts +17 -0
- package/src/types/common/appCatalogTypes.ts +167 -0
- package/src/types/common/dataRootTypes.ts +72 -10
- package/src/types/index.ts +2 -0
- package/dist/esm/__tests__/dummy.test.d.ts +0 -1
- package/dist/esm/index.d.ts +0 -7
- package/dist/esm/index.js +0 -9
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/server/controller.d.ts +0 -32
- package/dist/esm/server/controller.js +0 -35
- package/dist/esm/server/controller.js.map +0 -1
- package/dist/esm/server/db.d.ts +0 -2
- package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
- package/dist/esm/server/ehStaticControllerContract.js +0 -12
- package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
- package/dist/esm/server/ehTrpcContext.d.ts +0 -8
- package/dist/esm/server/ehTrpcContext.js +0 -11
- package/dist/esm/server/ehTrpcContext.js.map +0 -1
- package/dist/esm/types/backend/api.d.ts +0 -71
- package/dist/esm/types/backend/common.d.ts +0 -9
- package/dist/esm/types/backend/dataSources.d.ts +0 -20
- package/dist/esm/types/backend/deployments.d.ts +0 -34
- package/dist/esm/types/common/app/appTypes.d.ts +0 -12
- package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
- package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
- package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
- package/dist/esm/types/common/env/envTypes.d.ts +0 -6
- package/dist/esm/types/common/resourceTypes.d.ts +0 -8
- package/dist/esm/types/common/sharedTypes.d.ts +0 -4
- package/dist/esm/types/index.d.ts +0 -11
- package/src/server/db.ts +0 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1806 @@
|
|
|
1
|
+
import { initTRPC } from "@trpc/server";
|
|
2
|
+
import z$1, { z } from "zod";
|
|
3
|
+
import { PrismaClient } from "@prisma/client";
|
|
4
|
+
import { tableSync } from "@env-hopper/table-sync";
|
|
5
|
+
import { mapValues, omit, pick } from "radashi";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import sharp from "sharp";
|
|
8
|
+
import { betterAuth } from "better-auth";
|
|
9
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
10
|
+
import { toNodeHandler } from "better-auth/node";
|
|
11
|
+
import { genericOAuth, okta } from "better-auth/plugins";
|
|
12
|
+
import { stepCountIs, streamText, tool } from "ai";
|
|
13
|
+
import multer from "multer";
|
|
14
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { extname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
//#region src/db/client.ts
|
|
18
|
+
let prismaClient = null;
|
|
19
|
+
/**
|
|
20
|
+
* Gets the internal Prisma client instance.
|
|
21
|
+
* Creates one if it doesn't exist.
|
|
22
|
+
*/
|
|
23
|
+
function getDbClient() {
|
|
24
|
+
if (!prismaClient) prismaClient = new PrismaClient();
|
|
25
|
+
return prismaClient;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Connects to the database.
|
|
29
|
+
* Call this before performing database operations.
|
|
30
|
+
*/
|
|
31
|
+
async function connectDb() {
|
|
32
|
+
await getDbClient().$connect();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Disconnects from the database.
|
|
36
|
+
* Call this when done with database operations (e.g., in scripts).
|
|
37
|
+
*/
|
|
38
|
+
async function disconnectDb() {
|
|
39
|
+
if (prismaClient) {
|
|
40
|
+
await prismaClient.$disconnect();
|
|
41
|
+
prismaClient = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/modules/appCatalog/service.ts
|
|
47
|
+
function capitalize(word) {
|
|
48
|
+
if (!word) return word;
|
|
49
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
50
|
+
}
|
|
51
|
+
async function getAppsFromPrisma() {
|
|
52
|
+
return (await getDbClient().dbAppForCatalog.findMany()).map((row) => {
|
|
53
|
+
const access = row.access;
|
|
54
|
+
const roles = row.roles == null ? void 0 : row.roles;
|
|
55
|
+
const teams = row.teams ?? [];
|
|
56
|
+
const tags = row.tags ?? [];
|
|
57
|
+
const screenshotIds = row.screenshotIds ?? [];
|
|
58
|
+
const notes = row.notes == null ? void 0 : row.notes;
|
|
59
|
+
const appUrl = row.appUrl == null ? void 0 : row.appUrl;
|
|
60
|
+
const iconName = row.iconName == null ? void 0 : row.iconName;
|
|
61
|
+
return {
|
|
62
|
+
id: row.id,
|
|
63
|
+
slug: row.slug,
|
|
64
|
+
displayName: row.displayName,
|
|
65
|
+
description: row.description,
|
|
66
|
+
access,
|
|
67
|
+
teams,
|
|
68
|
+
roles,
|
|
69
|
+
approver: row.approverName && row.approverEmail ? {
|
|
70
|
+
name: row.approverName,
|
|
71
|
+
email: row.approverEmail
|
|
72
|
+
} : void 0,
|
|
73
|
+
notes,
|
|
74
|
+
tags,
|
|
75
|
+
appUrl,
|
|
76
|
+
iconName,
|
|
77
|
+
screenshotIds
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function deriveCategories(apps) {
|
|
82
|
+
const tagSet = /* @__PURE__ */ new Set();
|
|
83
|
+
for (const app of apps) for (const tag of app.tags ?? []) {
|
|
84
|
+
const normalized = tag.trim().toLowerCase();
|
|
85
|
+
if (normalized) tagSet.add(normalized);
|
|
86
|
+
}
|
|
87
|
+
const categories = [{
|
|
88
|
+
id: "all",
|
|
89
|
+
name: "All"
|
|
90
|
+
}];
|
|
91
|
+
for (const tag of Array.from(tagSet).sort()) categories.push({
|
|
92
|
+
id: tag,
|
|
93
|
+
name: capitalize(tag)
|
|
94
|
+
});
|
|
95
|
+
return categories;
|
|
96
|
+
}
|
|
97
|
+
async function getAppCatalogData(getAppsOptional) {
|
|
98
|
+
const apps = getAppsOptional ? await getAppsOptional() : await getAppsFromPrisma();
|
|
99
|
+
return {
|
|
100
|
+
apps,
|
|
101
|
+
categories: deriveCategories(apps)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/db/tableSyncPrismaAdapter.ts
|
|
107
|
+
function getPrismaModelOperations(prisma, prismaModelName) {
|
|
108
|
+
return prisma[prismaModelName.slice(0, 1).toLowerCase() + prismaModelName.slice(1)];
|
|
109
|
+
}
|
|
110
|
+
function tableSyncPrisma(params) {
|
|
111
|
+
const { prisma, prismaModelName, uniqColumns, where: whereGlobal, upsertOnly } = params;
|
|
112
|
+
const prismOperations = getPrismaModelOperations(prisma, prismaModelName);
|
|
113
|
+
return tableSync({
|
|
114
|
+
id: "id",
|
|
115
|
+
uniqColumns,
|
|
116
|
+
readAll: async () => {
|
|
117
|
+
const findManyArgs = whereGlobal ? { where: whereGlobal } : {};
|
|
118
|
+
return await prismOperations.findMany(findManyArgs);
|
|
119
|
+
},
|
|
120
|
+
writeAll: async (createData, update, deleteIds) => {
|
|
121
|
+
const prismaUniqKey = params.uniqColumns.join("_");
|
|
122
|
+
const relationColumnList = params.relationColumns ?? [];
|
|
123
|
+
return prisma.$transaction(async (tx) => {
|
|
124
|
+
const txOps = getPrismaModelOperations(tx, prismaModelName);
|
|
125
|
+
for (const { data, where } of update) {
|
|
126
|
+
const uniqKeyWhere = Object.keys(where).length > 1 ? { [prismaUniqKey]: where } : where;
|
|
127
|
+
const dataScalar = omit(data, relationColumnList);
|
|
128
|
+
const dataRelations = mapValues(pick(data, relationColumnList), (value) => {
|
|
129
|
+
return { set: value };
|
|
130
|
+
});
|
|
131
|
+
await txOps.update({
|
|
132
|
+
data: {
|
|
133
|
+
...dataScalar,
|
|
134
|
+
...dataRelations
|
|
135
|
+
},
|
|
136
|
+
where: { ...uniqKeyWhere }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (upsertOnly !== true) await txOps.deleteMany({ where: { id: { in: deleteIds } } });
|
|
140
|
+
const createDataMapped = createData.map((data) => {
|
|
141
|
+
const dataScalar = omit(data, relationColumnList);
|
|
142
|
+
const dataRelations = mapValues(pick(data, relationColumnList), (value) => {
|
|
143
|
+
return { connect: value };
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
...dataScalar,
|
|
147
|
+
...dataRelations
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
if (createDataMapped.length > 0) {
|
|
151
|
+
const uniqKeysInCreate = /* @__PURE__ */ new Set();
|
|
152
|
+
const duplicateKeys = [];
|
|
153
|
+
for (const data of createDataMapped) {
|
|
154
|
+
const key = params.uniqColumns.map((col) => {
|
|
155
|
+
const value = data[col];
|
|
156
|
+
return value === null || value === void 0 ? "null" : String(value);
|
|
157
|
+
}).join(":");
|
|
158
|
+
if (uniqKeysInCreate.has(key)) duplicateKeys.push(key);
|
|
159
|
+
else uniqKeysInCreate.add(key);
|
|
160
|
+
}
|
|
161
|
+
if (duplicateKeys.length > 0) {
|
|
162
|
+
const uniqColumnsStr = params.uniqColumns.join(", ");
|
|
163
|
+
throw new Error(`Duplicate unique key values found in data to be created. Model: ${prismaModelName}, Unique columns: [${uniqColumnsStr}], Duplicate keys: [${duplicateKeys.join(", ")}]`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const results = [];
|
|
167
|
+
if (relationColumnList.length === 0) {
|
|
168
|
+
const batchResult = await txOps.createManyAndReturn({ data: createDataMapped });
|
|
169
|
+
results.push(...batchResult);
|
|
170
|
+
} else for (const dataMappedElement of createDataMapped) {
|
|
171
|
+
const newVar = await txOps.create({ data: dataMappedElement });
|
|
172
|
+
results.push(newVar);
|
|
173
|
+
}
|
|
174
|
+
return results;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/db/tableSyncMagazine.ts
|
|
182
|
+
const TABLE_SYNC_MAGAZINE = { DbAppForCatalog: {
|
|
183
|
+
prismaModelName: "DbAppForCatalog",
|
|
184
|
+
uniqColumns: ["slug"]
|
|
185
|
+
} };
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/db/syncAppCatalog.ts
|
|
189
|
+
/**
|
|
190
|
+
* Syncs app catalog data to the database using table sync.
|
|
191
|
+
* This will create new apps, update existing ones, and delete any that are no longer in the input.
|
|
192
|
+
*
|
|
193
|
+
* Note: Call connectDb() before and disconnectDb() after if running in a script.
|
|
194
|
+
*/
|
|
195
|
+
async function syncAppCatalog(apps) {
|
|
196
|
+
const sync = tableSyncPrisma({
|
|
197
|
+
prisma: getDbClient(),
|
|
198
|
+
...TABLE_SYNC_MAGAZINE.DbAppForCatalog
|
|
199
|
+
});
|
|
200
|
+
const dbApps = apps.map((app) => {
|
|
201
|
+
var _app$approver, _app$approver2;
|
|
202
|
+
return {
|
|
203
|
+
slug: app.slug || app.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
|
|
204
|
+
displayName: app.displayName,
|
|
205
|
+
description: app.description,
|
|
206
|
+
access: app.access,
|
|
207
|
+
teams: app.teams ?? [],
|
|
208
|
+
roles: app.roles ?? null,
|
|
209
|
+
approverName: ((_app$approver = app.approver) === null || _app$approver === void 0 ? void 0 : _app$approver.name) ?? null,
|
|
210
|
+
approverEmail: ((_app$approver2 = app.approver) === null || _app$approver2 === void 0 ? void 0 : _app$approver2.email) ?? null,
|
|
211
|
+
notes: app.notes ?? null,
|
|
212
|
+
tags: app.tags ?? [],
|
|
213
|
+
appUrl: app.appUrl ?? null,
|
|
214
|
+
links: app.links ?? null,
|
|
215
|
+
iconName: app.iconName ?? null,
|
|
216
|
+
screenshotIds: app.screenshotIds ?? []
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
const actual = (await sync.sync(dbApps)).getActual();
|
|
220
|
+
return {
|
|
221
|
+
created: actual.length - apps.length + (apps.length - actual.length),
|
|
222
|
+
updated: 0,
|
|
223
|
+
deleted: 0,
|
|
224
|
+
total: actual.length
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/modules/appCatalogAdmin/appCatalogAdminRouter.ts
|
|
230
|
+
const AccessMethodSchema = z.object({ type: z.enum([
|
|
231
|
+
"bot",
|
|
232
|
+
"ticketing",
|
|
233
|
+
"email",
|
|
234
|
+
"self-service",
|
|
235
|
+
"documentation",
|
|
236
|
+
"manual"
|
|
237
|
+
]) }).passthrough();
|
|
238
|
+
const AppLinkSchema = z.object({
|
|
239
|
+
displayName: z.string().optional(),
|
|
240
|
+
url: z.string().url()
|
|
241
|
+
});
|
|
242
|
+
const AppRoleSchema = z.object({
|
|
243
|
+
id: z.string(),
|
|
244
|
+
name: z.string(),
|
|
245
|
+
description: z.string().optional()
|
|
246
|
+
});
|
|
247
|
+
const ApproverSchema = z.object({
|
|
248
|
+
name: z.string(),
|
|
249
|
+
email: z.string().email()
|
|
250
|
+
});
|
|
251
|
+
const CreateAppForCatalogSchema = z.object({
|
|
252
|
+
slug: z.string().min(1).regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
253
|
+
displayName: z.string().min(1),
|
|
254
|
+
description: z.string(),
|
|
255
|
+
access: AccessMethodSchema,
|
|
256
|
+
teams: z.array(z.string()).optional(),
|
|
257
|
+
roles: z.array(AppRoleSchema).optional(),
|
|
258
|
+
approver: ApproverSchema.optional(),
|
|
259
|
+
notes: z.string().optional(),
|
|
260
|
+
tags: z.array(z.string()).optional(),
|
|
261
|
+
appUrl: z.string().url().optional(),
|
|
262
|
+
links: z.array(AppLinkSchema).optional(),
|
|
263
|
+
iconName: z.string().optional(),
|
|
264
|
+
screenshotIds: z.array(z.string()).optional()
|
|
265
|
+
});
|
|
266
|
+
const UpdateAppForCatalogSchema = CreateAppForCatalogSchema.partial().extend({ id: z.string() });
|
|
267
|
+
function createAppCatalogAdminRouter(t$1) {
|
|
268
|
+
const router$1 = t$1.router;
|
|
269
|
+
const publicProcedure$1 = t$1.procedure;
|
|
270
|
+
return router$1({
|
|
271
|
+
list: publicProcedure$1.query(async () => {
|
|
272
|
+
return getDbClient().dbAppForCatalog.findMany({ orderBy: { displayName: "asc" } });
|
|
273
|
+
}),
|
|
274
|
+
getById: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
|
|
275
|
+
return getDbClient().dbAppForCatalog.findUnique({ where: { id: input.id } });
|
|
276
|
+
}),
|
|
277
|
+
create: publicProcedure$1.input(CreateAppForCatalogSchema).mutation(async ({ input }) => {
|
|
278
|
+
const prisma = getDbClient();
|
|
279
|
+
const { approver,...rest } = input;
|
|
280
|
+
return prisma.dbAppForCatalog.create({ data: {
|
|
281
|
+
...rest,
|
|
282
|
+
approverName: (approver === null || approver === void 0 ? void 0 : approver.name) ?? null,
|
|
283
|
+
approverEmail: (approver === null || approver === void 0 ? void 0 : approver.email) ?? null,
|
|
284
|
+
teams: input.teams ?? [],
|
|
285
|
+
tags: input.tags ?? [],
|
|
286
|
+
screenshotIds: input.screenshotIds ?? []
|
|
287
|
+
} });
|
|
288
|
+
}),
|
|
289
|
+
update: publicProcedure$1.input(UpdateAppForCatalogSchema).mutation(async ({ input }) => {
|
|
290
|
+
const prisma = getDbClient();
|
|
291
|
+
const { id, approver,...rest } = input;
|
|
292
|
+
return prisma.dbAppForCatalog.update({
|
|
293
|
+
where: { id },
|
|
294
|
+
data: {
|
|
295
|
+
...rest,
|
|
296
|
+
...approver !== void 0 && {
|
|
297
|
+
approverName: approver.name || null,
|
|
298
|
+
approverEmail: approver.email || null
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}),
|
|
303
|
+
delete: publicProcedure$1.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
|
|
304
|
+
return getDbClient().dbAppForCatalog.delete({ where: { id: input.id } });
|
|
305
|
+
})
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/modules/assets/screenshotRouter.ts
|
|
311
|
+
function createScreenshotRouter(t$1) {
|
|
312
|
+
const router$1 = t$1.router;
|
|
313
|
+
const publicProcedure$1 = t$1.procedure;
|
|
314
|
+
return router$1({
|
|
315
|
+
list: publicProcedure$1.query(async () => {
|
|
316
|
+
return getDbClient().dbAsset.findMany({
|
|
317
|
+
where: { assetType: "screenshot" },
|
|
318
|
+
select: {
|
|
319
|
+
id: true,
|
|
320
|
+
name: true,
|
|
321
|
+
mimeType: true,
|
|
322
|
+
fileSize: true,
|
|
323
|
+
width: true,
|
|
324
|
+
height: true,
|
|
325
|
+
createdAt: true,
|
|
326
|
+
updatedAt: true
|
|
327
|
+
},
|
|
328
|
+
orderBy: { createdAt: "desc" }
|
|
329
|
+
});
|
|
330
|
+
}),
|
|
331
|
+
getOne: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
|
|
332
|
+
return getDbClient().dbAsset.findFirst({
|
|
333
|
+
where: {
|
|
334
|
+
id: input.id,
|
|
335
|
+
assetType: "screenshot"
|
|
336
|
+
},
|
|
337
|
+
select: {
|
|
338
|
+
id: true,
|
|
339
|
+
name: true,
|
|
340
|
+
mimeType: true,
|
|
341
|
+
fileSize: true,
|
|
342
|
+
width: true,
|
|
343
|
+
height: true,
|
|
344
|
+
createdAt: true,
|
|
345
|
+
updatedAt: true
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}),
|
|
349
|
+
getByAppSlug: publicProcedure$1.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
|
|
350
|
+
const prisma = getDbClient();
|
|
351
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
352
|
+
where: { slug: input.appSlug },
|
|
353
|
+
select: { screenshotIds: true }
|
|
354
|
+
});
|
|
355
|
+
if (!app) return [];
|
|
356
|
+
return prisma.dbAsset.findMany({
|
|
357
|
+
where: {
|
|
358
|
+
id: { in: app.screenshotIds },
|
|
359
|
+
assetType: "screenshot"
|
|
360
|
+
},
|
|
361
|
+
select: {
|
|
362
|
+
id: true,
|
|
363
|
+
name: true,
|
|
364
|
+
mimeType: true,
|
|
365
|
+
fileSize: true,
|
|
366
|
+
width: true,
|
|
367
|
+
height: true,
|
|
368
|
+
createdAt: true,
|
|
369
|
+
updatedAt: true
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}),
|
|
373
|
+
getFirstByAppSlug: publicProcedure$1.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
|
|
374
|
+
const prisma = getDbClient();
|
|
375
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
376
|
+
where: { slug: input.appSlug },
|
|
377
|
+
select: { screenshotIds: true }
|
|
378
|
+
});
|
|
379
|
+
if (!app || app.screenshotIds.length === 0) return null;
|
|
380
|
+
return prisma.dbAsset.findUnique({
|
|
381
|
+
where: { id: app.screenshotIds[0] },
|
|
382
|
+
select: {
|
|
383
|
+
id: true,
|
|
384
|
+
name: true,
|
|
385
|
+
mimeType: true,
|
|
386
|
+
fileSize: true,
|
|
387
|
+
width: true,
|
|
388
|
+
height: true,
|
|
389
|
+
createdAt: true,
|
|
390
|
+
updatedAt: true
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
})
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/modules/auth/authRouter.ts
|
|
399
|
+
/**
|
|
400
|
+
* Create auth tRPC procedures
|
|
401
|
+
* @param t - tRPC instance
|
|
402
|
+
* @param auth - Better Auth instance (optional, for future extensions)
|
|
403
|
+
* @returns tRPC router with auth procedures
|
|
404
|
+
*/
|
|
405
|
+
function createAuthRouter(t$1, auth) {
|
|
406
|
+
const router$1 = t$1.router;
|
|
407
|
+
const publicProcedure$1 = t$1.procedure;
|
|
408
|
+
return router$1({
|
|
409
|
+
getSession: publicProcedure$1.query(async ({ ctx }) => {
|
|
410
|
+
const contextWithUser = ctx;
|
|
411
|
+
return {
|
|
412
|
+
user: contextWithUser.user ?? null,
|
|
413
|
+
isAuthenticated: !!contextWithUser.user
|
|
414
|
+
};
|
|
415
|
+
}),
|
|
416
|
+
getProviders: publicProcedure$1.query(() => {
|
|
417
|
+
const providers = [];
|
|
418
|
+
const authOptions = auth === null || auth === void 0 ? void 0 : auth.options;
|
|
419
|
+
if (authOptions === null || authOptions === void 0 ? void 0 : authOptions.socialProviders) {
|
|
420
|
+
const socialProviders = authOptions.socialProviders;
|
|
421
|
+
Object.keys(socialProviders).forEach((key) => {
|
|
422
|
+
if (socialProviders[key]) providers.push(key);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (authOptions === null || authOptions === void 0 ? void 0 : authOptions.plugins) authOptions.plugins.forEach((plugin) => {
|
|
426
|
+
var _pluginWithConfig$opt;
|
|
427
|
+
const pluginWithConfig = plugin;
|
|
428
|
+
if (pluginWithConfig.id === "generic-oauth" && ((_pluginWithConfig$opt = pluginWithConfig.options) === null || _pluginWithConfig$opt === void 0 ? void 0 : _pluginWithConfig$opt.config)) (Array.isArray(pluginWithConfig.options.config) ? pluginWithConfig.options.config : [pluginWithConfig.options.config]).forEach((config) => {
|
|
429
|
+
if (config.providerId) providers.push(config.providerId);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
return { providers };
|
|
433
|
+
})
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/modules/assets/assetUtils.ts
|
|
439
|
+
/**
|
|
440
|
+
* Extract image dimensions from a buffer using sharp
|
|
441
|
+
*/
|
|
442
|
+
async function getImageDimensions(buffer) {
|
|
443
|
+
try {
|
|
444
|
+
const metadata = await sharp(buffer).metadata();
|
|
445
|
+
return {
|
|
446
|
+
width: metadata.width ?? null,
|
|
447
|
+
height: metadata.height ?? null
|
|
448
|
+
};
|
|
449
|
+
} catch (error) {
|
|
450
|
+
console.error("Error extracting image dimensions:", error);
|
|
451
|
+
return {
|
|
452
|
+
width: null,
|
|
453
|
+
height: null
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Resize an image buffer to the specified dimensions
|
|
459
|
+
* @param buffer - The image buffer to resize
|
|
460
|
+
* @param width - Target width (optional)
|
|
461
|
+
* @param height - Target height (optional)
|
|
462
|
+
* @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
|
|
463
|
+
*/
|
|
464
|
+
async function resizeImage(buffer, width, height, format) {
|
|
465
|
+
let pipeline = sharp(buffer);
|
|
466
|
+
if (width || height) pipeline = pipeline.resize({
|
|
467
|
+
width,
|
|
468
|
+
height,
|
|
469
|
+
fit: "inside",
|
|
470
|
+
withoutEnlargement: true
|
|
471
|
+
});
|
|
472
|
+
if (format === "png") pipeline = pipeline.png();
|
|
473
|
+
else if (format === "webp") pipeline = pipeline.webp();
|
|
474
|
+
else if (format === "jpeg") pipeline = pipeline.jpeg();
|
|
475
|
+
return pipeline.toBuffer();
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Generate SHA-256 checksum for a buffer
|
|
479
|
+
*/
|
|
480
|
+
function generateChecksum(buffer) {
|
|
481
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Detect image format from mime type
|
|
485
|
+
*/
|
|
486
|
+
function getImageFormat(mimeType) {
|
|
487
|
+
if (mimeType.includes("png")) return "png";
|
|
488
|
+
if (mimeType.includes("webp")) return "webp";
|
|
489
|
+
if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return "jpeg";
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Check if a mime type represents a raster image (not SVG)
|
|
494
|
+
*/
|
|
495
|
+
function isRasterImage(mimeType) {
|
|
496
|
+
return mimeType.startsWith("image/") && !mimeType.includes("svg");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/modules/icons/iconRouter.ts
|
|
501
|
+
function createIconRouter(t$1) {
|
|
502
|
+
const router$1 = t$1.router;
|
|
503
|
+
const publicProcedure$1 = t$1.procedure;
|
|
504
|
+
return router$1({
|
|
505
|
+
list: publicProcedure$1.query(async () => {
|
|
506
|
+
return getDbClient().dbAsset.findMany({
|
|
507
|
+
where: { assetType: "icon" },
|
|
508
|
+
select: {
|
|
509
|
+
id: true,
|
|
510
|
+
name: true,
|
|
511
|
+
mimeType: true,
|
|
512
|
+
fileSize: true,
|
|
513
|
+
createdAt: true,
|
|
514
|
+
updatedAt: true
|
|
515
|
+
},
|
|
516
|
+
orderBy: { name: "asc" }
|
|
517
|
+
});
|
|
518
|
+
}),
|
|
519
|
+
getOne: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
|
|
520
|
+
return getDbClient().dbAsset.findFirst({ where: {
|
|
521
|
+
id: input.id,
|
|
522
|
+
assetType: "icon"
|
|
523
|
+
} });
|
|
524
|
+
}),
|
|
525
|
+
create: publicProcedure$1.input(z.object({
|
|
526
|
+
name: z.string().min(1),
|
|
527
|
+
content: z.string(),
|
|
528
|
+
mimeType: z.string(),
|
|
529
|
+
fileSize: z.number().int().positive()
|
|
530
|
+
})).mutation(async ({ input }) => {
|
|
531
|
+
const prisma = getDbClient();
|
|
532
|
+
const buffer = Buffer.from(input.content, "base64");
|
|
533
|
+
const checksum = generateChecksum(buffer);
|
|
534
|
+
const { width, height } = await getImageDimensions(buffer);
|
|
535
|
+
const existing = await prisma.dbAsset.findFirst({ where: {
|
|
536
|
+
checksum,
|
|
537
|
+
assetType: "icon"
|
|
538
|
+
} });
|
|
539
|
+
if (existing) return existing;
|
|
540
|
+
return prisma.dbAsset.create({ data: {
|
|
541
|
+
name: input.name,
|
|
542
|
+
assetType: "icon",
|
|
543
|
+
content: new Uint8Array(buffer),
|
|
544
|
+
checksum,
|
|
545
|
+
mimeType: input.mimeType,
|
|
546
|
+
fileSize: input.fileSize,
|
|
547
|
+
width,
|
|
548
|
+
height
|
|
549
|
+
} });
|
|
550
|
+
}),
|
|
551
|
+
update: publicProcedure$1.input(z.object({
|
|
552
|
+
id: z.string(),
|
|
553
|
+
name: z.string().min(1).optional(),
|
|
554
|
+
content: z.string().optional(),
|
|
555
|
+
mimeType: z.string().optional(),
|
|
556
|
+
fileSize: z.number().int().positive().optional()
|
|
557
|
+
})).mutation(async ({ input }) => {
|
|
558
|
+
const prisma = getDbClient();
|
|
559
|
+
const { id, content,...rest } = input;
|
|
560
|
+
const data = { ...rest };
|
|
561
|
+
if (content) {
|
|
562
|
+
const buffer = Buffer.from(content, "base64");
|
|
563
|
+
data.content = new Uint8Array(buffer);
|
|
564
|
+
data.checksum = generateChecksum(buffer);
|
|
565
|
+
const { width, height } = await getImageDimensions(buffer);
|
|
566
|
+
data.width = width;
|
|
567
|
+
data.height = height;
|
|
568
|
+
}
|
|
569
|
+
return prisma.dbAsset.update({
|
|
570
|
+
where: { id },
|
|
571
|
+
data
|
|
572
|
+
});
|
|
573
|
+
}),
|
|
574
|
+
delete: publicProcedure$1.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
|
|
575
|
+
return getDbClient().dbAsset.delete({ where: { id: input.id } });
|
|
576
|
+
}),
|
|
577
|
+
deleteMany: publicProcedure$1.input(z.object({ ids: z.array(z.string()) })).mutation(async ({ input }) => {
|
|
578
|
+
return getDbClient().dbAsset.deleteMany({ where: {
|
|
579
|
+
id: { in: input.ids },
|
|
580
|
+
assetType: "icon"
|
|
581
|
+
} });
|
|
582
|
+
}),
|
|
583
|
+
getContent: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
|
|
584
|
+
const asset = await getDbClient().dbAsset.findFirst({
|
|
585
|
+
where: {
|
|
586
|
+
id: input.id,
|
|
587
|
+
assetType: "icon"
|
|
588
|
+
},
|
|
589
|
+
select: {
|
|
590
|
+
content: true,
|
|
591
|
+
mimeType: true
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
if (!asset) throw new Error("Icon not found");
|
|
595
|
+
return {
|
|
596
|
+
content: Buffer.from(asset.content).toString("base64"),
|
|
597
|
+
mimeType: asset.mimeType
|
|
598
|
+
};
|
|
599
|
+
})
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/server/controller.ts
|
|
605
|
+
/**
|
|
606
|
+
* Initialization of tRPC backend
|
|
607
|
+
* Should be done only once per backend!
|
|
608
|
+
*/
|
|
609
|
+
const t = initTRPC.context().create({ errorFormatter({ error, shape }) {
|
|
610
|
+
var _data;
|
|
611
|
+
console.error("[tRPC Error]", {
|
|
612
|
+
path: (_data = shape.data) === null || _data === void 0 ? void 0 : _data.path,
|
|
613
|
+
code: error.code,
|
|
614
|
+
message: error.message,
|
|
615
|
+
cause: error.cause,
|
|
616
|
+
stack: error.stack
|
|
617
|
+
});
|
|
618
|
+
return shape;
|
|
619
|
+
} });
|
|
620
|
+
/**
|
|
621
|
+
* Export reusable router and procedure helpers
|
|
622
|
+
* that can be used throughout the router
|
|
623
|
+
*/
|
|
624
|
+
const router = t.router;
|
|
625
|
+
const publicProcedure = t.procedure;
|
|
626
|
+
/**
|
|
627
|
+
* Create the main tRPC router with optional auth instance
|
|
628
|
+
* @param auth - Optional Better Auth instance for auth-related queries
|
|
629
|
+
*/
|
|
630
|
+
function createTrpcRouter(auth) {
|
|
631
|
+
return router({
|
|
632
|
+
bootstrap: publicProcedure.query(async ({ ctx }) => {
|
|
633
|
+
return await ctx.companySpecificBackend.getBootstrapData();
|
|
634
|
+
}),
|
|
635
|
+
availabilityMatrix: publicProcedure.query(async ({ ctx }) => {
|
|
636
|
+
return await ctx.companySpecificBackend.getAvailabilityMatrix();
|
|
637
|
+
}),
|
|
638
|
+
tryFindRenameRule: publicProcedure.input(z$1.object({
|
|
639
|
+
envSlug: z$1.string().optional(),
|
|
640
|
+
resourceSlug: z$1.string().optional()
|
|
641
|
+
})).query(async ({ input, ctx }) => {
|
|
642
|
+
return await ctx.companySpecificBackend.getNameMigrations(input);
|
|
643
|
+
}),
|
|
644
|
+
resourceJumps: publicProcedure.query(async ({ ctx }) => {
|
|
645
|
+
return await ctx.companySpecificBackend.getResourceJumps();
|
|
646
|
+
}),
|
|
647
|
+
resourceJumpsExtended: publicProcedure.query(async ({ ctx }) => {
|
|
648
|
+
return await ctx.companySpecificBackend.getResourceJumpsExtended();
|
|
649
|
+
}),
|
|
650
|
+
resourceJumpBySlugAndEnv: publicProcedure.input(z$1.object({
|
|
651
|
+
jumpResourceSlug: z$1.string(),
|
|
652
|
+
envSlug: z$1.string()
|
|
653
|
+
})).query(async ({ input, ctx }) => {
|
|
654
|
+
return filterSingleResourceJump(await ctx.companySpecificBackend.getResourceJumps(), input.jumpResourceSlug, input.envSlug);
|
|
655
|
+
}),
|
|
656
|
+
appCatalog: publicProcedure.query(async ({ ctx }) => {
|
|
657
|
+
return await getAppCatalogData(ctx.companySpecificBackend.getApps);
|
|
658
|
+
}),
|
|
659
|
+
icon: createIconRouter(t),
|
|
660
|
+
screenshot: createScreenshotRouter(t),
|
|
661
|
+
appCatalogAdmin: createAppCatalogAdminRouter(t),
|
|
662
|
+
auth: createAuthRouter(t, auth)
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
function filterSingleResourceJump(resourceJumps, jumpResourceSlug, envSlug) {
|
|
666
|
+
const filteredResourceJump = resourceJumps.resourceJumps.find((item) => item.slug === jumpResourceSlug);
|
|
667
|
+
const filteredEnv = resourceJumps.envs.find((item) => item.slug === envSlug);
|
|
668
|
+
return {
|
|
669
|
+
resourceJumps: filteredResourceJump ? [filteredResourceJump] : [],
|
|
670
|
+
envs: filteredEnv ? [filteredEnv] : [],
|
|
671
|
+
lateResolvableParams: resourceJumps.lateResolvableParams
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/server/ehTrpcContext.ts
|
|
677
|
+
function createEhTrpcContext({ companySpecificBackend }) {
|
|
678
|
+
return { companySpecificBackend };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/server/ehStaticControllerContract.ts
|
|
683
|
+
const staticControllerContract = { methods: {
|
|
684
|
+
getIcon: {
|
|
685
|
+
method: "get",
|
|
686
|
+
url: "icon/:icon"
|
|
687
|
+
},
|
|
688
|
+
getScreenshot: {
|
|
689
|
+
method: "get",
|
|
690
|
+
url: "screenshot/:id"
|
|
691
|
+
}
|
|
692
|
+
} };
|
|
693
|
+
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/modules/auth/auth.ts
|
|
696
|
+
function createAuth(config) {
|
|
697
|
+
const prisma = getDbClient();
|
|
698
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
699
|
+
return betterAuth({
|
|
700
|
+
appName: config.appName || "EnvHopper",
|
|
701
|
+
baseURL: config.baseURL,
|
|
702
|
+
basePath: "/api/auth",
|
|
703
|
+
secret: config.secret,
|
|
704
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
705
|
+
socialProviders: config.providers || {},
|
|
706
|
+
plugins: config.plugins || [],
|
|
707
|
+
emailAndPassword: { enabled: true },
|
|
708
|
+
session: {
|
|
709
|
+
expiresIn: config.sessionExpiresIn ?? 3600 * 24 * 30,
|
|
710
|
+
updateAge: config.sessionUpdateAge ?? 3600 * 24,
|
|
711
|
+
cookieCache: {
|
|
712
|
+
enabled: true,
|
|
713
|
+
maxAge: 300
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
advanced: { useSecureCookies: isProduction }
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/modules/auth/registerAuthRoutes.ts
|
|
722
|
+
/**
|
|
723
|
+
* Register Better Auth routes with Express
|
|
724
|
+
* @param app - Express application instance
|
|
725
|
+
* @param auth - Better Auth instance
|
|
726
|
+
*/
|
|
727
|
+
function registerAuthRoutes(app, auth) {
|
|
728
|
+
app.get("/api/auth/session", async (req, res) => {
|
|
729
|
+
try {
|
|
730
|
+
const session = await auth.api.getSession({ headers: req.headers });
|
|
731
|
+
if (session) res.json(session);
|
|
732
|
+
else res.status(401).json({ error: "Not authenticated" });
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error("[Auth Session Error]", error);
|
|
735
|
+
res.status(500).json({ error: "Internal server error" });
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
const authHandler = toNodeHandler(auth);
|
|
739
|
+
app.all("/api/auth/{*any}", authHandler);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/modules/auth/authProviders.ts
|
|
744
|
+
function getAuthProvidersFromEnv() {
|
|
745
|
+
const providers = {};
|
|
746
|
+
if (process.env.AUTH_GITHUB_CLIENT_ID && process.env.AUTH_GITHUB_CLIENT_SECRET) providers.github = {
|
|
747
|
+
clientId: process.env.AUTH_GITHUB_CLIENT_ID,
|
|
748
|
+
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET
|
|
749
|
+
};
|
|
750
|
+
if (process.env.AUTH_GOOGLE_CLIENT_ID && process.env.AUTH_GOOGLE_CLIENT_SECRET) providers.google = {
|
|
751
|
+
clientId: process.env.AUTH_GOOGLE_CLIENT_ID,
|
|
752
|
+
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET
|
|
753
|
+
};
|
|
754
|
+
return providers;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Get auth plugins from environment variables
|
|
758
|
+
* Currently supports: Okta
|
|
759
|
+
*
|
|
760
|
+
* Example .env:
|
|
761
|
+
* AUTH_OKTA_CLIENT_ID=your_okta_client_id
|
|
762
|
+
* AUTH_OKTA_CLIENT_SECRET=your_okta_client_secret
|
|
763
|
+
* AUTH_OKTA_ISSUER=https://your-org.okta.com/oauth2/ausxb83g4wY1x09ec0h7
|
|
764
|
+
*
|
|
765
|
+
* Note: If you get "User is not assigned to the client application" errors,
|
|
766
|
+
* you need to configure your Okta application to allow all users:
|
|
767
|
+
* 1. In Okta Admin Console, go to Applications → Your App
|
|
768
|
+
* 2. Assignments tab → Assign to Groups → Add "Everyone" group
|
|
769
|
+
* OR
|
|
770
|
+
* 3. Edit the application → In "User consent" section, enable appropriate settings
|
|
771
|
+
*
|
|
772
|
+
* For group-based authorization:
|
|
773
|
+
* 1. Add "groups" scope to your auth server policy rule
|
|
774
|
+
* 2. Create a groups claim in your auth server
|
|
775
|
+
* 3. Groups will be available in the user object after authentication
|
|
776
|
+
*/
|
|
777
|
+
function getAuthPluginsFromEnv() {
|
|
778
|
+
const plugins = [];
|
|
779
|
+
const oktaConfig = [];
|
|
780
|
+
if (process.env.AUTH_OKTA_CLIENT_ID && process.env.AUTH_OKTA_CLIENT_SECRET && process.env.AUTH_OKTA_ISSUER) oktaConfig.push(okta({
|
|
781
|
+
clientId: process.env.AUTH_OKTA_CLIENT_ID,
|
|
782
|
+
clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET,
|
|
783
|
+
issuer: process.env.AUTH_OKTA_ISSUER
|
|
784
|
+
}));
|
|
785
|
+
if (oktaConfig.length > 0) plugins.push(genericOAuth({ config: oktaConfig }));
|
|
786
|
+
return plugins;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Validate required auth environment variables
|
|
790
|
+
*/
|
|
791
|
+
function validateAuthConfig() {
|
|
792
|
+
const secret = process.env.BETTER_AUTH_SECRET;
|
|
793
|
+
const baseUrl = process.env.BETTER_AUTH_URL;
|
|
794
|
+
if (!secret) console.warn("BETTER_AUTH_SECRET not set. Using development fallback. Set this in production!");
|
|
795
|
+
if (!baseUrl) console.info("BETTER_AUTH_URL not set. Using default http://localhost:3000");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/modules/auth/authorizationUtils.ts
|
|
800
|
+
/**
|
|
801
|
+
* Extract groups from user object
|
|
802
|
+
* Groups can be stored in different locations depending on the OAuth provider
|
|
803
|
+
*/
|
|
804
|
+
function getUserGroups(user) {
|
|
805
|
+
if (!user) return [];
|
|
806
|
+
const groups = user.groups || user.env_hopper_groups || user.oktaGroups || user.roles || [];
|
|
807
|
+
return Array.isArray(groups) ? groups : [];
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Check if user is a member of any of the specified groups
|
|
811
|
+
*/
|
|
812
|
+
function isMemberOfAnyGroup(user, allowedGroups) {
|
|
813
|
+
const userGroups = getUserGroups(user);
|
|
814
|
+
return allowedGroups.some((group) => userGroups.includes(group));
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Check if user is a member of all specified groups
|
|
818
|
+
*/
|
|
819
|
+
function isMemberOfAllGroups(user, requiredGroups) {
|
|
820
|
+
const userGroups = getUserGroups(user);
|
|
821
|
+
return requiredGroups.every((group) => userGroups.includes(group));
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Get admin group names from environment variables
|
|
825
|
+
* Default: env_hopper_ui_super_admins
|
|
826
|
+
*/
|
|
827
|
+
function getAdminGroupsFromEnv() {
|
|
828
|
+
return (process.env.AUTH_ADMIN_GROUPS || "env_hopper_ui_super_admins").split(",").map((g) => g.trim()).filter(Boolean);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Check if user has admin permissions
|
|
832
|
+
*/
|
|
833
|
+
function isAdmin(user) {
|
|
834
|
+
return isMemberOfAnyGroup(user, getAdminGroupsFromEnv());
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Require admin permissions - throws error if not admin
|
|
838
|
+
*/
|
|
839
|
+
function requireAdmin(user) {
|
|
840
|
+
if (!isAdmin(user)) throw new Error("Forbidden: Admin access required");
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Require membership in specific groups - throws error if not member
|
|
844
|
+
*/
|
|
845
|
+
function requireGroups(user, groups) {
|
|
846
|
+
if (!isMemberOfAnyGroup(user, groups)) throw new Error(`Forbidden: Membership in one of these groups required: ${groups.join(", ")}`);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/modules/admin/chat/createAdminChatHandler.ts
|
|
851
|
+
function convertToCoreMessages(messages) {
|
|
852
|
+
return messages.map((msg) => {
|
|
853
|
+
var _msg$parts;
|
|
854
|
+
if (msg.content) return {
|
|
855
|
+
role: msg.role,
|
|
856
|
+
content: msg.content
|
|
857
|
+
};
|
|
858
|
+
const textContent = ((_msg$parts = msg.parts) === null || _msg$parts === void 0 ? void 0 : _msg$parts.filter((part) => part.type === "text").map((part) => part.text).join("")) ?? "";
|
|
859
|
+
return {
|
|
860
|
+
role: msg.role,
|
|
861
|
+
content: textContent
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Creates an Express handler for the admin chat endpoint.
|
|
867
|
+
*
|
|
868
|
+
* Usage in thin wrappers:
|
|
869
|
+
*
|
|
870
|
+
* ```typescript
|
|
871
|
+
* // With OpenAI
|
|
872
|
+
* import { openai } from '@ai-sdk/openai'
|
|
873
|
+
* app.post('/api/admin/chat', createAdminChatHandler({
|
|
874
|
+
* model: openai('gpt-4o-mini'),
|
|
875
|
+
* }))
|
|
876
|
+
*
|
|
877
|
+
* // With Claude
|
|
878
|
+
* import { anthropic } from '@ai-sdk/anthropic'
|
|
879
|
+
* app.post('/api/admin/chat', createAdminChatHandler({
|
|
880
|
+
* model: anthropic('claude-sonnet-4-20250514'),
|
|
881
|
+
* }))
|
|
882
|
+
* ```
|
|
883
|
+
*/
|
|
884
|
+
function createAdminChatHandler(options) {
|
|
885
|
+
const { model, systemPrompt = "You are a helpful admin assistant for the Env Hopper application. Help users manage apps, data sources, and MCP server configurations.", tools = {}, validateConfig } = options;
|
|
886
|
+
return async (req, res) => {
|
|
887
|
+
try {
|
|
888
|
+
if (validateConfig) validateConfig();
|
|
889
|
+
const { messages } = req.body;
|
|
890
|
+
const coreMessages = convertToCoreMessages(messages);
|
|
891
|
+
console.log("[Admin Chat] Received messages:", JSON.stringify(coreMessages, null, 2));
|
|
892
|
+
console.log("[Admin Chat] Available tools:", Object.keys(tools));
|
|
893
|
+
const response = streamText({
|
|
894
|
+
model,
|
|
895
|
+
system: systemPrompt,
|
|
896
|
+
messages: coreMessages,
|
|
897
|
+
tools,
|
|
898
|
+
stopWhen: stepCountIs(5),
|
|
899
|
+
onFinish: (event) => {
|
|
900
|
+
console.log("[Admin Chat] Finished:", {
|
|
901
|
+
finishReason: event.finishReason,
|
|
902
|
+
usage: event.usage,
|
|
903
|
+
hasText: !!event.text,
|
|
904
|
+
textLength: event.text.length
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}).toUIMessageStreamResponse();
|
|
908
|
+
response.headers.forEach((value, key) => {
|
|
909
|
+
res.setHeader(key, value);
|
|
910
|
+
});
|
|
911
|
+
if (response.body) {
|
|
912
|
+
const reader = response.body.getReader();
|
|
913
|
+
const pump = async () => {
|
|
914
|
+
const { done, value } = await reader.read();
|
|
915
|
+
if (done) {
|
|
916
|
+
res.end();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
res.write(value);
|
|
920
|
+
return pump();
|
|
921
|
+
};
|
|
922
|
+
await pump();
|
|
923
|
+
} else {
|
|
924
|
+
console.error("[Admin Chat] No response body");
|
|
925
|
+
res.status(500).json({ error: "No response from AI model" });
|
|
926
|
+
}
|
|
927
|
+
} catch (error) {
|
|
928
|
+
console.error("[Admin Chat] Error:", error);
|
|
929
|
+
res.status(500).json({ error: "Failed to process chat request" });
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region src/modules/admin/chat/createDatabaseTools.ts
|
|
936
|
+
/**
|
|
937
|
+
* Creates a DatabaseClient from a Prisma client.
|
|
938
|
+
*/
|
|
939
|
+
function createPrismaDatabaseClient(prisma) {
|
|
940
|
+
return {
|
|
941
|
+
query: async (sql) => {
|
|
942
|
+
return await prisma.$queryRawUnsafe(sql);
|
|
943
|
+
},
|
|
944
|
+
execute: async (sql) => {
|
|
945
|
+
return { affectedRows: await prisma.$executeRawUnsafe(sql) };
|
|
946
|
+
},
|
|
947
|
+
getTables: async () => {
|
|
948
|
+
return (await prisma.$queryRawUnsafe(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)).map((t$1) => t$1.tablename);
|
|
949
|
+
},
|
|
950
|
+
getColumns: async (tableName) => {
|
|
951
|
+
return (await prisma.$queryRawUnsafe(`SELECT column_name, data_type, is_nullable
|
|
952
|
+
FROM information_schema.columns
|
|
953
|
+
WHERE table_name = '${tableName}' AND table_schema = 'public'`)).map((c) => ({
|
|
954
|
+
name: c.column_name,
|
|
955
|
+
type: c.data_type,
|
|
956
|
+
nullable: c.is_nullable === "YES"
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
const querySchema = z.object({ sql: z.string().describe("The SELECT SQL query to execute") });
|
|
962
|
+
const modifySchema = z.object({
|
|
963
|
+
sql: z.string().describe("The INSERT, UPDATE, or DELETE SQL query to execute"),
|
|
964
|
+
confirmed: z.boolean().describe("Must be true to execute destructive operations")
|
|
965
|
+
});
|
|
966
|
+
const schemaParamsSchema = z.object({ tableName: z.string().optional().describe("Specific table name to get columns for. If not provided, returns list of all tables.") });
|
|
967
|
+
/**
|
|
968
|
+
* Creates a DatabaseClient using the internal backend-core Prisma client.
|
|
969
|
+
* This is a convenience function for apps that don't need to pass their own Prisma client.
|
|
970
|
+
*/
|
|
971
|
+
function createInternalDatabaseClient() {
|
|
972
|
+
return createPrismaDatabaseClient(getDbClient());
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Creates AI tools for generic database access.
|
|
976
|
+
*
|
|
977
|
+
* The AI uses these internally - users interact via natural language.
|
|
978
|
+
* Results are formatted as tables by the AI based on the system prompt.
|
|
979
|
+
* Uses the internal backend-core Prisma client automatically.
|
|
980
|
+
*/
|
|
981
|
+
function createDatabaseTools() {
|
|
982
|
+
const db = createInternalDatabaseClient();
|
|
983
|
+
return {
|
|
984
|
+
queryDatabase: {
|
|
985
|
+
description: `Execute a SELECT query to read data from the database.
|
|
986
|
+
Use this to list, search, or filter records from any table.
|
|
987
|
+
Always use double quotes around table and column names for PostgreSQL (e.g., SELECT * FROM "App").
|
|
988
|
+
Return results will be formatted as a table for the user.`,
|
|
989
|
+
inputSchema: querySchema,
|
|
990
|
+
execute: async ({ sql }) => {
|
|
991
|
+
console.log(`Executing ${sql}`);
|
|
992
|
+
if (!sql.trim().toUpperCase().startsWith("SELECT")) return { error: "Only SELECT queries are allowed with queryDatabase. Use modifyDatabase for changes." };
|
|
993
|
+
try {
|
|
994
|
+
const results = await db.query(sql);
|
|
995
|
+
return {
|
|
996
|
+
success: true,
|
|
997
|
+
rowCount: Array.isArray(results) ? results.length : 0,
|
|
998
|
+
data: results
|
|
999
|
+
};
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return {
|
|
1002
|
+
success: false,
|
|
1003
|
+
error: error instanceof Error ? error.message : "Query failed"
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
modifyDatabase: {
|
|
1009
|
+
description: `Execute an INSERT, UPDATE, or DELETE query to modify data.
|
|
1010
|
+
Use double quotes around table and column names for PostgreSQL.
|
|
1011
|
+
IMPORTANT: Always ask for user confirmation before executing. Set confirmed=true only after user confirms.
|
|
1012
|
+
For UPDATE/DELETE, always include a WHERE clause to avoid affecting all rows.`,
|
|
1013
|
+
inputSchema: modifySchema,
|
|
1014
|
+
execute: async ({ sql, confirmed }) => {
|
|
1015
|
+
if (!confirmed) return {
|
|
1016
|
+
needsConfirmation: true,
|
|
1017
|
+
message: "Please confirm you want to execute this operation.",
|
|
1018
|
+
sql
|
|
1019
|
+
};
|
|
1020
|
+
const normalizedSql = sql.trim().toUpperCase();
|
|
1021
|
+
if (normalizedSql.startsWith("SELECT")) return { error: "Use queryDatabase for SELECT queries." };
|
|
1022
|
+
if ((normalizedSql.startsWith("UPDATE") || normalizedSql.startsWith("DELETE")) && !normalizedSql.includes("WHERE")) return {
|
|
1023
|
+
error: "UPDATE and DELETE queries must include a WHERE clause for safety.",
|
|
1024
|
+
sql
|
|
1025
|
+
};
|
|
1026
|
+
try {
|
|
1027
|
+
const result = await db.execute(sql);
|
|
1028
|
+
return {
|
|
1029
|
+
success: true,
|
|
1030
|
+
affectedRows: result.affectedRows,
|
|
1031
|
+
message: `Operation completed. ${result.affectedRows} row(s) affected.`
|
|
1032
|
+
};
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
return {
|
|
1035
|
+
success: false,
|
|
1036
|
+
error: error instanceof Error ? error.message : "Operation failed"
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
getDatabaseSchema: {
|
|
1042
|
+
description: `Get information about database tables and their columns.
|
|
1043
|
+
Use this to understand the database structure before writing queries.
|
|
1044
|
+
Call without tableName to list all tables, or with tableName to get columns for a specific table.`,
|
|
1045
|
+
inputSchema: schemaParamsSchema,
|
|
1046
|
+
execute: async ({ tableName }) => {
|
|
1047
|
+
try {
|
|
1048
|
+
if (tableName) return {
|
|
1049
|
+
success: true,
|
|
1050
|
+
table: tableName,
|
|
1051
|
+
columns: await db.getColumns(tableName)
|
|
1052
|
+
};
|
|
1053
|
+
else return {
|
|
1054
|
+
success: true,
|
|
1055
|
+
tables: await db.getTables()
|
|
1056
|
+
};
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
return {
|
|
1059
|
+
success: false,
|
|
1060
|
+
error: error instanceof Error ? error.message : "Failed to get schema"
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Default system prompt for the database admin assistant.
|
|
1069
|
+
* Can be customized or extended.
|
|
1070
|
+
*/
|
|
1071
|
+
const DEFAULT_ADMIN_SYSTEM_PROMPT = `You are a helpful database admin assistant. You help users view and manage data in the database.
|
|
1072
|
+
|
|
1073
|
+
IMPORTANT RULES:
|
|
1074
|
+
1. When showing data, ALWAYS format it as a numbered ASCII table so users can reference rows by number
|
|
1075
|
+
2. NEVER show raw SQL to users - just describe what you're doing in plain language
|
|
1076
|
+
3. When users ask to modify data (update, delete, create), ALWAYS confirm before executing
|
|
1077
|
+
4. For updates, show the current value and ask for confirmation before changing
|
|
1078
|
+
5. Keep responses concise and focused on the data
|
|
1079
|
+
|
|
1080
|
+
FORMATTING EXAMPLE:
|
|
1081
|
+
When user asks "show me all apps", respond like:
|
|
1082
|
+
"Here are all the apps:
|
|
1083
|
+
|
|
1084
|
+
| # | ID | Slug | Display Name | Icon |
|
|
1085
|
+
|---|----|---------|-----------------| -------|
|
|
1086
|
+
| 1 | 1 | portal | Portal | portal |
|
|
1087
|
+
| 2 | 2 | admin | Admin Dashboard | admin |
|
|
1088
|
+
| 3 | 3 | api | API Service | null |
|
|
1089
|
+
|
|
1090
|
+
Found 3 apps total."
|
|
1091
|
+
|
|
1092
|
+
When user says "update row 2 display name to 'Admin Panel'":
|
|
1093
|
+
1. First confirm: "I'll update the app 'Admin Dashboard' (ID: 2) to have display name 'Admin Panel'. Proceed?"
|
|
1094
|
+
2. Only after user confirms, execute the update
|
|
1095
|
+
3. Then show the updated row
|
|
1096
|
+
|
|
1097
|
+
AVAILABLE TABLES:
|
|
1098
|
+
Use getDatabaseSchema tool to discover tables and their columns.
|
|
1099
|
+
`;
|
|
1100
|
+
|
|
1101
|
+
//#endregion
|
|
1102
|
+
//#region src/modules/icons/iconRestController.ts
|
|
1103
|
+
const upload$1 = multer({
|
|
1104
|
+
storage: multer.memoryStorage(),
|
|
1105
|
+
limits: { fileSize: 10 * 1024 * 1024 },
|
|
1106
|
+
fileFilter: (_req, file, cb) => {
|
|
1107
|
+
if (!file.mimetype.startsWith("image/")) {
|
|
1108
|
+
cb(/* @__PURE__ */ new Error("Only image files are allowed"));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
cb(null, true);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
/**
|
|
1115
|
+
* Registers REST endpoints for icon upload and retrieval
|
|
1116
|
+
*
|
|
1117
|
+
* Endpoints:
|
|
1118
|
+
* - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)
|
|
1119
|
+
* - GET {basePath}/:id - Get icon binary by ID
|
|
1120
|
+
* - GET {basePath}/:id/metadata - Get icon metadata only
|
|
1121
|
+
*/
|
|
1122
|
+
function registerIconRestController(router$1, config) {
|
|
1123
|
+
const { basePath } = config;
|
|
1124
|
+
router$1.post(`${basePath}/upload`, upload$1.single("icon"), async (req, res) => {
|
|
1125
|
+
try {
|
|
1126
|
+
if (!req.file) {
|
|
1127
|
+
res.status(400).json({ error: "No file uploaded" });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const name = req.body["name"];
|
|
1131
|
+
if (!name) {
|
|
1132
|
+
res.status(400).json({ error: "Name is required" });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const prisma = getDbClient();
|
|
1136
|
+
const checksum = createHash("sha256").update(req.file.buffer).digest("hex");
|
|
1137
|
+
const icon = await prisma.dbAsset.create({ data: {
|
|
1138
|
+
name,
|
|
1139
|
+
assetType: "icon",
|
|
1140
|
+
content: new Uint8Array(req.file.buffer),
|
|
1141
|
+
mimeType: req.file.mimetype,
|
|
1142
|
+
fileSize: req.file.size,
|
|
1143
|
+
checksum
|
|
1144
|
+
} });
|
|
1145
|
+
res.status(201).json({
|
|
1146
|
+
id: icon.id,
|
|
1147
|
+
name: icon.name,
|
|
1148
|
+
mimeType: icon.mimeType,
|
|
1149
|
+
fileSize: icon.fileSize,
|
|
1150
|
+
createdAt: icon.createdAt
|
|
1151
|
+
});
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
console.error("Error uploading icon:", error);
|
|
1154
|
+
res.status(500).json({ error: "Failed to upload icon" });
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
router$1.get(`${basePath}/:id`, async (req, res) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const { id } = req.params;
|
|
1160
|
+
const icon = await getDbClient().dbAsset.findUnique({
|
|
1161
|
+
where: { id },
|
|
1162
|
+
select: {
|
|
1163
|
+
content: true,
|
|
1164
|
+
mimeType: true,
|
|
1165
|
+
name: true
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
if (!icon) {
|
|
1169
|
+
res.status(404).json({ error: "Icon not found" });
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
res.setHeader("Content-Type", icon.mimeType);
|
|
1173
|
+
res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
|
|
1174
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
1175
|
+
res.send(icon.content);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
console.error("Error fetching icon:", error);
|
|
1178
|
+
res.status(500).json({ error: "Failed to fetch icon" });
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
|
|
1182
|
+
try {
|
|
1183
|
+
const { id } = req.params;
|
|
1184
|
+
const icon = await getDbClient().dbAsset.findUnique({
|
|
1185
|
+
where: { id },
|
|
1186
|
+
select: {
|
|
1187
|
+
id: true,
|
|
1188
|
+
name: true,
|
|
1189
|
+
mimeType: true,
|
|
1190
|
+
fileSize: true,
|
|
1191
|
+
createdAt: true,
|
|
1192
|
+
updatedAt: true
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
if (!icon) {
|
|
1196
|
+
res.status(404).json({ error: "Icon not found" });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
res.json(icon);
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
console.error("Error fetching icon metadata:", error);
|
|
1202
|
+
res.status(500).json({ error: "Failed to fetch icon metadata" });
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
router$1.get(`${basePath}/by-name/:name`, async (req, res) => {
|
|
1206
|
+
try {
|
|
1207
|
+
const { name } = req.params;
|
|
1208
|
+
const icon = await getDbClient().dbAsset.findUnique({
|
|
1209
|
+
where: { name },
|
|
1210
|
+
select: {
|
|
1211
|
+
content: true,
|
|
1212
|
+
mimeType: true,
|
|
1213
|
+
name: true
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
if (!icon) {
|
|
1217
|
+
res.status(404).json({ error: "Icon not found" });
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
res.setHeader("Content-Type", icon.mimeType);
|
|
1221
|
+
res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
|
|
1222
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
1223
|
+
res.send(icon.content);
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
console.error("Error fetching icon by name:", error);
|
|
1226
|
+
res.status(500).json({ error: "Failed to fetch icon" });
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/modules/icons/iconService.ts
|
|
1233
|
+
/**
|
|
1234
|
+
* Upsert an icon to the database.
|
|
1235
|
+
* If an icon with the same name exists, it will be updated.
|
|
1236
|
+
* Otherwise, a new icon will be created.
|
|
1237
|
+
*/
|
|
1238
|
+
async function upsertIcon(input) {
|
|
1239
|
+
const prisma = getDbClient();
|
|
1240
|
+
const checksum = generateChecksum(input.content);
|
|
1241
|
+
const { width, height } = await getImageDimensions(input.content);
|
|
1242
|
+
return prisma.dbAsset.upsert({
|
|
1243
|
+
where: { name: input.name },
|
|
1244
|
+
update: {
|
|
1245
|
+
content: new Uint8Array(input.content),
|
|
1246
|
+
checksum,
|
|
1247
|
+
mimeType: input.mimeType,
|
|
1248
|
+
fileSize: input.fileSize,
|
|
1249
|
+
width,
|
|
1250
|
+
height
|
|
1251
|
+
},
|
|
1252
|
+
create: {
|
|
1253
|
+
name: input.name,
|
|
1254
|
+
assetType: "icon",
|
|
1255
|
+
content: new Uint8Array(input.content),
|
|
1256
|
+
checksum,
|
|
1257
|
+
mimeType: input.mimeType,
|
|
1258
|
+
fileSize: input.fileSize,
|
|
1259
|
+
width,
|
|
1260
|
+
height
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Upsert multiple icons to the database.
|
|
1266
|
+
* This is more efficient than calling upsertIcon multiple times.
|
|
1267
|
+
*/
|
|
1268
|
+
async function upsertIcons(icons) {
|
|
1269
|
+
const results = [];
|
|
1270
|
+
for (const icon of icons) {
|
|
1271
|
+
const result = await upsertIcon(icon);
|
|
1272
|
+
results.push(result);
|
|
1273
|
+
}
|
|
1274
|
+
return results;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Get an asset (icon or screenshot) by name from the database.
|
|
1278
|
+
* Returns the asset content, mimeType, and name if found.
|
|
1279
|
+
*/
|
|
1280
|
+
async function getAssetByName(name) {
|
|
1281
|
+
return getDbClient().dbAsset.findUnique({
|
|
1282
|
+
where: { name },
|
|
1283
|
+
select: {
|
|
1284
|
+
content: true,
|
|
1285
|
+
mimeType: true,
|
|
1286
|
+
name: true
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region src/modules/assets/assetRestController.ts
|
|
1293
|
+
const upload = multer({
|
|
1294
|
+
storage: multer.memoryStorage(),
|
|
1295
|
+
limits: { fileSize: 10 * 1024 * 1024 },
|
|
1296
|
+
fileFilter: (_req, file, cb) => {
|
|
1297
|
+
if (!file.mimetype.startsWith("image/")) {
|
|
1298
|
+
cb(/* @__PURE__ */ new Error("Only image files are allowed"));
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
cb(null, true);
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
/**
|
|
1305
|
+
* Registers REST endpoints for universal asset upload and retrieval
|
|
1306
|
+
*
|
|
1307
|
+
* Endpoints:
|
|
1308
|
+
* - POST {basePath}/upload - Upload a new asset (multipart/form-data)
|
|
1309
|
+
* - GET {basePath}/:id - Get asset binary by ID
|
|
1310
|
+
* - GET {basePath}/:id/metadata - Get asset metadata only
|
|
1311
|
+
* - GET {basePath}/by-name/:name - Get asset binary by name
|
|
1312
|
+
*/
|
|
1313
|
+
function registerAssetRestController(router$1, config) {
|
|
1314
|
+
const { basePath } = config;
|
|
1315
|
+
router$1.post(`${basePath}/upload`, upload.single("asset"), async (req, res) => {
|
|
1316
|
+
try {
|
|
1317
|
+
if (!req.file) {
|
|
1318
|
+
res.status(400).json({ error: "No file uploaded" });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const name = req.body["name"];
|
|
1322
|
+
const assetType = req.body["assetType"] ?? "icon";
|
|
1323
|
+
if (!name) {
|
|
1324
|
+
res.status(400).json({ error: "Name is required" });
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const prisma = getDbClient();
|
|
1328
|
+
const checksum = generateChecksum(req.file.buffer);
|
|
1329
|
+
const existing = await prisma.dbAsset.findUnique({
|
|
1330
|
+
where: { checksum },
|
|
1331
|
+
select: {
|
|
1332
|
+
id: true,
|
|
1333
|
+
name: true,
|
|
1334
|
+
assetType: true,
|
|
1335
|
+
checksum: true,
|
|
1336
|
+
mimeType: true,
|
|
1337
|
+
fileSize: true,
|
|
1338
|
+
width: true,
|
|
1339
|
+
height: true,
|
|
1340
|
+
createdAt: true
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
if (existing) {
|
|
1344
|
+
res.status(200).json(existing);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
const { width, height } = await getImageDimensions(req.file.buffer);
|
|
1348
|
+
const asset = await prisma.dbAsset.create({ data: {
|
|
1349
|
+
name,
|
|
1350
|
+
checksum,
|
|
1351
|
+
assetType,
|
|
1352
|
+
content: new Uint8Array(req.file.buffer),
|
|
1353
|
+
mimeType: req.file.mimetype,
|
|
1354
|
+
fileSize: req.file.size,
|
|
1355
|
+
width,
|
|
1356
|
+
height
|
|
1357
|
+
} });
|
|
1358
|
+
res.status(201).json({
|
|
1359
|
+
id: asset.id,
|
|
1360
|
+
name: asset.name,
|
|
1361
|
+
assetType: asset.assetType,
|
|
1362
|
+
mimeType: asset.mimeType,
|
|
1363
|
+
fileSize: asset.fileSize,
|
|
1364
|
+
width: asset.width,
|
|
1365
|
+
height: asset.height,
|
|
1366
|
+
createdAt: asset.createdAt
|
|
1367
|
+
});
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
console.error("Error uploading asset:", error);
|
|
1370
|
+
res.status(500).json({ error: "Failed to upload asset" });
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
router$1.get(`${basePath}/:id`, async (req, res) => {
|
|
1374
|
+
try {
|
|
1375
|
+
const { id } = req.params;
|
|
1376
|
+
const asset = await getDbClient().dbAsset.findUnique({
|
|
1377
|
+
where: { id },
|
|
1378
|
+
select: {
|
|
1379
|
+
content: true,
|
|
1380
|
+
mimeType: true,
|
|
1381
|
+
name: true,
|
|
1382
|
+
width: true,
|
|
1383
|
+
height: true
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
if (!asset) {
|
|
1387
|
+
res.status(404).json({ error: "Asset not found" });
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const resizeEnabled = String(process.env.EH_ASSETS_RESIZE_ENABLED || "true") === "true";
|
|
1391
|
+
const wParam = req.query["w"];
|
|
1392
|
+
const width = wParam ? Number.parseInt(wParam, 10) : void 0;
|
|
1393
|
+
let outBuffer = asset.content;
|
|
1394
|
+
let outMime = asset.mimeType;
|
|
1395
|
+
if (resizeEnabled && isRasterImage(asset.mimeType) && !!width && Number.isFinite(width) && width > 0) {
|
|
1396
|
+
const fmt = getImageFormat(asset.mimeType) || "jpeg";
|
|
1397
|
+
const buf = await resizeImage(Buffer.from(asset.content), width, void 0, fmt);
|
|
1398
|
+
outBuffer = new Uint8Array(buf);
|
|
1399
|
+
outMime = `image/${fmt}`;
|
|
1400
|
+
}
|
|
1401
|
+
res.setHeader("Content-Type", outMime);
|
|
1402
|
+
res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
|
|
1403
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
1404
|
+
res.send(outBuffer);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
console.error("Error fetching asset:", error);
|
|
1407
|
+
res.status(500).json({ error: "Failed to fetch asset" });
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
|
|
1411
|
+
try {
|
|
1412
|
+
const { id } = req.params;
|
|
1413
|
+
const asset = await getDbClient().dbAsset.findUnique({
|
|
1414
|
+
where: { id },
|
|
1415
|
+
select: {
|
|
1416
|
+
id: true,
|
|
1417
|
+
name: true,
|
|
1418
|
+
assetType: true,
|
|
1419
|
+
mimeType: true,
|
|
1420
|
+
fileSize: true,
|
|
1421
|
+
width: true,
|
|
1422
|
+
height: true,
|
|
1423
|
+
createdAt: true,
|
|
1424
|
+
updatedAt: true
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
if (!asset) {
|
|
1428
|
+
res.status(404).json({ error: "Asset not found" });
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
res.json(asset);
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
console.error("Error fetching asset metadata:", error);
|
|
1434
|
+
res.status(500).json({ error: "Failed to fetch asset metadata" });
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
router$1.get(`${basePath}/by-name/:name`, async (req, res) => {
|
|
1438
|
+
try {
|
|
1439
|
+
const { name } = req.params;
|
|
1440
|
+
const asset = await getDbClient().dbAsset.findUnique({
|
|
1441
|
+
where: { name },
|
|
1442
|
+
select: {
|
|
1443
|
+
content: true,
|
|
1444
|
+
mimeType: true,
|
|
1445
|
+
name: true,
|
|
1446
|
+
width: true,
|
|
1447
|
+
height: true
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
if (!asset) {
|
|
1451
|
+
res.status(404).json({ error: "Asset not found" });
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const resizeEnabled = String(process.env.EH_ASSETS_RESIZE_ENABLED || "true") === "true";
|
|
1455
|
+
const wParam = req.query["w"];
|
|
1456
|
+
const width = wParam ? Number.parseInt(wParam, 10) : void 0;
|
|
1457
|
+
let outBuffer = asset.content;
|
|
1458
|
+
let outMime = asset.mimeType;
|
|
1459
|
+
const isRaster = asset.mimeType.startsWith("image/") && !asset.mimeType.includes("svg");
|
|
1460
|
+
if (resizeEnabled && isRaster && !!width && Number.isFinite(width) && width > 0) {
|
|
1461
|
+
const fmt = asset.mimeType.includes("png") ? "png" : asset.mimeType.includes("webp") ? "webp" : "jpeg";
|
|
1462
|
+
let buf;
|
|
1463
|
+
const pipeline = sharp(Buffer.from(asset.content)).resize({
|
|
1464
|
+
width,
|
|
1465
|
+
fit: "inside",
|
|
1466
|
+
withoutEnlargement: true
|
|
1467
|
+
});
|
|
1468
|
+
if (fmt === "png") {
|
|
1469
|
+
buf = await pipeline.png().toBuffer();
|
|
1470
|
+
outMime = "image/png";
|
|
1471
|
+
} else if (fmt === "webp") {
|
|
1472
|
+
buf = await pipeline.webp().toBuffer();
|
|
1473
|
+
outMime = "image/webp";
|
|
1474
|
+
} else {
|
|
1475
|
+
buf = await pipeline.jpeg().toBuffer();
|
|
1476
|
+
outMime = "image/jpeg";
|
|
1477
|
+
}
|
|
1478
|
+
outBuffer = new Uint8Array(buf);
|
|
1479
|
+
}
|
|
1480
|
+
res.setHeader("Content-Type", outMime);
|
|
1481
|
+
res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
|
|
1482
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
1483
|
+
res.send(outBuffer);
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
console.error("Error fetching asset by name:", error);
|
|
1486
|
+
res.status(500).json({ error: "Failed to fetch asset" });
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
//#endregion
|
|
1492
|
+
//#region src/modules/assets/screenshotRestController.ts
|
|
1493
|
+
/**
|
|
1494
|
+
* Registers REST endpoints for screenshot retrieval
|
|
1495
|
+
*
|
|
1496
|
+
* Endpoints:
|
|
1497
|
+
* - GET {basePath}/app/:appId - Get all screenshots for an app
|
|
1498
|
+
* - GET {basePath}/:id - Get screenshot binary by ID
|
|
1499
|
+
* - GET {basePath}/:id/metadata - Get screenshot metadata only
|
|
1500
|
+
*/
|
|
1501
|
+
function registerScreenshotRestController(router$1, config) {
|
|
1502
|
+
const { basePath } = config;
|
|
1503
|
+
router$1.get(`${basePath}/app/:appSlug`, async (req, res) => {
|
|
1504
|
+
try {
|
|
1505
|
+
const { appSlug } = req.params;
|
|
1506
|
+
const prisma = getDbClient();
|
|
1507
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
1508
|
+
where: { slug: appSlug },
|
|
1509
|
+
select: { screenshotIds: true }
|
|
1510
|
+
});
|
|
1511
|
+
if (!app) {
|
|
1512
|
+
res.status(404).json({ error: "App not found" });
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const screenshots = await prisma.dbAsset.findMany({
|
|
1516
|
+
where: {
|
|
1517
|
+
id: { in: app.screenshotIds },
|
|
1518
|
+
assetType: "screenshot"
|
|
1519
|
+
},
|
|
1520
|
+
select: {
|
|
1521
|
+
id: true,
|
|
1522
|
+
name: true,
|
|
1523
|
+
mimeType: true,
|
|
1524
|
+
fileSize: true,
|
|
1525
|
+
width: true,
|
|
1526
|
+
height: true,
|
|
1527
|
+
createdAt: true
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
res.json(screenshots);
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
console.error("Error fetching app screenshots:", error);
|
|
1533
|
+
res.status(500).json({ error: "Failed to fetch screenshots" });
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
router$1.get(`${basePath}/app/:appSlug/first`, async (req, res) => {
|
|
1537
|
+
try {
|
|
1538
|
+
const { appSlug } = req.params;
|
|
1539
|
+
const prisma = getDbClient();
|
|
1540
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
1541
|
+
where: { slug: appSlug },
|
|
1542
|
+
select: { screenshotIds: true }
|
|
1543
|
+
});
|
|
1544
|
+
if (!app || app.screenshotIds.length === 0) {
|
|
1545
|
+
res.status(404).json({ error: "No screenshots found" });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
const screenshot = await prisma.dbAsset.findUnique({
|
|
1549
|
+
where: { id: app.screenshotIds[0] },
|
|
1550
|
+
select: {
|
|
1551
|
+
id: true,
|
|
1552
|
+
name: true,
|
|
1553
|
+
mimeType: true,
|
|
1554
|
+
fileSize: true,
|
|
1555
|
+
width: true,
|
|
1556
|
+
height: true,
|
|
1557
|
+
createdAt: true
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
if (!screenshot) {
|
|
1561
|
+
res.status(404).json({ error: "Screenshot not found" });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
res.json(screenshot);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
console.error("Error fetching first screenshot:", error);
|
|
1567
|
+
res.status(500).json({ error: "Failed to fetch screenshot" });
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
router$1.get(`${basePath}/:id`, async (req, res) => {
|
|
1571
|
+
try {
|
|
1572
|
+
const { id } = req.params;
|
|
1573
|
+
const sizeParam = req.query.size;
|
|
1574
|
+
const targetSize = sizeParam ? parseInt(sizeParam, 10) : void 0;
|
|
1575
|
+
const screenshot = await getDbClient().dbAsset.findUnique({
|
|
1576
|
+
where: { id },
|
|
1577
|
+
select: {
|
|
1578
|
+
content: true,
|
|
1579
|
+
mimeType: true,
|
|
1580
|
+
name: true
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
if (!screenshot) {
|
|
1584
|
+
res.status(404).json({ error: "Screenshot not found" });
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
let content = screenshot.content;
|
|
1588
|
+
if (targetSize && targetSize > 0) try {
|
|
1589
|
+
content = await sharp(screenshot.content).resize(targetSize, targetSize, {
|
|
1590
|
+
fit: "inside",
|
|
1591
|
+
withoutEnlargement: true
|
|
1592
|
+
}).toBuffer();
|
|
1593
|
+
} catch (resizeError) {
|
|
1594
|
+
console.error("Error resizing screenshot:", resizeError);
|
|
1595
|
+
}
|
|
1596
|
+
res.setHeader("Content-Type", screenshot.mimeType);
|
|
1597
|
+
res.setHeader("Content-Disposition", `inline; filename="${screenshot.name}"`);
|
|
1598
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
1599
|
+
res.send(content);
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
console.error("Error fetching screenshot:", error);
|
|
1602
|
+
res.status(500).json({ error: "Failed to fetch screenshot" });
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
|
|
1606
|
+
try {
|
|
1607
|
+
const { id } = req.params;
|
|
1608
|
+
const screenshot = await getDbClient().dbAsset.findUnique({
|
|
1609
|
+
where: { id },
|
|
1610
|
+
select: {
|
|
1611
|
+
id: true,
|
|
1612
|
+
name: true,
|
|
1613
|
+
mimeType: true,
|
|
1614
|
+
fileSize: true,
|
|
1615
|
+
width: true,
|
|
1616
|
+
height: true,
|
|
1617
|
+
createdAt: true,
|
|
1618
|
+
updatedAt: true
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
if (!screenshot) {
|
|
1622
|
+
res.status(404).json({ error: "Screenshot not found" });
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
res.json(screenshot);
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
console.error("Error fetching screenshot metadata:", error);
|
|
1628
|
+
res.status(500).json({ error: "Failed to fetch screenshot metadata" });
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
//#endregion
|
|
1634
|
+
//#region src/modules/assets/syncAssets.ts
|
|
1635
|
+
/**
|
|
1636
|
+
* Sync local asset files (icons and screenshots) from directories into the database.
|
|
1637
|
+
*
|
|
1638
|
+
* This function allows consuming applications to sync asset files without directly
|
|
1639
|
+
* exposing the Prisma client. It handles:
|
|
1640
|
+
* - Icon files: Assigned to apps by matching filename to icon name patterns
|
|
1641
|
+
* - Screenshot files: Assigned to apps by matching filename to app ID (format: <app-id>_screenshot_<no>.<ext>)
|
|
1642
|
+
*
|
|
1643
|
+
* @param config Configuration with paths to icon and screenshot directories
|
|
1644
|
+
*/
|
|
1645
|
+
async function syncAssets(config) {
|
|
1646
|
+
const prisma = getDbClient();
|
|
1647
|
+
let iconsUpserted = 0;
|
|
1648
|
+
let screenshotsUpserted = 0;
|
|
1649
|
+
if (config.iconsDir) {
|
|
1650
|
+
console.log(`📁 Syncing icons from ${config.iconsDir}...`);
|
|
1651
|
+
iconsUpserted = await syncIconsFromDirectory(prisma, config.iconsDir);
|
|
1652
|
+
console.log(` ✓ Upserted ${iconsUpserted} icons`);
|
|
1653
|
+
}
|
|
1654
|
+
if (config.screenshotsDir) {
|
|
1655
|
+
console.log(`📷 Syncing screenshots from ${config.screenshotsDir}...`);
|
|
1656
|
+
screenshotsUpserted = await syncScreenshotsFromDirectory(prisma, config.screenshotsDir);
|
|
1657
|
+
console.log(` ✓ Upserted ${screenshotsUpserted} screenshots and assigned to apps`);
|
|
1658
|
+
}
|
|
1659
|
+
return {
|
|
1660
|
+
iconsUpserted,
|
|
1661
|
+
screenshotsUpserted
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Sync icon files from a directory
|
|
1666
|
+
*/
|
|
1667
|
+
async function syncIconsFromDirectory(prisma, iconsDir) {
|
|
1668
|
+
let count = 0;
|
|
1669
|
+
try {
|
|
1670
|
+
const files = readdirSync(iconsDir);
|
|
1671
|
+
for (const file of files) {
|
|
1672
|
+
const filePath = join(iconsDir, file);
|
|
1673
|
+
const ext = extname(file).toLowerCase().slice(1);
|
|
1674
|
+
if (![
|
|
1675
|
+
"png",
|
|
1676
|
+
"jpg",
|
|
1677
|
+
"jpeg",
|
|
1678
|
+
"gif",
|
|
1679
|
+
"webp",
|
|
1680
|
+
"svg"
|
|
1681
|
+
].includes(ext)) continue;
|
|
1682
|
+
try {
|
|
1683
|
+
const content = readFileSync(filePath);
|
|
1684
|
+
const buffer = Buffer.from(content);
|
|
1685
|
+
const checksum = generateChecksum(buffer);
|
|
1686
|
+
const iconName = file.replace(/\.[^/.]+$/, "");
|
|
1687
|
+
if (await prisma.dbAsset.findFirst({ where: {
|
|
1688
|
+
checksum,
|
|
1689
|
+
assetType: "icon"
|
|
1690
|
+
} })) continue;
|
|
1691
|
+
let width = null;
|
|
1692
|
+
let height = null;
|
|
1693
|
+
if (!ext.includes("svg")) {
|
|
1694
|
+
const { width: w, height: h } = await getImageDimensions(buffer);
|
|
1695
|
+
width = w;
|
|
1696
|
+
height = h;
|
|
1697
|
+
}
|
|
1698
|
+
const mimeType = {
|
|
1699
|
+
png: "image/png",
|
|
1700
|
+
jpg: "image/jpeg",
|
|
1701
|
+
jpeg: "image/jpeg",
|
|
1702
|
+
gif: "image/gif",
|
|
1703
|
+
webp: "image/webp",
|
|
1704
|
+
svg: "image/svg+xml"
|
|
1705
|
+
}[ext] || "application/octet-stream";
|
|
1706
|
+
await prisma.dbAsset.create({ data: {
|
|
1707
|
+
name: iconName,
|
|
1708
|
+
assetType: "icon",
|
|
1709
|
+
content: new Uint8Array(buffer),
|
|
1710
|
+
checksum,
|
|
1711
|
+
mimeType,
|
|
1712
|
+
fileSize: buffer.length,
|
|
1713
|
+
width,
|
|
1714
|
+
height
|
|
1715
|
+
} });
|
|
1716
|
+
count++;
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
console.warn(` ⚠ Failed to sync icon ${file}:`, error);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
console.error(` ❌ Error reading icons directory:`, error);
|
|
1723
|
+
}
|
|
1724
|
+
return count;
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Sync screenshot files from a directory and assign to apps
|
|
1728
|
+
*/
|
|
1729
|
+
async function syncScreenshotsFromDirectory(prisma, screenshotsDir) {
|
|
1730
|
+
let count = 0;
|
|
1731
|
+
try {
|
|
1732
|
+
const files = readdirSync(screenshotsDir);
|
|
1733
|
+
const screenshotsByApp = /* @__PURE__ */ new Map();
|
|
1734
|
+
for (const file of files) {
|
|
1735
|
+
const match = file.match(/^(.+?)_screenshot_(\d+)\.([^.]+)$/);
|
|
1736
|
+
if (!match || !match[1] || !match[3]) continue;
|
|
1737
|
+
const appId = match[1];
|
|
1738
|
+
const ext = match[3];
|
|
1739
|
+
if (!screenshotsByApp.has(appId)) screenshotsByApp.set(appId, []);
|
|
1740
|
+
screenshotsByApp.get(appId).push({
|
|
1741
|
+
path: join(screenshotsDir, file),
|
|
1742
|
+
ext
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
for (const [appId, screenshots] of screenshotsByApp) try {
|
|
1746
|
+
if (!await prisma.dbAppForCatalog.findUnique({
|
|
1747
|
+
where: { slug: appId },
|
|
1748
|
+
select: { id: true }
|
|
1749
|
+
})) {
|
|
1750
|
+
console.warn(` ⚠ App not found: ${appId}`);
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1753
|
+
for (const screenshot of screenshots) try {
|
|
1754
|
+
const content = readFileSync(screenshot.path);
|
|
1755
|
+
const buffer = Buffer.from(content);
|
|
1756
|
+
const checksum = generateChecksum(buffer);
|
|
1757
|
+
const existing = await prisma.dbAsset.findFirst({ where: {
|
|
1758
|
+
checksum,
|
|
1759
|
+
assetType: "screenshot"
|
|
1760
|
+
} });
|
|
1761
|
+
if (existing) {
|
|
1762
|
+
const existingApp = await prisma.dbAppForCatalog.findUnique({ where: { slug: appId } });
|
|
1763
|
+
if (existingApp && !existingApp.screenshotIds.includes(existing.id)) await prisma.dbAppForCatalog.update({
|
|
1764
|
+
where: { slug: appId },
|
|
1765
|
+
data: { screenshotIds: [...existingApp.screenshotIds, existing.id] }
|
|
1766
|
+
});
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
const { width, height } = await getImageDimensions(buffer);
|
|
1770
|
+
const mimeType = {
|
|
1771
|
+
png: "image/png",
|
|
1772
|
+
jpg: "image/jpeg",
|
|
1773
|
+
jpeg: "image/jpeg",
|
|
1774
|
+
gif: "image/gif",
|
|
1775
|
+
webp: "image/webp"
|
|
1776
|
+
}[screenshot.ext.toLowerCase()] || "application/octet-stream";
|
|
1777
|
+
const asset = await prisma.dbAsset.create({ data: {
|
|
1778
|
+
name: `${appId}-screenshot-${Date.now()}`,
|
|
1779
|
+
assetType: "screenshot",
|
|
1780
|
+
content: new Uint8Array(buffer),
|
|
1781
|
+
checksum,
|
|
1782
|
+
mimeType,
|
|
1783
|
+
fileSize: buffer.length,
|
|
1784
|
+
width,
|
|
1785
|
+
height
|
|
1786
|
+
} });
|
|
1787
|
+
await prisma.dbAppForCatalog.update({
|
|
1788
|
+
where: { slug: appId },
|
|
1789
|
+
data: { screenshotIds: { push: asset.id } }
|
|
1790
|
+
});
|
|
1791
|
+
count++;
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
console.warn(` ⚠ Failed to sync screenshot ${screenshot.path}:`, error);
|
|
1794
|
+
}
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
console.warn(` ⚠ Failed to process app ${appId}:`, error);
|
|
1797
|
+
}
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
console.error(` ❌ Error reading screenshots directory:`, error);
|
|
1800
|
+
}
|
|
1801
|
+
return count;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
//#endregion
|
|
1805
|
+
export { DEFAULT_ADMIN_SYSTEM_PROMPT, TABLE_SYNC_MAGAZINE, connectDb, createAdminChatHandler, createAppCatalogAdminRouter, createAuth, createAuthRouter, createDatabaseTools, createEhTrpcContext, createPrismaDatabaseClient, createScreenshotRouter, createTrpcRouter, disconnectDb, getAdminGroupsFromEnv, getAssetByName, getAuthPluginsFromEnv, getAuthProvidersFromEnv, getDbClient, getUserGroups, isAdmin, isMemberOfAllGroups, isMemberOfAnyGroup, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, requireAdmin, requireGroups, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, tool, upsertIcon, upsertIcons, validateAuthConfig };
|
|
1806
|
+
//# sourceMappingURL=index.js.map
|