@igstack/app-catalog-backend-core 0.0.1
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/LICENSE +21 -0
- package/dist/index.d.ts +1934 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
- package/prisma/migrations/20250526183023_init/migration.sql +71 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +149 -0
- package/src/__tests__/dummy.test.ts +7 -0
- package/src/db/client.ts +42 -0
- package/src/db/index.ts +21 -0
- package/src/db/syncAppCatalog.ts +312 -0
- package/src/db/tableSyncMagazine.ts +32 -0
- package/src/db/tableSyncPrismaAdapter.ts +203 -0
- package/src/index.ts +126 -0
- package/src/middleware/backendResolver.ts +42 -0
- package/src/middleware/createEhMiddleware.ts +171 -0
- package/src/middleware/database.ts +62 -0
- package/src/middleware/featureRegistry.ts +173 -0
- package/src/middleware/index.ts +43 -0
- package/src/middleware/types.ts +202 -0
- package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
- package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
- package/src/modules/appCatalog/service.ts +130 -0
- package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
- package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
- package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
- package/src/modules/approvalMethod/slugUtils.ts +17 -0
- package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
- package/src/modules/assets/assetRestController.ts +271 -0
- package/src/modules/assets/assetUtils.ts +114 -0
- package/src/modules/assets/screenshotRestController.ts +195 -0
- package/src/modules/assets/screenshotRouter.ts +112 -0
- package/src/modules/assets/syncAssets.ts +277 -0
- package/src/modules/assets/upsertAsset.ts +46 -0
- package/src/modules/auth/auth.ts +51 -0
- package/src/modules/auth/authProviders.ts +40 -0
- package/src/modules/auth/authRouter.ts +75 -0
- package/src/modules/auth/authorizationUtils.ts +132 -0
- package/src/modules/auth/devMockUserUtils.ts +49 -0
- package/src/modules/auth/registerAuthRoutes.ts +33 -0
- package/src/modules/icons/iconRestController.ts +171 -0
- package/src/modules/icons/iconRouter.ts +180 -0
- package/src/modules/icons/iconService.ts +73 -0
- package/src/modules/icons/iconUtils.ts +46 -0
- package/src/prisma-json-types.d.ts +34 -0
- package/src/server/controller.ts +47 -0
- package/src/server/ehStaticControllerContract.ts +19 -0
- package/src/server/ehTrpcContext.ts +26 -0
- package/src/server/trpcSetup.ts +89 -0
- package/src/types/backend/api.ts +73 -0
- package/src/types/backend/common.ts +10 -0
- package/src/types/backend/companySpecificBackend.ts +5 -0
- package/src/types/backend/dataSources.ts +25 -0
- package/src/types/backend/deployments.ts +40 -0
- package/src/types/common/app/appTypes.ts +13 -0
- package/src/types/common/app/ui/appUiTypes.ts +12 -0
- package/src/types/common/appCatalogTypes.ts +65 -0
- package/src/types/common/approvalMethodTypes.ts +149 -0
- package/src/types/common/env/envTypes.ts +7 -0
- package/src/types/common/resourceTypes.ts +8 -0
- package/src/types/common/sharedTypes.ts +5 -0
- package/src/types/index.ts +21 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { FormatEnum } from 'sharp'
|
|
3
|
+
import sharp from 'sharp'
|
|
4
|
+
import type { ParseAssetParams, ParseAssetReturn } from './assetRestController'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract image dimensions from a buffer using sharp
|
|
8
|
+
*/
|
|
9
|
+
export async function getImageDimensions(
|
|
10
|
+
buffer: Buffer,
|
|
11
|
+
): Promise<{ width?: number; height?: number }> {
|
|
12
|
+
try {
|
|
13
|
+
const metadata = await sharp(buffer).metadata()
|
|
14
|
+
return {
|
|
15
|
+
width: metadata.width,
|
|
16
|
+
height: metadata.height,
|
|
17
|
+
}
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error('Error extracting image dimensions:', error)
|
|
20
|
+
return { width: undefined, height: undefined }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resize an image buffer to the specified dimensions
|
|
26
|
+
* @param buffer - The image buffer to resize
|
|
27
|
+
* @param width - Target width (optional)
|
|
28
|
+
* @param height - Target height (optional)
|
|
29
|
+
* @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
|
|
30
|
+
*/
|
|
31
|
+
export async function resizeImage(
|
|
32
|
+
buffer: Buffer,
|
|
33
|
+
width?: number,
|
|
34
|
+
height?: number,
|
|
35
|
+
format?: 'png' | 'jpeg' | 'webp',
|
|
36
|
+
): Promise<Buffer> {
|
|
37
|
+
let pipeline = sharp(buffer)
|
|
38
|
+
|
|
39
|
+
// Apply resize if dimensions provided
|
|
40
|
+
if (width || height) {
|
|
41
|
+
pipeline = pipeline.resize({
|
|
42
|
+
width,
|
|
43
|
+
height,
|
|
44
|
+
fit: 'inside',
|
|
45
|
+
withoutEnlargement: true,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Apply format conversion if specified
|
|
50
|
+
if (format === 'png') {
|
|
51
|
+
pipeline = pipeline.png()
|
|
52
|
+
} else if (format === 'webp') {
|
|
53
|
+
pipeline = pipeline.webp()
|
|
54
|
+
} else if (format === 'jpeg') {
|
|
55
|
+
pipeline = pipeline.jpeg()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return pipeline.toBuffer()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate SHA-256 checksum for a buffer
|
|
63
|
+
*/
|
|
64
|
+
export function generateChecksum(buffer: Buffer): string {
|
|
65
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detect image format from mime type
|
|
70
|
+
*/
|
|
71
|
+
export function getImageFormat(
|
|
72
|
+
mimeType: string,
|
|
73
|
+
): 'png' | 'webp' | 'jpeg' | null {
|
|
74
|
+
if (mimeType.includes('png')) return 'png'
|
|
75
|
+
if (mimeType.includes('webp')) return 'webp'
|
|
76
|
+
if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a mime type represents a raster image (not SVG)
|
|
82
|
+
*/
|
|
83
|
+
export function isRasterImage(mimeType: string): boolean {
|
|
84
|
+
return mimeType.startsWith('image/') && !mimeType.includes('svg')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function parseAssetMeta(
|
|
88
|
+
p: ParseAssetParams,
|
|
89
|
+
): Promise<ParseAssetReturn> {
|
|
90
|
+
// Get image dimensions using our utility
|
|
91
|
+
const { width, height, format, size } = await sharp(p.buffer).metadata()
|
|
92
|
+
|
|
93
|
+
const formatToMime: Partial<Record<keyof FormatEnum, string>> = {
|
|
94
|
+
jpeg: 'image/jpeg',
|
|
95
|
+
jpg: 'image/jpeg',
|
|
96
|
+
png: 'image/png',
|
|
97
|
+
webp: 'image/webp',
|
|
98
|
+
avif: 'image/avif',
|
|
99
|
+
tiff: 'image/tiff',
|
|
100
|
+
gif: 'image/gif',
|
|
101
|
+
heif: 'image/heif',
|
|
102
|
+
raw: 'application/octet-stream',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
checksum: generateChecksum(p.buffer),
|
|
107
|
+
width,
|
|
108
|
+
height,
|
|
109
|
+
mimeType: format
|
|
110
|
+
? (formatToMime[format] ?? `image/${format}`)
|
|
111
|
+
: 'application/octet-stream',
|
|
112
|
+
fileSize: size || 0,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { Request, Response, Router } from 'express'
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import { getDbClient } from '../../db'
|
|
4
|
+
|
|
5
|
+
export interface ScreenshotRestControllerConfig {
|
|
6
|
+
/**
|
|
7
|
+
* Base path for screenshot endpoints (e.g., '/api/screenshots')
|
|
8
|
+
*/
|
|
9
|
+
basePath: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registers REST endpoints for screenshot retrieval
|
|
14
|
+
*
|
|
15
|
+
* Endpoints:
|
|
16
|
+
* - GET {basePath}/app/:appId - Get all screenshots for an app
|
|
17
|
+
* - GET {basePath}/:id - Get screenshot binary by ID
|
|
18
|
+
* - GET {basePath}/:id/metadata - Get screenshot metadata only
|
|
19
|
+
*/
|
|
20
|
+
export function registerScreenshotRestController(
|
|
21
|
+
router: Router,
|
|
22
|
+
config: ScreenshotRestControllerConfig,
|
|
23
|
+
): void {
|
|
24
|
+
const { basePath } = config
|
|
25
|
+
|
|
26
|
+
// Get all screenshots for an app
|
|
27
|
+
router.get(`${basePath}/app/:appSlug`, async (req: Request, res: Response) => {
|
|
28
|
+
try {
|
|
29
|
+
const { appSlug } = req.params
|
|
30
|
+
|
|
31
|
+
const prisma = getDbClient()
|
|
32
|
+
|
|
33
|
+
// Find app by slug
|
|
34
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
35
|
+
where: { slug: appSlug },
|
|
36
|
+
select: { screenshotIds: true },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (!app) {
|
|
40
|
+
res.status(404).json({ error: 'App not found' })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fetch all screenshots for the app
|
|
45
|
+
const screenshots = await prisma.dbAsset.findMany({
|
|
46
|
+
where: {
|
|
47
|
+
id: { in: app.screenshotIds },
|
|
48
|
+
assetType: 'screenshot',
|
|
49
|
+
},
|
|
50
|
+
select: {
|
|
51
|
+
id: true,
|
|
52
|
+
name: true,
|
|
53
|
+
mimeType: true,
|
|
54
|
+
fileSize: true,
|
|
55
|
+
width: true,
|
|
56
|
+
height: true,
|
|
57
|
+
createdAt: true,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
res.json(screenshots)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Error fetching app screenshots:', error)
|
|
64
|
+
res.status(500).json({ error: 'Failed to fetch screenshots' })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Get first screenshot for an app (convenience endpoint)
|
|
69
|
+
router.get(`${basePath}/app/:appSlug/first`, async (req: Request, res: Response) => {
|
|
70
|
+
try {
|
|
71
|
+
const { appSlug } = req.params
|
|
72
|
+
|
|
73
|
+
const prisma = getDbClient()
|
|
74
|
+
|
|
75
|
+
// Find app by slug
|
|
76
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
77
|
+
where: { slug: appSlug },
|
|
78
|
+
select: { screenshotIds: true },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!app || app.screenshotIds.length === 0) {
|
|
82
|
+
res.status(404).json({ error: 'No screenshots found' })
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fetch first screenshot
|
|
87
|
+
const screenshot = await prisma.dbAsset.findUnique({
|
|
88
|
+
where: { id: app.screenshotIds[0] },
|
|
89
|
+
select: {
|
|
90
|
+
id: true,
|
|
91
|
+
name: true,
|
|
92
|
+
mimeType: true,
|
|
93
|
+
fileSize: true,
|
|
94
|
+
width: true,
|
|
95
|
+
height: true,
|
|
96
|
+
createdAt: true,
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!screenshot) {
|
|
101
|
+
res.status(404).json({ error: 'Screenshot not found' })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
res.json(screenshot)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Error fetching first screenshot:', error)
|
|
108
|
+
res.status(500).json({ error: 'Failed to fetch screenshot' })
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Get screenshot binary by ID
|
|
113
|
+
router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
|
|
114
|
+
try {
|
|
115
|
+
const { id } = req.params
|
|
116
|
+
const sizeParam = req.query.size as string | undefined
|
|
117
|
+
const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined
|
|
118
|
+
|
|
119
|
+
const prisma = getDbClient()
|
|
120
|
+
const screenshot = await prisma.dbAsset.findUnique({
|
|
121
|
+
where: { id },
|
|
122
|
+
select: {
|
|
123
|
+
content: true,
|
|
124
|
+
mimeType: true,
|
|
125
|
+
name: true,
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
if (!screenshot) {
|
|
130
|
+
res.status(404).json({ error: 'Screenshot not found' })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let content: Uint8Array | Buffer = screenshot.content
|
|
135
|
+
|
|
136
|
+
// Resize if size parameter provided
|
|
137
|
+
if (targetSize && targetSize > 0) {
|
|
138
|
+
try {
|
|
139
|
+
content = await sharp(screenshot.content)
|
|
140
|
+
.resize(targetSize, targetSize, {
|
|
141
|
+
fit: 'inside',
|
|
142
|
+
withoutEnlargement: true,
|
|
143
|
+
})
|
|
144
|
+
.toBuffer()
|
|
145
|
+
} catch (resizeError) {
|
|
146
|
+
console.error('Error resizing screenshot:', resizeError)
|
|
147
|
+
// Fall back to original if resize fails
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Set appropriate headers
|
|
152
|
+
res.setHeader('Content-Type', screenshot.mimeType)
|
|
153
|
+
res.setHeader('Content-Disposition', `inline; filename="${screenshot.name}"`)
|
|
154
|
+
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
155
|
+
|
|
156
|
+
// Send binary content
|
|
157
|
+
res.send(content)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error('Error fetching screenshot:', error)
|
|
160
|
+
res.status(500).json({ error: 'Failed to fetch screenshot' })
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Get screenshot metadata only (no binary content)
|
|
165
|
+
router.get(`${basePath}/:id/metadata`, async (req: Request, res: Response) => {
|
|
166
|
+
try {
|
|
167
|
+
const { id } = req.params
|
|
168
|
+
|
|
169
|
+
const prisma = getDbClient()
|
|
170
|
+
const screenshot = await prisma.dbAsset.findUnique({
|
|
171
|
+
where: { id },
|
|
172
|
+
select: {
|
|
173
|
+
id: true,
|
|
174
|
+
name: true,
|
|
175
|
+
mimeType: true,
|
|
176
|
+
fileSize: true,
|
|
177
|
+
width: true,
|
|
178
|
+
height: true,
|
|
179
|
+
createdAt: true,
|
|
180
|
+
updatedAt: true,
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
if (!screenshot) {
|
|
185
|
+
res.status(404).json({ error: 'Screenshot not found' })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
res.json(screenshot)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error('Error fetching screenshot metadata:', error)
|
|
192
|
+
res.status(500).json({ error: 'Failed to fetch screenshot metadata' })
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { getDbClient } from '../../db'
|
|
3
|
+
import { publicProcedure, router } from '../../server/trpcSetup'
|
|
4
|
+
|
|
5
|
+
export function createScreenshotRouter() {
|
|
6
|
+
return router({
|
|
7
|
+
list: publicProcedure.query(async () => {
|
|
8
|
+
const prisma = getDbClient()
|
|
9
|
+
return prisma.dbAsset.findMany({
|
|
10
|
+
where: { assetType: 'screenshot' },
|
|
11
|
+
select: {
|
|
12
|
+
id: true,
|
|
13
|
+
name: true,
|
|
14
|
+
mimeType: true,
|
|
15
|
+
fileSize: true,
|
|
16
|
+
width: true,
|
|
17
|
+
height: true,
|
|
18
|
+
createdAt: true,
|
|
19
|
+
updatedAt: true,
|
|
20
|
+
},
|
|
21
|
+
orderBy: { createdAt: 'desc' },
|
|
22
|
+
})
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
getOne: publicProcedure
|
|
26
|
+
.input(z.object({ id: z.string() }))
|
|
27
|
+
.query(async ({ input }) => {
|
|
28
|
+
const prisma = getDbClient()
|
|
29
|
+
return prisma.dbAsset.findFirst({
|
|
30
|
+
where: {
|
|
31
|
+
id: input.id,
|
|
32
|
+
assetType: 'screenshot',
|
|
33
|
+
},
|
|
34
|
+
select: {
|
|
35
|
+
id: true,
|
|
36
|
+
name: true,
|
|
37
|
+
mimeType: true,
|
|
38
|
+
fileSize: true,
|
|
39
|
+
width: true,
|
|
40
|
+
height: true,
|
|
41
|
+
createdAt: true,
|
|
42
|
+
updatedAt: true,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
getByAppSlug: publicProcedure
|
|
48
|
+
.input(z.object({ appSlug: z.string() }))
|
|
49
|
+
.query(async ({ input }) => {
|
|
50
|
+
const prisma = getDbClient()
|
|
51
|
+
|
|
52
|
+
// Find app by slug
|
|
53
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
54
|
+
where: { slug: input.appSlug },
|
|
55
|
+
select: { screenshotIds: true },
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (!app) {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch all screenshots for the app
|
|
63
|
+
return prisma.dbAsset.findMany({
|
|
64
|
+
where: {
|
|
65
|
+
id: { in: app.screenshotIds },
|
|
66
|
+
assetType: 'screenshot',
|
|
67
|
+
},
|
|
68
|
+
select: {
|
|
69
|
+
id: true,
|
|
70
|
+
name: true,
|
|
71
|
+
mimeType: true,
|
|
72
|
+
fileSize: true,
|
|
73
|
+
width: true,
|
|
74
|
+
height: true,
|
|
75
|
+
createdAt: true,
|
|
76
|
+
updatedAt: true,
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
getFirstByAppSlug: publicProcedure
|
|
82
|
+
.input(z.object({ appSlug: z.string() }))
|
|
83
|
+
.query(async ({ input }) => {
|
|
84
|
+
const prisma = getDbClient()
|
|
85
|
+
|
|
86
|
+
// Find app by slug
|
|
87
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
88
|
+
where: { slug: input.appSlug },
|
|
89
|
+
select: { screenshotIds: true },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!app || app.screenshotIds.length === 0) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Fetch first screenshot
|
|
97
|
+
return prisma.dbAsset.findUnique({
|
|
98
|
+
where: { id: app.screenshotIds[0] },
|
|
99
|
+
select: {
|
|
100
|
+
id: true,
|
|
101
|
+
name: true,
|
|
102
|
+
mimeType: true,
|
|
103
|
+
fileSize: true,
|
|
104
|
+
width: true,
|
|
105
|
+
height: true,
|
|
106
|
+
createdAt: true,
|
|
107
|
+
updatedAt: true,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
}),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { extname, join } from 'node:path'
|
|
3
|
+
import { getDbClient } from '../../db'
|
|
4
|
+
import { generateChecksum, getImageDimensions } from './assetUtils'
|
|
5
|
+
|
|
6
|
+
export interface SyncAssetsConfig {
|
|
7
|
+
/**
|
|
8
|
+
* Directory containing icon files to sync
|
|
9
|
+
*/
|
|
10
|
+
iconsDir?: string
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Directory containing screenshot files to sync
|
|
14
|
+
*/
|
|
15
|
+
screenshotsDir?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sync local asset files (icons and screenshots) from directories into the database.
|
|
20
|
+
*
|
|
21
|
+
* This function allows consuming applications to sync asset files without directly
|
|
22
|
+
* exposing the Prisma client. It handles:
|
|
23
|
+
* - Icon files: Assigned to apps by matching filename to icon name patterns
|
|
24
|
+
* - Screenshot files: Assigned to apps by matching filename to app ID (format: <app-id>_screenshot_<no>.<ext>)
|
|
25
|
+
*
|
|
26
|
+
* @param config Configuration with paths to icon and screenshot directories
|
|
27
|
+
*/
|
|
28
|
+
export async function syncAssets(config: SyncAssetsConfig): Promise<{
|
|
29
|
+
iconsUpserted: number
|
|
30
|
+
screenshotsUpserted: number
|
|
31
|
+
}> {
|
|
32
|
+
const prisma = getDbClient()
|
|
33
|
+
let iconsUpserted = 0
|
|
34
|
+
let screenshotsUpserted = 0
|
|
35
|
+
|
|
36
|
+
// Sync icons from local/icons directory
|
|
37
|
+
if (config.iconsDir) {
|
|
38
|
+
console.log(`📁 Syncing icons from ${config.iconsDir}...`)
|
|
39
|
+
iconsUpserted = await syncIconsFromDirectory(prisma, config.iconsDir)
|
|
40
|
+
console.log(` ✓ Upserted ${iconsUpserted} icons`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sync screenshots from local/screenshots directory
|
|
44
|
+
if (config.screenshotsDir) {
|
|
45
|
+
console.log(`📷 Syncing screenshots from ${config.screenshotsDir}...`)
|
|
46
|
+
screenshotsUpserted = await syncScreenshotsFromDirectory(
|
|
47
|
+
prisma,
|
|
48
|
+
config.screenshotsDir,
|
|
49
|
+
)
|
|
50
|
+
console.log(
|
|
51
|
+
` ✓ Upserted ${screenshotsUpserted} screenshots and assigned to apps`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
iconsUpserted,
|
|
57
|
+
screenshotsUpserted,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sync icon files from a directory
|
|
63
|
+
*/
|
|
64
|
+
async function syncIconsFromDirectory(
|
|
65
|
+
prisma: ReturnType<typeof getDbClient>,
|
|
66
|
+
iconsDir: string,
|
|
67
|
+
): Promise<number> {
|
|
68
|
+
let count = 0
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const files = readdirSync(iconsDir)
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const filePath = join(iconsDir, file)
|
|
75
|
+
const ext = extname(file).toLowerCase().slice(1) // Remove leading dot
|
|
76
|
+
|
|
77
|
+
// Skip non-image files
|
|
78
|
+
if (!['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const content = readFileSync(filePath)
|
|
84
|
+
const buffer = Buffer.from(content)
|
|
85
|
+
const checksum = generateChecksum(buffer)
|
|
86
|
+
const iconName = file.replace(/\.[^/.]+$/, '') // Remove extension
|
|
87
|
+
|
|
88
|
+
// Check if asset with same checksum already exists
|
|
89
|
+
const existing = await prisma.dbAsset.findFirst({
|
|
90
|
+
where: { checksum, assetType: 'icon' },
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (existing) {
|
|
94
|
+
continue // Already synced
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Extract dimensions for raster images
|
|
98
|
+
let width: number | null = null
|
|
99
|
+
let height: number | null = null
|
|
100
|
+
if (!ext.includes('svg')) {
|
|
101
|
+
const { width: w, height: h } = await getImageDimensions(buffer)
|
|
102
|
+
width = w ?? null
|
|
103
|
+
height = h ?? null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Determine MIME type
|
|
107
|
+
const mimeType =
|
|
108
|
+
{
|
|
109
|
+
png: 'image/png',
|
|
110
|
+
jpg: 'image/jpeg',
|
|
111
|
+
jpeg: 'image/jpeg',
|
|
112
|
+
gif: 'image/gif',
|
|
113
|
+
webp: 'image/webp',
|
|
114
|
+
svg: 'image/svg+xml',
|
|
115
|
+
}[ext] || 'application/octet-stream'
|
|
116
|
+
|
|
117
|
+
await prisma.dbAsset.create({
|
|
118
|
+
data: {
|
|
119
|
+
name: iconName,
|
|
120
|
+
assetType: 'icon',
|
|
121
|
+
content: new Uint8Array(buffer),
|
|
122
|
+
checksum,
|
|
123
|
+
mimeType,
|
|
124
|
+
fileSize: buffer.length,
|
|
125
|
+
width,
|
|
126
|
+
height,
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
count++
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn(` ⚠ Failed to sync icon ${file}:`, error)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error(` ❌ Error reading icons directory:`, error)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return count
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Sync screenshot files from a directory and assign to apps
|
|
144
|
+
*/
|
|
145
|
+
async function syncScreenshotsFromDirectory(
|
|
146
|
+
prisma: ReturnType<typeof getDbClient>,
|
|
147
|
+
screenshotsDir: string,
|
|
148
|
+
): Promise<number> {
|
|
149
|
+
let count = 0
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const files = readdirSync(screenshotsDir)
|
|
153
|
+
|
|
154
|
+
// Group screenshots by app ID
|
|
155
|
+
const screenshotsByApp = new Map<
|
|
156
|
+
string,
|
|
157
|
+
Array<{ path: string; ext: string }>
|
|
158
|
+
>()
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
// Parse filename: <app-id>_screenshot_<no>.<ext>
|
|
162
|
+
const match = file.match(/^(.+?)_screenshot_(\d+)\.([^.]+)$/)
|
|
163
|
+
if (!match || !match[1] || !match[3]) {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const appId = match[1]
|
|
168
|
+
const ext = match[3]
|
|
169
|
+
if (!screenshotsByApp.has(appId)) {
|
|
170
|
+
screenshotsByApp.set(appId, [])
|
|
171
|
+
}
|
|
172
|
+
screenshotsByApp.get(appId)!.push({
|
|
173
|
+
path: join(screenshotsDir, file),
|
|
174
|
+
ext,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Process each app's screenshots
|
|
179
|
+
for (const [appId, screenshots] of screenshotsByApp) {
|
|
180
|
+
try {
|
|
181
|
+
// Check if app exists
|
|
182
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
183
|
+
where: { slug: appId },
|
|
184
|
+
select: { id: true },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
if (!app) {
|
|
188
|
+
console.warn(` ⚠ App not found: ${appId}`)
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Sync screenshots for this app
|
|
193
|
+
for (const screenshot of screenshots) {
|
|
194
|
+
try {
|
|
195
|
+
const content = readFileSync(screenshot.path)
|
|
196
|
+
const buffer = Buffer.from(content)
|
|
197
|
+
const checksum = generateChecksum(buffer)
|
|
198
|
+
|
|
199
|
+
// Check if screenshot with same checksum already exists
|
|
200
|
+
const existing = await prisma.dbAsset.findFirst({
|
|
201
|
+
where: { checksum, assetType: 'screenshot' },
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
if (existing) {
|
|
205
|
+
// Link to app via screenshotIds array if not already linked
|
|
206
|
+
const existingApp = await prisma.dbAppForCatalog.findUnique({
|
|
207
|
+
where: { slug: appId },
|
|
208
|
+
})
|
|
209
|
+
if (
|
|
210
|
+
existingApp &&
|
|
211
|
+
!existingApp.screenshotIds.includes(existing.id)
|
|
212
|
+
) {
|
|
213
|
+
await prisma.dbAppForCatalog.update({
|
|
214
|
+
where: { slug: appId },
|
|
215
|
+
data: {
|
|
216
|
+
screenshotIds: [...existingApp.screenshotIds, existing.id],
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Extract dimensions
|
|
224
|
+
const { width, height } = await getImageDimensions(buffer)
|
|
225
|
+
|
|
226
|
+
// Determine MIME type
|
|
227
|
+
const mimeType =
|
|
228
|
+
{
|
|
229
|
+
png: 'image/png',
|
|
230
|
+
jpg: 'image/jpeg',
|
|
231
|
+
jpeg: 'image/jpeg',
|
|
232
|
+
gif: 'image/gif',
|
|
233
|
+
webp: 'image/webp',
|
|
234
|
+
}[screenshot.ext.toLowerCase()] || 'application/octet-stream'
|
|
235
|
+
|
|
236
|
+
// Create screenshot asset
|
|
237
|
+
const asset = await prisma.dbAsset.create({
|
|
238
|
+
data: {
|
|
239
|
+
name: `${appId}-screenshot-${Date.now()}`,
|
|
240
|
+
assetType: 'screenshot',
|
|
241
|
+
content: new Uint8Array(buffer),
|
|
242
|
+
checksum,
|
|
243
|
+
mimeType,
|
|
244
|
+
fileSize: buffer.length,
|
|
245
|
+
width: width ?? null,
|
|
246
|
+
height: height ?? null,
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Link screenshot to app via screenshotIds array
|
|
251
|
+
await prisma.dbAppForCatalog.update({
|
|
252
|
+
where: { slug: appId },
|
|
253
|
+
data: {
|
|
254
|
+
screenshotIds: {
|
|
255
|
+
push: asset.id,
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
count++
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.warn(
|
|
263
|
+
` ⚠ Failed to sync screenshot ${screenshot.path}:`,
|
|
264
|
+
error,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.warn(` ⚠ Failed to process app ${appId}:`, error)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error(` ❌ Error reading screenshots directory:`, error)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return count
|
|
277
|
+
}
|