@stacksjs/defaults 0.75.90 → 0.75.91

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-actions
3
- description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 660 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 663 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
66
66
  - **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
67
67
 
68
68
  ### Server Auto-Imports (100+)
69
- - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (106 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (107 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-dashboard
3
- description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 408 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
3
+ description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 409 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Dashboard
10
10
 
11
- The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 408 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 409 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
12
12
  - Database package: `storage/framework/core/database/src/`
13
13
  - Configuration: `config/database.ts`
14
14
  - QB config: `config/query-builder.ts`
15
- - Migrations: `database/migrations/` (236 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (237 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-migrations
3
- description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 236 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
3
+ description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 237 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
119
119
  - Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
120
120
  - If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
121
121
  - `--seed` flag after `migrate:fresh` seeds the database with factory data
122
- - 236 migration files exist by default for all framework models
122
+ - 237 migration files exist by default for all framework models
123
123
  - SQLite >= 3.47.2 is required (system requirement)
124
124
  - For the database API (queries, connections), see the `stacks-database` skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-models
3
- description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 106 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
3
+ description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 107 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -304,7 +304,7 @@ honoured: fewer rows are seeded rather than a duplicate combination.
304
304
  ## Built-in models by category
305
305
 
306
306
  The ones below are worth knowing by name. They are a selection, not the set:
307
- `storage/framework/defaults/app/Models/` holds 106, and that directory is the
307
+ `storage/framework/defaults/app/Models/` holds 107, and that directory is the
308
308
  authority. This section said "All 62 built-in models by category" while
309
309
  listing fewer than that against 102 on disk, so an agent reading to the end
310
310
  had no way to tell it was short.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-orm
3
- description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 106 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
3
+ description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 107 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
11
11
  ## Key Paths
12
12
  - Core ORM package: `storage/framework/core/orm/src/`
13
13
  - ORM implementation: `storage/framework/orm/`
14
- - Model definitions: `storage/framework/defaults/app/Models/` (106 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (107 models)
15
15
  - Application models: `app/Models/`
16
16
  - Default model templates: `storage/framework/defaults/app/Models/`
17
17
  - ORM type globals: `storage/framework/types/orm-globals.d.ts`
@@ -1,10 +1,21 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
+ import { randomUUID } from 'node:crypto'
2
3
  import { Action } from '@stacksjs/actions/runtime'
3
4
  import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
5
  import { response } from '@stacksjs/router'
5
6
  import { kanbanActionError } from '../Kanban/kanban-response'
7
+ import {
8
+ acceptAttachment,
9
+ acceptAttachmentSize,
10
+ FEEDBACK_ATTACHMENT_MAX_FILES,
11
+ feedbackAttachmentPath,
12
+ SNIFF_BYTES,
13
+ } from './feedback-attachment'
14
+ import { notifyFeedbackFiled } from './feedback-notifier'
6
15
  import {
7
16
  authorizeFeedbackToken,
17
+ FEEDBACK_MAX_DESCRIPTION as MAX_DESCRIPTION,
18
+ FEEDBACK_MAX_TITLE as MAX_TITLE,
8
19
  hashFeedbackToken,
9
20
  looksLikeFeedbackToken,
10
21
  refusalMessage,
@@ -15,11 +26,43 @@ interface FeedbackInput {
15
26
  description?: unknown
16
27
  }
17
28
 
18
- /** Matches `Card.description`, so a submission cannot be longer than the column. */
19
- const MAX_DESCRIPTION = 10_000
20
-
21
- /** Matches `Card.title`. */
22
- const MAX_TITLE = 300
29
+ /**
30
+ * Write the screenshot and record it against the card.
31
+ *
32
+ * Runs after the card exists, because the path is keyed by card id. A failure
33
+ * here is logged and swallowed: the report is already saved and is worth more
34
+ * than the picture, and telling the reviewer their submission failed would
35
+ * invite a resubmit that files the card twice.
36
+ */
37
+ async function storeAttachment(
38
+ cardId: number,
39
+ attachment: { bytes: Uint8Array, mimeType: string, extension: string },
40
+ ): Promise<void> {
41
+ try {
42
+ const { dashboard } = await import('@stacksjs/config')
43
+ // `local` is private storage under `storage/app`. The default is not read
44
+ // from `filesystems.driver`, which an app may point at `public`: an
45
+ // unauthenticated stranger's upload does not belong in a web-served
46
+ // directory.
47
+ const disk = String((dashboard as any)?.feedback?.attachments?.disk || 'local')
48
+ const path = feedbackAttachmentPath(cardId, randomUUID(), attachment.extension)
49
+
50
+ const { Storage } = await import('@stacksjs/storage')
51
+ await Storage.disk(disk as never).write(path, attachment.bytes)
52
+
53
+ const { CardAttachment } = await import('@stacksjs/orm')
54
+ await CardAttachment.create({
55
+ cardId,
56
+ disk,
57
+ path,
58
+ mimeType: attachment.mimeType,
59
+ sizeBytes: attachment.bytes.byteLength,
60
+ })
61
+ }
62
+ catch (err) {
63
+ console.warn(`[FeedbackStoreAction] card ${cardId} was filed without its screenshot: ${err instanceof Error ? err.message : String(err)}`)
64
+ }
65
+ }
23
66
 
24
67
  /**
25
68
  * `POST /api/feedback/{token}`.
@@ -63,11 +106,46 @@ export default new Action({
63
106
  return response.json({ message: `The description must be ${MAX_DESCRIPTION} characters or fewer.` }, 400)
64
107
  const description = rawDescription || undefined
65
108
 
109
+ // The screenshot is judged BEFORE anything is written. Filing the card
110
+ // first and then refusing the file would show the reviewer an error for a
111
+ // report that was in fact saved, and the obvious thing to do about an
112
+ // error is to send it again.
113
+ const sent = request.getFiles('screenshot')
114
+ const screenshot = sent.length > 0 ? sent[0] : request.file('screenshot')
115
+ if (sent.length > FEEDBACK_ATTACHMENT_MAX_FILES)
116
+ return response.json({ message: `Attach ${FEEDBACK_ATTACHMENT_MAX_FILES === 1 ? 'one screenshot' : `${FEEDBACK_ATTACHMENT_MAX_FILES} screenshots`} at most.` }, 400)
117
+
118
+ let attachment: { bytes: Uint8Array, mimeType: string, extension: string } | undefined
119
+ if (screenshot) {
120
+ // Size first, and on its own. The router hands this an `UploadedFile`
121
+ // wrapper, which reports `size` synchronously and has no `slice`, so
122
+ // reading the leading bytes means reading the whole file. Sniffing
123
+ // first would load a 2 GB upload into memory to decide it was too
124
+ // large.
125
+ const size = Number(screenshot.size || 0)
126
+ const sized = acceptAttachmentSize(size)
127
+ if (!sized.ok)
128
+ return response.json({ message: sized.message }, 400)
129
+
130
+ const bytes = await screenshot.bytes()
131
+ const verdict = acceptAttachment({ size, head: bytes.subarray(0, SNIFF_BYTES) })
132
+ if (!verdict.ok)
133
+ return response.json({ message: verdict.message }, 400)
134
+
135
+ attachment = {
136
+ bytes,
137
+ // From the bytes, never from `screenshot.mimeType`, which the
138
+ // submitter writes. The dashboard serves the file as this.
139
+ mimeType: verdict.type,
140
+ extension: verdict.extension,
141
+ }
142
+ }
143
+
66
144
  try {
67
145
  const token = await (db as any)
68
146
  .selectFrom('feedback_tokens')
69
147
  .where('token', '=', hashFeedbackToken(raw))
70
- .select(['id', 'board_id', 'revoked_at', 'expires_at'])
148
+ .select(['id', 'board_id', 'revoked_at', 'expires_at', 'label'])
71
149
  .executeTakeFirst()
72
150
 
73
151
  const verdict = authorizeFeedbackToken(token)
@@ -123,9 +201,26 @@ export default new Action({
123
201
  [new Date().toISOString().slice(0, 19).replace('T', ' '), Number(token.id)],
124
202
  ).execute()
125
203
 
204
+ const cardId = Number(card.get('id'))
205
+
206
+ if (attachment)
207
+ await storeAttachment(cardId, attachment)
208
+
209
+ // `void`: the card is saved, so the reviewer is told it went through
210
+ // whatever the notification does. Blocking on an SMTP round-trip would
211
+ // make a mail outage look like a failed submission, and failing on one
212
+ // would be worse.
213
+ void notifyFeedbackFiled({
214
+ boardId: verdict.boardId,
215
+ cardId,
216
+ title,
217
+ description,
218
+ label: String(token.label ?? ''),
219
+ })
220
+
126
221
  // Acknowledgement only. Returning the card, its position or anything
127
222
  // about the board would make this a read endpoint, which it is not.
128
- return { filed: true, id: Number(card.get('id')) }
223
+ return { filed: true, id: cardId }
129
224
  }
130
225
  catch (err) {
131
226
  return kanbanActionError(err, 'FeedbackStoreAction')
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ acceptAttachment,
4
+ acceptAttachmentSize,
5
+ FEEDBACK_ATTACHMENT_MAX_BYTES,
6
+ FEEDBACK_ATTACHMENT_TYPES,
7
+ feedbackAttachmentPath,
8
+ sniffImageType,
9
+ SNIFF_BYTES,
10
+ } from './feedback-attachment'
11
+
12
+ /**
13
+ * The attachment rules, which are the whole security boundary of an
14
+ * unauthenticated upload.
15
+ *
16
+ * Every one of these is reachable only by POSTing a crafted file to a live
17
+ * feedback link, so left in the action they would be rules nobody ever
18
+ * checked (stacksjs/stacks#2872).
19
+ */
20
+
21
+ /** Leading bytes of a real file of each type, padded to the sniff window. */
22
+ const HEADS: Record<string, number[]> = {
23
+ png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D],
24
+ jpeg: [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01],
25
+ webp: [0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50],
26
+ gif: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00],
27
+ }
28
+
29
+ const head = (name: keyof typeof HEADS) => new Uint8Array(HEADS[name])
30
+ const bytes = (text: string) => new TextEncoder().encode(text.padEnd(SNIFF_BYTES, ' '))
31
+
32
+ describe('sniffImageType', () => {
33
+ it('reads each accepted format from its own leading bytes', () => {
34
+ expect(sniffImageType(head('png'))).toBe('image/png')
35
+ expect(sniffImageType(head('jpeg'))).toBe('image/jpeg')
36
+ expect(sniffImageType(head('webp'))).toBe('image/webp')
37
+ expect(sniffImageType(head('gif'))).toBe('image/gif')
38
+ })
39
+
40
+ it('reads GIF87a as well as GIF89a', () => {
41
+ const gif87 = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0, 0, 0, 0, 0, 0])
42
+ expect(sniffImageType(gif87)).toBe('image/gif')
43
+ })
44
+
45
+ it('refuses a RIFF container that is not WebP', () => {
46
+ // A WAV file is RIFF too. Checking only the first four bytes would store
47
+ // audio as an image.
48
+ const wav = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0x24, 0, 0, 0, 0x57, 0x41, 0x56, 0x45])
49
+ expect(sniffImageType(wav)).toBeNull()
50
+ })
51
+
52
+ it('refuses SVG, however it is dressed up', () => {
53
+ // SVG is XML and can carry script. It would be uploaded by an
54
+ // unauthenticated stranger and opened by a signed-in operator, which is a
55
+ // stored XSS rather than a screenshot. This must never start passing.
56
+ expect(sniffImageType(bytes('<svg xmlns='))).toBeNull()
57
+ expect(sniffImageType(bytes('<?xml versio'))).toBeNull()
58
+ expect(sniffImageType(bytes('<svg>'))).toBeNull()
59
+ })
60
+
61
+ it('refuses the things people try to pass off as images', () => {
62
+ for (const probe of ['<!DOCTYPE htm', '<html><body>', 'PK\u0003\u0004zip', '%PDF-1.7', '#!/bin/sh', 'GIF', ''])
63
+ expect(sniffImageType(bytes(probe))).toBeNull()
64
+ })
65
+
66
+ it('refuses a file too short to identify rather than reading past its end', () => {
67
+ expect(sniffImageType(new Uint8Array([0x89, 0x50]))).toBeNull()
68
+ expect(sniffImageType(new Uint8Array([0x52, 0x49, 0x46, 0x46]))).toBeNull()
69
+ expect(sniffImageType(new Uint8Array())).toBeNull()
70
+ })
71
+ })
72
+
73
+ describe('acceptAttachmentSize', () => {
74
+ it('is the gate that passes before a byte is read', () => {
75
+ // The router hands an action an `UploadedFile` wrapper, which reports
76
+ // `size` synchronously and has NO `slice` - reading the leading bytes
77
+ // means reading the whole file. The first version of the action sniffed
78
+ // first and threw `screenshot.slice is not a function` on every upload,
79
+ // which only a real multipart POST found: every unit test here passes a
80
+ // plain Uint8Array, and so did the one the action was written against.
81
+ expect(acceptAttachmentSize(120_000)).toEqual({ ok: true })
82
+ expect(acceptAttachmentSize(FEEDBACK_ATTACHMENT_MAX_BYTES)).toEqual({ ok: true })
83
+ })
84
+
85
+ it('refuses an empty or absurd size', () => {
86
+ for (const size of [0, -1, Number.NaN, Number.POSITIVE_INFINITY])
87
+ expect(acceptAttachmentSize(size)).toMatchObject({ ok: false, reason: 'empty' })
88
+ })
89
+
90
+ it('refuses one byte over the cap', () => {
91
+ expect(acceptAttachmentSize(FEEDBACK_ATTACHMENT_MAX_BYTES + 1))
92
+ .toMatchObject({ ok: false, reason: 'too-large' })
93
+ })
94
+
95
+ it('agrees with acceptAttachment, so the order of refusals is the same either way', () => {
96
+ for (const size of [0, 1, FEEDBACK_ATTACHMENT_MAX_BYTES, FEEDBACK_ATTACHMENT_MAX_BYTES + 1]) {
97
+ const gate = acceptAttachmentSize(size)
98
+ const full = acceptAttachment({ size, head: head('png') })
99
+ expect(full.ok).toBe(gate.ok)
100
+ if (!gate.ok && !full.ok)
101
+ expect(full.reason).toBe(gate.reason)
102
+ }
103
+ })
104
+ })
105
+
106
+ describe('acceptAttachment', () => {
107
+ it('accepts a real screenshot and names what to store it as', () => {
108
+ expect(acceptAttachment({ size: 120_000, head: head('png') }))
109
+ .toEqual({ ok: true, type: 'image/png', extension: 'png' })
110
+ // JPEG stores as `jpg`, which is the extension and not the type name.
111
+ expect(acceptAttachment({ size: 1, head: head('jpeg') }))
112
+ .toEqual({ ok: true, type: 'image/jpeg', extension: 'jpg' })
113
+ })
114
+
115
+ it('refuses an empty file', () => {
116
+ for (const size of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
117
+ const verdict = acceptAttachment({ size, head: head('png') })
118
+ expect(verdict).toMatchObject({ ok: false, reason: 'empty' })
119
+ }
120
+ })
121
+
122
+ it('refuses one byte over the cap, and accepts the cap itself', () => {
123
+ expect(acceptAttachment({ size: FEEDBACK_ATTACHMENT_MAX_BYTES, head: head('png') }).ok).toBe(true)
124
+ const over = acceptAttachment({ size: FEEDBACK_ATTACHMENT_MAX_BYTES + 1, head: head('png') })
125
+ expect(over).toMatchObject({ ok: false, reason: 'too-large' })
126
+ expect((over as { message: string }).message).toContain('5 MB')
127
+ })
128
+
129
+ it('checks the size before the bytes, so an enormous upload costs nothing to refuse', () => {
130
+ // Head deliberately empty: a verdict of `too-large` rather than
131
+ // `unsupported` is what says the size was decided first.
132
+ expect(acceptAttachment({ size: 2 ** 31, head: new Uint8Array() }))
133
+ .toMatchObject({ ok: false, reason: 'too-large' })
134
+ })
135
+
136
+ it('refuses a file whose bytes are not an accepted image', () => {
137
+ const verdict = acceptAttachment({ size: 400, head: bytes('%PDF-1.7') })
138
+ expect(verdict).toMatchObject({ ok: false, reason: 'unsupported' })
139
+ // Says which rule was hit. "Could not attach that" leaves a reviewer
140
+ // guessing between the size and the format.
141
+ expect((verdict as { message: string }).message).toContain('PNG')
142
+ })
143
+
144
+ it('tells a reviewer which rule they hit, in words', () => {
145
+ for (const probe of [
146
+ { size: 0, head: head('png') },
147
+ { size: FEEDBACK_ATTACHMENT_MAX_BYTES * 2, head: head('png') },
148
+ { size: 10, head: bytes('nope') },
149
+ ]) {
150
+ const verdict = acceptAttachment(probe)
151
+ expect(verdict.ok).toBe(false)
152
+ expect((verdict as { message: string }).message.length).toBeGreaterThan(10)
153
+ // User-visible copy: no em-dashes, per the project's own rule.
154
+ expect((verdict as { message: string }).message).not.toMatch(/[–—]/)
155
+ }
156
+ })
157
+ })
158
+
159
+ describe('feedbackAttachmentPath', () => {
160
+ const id = '0190f3b4-7c21-4f0a-9a1e-3d5c7b8e9f00'
161
+
162
+ it('writes under a fixed prefix, keyed by card', () => {
163
+ expect(feedbackAttachmentPath(42, id, 'png')).toBe(`feedback/42/${id}.png`)
164
+ })
165
+
166
+ it('lower-cases the generated id so one file has one path', () => {
167
+ expect(feedbackAttachmentPath(42, id.toUpperCase(), 'png')).toBe(`feedback/42/${id}.png`)
168
+ })
169
+
170
+ it('refuses an id that is not one it generated', () => {
171
+ // The submitter contributes no segment of the path, so anything shaped
172
+ // like traversal or an absolute path is a bug upstream rather than input
173
+ // to sanitise.
174
+ for (const bad of ['../../etc/passwd', 'a/b', '/abs', '', 'short', 'id with spaces', 'x'.repeat(65)])
175
+ expect(() => feedbackAttachmentPath(42, bad, 'png')).toThrow('Refusing to build')
176
+ })
177
+
178
+ it('refuses an extension it did not choose', () => {
179
+ for (const bad of ['', 'svg+xml', '../', 'PNG', 'phtml'])
180
+ expect(() => feedbackAttachmentPath(42, id, bad)).toThrow('Refusing to build')
181
+ })
182
+
183
+ it('refuses to build a path without a card', () => {
184
+ for (const bad of [0, -1, 1.5, Number.NaN])
185
+ expect(() => feedbackAttachmentPath(bad, id, 'png')).toThrow('needs the id of the card')
186
+ })
187
+
188
+ it('builds a path for every extension it may be handed', () => {
189
+ for (const extension of Object.values(FEEDBACK_ATTACHMENT_TYPES))
190
+ expect(feedbackAttachmentPath(1, id, extension)).toBe(`feedback/1/${id}.${extension}`)
191
+ })
192
+ })
@@ -0,0 +1,161 @@
1
+ /**
2
+ * What a feedback submission may attach, and where it is written.
3
+ *
4
+ * A screenshot is most of what a reviewer sends, which is why
5
+ * stacksjs/stacks#2872 asks for it. It is also the one part of this feature
6
+ * that is an unauthenticated write, so every decision is here rather than in
7
+ * the action: a limit that can only be reached through an HTTP request with a
8
+ * crafted file is a limit nobody tests.
9
+ *
10
+ * The existing `FileUploadAction` is deliberately not reused. It takes `disk`
11
+ * and `path` from the request, which is right for a signed-in dashboard user
12
+ * and an arbitrary-write primitive for a feedback link.
13
+ */
14
+
15
+ /**
16
+ * One file per submission.
17
+ *
18
+ * A reviewer sends one screenshot of one thing; the brief asks for one thing
19
+ * per message so each can be picked up on its own. More than one is a
20
+ * multi-file upload API on an endpoint that authenticates nobody.
21
+ */
22
+ export const FEEDBACK_ATTACHMENT_MAX_FILES = 1
23
+
24
+ /**
25
+ * 5 MB, which is a generous full-window PNG screenshot on a retina display
26
+ * and far below what would make this worth using as free object storage. The
27
+ * route's rate limit caps the rest.
28
+ */
29
+ export const FEEDBACK_ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024
30
+
31
+ /**
32
+ * The image types accepted, and the extension each is stored under.
33
+ *
34
+ * SVG is deliberately absent and must stay absent. It is XML, it can carry
35
+ * script, and it would be uploaded by an unauthenticated stranger and later
36
+ * opened by a signed-in operator looking at their own dashboard, which is
37
+ * the shape of a stored cross-site scripting bug rather than a screenshot.
38
+ *
39
+ * Nothing here is decided from the declared `Content-Type`, which the
40
+ * submitter writes.
41
+ */
42
+ export const FEEDBACK_ATTACHMENT_TYPES = {
43
+ 'image/png': 'png',
44
+ 'image/jpeg': 'jpg',
45
+ 'image/webp': 'webp',
46
+ 'image/gif': 'gif',
47
+ } as const
48
+
49
+ export type FeedbackAttachmentType = keyof typeof FEEDBACK_ATTACHMENT_TYPES
50
+
51
+ /** How many leading bytes {@link sniffImageType} needs. */
52
+ export const SNIFF_BYTES = 12
53
+
54
+ function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
55
+ if (bytes.length < offset + signature.length)
56
+ return false
57
+ return signature.every((byte, index) => bytes[offset + index] === byte)
58
+ }
59
+
60
+ /**
61
+ * The type the bytes actually are, or null.
62
+ *
63
+ * Read from the file's own leading bytes, because the declared content type
64
+ * and the filename are both written by whoever is uploading. A `.png` that is
65
+ * an HTML document would otherwise be stored as an image and served as one.
66
+ */
67
+ export function sniffImageType(bytes: Uint8Array): FeedbackAttachmentType | null {
68
+ // \x89PNG\r\n\x1a\n
69
+ if (startsWith(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
70
+ return 'image/png'
71
+ // JPEG's SOI, then any marker.
72
+ if (startsWith(bytes, [0xFF, 0xD8, 0xFF]))
73
+ return 'image/jpeg'
74
+ // RIFF....WEBP: the size field sits between the two, so the second
75
+ // signature is checked at its own offset rather than appended.
76
+ if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8))
77
+ return 'image/webp'
78
+ // GIF87a and GIF89a share these four.
79
+ if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38]))
80
+ return 'image/gif'
81
+ return null
82
+ }
83
+
84
+ export type FeedbackAttachmentRefusal = 'empty' | 'too-large' | 'unsupported'
85
+
86
+ export type FeedbackAttachmentVerdict =
87
+ | { ok: true, type: FeedbackAttachmentType, extension: string }
88
+ | { ok: false, reason: FeedbackAttachmentRefusal, message: string }
89
+
90
+ /**
91
+ * Whether a file of this size may be attached at all.
92
+ *
93
+ * Separate from {@link acceptAttachment} because it is the gate that has to
94
+ * be passed BEFORE the bytes are read. The router hands an action an
95
+ * `UploadedFile` wrapper, which exposes `size` synchronously and has no
96
+ * `slice`, so reading the leading bytes means reading the whole file: a
97
+ * caller that sniffed first would load a 2 GB upload into memory to decide
98
+ * it was too large. Checked by itself, nothing is read.
99
+ */
100
+ export function acceptAttachmentSize(size: number): { ok: true } | { ok: false, reason: FeedbackAttachmentRefusal, message: string } {
101
+ if (!Number.isFinite(size) || size <= 0)
102
+ return { ok: false, reason: 'empty', message: 'That file is empty.' }
103
+
104
+ if (size > FEEDBACK_ATTACHMENT_MAX_BYTES) {
105
+ const mb = Math.floor(FEEDBACK_ATTACHMENT_MAX_BYTES / (1024 * 1024))
106
+ return { ok: false, reason: 'too-large', message: `A screenshot must be ${mb} MB or smaller.` }
107
+ }
108
+
109
+ return { ok: true }
110
+ }
111
+
112
+ /**
113
+ * Whether this file may be attached.
114
+ *
115
+ * `size` is the whole file's length and `head` its leading bytes. The size is
116
+ * judged first, through {@link acceptAttachmentSize}, so the order of the
117
+ * refusals is the same whether a caller gates on size separately or not.
118
+ */
119
+ export function acceptAttachment(input: { size: number, head: Uint8Array }): FeedbackAttachmentVerdict {
120
+ const sized = acceptAttachmentSize(input.size)
121
+ if (!sized.ok)
122
+ return sized
123
+
124
+ const type = sniffImageType(input.head)
125
+ if (!type) {
126
+ // Named rather than vague: a reviewer who tried to attach a PDF or a zip
127
+ // should know which of the two rules they hit.
128
+ return { ok: false, reason: 'unsupported', message: 'Attach a PNG, JPEG, WebP or GIF image.' }
129
+ }
130
+
131
+ return { ok: true, type, extension: FEEDBACK_ATTACHMENT_TYPES[type] }
132
+ }
133
+
134
+ const ALLOWED_EXTENSIONS: ReadonlySet<string> = new Set(Object.values(FEEDBACK_ATTACHMENT_TYPES))
135
+
136
+ /**
137
+ * Where the file is written.
138
+ *
139
+ * Every segment is generated. The submitter contributes no part of the path,
140
+ * not even the extension, which comes from the sniffed type: a filename is an
141
+ * untrusted string and path traversal through an upload name is the oldest
142
+ * bug in this category.
143
+ *
144
+ * Under a fixed `feedback/` prefix so an operator can find, audit or sweep
145
+ * the whole lot, and keyed by card so a deleted card's screenshot is one
146
+ * prefix to remove.
147
+ */
148
+ export function feedbackAttachmentPath(cardId: number, id: string, extension: string): string {
149
+ if (!Number.isSafeInteger(cardId) || cardId <= 0)
150
+ throw new Error('A feedback attachment needs the id of the card it belongs to.')
151
+ if (!/^[0-9a-f-]{8,64}$/i.test(id))
152
+ throw new Error(`Refusing to build an attachment path from ${JSON.stringify(id)}.`)
153
+ // An allowlist, not a shape check. `^[a-z0-9]{2,5}$` admitted `phtml`,
154
+ // which is a shape a `.png` can never have and a suffix some web servers
155
+ // will happily execute. The extension always comes from
156
+ // FEEDBACK_ATTACHMENT_TYPES, so membership is the real rule.
157
+ if (!ALLOWED_EXTENSIONS.has(extension))
158
+ throw new Error(`Refusing to build an attachment path with extension ${JSON.stringify(extension)}.`)
159
+
160
+ return `feedback/${cardId}/${id.toLowerCase()}.${extension}`
161
+ }