@stacksjs/defaults 0.75.89 → 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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/app/Actions/Dashboard/Feedback/FeedbackStoreAction.ts +229 -0
- package/app/Actions/Dashboard/Feedback/feedback-attachment.test.ts +192 -0
- package/app/Actions/Dashboard/Feedback/feedback-attachment.ts +161 -0
- package/app/Actions/Dashboard/Feedback/feedback-notifier.test.ts +144 -0
- package/app/Actions/Dashboard/Feedback/feedback-notifier.ts +211 -0
- package/app/Actions/Dashboard/Feedback/feedback-token.test.ts +166 -0
- package/app/Actions/Dashboard/Feedback/feedback-token.ts +149 -0
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +12 -6
- package/app/Actions/Dashboard/Kanban/card-attachments.test.ts +125 -0
- package/app/Actions/Dashboard/Kanban/card-attachments.ts +107 -0
- package/app/Models/CardAttachment.ts +103 -0
- package/app/Models/FeedbackToken.ts +129 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Feedback/FeedbackForm.stx +278 -0
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +30 -0
- package/resources/views/feedback/[token].stx +25 -0
- package/routes/dashboard.ts +15 -0
- package/views/dashboard/stores/kanban.ts +25 -1
|
@@ -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
|
|
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. (
|
|
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
|
|
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,
|
|
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/` (
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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/` (
|
|
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`
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
4
|
+
import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
|
|
5
|
+
import { response } from '@stacksjs/router'
|
|
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'
|
|
15
|
+
import {
|
|
16
|
+
authorizeFeedbackToken,
|
|
17
|
+
FEEDBACK_MAX_DESCRIPTION as MAX_DESCRIPTION,
|
|
18
|
+
FEEDBACK_MAX_TITLE as MAX_TITLE,
|
|
19
|
+
hashFeedbackToken,
|
|
20
|
+
looksLikeFeedbackToken,
|
|
21
|
+
refusalMessage,
|
|
22
|
+
} from './feedback-token'
|
|
23
|
+
|
|
24
|
+
interface FeedbackInput {
|
|
25
|
+
title?: unknown
|
|
26
|
+
description?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
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
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `POST /api/feedback/{token}`.
|
|
69
|
+
*
|
|
70
|
+
* The one unauthenticated write path the dashboard has, so it is deliberately
|
|
71
|
+
* narrow: a feedback link may add a card to the one board its token names,
|
|
72
|
+
* and may not read anything (stacksjs/stacks#2872).
|
|
73
|
+
*
|
|
74
|
+
* Every refusal answers 404 with the same sentence. A 401 would confirm the
|
|
75
|
+
* link once existed, and distinguishing unknown from revoked from expired
|
|
76
|
+
* tells a holder of a guessed token which half of the guess was right, or
|
|
77
|
+
* tells a revoked reviewer they were revoked rather than that the link simply
|
|
78
|
+
* stopped working. The reason is logged, not returned.
|
|
79
|
+
*
|
|
80
|
+
* Rate limited at the route rather than here, the way the other
|
|
81
|
+
* unauthenticated endpoints are.
|
|
82
|
+
*/
|
|
83
|
+
export default new Action({
|
|
84
|
+
name: 'Feedback Store',
|
|
85
|
+
description: 'Files a card on one board from a revocable feedback link.',
|
|
86
|
+
method: 'POST',
|
|
87
|
+
apiResponse: true,
|
|
88
|
+
|
|
89
|
+
async handle(request: RequestInstance<FeedbackInput>) {
|
|
90
|
+
const raw = String(request.getParam('token') || '')
|
|
91
|
+
|
|
92
|
+
// Shape before lookup, so a submit carrying a sentence, a path or a SQL
|
|
93
|
+
// fragment costs no query.
|
|
94
|
+
if (!looksLikeFeedbackToken(raw))
|
|
95
|
+
return response.json({ message: refusalMessage() }, 404)
|
|
96
|
+
|
|
97
|
+
const body = request.all()
|
|
98
|
+
const title = typeof body.title === 'string' ? body.title.trim() : ''
|
|
99
|
+
if (!title || title.length > MAX_TITLE)
|
|
100
|
+
return response.json({ message: `A title is required, up to ${MAX_TITLE} characters.` }, 400)
|
|
101
|
+
|
|
102
|
+
// Rejected rather than truncated. Silently dropping the end of somebody's
|
|
103
|
+
// report is worse than telling them it was too long.
|
|
104
|
+
const rawDescription = typeof body.description === 'string' ? body.description.trim() : ''
|
|
105
|
+
if (rawDescription.length > MAX_DESCRIPTION)
|
|
106
|
+
return response.json({ message: `The description must be ${MAX_DESCRIPTION} characters or fewer.` }, 400)
|
|
107
|
+
const description = rawDescription || undefined
|
|
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
|
+
|
|
144
|
+
try {
|
|
145
|
+
const token = await (db as any)
|
|
146
|
+
.selectFrom('feedback_tokens')
|
|
147
|
+
.where('token', '=', hashFeedbackToken(raw))
|
|
148
|
+
.select(['id', 'board_id', 'revoked_at', 'expires_at', 'label'])
|
|
149
|
+
.executeTakeFirst()
|
|
150
|
+
|
|
151
|
+
const verdict = authorizeFeedbackToken(token)
|
|
152
|
+
if (!verdict.ok) {
|
|
153
|
+
// Logged with its reason and never with the token, so an operator can
|
|
154
|
+
// tell a revoked link from a guessed one without the log holding a
|
|
155
|
+
// live credential.
|
|
156
|
+
console.warn(`[FeedbackStoreAction] refused a submission: ${verdict.reason}`)
|
|
157
|
+
return response.json({ message: refusalMessage() }, 404)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { param } = sqlHelpers(getDatabaseDialect())
|
|
161
|
+
|
|
162
|
+
// The board is checked rather than assumed. `feedback_tokens.board_id`
|
|
163
|
+
// cascades on delete, but SQLite enforces that only with
|
|
164
|
+
// `foreign_keys = ON`, which the framework sets while renaming and not
|
|
165
|
+
// otherwise, so a token can outlive its board.
|
|
166
|
+
const column = await db.unsafe(
|
|
167
|
+
`SELECT id FROM board_columns WHERE board_id = ${param(1)} ORDER BY position ASC, id ASC LIMIT 1`,
|
|
168
|
+
[verdict.boardId],
|
|
169
|
+
).execute() as Array<{ id: number }>
|
|
170
|
+
const columnId = Number(column?.[0]?.id)
|
|
171
|
+
if (!Number.isInteger(columnId) || columnId <= 0) {
|
|
172
|
+
// The link is valid and there is nowhere to put the card. Not the
|
|
173
|
+
// submitter's problem to diagnose, so they get the same sentence.
|
|
174
|
+
console.warn(`[FeedbackStoreAction] board ${verdict.boardId} has no column to file into`)
|
|
175
|
+
return response.json({ message: refusalMessage() }, 404)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const maxRow = await db.unsafe(
|
|
179
|
+
`SELECT COALESCE(MAX(position), -1) AS m FROM cards WHERE column_id = ${param(1)}`,
|
|
180
|
+
[columnId],
|
|
181
|
+
).execute() as Array<{ m: number }>
|
|
182
|
+
const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
|
|
183
|
+
|
|
184
|
+
const { Card } = await import('@stacksjs/orm')
|
|
185
|
+
const card = await Card.create({
|
|
186
|
+
columnId,
|
|
187
|
+
boardId: verdict.boardId,
|
|
188
|
+
title,
|
|
189
|
+
description,
|
|
190
|
+
position: nextPosition,
|
|
191
|
+
// No user. A feedback link authenticates nobody, which is the point
|
|
192
|
+
// of it, so the card is attributed by its board and its label rather
|
|
193
|
+
// than by an account that does not exist.
|
|
194
|
+
createdByUserId: null,
|
|
195
|
+
dueDate: null,
|
|
196
|
+
archived: false,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await db.unsafe(
|
|
200
|
+
`UPDATE feedback_tokens SET last_used_at = ${param(1)} WHERE id = ${param(2)}`,
|
|
201
|
+
[new Date().toISOString().slice(0, 19).replace('T', ' '), Number(token.id)],
|
|
202
|
+
).execute()
|
|
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
|
+
|
|
221
|
+
// Acknowledgement only. Returning the card, its position or anything
|
|
222
|
+
// about the board would make this a read endpoint, which it is not.
|
|
223
|
+
return { filed: true, id: cardId }
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
return kanbanActionError(err, 'FeedbackStoreAction')
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
})
|
|
@@ -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
|
+
})
|