@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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-chat/SKILL.md +51 -1
- package/ai/skills/stacks-cloud/SKILL.md +36 -0
- package/ai/skills/stacks-dashboard/SKILL.md +16 -2
- package/ai/skills/stacks-database/SKILL.md +24 -3
- package/ai/skills/stacks-deploy/SKILL.md +7 -1
- package/ai/skills/stacks-desktop/SKILL.md +10 -0
- package/ai/skills/stacks-development/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +38 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/ai/skills/stacks-security/SKILL.md +28 -1
- package/app/Actions/Auth/DataExportAction.test.ts +84 -0
- package/app/Actions/Auth/DataExportAction.ts +40 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
- package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
- package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
- package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
- package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
- package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
- package/app/Jobs/PruneRetainedDataJob.ts +46 -0
- package/app/Models/Activity.ts +2 -0
- package/app/Models/CampaignSend.ts +2 -0
- package/app/Models/Card.ts +1 -0
- package/app/Models/CardComment.ts +1 -0
- package/app/Models/Comment.ts +5 -0
- package/app/Models/CommunicationSuppression.ts +2 -1
- package/app/Models/ConsentEvent.ts +4 -3
- package/app/Models/Content/Author.ts +5 -0
- package/app/Models/EmailListSubscriber.ts +1 -0
- package/app/Models/EmailSuppression.ts +2 -0
- package/app/Models/Forms/FormSubmission.ts +5 -0
- package/app/Models/GdprRequest.ts +77 -0
- package/app/Models/MagicLinkToken.ts +2 -0
- package/app/Models/Notification.ts +2 -0
- package/app/Models/NotificationDelivery.ts +4 -0
- package/app/Models/PaymentMethod.ts +5 -0
- package/app/Models/PaymentTransaction.ts +1 -0
- package/app/Models/PersonalAccessToken.ts +3 -0
- package/app/Models/Referral.ts +4 -1
- package/app/Models/ReferralCode.ts +4 -1
- package/app/Models/Request.ts +3 -0
- package/app/Models/SmsOptOut.ts +2 -0
- package/app/Models/SocialAccount.ts +3 -0
- package/app/Models/SocialPost.ts +2 -0
- package/app/Models/StorageItemTask.ts +14 -8
- package/app/Models/Subscriber.ts +2 -0
- package/app/Models/SubscriberEmail.ts +2 -0
- package/app/Models/Subscription.ts +1 -0
- package/app/Models/TeamInvitation.ts +2 -0
- package/app/Models/TeamMember.ts +1 -0
- package/app/Models/User.ts +5 -0
- package/app/Models/commerce/Bid.ts +3 -0
- package/app/Models/commerce/Cart.ts +2 -0
- package/app/Models/commerce/Courier.ts +7 -0
- package/app/Models/commerce/CourierPing.ts +3 -0
- package/app/Models/commerce/Customer.ts +5 -0
- package/app/Models/commerce/DeliveryStop.ts +7 -0
- package/app/Models/commerce/GiftCard.ts +4 -0
- package/app/Models/commerce/Order.ts +5 -0
- package/app/Models/commerce/OrderItem.ts +2 -0
- package/app/Models/commerce/Payment.ts +5 -0
- package/app/Models/commerce/Pledge.ts +3 -0
- package/app/Models/commerce/Transaction.ts +2 -0
- package/app/Models/commerce/WaitlistProduct.ts +5 -0
- package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
- package/ide/vscode/.vscode/extensions.json +1 -1
- package/ide/vscode/.vscode/settings.json +8 -149
- package/ide/vscode/README.md +48 -23
- package/ide/vscode/build.ts +43 -0
- package/ide/vscode/package.json +305 -56
- package/ide/vscode/scripts/stx-contributes.ts +66 -0
- package/ide/vscode/src/env-file.ts +103 -0
- package/ide/vscode/src/env.ts +241 -0
- package/ide/vscode/src/extension.ts +120 -26
- package/ide/vscode/src/pickier-protocol.ts +51 -0
- package/ide/vscode/src/pickier-worker.ts +92 -0
- package/ide/vscode/src/pickier.ts +433 -0
- package/ide/vscode/src/project.ts +14 -4
- package/ide/vscode/src/stacks.ts +18 -11
- package/ide/vscode/tests/build.test.ts +58 -0
- package/ide/vscode/tests/env.test.ts +166 -0
- package/ide/vscode/tests/extension.test.ts +8 -0
- package/ide/vscode/tests/fake-vscode.ts +228 -0
- package/ide/vscode/tests/manifest.test.ts +88 -0
- package/ide/vscode/tests/pickier.test.ts +202 -0
- package/ide/vscode/tsconfig.json +2 -2
- package/package.json +2 -2
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
- package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
- package/routes/auth.ts +4 -0
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- 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
|
-
/**
|
|
34
|
-
|
|
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
|
+
}
|
package/app/Models/Activity.ts
CHANGED
|
@@ -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: {
|