@igstack/app-catalog-backend-core 0.14.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/client.d.mts +54 -1
- package/dist/db/client.d.mts.map +1 -1
- package/dist/db/client.mjs +88 -7
- package/dist/db/client.mjs.map +1 -1
- package/dist/db/index.d.mts +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/middleware/database.d.mts +1 -0
- package/dist/middleware/database.d.mts.map +1 -1
- package/dist/middleware/database.mjs +10 -13
- package/dist/middleware/database.mjs.map +1 -1
- package/dist/modules/assets/assetCache.mjs +25 -0
- package/dist/modules/assets/assetCache.mjs.map +1 -0
- package/dist/modules/assets/assetRestController.d.mts.map +1 -1
- package/dist/modules/assets/assetRestController.mjs +17 -6
- package/dist/modules/assets/assetRestController.mjs.map +1 -1
- package/dist/modules/assets/assetUtils.mjs +1 -0
- package/dist/modules/assets/assetUtils.mjs.map +1 -1
- package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
- package/dist/modules/assets/screenshotRestController.mjs +7 -2
- package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
- package/dist/modules/assets/upsertAsset.mjs +7 -1
- package/dist/modules/assets/upsertAsset.mjs.map +1 -1
- package/dist/modules/icons/iconRestController.d.mts.map +1 -1
- package/dist/modules/icons/iconRestController.mjs +7 -2
- package/dist/modules/icons/iconRestController.mjs.map +1 -1
- package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
- package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
- package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/assetMime.test.ts +74 -0
- package/src/__tests__/dbSchema.test.ts +129 -0
- package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
- package/src/__tests__/iconCache.test.ts +81 -0
- package/src/db/client.ts +118 -12
- package/src/db/index.ts +10 -1
- package/src/index.ts +4 -0
- package/src/middleware/database.ts +20 -19
- package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
- package/src/modules/assets/assetCache.ts +30 -0
- package/src/modules/assets/assetRestController.ts +25 -2
- package/src/modules/assets/assetUtils.ts +1 -0
- package/src/modules/assets/screenshotRestController.ts +13 -1
- package/src/modules/assets/upsertAsset.ts +8 -0
- package/src/modules/icons/iconRestController.ts +11 -1
- package/src/modules/lighthouseKeeper/tools.ts +6 -7
package/src/db/client.ts
CHANGED
|
@@ -6,6 +6,120 @@ import { buildPgSslConfig } from './sslConfig'
|
|
|
6
6
|
let prismaClient: PrismaClient | null = null
|
|
7
7
|
let pool: pg.Pool | null = null
|
|
8
8
|
|
|
9
|
+
type PrismaLogOption = NonNullable<
|
|
10
|
+
ConstructorParameters<typeof PrismaClient>[0]
|
|
11
|
+
>['log']
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The schema a deployment should be reading and writing.
|
|
15
|
+
*
|
|
16
|
+
* DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
|
|
17
|
+
* config - otherwise a config that names `public` would pin a preview env back
|
|
18
|
+
* to the shared tables it was meant to be isolated from.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveDbSchema(configuredSchema?: string): string | undefined {
|
|
21
|
+
return process.env.DB_SCHEMA || configuredSchema || undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `search_path` startup options for a schema-isolated deployment.
|
|
26
|
+
*
|
|
27
|
+
* node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
|
|
28
|
+
* schema has to be injected as a connection option instead. This half covers
|
|
29
|
+
* raw SQL and the migrations; Prisma's own queries need the adapter half too,
|
|
30
|
+
* which is why every core connection goes through createCorePrismaClient.
|
|
31
|
+
*/
|
|
32
|
+
export function buildPgSchemaOptions(configuredSchema?: string): {
|
|
33
|
+
options?: string
|
|
34
|
+
} {
|
|
35
|
+
const schema = resolveDbSchema(configuredSchema)
|
|
36
|
+
return schema ? { options: `-c search_path=${schema}` } : {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The one way to open a connection to the core database.
|
|
41
|
+
*
|
|
42
|
+
* Schema isolation takes two halves and both live here:
|
|
43
|
+
* - the pool's `search_path`, which raw SQL and the migrations follow;
|
|
44
|
+
* - the adapter's `schema`, which is what Prisma qualifies its table names
|
|
45
|
+
* with. A client built without it emits `"public"."DbResource"` however
|
|
46
|
+
* correct the pool's `current_schema()` is, so a preview env silently reads
|
|
47
|
+
* and overwrites the shared canonical tables.
|
|
48
|
+
*
|
|
49
|
+
* A pool built outside this factory gets neither half, so don't build one.
|
|
50
|
+
*/
|
|
51
|
+
export function createCorePrismaClient(params: {
|
|
52
|
+
connectionString: string
|
|
53
|
+
configuredSchema?: string
|
|
54
|
+
log?: PrismaLogOption
|
|
55
|
+
}): { client: PrismaClient; pool: pg.Pool; schema: string | undefined } {
|
|
56
|
+
const schema = resolveDbSchema(params.configuredSchema)
|
|
57
|
+
// SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres
|
|
58
|
+
// doesn't honor those correctly for a connection-string pool - see
|
|
59
|
+
// buildPgSslConfig).
|
|
60
|
+
const ssl = buildPgSslConfig()
|
|
61
|
+
const newPool = new pg.Pool({
|
|
62
|
+
connectionString: params.connectionString,
|
|
63
|
+
...(ssl === undefined ? {} : { ssl }),
|
|
64
|
+
...buildPgSchemaOptions(params.configuredSchema),
|
|
65
|
+
})
|
|
66
|
+
const adapter = new PrismaPg(newPool, { schema })
|
|
67
|
+
const client = new PrismaClient({
|
|
68
|
+
adapter,
|
|
69
|
+
...(params.log === undefined ? {} : { log: params.log }),
|
|
70
|
+
})
|
|
71
|
+
return { client, pool: newPool, schema }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Postgres NAMEDATALEN - 1: identifiers are truncated to this many bytes. */
|
|
75
|
+
const PG_MAX_IDENTIFIER_BYTES = 63
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How Postgres will have stored an identifier this long.
|
|
79
|
+
*
|
|
80
|
+
* A schema name over the limit is truncated identically wherever it appears - at
|
|
81
|
+
* CREATE SCHEMA and in `search_path` alike - so the isolation still holds and
|
|
82
|
+
* current_schema() merely reports the shortened name. Comparing raw strings would
|
|
83
|
+
* fail a correctly isolated deployment, so compare what Postgres kept.
|
|
84
|
+
*/
|
|
85
|
+
function truncateIdentifier(name: string): string {
|
|
86
|
+
const bytes = Buffer.from(name, 'utf8')
|
|
87
|
+
if (bytes.length <= PG_MAX_IDENTIFIER_BYTES) return name
|
|
88
|
+
return bytes.subarray(0, PG_MAX_IDENTIFIER_BYTES).toString('utf8')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fails a deployment that believes it is schema-isolated but is not.
|
|
93
|
+
*
|
|
94
|
+
* Speaks up whenever a schema was resolved at all - the same resolution feeds the
|
|
95
|
+
* pool's `search_path`, so the expectation and the connection can never disagree
|
|
96
|
+
* by construction, and a deployment that configures isolation without setting
|
|
97
|
+
* DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres
|
|
98
|
+
* silently skips a `search_path` entry that does not exist and falls through to
|
|
99
|
+
* `public`, which is the shared-catalog corruption this is here to prevent.
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyDbSchema(
|
|
102
|
+
poolToCheck: pg.Pool,
|
|
103
|
+
configuredSchema?: string,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const resolved = resolveDbSchema(configuredSchema)
|
|
106
|
+
const result = await poolToCheck.query<{ schema: string | null }>(
|
|
107
|
+
'SELECT current_schema() AS schema',
|
|
108
|
+
)
|
|
109
|
+
const actual = result.rows[0]?.schema ?? null
|
|
110
|
+
console.log(
|
|
111
|
+
`[db] core schema: resolved=${resolved ?? '(none)'} current_schema=${actual ?? '(none)'}`,
|
|
112
|
+
)
|
|
113
|
+
if (!resolved) return
|
|
114
|
+
if (actual !== truncateIdentifier(resolved)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`The configured schema is "${resolved}" but the database resolved current_schema() to ` +
|
|
117
|
+
`"${actual}". The schema is most likely missing, so every query would fall through to ` +
|
|
118
|
+
`the shared catalog tables. Migrate this schema before starting the app.`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
9
123
|
/**
|
|
10
124
|
* Gets the internal Prisma client instance.
|
|
11
125
|
* Creates one if it doesn't exist.
|
|
@@ -20,18 +134,9 @@ export function getDbClient(): PrismaClient {
|
|
|
20
134
|
)
|
|
21
135
|
}
|
|
22
136
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// buildPgSslConfig).
|
|
27
|
-
const ssl = buildPgSslConfig()
|
|
28
|
-
pool = new pg.Pool({
|
|
29
|
-
connectionString: databaseUrl,
|
|
30
|
-
...(ssl === undefined ? {} : { ssl }),
|
|
31
|
-
})
|
|
32
|
-
const adapter = new PrismaPg(pool)
|
|
33
|
-
|
|
34
|
-
prismaClient = new PrismaClient({ adapter })
|
|
137
|
+
const created = createCorePrismaClient({ connectionString: databaseUrl })
|
|
138
|
+
pool = created.pool
|
|
139
|
+
prismaClient = created.client
|
|
35
140
|
}
|
|
36
141
|
return prismaClient
|
|
37
142
|
}
|
|
@@ -51,6 +156,7 @@ export function setDbClient(client: PrismaClient): void {
|
|
|
51
156
|
export async function connectDb(): Promise<void> {
|
|
52
157
|
const client = getDbClient()
|
|
53
158
|
await client.$connect()
|
|
159
|
+
if (pool) await verifyDbSchema(pool)
|
|
54
160
|
}
|
|
55
161
|
|
|
56
162
|
/**
|
package/src/db/index.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
// Database connection
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
buildPgSchemaOptions,
|
|
4
|
+
connectDb,
|
|
5
|
+
createCorePrismaClient,
|
|
6
|
+
disconnectDb,
|
|
7
|
+
getDbClient,
|
|
8
|
+
resolveDbSchema,
|
|
9
|
+
setDbClient,
|
|
10
|
+
verifyDbSchema,
|
|
11
|
+
} from './client'
|
|
3
12
|
export { buildPgSslConfig } from './sslConfig'
|
|
4
13
|
|
|
5
14
|
// Table sync utilities
|
package/src/index.ts
CHANGED
|
@@ -59,14 +59,18 @@ export {
|
|
|
59
59
|
|
|
60
60
|
// Database utilities
|
|
61
61
|
export {
|
|
62
|
+
buildPgSchemaOptions,
|
|
62
63
|
buildPgSslConfig,
|
|
63
64
|
connectDb,
|
|
65
|
+
createCorePrismaClient,
|
|
64
66
|
disconnectDb,
|
|
65
67
|
getDbClient,
|
|
68
|
+
resolveDbSchema,
|
|
66
69
|
setDbClient,
|
|
67
70
|
syncAppCatalog,
|
|
68
71
|
TABLE_SYNC_MAGAZINE,
|
|
69
72
|
tableSyncPrisma,
|
|
73
|
+
verifyDbSchema,
|
|
70
74
|
type MakeTFromPrismaModel,
|
|
71
75
|
type ObjectKeys,
|
|
72
76
|
type ScalarFilter,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { PrismaClient } from '../generated/prisma/client'
|
|
2
|
-
import
|
|
3
|
-
import pg from 'pg'
|
|
1
|
+
import type { PrismaClient } from '../generated/prisma/client'
|
|
2
|
+
import type pg from 'pg'
|
|
4
3
|
import type { AcDatabaseConfig } from './types'
|
|
5
|
-
import {
|
|
6
|
-
|
|
4
|
+
import {
|
|
5
|
+
createCorePrismaClient,
|
|
6
|
+
setDbClient,
|
|
7
|
+
verifyDbSchema,
|
|
8
|
+
} from '../db/client'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Formats a database connection URL from structured config.
|
|
@@ -36,25 +38,19 @@ export class AcDatabaseManager {
|
|
|
36
38
|
*/
|
|
37
39
|
getClient(): PrismaClient {
|
|
38
40
|
if (!this.client) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.pool = new pg.Pool({
|
|
46
|
-
connectionString: datasourceUrl,
|
|
47
|
-
...(ssl === undefined ? {} : { ssl }),
|
|
48
|
-
})
|
|
49
|
-
const adapter = new PrismaPg(this.pool)
|
|
50
|
-
|
|
51
|
-
this.client = new PrismaClient({
|
|
52
|
-
adapter,
|
|
41
|
+
// This client - not getDbClient()'s - is the one the app ends up using.
|
|
42
|
+
// The `?schema=` in the url is inert (node-postgres drops unknown URL
|
|
43
|
+
// params), so the schema has to travel via createCorePrismaClient.
|
|
44
|
+
const created = createCorePrismaClient({
|
|
45
|
+
connectionString: formatConnectionUrl(this.config),
|
|
46
|
+
configuredSchema: this.configuredSchema(),
|
|
53
47
|
log:
|
|
54
48
|
process.env.NODE_ENV === 'development'
|
|
55
49
|
? ['warn', 'error']
|
|
56
50
|
: ['warn', 'error'],
|
|
57
51
|
})
|
|
52
|
+
this.pool = created.pool
|
|
53
|
+
this.client = created.client
|
|
58
54
|
|
|
59
55
|
// Bridge with existing backend-core getDbClient() usage
|
|
60
56
|
setDbClient(this.client)
|
|
@@ -65,6 +61,7 @@ export class AcDatabaseManager {
|
|
|
65
61
|
async connect(): Promise<void> {
|
|
66
62
|
const client = this.getClient()
|
|
67
63
|
await client.$connect()
|
|
64
|
+
if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema())
|
|
68
65
|
}
|
|
69
66
|
|
|
70
67
|
async disconnect(): Promise<void> {
|
|
@@ -77,4 +74,8 @@ export class AcDatabaseManager {
|
|
|
77
74
|
this.pool = null
|
|
78
75
|
}
|
|
79
76
|
}
|
|
77
|
+
|
|
78
|
+
private configuredSchema(): string | undefined {
|
|
79
|
+
return 'url' in this.config ? undefined : this.config.schema
|
|
80
|
+
}
|
|
80
81
|
}
|
|
@@ -37,7 +37,11 @@ export function createPrismaDatabaseClient(prisma: {
|
|
|
37
37
|
},
|
|
38
38
|
getTables: async () => {
|
|
39
39
|
const tables = await prisma.$queryRawUnsafe<{ tablename: string }[]>(
|
|
40
|
-
|
|
40
|
+
// current_schema() - not a hardcoded 'public' - because the DML these
|
|
41
|
+
// tools go on to run is unqualified and therefore resolves through
|
|
42
|
+
// search_path. A schema-isolated deployment would otherwise be told
|
|
43
|
+
// about tables it cannot see.
|
|
44
|
+
`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`,
|
|
41
45
|
)
|
|
42
46
|
return tables.map((t) => t.tablename)
|
|
43
47
|
},
|
|
@@ -51,7 +55,7 @@ export function createPrismaDatabaseClient(prisma: {
|
|
|
51
55
|
>(
|
|
52
56
|
`SELECT column_name, data_type, is_nullable
|
|
53
57
|
FROM information_schema.columns
|
|
54
|
-
WHERE table_name = '${tableName}' AND table_schema =
|
|
58
|
+
WHERE table_name = '${tableName}' AND table_schema = current_schema()`,
|
|
55
59
|
)
|
|
56
60
|
return columns.map((c) => ({
|
|
57
61
|
name: c.column_name,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Request, Response } from 'express'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Asset URLs are keyed by row id or by icon name, and `upsertAsset` replaces
|
|
5
|
+
* content in place rather than inserting a new row — so the bytes behind a
|
|
6
|
+
* given URL can change. A long `max-age` would then serve the old artwork for
|
|
7
|
+
* the whole window (an icon replacement stayed invisible for a day). Revalidate
|
|
8
|
+
* against the stored checksum instead: unchanged content still costs only a
|
|
9
|
+
* conditional request answered with 304, and a replacement shows up at once.
|
|
10
|
+
*
|
|
11
|
+
* `variant` distinguishes derived renditions of the same row (a resize width,
|
|
12
|
+
* for example) so they do not share one entity tag.
|
|
13
|
+
*/
|
|
14
|
+
export function setRevalidatingCacheHeaders(
|
|
15
|
+
res: Response,
|
|
16
|
+
checksum: string,
|
|
17
|
+
variant?: string | number,
|
|
18
|
+
): string {
|
|
19
|
+
const etag =
|
|
20
|
+
variant === undefined ? `"${checksum}"` : `"${checksum}-${variant}"`
|
|
21
|
+
|
|
22
|
+
res.setHeader('ETag', etag)
|
|
23
|
+
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
|
|
24
|
+
|
|
25
|
+
return etag
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isNotModified(req: Request, etag: string): boolean {
|
|
29
|
+
return req.headers['if-none-match'] === etag
|
|
30
|
+
}
|
|
@@ -4,6 +4,7 @@ import sharp from 'sharp'
|
|
|
4
4
|
import { getDbClient } from '../../db'
|
|
5
5
|
import { getImageFormat, isRasterImage, resizeImage } from './assetUtils'
|
|
6
6
|
import { upsertAsset } from './upsertAsset'
|
|
7
|
+
import { isNotModified, setRevalidatingCacheHeaders } from './assetCache'
|
|
7
8
|
|
|
8
9
|
// Configure multer for memory storage
|
|
9
10
|
const upload = multer({
|
|
@@ -106,6 +107,7 @@ export function registerAssetRestController(
|
|
|
106
107
|
name: true,
|
|
107
108
|
width: true,
|
|
108
109
|
height: true,
|
|
110
|
+
checksum: true,
|
|
109
111
|
},
|
|
110
112
|
})
|
|
111
113
|
|
|
@@ -129,6 +131,17 @@ export function registerAssetRestController(
|
|
|
129
131
|
Number.isFinite(width) &&
|
|
130
132
|
width > 0
|
|
131
133
|
|
|
134
|
+
// Answered before resizing, so an unchanged asset costs no image work.
|
|
135
|
+
const etag = setRevalidatingCacheHeaders(
|
|
136
|
+
res,
|
|
137
|
+
asset.checksum,
|
|
138
|
+
shouldResize ? `w${width}` : undefined,
|
|
139
|
+
)
|
|
140
|
+
if (isNotModified(req, etag)) {
|
|
141
|
+
res.status(304).end()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
132
145
|
if (shouldResize) {
|
|
133
146
|
const fmt = getImageFormat(asset.mimeType) || 'jpeg'
|
|
134
147
|
const buf = await resizeImage(
|
|
@@ -144,7 +157,6 @@ export function registerAssetRestController(
|
|
|
144
157
|
// Set appropriate headers
|
|
145
158
|
res.setHeader('Content-Type', outMime)
|
|
146
159
|
res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
|
|
147
|
-
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
148
160
|
|
|
149
161
|
// Send binary content (resized if requested)
|
|
150
162
|
res.send(outBuffer)
|
|
@@ -204,6 +216,7 @@ export function registerAssetRestController(
|
|
|
204
216
|
name: true,
|
|
205
217
|
width: true,
|
|
206
218
|
height: true,
|
|
219
|
+
checksum: true,
|
|
207
220
|
},
|
|
208
221
|
})
|
|
209
222
|
|
|
@@ -229,6 +242,17 @@ export function registerAssetRestController(
|
|
|
229
242
|
Number.isFinite(width) &&
|
|
230
243
|
width > 0
|
|
231
244
|
|
|
245
|
+
// Answered before resizing, so an unchanged asset costs no image work.
|
|
246
|
+
const etag = setRevalidatingCacheHeaders(
|
|
247
|
+
res,
|
|
248
|
+
asset.checksum,
|
|
249
|
+
shouldResize ? `w${width}` : undefined,
|
|
250
|
+
)
|
|
251
|
+
if (isNotModified(req, etag)) {
|
|
252
|
+
res.status(304).end()
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
232
256
|
if (shouldResize) {
|
|
233
257
|
const fmt = asset.mimeType.includes('png')
|
|
234
258
|
? 'png'
|
|
@@ -258,7 +282,6 @@ export function registerAssetRestController(
|
|
|
258
282
|
// Set appropriate headers
|
|
259
283
|
res.setHeader('Content-Type', outMime)
|
|
260
284
|
res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
|
|
261
|
-
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
262
285
|
|
|
263
286
|
// Send binary content (resized if requested)
|
|
264
287
|
res.send(outBuffer)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Request, Response, Router } from 'express'
|
|
2
2
|
import sharp from 'sharp'
|
|
3
3
|
import { getDbClient } from '../../db'
|
|
4
|
+
import { isNotModified, setRevalidatingCacheHeaders } from './assetCache'
|
|
4
5
|
|
|
5
6
|
export interface ScreenshotRestControllerConfig {
|
|
6
7
|
/**
|
|
@@ -133,6 +134,7 @@ export function registerScreenshotRestController(
|
|
|
133
134
|
content: true,
|
|
134
135
|
mimeType: true,
|
|
135
136
|
name: true,
|
|
137
|
+
checksum: true,
|
|
136
138
|
},
|
|
137
139
|
})
|
|
138
140
|
|
|
@@ -141,6 +143,17 @@ export function registerScreenshotRestController(
|
|
|
141
143
|
return
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
// Answered before resizing, so an unchanged screenshot costs no image work.
|
|
147
|
+
const etag = setRevalidatingCacheHeaders(
|
|
148
|
+
res,
|
|
149
|
+
screenshot.checksum,
|
|
150
|
+
targetSize && targetSize > 0 ? `s${targetSize}` : undefined,
|
|
151
|
+
)
|
|
152
|
+
if (isNotModified(req, etag)) {
|
|
153
|
+
res.status(304).end()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
let content: Uint8Array | Buffer = screenshot.content
|
|
145
158
|
|
|
146
159
|
// Resize if size parameter provided
|
|
@@ -164,7 +177,6 @@ export function registerScreenshotRestController(
|
|
|
164
177
|
'Content-Disposition',
|
|
165
178
|
`inline; filename="${screenshot.name}"`,
|
|
166
179
|
)
|
|
167
|
-
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
168
180
|
|
|
169
181
|
// Send binary content
|
|
170
182
|
res.send(content)
|
|
@@ -27,6 +27,14 @@ export async function upsertAsset({
|
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
if (existing) {
|
|
30
|
+
// Reusing the stored binary must not keep a mimeType we no longer derive:
|
|
31
|
+
// rows written by an older, wrong derivation would never be corrected.
|
|
32
|
+
if (existing.mimeType !== mimeType) {
|
|
33
|
+
await prisma.dbAsset.update({
|
|
34
|
+
where: { id: existing.id },
|
|
35
|
+
data: { mimeType },
|
|
36
|
+
})
|
|
37
|
+
}
|
|
30
38
|
return existing.id
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -3,6 +3,10 @@ import multer from 'multer'
|
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { getDbClient } from '../../db'
|
|
5
5
|
import { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'
|
|
6
|
+
import {
|
|
7
|
+
isNotModified,
|
|
8
|
+
setRevalidatingCacheHeaders,
|
|
9
|
+
} from '../assets/assetCache'
|
|
6
10
|
|
|
7
11
|
// Configure multer for memory storage
|
|
8
12
|
const upload = multer({
|
|
@@ -112,6 +116,7 @@ export function registerIconRestController(
|
|
|
112
116
|
content: true,
|
|
113
117
|
mimeType: true,
|
|
114
118
|
name: true,
|
|
119
|
+
checksum: true,
|
|
115
120
|
},
|
|
116
121
|
})
|
|
117
122
|
|
|
@@ -120,10 +125,15 @@ export function registerIconRestController(
|
|
|
120
125
|
return
|
|
121
126
|
}
|
|
122
127
|
|
|
128
|
+
const etag = setRevalidatingCacheHeaders(res, icon.checksum)
|
|
129
|
+
if (isNotModified(req, etag)) {
|
|
130
|
+
res.status(304).end()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
// Set appropriate headers
|
|
124
135
|
res.setHeader('Content-Type', icon.mimeType)
|
|
125
136
|
res.setHeader('Content-Disposition', `inline; filename="${icon.name}"`)
|
|
126
|
-
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
127
137
|
|
|
128
138
|
// Send binary content
|
|
129
139
|
res.send(icon.content)
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { Tool } from 'ai'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
-
import {
|
|
4
|
-
import { PrismaPg } from '@prisma/adapter-pg'
|
|
5
|
-
import pg from 'pg'
|
|
3
|
+
import { createCorePrismaClient } from '../../db/client'
|
|
6
4
|
import type { AcDatabaseConfig } from '../../middleware/types.js'
|
|
7
5
|
|
|
8
6
|
// ============================================================================
|
|
@@ -27,10 +25,11 @@ function getDatabaseUrl(config: AcDatabaseConfig): string {
|
|
|
27
25
|
export function createAppCatalogAITools(
|
|
28
26
|
databaseConfig: AcDatabaseConfig,
|
|
29
27
|
): Record<string, Tool> {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const { client: prisma } = createCorePrismaClient({
|
|
29
|
+
connectionString: getDatabaseUrl(databaseConfig),
|
|
30
|
+
configuredSchema:
|
|
31
|
+
'url' in databaseConfig ? undefined : databaseConfig.schema,
|
|
32
|
+
})
|
|
34
33
|
|
|
35
34
|
const getAppCardSchema = z.object({
|
|
36
35
|
slug: z.string().describe('The app slug to fetch'),
|