@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.
- 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 +102 -7
- 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.ts +13 -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/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/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`
|
|
@@ -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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
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
|
+
}
|