@igstack/app-catalog-backend-core 6.3.0 → 6.4.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/syncAppCatalog.mjs +1 -0
- package/dist/db/syncAppCatalog.mjs.map +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +72 -72
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +17 -5
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +145 -51
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbApprovalMethod.d.mts +30 -3
- package/dist/generated/prisma/models/DbApprovalMethod.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbAsset.d.mts +2 -11
- package/dist/generated/prisma/models/DbAsset.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbFeedback.d.mts +1565 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts +1431 -0
- package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbResource.d.mts +108 -60
- package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
- package/dist/generated/prisma/models/SourceReference.d.mts +1 -1
- package/dist/generated/prisma/models.d.mts +4 -3
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +5 -3
- package/dist/middleware/createAcMiddleware.mjs +2 -2
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/featureRegistry.mjs +3 -1
- package/dist/middleware/featureRegistry.mjs.map +1 -1
- package/dist/modules/appCatalog/service.d.mts.map +1 -1
- package/dist/modules/appCatalog/service.mjs +1 -0
- package/dist/modules/appCatalog/service.mjs.map +1 -1
- package/dist/modules/feedback/feedbackAttachmentController.d.mts +22 -0
- package/dist/modules/feedback/feedbackAttachmentController.d.mts.map +1 -0
- package/dist/modules/feedback/feedbackAttachmentController.mjs +192 -0
- package/dist/modules/feedback/feedbackAttachmentController.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +54 -0
- package/dist/modules/feedback/feedbackRouter.d.mts.map +1 -0
- package/dist/modules/feedback/feedbackRouter.mjs +59 -0
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -0
- package/dist/modules/feedback/service.d.mts +42 -0
- package/dist/modules/feedback/service.d.mts.map +1 -0
- package/dist/modules/feedback/service.mjs +165 -0
- package/dist/modules/feedback/service.mjs.map +1 -0
- package/dist/modules/visitor/visitorIdentity.d.mts +86 -0
- package/dist/modules/visitor/visitorIdentity.d.mts.map +1 -0
- package/dist/modules/{comments → visitor}/visitorIdentity.mjs +53 -2
- package/dist/modules/visitor/visitorIdentity.mjs.map +1 -0
- package/dist/server/acTrpcContext.d.mts +8 -7
- package/dist/server/acTrpcContext.d.mts.map +1 -1
- package/dist/server/acTrpcContext.mjs +2 -2
- package/dist/server/acTrpcContext.mjs.map +1 -1
- package/dist/server/controller.d.mts +15 -8
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -2
- package/dist/server/controller.mjs.map +1 -1
- package/dist/types/common/appCatalogTypes.d.mts +12 -0
- package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
- package/dist/types/common/approvalMethodTypes.d.mts +12 -0
- package/dist/types/common/approvalMethodTypes.d.mts.map +1 -1
- package/package.json +5 -5
- package/prisma/migrations/20261004000000_feedback_replaces_comments/migration.sql +74 -0
- package/prisma/schema.prisma +84 -24
- package/src/__tests__/feedbackAttachmentUpload.test.ts +192 -0
- package/src/__tests__/{comments.test.ts → visitorIdentity.test.ts} +1 -1
- package/src/db/syncAppCatalog.ts +1 -0
- package/src/generated/prisma/browser.ts +22 -7
- package/src/generated/prisma/client.ts +22 -7
- package/src/generated/prisma/commonInputTypes.ts +85 -85
- package/src/generated/prisma/internal/class.ts +18 -8
- package/src/generated/prisma/internal/prismaNamespace.ts +143 -47
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +24 -3
- package/src/generated/prisma/models/DbApprovalMethod.ts +29 -1
- package/src/generated/prisma/models/DbAsset.ts +0 -12
- package/src/generated/prisma/models/DbFeedback.ts +1814 -0
- package/src/generated/prisma/models/DbFeedbackAttachment.ts +1661 -0
- package/src/generated/prisma/models/DbResource.ts +107 -58
- package/src/generated/prisma/models.ts +2 -1
- package/src/index.ts +24 -4
- package/src/middleware/createAcMiddleware.ts +4 -3
- package/src/middleware/featureRegistry.ts +7 -0
- package/src/modules/appCatalog/service.ts +1 -0
- package/src/modules/feedback/feedbackAttachmentController.ts +248 -0
- package/src/modules/feedback/feedbackRouter.ts +102 -0
- package/src/modules/feedback/service.ts +295 -0
- package/src/modules/{comments → visitor}/visitorIdentity.ts +84 -0
- package/src/server/acTrpcContext.ts +9 -8
- package/src/server/controller.ts +2 -2
- package/src/types/common/appCatalogTypes.ts +12 -0
- package/src/types/common/approvalMethodTypes.ts +12 -0
- package/dist/generated/prisma/models/DbComment.d.mts +0 -1340
- package/dist/generated/prisma/models/DbComment.d.mts.map +0 -1
- package/dist/modules/comments/commentsRouter.d.mts +0 -47
- package/dist/modules/comments/commentsRouter.d.mts.map +0 -1
- package/dist/modules/comments/commentsRouter.mjs +0 -55
- package/dist/modules/comments/commentsRouter.mjs.map +0 -1
- package/dist/modules/comments/service.d.mts +0 -40
- package/dist/modules/comments/service.d.mts.map +0 -1
- package/dist/modules/comments/service.mjs +0 -88
- package/dist/modules/comments/service.mjs.map +0 -1
- package/dist/modules/comments/visitorIdentity.d.mts +0 -15
- package/dist/modules/comments/visitorIdentity.d.mts.map +0 -1
- package/dist/modules/comments/visitorIdentity.mjs.map +0 -1
- package/src/generated/prisma/models/DbComment.ts +0 -1561
- package/src/modules/comments/commentsRouter.ts +0 -85
- package/src/modules/comments/service.ts +0 -174
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-backend-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "Backend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"tsyringe": "^4.10.0",
|
|
45
45
|
"yaml": "^2.8.0",
|
|
46
46
|
"zod": "^4.3.5",
|
|
47
|
-
"@igstack/app-catalog-
|
|
48
|
-
"@igstack/app-catalog-
|
|
47
|
+
"@igstack/app-catalog-shared-core": "6.4.0",
|
|
48
|
+
"@igstack/app-catalog-table-sync": "6.4.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@tanstack/vite-config": "^0.4.3",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=24"
|
|
74
74
|
},
|
|
75
|
-
"gitHead": "
|
|
75
|
+
"gitHead": "b4737345fd290c2e1616fcb51c4d2e6f4e6ac57c",
|
|
76
76
|
"scripts": {
|
|
77
77
|
"build": "tsdown",
|
|
78
78
|
"build:lenient": "tsdown",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"compile": "tsc --build",
|
|
81
81
|
"dev": "tsx watch src/index.ts",
|
|
82
82
|
"fix": "prettier --write ./src && eslint --fix ./src",
|
|
83
|
-
"prisma:generate": "prisma generate",
|
|
83
|
+
"prisma:generate": "rm -rf src/generated && prisma generate",
|
|
84
84
|
"test:eslint": "eslint ./src --max-warnings=0",
|
|
85
85
|
"test:types": "tsc --noEmit",
|
|
86
86
|
"test:unit": "vitest",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- Comments become feedback: one object for a note, a correction, or an ask for something
|
|
2
|
+
-- the catalog does not have yet. Ids are carried over rather than regenerated, because the
|
|
3
|
+
-- review tooling prints them and a maintainer may be holding one in a terminal scrollback.
|
|
4
|
+
|
|
5
|
+
-- CreateTable
|
|
6
|
+
CREATE TABLE "DbFeedback" (
|
|
7
|
+
"id" TEXT NOT NULL,
|
|
8
|
+
"resourceId" TEXT,
|
|
9
|
+
"subject" TEXT,
|
|
10
|
+
"authorHash" TEXT NOT NULL,
|
|
11
|
+
"authorAlias" TEXT NOT NULL,
|
|
12
|
+
"body" TEXT,
|
|
13
|
+
"editedAt" TIMESTAMP(3),
|
|
14
|
+
"status" "CommentStatus",
|
|
15
|
+
"reviewerReply" TEXT,
|
|
16
|
+
"reviewedAt" TIMESTAMP(3),
|
|
17
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
18
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
19
|
+
|
|
20
|
+
CONSTRAINT "DbFeedback_pkey" PRIMARY KEY ("id")
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
-- CreateTable
|
|
24
|
+
CREATE TABLE "DbFeedbackAttachment" (
|
|
25
|
+
"id" TEXT NOT NULL,
|
|
26
|
+
"feedbackId" TEXT,
|
|
27
|
+
"authorHash" TEXT NOT NULL,
|
|
28
|
+
"content" BYTEA NOT NULL,
|
|
29
|
+
"mimeType" TEXT NOT NULL,
|
|
30
|
+
"fileSize" INTEGER NOT NULL,
|
|
31
|
+
"checksum" TEXT NOT NULL,
|
|
32
|
+
"width" INTEGER,
|
|
33
|
+
"height" INTEGER,
|
|
34
|
+
"originalFilename" TEXT NOT NULL,
|
|
35
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
36
|
+
|
|
37
|
+
CONSTRAINT "DbFeedbackAttachment_pkey" PRIMARY KEY ("id")
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- CreateIndex
|
|
41
|
+
CREATE INDEX "DbFeedback_resourceId_createdAt_idx" ON "DbFeedback"("resourceId", "createdAt");
|
|
42
|
+
|
|
43
|
+
-- CreateIndex
|
|
44
|
+
CREATE INDEX "DbFeedback_authorHash_idx" ON "DbFeedback"("authorHash");
|
|
45
|
+
|
|
46
|
+
-- CreateIndex
|
|
47
|
+
CREATE INDEX "DbFeedback_status_createdAt_idx" ON "DbFeedback"("status", "createdAt");
|
|
48
|
+
|
|
49
|
+
-- CreateIndex
|
|
50
|
+
CREATE INDEX "DbFeedbackAttachment_feedbackId_idx" ON "DbFeedbackAttachment"("feedbackId");
|
|
51
|
+
|
|
52
|
+
-- CreateIndex
|
|
53
|
+
-- Covers both the ownership check on submit and the orphan sweep.
|
|
54
|
+
CREATE INDEX "DbFeedbackAttachment_authorHash_createdAt_idx" ON "DbFeedbackAttachment"("authorHash", "createdAt");
|
|
55
|
+
|
|
56
|
+
-- AddForeignKey
|
|
57
|
+
ALTER TABLE "DbFeedback" ADD CONSTRAINT "DbFeedback_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "DbResource"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
58
|
+
|
|
59
|
+
-- AddForeignKey
|
|
60
|
+
ALTER TABLE "DbFeedbackAttachment" ADD CONSTRAINT "DbFeedbackAttachment_feedbackId_fkey" FOREIGN KEY ("feedbackId") REFERENCES "DbFeedback"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
61
|
+
|
|
62
|
+
-- Carry every existing comment across, ids and review state intact. `body` was NOT NULL on
|
|
63
|
+
-- DbComment and is nullable here, so nothing needs coercing.
|
|
64
|
+
INSERT INTO "DbFeedback" (
|
|
65
|
+
"id", "resourceId", "subject", "authorHash", "authorAlias", "body",
|
|
66
|
+
"editedAt", "status", "reviewerReply", "reviewedAt", "createdAt", "updatedAt"
|
|
67
|
+
)
|
|
68
|
+
SELECT
|
|
69
|
+
"id", "resourceId", NULL, "authorHash", "authorAlias", "body",
|
|
70
|
+
"editedAt", "status", "reviewerReply", "reviewedAt", "createdAt", "updatedAt"
|
|
71
|
+
FROM "DbComment";
|
|
72
|
+
|
|
73
|
+
-- DropTable
|
|
74
|
+
DROP TABLE "DbComment";
|
package/prisma/schema.prisma
CHANGED
|
@@ -115,6 +115,7 @@ model DbApprovalMethod {
|
|
|
115
115
|
slug String @id
|
|
116
116
|
type ApprovalMethodType
|
|
117
117
|
displayName String
|
|
118
|
+
shortName String? // One or two words for tight spots (the detail card's access tab); falls back to displayName
|
|
118
119
|
/// [ApprovalMethodConfig] - Type-specific configuration
|
|
119
120
|
config Json?
|
|
120
121
|
deprecatedAliases String[] @default([]) // Old URLs that should redirect to this method
|
|
@@ -156,8 +157,9 @@ model DbResource {
|
|
|
156
157
|
tiers Json?
|
|
157
158
|
/// [QuickJump[]]
|
|
158
159
|
quickJumps Json?
|
|
160
|
+
childrenLabel String? // Plural noun for this resource's children ("Accounts"); set on the parent, names its tab
|
|
159
161
|
sourceRefs SourceReference[]
|
|
160
|
-
|
|
162
|
+
feedback DbFeedback[]
|
|
161
163
|
// Self-referential tree: sub-resources point to their parent
|
|
162
164
|
parentSlug String?
|
|
163
165
|
parent DbResource? @relation("ResourceTree", fields: [parentSlug], references: [slug])
|
|
@@ -195,37 +197,95 @@ enum CommentStatus {
|
|
|
195
197
|
acknowledged
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
///
|
|
200
|
+
/// One piece of visitor input about the catalog: a note, a correction, or an ask for
|
|
201
|
+
/// something that is not in the catalog yet.
|
|
199
202
|
///
|
|
200
|
-
///
|
|
201
|
-
///
|
|
202
|
-
///
|
|
203
|
-
///
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
203
|
+
/// Browsable without logging in, so this carries no user id. The server issues an
|
|
204
|
+
/// opaque visitor token in an httpOnly cookie and stores its hash: enough to let an
|
|
205
|
+
/// author edit or withdraw their own, and nothing a reader can forge.
|
|
206
|
+
///
|
|
207
|
+
/// There is deliberately no `kind` discriminator. "Is anyone waiting on this" is
|
|
208
|
+
/// `status IS NULL`, and "is this about an entry or about the catalog" is whether
|
|
209
|
+
/// `resourceId` is set — so an enum would encode nothing the row does not already say,
|
|
210
|
+
/// and asking a visitor to classify their own report costs more than it buys.
|
|
211
|
+
model DbFeedback {
|
|
212
|
+
id String @id @default(cuid())
|
|
213
|
+
|
|
214
|
+
/// Null for a catalog-level ask ("please add X") — there is no entry to hang it on.
|
|
215
|
+
/// The resource's id, not its slug: a slug is user-facing and gets renamed.
|
|
216
|
+
resourceId String?
|
|
217
|
+
resource DbResource? @relation(fields: [resourceId], references: [id], onDelete: Cascade)
|
|
218
|
+
|
|
219
|
+
/// What the visitor was looking for when nothing matched. Set only when resourceId is
|
|
220
|
+
/// null — it is the whole substance of a new-app ask, and the thing we research.
|
|
221
|
+
subject String?
|
|
222
|
+
|
|
223
|
+
/// sha256 of the visitor token. Authorises edit/withdraw; never sent to a client.
|
|
210
224
|
authorHash String
|
|
211
|
-
|
|
212
|
-
|
|
225
|
+
/// The pseudonym shown alongside, stored rather than derived at render time so
|
|
226
|
+
/// widening the wordlist later cannot rename everybody's history.
|
|
213
227
|
authorAlias String
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
228
|
+
|
|
229
|
+
/// Optional: a bare flag with no words is a valid signal, and the cheapest one a
|
|
230
|
+
/// passing visitor can send.
|
|
231
|
+
body String?
|
|
232
|
+
|
|
233
|
+
attachments DbFeedbackAttachment[]
|
|
234
|
+
|
|
235
|
+
/// Set on a later edit, so the UI can mark it edited. Null when never edited.
|
|
236
|
+
editedAt DateTime?
|
|
237
|
+
|
|
238
|
+
/// What a maintainer did with it. Null until reviewed — which is also what "open"
|
|
239
|
+
/// means, so pending needs no stored value. Written out of band by review tooling,
|
|
240
|
+
/// never by a visitor.
|
|
241
|
+
status CommentStatus?
|
|
222
242
|
reviewerReply String?
|
|
223
243
|
reviewedAt DateTime?
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
|
|
245
|
+
createdAt DateTime @default(now())
|
|
246
|
+
updatedAt DateTime @updatedAt
|
|
226
247
|
|
|
227
248
|
@@index([resourceId, createdAt])
|
|
228
249
|
@@index([authorHash])
|
|
250
|
+
/// Drives the unreviewed counts on both surfaces: open items, newest first.
|
|
251
|
+
@@index([status, createdAt])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// An image attached to one piece of feedback.
|
|
255
|
+
///
|
|
256
|
+
/// Deliberately not a DbAsset: that model's `name` is `@unique` and that namespace holds
|
|
257
|
+
/// published, reviewed assets, so an unreviewed stranger's upload must not be able to
|
|
258
|
+
/// squat `jira.svg`. Promoting an attached logo into a real icon is an explicit
|
|
259
|
+
/// maintainer step through the existing upsertAsset.
|
|
260
|
+
model DbFeedbackAttachment {
|
|
261
|
+
id String @id @default(cuid())
|
|
262
|
+
|
|
263
|
+
/// Null between upload and submit: an image is stored before the feedback row exists,
|
|
264
|
+
/// so abandoning the form leaves an orphan image rather than half-written feedback.
|
|
265
|
+
/// A sweep deletes orphans older than a day.
|
|
266
|
+
feedbackId String?
|
|
267
|
+
feedback DbFeedback? @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
|
|
268
|
+
|
|
269
|
+
/// Who uploaded it, so submitting can only claim your OWN unclaimed images. Without
|
|
270
|
+
/// this, `feedbackId` being a single FK means attaching a guessed id would MOVE that
|
|
271
|
+
/// image off somebody else's feedback.
|
|
272
|
+
authorHash String
|
|
273
|
+
|
|
274
|
+
/// Re-encoded by sharp before storage, so this is always one of our own formats and
|
|
275
|
+
/// never whatever bytes arrived.
|
|
276
|
+
content Bytes
|
|
277
|
+
mimeType String
|
|
278
|
+
fileSize Int
|
|
279
|
+
checksum String
|
|
280
|
+
width Int?
|
|
281
|
+
height Int?
|
|
282
|
+
/// The uploader's filename, shown to a maintainer. Never used as a path or a key.
|
|
283
|
+
originalFilename String
|
|
284
|
+
|
|
285
|
+
createdAt DateTime @default(now())
|
|
286
|
+
|
|
287
|
+
@@index([feedbackId])
|
|
288
|
+
@@index([authorHash, createdAt])
|
|
229
289
|
}
|
|
230
290
|
|
|
231
291
|
model DbAppTagDefinition {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import type { AddressInfo } from 'node:net'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import * as dbClient from '../db/client'
|
|
7
|
+
import type { PrismaClient } from '../generated/prisma/client'
|
|
8
|
+
import { registerFeedbackAttachmentController } from '../modules/feedback/feedbackAttachmentController'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drives the real route over HTTP — multer, sharp and all — because every property
|
|
12
|
+
* worth asserting here is about what the middleware chain does to bytes a stranger
|
|
13
|
+
* supplied, and a unit test of the handler alone would skip all of it.
|
|
14
|
+
*/
|
|
15
|
+
const startApp = async () => {
|
|
16
|
+
const app = express()
|
|
17
|
+
const router = express.Router()
|
|
18
|
+
registerFeedbackAttachmentController(router, {
|
|
19
|
+
basePath: '/api/feedback-attachments',
|
|
20
|
+
})
|
|
21
|
+
app.use(router)
|
|
22
|
+
const server = app.listen(0)
|
|
23
|
+
await new Promise((resolve) => server.once('listening', resolve))
|
|
24
|
+
const { port } = server.address() as AddressInfo
|
|
25
|
+
return { server, base: `http://127.0.0.1:${port}` }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const create = vi.fn()
|
|
29
|
+
const deleteMany = vi.fn()
|
|
30
|
+
|
|
31
|
+
/** The bytes handed to persistence — i.e. what would actually be stored. */
|
|
32
|
+
const storedRows = () => create.mock.calls.map(([{ data }]) => data)
|
|
33
|
+
|
|
34
|
+
const postImages = (
|
|
35
|
+
base: string,
|
|
36
|
+
files: { body: Buffer; type: string; name: string }[],
|
|
37
|
+
cookie?: string,
|
|
38
|
+
) => {
|
|
39
|
+
const form = new FormData()
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
form.append(
|
|
42
|
+
'image',
|
|
43
|
+
new Blob([new Uint8Array(f.body)], { type: f.type }),
|
|
44
|
+
f.name,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return fetch(`${base}/api/feedback-attachments/upload`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
body: form,
|
|
50
|
+
headers: cookie ? { cookie } : {},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The visitor cookie the first response minted, in a form fetch will send back. */
|
|
55
|
+
const visitorCookie = (res: Response): string => {
|
|
56
|
+
const raw = res.headers.getSetCookie().find((c) => c.startsWith('ac_cid='))
|
|
57
|
+
return raw ? (raw.split(';')[0] ?? '') : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const png = (width: number, height: number) =>
|
|
61
|
+
sharp({
|
|
62
|
+
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
|
63
|
+
})
|
|
64
|
+
.png()
|
|
65
|
+
.toBuffer()
|
|
66
|
+
|
|
67
|
+
describe('feedback attachment upload', () => {
|
|
68
|
+
let server: Awaited<ReturnType<typeof startApp>>['server']
|
|
69
|
+
let base: string
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
create.mockReset()
|
|
73
|
+
deleteMany.mockReset()
|
|
74
|
+
deleteMany.mockResolvedValue({ count: 0 })
|
|
75
|
+
create.mockImplementation(({ data }) => ({
|
|
76
|
+
id: `att-${create.mock.calls.length}`,
|
|
77
|
+
...data,
|
|
78
|
+
}))
|
|
79
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue({
|
|
80
|
+
dbFeedbackAttachment: { create, deleteMany },
|
|
81
|
+
} as unknown as PrismaClient)
|
|
82
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
83
|
+
;({ server, base } = await startApp())
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
server.close()
|
|
88
|
+
vi.mocked(console.error).mockRestore()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The whole security model in one assertion: nothing a visitor uploads is ever the
|
|
93
|
+
* thing we store. Decoding to pixels and re-encoding is what drops EXIF, trailing
|
|
94
|
+
* data and anything polyglot — a content-type check would wave all of it through.
|
|
95
|
+
*/
|
|
96
|
+
it('stores its own re-encoded webp, never the uploaded bytes', async () => {
|
|
97
|
+
const original = await png(120, 80)
|
|
98
|
+
const res = await postImages(base, [
|
|
99
|
+
{ body: original, type: 'image/png', name: 'shot.png' },
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(201)
|
|
103
|
+
const [row] = storedRows()
|
|
104
|
+
expect(row.mimeType).toBe('image/webp')
|
|
105
|
+
expect(Buffer.from(row.content).equals(original)).toBe(false)
|
|
106
|
+
// Really a webp, by its own magic bytes rather than by what we labelled it.
|
|
107
|
+
expect(Buffer.from(row.content).subarray(8, 12).toString()).toBe('WEBP')
|
|
108
|
+
expect(row.width).toBe(120)
|
|
109
|
+
expect(row.height).toBe(80)
|
|
110
|
+
expect(row.fileSize).toBe(row.content.byteLength)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('rasterises a script-bearing SVG, so no script survives storage', async () => {
|
|
114
|
+
const svg = Buffer.from(
|
|
115
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">' +
|
|
116
|
+
'<rect width="40" height="40" fill="green"/><script>alert(1)</script></svg>',
|
|
117
|
+
)
|
|
118
|
+
const res = await postImages(base, [
|
|
119
|
+
{ body: svg, type: 'image/svg+xml', name: 'logo.svg' },
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
expect(res.status).toBe(201)
|
|
123
|
+
const [row] = storedRows()
|
|
124
|
+
expect(row.mimeType).toBe('image/webp')
|
|
125
|
+
expect(Buffer.from(row.content).includes('alert')).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('refuses something that is not an image, whatever it claims to be', async () => {
|
|
129
|
+
const res = await postImages(base, [
|
|
130
|
+
{ body: Buffer.from('just text'), type: 'image/png', name: 'fake.png' },
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
expect(res.status).toBe(400)
|
|
134
|
+
expect(await res.json()).toEqual({
|
|
135
|
+
error: 'Not a readable image: fake.png',
|
|
136
|
+
})
|
|
137
|
+
expect(storedRows()).toHaveLength(0)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Multer's limit errors used to reach express's default handler, which answers a
|
|
142
|
+
* public endpoint with an HTML stack trace carrying absolute server paths.
|
|
143
|
+
*/
|
|
144
|
+
it('answers its own limits as JSON, with no stack trace', async () => {
|
|
145
|
+
const small = await png(10, 10)
|
|
146
|
+
const res = await postImages(
|
|
147
|
+
base,
|
|
148
|
+
Array.from({ length: 4 }, (_, i) => ({
|
|
149
|
+
body: small,
|
|
150
|
+
type: 'image/png',
|
|
151
|
+
name: `s${i}.png`,
|
|
152
|
+
})),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
expect(res.status).toBe(400)
|
|
156
|
+
expect(await res.json()).toEqual({ error: 'Up to 3 images' })
|
|
157
|
+
expect(res.headers.get('content-type')).toContain('application/json')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('records the uploader, and leaves the image unclaimed until it is submitted', async () => {
|
|
161
|
+
const res = await postImages(base, [
|
|
162
|
+
{ body: await png(20, 20), type: 'image/png', name: 'a.png' },
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
expect(res.status).toBe(201)
|
|
166
|
+
const [row] = storedRows()
|
|
167
|
+
expect(row.authorHash).toMatch(/^[0-9a-f]{16,}$/)
|
|
168
|
+
// Unset on purpose: the feedback row does not exist yet, so abandoning the form
|
|
169
|
+
// leaves an orphan image rather than half-written feedback.
|
|
170
|
+
expect(row.feedbackId).toBeUndefined()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('cuts a browser off after its hourly budget', async () => {
|
|
174
|
+
const small = await png(8, 8)
|
|
175
|
+
const file = { body: small, type: 'image/png', name: 'r.png' }
|
|
176
|
+
|
|
177
|
+
// The first request mints the visitor; reusing its cookie is what makes the
|
|
178
|
+
// remaining 20 count against ONE browser rather than twenty strangers.
|
|
179
|
+
const first = await postImages(base, [file])
|
|
180
|
+
expect(first.status).toBe(201)
|
|
181
|
+
const cookie = visitorCookie(first)
|
|
182
|
+
expect(cookie).not.toBe('')
|
|
183
|
+
|
|
184
|
+
const codes = [first.status]
|
|
185
|
+
for (let i = 0; i < 20; i++) {
|
|
186
|
+
codes.push((await postImages(base, [file], cookie)).status)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
expect(codes.filter((c) => c === 201)).toHaveLength(20)
|
|
190
|
+
expect(codes.at(-1)).toBe(429)
|
|
191
|
+
})
|
|
192
|
+
})
|
package/src/db/syncAppCatalog.ts
CHANGED
|
@@ -313,6 +313,7 @@ export async function syncAppCatalog(
|
|
|
313
313
|
urlIssues: resource.urlIssues ?? [],
|
|
314
314
|
tiers: resource.tiers ?? null,
|
|
315
315
|
quickJumps: resource.quickJumps ?? null,
|
|
316
|
+
childrenLabel: resource.childrenLabel ?? null,
|
|
316
317
|
// Fields from former SubResource
|
|
317
318
|
parentSlug: resource.parentSlug ?? null,
|
|
318
319
|
tier: resource.tier ?? null,
|
|
@@ -63,15 +63,30 @@ export type DbApprovalMethod = Prisma.DbApprovalMethodModel
|
|
|
63
63
|
*/
|
|
64
64
|
export type DbResource = Prisma.DbResourceModel
|
|
65
65
|
/**
|
|
66
|
-
* Model
|
|
67
|
-
*
|
|
66
|
+
* Model DbFeedback
|
|
67
|
+
* One piece of visitor input about the catalog: a note, a correction, or an ask for
|
|
68
|
+
* something that is not in the catalog yet.
|
|
68
69
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Browsable without logging in, so this carries no user id. The server issues an
|
|
71
|
+
* opaque visitor token in an httpOnly cookie and stores its hash: enough to let an
|
|
72
|
+
* author edit or withdraw their own, and nothing a reader can forge.
|
|
73
|
+
*
|
|
74
|
+
* There is deliberately no `kind` discriminator. "Is anyone waiting on this" is
|
|
75
|
+
* `status IS NULL`, and "is this about an entry or about the catalog" is whether
|
|
76
|
+
* `resourceId` is set — so an enum would encode nothing the row does not already say,
|
|
77
|
+
* and asking a visitor to classify their own report costs more than it buys.
|
|
78
|
+
*/
|
|
79
|
+
export type DbFeedback = Prisma.DbFeedbackModel
|
|
80
|
+
/**
|
|
81
|
+
* Model DbFeedbackAttachment
|
|
82
|
+
* An image attached to one piece of feedback.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately not a DbAsset: that model's `name` is `@unique` and that namespace holds
|
|
85
|
+
* published, reviewed assets, so an unreviewed stranger's upload must not be able to
|
|
86
|
+
* squat `jira.svg`. Promoting an attached logo into a real icon is an explicit
|
|
87
|
+
* maintainer step through the existing upsertAsset.
|
|
73
88
|
*/
|
|
74
|
-
export type
|
|
89
|
+
export type DbFeedbackAttachment = Prisma.DbFeedbackAttachmentModel
|
|
75
90
|
/**
|
|
76
91
|
* Model DbAppTagDefinition
|
|
77
92
|
*
|
|
@@ -87,15 +87,30 @@ export type DbApprovalMethod = Prisma.DbApprovalMethodModel
|
|
|
87
87
|
*/
|
|
88
88
|
export type DbResource = Prisma.DbResourceModel
|
|
89
89
|
/**
|
|
90
|
-
* Model
|
|
91
|
-
*
|
|
90
|
+
* Model DbFeedback
|
|
91
|
+
* One piece of visitor input about the catalog: a note, a correction, or an ask for
|
|
92
|
+
* something that is not in the catalog yet.
|
|
92
93
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* Browsable without logging in, so this carries no user id. The server issues an
|
|
95
|
+
* opaque visitor token in an httpOnly cookie and stores its hash: enough to let an
|
|
96
|
+
* author edit or withdraw their own, and nothing a reader can forge.
|
|
97
|
+
*
|
|
98
|
+
* There is deliberately no `kind` discriminator. "Is anyone waiting on this" is
|
|
99
|
+
* `status IS NULL`, and "is this about an entry or about the catalog" is whether
|
|
100
|
+
* `resourceId` is set — so an enum would encode nothing the row does not already say,
|
|
101
|
+
* and asking a visitor to classify their own report costs more than it buys.
|
|
102
|
+
*/
|
|
103
|
+
export type DbFeedback = Prisma.DbFeedbackModel
|
|
104
|
+
/**
|
|
105
|
+
* Model DbFeedbackAttachment
|
|
106
|
+
* An image attached to one piece of feedback.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately not a DbAsset: that model's `name` is `@unique` and that namespace holds
|
|
109
|
+
* published, reviewed assets, so an unreviewed stranger's upload must not be able to
|
|
110
|
+
* squat `jira.svg`. Promoting an attached logo into a real icon is an explicit
|
|
111
|
+
* maintainer step through the existing upsertAsset.
|
|
97
112
|
*/
|
|
98
|
-
export type
|
|
113
|
+
export type DbFeedbackAttachment = Prisma.DbFeedbackAttachmentModel
|
|
99
114
|
/**
|
|
100
115
|
* Model DbAppTagDefinition
|
|
101
116
|
*
|