@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.
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { feedbackNotification, planFeedbackFanout } from './feedback-notifier'
3
+
4
+ /**
5
+ * Who gets told when feedback arrives, and what they are told.
6
+ *
7
+ * Both halves are reachable only by filing a card through a live link against
8
+ * configured channels, so left in the fan-out they would be tested by a
9
+ * reviewer noticing that nobody heard from them.
10
+ */
11
+
12
+ describe('planFeedbackFanout', () => {
13
+ it('sends nothing while it is off', () => {
14
+ expect(planFeedbackFanout(undefined)).toEqual({ send: false, reason: 'disabled' })
15
+ expect(planFeedbackFanout({})).toEqual({ send: false, reason: 'disabled' })
16
+ expect(planFeedbackFanout({ enabled: false, channels: ['chat'] })).toEqual({ send: false, reason: 'disabled' })
17
+ })
18
+
19
+ it('broadcasts chat once, with no recipient, however many are listed', () => {
20
+ // Every listed person would otherwise post the same message into the same
21
+ // Slack channel.
22
+ const plan = planFeedbackFanout({
23
+ enabled: true,
24
+ channels: ['chat'],
25
+ recipients: [{ email: 'a@example.com' }, { email: 'b@example.com' }],
26
+ })
27
+
28
+ expect(plan).toEqual({ send: true, targets: [{ recipient: {}, channels: ['chat'] }], unreachable: [] })
29
+ })
30
+
31
+ it('defaults to chat when no channel is named', () => {
32
+ const plan = planFeedbackFanout({ enabled: true })
33
+ expect(plan.send).toBe(true)
34
+ expect((plan as any).targets).toEqual([{ recipient: {}, channels: ['chat'] }])
35
+ })
36
+
37
+ it('offers a recipient only the channels whose contact field it carries', () => {
38
+ // `notify()` throws when the email channel gets a recipient with no
39
+ // address, and one throw per card is how a half-filled list becomes no
40
+ // notifications at all.
41
+ const plan = planFeedbackFanout({
42
+ enabled: true,
43
+ channels: ['email', 'sms', 'database'],
44
+ recipients: [
45
+ { email: 'chris@example.com' },
46
+ { phone: '+15550000' },
47
+ { userId: 7 },
48
+ { email: 'both@example.com', phone: '+15551111' },
49
+ ],
50
+ })
51
+
52
+ expect(plan.send).toBe(true)
53
+ expect((plan as any).targets).toEqual([
54
+ { recipient: { email: 'chris@example.com' }, channels: ['email'] },
55
+ { recipient: { phone: '+15550000' }, channels: ['sms'] },
56
+ { recipient: { userId: 7 }, channels: ['database'] },
57
+ { recipient: { email: 'both@example.com', phone: '+15551111' }, channels: ['email', 'sms'] },
58
+ ])
59
+ expect((plan as any).unreachable).toEqual([])
60
+ })
61
+
62
+ it('leaves out a recipient whose contact field is blank', () => {
63
+ const plan = planFeedbackFanout({
64
+ enabled: true,
65
+ channels: ['email'],
66
+ recipients: [{ email: ' ' }, { email: 'real@example.com' }],
67
+ })
68
+
69
+ expect((plan as any).targets).toEqual([{ recipient: { email: 'real@example.com' }, channels: ['email'] }])
70
+ })
71
+
72
+ it('reports a channel configured with nobody able to receive it', () => {
73
+ // "Configured and delivering nothing" and "not configured" look the same
74
+ // from outside, and only one of them is a mistake.
75
+ const plan = planFeedbackFanout({
76
+ enabled: true,
77
+ channels: ['chat', 'email'],
78
+ recipients: [{ phone: '+15550000' }],
79
+ })
80
+
81
+ expect(plan.send).toBe(true)
82
+ expect((plan as any).unreachable).toEqual(['email'])
83
+ })
84
+
85
+ it('refuses a channel list that can reach nobody at all', () => {
86
+ expect(planFeedbackFanout({ enabled: true, channels: ['email'] }))
87
+ .toEqual({ send: false, reason: 'no-recipients' })
88
+ expect(planFeedbackFanout({ enabled: true, channels: ['email'], recipients: [{ phone: '+1' }] }))
89
+ .toEqual({ send: false, reason: 'no-recipients' })
90
+ expect(planFeedbackFanout({ enabled: true, channels: [] }))
91
+ .toEqual({ send: false, reason: 'no-channels' })
92
+ })
93
+
94
+ it('drops a channel name it does not know rather than handing it to notify', () => {
95
+ const plan = planFeedbackFanout({ enabled: true, channels: ['chat', 'carrier-pigeon' as any] })
96
+ expect((plan as any).targets).toEqual([{ recipient: {}, channels: ['chat'] }])
97
+ })
98
+ })
99
+
100
+ describe('feedbackNotification', () => {
101
+ const base = {
102
+ boardId: 4,
103
+ cardId: 42,
104
+ title: 'Checkout button does nothing on iPhone',
105
+ label: 'Pawel',
106
+ }
107
+
108
+ it('names the board, the card and the link it came through', () => {
109
+ const { subject, body, data } = feedbackNotification({ ...base, boardName: 'Feedback' })
110
+
111
+ expect(subject).toBe('New feedback on Feedback: Checkout button does nothing on iPhone')
112
+ expect(body).toContain('Board: Feedback (/kanban/4)')
113
+ expect(body).toContain('Card: 42')
114
+ expect(body).toContain('Through the link for: Pawel')
115
+ expect(data).toEqual({ boardId: 4, cardId: 42, label: 'Pawel' })
116
+ })
117
+
118
+ it('falls back to the board id when the name could not be read', () => {
119
+ for (const boardName of [undefined, null, ' ']) {
120
+ const { subject, body } = feedbackNotification({ ...base, boardName })
121
+ expect(subject).toContain('board 4')
122
+ expect(body).toContain('Board: board 4 (/kanban/4)')
123
+ }
124
+ })
125
+
126
+ it('quotes the description and cuts a long one', () => {
127
+ const short = feedbackNotification({ ...base, description: 'Tapped it twice, nothing happened.' })
128
+ expect(short.body).toContain('Tapped it twice, nothing happened.')
129
+
130
+ const long = feedbackNotification({ ...base, description: 'x'.repeat(5000) })
131
+ expect(long.body).toContain('...')
132
+ // A 10k-character report must not become a 10k-character SMS.
133
+ expect(long.body.length).toBeLessThan(1200)
134
+ })
135
+
136
+ it('carries no token, by having none to carry', () => {
137
+ // The whole message is built from the board, the card and the label. An
138
+ // operator revokes by label, so the raw token never needs to appear and
139
+ // notifications are stored and forwarded by things nobody audits.
140
+ const { subject, body, data } = feedbackNotification({ ...base, boardName: 'Feedback', description: 'd' })
141
+ const everything = `${subject}\n${body}\n${JSON.stringify(data)}`
142
+ expect(everything).not.toMatch(/[0-9a-f]{64}/)
143
+ })
144
+ })
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Telling somebody that feedback arrived.
3
+ *
4
+ * A card filed through a feedback link lands on a board nobody is necessarily
5
+ * looking at, so the feature's own brief asks for a notification "so feedback
6
+ * is not found by accident" (stacksjs/stacks#2872).
7
+ *
8
+ * The fan-out mirrors `dashboard.ci.notifications`, down to the channel and
9
+ * recipient shapes, rather than inventing a second convention for the same
10
+ * job.
11
+ *
12
+ * Two deliberate differences from the CI notifier:
13
+ *
14
+ * - No cooldown. CI needs one because a flapping repo re-reports the same
15
+ * fact; every feedback card is a different person saying a different thing,
16
+ * so a cooldown would not de-duplicate anything, it would silently drop
17
+ * reports. The route's rate limit is what bounds the volume.
18
+ * - A recipient is matched to the channels it can actually carry. `notify()`
19
+ * throws when the `email` channel gets a recipient with no address, and a
20
+ * throw per card is how a half-filled recipient list turns into no
21
+ * notifications at all.
22
+ */
23
+
24
+ export type FeedbackNotificationChannel = 'email' | 'sms' | 'chat' | 'database'
25
+
26
+ export interface FeedbackNotificationRecipient {
27
+ email?: string
28
+ phone?: string
29
+ userId?: number
30
+ }
31
+
32
+ export interface FeedbackNotificationSettings {
33
+ enabled?: boolean
34
+ channels?: FeedbackNotificationChannel[]
35
+ recipients?: FeedbackNotificationRecipient[]
36
+ }
37
+
38
+ /** What each channel needs on a recipient before `notify()` will take it. */
39
+ const REQUIRES: Record<FeedbackNotificationChannel, keyof FeedbackNotificationRecipient | null> = {
40
+ email: 'email',
41
+ sms: 'phone',
42
+ database: 'userId',
43
+ // Already scoped to one Slack channel by its own env, so it carries no
44
+ // recipient at all.
45
+ chat: null,
46
+ }
47
+
48
+ export interface FeedbackFanoutTarget {
49
+ recipient: FeedbackNotificationRecipient
50
+ channels: FeedbackNotificationChannel[]
51
+ }
52
+
53
+ export type FeedbackFanout =
54
+ | { send: false, reason: 'disabled' | 'no-channels' | 'no-recipients' }
55
+ | { send: true, targets: FeedbackFanoutTarget[], unreachable: FeedbackNotificationChannel[] }
56
+
57
+ /**
58
+ * Who gets told, and through what.
59
+ *
60
+ * Pure, so the awkward combinations can be read off a test rather than
61
+ * discovered by filing feedback: a channel list with nobody who can receive
62
+ * it, a recipient carrying the wrong contact field for the channel it was
63
+ * listed under, `chat` alongside channels that do need a recipient.
64
+ *
65
+ * `unreachable` names the configured channels that reached nobody. Returned
66
+ * rather than dropped: "configured and delivering nothing" and "not
67
+ * configured" look identical from the outside, and only one of them is a
68
+ * mistake.
69
+ */
70
+ export function planFeedbackFanout(settings: FeedbackNotificationSettings | undefined): FeedbackFanout {
71
+ if (!settings?.enabled)
72
+ return { send: false, reason: 'disabled' }
73
+
74
+ const channels = (settings.channels ?? ['chat']).filter(channel => channel in REQUIRES)
75
+ if (channels.length === 0)
76
+ return { send: false, reason: 'no-channels' }
77
+
78
+ const targets: FeedbackFanoutTarget[] = []
79
+ const reached = new Set<FeedbackNotificationChannel>()
80
+
81
+ // `chat` goes out once with no recipient, however many people are listed:
82
+ // every one of them would otherwise post the same message to the same
83
+ // Slack channel.
84
+ const broadcast = channels.filter(channel => REQUIRES[channel] === null)
85
+ if (broadcast.length > 0) {
86
+ targets.push({ recipient: {}, channels: broadcast })
87
+ for (const channel of broadcast) reached.add(channel)
88
+ }
89
+
90
+ const addressed = channels.filter(channel => REQUIRES[channel] !== null)
91
+ for (const recipient of settings.recipients ?? []) {
92
+ const usable = addressed.filter((channel) => {
93
+ const field = REQUIRES[channel]!
94
+ const value = recipient[field]
95
+ return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null
96
+ })
97
+ if (usable.length === 0)
98
+ continue
99
+ targets.push({ recipient, channels: usable })
100
+ for (const channel of usable) reached.add(channel)
101
+ }
102
+
103
+ if (targets.length === 0)
104
+ return { send: false, reason: 'no-recipients' }
105
+
106
+ return { send: true, targets, unreachable: channels.filter(channel => !reached.has(channel)) }
107
+ }
108
+
109
+ export interface FeedbackNotificationInput {
110
+ boardId: number
111
+ boardName?: string | null
112
+ cardId: number
113
+ title: string
114
+ description?: string
115
+ /** The link's label, so two reviewers' feedback can be told apart. */
116
+ label: string
117
+ }
118
+
119
+ /** How long a quoted description runs before it is cut. */
120
+ const EXCERPT = 600
121
+
122
+ /**
123
+ * The message itself.
124
+ *
125
+ * No URL. The board lives at `/kanban/{id}` of whatever origin serves the
126
+ * dashboard, which is not always the app's own, and a notification carrying a
127
+ * link that 404s for everybody is worse than one carrying a path.
128
+ *
129
+ * Never the token. The notification says which link a card came through by
130
+ * its label, which is what an operator revokes by.
131
+ */
132
+ export function feedbackNotification(input: FeedbackNotificationInput): {
133
+ subject: string
134
+ body: string
135
+ data: Record<string, unknown>
136
+ } {
137
+ const board = input.boardName?.trim() || `board ${input.boardId}`
138
+ const description = input.description?.trim()
139
+
140
+ const lines = [`${input.title}`, '']
141
+ if (description)
142
+ lines.push(description.length > EXCERPT ? `${description.slice(0, EXCERPT)}...` : description, '')
143
+ lines.push(`Board: ${board} (/kanban/${input.boardId})`)
144
+ lines.push(`Card: ${input.cardId}`)
145
+ lines.push(`Through the link for: ${input.label}`)
146
+
147
+ return {
148
+ subject: `New feedback on ${board}: ${input.title}`,
149
+ body: lines.join('\n'),
150
+ data: {
151
+ boardId: input.boardId,
152
+ cardId: input.cardId,
153
+ label: input.label,
154
+ },
155
+ }
156
+ }
157
+
158
+ /** The board's own name, for the subject line. Null if it cannot be read. */
159
+ async function boardName(boardId: number): Promise<string | null> {
160
+ try {
161
+ const { db } = await import('@stacksjs/database/runtime')
162
+ const row = await (db as any).selectFrom('boards').select(['name']).where('id', '=', boardId).executeTakeFirst()
163
+ const name = row?.name
164
+ return typeof name === 'string' && name.trim() ? name : null
165
+ }
166
+ catch {
167
+ // The message reads fine without it, and a notification is not worth
168
+ // failing over a name.
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Fan the notification out. Call as `void notifyFeedbackFiled(...)`.
175
+ *
176
+ * The card is already saved by the time this runs, so nothing here may fail
177
+ * the submission: a reviewer must not be told their report did not go through
178
+ * because an SMTP host was down. Every error is logged and swallowed, which is
179
+ * the same contract `runCiFailureNotifier` has.
180
+ */
181
+ export async function notifyFeedbackFiled(input: FeedbackNotificationInput): Promise<void> {
182
+ try {
183
+ const { dashboard } = await import('@stacksjs/config')
184
+ const plan = planFeedbackFanout((dashboard as any)?.feedback?.notifications)
185
+ if (!plan.send)
186
+ return
187
+
188
+ for (const channel of plan.unreachable)
189
+ console.warn(`[feedback-notifier] the ${channel} channel is configured but nobody can receive it`)
190
+
191
+ const { notify } = await import('@stacksjs/notifications')
192
+ // Only now, because it is a query the submission does not need: the hot
193
+ // path has already answered the reviewer, and an app with notifications
194
+ // off never runs this at all.
195
+ const payload = feedbackNotification({ ...input, boardName: input.boardName ?? await boardName(input.boardId) })
196
+
197
+ for (const target of plan.targets) {
198
+ try {
199
+ // `ignorePreferences`, as the CI notifier does: this is an operator
200
+ // alert on a surface they switched on, not a per-user subscription.
201
+ await notify(target.recipient, payload, target.channels, { ignorePreferences: true })
202
+ }
203
+ catch (err) {
204
+ console.warn(`[feedback-notifier] notify failed: ${err instanceof Error ? err.message : String(err)}`)
205
+ }
206
+ }
207
+ }
208
+ catch (err) {
209
+ console.warn(`[feedback-notifier] could not notify: ${err instanceof Error ? err.message : String(err)}`)
210
+ }
211
+ }
@@ -18,6 +18,19 @@ import { createHash, randomBytes } from 'node:crypto'
18
18
  /** 256 bits. The raw token is the only secret, so it carries all the entropy. */
19
19
  export const FEEDBACK_TOKEN_BYTES = 32
20
20
 
21
+ /**
22
+ * What a submission may carry, matching `cards.title` and
23
+ * `cards.description`.
24
+ *
25
+ * Here rather than in the action so the form can render the same limits it
26
+ * will be judged against. A `maxlength` the server disagrees with is the
27
+ * worst of both: the reviewer is either stopped from typing something that
28
+ * would have been accepted, or allowed to write a report that is refused on
29
+ * submit.
30
+ */
31
+ export const FEEDBACK_MAX_TITLE = 300
32
+ export const FEEDBACK_MAX_DESCRIPTION = 10_000
33
+
21
34
  /** A raw token as it appears in a link, and as the column stores it. */
22
35
  const RAW_TOKEN = /^[0-9a-f]{64}$/
23
36
 
@@ -1,6 +1,7 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
+ import { cardAttachments } from './card-attachments'
4
5
  import { modelBoolean } from './kanban-model'
5
6
  import { kanbanActionError, kanbanError } from './kanban-response'
6
7
 
@@ -29,11 +30,12 @@ interface CardRow {
29
30
  * pivots) — this endpoint is for the case where the user lands
30
31
  * directly on a card URL or refreshes mid-modal.
31
32
  *
32
- * Three queries via `Promise.all`:
33
- * 1. The card itself.
34
- * 2. Labels via the card_labels pivot (with the label row).
35
- * 3. Assignees via the card_assignees pivot (with user.name/email).
36
- * 4. Comments (denormalised user name/email for display).
33
+ * The card itself, then four in parallel via `Promise.all`:
34
+ * 1. Labels via the card_labels pivot (with the label row).
35
+ * 2. Assignees via the card_assignees pivot (with user.name/email).
36
+ * 3. Comments (denormalised user name/email for display).
37
+ * 4. Attachments, each with a freshly signed, expiring URL. They live on a
38
+ * private disk, so there is no path a browser could fetch.
37
39
  */
38
40
  export default new Action({
39
41
  name: 'Kanban Card Show',
@@ -59,7 +61,7 @@ export default new Action({
59
61
  return kanbanError('Card not found', 404)
60
62
  }
61
63
 
62
- const [labels, assignees, comments] = await Promise.all([
64
+ const [labels, assignees, comments, attachments] = await Promise.all([
63
65
  db.unsafe(
64
66
  `SELECT l.id, l.name, l.color
65
67
  FROM card_labels cl
@@ -83,6 +85,9 @@ export default new Action({
83
85
  ORDER BY cc.created_at ASC, cc.id ASC`,
84
86
  [id],
85
87
  ).execute() as Promise<Array<{ id: number, uuid: string | null, user_id: number | null, body: string, created_at: string | null, updated_at: string | null, name: string | null, email: string | null }>>,
88
+ // Each one carries a freshly signed, expiring URL: the bytes are on a
89
+ // private disk, so there is no path the browser could fetch.
90
+ cardAttachments(id),
86
91
  ])
87
92
 
88
93
  return {
@@ -108,6 +113,7 @@ export default new Action({
108
113
  assignedByUserId: a.assigned_by_user_id,
109
114
  assignedAt: a.created_at,
110
115
  })),
116
+ attachments,
111
117
  comments: (comments ?? []).map(c => ({
112
118
  id: c.id,
113
119
  uuid: c.uuid,
@@ -0,0 +1,125 @@
1
+ import { afterAll, afterEach, describe, expect, it, spyOn } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { CARD_ATTACHMENT_URL_TTL_SECONDS, shapeCardAttachment, signCardAttachmentUrl } from './card-attachments'
5
+
6
+ const row = {
7
+ id: 7,
8
+ uuid: 'c0ffee00-0000-4000-8000-000000000000',
9
+ disk: 'local',
10
+ path: 'feedback/42/0190f3b4-7c21-4f0a-9a1e-3d5c7b8e9f00.png',
11
+ mime_type: 'image/png',
12
+ size_bytes: 120_000,
13
+ created_at: '2026-10-08 09:12:00',
14
+ }
15
+
16
+ describe('shapeCardAttachment', () => {
17
+ it('returns what the dialog renders and nothing else', () => {
18
+ expect(shapeCardAttachment(row, 'https://example.com/__storage/x?token=y')).toEqual({
19
+ id: 7,
20
+ uuid: 'c0ffee00-0000-4000-8000-000000000000',
21
+ mimeType: 'image/png',
22
+ sizeBytes: 120_000,
23
+ createdAt: '2026-10-08 09:12:00',
24
+ url: 'https://example.com/__storage/x?token=y',
25
+ })
26
+ })
27
+
28
+ it('never publishes the storage path or the disk', () => {
29
+ // The signed URL already carries the path it grants, so the bare path
30
+ // adds nothing a client can use and does invite trying it without a
31
+ // token.
32
+ const shaped = shapeCardAttachment(row, null) as Record<string, unknown>
33
+ expect(Object.keys(shaped).sort()).toEqual(['createdAt', 'id', 'mimeType', 'sizeBytes', 'url', 'uuid'])
34
+ expect(JSON.stringify(shaped)).not.toContain('feedback/42')
35
+ expect(JSON.stringify(shaped)).not.toContain('local')
36
+ })
37
+
38
+ it('lists an attachment whose URL could not be minted', () => {
39
+ // Null, not omitted and not thrown: "there is a screenshot nobody can
40
+ // open" is worth chasing and is a different problem from "there is no
41
+ // screenshot".
42
+ expect(shapeCardAttachment(row, null).url).toBeNull()
43
+ })
44
+
45
+ it('reads a row whose numbers arrive as strings', () => {
46
+ // SQLite and Postgres disagree about this often enough that the Kanban
47
+ // actions already normalise everywhere else.
48
+ const shaped = shapeCardAttachment({ ...row, id: '7' as never, size_bytes: '120000' as never }, null)
49
+ expect(shaped.id).toBe(7)
50
+ expect(shaped.sizeBytes).toBe(120_000)
51
+ })
52
+ })
53
+
54
+ describe('the signed URL lifetime', () => {
55
+ it('outlasts opening a card and not much more', () => {
56
+ // Long enough to look at a screenshot, short enough that a URL copied
57
+ // out of devtools is not a handout.
58
+ expect(CARD_ATTACHMENT_URL_TTL_SECONDS).toBeGreaterThanOrEqual(5 * 60)
59
+ expect(CARD_ATTACHMENT_URL_TTL_SECONDS).toBeLessThanOrEqual(60 * 60)
60
+ })
61
+ })
62
+
63
+ describe('signing one attachment', () => {
64
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
65
+ afterEach(() => warn.mockClear())
66
+ afterAll(() => warn.mockRestore())
67
+
68
+ it('asks the row\'s own disk, for the row\'s path, with the card lifetime', async () => {
69
+ const asked: unknown[] = []
70
+ const url = await signCardAttachmentUrl(row, (name) => {
71
+ asked.push(name)
72
+ return {
73
+ signedUrl: async (path, options) => {
74
+ asked.push(path, options)
75
+ return `https://app.test/__storage/${path}?token=t`
76
+ },
77
+ }
78
+ })
79
+ expect(url).toBe(`https://app.test/__storage/${row.path}?token=t`)
80
+ expect(asked).toEqual(['local', row.path, { expiresIn: CARD_ATTACHMENT_URL_TTL_SECONDS }])
81
+ })
82
+
83
+ it('is null, not a throw, for a disk with no signer', async () => {
84
+ expect(await signCardAttachmentUrl(row, () => ({}))).toBeNull()
85
+ expect(String(warn.mock.calls[0]?.[0])).toContain('does not support signedUrl')
86
+ })
87
+
88
+ it('is null, not a throw, for a disk that is no longer configured', async () => {
89
+ const url = await signCardAttachmentUrl(row, () => {
90
+ throw new Error('disk \'local\' is not configured')
91
+ })
92
+ expect(url).toBeNull()
93
+ expect(String(warn.mock.calls[0]?.[0])).toContain(`attachment ${row.id}`)
94
+ })
95
+
96
+ it('is null, not a throw, when the signer refuses', async () => {
97
+ const url = await signCardAttachmentUrl(row, () => ({
98
+ signedUrl: async () => { throw new Error('APP_KEY is too short to sign with') },
99
+ }))
100
+ expect(url).toBeNull()
101
+ })
102
+ })
103
+
104
+ describe('the card dialog', () => {
105
+ const dialog = readFileSync(
106
+ join(import.meta.dir, '../../../../resources/components/Dashboard/Kanban/KanbanCardDialog.stx'),
107
+ 'utf8',
108
+ )
109
+
110
+ it('renders the attachments it is given', () => {
111
+ expect(dialog).toContain('openCard()?.attachments')
112
+ expect(dialog).toContain(':for="attachment in cardAttachments"')
113
+ })
114
+
115
+ it('opens a signed URL in a new tab, with the referrer withheld', () => {
116
+ // The URL is a bearer credential for one file. `noreferrer` keeps it out
117
+ // of the next page's `Referer` header.
118
+ expect(dialog).toContain('rel="noopener noreferrer"')
119
+ expect(dialog).toContain('target="_blank"')
120
+ })
121
+
122
+ it('shows a placeholder rather than a broken image when there is no URL', () => {
123
+ expect(dialog).toMatch(/:if="attachment\.url"[\s\S]*?:else/)
124
+ })
125
+ })
@@ -0,0 +1,107 @@
1
+ import type { StorageAdapter } from '@stacksjs/storage'
2
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
3
+
4
+ /**
5
+ * Reading a card's attachments back out.
6
+ *
7
+ * The bytes sit on a private disk, because the only thing that writes them
8
+ * today is the unauthenticated feedback intake and a stranger's upload does
9
+ * not belong in a web-served directory (stacksjs/stacks#2872). So the
10
+ * dashboard cannot link to a file path; it links to a short-lived signed URL,
11
+ * minted per request against `/__storage/{path}`, which verifies the HMAC
12
+ * against both the path and the expiry.
13
+ */
14
+
15
+ export interface CardAttachmentRow {
16
+ id: number
17
+ uuid: string | null
18
+ disk: string
19
+ path: string
20
+ mime_type: string
21
+ size_bytes: number
22
+ created_at: string | null
23
+ }
24
+
25
+ export interface CardAttachmentView {
26
+ id: number
27
+ uuid: string | null
28
+ mimeType: string
29
+ sizeBytes: number
30
+ createdAt: string | null
31
+ /**
32
+ * A signed, expiring URL, or null when one could not be minted.
33
+ *
34
+ * Null rather than absent, and rather than throwing: a file whose URL
35
+ * cannot be signed is one the dashboard should still list, because "the
36
+ * card has a screenshot nobody can open" is a different problem from "the
37
+ * card has no screenshot" and only the first one is worth chasing.
38
+ */
39
+ url: string | null
40
+ }
41
+
42
+ /** How long a minted URL lasts. Long enough to open a card, short enough that a copied link is not a handout. */
43
+ export const CARD_ATTACHMENT_URL_TTL_SECONDS = 15 * 60
44
+
45
+ /**
46
+ * The response shape, with the storage path deliberately left out.
47
+ *
48
+ * The signed URL carries the path it grants, so publishing the bare path
49
+ * alongside adds nothing a client can use and does invite someone to try it
50
+ * without a token.
51
+ */
52
+ export function shapeCardAttachment(row: CardAttachmentRow, url: string | null): CardAttachmentView {
53
+ return {
54
+ id: Number(row.id),
55
+ uuid: row.uuid ?? null,
56
+ mimeType: String(row.mime_type ?? ''),
57
+ sizeBytes: Number(row.size_bytes ?? 0),
58
+ createdAt: row.created_at ?? null,
59
+ url,
60
+ }
61
+ }
62
+
63
+ /** One card's attachments, oldest first, each with a freshly signed URL. */
64
+ export async function cardAttachments(cardId: number): Promise<CardAttachmentView[]> {
65
+ const { param } = sqlHelpers(getDatabaseDialect())
66
+ const rows = await db.unsafe(
67
+ `SELECT id, uuid, disk, path, mime_type, size_bytes, created_at
68
+ FROM card_attachments
69
+ WHERE card_id = ${param(1)}
70
+ ORDER BY created_at ASC, id ASC`,
71
+ [cardId],
72
+ ).execute() as unknown as CardAttachmentRow[]
73
+
74
+ if (!rows?.length)
75
+ return []
76
+
77
+ const { Storage } = await import('@stacksjs/storage')
78
+
79
+ return Promise.all((rows ?? []).map(async row =>
80
+ shapeCardAttachment(row, await signCardAttachmentUrl(row, name => Storage.disk(name as never))),
81
+ ))
82
+ }
83
+
84
+ /**
85
+ * A signed URL for one attachment, or null when its disk cannot mint one.
86
+ *
87
+ * Per attachment, so one unreadable file does not take the card down with it.
88
+ */
89
+ export async function signCardAttachmentUrl(
90
+ row: CardAttachmentRow,
91
+ openDisk: (name: string) => Pick<StorageAdapter, 'signedUrl'>,
92
+ ): Promise<string | null> {
93
+ try {
94
+ const disk = openDisk(row.disk)
95
+ // A disk with no signer at all (a driver that cannot mint one) is the
96
+ // same outcome as one that fails to: listed, but with nothing to open.
97
+ if (typeof disk.signedUrl !== 'function')
98
+ throw new Error(`disk '${row.disk}' does not support signedUrl`)
99
+ return await disk.signedUrl(row.path, { expiresIn: CARD_ATTACHMENT_URL_TTL_SECONDS })
100
+ }
101
+ catch (err) {
102
+ // A disk that is no longer configured or cannot sign, or an APP_KEY too
103
+ // short to sign with.
104
+ console.warn(`[card-attachments] could not sign attachment ${row.id}: ${err instanceof Error ? err.message : String(err)}`)
105
+ return null
106
+ }
107
+ }