@stacksjs/defaults 0.75.41 → 0.75.43

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.
Files changed (98) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  3. package/ai/skills/stacks-chat/SKILL.md +51 -1
  4. package/ai/skills/stacks-cloud/SKILL.md +36 -0
  5. package/ai/skills/stacks-dashboard/SKILL.md +16 -2
  6. package/ai/skills/stacks-database/SKILL.md +24 -3
  7. package/ai/skills/stacks-deploy/SKILL.md +7 -1
  8. package/ai/skills/stacks-desktop/SKILL.md +10 -0
  9. package/ai/skills/stacks-development/SKILL.md +1 -1
  10. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  11. package/ai/skills/stacks-models/SKILL.md +38 -2
  12. package/ai/skills/stacks-orm/SKILL.md +2 -2
  13. package/ai/skills/stacks-security/SKILL.md +28 -1
  14. package/app/Actions/Auth/DataExportAction.test.ts +84 -0
  15. package/app/Actions/Auth/DataExportAction.ts +40 -0
  16. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
  17. package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
  18. package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
  19. package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
  20. package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
  21. package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
  22. package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
  23. package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
  24. package/app/Jobs/PruneRetainedDataJob.ts +46 -0
  25. package/app/Models/Activity.ts +2 -0
  26. package/app/Models/CampaignSend.ts +2 -0
  27. package/app/Models/Card.ts +1 -0
  28. package/app/Models/CardComment.ts +1 -0
  29. package/app/Models/Comment.ts +5 -0
  30. package/app/Models/CommunicationSuppression.ts +2 -1
  31. package/app/Models/ConsentEvent.ts +4 -3
  32. package/app/Models/Content/Author.ts +5 -0
  33. package/app/Models/EmailListSubscriber.ts +1 -0
  34. package/app/Models/EmailSuppression.ts +2 -0
  35. package/app/Models/Forms/FormSubmission.ts +5 -0
  36. package/app/Models/GdprRequest.ts +77 -0
  37. package/app/Models/MagicLinkToken.ts +2 -0
  38. package/app/Models/Notification.ts +2 -0
  39. package/app/Models/NotificationDelivery.ts +4 -0
  40. package/app/Models/PaymentMethod.ts +5 -0
  41. package/app/Models/PaymentTransaction.ts +1 -0
  42. package/app/Models/PersonalAccessToken.ts +3 -0
  43. package/app/Models/Referral.ts +4 -1
  44. package/app/Models/ReferralCode.ts +4 -1
  45. package/app/Models/Request.ts +3 -0
  46. package/app/Models/SmsOptOut.ts +2 -0
  47. package/app/Models/SocialAccount.ts +3 -0
  48. package/app/Models/SocialPost.ts +2 -0
  49. package/app/Models/StorageItemTask.ts +14 -8
  50. package/app/Models/Subscriber.ts +2 -0
  51. package/app/Models/SubscriberEmail.ts +2 -0
  52. package/app/Models/Subscription.ts +1 -0
  53. package/app/Models/TeamInvitation.ts +2 -0
  54. package/app/Models/TeamMember.ts +1 -0
  55. package/app/Models/User.ts +5 -0
  56. package/app/Models/commerce/Bid.ts +3 -0
  57. package/app/Models/commerce/Cart.ts +2 -0
  58. package/app/Models/commerce/Courier.ts +7 -0
  59. package/app/Models/commerce/CourierPing.ts +3 -0
  60. package/app/Models/commerce/Customer.ts +5 -0
  61. package/app/Models/commerce/DeliveryStop.ts +7 -0
  62. package/app/Models/commerce/GiftCard.ts +4 -0
  63. package/app/Models/commerce/Order.ts +5 -0
  64. package/app/Models/commerce/OrderItem.ts +2 -0
  65. package/app/Models/commerce/Payment.ts +5 -0
  66. package/app/Models/commerce/Pledge.ts +3 -0
  67. package/app/Models/commerce/Transaction.ts +2 -0
  68. package/app/Models/commerce/WaitlistProduct.ts +5 -0
  69. package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
  70. package/ide/vscode/.vscode/extensions.json +1 -1
  71. package/ide/vscode/.vscode/settings.json +8 -149
  72. package/ide/vscode/README.md +48 -23
  73. package/ide/vscode/build.ts +43 -0
  74. package/ide/vscode/package.json +305 -56
  75. package/ide/vscode/scripts/stx-contributes.ts +66 -0
  76. package/ide/vscode/src/env-file.ts +103 -0
  77. package/ide/vscode/src/env.ts +241 -0
  78. package/ide/vscode/src/extension.ts +120 -26
  79. package/ide/vscode/src/pickier-protocol.ts +51 -0
  80. package/ide/vscode/src/pickier-worker.ts +92 -0
  81. package/ide/vscode/src/pickier.ts +433 -0
  82. package/ide/vscode/src/project.ts +14 -4
  83. package/ide/vscode/src/stacks.ts +18 -11
  84. package/ide/vscode/tests/build.test.ts +58 -0
  85. package/ide/vscode/tests/env.test.ts +166 -0
  86. package/ide/vscode/tests/extension.test.ts +8 -0
  87. package/ide/vscode/tests/fake-vscode.ts +228 -0
  88. package/ide/vscode/tests/manifest.test.ts +88 -0
  89. package/ide/vscode/tests/pickier.test.ts +202 -0
  90. package/ide/vscode/tsconfig.json +2 -2
  91. package/package.json +2 -2
  92. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
  93. package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
  94. package/routes/auth.ts +4 -0
  95. package/vcs/github/workflows/buddy-bot.yml +2 -2
  96. package/vcs/github/workflows/ci.yml +3 -3
  97. package/vcs/github/workflows/release.yml +1 -1
  98. package/ide/vscode/src/vscode.d.ts +0 -181
@@ -0,0 +1,548 @@
1
+ import type { StorageAdapter } from '@stacksjs/storage'
2
+ import type { StorageItemTask, StorageMetadataStore } from './file-metadata'
3
+ import { posix } from 'node:path'
4
+ import { resolveMime } from '@stacksjs/storage'
5
+ import { bareMime, runTask, StorageTaskSkipped, variantsPathFor } from './file-metadata'
6
+
7
+ /**
8
+ * File previews for the dashboard file manager (stacksjs/stacks#308).
9
+ *
10
+ * #308 left one question open: render previews on demand at the edge, or make
11
+ * one once and serve it as a normal stored file. This is the second. A preview
12
+ * is a derivative like the image variants and the video renditions, so it goes
13
+ * through the same machinery - a `preview` row in `storage_item_tasks`, a job
14
+ * on the `media` queue, the output written to the same disk under
15
+ * `.variants/<path>/` - and it follows a rename, a delete and a sweep the way
16
+ * they already do.
17
+ *
18
+ * ## What draws, and what is recorded as skipped
19
+ *
20
+ * Only what the framework's own libraries can render. No ffmpeg, no
21
+ * ImageMagick, no headless browser:
22
+ *
23
+ * - **Images** (PNG, JPEG, GIF, BMP, WebP, AVIF) are decoded, scaled to fit
24
+ * {@link PREVIEW_MAX_EDGE} and encoded as PNG by `ts-images`, entirely in
25
+ * memory. PNG rather than WebP because `ts-webp` reaches for `cwebp` when one
26
+ * is installed, and a preview should not depend on what the box happens to
27
+ * have on its PATH.
28
+ * - **WAV** is drawn as a waveform. Its samples are PCM already, so there is
29
+ * nothing to decode - the header says where they are.
30
+ * - **TrueType fonts** are drawn as a specimen with the font itself, through
31
+ * the `ts-images` glyph rasterizer.
32
+ * - **Text** gets a snippet - the first lines, as plain text - rather than an
33
+ * image. Drawing text needs a font, the framework ships no TrueType face to
34
+ * draw it with, and a file manager can show twelve lines of text as text.
35
+ *
36
+ * Everything else that has a preview kind is recorded `skipped`, with the
37
+ * reason, at dispatch time and without a queue round trip:
38
+ *
39
+ * - **SVG** is never rasterized. It is markup somebody else wrote, and drawing
40
+ * attacker-supplied SVG is script execution - `fileInfo` marks it
41
+ * non-previewable for the same reason.
42
+ * - **Video**: `ts-videos` reads containers itself, but decoding a frame goes
43
+ * through WebCodecs, which this runtime does not provide.
44
+ * - **Compressed audio** (MP3, AAC, FLAC, Ogg): same, through `@ts-audio`.
45
+ * - **PDF**: there is no homegrown PDF renderer.
46
+ * - **CFF, WOFF and WOFF2 fonts**: the rasterizer reads TrueType outlines only.
47
+ * - **HEIC and TIFF**: no decoder for either.
48
+ *
49
+ * Archives and unknown binaries get no preview row at all - there is nothing to
50
+ * say about a zip that its icon does not already say.
51
+ */
52
+
53
+ /** The longest edge of a raster preview, in pixels. Twice a file-manager card, for dense screens. */
54
+ export const PREVIEW_MAX_EDGE = 320
55
+
56
+ /** How much of a text file the snippet keeps. */
57
+ export const TEXT_PREVIEW_MAX_LINES = 12
58
+ export const TEXT_PREVIEW_MAX_COLUMNS = 80
59
+
60
+ /**
61
+ * Bounds on what the worker will load to draw a preview.
62
+ *
63
+ * A preview is a thumbnail; a 2 GB WAV or a 200-megapixel scan is not worth
64
+ * holding in a worker's memory to make one. Past these the task is skipped
65
+ * with the reason rather than failed - retrying will not make the file smaller.
66
+ */
67
+ export const MAX_PREVIEW_SOURCE_BYTES = 128 * 1024 * 1024
68
+ export const MAX_PREVIEW_PIXELS = 50_000_000
69
+
70
+ /** Bytes read from a text file to make its snippet. Enough for the lines kept, with room for long ones. */
71
+ const TEXT_HEAD_BYTES = 16 * 1024
72
+
73
+ export type PreviewRenderer = 'image' | 'waveform' | 'font' | 'text'
74
+
75
+ /** The stored format of a preview: a PNG for everything drawn, a snippet for text. */
76
+ export type PreviewFormat = 'png' | 'txt'
77
+
78
+ /** What a content type gets: a renderer, or the reason there is none here. */
79
+ export type PreviewPlan =
80
+ | { renderer: PreviewRenderer, format: PreviewFormat }
81
+ | { skip: string }
82
+
83
+ /** A preview, rendered and not yet stored. */
84
+ export interface RenderedPreview {
85
+ format: PreviewFormat
86
+ bytes: Uint8Array
87
+ /** Pixel size of a drawn preview; absent for a text snippet. */
88
+ width?: number
89
+ height?: number
90
+ }
91
+
92
+ /** What a finished preview task reports. */
93
+ export interface GeneratedPreview {
94
+ path: string
95
+ format: PreviewFormat
96
+ bytes: number
97
+ width?: number
98
+ height?: number
99
+ }
100
+
101
+ /** Image formats `ts-images` decodes. HEIC and TIFF it recognises but cannot decode. */
102
+ const DECODABLE_IMAGES = new Set([
103
+ 'image/png',
104
+ 'image/apng',
105
+ 'image/jpeg',
106
+ 'image/jpg',
107
+ 'image/pjpeg',
108
+ 'image/gif',
109
+ 'image/bmp',
110
+ 'image/x-bmp',
111
+ 'image/x-ms-bmp',
112
+ 'image/webp',
113
+ 'image/avif',
114
+ ])
115
+
116
+ const WAV_TYPES = new Set(['audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave'])
117
+
118
+ const TRUETYPE_TYPES = new Set(['font/ttf', 'font/sfnt', 'application/x-font-ttf', 'application/font-sfnt', 'application/x-font-truetype'])
119
+
120
+ const TEXT_APPLICATION_TYPES = /^application\/(?:json|ld\+json|xml|yaml|x-yaml|javascript|typescript|x-sh|toml|sql|graphql)$/
121
+
122
+ /**
123
+ * The content type a file claims to be.
124
+ *
125
+ * What the adapter reports, and when that is nothing useful - the local driver
126
+ * answers `application/octet-stream` for a `.webp`, a `.md` or a `.ttf` - a
127
+ * guess from the extension. A claim either way: the job checks it against the
128
+ * bytes before drawing anything, and a file whose bytes disagree with its name
129
+ * is skipped rather than drawn as whatever the bytes turned out to be.
130
+ */
131
+ export function claimedContentType(path: string, reported?: string): string {
132
+ const mime = bareMime(reported)
133
+ if (mime && mime !== 'application/octet-stream')
134
+ return mime
135
+ return bareMime(Bun.file(posix.basename(path)).type) || 'application/octet-stream'
136
+ }
137
+
138
+ /**
139
+ * Whether a file gets a preview, and how.
140
+ *
141
+ * `null` for a type with nothing to preview - archives, unknown binaries - so
142
+ * no row is written. A `skip` is recorded as a skipped task with its reason,
143
+ * which is the answer to "why does this file have no thumbnail".
144
+ */
145
+ export function previewPlanFor(contentType: string | undefined): PreviewPlan | null {
146
+ const mime = bareMime(contentType)
147
+
148
+ if (mime === 'image/svg+xml')
149
+ return { skip: 'SVG is never rasterized here: it is markup, and drawing attacker-supplied SVG is script execution.' }
150
+ if (DECODABLE_IMAGES.has(mime))
151
+ return { renderer: 'image', format: 'png' }
152
+ if (mime.startsWith('image/'))
153
+ return { skip: `No homegrown decoder reads ${mime}.` }
154
+
155
+ if (mime.startsWith('video/'))
156
+ return { skip: 'Drawing a video frame needs a decoder: ts-videos decodes through WebCodecs, which this runtime does not provide.' }
157
+
158
+ if (WAV_TYPES.has(mime))
159
+ return { renderer: 'waveform', format: 'png' }
160
+ if (mime.startsWith('audio/'))
161
+ return { skip: `Only uncompressed WAV can be drawn here: decoding ${mime} needs WebCodecs, which this runtime does not provide.` }
162
+
163
+ if (mime === 'application/pdf')
164
+ return { skip: 'There is no homegrown PDF renderer to draw a page with.' }
165
+
166
+ if (TRUETYPE_TYPES.has(mime))
167
+ return { renderer: 'font', format: 'png' }
168
+ if (mime.startsWith('font/') || /^application\/(?:x-)?font/.test(mime))
169
+ return { skip: 'Only TrueType outlines can be drawn here; CFF, WOFF and WOFF2 fonts are not.' }
170
+
171
+ if (mime.startsWith('text/') || TEXT_APPLICATION_TYPES.test(mime))
172
+ return { renderer: 'text', format: 'txt' }
173
+
174
+ return null
175
+ }
176
+
177
+ /**
178
+ * Where a file's preview is stored, beside its other derivatives.
179
+ *
180
+ * Derived from the path and the format rather than recorded in a column, so it
181
+ * cannot go stale: the task row says whether a preview was made, this says
182
+ * where. A rename through the dashboard moves the folder with the row.
183
+ */
184
+ export function previewPathFor(path: string, format: PreviewFormat = 'png'): string {
185
+ return `${variantsPathFor(path)}/preview.${format}`
186
+ }
187
+
188
+ /**
189
+ * Draw the preview for `bytes`, as `plan` says to.
190
+ *
191
+ * Throws {@link StorageTaskSkipped} when the bytes turn out not to be what the
192
+ * content type claimed, or are past the size bounds; throws anything else when
193
+ * the file is what it claims and is broken, which is a failure worth seeing.
194
+ */
195
+ export async function renderPreview(
196
+ bytes: Uint8Array,
197
+ plan: { renderer: PreviewRenderer },
198
+ name = '',
199
+ ): Promise<RenderedPreview> {
200
+ switch (plan.renderer) {
201
+ case 'image':
202
+ return await renderImage(bytes, name)
203
+ case 'waveform':
204
+ return await renderWaveform(bytes)
205
+ case 'font':
206
+ return await renderFont(bytes)
207
+ case 'text':
208
+ return renderText(bytes, name)
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Make, store and record one file's preview.
214
+ *
215
+ * What `GenerateStoragePreviewJob` runs, separated from it so it can be tested
216
+ * against an in-memory store and a temporary disk. Idempotent: the path is
217
+ * derived, so a re-run overwrites the same file, and the task row is replaced
218
+ * rather than added to.
219
+ */
220
+ export async function generateStoragePreview(
221
+ adapter: StorageAdapter,
222
+ store: StorageMetadataStore,
223
+ disk: string,
224
+ path: string,
225
+ ): Promise<GeneratedPreview | undefined> {
226
+ return await runTask(store, disk, path, 'preview', async () => {
227
+ const plan = previewPlanFor(claimedContentType(path, await adapter.mimeType(path).catch(() => undefined)))
228
+ if (!plan)
229
+ throw new StorageTaskSkipped('Nothing previews this kind of file.')
230
+ if ('skip' in plan)
231
+ throw new StorageTaskSkipped(plan.skip)
232
+
233
+ const size = await adapter.fileSize(path)
234
+ if (plan.renderer !== 'text' && size > MAX_PREVIEW_SOURCE_BYTES)
235
+ throw new StorageTaskSkipped(`The file is ${size} bytes; previews are drawn from files up to ${MAX_PREVIEW_SOURCE_BYTES}.`)
236
+
237
+ const source = plan.renderer === 'text'
238
+ ? await readHead(adapter, path, TEXT_HEAD_BYTES)
239
+ : new Uint8Array(await adapter.readToBuffer(path))
240
+
241
+ const rendered = await renderPreview(source, plan, path)
242
+ const stored = previewPathFor(path, rendered.format)
243
+ await adapter.write(stored, rendered.bytes)
244
+
245
+ return {
246
+ path: stored,
247
+ format: rendered.format,
248
+ bytes: rendered.bytes.byteLength,
249
+ width: rendered.width,
250
+ height: rendered.height,
251
+ }
252
+ })
253
+ }
254
+
255
+ /**
256
+ * The URL a finished image preview is served from, or `undefined`.
257
+ *
258
+ * Resolved the way the listing resolves a file's own `url` and `thumbnail`: a
259
+ * public disk serves it at its public URL, and the project's own `public` disk
260
+ * at its path under the site root. A private disk has no URL to give - the same
261
+ * as the original file there - so its previews are not linked, rather than
262
+ * linked to something that answers 403.
263
+ */
264
+ export async function previewUrlFor(
265
+ adapter: Pick<StorageAdapter, 'publicUrl'>,
266
+ path: string,
267
+ options: { public: boolean, servesProjectPublic: boolean },
268
+ ): Promise<string | undefined> {
269
+ if (!options.public)
270
+ return undefined
271
+
272
+ const stored = previewPathFor(path, 'png')
273
+ if (options.servesProjectPublic)
274
+ return `/${stored.split('/').map(component => encodeURIComponent(component)).join('/')}`
275
+
276
+ try {
277
+ return await adapter.publicUrl(stored)
278
+ }
279
+ catch {
280
+ return undefined
281
+ }
282
+ }
283
+
284
+ /** Whether a file's preview task finished, so there is a preview to point at. */
285
+ export function hasPreview(tasks: readonly StorageItemTask[]): boolean {
286
+ return tasks.some(task => task.kind === 'preview' && task.state === 'done')
287
+ }
288
+
289
+ /** The first `limit` bytes of a file, without reading the rest when the adapter can stream. */
290
+ async function readHead(adapter: StorageAdapter, path: string, limit: number): Promise<Uint8Array> {
291
+ if (!adapter.getStream)
292
+ return new Uint8Array(await adapter.readToBuffer(path)).subarray(0, limit)
293
+
294
+ const reader = (await adapter.getStream(path)).getReader()
295
+ const chunks: Uint8Array[] = []
296
+ let length = 0
297
+
298
+ try {
299
+ while (length < limit) {
300
+ const { done, value } = await reader.read()
301
+ if (done || !value)
302
+ break
303
+ chunks.push(value)
304
+ length += value.byteLength
305
+ }
306
+ }
307
+ finally {
308
+ await reader.cancel().catch(() => {})
309
+ }
310
+
311
+ const head = new Uint8Array(Math.min(length, limit))
312
+ let offset = 0
313
+ for (const chunk of chunks) {
314
+ const take = Math.min(chunk.byteLength, head.byteLength - offset)
315
+ head.set(chunk.subarray(0, take), offset)
316
+ offset += take
317
+ if (offset >= head.byteLength)
318
+ break
319
+ }
320
+ return head
321
+ }
322
+
323
+ /** Refuse to draw bytes whose signature says they are something other than claimed. */
324
+ function assertSniffedAs(bytes: Uint8Array, name: string, accept: (mime: string) => boolean, what: string): void {
325
+ const { mime, source } = resolveMime(bytes, name)
326
+ if (source === 'magic-bytes' && !accept(mime))
327
+ throw new StorageTaskSkipped(`The content is ${mime}, not ${what}; a preview is only drawn when the bytes agree with the name.`)
328
+ }
329
+
330
+ async function renderImage(bytes: Uint8Array, name: string): Promise<RenderedPreview> {
331
+ // SVG cannot reach here through the plan, and this is the second lock on that
332
+ // door: a `.png` whose bytes are SVG is markup, and it is not drawn either.
333
+ assertSniffedAs(bytes, name, mime => DECODABLE_IMAGES.has(mime), 'a raster image this decoder reads')
334
+
335
+ const { decode, encode, getMetadata, resize } = await import('ts-images')
336
+
337
+ const metadata = await getMetadata(bytes).catch(() => undefined)
338
+ if (metadata && metadata.width * metadata.height > MAX_PREVIEW_PIXELS)
339
+ throw new StorageTaskSkipped(`The image is ${metadata.width}x${metadata.height}; previews are drawn from images up to ${MAX_PREVIEW_PIXELS} pixels.`)
340
+
341
+ const source = await decode(bytes)
342
+ const scale = Math.min(1, PREVIEW_MAX_EDGE / Math.max(source.width, source.height))
343
+ const width = Math.max(1, Math.round(source.width * scale))
344
+ const height = Math.max(1, Math.round(source.height * scale))
345
+ // Never upscaled: a 64px icon is previewed at 64px, and the card scales it.
346
+ const scaled = scale < 1 ? resize(source, { width, height, fit: 'fill', kernel: 'bilinear' }) : source
347
+
348
+ return { format: 'png', bytes: await encode(scaled, 'png'), width: scaled.width, height: scaled.height }
349
+ }
350
+
351
+ interface PcmFormat {
352
+ /** 1 integer PCM, 3 IEEE float. */
353
+ encoding: 1 | 3
354
+ channels: number
355
+ sampleRate: number
356
+ bitsPerSample: number
357
+ dataOffset: number
358
+ dataLength: number
359
+ }
360
+
361
+ /**
362
+ * Where the samples are in a WAV file, from its RIFF header.
363
+ *
364
+ * Skips rather than fails on anything that is not uncompressed PCM - a WAV
365
+ * container can carry ADPCM or even MP3, and drawing those needs a decoder.
366
+ */
367
+ export function readWavFormat(bytes: Uint8Array): PcmFormat {
368
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
369
+ const tag = (offset: number): string => String.fromCharCode(...bytes.subarray(offset, offset + 4))
370
+
371
+ if (bytes.byteLength < 12 || tag(0) !== 'RIFF' || tag(8) !== 'WAVE')
372
+ throw new StorageTaskSkipped('The content is not a RIFF/WAVE file, so there are no samples to draw.')
373
+
374
+ let format: Omit<PcmFormat, 'dataOffset' | 'dataLength'> | undefined
375
+ let offset = 12
376
+
377
+ while (offset + 8 <= bytes.byteLength) {
378
+ const id = tag(offset)
379
+ const size = view.getUint32(offset + 4, true)
380
+ const body = offset + 8
381
+
382
+ if (id === 'fmt ' && size >= 16 && body + 16 <= bytes.byteLength) {
383
+ let encoding = view.getUint16(body, true)
384
+ // WAVE_FORMAT_EXTENSIBLE carries the real format in its sub-format GUID,
385
+ // whose first two bytes are the classic format code.
386
+ if (encoding === 0xFFFE && size >= 26 && body + 26 <= bytes.byteLength)
387
+ encoding = view.getUint16(body + 24, true)
388
+
389
+ if (encoding !== 1 && encoding !== 3)
390
+ throw new StorageTaskSkipped(`This WAV is compressed (format 0x${encoding.toString(16)}); only uncompressed PCM can be drawn here.`)
391
+
392
+ format = {
393
+ encoding,
394
+ channels: view.getUint16(body + 2, true),
395
+ sampleRate: view.getUint32(body + 4, true),
396
+ bitsPerSample: view.getUint16(body + 14, true),
397
+ }
398
+ }
399
+ else if (id === 'data') {
400
+ if (!format)
401
+ throw new Error('The WAV data chunk comes before its format chunk.')
402
+ return { ...format, dataOffset: body, dataLength: Math.min(size, bytes.byteLength - body) }
403
+ }
404
+
405
+ // Chunks are word-aligned: an odd-sized chunk has a pad byte after it.
406
+ offset = body + size + (size % 2)
407
+ }
408
+
409
+ throw new Error('The WAV file has no data chunk.')
410
+ }
411
+
412
+ /**
413
+ * Peak amplitude per column, across every channel, from 0 to 1.
414
+ *
415
+ * Computed straight off the bytes rather than by decoding every sample into a
416
+ * float array first: ten minutes of 48 kHz stereo is 28 million samples, and
417
+ * a waveform 320 pixels wide needs one number per pixel.
418
+ */
419
+ export function wavPeaks(bytes: Uint8Array, format: PcmFormat, columns: number): number[] {
420
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
421
+ const bytesPerSample = format.bitsPerSample / 8
422
+ const frameSize = bytesPerSample * format.channels
423
+ const valid = (format.encoding === 1 && [8, 16, 24, 32].includes(format.bitsPerSample))
424
+ || (format.encoding === 3 && [32, 64].includes(format.bitsPerSample))
425
+
426
+ if (!valid || format.channels < 1 || frameSize === 0)
427
+ throw new StorageTaskSkipped(`A ${format.bitsPerSample}-bit ${format.encoding === 3 ? 'float' : 'integer'} WAV is not a sample layout drawn here.`)
428
+
429
+ const frames = Math.floor(format.dataLength / frameSize)
430
+ const peaks: number[] = Array.from({ length: columns }, () => 0)
431
+ if (frames === 0)
432
+ return peaks
433
+
434
+ const read = (at: number): number => {
435
+ switch (format.bitsPerSample) {
436
+ case 8: return (view.getUint8(at) - 128) / 128
437
+ case 16: return view.getInt16(at, true) / 32768
438
+ case 24: {
439
+ const value = view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getInt8(at + 2) << 16)
440
+ return value / 8388608
441
+ }
442
+ case 32: return format.encoding === 3 ? view.getFloat32(at, true) : view.getInt32(at, true) / 2147483648
443
+ default: return view.getFloat64(at, true)
444
+ }
445
+ }
446
+
447
+ for (let frame = 0; frame < frames; frame++) {
448
+ const column = Math.min(columns - 1, Math.floor(frame * columns / frames))
449
+ const base = format.dataOffset + frame * frameSize
450
+ for (let channel = 0; channel < format.channels; channel++) {
451
+ const sample = Math.abs(read(base + channel * bytesPerSample))
452
+ if (Number.isFinite(sample) && sample > peaks[column]!)
453
+ peaks[column] = Math.min(1, sample)
454
+ }
455
+ }
456
+
457
+ return peaks
458
+ }
459
+
460
+ async function renderWaveform(bytes: Uint8Array): Promise<RenderedPreview> {
461
+ const format = readWavFormat(bytes)
462
+ const width = PREVIEW_MAX_EDGE
463
+ const height = 120
464
+ const bar = 2
465
+ const gap = 1
466
+ const peaks = wavPeaks(bytes, format, Math.floor(width / (bar + gap)))
467
+
468
+ // Normalized to the loudest column, so a quiet recording still has a shape.
469
+ const loudest = Math.max(...peaks, 0) || 1
470
+ const { createImageData, encode, fillRect } = await import('ts-images')
471
+ const canvas = createImageData(width, height, { hasAlpha: true, fill: { r: 245, g: 243, b: 255, a: 255 } })
472
+ const middle = height / 2
473
+
474
+ peaks.forEach((peak, index) => {
475
+ const extent = Math.max(1, Math.round((peak / loudest) * (middle - 8)))
476
+ fillRect(canvas, { x: index * (bar + gap), y: Math.round(middle - extent), width: bar, height: extent * 2 }, { r: 124, g: 58, b: 237, a: 255 })
477
+ })
478
+
479
+ return { format: 'png', bytes: await encode(canvas, 'png'), width, height }
480
+ }
481
+
482
+ /** The SFNT signatures, so a font the rasterizer cannot read is skipped with a reason rather than failed. */
483
+ function assertTrueType(bytes: Uint8Array): void {
484
+ const signature = String.fromCharCode(...bytes.subarray(0, 4))
485
+ if (signature === 'OTTO')
486
+ throw new StorageTaskSkipped('This is an OpenType font with CFF outlines; only TrueType outlines can be drawn here.')
487
+ if (signature === 'wOFF' || signature === 'wOF2')
488
+ throw new StorageTaskSkipped('This is a compressed WOFF font; only uncompressed TrueType can be drawn here.')
489
+ if (signature === 'ttcf')
490
+ throw new StorageTaskSkipped('This is a TrueType collection; only single fonts are drawn here.')
491
+ const truetype = bytes.byteLength >= 4
492
+ && ((bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0) || signature === 'true')
493
+ if (!truetype)
494
+ throw new StorageTaskSkipped('The content is not a TrueType font, so there are no outlines to draw.')
495
+ }
496
+
497
+ async function renderFont(bytes: Uint8Array): Promise<RenderedPreview> {
498
+ assertTrueType(bytes)
499
+
500
+ const { createImageData, drawText, encode, loadFont } = await import('ts-images')
501
+ // `warnOnVariable` off: a variable font draws its default master, which is a
502
+ // perfectly good specimen, and a warning per upload is noise in a worker log.
503
+ const font = loadFont(bytes, { warnOnVariable: false })
504
+
505
+ const width = PREVIEW_MAX_EDGE
506
+ const height = 200
507
+ const canvas = createImageData(width, height, { hasAlpha: true, fill: { r: 255, g: 255, b: 255, a: 255 } })
508
+ const ink = { r: 28, g: 25, b: 23, a: 255 }
509
+
510
+ drawText(canvas, { text: 'Aa', font, size: 96, x: 20, y: 110, color: ink })
511
+ drawText(canvas, { text: 'ABCDEFGHIJKLM', font, size: 20, x: 20, y: 150, color: ink, maxWidth: width - 40, maxLines: 1 })
512
+ drawText(canvas, { text: 'abcdefghijklm 0123456789', font, size: 20, x: 20, y: 180, color: ink, maxWidth: width - 40, maxLines: 1 })
513
+
514
+ return { format: 'png', bytes: await encode(canvas, 'png'), width, height }
515
+ }
516
+
517
+ /**
518
+ * The first lines of a text file, cleaned for display.
519
+ *
520
+ * Control characters other than tabs are dropped, tabs become two spaces, and
521
+ * each line is cut at {@link TEXT_PREVIEW_MAX_COLUMNS}. The result is plain
522
+ * text for a `<pre>`, never markup - a dashboard interpolates it as text.
523
+ */
524
+ export function textSnippet(bytes: Uint8Array): string {
525
+ // A NUL is the cheapest reliable sign of a binary file wearing a text name.
526
+ if (bytes.includes(0))
527
+ throw new StorageTaskSkipped('The content has NUL bytes, so it is binary rather than text.')
528
+
529
+ let text = new TextDecoder('utf-8').decode(bytes)
530
+ // A head cut mid-character ends in a replacement character; drop it rather
531
+ // than show a glyph for a byte that was never part of the file's text.
532
+ text = text.replace(/�$/, '').replace(/^/, '')
533
+
534
+ return text
535
+ .split(/\r\n|\r|\n/)
536
+ .slice(0, TEXT_PREVIEW_MAX_LINES)
537
+ // eslint-disable-next-line no-control-regex
538
+ .map(line => [...line.replace(/\t/g, ' ').replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '')].slice(0, TEXT_PREVIEW_MAX_COLUMNS).join('').trimEnd())
539
+ .join('\n')
540
+ .trimEnd()
541
+ }
542
+
543
+ function renderText(bytes: Uint8Array, name: string): RenderedPreview {
544
+ // Text has no signature, so ANY signature match - a PNG, a zip, a PDF named
545
+ // `.txt` - means the bytes are not text.
546
+ assertSniffedAs(bytes, name, () => false, 'text')
547
+ return { format: 'txt', bytes: new TextEncoder().encode(textSnippet(bytes)) }
548
+ }
@@ -0,0 +1,47 @@
1
+ import { log } from '@stacksjs/logging'
2
+ import { Job } from '@stacksjs/queue'
3
+ import { Storage } from '@stacksjs/storage'
4
+ import { generateStoragePreview } from '../Actions/Dashboard/Content/file-preview'
5
+ import { databaseMetadataStore } from '../Actions/Dashboard/Content/file-metadata-store'
6
+
7
+ /**
8
+ * Draw the small preview the file manager shows for an upload (stacksjs/stacks#308).
9
+ *
10
+ * Generated once and stored, not rendered on demand at the edge: a preview is a
11
+ * derivative like the image variants and the video renditions, so it rides the
12
+ * same `storage_item_tasks` row, the same `media` queue and the same
13
+ * `.variants/<path>/` folder, and follows a rename or a delete the way they do.
14
+ * The work itself - which kinds draw, which are skipped and why - is in
15
+ * `file-preview.ts`, so it can be tested without a queue or a database.
16
+ *
17
+ * Unlike the other media jobs this one needs no staging directory: every
18
+ * renderer works on bytes in memory, so a remote disk is read straight into the
19
+ * decoder.
20
+ */
21
+ interface GenerateStoragePreviewPayload {
22
+ disk: string
23
+ path: string
24
+ }
25
+
26
+ export default new Job({
27
+ name: 'GenerateStoragePreview',
28
+ description: 'Draw the file manager preview for an uploaded file (background)',
29
+ queue: 'media',
30
+ // Two tries: a decode that failed once on the same bytes will fail again, so
31
+ // the retry is only for a disk read that hiccupped. A kind that cannot be
32
+ // drawn here is recorded as skipped and never thrown, so it is not retried.
33
+ tries: 2,
34
+ backoff: [30, 120],
35
+
36
+ async handle(payload: GenerateStoragePreviewPayload) {
37
+ if (!payload?.disk || !payload?.path)
38
+ throw new Error('[GenerateStoragePreview] payload.disk and payload.path are required')
39
+
40
+ const preview = await generateStoragePreview(Storage.disk(payload.disk), databaseMetadataStore, payload.disk, payload.path)
41
+
42
+ if (preview)
43
+ log.debug(`[GenerateStoragePreview] ${payload.path}: ${preview.path} (${preview.bytes} bytes)`)
44
+
45
+ return preview ?? { skipped: true }
46
+ },
47
+ })
@@ -5,7 +5,7 @@ import { image } from '@stacksjs/image'
5
5
  import { log } from '@stacksjs/logging'
6
6
  import { Job } from '@stacksjs/queue'
7
7
  import { Storage } from '@stacksjs/storage'
8
- import { runTask } from '../Actions/Dashboard/Content/file-metadata'
8
+ import { runTask, VARIANT_PREFIX } from '../Actions/Dashboard/Content/file-metadata'
9
9
  import { databaseMetadataStore } from '../Actions/Dashboard/Content/file-metadata-store'
10
10
 
11
11
  /**
@@ -30,8 +30,12 @@ interface OptimizeStorageImagePayload {
30
30
  path: string
31
31
  }
32
32
 
33
- /** Where derivatives live, relative to the disk root. Hidden, so listings skip it. */
34
- export const VARIANT_PREFIX = '.variants'
33
+ /**
34
+ * Where derivatives live, relative to the disk root. Hidden, so listings skip
35
+ * it. Re-exported from the metadata layer, which owns it so the request path
36
+ * can reach it without loading an image encoder.
37
+ */
38
+ export { VARIANT_PREFIX }
35
39
 
36
40
  export default new Job({
37
41
  name: 'OptimizeStorageImage',
@@ -0,0 +1,46 @@
1
+ import { log } from '@stacksjs/logging'
2
+
3
+ /**
4
+ * Apply every model's declared retention policy, daily (stacksjs/stacks#365).
5
+ *
6
+ * A model opts in with `traits.gdpr.retention`:
7
+ *
8
+ * ```ts
9
+ * traits: { gdpr: { retention: { days: 90, action: 'delete' } } }
10
+ * ```
11
+ *
12
+ * Nothing is pruned for a model that declares no policy, so this job is a
13
+ * no-op until one does. `buddy gdpr:prune --dry-run` shows what it would do.
14
+ * Each run that changes something writes a `retention` row to `gdpr_requests`.
15
+ */
16
+ export default class PruneRetainedDataJob {
17
+ public static config = {
18
+ schedule: '30 3 * * *', // Daily at 03:30, clear of the midnight prune jobs
19
+ withoutOverlapping: true,
20
+ timeout: 15 * 60,
21
+ retries: 3,
22
+ retryAfter: [60, 300, 900],
23
+ }
24
+
25
+ public static async handle(): Promise<{ changed: number, results: Array<{ model: string, action: string, changed: number }> }> {
26
+ const { pruneRetainedData } = await import('@stacksjs/orm')
27
+
28
+ try {
29
+ const result = await pruneRetainedData({ actor: 'scheduler' })
30
+ const changed = result.changes.reduce((sum, change) => sum + change.changed, 0)
31
+
32
+ if (result.skipped.length)
33
+ log.info(`Retention: declared tables not in this database: ${result.skipped.join(', ')}`)
34
+ log.info(`Retention applied: ${changed} row(s) changed across ${result.changes.length} model(s)`)
35
+
36
+ return {
37
+ changed,
38
+ results: result.changes.map(({ model, action, changed }) => ({ model, action, changed })),
39
+ }
40
+ }
41
+ catch (error) {
42
+ log.error('Failed to apply retention policies:', error)
43
+ throw error
44
+ }
45
+ }
46
+ }
@@ -8,6 +8,7 @@ export default defineModel({
8
8
  autoIncrement: true,
9
9
 
10
10
  traits: {
11
+ gdpr: { erasure: 'anonymize', basis: 'legitimate_interests', purpose: 'Activity feed' },
11
12
  useUuid: true,
12
13
  useTimestamps: true,
13
14
  useSeeder: {
@@ -84,6 +85,7 @@ export default defineModel({
84
85
  },
85
86
 
86
87
  ipAddress: {
88
+ personal: true,
87
89
  required: false,
88
90
  fillable: true,
89
91
  validation: {