@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha.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/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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { AssetType } from '@prisma/client'
|
|
2
|
+
import type { Request, Response, Router } from 'express'
|
|
3
|
+
import multer from 'multer'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import { getDbClient } from '../../db'
|
|
6
|
+
import {
|
|
7
|
+
generateChecksum,
|
|
8
|
+
getImageDimensions,
|
|
9
|
+
getImageFormat,
|
|
10
|
+
isRasterImage,
|
|
11
|
+
resizeImage,
|
|
12
|
+
} from './assetUtils'
|
|
13
|
+
|
|
14
|
+
// Configure multer for memory storage
|
|
15
|
+
const upload = multer({
|
|
16
|
+
storage: multer.memoryStorage(),
|
|
17
|
+
limits: {
|
|
18
|
+
fileSize: 10 * 1024 * 1024, // 10MB limit
|
|
19
|
+
},
|
|
20
|
+
fileFilter: (_req, file, cb) => {
|
|
21
|
+
// Accept images only
|
|
22
|
+
if (!file.mimetype.startsWith('image/')) {
|
|
23
|
+
cb(new Error('Only image files are allowed'))
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
cb(null, true)
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export interface AssetRestControllerConfig {
|
|
31
|
+
/**
|
|
32
|
+
* Base path for asset endpoints (e.g., '/api/assets')
|
|
33
|
+
*/
|
|
34
|
+
basePath: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Registers REST endpoints for universal asset upload and retrieval
|
|
39
|
+
*
|
|
40
|
+
* Endpoints:
|
|
41
|
+
* - POST {basePath}/upload - Upload a new asset (multipart/form-data)
|
|
42
|
+
* - GET {basePath}/:id - Get asset binary by ID
|
|
43
|
+
* - GET {basePath}/:id/metadata - Get asset metadata only
|
|
44
|
+
* - GET {basePath}/by-name/:name - Get asset binary by name
|
|
45
|
+
*/
|
|
46
|
+
export function registerAssetRestController(
|
|
47
|
+
router: Router,
|
|
48
|
+
config: AssetRestControllerConfig,
|
|
49
|
+
): void {
|
|
50
|
+
const { basePath } = config
|
|
51
|
+
|
|
52
|
+
// Upload endpoint - accepts multipart/form-data
|
|
53
|
+
router.post(
|
|
54
|
+
`${basePath}/upload`,
|
|
55
|
+
upload.single('asset'),
|
|
56
|
+
async (req: Request, res: Response) => {
|
|
57
|
+
try {
|
|
58
|
+
if (!req.file) {
|
|
59
|
+
res.status(400).json({ error: 'No file uploaded' })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const name = req.body['name'] as string
|
|
64
|
+
const assetTypeInput = req.body['assetType']
|
|
65
|
+
const assetType = (assetTypeInput as AssetType | undefined) ?? 'icon'
|
|
66
|
+
|
|
67
|
+
if (!name) {
|
|
68
|
+
res.status(400).json({ error: 'Name is required' })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const prisma = getDbClient()
|
|
73
|
+
|
|
74
|
+
// Compute checksum of the binary for content-based deduplication.
|
|
75
|
+
const checksum = generateChecksum(req.file.buffer)
|
|
76
|
+
|
|
77
|
+
// If an asset with the same checksum already exists, reuse it instead of storing duplicate binary.
|
|
78
|
+
const existing = await prisma.dbAsset.findUnique({
|
|
79
|
+
where: { checksum },
|
|
80
|
+
select: {
|
|
81
|
+
id: true,
|
|
82
|
+
name: true,
|
|
83
|
+
assetType: true,
|
|
84
|
+
checksum: true,
|
|
85
|
+
mimeType: true,
|
|
86
|
+
fileSize: true,
|
|
87
|
+
width: true,
|
|
88
|
+
height: true,
|
|
89
|
+
createdAt: true,
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (existing) {
|
|
94
|
+
res.status(200).json(existing)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get image dimensions using our utility
|
|
99
|
+
const { width, height } = await getImageDimensions(req.file.buffer)
|
|
100
|
+
|
|
101
|
+
const asset = await prisma.dbAsset.create({
|
|
102
|
+
data: {
|
|
103
|
+
name,
|
|
104
|
+
checksum,
|
|
105
|
+
assetType,
|
|
106
|
+
content: new Uint8Array(req.file.buffer),
|
|
107
|
+
mimeType: req.file.mimetype,
|
|
108
|
+
fileSize: req.file.size,
|
|
109
|
+
width,
|
|
110
|
+
height,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
res.status(201).json({
|
|
115
|
+
id: asset.id,
|
|
116
|
+
name: asset.name,
|
|
117
|
+
assetType: asset.assetType,
|
|
118
|
+
mimeType: asset.mimeType,
|
|
119
|
+
fileSize: asset.fileSize,
|
|
120
|
+
width: asset.width,
|
|
121
|
+
height: asset.height,
|
|
122
|
+
createdAt: asset.createdAt,
|
|
123
|
+
})
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Error uploading asset:', error)
|
|
126
|
+
res.status(500).json({ error: 'Failed to upload asset' })
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Get asset binary by ID
|
|
132
|
+
router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
|
|
133
|
+
try {
|
|
134
|
+
const { id } = req.params
|
|
135
|
+
|
|
136
|
+
const prisma = getDbClient()
|
|
137
|
+
const asset = await prisma.dbAsset.findUnique({
|
|
138
|
+
where: { id },
|
|
139
|
+
select: {
|
|
140
|
+
content: true,
|
|
141
|
+
mimeType: true,
|
|
142
|
+
name: true,
|
|
143
|
+
width: true,
|
|
144
|
+
height: true,
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (!asset) {
|
|
149
|
+
res.status(404).json({ error: 'Asset not found' })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const resizeEnabled =
|
|
154
|
+
String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'
|
|
155
|
+
const wParam = req.query['w'] as string | undefined
|
|
156
|
+
const width = wParam ? Number.parseInt(wParam, 10) : undefined
|
|
157
|
+
|
|
158
|
+
let outBuffer: Uint8Array = asset.content
|
|
159
|
+
let outMime = asset.mimeType
|
|
160
|
+
|
|
161
|
+
const shouldResize =
|
|
162
|
+
resizeEnabled &&
|
|
163
|
+
isRasterImage(asset.mimeType) &&
|
|
164
|
+
!!width &&
|
|
165
|
+
Number.isFinite(width) &&
|
|
166
|
+
width > 0
|
|
167
|
+
|
|
168
|
+
if (shouldResize) {
|
|
169
|
+
const fmt = getImageFormat(asset.mimeType) || 'jpeg'
|
|
170
|
+
const buf = await resizeImage(
|
|
171
|
+
Buffer.from(asset.content),
|
|
172
|
+
width,
|
|
173
|
+
undefined,
|
|
174
|
+
fmt,
|
|
175
|
+
)
|
|
176
|
+
outBuffer = new Uint8Array(buf)
|
|
177
|
+
outMime = `image/${fmt}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Set appropriate headers
|
|
181
|
+
res.setHeader('Content-Type', outMime)
|
|
182
|
+
res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
|
|
183
|
+
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
184
|
+
|
|
185
|
+
// Send binary content (resized if requested)
|
|
186
|
+
res.send(outBuffer)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error('Error fetching asset:', error)
|
|
189
|
+
res.status(500).json({ error: 'Failed to fetch asset' })
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Get asset metadata only (no binary content)
|
|
194
|
+
router.get(
|
|
195
|
+
`${basePath}/:id/metadata`,
|
|
196
|
+
async (req: Request, res: Response) => {
|
|
197
|
+
try {
|
|
198
|
+
const { id } = req.params
|
|
199
|
+
|
|
200
|
+
const prisma = getDbClient()
|
|
201
|
+
const asset = await prisma.dbAsset.findUnique({
|
|
202
|
+
where: { id },
|
|
203
|
+
select: {
|
|
204
|
+
id: true,
|
|
205
|
+
name: true,
|
|
206
|
+
assetType: true,
|
|
207
|
+
mimeType: true,
|
|
208
|
+
fileSize: true,
|
|
209
|
+
width: true,
|
|
210
|
+
height: true,
|
|
211
|
+
createdAt: true,
|
|
212
|
+
updatedAt: true,
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
if (!asset) {
|
|
217
|
+
res.status(404).json({ error: 'Asset not found' })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
res.json(asset)
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('Error fetching asset metadata:', error)
|
|
224
|
+
res.status(500).json({ error: 'Failed to fetch asset metadata' })
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
// Get asset binary by name
|
|
230
|
+
router.get(
|
|
231
|
+
`${basePath}/by-name/:name`,
|
|
232
|
+
async (req: Request, res: Response) => {
|
|
233
|
+
try {
|
|
234
|
+
const { name } = req.params
|
|
235
|
+
|
|
236
|
+
const prisma = getDbClient()
|
|
237
|
+
const asset = await prisma.dbAsset.findUnique({
|
|
238
|
+
where: { name },
|
|
239
|
+
select: {
|
|
240
|
+
content: true,
|
|
241
|
+
mimeType: true,
|
|
242
|
+
name: true,
|
|
243
|
+
width: true,
|
|
244
|
+
height: true,
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
if (!asset) {
|
|
249
|
+
res.status(404).json({ error: 'Asset not found' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const resizeEnabled =
|
|
254
|
+
String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'
|
|
255
|
+
const wParam = req.query['w'] as string | undefined
|
|
256
|
+
const width = wParam ? Number.parseInt(wParam, 10) : undefined
|
|
257
|
+
|
|
258
|
+
let outBuffer: Uint8Array = asset.content
|
|
259
|
+
let outMime = asset.mimeType
|
|
260
|
+
|
|
261
|
+
const isRaster =
|
|
262
|
+
asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')
|
|
263
|
+
const shouldResize =
|
|
264
|
+
resizeEnabled &&
|
|
265
|
+
isRaster &&
|
|
266
|
+
!!width &&
|
|
267
|
+
Number.isFinite(width) &&
|
|
268
|
+
width > 0
|
|
269
|
+
|
|
270
|
+
if (shouldResize) {
|
|
271
|
+
const fmt = asset.mimeType.includes('png')
|
|
272
|
+
? 'png'
|
|
273
|
+
: asset.mimeType.includes('webp')
|
|
274
|
+
? 'webp'
|
|
275
|
+
: 'jpeg'
|
|
276
|
+
|
|
277
|
+
let buf: Buffer
|
|
278
|
+
const pipeline = sharp(Buffer.from(asset.content)).resize({
|
|
279
|
+
width,
|
|
280
|
+
fit: 'inside',
|
|
281
|
+
withoutEnlargement: true,
|
|
282
|
+
})
|
|
283
|
+
if (fmt === 'png') {
|
|
284
|
+
buf = await pipeline.png().toBuffer()
|
|
285
|
+
outMime = 'image/png'
|
|
286
|
+
} else if (fmt === 'webp') {
|
|
287
|
+
buf = await pipeline.webp().toBuffer()
|
|
288
|
+
outMime = 'image/webp'
|
|
289
|
+
} else {
|
|
290
|
+
buf = await pipeline.jpeg().toBuffer()
|
|
291
|
+
outMime = 'image/jpeg'
|
|
292
|
+
}
|
|
293
|
+
outBuffer = new Uint8Array(buf)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Set appropriate headers
|
|
297
|
+
res.setHeader('Content-Type', outMime)
|
|
298
|
+
res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
|
|
299
|
+
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
300
|
+
|
|
301
|
+
// Send binary content (resized if requested)
|
|
302
|
+
res.send(outBuffer)
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error('Error fetching asset by name:', error)
|
|
305
|
+
res.status(500).json({ error: 'Failed to fetch asset' })
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract image dimensions from a buffer using sharp
|
|
6
|
+
*/
|
|
7
|
+
export async function getImageDimensions(
|
|
8
|
+
buffer: Buffer,
|
|
9
|
+
): Promise<{ width: number | null; height: number | null }> {
|
|
10
|
+
try {
|
|
11
|
+
const metadata = await sharp(buffer).metadata()
|
|
12
|
+
return {
|
|
13
|
+
width: metadata.width ?? null,
|
|
14
|
+
height: metadata.height ?? null,
|
|
15
|
+
}
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error('Error extracting image dimensions:', error)
|
|
18
|
+
return { width: null, height: null }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resize an image buffer to the specified dimensions
|
|
24
|
+
* @param buffer - The image buffer to resize
|
|
25
|
+
* @param width - Target width (optional)
|
|
26
|
+
* @param height - Target height (optional)
|
|
27
|
+
* @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
|
|
28
|
+
*/
|
|
29
|
+
export async function resizeImage(
|
|
30
|
+
buffer: Buffer,
|
|
31
|
+
width?: number,
|
|
32
|
+
height?: number,
|
|
33
|
+
format?: 'png' | 'jpeg' | 'webp',
|
|
34
|
+
): Promise<Buffer> {
|
|
35
|
+
let pipeline = sharp(buffer)
|
|
36
|
+
|
|
37
|
+
// Apply resize if dimensions provided
|
|
38
|
+
if (width || height) {
|
|
39
|
+
pipeline = pipeline.resize({
|
|
40
|
+
width,
|
|
41
|
+
height,
|
|
42
|
+
fit: 'inside',
|
|
43
|
+
withoutEnlargement: true,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Apply format conversion if specified
|
|
48
|
+
if (format === 'png') {
|
|
49
|
+
pipeline = pipeline.png()
|
|
50
|
+
} else if (format === 'webp') {
|
|
51
|
+
pipeline = pipeline.webp()
|
|
52
|
+
} else if (format === 'jpeg') {
|
|
53
|
+
pipeline = pipeline.jpeg()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return pipeline.toBuffer()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate SHA-256 checksum for a buffer
|
|
61
|
+
*/
|
|
62
|
+
export function generateChecksum(buffer: Buffer): string {
|
|
63
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detect image format from mime type
|
|
68
|
+
*/
|
|
69
|
+
export function getImageFormat(mimeType: string): 'png' | 'webp' | 'jpeg' | null {
|
|
70
|
+
if (mimeType.includes('png')) return 'png'
|
|
71
|
+
if (mimeType.includes('webp')) return 'webp'
|
|
72
|
+
if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if a mime type represents a raster image (not SVG)
|
|
78
|
+
*/
|
|
79
|
+
export function isRasterImage(mimeType: string): boolean {
|
|
80
|
+
return mimeType.startsWith('image/') && !mimeType.includes('svg')
|
|
81
|
+
}
|
|
@@ -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,116 @@
|
|
|
1
|
+
import type { TRPCRootObject } from '@trpc/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { getDbClient } from '../../db'
|
|
4
|
+
import type { EhTrpcContext } from '../../server/ehTrpcContext'
|
|
5
|
+
|
|
6
|
+
export function createScreenshotRouter(t: TRPCRootObject<EhTrpcContext, {}, {}>) {
|
|
7
|
+
const router = t.router
|
|
8
|
+
const publicProcedure = t.procedure
|
|
9
|
+
|
|
10
|
+
return router({
|
|
11
|
+
list: publicProcedure.query(async () => {
|
|
12
|
+
const prisma = getDbClient()
|
|
13
|
+
return prisma.dbAsset.findMany({
|
|
14
|
+
where: { assetType: 'screenshot' },
|
|
15
|
+
select: {
|
|
16
|
+
id: true,
|
|
17
|
+
name: true,
|
|
18
|
+
mimeType: true,
|
|
19
|
+
fileSize: true,
|
|
20
|
+
width: true,
|
|
21
|
+
height: true,
|
|
22
|
+
createdAt: true,
|
|
23
|
+
updatedAt: true,
|
|
24
|
+
},
|
|
25
|
+
orderBy: { createdAt: 'desc' },
|
|
26
|
+
})
|
|
27
|
+
}),
|
|
28
|
+
|
|
29
|
+
getOne: publicProcedure
|
|
30
|
+
.input(z.object({ id: z.string() }))
|
|
31
|
+
.query(async ({ input }) => {
|
|
32
|
+
const prisma = getDbClient()
|
|
33
|
+
return prisma.dbAsset.findFirst({
|
|
34
|
+
where: {
|
|
35
|
+
id: input.id,
|
|
36
|
+
assetType: 'screenshot',
|
|
37
|
+
},
|
|
38
|
+
select: {
|
|
39
|
+
id: true,
|
|
40
|
+
name: true,
|
|
41
|
+
mimeType: true,
|
|
42
|
+
fileSize: true,
|
|
43
|
+
width: true,
|
|
44
|
+
height: true,
|
|
45
|
+
createdAt: true,
|
|
46
|
+
updatedAt: true,
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
getByAppSlug: publicProcedure
|
|
52
|
+
.input(z.object({ appSlug: z.string() }))
|
|
53
|
+
.query(async ({ input }) => {
|
|
54
|
+
const prisma = getDbClient()
|
|
55
|
+
|
|
56
|
+
// Find app by slug
|
|
57
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
58
|
+
where: { slug: input.appSlug },
|
|
59
|
+
select: { screenshotIds: true },
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if (!app) {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fetch all screenshots for the app
|
|
67
|
+
return prisma.dbAsset.findMany({
|
|
68
|
+
where: {
|
|
69
|
+
id: { in: app.screenshotIds },
|
|
70
|
+
assetType: 'screenshot',
|
|
71
|
+
},
|
|
72
|
+
select: {
|
|
73
|
+
id: true,
|
|
74
|
+
name: true,
|
|
75
|
+
mimeType: true,
|
|
76
|
+
fileSize: true,
|
|
77
|
+
width: true,
|
|
78
|
+
height: true,
|
|
79
|
+
createdAt: true,
|
|
80
|
+
updatedAt: true,
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
getFirstByAppSlug: publicProcedure
|
|
86
|
+
.input(z.object({ appSlug: z.string() }))
|
|
87
|
+
.query(async ({ input }) => {
|
|
88
|
+
const prisma = getDbClient()
|
|
89
|
+
|
|
90
|
+
// Find app by slug
|
|
91
|
+
const app = await prisma.dbAppForCatalog.findUnique({
|
|
92
|
+
where: { slug: input.appSlug },
|
|
93
|
+
select: { screenshotIds: true },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!app || app.screenshotIds.length === 0) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Fetch first screenshot
|
|
101
|
+
return prisma.dbAsset.findUnique({
|
|
102
|
+
where: { id: app.screenshotIds[0] },
|
|
103
|
+
select: {
|
|
104
|
+
id: true,
|
|
105
|
+
name: true,
|
|
106
|
+
mimeType: true,
|
|
107
|
+
fileSize: true,
|
|
108
|
+
width: true,
|
|
109
|
+
height: true,
|
|
110
|
+
createdAt: true,
|
|
111
|
+
updatedAt: true,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
}
|