@stacksjs/defaults 0.74.33 → 0.74.34
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-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +60 -0
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-storage/SKILL.md +58 -4
- package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +25 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +31 -0
- package/app/Actions/Dashboard/Content/FileTagsAction.ts +27 -0
- package/app/Actions/Dashboard/Content/file-manager.test.ts +47 -27
- package/app/Actions/Dashboard/Content/file-manager.ts +338 -12
- package/app/Actions/Dashboard/Content/file-metadata-store.ts +432 -0
- package/app/Actions/Dashboard/Content/file-metadata.test.ts +357 -0
- package/app/Actions/Dashboard/Content/file-metadata.ts +550 -0
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +344 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +74 -0
- package/app/Jobs/TagStorageMediaJob.ts +122 -0
- package/app/Jobs/TranscodeStorageVideoJob.ts +88 -0
- package/app/Models/StorageItem.ts +123 -0
- package/app/Models/StorageItemTask.ts +134 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard-api.ts +10 -0
- package/vcs/github/workflows/buddy-bot.yml +109 -0
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- package/vcs/github/renovate.json +0 -5
|
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
|
|
|
66
66
|
- **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
|
|
67
67
|
|
|
68
68
|
### Server Auto-Imports (100+)
|
|
69
|
-
- **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (
|
|
69
|
+
- **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (102 models)
|
|
70
70
|
- **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
|
|
71
71
|
- **Actions**: Action types and helpers
|
|
72
72
|
- **Schema**: validation schema builder
|
|
@@ -61,6 +61,66 @@ dashboard data Actions.
|
|
|
61
61
|
- `/content/comments` - comment moderation
|
|
62
62
|
- `/content/files`, `/content/blog`, `/content/seo` - files, blog operations, and SEO
|
|
63
63
|
|
|
64
|
+
### The file manager's two layers (stacksjs/stacks#2577)
|
|
65
|
+
|
|
66
|
+
Worth knowing before adding anything to it, because the split is not obvious
|
|
67
|
+
from the endpoints:
|
|
68
|
+
|
|
69
|
+
- **Storage operations** map to a `StorageAdapter` method and go straight to the
|
|
70
|
+
disk: list, upload, create folder, rename, visibility, duplicate, delete.
|
|
71
|
+
- **Metadata** - favourites and tags - has nowhere to live on a disk (extended
|
|
72
|
+
attributes do not survive a copy; S3 object metadata is set at write time, so
|
|
73
|
+
starring a 2 GB video would rewrite 2 GB). It lives in `storage_items`, keyed
|
|
74
|
+
by `(disk, path)`, written by `PUT /files/favorite` and `PUT /files/tags`.
|
|
75
|
+
|
|
76
|
+
**The disk is authoritative and the table is advisory.** The listing comes from
|
|
77
|
+
the disk and rows are joined onto it, so a path with no row is a file with
|
|
78
|
+
nothing recorded - which is most files. Renames and deletes made THROUGH the
|
|
79
|
+
dashboard reconcile eagerly (a folder is a prefix update, because moving a
|
|
80
|
+
folder moves everything under it); a completed listing sweeps rows for paths it
|
|
81
|
+
did not see, which is free because the walk already enumerated them. A TRUNCATED
|
|
82
|
+
listing sweeps nothing - it has not proved a path is absent.
|
|
83
|
+
|
|
84
|
+
A file renamed outside the dashboard loses its metadata, and that is by design:
|
|
85
|
+
a rename and a copy-then-delete are the same two events to a bucket listing, so
|
|
86
|
+
reconciling would be guessing.
|
|
87
|
+
|
|
88
|
+
### The media pipeline (stacksjs/stacks#2578)
|
|
89
|
+
|
|
90
|
+
None of the three things an upload might need can happen inside the request: a
|
|
91
|
+
transcode is minutes, a vision call is a round trip to a third party. So an
|
|
92
|
+
upload dispatches and the dashboard shows state.
|
|
93
|
+
|
|
94
|
+
- `storage_item_tasks`, one row per `(disk, path, kind)`, kind being
|
|
95
|
+
`optimize` (images, via `ts-images`), `transcode` (video, via `ts-videos`) or
|
|
96
|
+
`tag` (a vision model). They succeed and fail independently, which is why this
|
|
97
|
+
is not a column on `storage_items` - a video whose transcode finished and
|
|
98
|
+
whose tagging failed is a normal state.
|
|
99
|
+
- `dispatchDashboardFileTasks` decides from the CONTENT TYPE what a file needs.
|
|
100
|
+
Most uploads are documents and get nothing. A transcode waits for a video
|
|
101
|
+
profile, because the ladder is derived from the source dimensions.
|
|
102
|
+
- A dispatch failure is RECORDED, not thrown: a queue that is down leaves a
|
|
103
|
+
visible failure rather than an upload that fails or a file that is silently
|
|
104
|
+
never processed.
|
|
105
|
+
- `runTask` owns the queued -> running -> done/failed transitions so the three
|
|
106
|
+
jobs cannot disagree about them. It rethrows after recording, because the row
|
|
107
|
+
and the queue answer different questions - the queue decides whether to retry,
|
|
108
|
+
the row is what somebody looking at the file sees.
|
|
109
|
+
- `POST /files/reprocess` re-runs everything, or the kinds you name.
|
|
110
|
+
|
|
111
|
+
Derivatives are written back to the same disk under `.variants/<path>/`. The
|
|
112
|
+
leading dot keeps them out of the listing, which skips hidden components - a
|
|
113
|
+
folder of thirty derivatives beside every photo makes the browser useless.
|
|
114
|
+
|
|
115
|
+
**There is no ffmpeg.** #2578 asked whether video was in scope given the
|
|
116
|
+
external binary, its licensing and its provisioning; `@stacksjs/video` is built
|
|
117
|
+
on `ts-videos`, which encodes itself, so that question was already answered.
|
|
118
|
+
|
|
119
|
+
Tags go through `taggables` + `taggable_models` with `taggable_type =
|
|
120
|
+
'storage_items'` - the trait the CMS already uses. Do NOT declare a
|
|
121
|
+
`belongsToMany` to the `Tag` model for this: `taggable_models.tag_id` resolves
|
|
122
|
+
against `taggables`, which is a different table from `tags`.
|
|
123
|
+
|
|
64
124
|
### Data Management
|
|
65
125
|
- `/data/dashboard` - data overview
|
|
66
126
|
- `/data/users` - user management
|
|
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
11
11
|
## Key Paths
|
|
12
12
|
- Core ORM package: `storage/framework/core/orm/src/`
|
|
13
13
|
- ORM implementation: `storage/framework/orm/`
|
|
14
|
-
- Model definitions: `storage/framework/defaults/app/Models/` (
|
|
14
|
+
- Model definitions: `storage/framework/defaults/app/Models/` (102 models)
|
|
15
15
|
- Application models: `app/Models/`
|
|
16
16
|
- Default model templates: `storage/framework/defaults/app/Models/`
|
|
17
17
|
- ORM type globals: `storage/framework/types/orm-globals.d.ts`
|
|
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
8
8
|
|
|
9
9
|
# Stacks Storage
|
|
10
10
|
|
|
11
|
-
File system abstraction with a Laravel-style Storage facade, local/S3 adapters, file upload handling, and low-level file utilities.
|
|
11
|
+
File system abstraction with a Laravel-style Storage facade, local/S3/Azure adapters, file upload handling, and low-level file utilities.
|
|
12
12
|
|
|
13
13
|
## Key Paths
|
|
14
14
|
- Core package: `storage/framework/core/storage/src/`
|
|
@@ -18,6 +18,8 @@ File system abstraction with a Laravel-style Storage facade, local/S3 adapters,
|
|
|
18
18
|
- Filesystem config types: `storage/framework/core/storage/src/types/filesystem.ts`
|
|
19
19
|
- Local adapter: `storage/framework/core/storage/src/adapters/local.ts`
|
|
20
20
|
- S3 adapter: `storage/framework/core/storage/src/adapters/s3.ts`
|
|
21
|
+
- Azure adapter: `storage/framework/core/storage/src/adapters/azure.ts`
|
|
22
|
+
- Azure request signing: `storage/framework/core/storage/src/azure-signing.ts`
|
|
21
23
|
- Memory adapter: `storage/framework/core/storage/src/adapters/memory.ts`
|
|
22
24
|
- Bun adapter: `storage/framework/core/storage/src/adapters/bun.ts`
|
|
23
25
|
- File utilities: `storage/framework/core/storage/src/files.ts`
|
|
@@ -45,7 +47,7 @@ import { createLocalStorage, LocalStorageAdapter } from '@stacksjs/storage'
|
|
|
45
47
|
import { createS3Storage, S3StorageAdapter } from '@stacksjs/storage'
|
|
46
48
|
|
|
47
49
|
// Config helpers
|
|
48
|
-
import { localDisk, s3Disk, configFromEnv } from '@stacksjs/storage'
|
|
50
|
+
import { localDisk, s3Disk, azureDisk, r2Disk, gcsDisk, filebaseDisk, backblazeDisk, hetznerDisk, configFromEnv } from '@stacksjs/storage'
|
|
49
51
|
|
|
50
52
|
// Types
|
|
51
53
|
import type { StorageAdapter, FileContents, StatEntry, DirectoryEntry, DirectoryListing } from '@stacksjs/storage'
|
|
@@ -233,6 +235,46 @@ const s3 = new S3StorageAdapter(client, { bucket: 'my-bucket', region: 'us-east-
|
|
|
233
235
|
- `list()` supports pagination via continuation tokens; `deep: true` uses `listAllObjects()`
|
|
234
236
|
- `fileExists()` uses `headObject()` and catches 404/NoSuchKey/NotFound errors
|
|
235
237
|
|
|
238
|
+
Every S3-COMPATIBLE provider is an `s3` disk with a different endpoint, and has
|
|
239
|
+
a preset that fills it in: `r2Disk(bucket, accountId)`, `gcsDisk(bucket)`,
|
|
240
|
+
`filebaseDisk(bucket)`, `backblazeDisk(bucket, region)`,
|
|
241
|
+
`hetznerDisk(bucket, location)`. R2 additionally needs `url` set, because its
|
|
242
|
+
API host never serves public objects.
|
|
243
|
+
|
|
244
|
+
### Azure Adapter (`AzureBlobStorageAdapter`)
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { azureDisk, AzureBlobStorageAdapter } from '@stacksjs/storage'
|
|
248
|
+
|
|
249
|
+
const azure = new AzureBlobStorageAdapter({
|
|
250
|
+
account: 'mystorageaccount',
|
|
251
|
+
accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY,
|
|
252
|
+
container: 'uploads',
|
|
253
|
+
prefix: 'tenant-7',
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Its own driver rather than an `s3` disk with an endpoint, because Azure is the
|
|
258
|
+
one provider with no S3-compatible API.
|
|
259
|
+
|
|
260
|
+
- Speaks the blob REST API over `fetch`; no SDK, and no lazy-client dance
|
|
261
|
+
- Shared Key authorization for server-side calls, service SAS for signed URLs,
|
|
262
|
+
both in `azure-signing.ts` and tested there directly
|
|
263
|
+
- `changeVisibility()` THROWS -- Azure has no per-blob ACL. Public access is a
|
|
264
|
+
container property, so `visibility()` reports the container's level, and a
|
|
265
|
+
per-object grant is `signedUrl()`
|
|
266
|
+
- `putStream()` stages blocks (Put Block) and commits one Put Block List, so the
|
|
267
|
+
write is atomic; block ids are zero-padded to a fixed width because Azure
|
|
268
|
+
rejects unequal-length ids
|
|
269
|
+
- `getStream()` is genuinely incremental, unlike the S3 adapter's buffered one
|
|
270
|
+
- `createDirectory()` is a no-op and `deleteDirectory()` deletes by prefix, same
|
|
271
|
+
as S3
|
|
272
|
+
- `endpoint` defaults to `https://<account>.blob.core.windows.net`; point it at
|
|
273
|
+
`http://127.0.0.1:10000/devstoreaccount1` for Azurite
|
|
274
|
+
- A `sasToken` disk can read and write but cannot sign URLs -- minting a SAS
|
|
275
|
+
needs the account key, and re-serving the configured token would hand out its
|
|
276
|
+
full grant
|
|
277
|
+
|
|
236
278
|
## File Uploads (UploadedFile)
|
|
237
279
|
|
|
238
280
|
```typescript
|
|
@@ -490,8 +532,8 @@ interface ChecksumOptions {
|
|
|
490
532
|
}
|
|
491
533
|
|
|
492
534
|
// Filesystem config types
|
|
493
|
-
type FilesystemDriver = 'local' | 's3'
|
|
494
|
-
type DiskConfig = LocalDiskConfig | S3DiskConfig
|
|
535
|
+
type FilesystemDriver = 'local' | 's3' | 'azure'
|
|
536
|
+
type DiskConfig = LocalDiskConfig | S3DiskConfig | AzureDiskConfig
|
|
495
537
|
|
|
496
538
|
interface LocalDiskConfig {
|
|
497
539
|
driver: 'local'
|
|
@@ -512,6 +554,18 @@ interface S3DiskConfig {
|
|
|
512
554
|
visibility?: 'public' | 'private'
|
|
513
555
|
}
|
|
514
556
|
|
|
557
|
+
interface AzureDiskConfig {
|
|
558
|
+
driver: 'azure'
|
|
559
|
+
account: string
|
|
560
|
+
container: string
|
|
561
|
+
accountKey?: string
|
|
562
|
+
sasToken?: string
|
|
563
|
+
prefix?: string
|
|
564
|
+
url?: string
|
|
565
|
+
endpoint?: string
|
|
566
|
+
visibility?: 'public' | 'private'
|
|
567
|
+
}
|
|
568
|
+
|
|
515
569
|
interface FilesystemConfig {
|
|
516
570
|
default: string
|
|
517
571
|
disks: Record<string, DiskConfig>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { DashboardFileError, setDashboardFileFavorite } from './file-manager'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'FileFavoriteAction',
|
|
8
|
+
description: 'Stars or unstars a file or directory on a configured storage disk.',
|
|
9
|
+
method: 'PUT',
|
|
10
|
+
async handle(request: RequestInstance) {
|
|
11
|
+
try {
|
|
12
|
+
const result = await setDashboardFileFavorite({
|
|
13
|
+
disk: String(request.get('disk', 'public')),
|
|
14
|
+
path: request.get('path'),
|
|
15
|
+
favorite: request.get('favorite'),
|
|
16
|
+
})
|
|
17
|
+
return response.json(result)
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof DashboardFileError)
|
|
21
|
+
return response.json({ message: error.message, fields: error.fields }, error.status)
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { DashboardFileError, reprocessDashboardFile } from './file-manager'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'FileReprocessAction',
|
|
8
|
+
description: 'Re-runs the background processing for a file: image variants, video renditions, AI tags.',
|
|
9
|
+
method: 'POST',
|
|
10
|
+
async handle(request: RequestInstance) {
|
|
11
|
+
try {
|
|
12
|
+
const result = await reprocessDashboardFile({
|
|
13
|
+
disk: String(request.get('disk', 'public')),
|
|
14
|
+
path: request.get('path'),
|
|
15
|
+
// Omit to run every kind the file's content type calls for; name kinds
|
|
16
|
+
// to re-run just one, which is what a failed transcode beside a
|
|
17
|
+
// successful tagging wants.
|
|
18
|
+
kinds: request.get('kinds'),
|
|
19
|
+
// A transcode derives its ladder from the source dimensions, so it only
|
|
20
|
+
// runs when the caller supplies them.
|
|
21
|
+
videoProfile: request.get('profile'),
|
|
22
|
+
})
|
|
23
|
+
return response.json(result)
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof DashboardFileError)
|
|
27
|
+
return response.json({ message: error.message, fields: error.fields }, error.status)
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { DashboardFileError, setDashboardFileTags } from './file-manager'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'FileTagsAction',
|
|
8
|
+
description: 'Replaces the tags on a file or directory on a configured storage disk.',
|
|
9
|
+
method: 'PUT',
|
|
10
|
+
async handle(request: RequestInstance) {
|
|
11
|
+
try {
|
|
12
|
+
const result = await setDashboardFileTags({
|
|
13
|
+
disk: String(request.get('disk', 'public')),
|
|
14
|
+
path: request.get('path'),
|
|
15
|
+
// The whole set, not a delta: a UI that can add a tag can also remove
|
|
16
|
+
// one, and there is no separate signal for a removal.
|
|
17
|
+
tags: request.get('tags'),
|
|
18
|
+
})
|
|
19
|
+
return response.json(result)
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof DashboardFileError)
|
|
23
|
+
return response.json({ message: error.message, fields: error.fields }, error.status)
|
|
24
|
+
throw error
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { StorageManager } from '@stacksjs/storage'
|
|
6
|
+
import { createMemoryMetadataStore } from './file-metadata'
|
|
6
7
|
import {
|
|
7
8
|
createDashboardDirectory,
|
|
8
9
|
deleteDashboardFile,
|
|
@@ -18,8 +19,27 @@ import {
|
|
|
18
19
|
|
|
19
20
|
let root = ''
|
|
20
21
|
let manager: StorageManager
|
|
22
|
+
/**
|
|
23
|
+
* The metadata layer, in memory (stacksjs/stacks#2577).
|
|
24
|
+
*
|
|
25
|
+
* These tests build a real disk in a temp directory; the metadata store is the
|
|
26
|
+
* one collaborator that would otherwise pull a database in, so it is injected
|
|
27
|
+
* the same way the storage manager already is.
|
|
28
|
+
*/
|
|
29
|
+
let store = createMemoryMetadataStore()
|
|
30
|
+
/**
|
|
31
|
+
* A dispatcher that records instead of queueing (stacksjs/stacks#2578). These
|
|
32
|
+
* tests are about the storage operations, not the pipeline, and a real queue
|
|
33
|
+
* would make them depend on a worker.
|
|
34
|
+
*/
|
|
35
|
+
let dispatched: Array<{ job: string, payload: Record<string, unknown> }> = []
|
|
36
|
+
const dispatch = async (job: string, payload: Record<string, unknown>): Promise<void> => {
|
|
37
|
+
dispatched.push({ job, payload })
|
|
38
|
+
}
|
|
21
39
|
|
|
22
40
|
beforeEach(async () => {
|
|
41
|
+
store = createMemoryMetadataStore()
|
|
42
|
+
dispatched = []
|
|
23
43
|
root = await mkdtemp(join(tmpdir(), 'stacks-dashboard-files-'))
|
|
24
44
|
await mkdir(join(root, 'public'), { recursive: true })
|
|
25
45
|
manager = new StorageManager().init({
|
|
@@ -47,7 +67,7 @@ describe('dashboard file manager', () => {
|
|
|
47
67
|
await disk.write('images/logo.png', new Uint8Array([137, 80, 78, 71]))
|
|
48
68
|
await disk.write('.hidden/secret.txt', 'not listed')
|
|
49
69
|
|
|
50
|
-
const snapshot = await getDashboardFileSnapshot({}, manager)
|
|
70
|
+
const snapshot = await getDashboardFileSnapshot({}, manager, store)
|
|
51
71
|
const documents = snapshot.root.items?.find(item => item.path === 'documents')
|
|
52
72
|
const readme = documents?.items?.find(item => item.path === 'documents/readme.txt')
|
|
53
73
|
|
|
@@ -74,7 +94,7 @@ describe('dashboard file manager', () => {
|
|
|
74
94
|
await manager.disk('public').write('one.txt', '1')
|
|
75
95
|
await manager.disk('public').write('two.txt', '2')
|
|
76
96
|
|
|
77
|
-
const snapshot = await getDashboardFileSnapshot({ maxEntries: 1 }, manager)
|
|
97
|
+
const snapshot = await getDashboardFileSnapshot({ maxEntries: 1 }, manager, store)
|
|
78
98
|
|
|
79
99
|
expect(snapshot.truncated).toBe(true)
|
|
80
100
|
expect(snapshot.stats.files).toBe(1)
|
|
@@ -94,7 +114,7 @@ describe('dashboard file manager', () => {
|
|
|
94
114
|
throw new Error('metadata unavailable')
|
|
95
115
|
}
|
|
96
116
|
|
|
97
|
-
await expect(getDashboardFileSnapshot({}, manager))
|
|
117
|
+
await expect(getDashboardFileSnapshot({}, manager, store))
|
|
98
118
|
.rejects
|
|
99
119
|
.toThrow('Metadata for storage file "unreadable.txt" could not be read')
|
|
100
120
|
})
|
|
@@ -106,7 +126,7 @@ describe('dashboard file manager', () => {
|
|
|
106
126
|
throw new Error('URL unavailable')
|
|
107
127
|
}
|
|
108
128
|
|
|
109
|
-
const snapshot = await getDashboardFileSnapshot({}, manager)
|
|
129
|
+
const snapshot = await getDashboardFileSnapshot({}, manager, store)
|
|
110
130
|
expect(snapshot.stats.files).toBe(1)
|
|
111
131
|
expect(snapshot.warnings).toEqual(['Public URL for "document.txt" could not be resolved.'])
|
|
112
132
|
})
|
|
@@ -116,10 +136,10 @@ describe('dashboard file manager', () => {
|
|
|
116
136
|
expect(await manager.disk('public').directoryExists('Product shots')).toBe(true)
|
|
117
137
|
|
|
118
138
|
await manager.disk('public').write('Product shots/photo.jpg', 'photo')
|
|
119
|
-
await deleteDashboardFile({ path: 'Product shots/photo.jpg' }, manager)
|
|
139
|
+
await deleteDashboardFile({ path: 'Product shots/photo.jpg' }, manager, store)
|
|
120
140
|
expect(await manager.disk('public').fileExists('Product shots/photo.jpg')).toBe(false)
|
|
121
141
|
|
|
122
|
-
await deleteDashboardFile({ path: 'Product shots' }, manager)
|
|
142
|
+
await deleteDashboardFile({ path: 'Product shots' }, manager, store)
|
|
123
143
|
expect(await manager.disk('public').directoryExists('Product shots')).toBe(false)
|
|
124
144
|
})
|
|
125
145
|
|
|
@@ -130,14 +150,14 @@ describe('dashboard file manager', () => {
|
|
|
130
150
|
bytes: async () => new TextEncoder().encode(contents),
|
|
131
151
|
})
|
|
132
152
|
|
|
133
|
-
const uploaded = await uploadDashboardFiles({ path: 'documents', files: [file('first')] }, manager)
|
|
153
|
+
const uploaded = await uploadDashboardFiles({ path: 'documents', files: [file('first')] }, manager, store, dispatch)
|
|
134
154
|
expect(uploaded[0]).toMatchObject({
|
|
135
155
|
path: 'documents/release_notes.txt',
|
|
136
156
|
url: '/storage/documents/release_notes.txt',
|
|
137
157
|
size: 5,
|
|
138
158
|
})
|
|
139
159
|
|
|
140
|
-
await expect(uploadDashboardFiles({ path: 'documents', files: [file('second')] }, manager))
|
|
160
|
+
await expect(uploadDashboardFiles({ path: 'documents', files: [file('second')] }, manager, store, dispatch))
|
|
141
161
|
.rejects
|
|
142
162
|
.toThrow('File already exists: documents/release_notes.txt')
|
|
143
163
|
expect(await manager.disk('public').readToString('documents/release_notes.txt')).toBe('first')
|
|
@@ -153,7 +173,7 @@ describe('dashboard file manager', () => {
|
|
|
153
173
|
|
|
154
174
|
await expect(uploadDashboardFiles({
|
|
155
175
|
files: [file('new.txt', 'new'), file('duplicate.txt', 'replacement')],
|
|
156
|
-
}, manager)).rejects.toThrow('No files from this upload were kept')
|
|
176
|
+
}, manager, store, dispatch)).rejects.toThrow('No files from this upload were kept')
|
|
157
177
|
|
|
158
178
|
expect(await manager.disk('public').fileExists('new.txt')).toBe(false)
|
|
159
179
|
expect(await manager.disk('public').readToString('duplicate.txt')).toBe('existing')
|
|
@@ -172,7 +192,7 @@ describe('renameDashboardFile', () => {
|
|
|
172
192
|
const disk = manager.disk('public')
|
|
173
193
|
await disk.write('documents/readme.txt', 'hello')
|
|
174
194
|
|
|
175
|
-
const result = await renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager)
|
|
195
|
+
const result = await renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager, store)
|
|
176
196
|
|
|
177
197
|
expect(result).toEqual({ from: 'documents/readme.txt', to: 'documents/guide.txt', type: 'file', moved: 1 })
|
|
178
198
|
expect(await disk.fileExists('documents/guide.txt')).toBe(true)
|
|
@@ -184,7 +204,7 @@ describe('renameDashboardFile', () => {
|
|
|
184
204
|
const disk = manager.disk('public')
|
|
185
205
|
await disk.write('notes.txt', 'top level')
|
|
186
206
|
|
|
187
|
-
expect(await renameDashboardFile({ path: 'notes.txt', name: 'todo.txt' }, manager))
|
|
207
|
+
expect(await renameDashboardFile({ path: 'notes.txt', name: 'todo.txt' }, manager, store))
|
|
188
208
|
.toEqual({ from: 'notes.txt', to: 'todo.txt', type: 'file', moved: 1 })
|
|
189
209
|
expect(await disk.readToString('todo.txt')).toBe('top level')
|
|
190
210
|
})
|
|
@@ -197,9 +217,9 @@ describe('renameDashboardFile', () => {
|
|
|
197
217
|
test('refuses a name that is really a path', async () => {
|
|
198
218
|
await manager.disk('public').write('a/b.txt', 'x')
|
|
199
219
|
|
|
200
|
-
await expect(renameDashboardFile({ path: 'a/b.txt', name: '../escaped.txt' }, manager))
|
|
220
|
+
await expect(renameDashboardFile({ path: 'a/b.txt', name: '../escaped.txt' }, manager, store))
|
|
201
221
|
.rejects.toMatchObject({ status: 422 })
|
|
202
|
-
await expect(renameDashboardFile({ path: 'a/b.txt', name: 'nested/deep.txt' }, manager))
|
|
222
|
+
await expect(renameDashboardFile({ path: 'a/b.txt', name: 'nested/deep.txt' }, manager, store))
|
|
203
223
|
.rejects.toMatchObject({ status: 422 })
|
|
204
224
|
})
|
|
205
225
|
|
|
@@ -208,7 +228,7 @@ describe('renameDashboardFile', () => {
|
|
|
208
228
|
await disk.write('images/logo.png', 'a')
|
|
209
229
|
await disk.write('images/icons/favicon.png', 'b')
|
|
210
230
|
|
|
211
|
-
const result = await renameDashboardFile({ path: 'images', name: 'media' }, manager)
|
|
231
|
+
const result = await renameDashboardFile({ path: 'images', name: 'media' }, manager, store)
|
|
212
232
|
|
|
213
233
|
expect(result).toEqual({ from: 'images', to: 'media', type: 'directory', moved: 2 })
|
|
214
234
|
expect(await disk.readToString('media/logo.png')).toBe('a')
|
|
@@ -222,7 +242,7 @@ describe('renameDashboardFile', () => {
|
|
|
222
242
|
await disk.write('documents/readme.txt', 'keep me')
|
|
223
243
|
await disk.write('documents/guide.txt', 'me too')
|
|
224
244
|
|
|
225
|
-
await expect(renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager))
|
|
245
|
+
await expect(renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager, store))
|
|
226
246
|
.rejects.toMatchObject({ status: 409 })
|
|
227
247
|
// Neither side moved: a refused rename is not a partial one.
|
|
228
248
|
expect(await disk.readToString('documents/readme.txt')).toBe('keep me')
|
|
@@ -232,12 +252,12 @@ describe('renameDashboardFile', () => {
|
|
|
232
252
|
test('says so rather than silently doing nothing when the name is unchanged', async () => {
|
|
233
253
|
await manager.disk('public').write('a.txt', 'x')
|
|
234
254
|
|
|
235
|
-
await expect(renameDashboardFile({ path: 'a.txt', name: 'a.txt' }, manager))
|
|
255
|
+
await expect(renameDashboardFile({ path: 'a.txt', name: 'a.txt' }, manager, store))
|
|
236
256
|
.rejects.toMatchObject({ status: 422 })
|
|
237
257
|
})
|
|
238
258
|
|
|
239
259
|
test('is a 404 when the item does not exist', async () => {
|
|
240
|
-
await expect(renameDashboardFile({ path: 'nope.txt', name: 'yes.txt' }, manager))
|
|
260
|
+
await expect(renameDashboardFile({ path: 'nope.txt', name: 'yes.txt' }, manager, store))
|
|
241
261
|
.rejects.toMatchObject({ status: 404 })
|
|
242
262
|
})
|
|
243
263
|
})
|
|
@@ -301,7 +321,7 @@ describe('duplicateDashboardFile', () => {
|
|
|
301
321
|
const disk = manager.disk('public')
|
|
302
322
|
await disk.write('documents/readme.txt', 'hello')
|
|
303
323
|
|
|
304
|
-
const result = await duplicateDashboardFile({ path: 'documents/readme.txt' }, manager)
|
|
324
|
+
const result = await duplicateDashboardFile({ path: 'documents/readme.txt' }, manager, store)
|
|
305
325
|
|
|
306
326
|
// `readme.txt copy` is a file whose type the OS, the browser and this
|
|
307
327
|
// dashboard's own type grouping would all read as unknown.
|
|
@@ -314,18 +334,18 @@ describe('duplicateDashboardFile', () => {
|
|
|
314
334
|
const disk = manager.disk('public')
|
|
315
335
|
await disk.write('a.txt', 'x')
|
|
316
336
|
|
|
317
|
-
expect((await duplicateDashboardFile({ path: 'a.txt' }, manager)).to).toBe('a copy.txt')
|
|
318
|
-
expect((await duplicateDashboardFile({ path: 'a.txt' }, manager)).to).toBe('a copy 2.txt')
|
|
319
|
-
expect((await duplicateDashboardFile({ path: 'a.txt' }, manager)).to).toBe('a copy 3.txt')
|
|
337
|
+
expect((await duplicateDashboardFile({ path: 'a.txt' }, manager, store)).to).toBe('a copy.txt')
|
|
338
|
+
expect((await duplicateDashboardFile({ path: 'a.txt' }, manager, store)).to).toBe('a copy 2.txt')
|
|
339
|
+
expect((await duplicateDashboardFile({ path: 'a.txt' }, manager, store)).to).toBe('a copy 3.txt')
|
|
320
340
|
})
|
|
321
341
|
|
|
322
342
|
test('takes an explicit name when given one', async () => {
|
|
323
343
|
await manager.disk('public').write('a.txt', 'x')
|
|
324
344
|
|
|
325
|
-
expect((await duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager)).to).toBe('b.txt')
|
|
326
|
-
await expect(duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager))
|
|
345
|
+
expect((await duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager, store)).to).toBe('b.txt')
|
|
346
|
+
await expect(duplicateDashboardFile({ path: 'a.txt', name: 'b.txt' }, manager, store))
|
|
327
347
|
.rejects.toMatchObject({ status: 409 })
|
|
328
|
-
await expect(duplicateDashboardFile({ path: 'a.txt', name: '../escaped.txt' }, manager))
|
|
348
|
+
await expect(duplicateDashboardFile({ path: 'a.txt', name: '../escaped.txt' }, manager, store))
|
|
329
349
|
.rejects.toMatchObject({ status: 422 })
|
|
330
350
|
})
|
|
331
351
|
|
|
@@ -334,7 +354,7 @@ describe('duplicateDashboardFile', () => {
|
|
|
334
354
|
await disk.write('images/logo.png', 'a')
|
|
335
355
|
await disk.write('images/icons/favicon.png', 'b')
|
|
336
356
|
|
|
337
|
-
const result = await duplicateDashboardFile({ path: 'images' }, manager)
|
|
357
|
+
const result = await duplicateDashboardFile({ path: 'images' }, manager, store)
|
|
338
358
|
|
|
339
359
|
expect(result).toEqual({ from: 'images', to: 'images copy', type: 'directory', copied: 2 })
|
|
340
360
|
expect(await disk.readToString('images copy/logo.png')).toBe('a')
|
|
@@ -352,12 +372,12 @@ describe('duplicateDashboardFile', () => {
|
|
|
352
372
|
await disk.write('media/one.txt', '1')
|
|
353
373
|
await disk.write('media/two.txt', '2')
|
|
354
374
|
|
|
355
|
-
expect((await duplicateDashboardFile({ path: 'media' }, manager)).copied).toBe(2)
|
|
375
|
+
expect((await duplicateDashboardFile({ path: 'media' }, manager, store)).copied).toBe(2)
|
|
356
376
|
expect(await disk.fileExists('media copy/media copy/one.txt')).toBe(false)
|
|
357
377
|
})
|
|
358
378
|
|
|
359
379
|
test('is a 404 when the item does not exist', async () => {
|
|
360
|
-
await expect(duplicateDashboardFile({ path: 'nope.txt' }, manager))
|
|
380
|
+
await expect(duplicateDashboardFile({ path: 'nope.txt' }, manager, store))
|
|
361
381
|
.rejects.toMatchObject({ status: 404 })
|
|
362
382
|
})
|
|
363
383
|
})
|