@stacksjs/defaults 0.75.90 → 0.75.92
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
|
@@ -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
|
-
*
|
|
33
|
-
* 1.
|
|
34
|
-
* 2.
|
|
35
|
-
* 3.
|
|
36
|
-
* 4.
|
|
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
|
+
}
|