@igstack/app-catalog-backend-core 6.3.1-alpha-20261003051755 → 6.5.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
|
@@ -17,7 +17,8 @@ export type * from './models/DbGroup'
|
|
|
17
17
|
export type * from './models/DbGroupMembership'
|
|
18
18
|
export type * from './models/DbApprovalMethod'
|
|
19
19
|
export type * from './models/DbResource'
|
|
20
|
-
export type * from './models/
|
|
20
|
+
export type * from './models/DbFeedback'
|
|
21
|
+
export type * from './models/DbFeedbackAttachment'
|
|
21
22
|
export type * from './models/DbAppTagDefinition'
|
|
22
23
|
export type * from './models/DbAsset'
|
|
23
24
|
export type * from './models/Source'
|
package/src/index.ts
CHANGED
|
@@ -27,14 +27,29 @@ export { registerAuthRoutes } from './modules/auth/registerAuthRoutes'
|
|
|
27
27
|
export { createAuthRouter, type AuthRouter } from './modules/auth/authRouter'
|
|
28
28
|
|
|
29
29
|
// Comments
|
|
30
|
-
//
|
|
30
|
+
// FeedbackView is part of the router's inferred type, so it has to be nameable
|
|
31
31
|
// from outside the package or every consumer's `useTRPC` becomes unportable.
|
|
32
|
-
export {
|
|
32
|
+
export { createFeedbackRouter } from './modules/feedback/feedbackRouter'
|
|
33
33
|
export {
|
|
34
34
|
EDIT_WINDOW_MS,
|
|
35
|
+
MAX_ATTACHMENTS,
|
|
35
36
|
MAX_BODY_LENGTH,
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
MAX_SUBJECT_LENGTH,
|
|
38
|
+
type FeedbackList,
|
|
39
|
+
type FeedbackView,
|
|
40
|
+
} from './modules/feedback/service'
|
|
41
|
+
// The identity every surface shares — rows store `hash`, clients may hold `publicId`,
|
|
42
|
+
// and `visitorFromSeed` is how a test or a dev session becomes a chosen visitor.
|
|
43
|
+
export {
|
|
44
|
+
publicIdFromHash,
|
|
45
|
+
resolveActor,
|
|
46
|
+
resolveVisitor,
|
|
47
|
+
visitorFromSeed,
|
|
48
|
+
VISITOR_COOKIE,
|
|
49
|
+
type Actor,
|
|
50
|
+
type ActorKind,
|
|
51
|
+
type Visitor,
|
|
52
|
+
} from './modules/visitor/visitorIdentity'
|
|
38
53
|
|
|
39
54
|
// Icon management
|
|
40
55
|
export {
|
|
@@ -60,6 +75,11 @@ export {
|
|
|
60
75
|
type ScreenshotRestControllerConfig,
|
|
61
76
|
} from './modules/assets/screenshotRestController'
|
|
62
77
|
|
|
78
|
+
export {
|
|
79
|
+
registerFeedbackAttachmentController,
|
|
80
|
+
type FeedbackAttachmentControllerConfig,
|
|
81
|
+
} from './modules/feedback/feedbackAttachmentController'
|
|
82
|
+
|
|
63
83
|
export { syncAssets, type SyncAssetsConfig } from './modules/assets/syncAssets'
|
|
64
84
|
|
|
65
85
|
// App Catalog utilities
|
|
@@ -12,7 +12,7 @@ import { createTrpcRouter } from '../server/controller'
|
|
|
12
12
|
import { createAcTrpcContext } from '../server/acTrpcContext'
|
|
13
13
|
import { createAuth } from '../modules/auth/auth'
|
|
14
14
|
import { createMockUserFromDevConfig } from '../modules/auth/devMockUserUtils'
|
|
15
|
-
import {
|
|
15
|
+
import { resolveActor } from '../modules/visitor/visitorIdentity'
|
|
16
16
|
|
|
17
17
|
export async function createAcMiddleware(
|
|
18
18
|
options: AcMiddlewareOptions,
|
|
@@ -82,8 +82,9 @@ export async function createAcMiddleware(
|
|
|
82
82
|
user,
|
|
83
83
|
isAdmin,
|
|
84
84
|
// Issued here, on every request, so the cookie already exists by the time
|
|
85
|
-
// someone
|
|
86
|
-
|
|
85
|
+
// someone sends feedback. Falls back to the anonymous browser when there is no
|
|
86
|
+
// signed-in user, which today is always.
|
|
87
|
+
actor: resolveActor(req, res, user?.id ?? null),
|
|
87
88
|
})
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import { registerIconRestController } from '../modules/icons/iconRestController'
|
|
9
9
|
import { registerAssetRestController } from '../modules/assets/assetRestController'
|
|
10
10
|
import { registerScreenshotRestController } from '../modules/assets/screenshotRestController'
|
|
11
|
+
import { registerFeedbackAttachmentController } from '../modules/feedback/feedbackAttachmentController'
|
|
11
12
|
import { createMockSessionResponse } from '../modules/auth/devMockUserUtils'
|
|
12
13
|
import { getCookie } from '../utils/cookies'
|
|
13
14
|
|
|
@@ -132,6 +133,12 @@ export function registerFeatures(
|
|
|
132
133
|
basePath: `${basePath}/screenshots`,
|
|
133
134
|
})
|
|
134
135
|
|
|
136
|
+
// Images on a correction request. Kept off the asset endpoints on purpose — these
|
|
137
|
+
// are visitor uploads and must not share a namespace with published artwork.
|
|
138
|
+
registerFeedbackAttachmentController(router, {
|
|
139
|
+
basePath: `${basePath}/feedback-attachments`,
|
|
140
|
+
})
|
|
141
|
+
|
|
135
142
|
// Optional toggleable features
|
|
136
143
|
const toggles = options.features || {}
|
|
137
144
|
|
|
@@ -205,6 +205,7 @@ function rowToResource(row: ResourceRowWithSourceRefs): Resource {
|
|
|
205
205
|
urlIssues,
|
|
206
206
|
tiers,
|
|
207
207
|
quickJumps,
|
|
208
|
+
childrenLabel: row.childrenLabel ?? undefined,
|
|
208
209
|
// Fields from former SubResource
|
|
209
210
|
parentSlug: row.parentSlug ?? undefined,
|
|
210
211
|
tier: row.tier ?? undefined,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Request, RequestHandler, Response, Router } from 'express'
|
|
2
|
+
import multer from 'multer'
|
|
3
|
+
import sharp from 'sharp'
|
|
4
|
+
import { getDbClient } from '../../db'
|
|
5
|
+
import {
|
|
6
|
+
isNotModified,
|
|
7
|
+
setRevalidatingCacheHeaders,
|
|
8
|
+
} from '../assets/assetCache'
|
|
9
|
+
import { generateChecksum } from '../assets/assetUtils'
|
|
10
|
+
import { resolveActor } from '../visitor/visitorIdentity'
|
|
11
|
+
import { MAX_ATTACHMENTS } from './service'
|
|
12
|
+
|
|
13
|
+
/** Long edge, in pixels. A correction request needs a readable screenshot, not a print. */
|
|
14
|
+
const MAX_EDGE = 2000
|
|
15
|
+
|
|
16
|
+
/** Per upload. Generous for a screenshot; multer rejects above it before we decode. */
|
|
17
|
+
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
/** Uploads one browser may make per window, and the window. */
|
|
20
|
+
const RATE_LIMIT = 20
|
|
21
|
+
const RATE_WINDOW_MS = 60 * 60 * 1000
|
|
22
|
+
|
|
23
|
+
/** Orphans older than this are swept — see the sweep below. */
|
|
24
|
+
const ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Everything is re-encoded to this one format, so the bytes we store are always
|
|
28
|
+
* produced by our own encoder from decoded pixels. That is the actual defence: it
|
|
29
|
+
* drops EXIF, colour profiles, trailing data and anything polyglot that a
|
|
30
|
+
* content-type header would have happily waved through.
|
|
31
|
+
*/
|
|
32
|
+
const STORED_MIME = 'image/webp'
|
|
33
|
+
|
|
34
|
+
const upload = multer({
|
|
35
|
+
storage: multer.memoryStorage(),
|
|
36
|
+
limits: { fileSize: MAX_UPLOAD_BYTES, files: MAX_ATTACHMENTS },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Multer's own errors, answered as JSON.
|
|
41
|
+
*
|
|
42
|
+
* Handed straight to express they reach the default error page — an HTML stack trace
|
|
43
|
+
* carrying absolute server paths, from an endpoint any passer-by can POST to. The
|
|
44
|
+
* limits are also the first thing a person hits by accident, so they deserve a
|
|
45
|
+
* sentence rather than a 500.
|
|
46
|
+
*/
|
|
47
|
+
const acceptImages: RequestHandler = (req, res, next) => {
|
|
48
|
+
upload.array('image', MAX_ATTACHMENTS)(req, res, (err: unknown) => {
|
|
49
|
+
if (err instanceof multer.MulterError) {
|
|
50
|
+
const message =
|
|
51
|
+
err.code === 'LIMIT_FILE_COUNT'
|
|
52
|
+
? `Up to ${MAX_ATTACHMENTS} images`
|
|
53
|
+
: err.code === 'LIMIT_FILE_SIZE'
|
|
54
|
+
? `Each image must be under ${MAX_UPLOAD_BYTES / 1024 / 1024}MB`
|
|
55
|
+
: err.code === 'LIMIT_UNEXPECTED_FILE'
|
|
56
|
+
? 'Unexpected upload field'
|
|
57
|
+
: 'Could not read the upload'
|
|
58
|
+
res.status(400).json({ error: message })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (err) {
|
|
62
|
+
console.error('Error reading feedback attachment upload:', err)
|
|
63
|
+
res.status(500).json({ error: 'Failed to upload image' })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
next()
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* ponytail: in-memory window, so the budget is per process rather than per browser
|
|
72
|
+
* across a replica set. Move to a shared store if this ever runs more than one
|
|
73
|
+
* replica and the abuse is real; for a form behind the office network it is enough
|
|
74
|
+
* to stop a script, which is all it is for.
|
|
75
|
+
*/
|
|
76
|
+
const recentUploads = new Map<string, number[]>()
|
|
77
|
+
|
|
78
|
+
function overRateLimit(actorHash: string): boolean {
|
|
79
|
+
const now = Date.now()
|
|
80
|
+
const kept = (recentUploads.get(actorHash) ?? []).filter(
|
|
81
|
+
(at) => now - at < RATE_WINDOW_MS,
|
|
82
|
+
)
|
|
83
|
+
if (kept.length >= RATE_LIMIT) {
|
|
84
|
+
recentUploads.set(actorHash, kept)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
kept.push(now)
|
|
88
|
+
recentUploads.set(actorHash, kept)
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delete images nobody ever submitted. Opportunistic rather than scheduled: an
|
|
94
|
+
* abandoned form is the only thing that creates them, so the cheapest place to notice
|
|
95
|
+
* is the next upload, and there is no cron to own.
|
|
96
|
+
*/
|
|
97
|
+
async function sweepOrphans(): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
await getDbClient().dbFeedbackAttachment.deleteMany({
|
|
100
|
+
where: {
|
|
101
|
+
feedbackId: null,
|
|
102
|
+
createdAt: { lt: new Date(Date.now() - ORPHAN_MAX_AGE_MS) },
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// A failed sweep must never fail the upload that triggered it.
|
|
107
|
+
console.error('Error sweeping orphaned feedback attachments:', error)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FeedbackAttachmentControllerConfig {
|
|
112
|
+
/** Base path for the endpoints (e.g. '/api/feedback-attachments'). */
|
|
113
|
+
basePath: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Upload and retrieval for the images on a correction request.
|
|
118
|
+
*
|
|
119
|
+
* These are deliberately NOT `DbAsset`: that table's `name` is unique and holds
|
|
120
|
+
* published catalog artwork, so letting a passing visitor write into it would mean
|
|
121
|
+
* untrusted uploads sharing a namespace with the icons the catalog serves.
|
|
122
|
+
*
|
|
123
|
+
* Endpoints:
|
|
124
|
+
* - POST {basePath}/upload - up to MAX_ATTACHMENTS images (multipart, field `image`)
|
|
125
|
+
* - GET {basePath}/:id - the stored image
|
|
126
|
+
*/
|
|
127
|
+
export function registerFeedbackAttachmentController(
|
|
128
|
+
router: Router,
|
|
129
|
+
config: FeedbackAttachmentControllerConfig,
|
|
130
|
+
): void {
|
|
131
|
+
const { basePath } = config
|
|
132
|
+
|
|
133
|
+
router.post(
|
|
134
|
+
`${basePath}/upload`,
|
|
135
|
+
acceptImages,
|
|
136
|
+
async (req: Request, res: Response) => {
|
|
137
|
+
try {
|
|
138
|
+
// Same identity the feedback row gets, and it issues the cookie if this is
|
|
139
|
+
// the visitor's first request — so an upload can be the first thing they do.
|
|
140
|
+
const actor = resolveActor(req, res)
|
|
141
|
+
if (!actor) {
|
|
142
|
+
res.status(412).json({ error: 'Uploading needs cookies enabled' })
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const files = Array.isArray(req.files) ? req.files : []
|
|
147
|
+
if (files.length === 0) {
|
|
148
|
+
res.status(400).json({ error: 'No image uploaded' })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (overRateLimit(actor.hash)) {
|
|
152
|
+
res.status(429).json({ error: 'Too many uploads, try again later' })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const prisma = getDbClient()
|
|
157
|
+
const ids: string[] = []
|
|
158
|
+
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
// The declared content type is never consulted. Decoding is the check:
|
|
161
|
+
// if sharp cannot read it as an image, it is not one, whatever the
|
|
162
|
+
// header claimed.
|
|
163
|
+
let content: Buffer
|
|
164
|
+
let width: number | undefined
|
|
165
|
+
let height: number | undefined
|
|
166
|
+
try {
|
|
167
|
+
const out = await sharp(file.buffer, { failOn: 'error' })
|
|
168
|
+
.rotate() // Apply EXIF orientation BEFORE the metadata is dropped.
|
|
169
|
+
.resize({
|
|
170
|
+
width: MAX_EDGE,
|
|
171
|
+
height: MAX_EDGE,
|
|
172
|
+
fit: 'inside',
|
|
173
|
+
withoutEnlargement: true,
|
|
174
|
+
})
|
|
175
|
+
.webp({ quality: 82 })
|
|
176
|
+
.toBuffer({ resolveWithObject: true })
|
|
177
|
+
content = out.data
|
|
178
|
+
width = out.info.width
|
|
179
|
+
height = out.info.height
|
|
180
|
+
} catch {
|
|
181
|
+
res
|
|
182
|
+
.status(400)
|
|
183
|
+
.json({ error: `Not a readable image: ${file.originalname}` })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const row = await prisma.dbFeedbackAttachment.create({
|
|
188
|
+
data: {
|
|
189
|
+
authorHash: actor.hash,
|
|
190
|
+
// Prisma's Bytes wants a plain view; sharp hands back a Buffer whose
|
|
191
|
+
// backing store it will not accept as-is.
|
|
192
|
+
content: new Uint8Array(content),
|
|
193
|
+
mimeType: STORED_MIME,
|
|
194
|
+
fileSize: content.byteLength,
|
|
195
|
+
checksum: generateChecksum(content),
|
|
196
|
+
width,
|
|
197
|
+
height,
|
|
198
|
+
// Kept for a maintainer to read, never used as a path or a key.
|
|
199
|
+
originalFilename: file.originalname.slice(0, 200),
|
|
200
|
+
},
|
|
201
|
+
select: { id: true },
|
|
202
|
+
})
|
|
203
|
+
ids.push(row.id)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
void sweepOrphans()
|
|
207
|
+
res.status(201).json({ ids })
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('Error uploading feedback attachment:', error)
|
|
210
|
+
res.status(500).json({ error: 'Failed to upload image' })
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
|
|
216
|
+
try {
|
|
217
|
+
const attachment = await getDbClient().dbFeedbackAttachment.findUnique({
|
|
218
|
+
where: { id: req.params['id'] ?? '' },
|
|
219
|
+
select: {
|
|
220
|
+
content: true,
|
|
221
|
+
mimeType: true,
|
|
222
|
+
checksum: true,
|
|
223
|
+
originalFilename: true,
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
if (!attachment) {
|
|
227
|
+
res.status(404).json({ error: 'Image not found' })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const etag = setRevalidatingCacheHeaders(res, attachment.checksum)
|
|
232
|
+
if (isNotModified(req, etag)) {
|
|
233
|
+
res.status(304).end()
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
res.setHeader('Content-Type', attachment.mimeType)
|
|
238
|
+
// Never inline-rendered as a document and never sniffed into one: this is the
|
|
239
|
+
// one place the catalog serves bytes a stranger supplied.
|
|
240
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
241
|
+
res.setHeader('Content-Disposition', 'inline')
|
|
242
|
+
res.send(Buffer.from(attachment.content))
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error('Error fetching feedback attachment:', error)
|
|
245
|
+
res.status(500).json({ error: 'Failed to fetch image' })
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { publicProcedure, router } from '../../server/trpcSetup'
|
|
4
|
+
import type { Actor } from '../visitor/visitorIdentity'
|
|
5
|
+
import {
|
|
6
|
+
FeedbackError,
|
|
7
|
+
MAX_ATTACHMENTS,
|
|
8
|
+
MAX_BODY_LENGTH,
|
|
9
|
+
MAX_SUBJECT_LENGTH,
|
|
10
|
+
addFeedback,
|
|
11
|
+
dismissFeedback,
|
|
12
|
+
editFeedback,
|
|
13
|
+
listFeedback,
|
|
14
|
+
listMyFeedback,
|
|
15
|
+
} from './service'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Writing needs a visitor token, and the token is issued on the request that reads the
|
|
19
|
+
* list — so by the time anyone can click Send, the cookie exists. A missing one here
|
|
20
|
+
* means cookies are blocked, which no error code describes well; PRECONDITION_FAILED at
|
|
21
|
+
* least separates it from a permission problem.
|
|
22
|
+
*/
|
|
23
|
+
function requireActor(actor: Actor | null): Actor {
|
|
24
|
+
if (!actor) {
|
|
25
|
+
throw new TRPCError({
|
|
26
|
+
code: 'PRECONDITION_FAILED',
|
|
27
|
+
message: 'Sending this needs cookies enabled',
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return actor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const REASON_CODES = {
|
|
34
|
+
'not-found': 'NOT_FOUND',
|
|
35
|
+
'not-author': 'FORBIDDEN',
|
|
36
|
+
'window-closed': 'FORBIDDEN',
|
|
37
|
+
'already-reviewed': 'FORBIDDEN',
|
|
38
|
+
empty: 'BAD_REQUEST',
|
|
39
|
+
'too-many-attachments': 'BAD_REQUEST',
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
async function mapErrors<T>(work: () => Promise<T>): Promise<T> {
|
|
43
|
+
try {
|
|
44
|
+
return await work()
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof FeedbackError) {
|
|
47
|
+
throw new TRPCError({
|
|
48
|
+
code: REASON_CODES[error.reason],
|
|
49
|
+
message: error.message,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createFeedbackRouter() {
|
|
57
|
+
return router({
|
|
58
|
+
/** One entry's thread, plus its unreviewed count so the badge needs no second call. */
|
|
59
|
+
list: publicProcedure
|
|
60
|
+
.input(z.object({ resourceSlug: z.string() }))
|
|
61
|
+
.query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),
|
|
62
|
+
|
|
63
|
+
/** Everything this browser filed, for the tracker. Empty means render no button. */
|
|
64
|
+
mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),
|
|
65
|
+
|
|
66
|
+
add: publicProcedure
|
|
67
|
+
.input(
|
|
68
|
+
z
|
|
69
|
+
.object({
|
|
70
|
+
resourceSlug: z.string().optional(),
|
|
71
|
+
subject: z.string().max(MAX_SUBJECT_LENGTH).optional(),
|
|
72
|
+
// Both optional on purpose: a bare flag with no words and no image is the
|
|
73
|
+
// cheapest signal a passing visitor can send, and still worth having.
|
|
74
|
+
body: z.string().max(MAX_BODY_LENGTH).optional(),
|
|
75
|
+
attachmentIds: z.array(z.string()).max(MAX_ATTACHMENTS).optional(),
|
|
76
|
+
})
|
|
77
|
+
.refine((v) => !!v.resourceSlug || !!v.subject?.trim(), {
|
|
78
|
+
message: 'Needs either an entry or a subject',
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
.mutation(({ input, ctx }) =>
|
|
82
|
+
mapErrors(() => addFeedback(input, requireActor(ctx.actor))),
|
|
83
|
+
),
|
|
84
|
+
|
|
85
|
+
edit: publicProcedure
|
|
86
|
+
.input(
|
|
87
|
+
z.object({ id: z.string(), body: z.string().max(MAX_BODY_LENGTH) }),
|
|
88
|
+
)
|
|
89
|
+
.mutation(({ input, ctx }) =>
|
|
90
|
+
mapErrors(() =>
|
|
91
|
+
editFeedback(input.id, input.body, requireActor(ctx.actor)),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
/** Withdraw your own, for as long as nobody has reviewed it. */
|
|
96
|
+
dismiss: publicProcedure
|
|
97
|
+
.input(z.object({ id: z.string() }))
|
|
98
|
+
.mutation(({ input, ctx }) =>
|
|
99
|
+
mapErrors(() => dismissFeedback(input.id, requireActor(ctx.actor))),
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
}
|