@stacksjs/defaults 0.74.32 → 0.74.33
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-analytics/SKILL.md +104 -26
- package/ai/skills/stacks-technical-diagrams/SKILL.md +1 -1
- package/app/Actions/Dashboard/Content/FileDuplicateAction.ts +25 -0
- package/app/Actions/Dashboard/Content/FileRenameAction.ts +25 -0
- package/app/Actions/Dashboard/Content/FileVisibilityAction.ts +25 -0
- package/app/Actions/Dashboard/Content/file-manager.test.ts +198 -0
- package/app/Actions/Dashboard/Content/file-manager.ts +208 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/routes/dashboard-api.ts +3 -0
- package/resources/assets/fonts/Monaco.ttf +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-analytics
|
|
3
|
-
description: Use when adding analytics to a Stacks application - configuring Fathom or self-hosted analytics, generating tracking scripts, privacy-friendly analytics setup, or the analytics configuration. Covers @stacksjs/analytics and config/analytics.ts.
|
|
3
|
+
description: Use when adding analytics to a Stacks application - configuring Fathom, Plausible, Google Analytics or self-hosted analytics, generating tracking scripts, privacy-friendly analytics setup, or the analytics configuration. Covers @stacksjs/analytics and config/analytics.ts.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -8,24 +8,84 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
8
8
|
|
|
9
9
|
# Stacks Analytics
|
|
10
10
|
|
|
11
|
-
Privacy-friendly analytics with Fathom
|
|
11
|
+
Privacy-friendly analytics with four drivers: Fathom, Plausible, Google Analytics
|
|
12
|
+
and self-hosted.
|
|
12
13
|
|
|
13
14
|
## Key Paths
|
|
14
15
|
- Core package: `storage/framework/core/analytics/src/`
|
|
16
|
+
- Drivers: `storage/framework/core/analytics/src/drivers/`
|
|
17
|
+
- Registry (dispatch on the configured driver): `storage/framework/core/analytics/src/registry.ts`
|
|
15
18
|
- Configuration: `config/analytics.ts`
|
|
16
19
|
|
|
20
|
+
## Driver registry
|
|
21
|
+
|
|
22
|
+
Read `config/analytics.ts` and render the driver it names. This is the entry
|
|
23
|
+
point; reach for an individual driver only when you need one specific script
|
|
24
|
+
regardless of config.
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { generateAnalyticsScript, getAnalyticsHead } from '@stacksjs/analytics'
|
|
28
|
+
import { analytics } from '@stacksjs/config'
|
|
29
|
+
|
|
30
|
+
// HTML you can drop into a layout <head>
|
|
31
|
+
const script = generateAnalyticsScript(analytics)
|
|
32
|
+
|
|
33
|
+
// Or the [tag, attributes] pairs a docs `head` array takes
|
|
34
|
+
const head = getAnalyticsHead(analytics)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The registry is loud on purpose. A `driver` value with no implementation throws,
|
|
38
|
+
and so does a driver that is selected but missing its config (the message names
|
|
39
|
+
the exact key to set). Only an app with no `driver` at all gets an empty result.
|
|
40
|
+
|
|
41
|
+
Nothing injects the script for you - a framework that silently posted pageviews
|
|
42
|
+
to a third party would be the wrong default. Render it yourself in the layout
|
|
43
|
+
that should carry it.
|
|
44
|
+
|
|
17
45
|
## Drivers
|
|
18
46
|
|
|
19
|
-
### Fathom
|
|
20
|
-
|
|
47
|
+
### Fathom
|
|
48
|
+
Hosted, cookie-free, GDPR-compliant. Requires a Fathom account.
|
|
49
|
+
`scriptUrl` points the tag at your own origin so a content blocker does not drop it.
|
|
21
50
|
|
|
22
|
-
|
|
23
|
-
|
|
51
|
+
```typescript
|
|
52
|
+
import { generateFathomScript } from '@stacksjs/analytics'
|
|
53
|
+
|
|
54
|
+
generateFathomScript({
|
|
55
|
+
siteId: 'ABCDEFGH',
|
|
56
|
+
honorDnt: true, // -> data-honor-dnt="true"
|
|
57
|
+
spa: true, // -> data-spa="auto"
|
|
58
|
+
scriptUrl: undefined, // defaults to Fathom's CDN
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Plausible
|
|
63
|
+
Cookie-free, hosted or self-hosted. `hashMode` and `trackLocalhost` pick the
|
|
64
|
+
script variant (`script.hash.js`, `script.local.js`); `scriptUrl` overrides the
|
|
65
|
+
computed URL entirely, which is how you point at a self-hosted install.
|
|
24
66
|
|
|
25
67
|
```typescript
|
|
26
|
-
import {
|
|
68
|
+
import { generatePlausibleScript } from '@stacksjs/analytics'
|
|
69
|
+
|
|
70
|
+
generatePlausibleScript({ domain: 'example.com', hashMode: true })
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Google Analytics
|
|
74
|
+
GA4 via gtag.js. Emits the loader plus the inline `js` / `config` bootstrap;
|
|
75
|
+
`debug: true` sets `debug_mode` so the property shows up in DebugView.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { generateGoogleAnalyticsScript } from '@stacksjs/analytics'
|
|
79
|
+
|
|
80
|
+
generateGoogleAnalyticsScript({ trackingId: 'G-XXXXXXXXXX', debug: false })
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Self-Hosted
|
|
84
|
+
A small first-party tracker that POSTs to your own endpoint.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { generateSelfHostedScript, getSelfHostedAnalyticsHead } from '@stacksjs/analytics'
|
|
27
88
|
|
|
28
|
-
// Generate tracking script tag
|
|
29
89
|
const script = generateSelfHostedScript({
|
|
30
90
|
siteId: 'ABCDEF',
|
|
31
91
|
apiEndpoint: 'https://analytics.myapp.com/api/event',
|
|
@@ -34,19 +94,34 @@ const script = generateSelfHostedScript({
|
|
|
34
94
|
trackOutboundLinks: true // track external link clicks
|
|
35
95
|
})
|
|
36
96
|
|
|
37
|
-
// Generate head configuration for STX
|
|
38
97
|
const headConfig = getSelfHostedAnalyticsHead({
|
|
39
98
|
siteId: 'ABCDEF',
|
|
40
99
|
apiEndpoint: 'https://analytics.myapp.com/api/event'
|
|
41
100
|
})
|
|
42
|
-
|
|
43
|
-
// Generate inline script (no external JS file)
|
|
44
|
-
const inline = generateInlineScript(config)
|
|
45
101
|
```
|
|
46
102
|
|
|
47
|
-
##
|
|
103
|
+
## Driver config interfaces
|
|
48
104
|
|
|
49
105
|
```typescript
|
|
106
|
+
interface FathomConfig {
|
|
107
|
+
siteId: string // Fathom site ID
|
|
108
|
+
scriptUrl?: string // serve the script first-party
|
|
109
|
+
honorDnt?: boolean // respect Do Not Track
|
|
110
|
+
spa?: boolean // re-record pageviews on client routing
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface PlausibleConfig {
|
|
114
|
+
domain: string // the site's domain
|
|
115
|
+
scriptUrl?: string // self-hosted Plausible or a proxy
|
|
116
|
+
trackLocalhost?: boolean // keep localhost pageviews
|
|
117
|
+
hashMode?: boolean // count hash routes
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface GoogleAnalyticsConfig {
|
|
121
|
+
trackingId: string // GA4 measurement ID
|
|
122
|
+
debug?: boolean // debug_mode -> DebugView
|
|
123
|
+
}
|
|
124
|
+
|
|
50
125
|
interface SelfHostedConfig {
|
|
51
126
|
siteId: string // unique site identifier
|
|
52
127
|
apiEndpoint: string // analytics API URL
|
|
@@ -60,18 +135,22 @@ interface SelfHostedConfig {
|
|
|
60
135
|
|
|
61
136
|
```typescript
|
|
62
137
|
{
|
|
63
|
-
driver: 'fathom', // 'fathom' | 'google-analytics'
|
|
138
|
+
driver: 'fathom', // 'fathom' | 'plausible' | 'google-analytics' | 'self-hosted'
|
|
64
139
|
drivers: {
|
|
65
|
-
googleAnalytics: {
|
|
66
|
-
|
|
67
|
-
},
|
|
68
|
-
|
|
69
|
-
siteId: '' // Fathom site ID
|
|
70
|
-
}
|
|
140
|
+
googleAnalytics: { trackingId: '' },
|
|
141
|
+
fathom: { siteId: '' },
|
|
142
|
+
plausible: { domain: '' },
|
|
143
|
+
selfHosted: { siteId: '', apiEndpoint: '' },
|
|
71
144
|
}
|
|
72
145
|
}
|
|
73
146
|
```
|
|
74
147
|
|
|
148
|
+
## First-party pageview capture
|
|
149
|
+
|
|
150
|
+
Separate from the drivers, and no script at all: `capturePageviews: true` has the
|
|
151
|
+
stx servers record page GETs into `analytics_events`, which is what feeds the
|
|
152
|
+
native `/analytics/pages`, `/referrers` and `/devices` dashboards.
|
|
153
|
+
|
|
75
154
|
## Dashboard Integration
|
|
76
155
|
|
|
77
156
|
Analytics dashboard at `/dashboard/analytics` displays:
|
|
@@ -81,11 +160,10 @@ Analytics dashboard at `/dashboard/analytics` displays:
|
|
|
81
160
|
- Engagement metrics
|
|
82
161
|
|
|
83
162
|
## Gotchas
|
|
84
|
-
-
|
|
85
|
-
-
|
|
163
|
+
- Selecting a driver does not inject anything - call `generateAnalyticsScript()` in your layout
|
|
164
|
+
- A misconfigured driver throws rather than emitting nothing; the message names the config key
|
|
86
165
|
- `honorDnt: true` respects browser Do Not Track settings
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
- Analytics scripts are generated server-side and injected into page head
|
|
90
|
-
- Fathom is a paid service — self-hosted is free but requires infrastructure
|
|
166
|
+
- Attribute values are escaped, and inline script values are JS-escaped, to prevent XSS
|
|
167
|
+
- Fathom is a paid service - self-hosted is free but requires infrastructure
|
|
91
168
|
- `trackOutboundLinks` adds click handlers to external `<a>` tags
|
|
169
|
+
- `capturePageviews` is server-side and independent of `driver`
|
|
@@ -30,7 +30,7 @@ Run `bun --config=storage/framework/defaults/ai/skills/stacks-technical-diagrams
|
|
|
30
30
|
1. Inspect the implementation before drawing. Treat `app/` as overrides and `storage/framework/defaults/app/` as fallbacks. Follow registrations in `app/Routes.ts`, route files, actions, jobs, listeners, middleware, models, config, resources, and framework entry points that matter to the requested view.
|
|
31
31
|
2. Read the relevant Stacks domain skill before mapping an unfamiliar subsystem. Common companions include `stacks-router`, `stacks-actions`, `stacks-models`, `stacks-database`, `stacks-jobs`, `stacks-events`, `stacks-realtime`, and `stacks-cloud`.
|
|
32
32
|
3. Draw one question per diagram. For a runtime overview, prefer browser or client -> stx/router -> action or service -> model/query -> database, then add only the external systems and trust boundaries needed for that story.
|
|
33
|
-
4. Write output to the user's requested path. When no path is given, use `docs/diagrams/<descriptive-name
|
|
33
|
+
4. Write output to the user's requested path. When no path is given, use `docs/public/diagrams/<descriptive-name>/index.html` and keep the source JSON beside it. That path matters: BunPress only renders `docs/**/*.md` and copies `docs/public/**`, so a diagram written anywhere else under `docs/` never reaches the built site. The directory-plus-`index.html` shape is what the deployed docs host serves at the clean URL `/docs/diagrams/<descriptive-name>` - a bare `<name>.html` is redirected to an extensionless path that does not exist. Link to it from a docs page as `/diagrams/<descriptive-name>` (BunPress adds the `/docs` base).
|
|
34
34
|
|
|
35
35
|
If Bun cannot run, fall back to architecture mode: copy `assets/template.html`, hand-place SVG using the design system below, and run the self-review checklist before delivering.
|
|
36
36
|
|
|
@@ -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, duplicateDashboardFile } from './file-manager'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'FileDuplicateAction',
|
|
8
|
+
description: 'Copies a file or directory beside the original on a configured storage disk.',
|
|
9
|
+
method: 'POST',
|
|
10
|
+
async handle(request: RequestInstance) {
|
|
11
|
+
try {
|
|
12
|
+
const duplicated = await duplicateDashboardFile({
|
|
13
|
+
disk: String(request.get('disk', 'public')),
|
|
14
|
+
path: request.get('path'),
|
|
15
|
+
name: request.get('name'),
|
|
16
|
+
})
|
|
17
|
+
return response.json(duplicated)
|
|
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,25 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { DashboardFileError, renameDashboardFile } from './file-manager'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'FileRenameAction',
|
|
8
|
+
description: 'Renames a file or directory in place on a configured storage disk.',
|
|
9
|
+
method: 'PATCH',
|
|
10
|
+
async handle(request: RequestInstance) {
|
|
11
|
+
try {
|
|
12
|
+
const renamed = await renameDashboardFile({
|
|
13
|
+
disk: String(request.get('disk', 'public')),
|
|
14
|
+
path: request.get('path'),
|
|
15
|
+
name: request.get('name'),
|
|
16
|
+
})
|
|
17
|
+
return response.json(renamed)
|
|
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,25 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { DashboardFileError, setDashboardFileVisibility } from './file-manager'
|
|
5
|
+
|
|
6
|
+
export default new Action({
|
|
7
|
+
name: 'FileVisibilityAction',
|
|
8
|
+
description: 'Sets a file or directory public or private on a configured storage disk.',
|
|
9
|
+
method: 'PUT',
|
|
10
|
+
async handle(request: RequestInstance) {
|
|
11
|
+
try {
|
|
12
|
+
const updated = await setDashboardFileVisibility({
|
|
13
|
+
disk: String(request.get('disk', 'public')),
|
|
14
|
+
path: request.get('path'),
|
|
15
|
+
visibility: request.get('visibility'),
|
|
16
|
+
})
|
|
17
|
+
return response.json(updated)
|
|
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
|
+
})
|
|
@@ -6,10 +6,13 @@ import { StorageManager } from '@stacksjs/storage'
|
|
|
6
6
|
import {
|
|
7
7
|
createDashboardDirectory,
|
|
8
8
|
deleteDashboardFile,
|
|
9
|
+
duplicateDashboardFile,
|
|
9
10
|
getDashboardFileSnapshot,
|
|
10
11
|
normalizeDashboardFileName,
|
|
11
12
|
normalizeDashboardFileLimit,
|
|
12
13
|
normalizeDashboardFilePath,
|
|
14
|
+
renameDashboardFile,
|
|
15
|
+
setDashboardFileVisibility,
|
|
13
16
|
uploadDashboardFiles,
|
|
14
17
|
} from './file-manager'
|
|
15
18
|
|
|
@@ -163,3 +166,198 @@ describe('dashboard file manager', () => {
|
|
|
163
166
|
expect(() => normalizeDashboardFileName('nested/name')).toThrow('separators')
|
|
164
167
|
})
|
|
165
168
|
})
|
|
169
|
+
|
|
170
|
+
describe('renameDashboardFile', () => {
|
|
171
|
+
test('renames a file and leaves nothing behind under the old name', async () => {
|
|
172
|
+
const disk = manager.disk('public')
|
|
173
|
+
await disk.write('documents/readme.txt', 'hello')
|
|
174
|
+
|
|
175
|
+
const result = await renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager)
|
|
176
|
+
|
|
177
|
+
expect(result).toEqual({ from: 'documents/readme.txt', to: 'documents/guide.txt', type: 'file', moved: 1 })
|
|
178
|
+
expect(await disk.fileExists('documents/guide.txt')).toBe(true)
|
|
179
|
+
expect(await disk.fileExists('documents/readme.txt')).toBe(false)
|
|
180
|
+
expect(await disk.readToString('documents/guide.txt')).toBe('hello')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('renames at the top level, where there is no parent to keep', async () => {
|
|
184
|
+
const disk = manager.disk('public')
|
|
185
|
+
await disk.write('notes.txt', 'top level')
|
|
186
|
+
|
|
187
|
+
expect(await renameDashboardFile({ path: 'notes.txt', name: 'todo.txt' }, manager))
|
|
188
|
+
.toEqual({ from: 'notes.txt', to: 'todo.txt', type: 'file', moved: 1 })
|
|
189
|
+
expect(await disk.readToString('todo.txt')).toBe('top level')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The parent is kept deliberately. Renaming is changing the last segment;
|
|
194
|
+
* moving something elsewhere is a different gesture and would want its own
|
|
195
|
+
* endpoint, so a name is a name and never a path.
|
|
196
|
+
*/
|
|
197
|
+
test('refuses a name that is really a path', async () => {
|
|
198
|
+
await manager.disk('public').write('a/b.txt', 'x')
|
|
199
|
+
|
|
200
|
+
await expect(renameDashboardFile({ path: 'a/b.txt', name: '../escaped.txt' }, manager))
|
|
201
|
+
.rejects.toMatchObject({ status: 422 })
|
|
202
|
+
await expect(renameDashboardFile({ path: 'a/b.txt', name: 'nested/deep.txt' }, manager))
|
|
203
|
+
.rejects.toMatchObject({ status: 422 })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('renames a directory by moving what is inside it, at any depth', async () => {
|
|
207
|
+
const disk = manager.disk('public')
|
|
208
|
+
await disk.write('images/logo.png', 'a')
|
|
209
|
+
await disk.write('images/icons/favicon.png', 'b')
|
|
210
|
+
|
|
211
|
+
const result = await renameDashboardFile({ path: 'images', name: 'media' }, manager)
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual({ from: 'images', to: 'media', type: 'directory', moved: 2 })
|
|
214
|
+
expect(await disk.readToString('media/logo.png')).toBe('a')
|
|
215
|
+
expect(await disk.readToString('media/icons/favicon.png')).toBe('b')
|
|
216
|
+
expect(await disk.fileExists('images/logo.png')).toBe(false)
|
|
217
|
+
expect(await disk.directoryExists('images')).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('refuses to overwrite an existing name', async () => {
|
|
221
|
+
const disk = manager.disk('public')
|
|
222
|
+
await disk.write('documents/readme.txt', 'keep me')
|
|
223
|
+
await disk.write('documents/guide.txt', 'me too')
|
|
224
|
+
|
|
225
|
+
await expect(renameDashboardFile({ path: 'documents/readme.txt', name: 'guide.txt' }, manager))
|
|
226
|
+
.rejects.toMatchObject({ status: 409 })
|
|
227
|
+
// Neither side moved: a refused rename is not a partial one.
|
|
228
|
+
expect(await disk.readToString('documents/readme.txt')).toBe('keep me')
|
|
229
|
+
expect(await disk.readToString('documents/guide.txt')).toBe('me too')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('says so rather than silently doing nothing when the name is unchanged', async () => {
|
|
233
|
+
await manager.disk('public').write('a.txt', 'x')
|
|
234
|
+
|
|
235
|
+
await expect(renameDashboardFile({ path: 'a.txt', name: 'a.txt' }, manager))
|
|
236
|
+
.rejects.toMatchObject({ status: 422 })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('is a 404 when the item does not exist', async () => {
|
|
240
|
+
await expect(renameDashboardFile({ path: 'nope.txt', name: 'yes.txt' }, manager))
|
|
241
|
+
.rejects.toMatchObject({ status: 404 })
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('setDashboardFileVisibility', () => {
|
|
246
|
+
test('flips a single file and reads back as the adapter sees it', async () => {
|
|
247
|
+
const disk = manager.disk('public')
|
|
248
|
+
await disk.write('documents/readme.txt', 'hello')
|
|
249
|
+
|
|
250
|
+
expect(await setDashboardFileVisibility({ path: 'documents/readme.txt', visibility: 'private' }, manager))
|
|
251
|
+
.toEqual({ path: 'documents/readme.txt', visibility: 'private', type: 'file', changed: 1 })
|
|
252
|
+
expect(await disk.visibility('documents/readme.txt')).toBe('private')
|
|
253
|
+
|
|
254
|
+
await setDashboardFileVisibility({ path: 'documents/readme.txt', visibility: 'public' }, manager)
|
|
255
|
+
expect(await disk.visibility('documents/readme.txt')).toBe('public')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A folder is applied file by file, not to the folder. Object storage has no
|
|
260
|
+
* directories to carry an ACL, and on a local disk a directory's mode gates
|
|
261
|
+
* listing rather than reading - the files are what gate access on both.
|
|
262
|
+
*/
|
|
263
|
+
test('applies to every file beneath a folder, at any depth', async () => {
|
|
264
|
+
const disk = manager.disk('public')
|
|
265
|
+
await disk.write('images/logo.png', 'a')
|
|
266
|
+
await disk.write('images/icons/favicon.png', 'b')
|
|
267
|
+
|
|
268
|
+
expect(await setDashboardFileVisibility({ path: 'images', visibility: 'private' }, manager))
|
|
269
|
+
.toEqual({ path: 'images', visibility: 'private', type: 'directory', changed: 2 })
|
|
270
|
+
expect(await disk.visibility('images/logo.png')).toBe('private')
|
|
271
|
+
expect(await disk.visibility('images/icons/favicon.png')).toBe('private')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('leaves files outside the folder alone', async () => {
|
|
275
|
+
const disk = manager.disk('public')
|
|
276
|
+
await disk.write('images/logo.png', 'a')
|
|
277
|
+
await disk.write('documents/readme.txt', 'b')
|
|
278
|
+
|
|
279
|
+
await setDashboardFileVisibility({ path: 'images', visibility: 'private' }, manager)
|
|
280
|
+
|
|
281
|
+
expect(await disk.visibility('documents/readme.txt')).toBe('public')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('rejects anything that is not public or private', async () => {
|
|
285
|
+
await manager.disk('public').write('a.txt', 'x')
|
|
286
|
+
|
|
287
|
+
for (const visibility of ['world-readable', '', 'PUBLIC', true, undefined]) {
|
|
288
|
+
await expect(setDashboardFileVisibility({ path: 'a.txt', visibility }, manager))
|
|
289
|
+
.rejects.toMatchObject({ status: 422 })
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('is a 404 when the item does not exist', async () => {
|
|
294
|
+
await expect(setDashboardFileVisibility({ path: 'nope.txt', visibility: 'private' }, manager))
|
|
295
|
+
.rejects.toMatchObject({ status: 404 })
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('duplicateDashboardFile', () => {
|
|
300
|
+
test('names the copy before the extension, not after it', async () => {
|
|
301
|
+
const disk = manager.disk('public')
|
|
302
|
+
await disk.write('documents/readme.txt', 'hello')
|
|
303
|
+
|
|
304
|
+
const result = await duplicateDashboardFile({ path: 'documents/readme.txt' }, manager)
|
|
305
|
+
|
|
306
|
+
// `readme.txt copy` is a file whose type the OS, the browser and this
|
|
307
|
+
// dashboard's own type grouping would all read as unknown.
|
|
308
|
+
expect(result).toEqual({ from: 'documents/readme.txt', to: 'documents/readme copy.txt', type: 'file', copied: 1 })
|
|
309
|
+
expect(await disk.readToString('documents/readme copy.txt')).toBe('hello')
|
|
310
|
+
expect(await disk.readToString('documents/readme.txt')).toBe('hello')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('counts up rather than colliding when a copy already exists', async () => {
|
|
314
|
+
const disk = manager.disk('public')
|
|
315
|
+
await disk.write('a.txt', 'x')
|
|
316
|
+
|
|
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')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('takes an explicit name when given one', async () => {
|
|
323
|
+
await manager.disk('public').write('a.txt', 'x')
|
|
324
|
+
|
|
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))
|
|
327
|
+
.rejects.toMatchObject({ status: 409 })
|
|
328
|
+
await expect(duplicateDashboardFile({ path: 'a.txt', name: '../escaped.txt' }, manager))
|
|
329
|
+
.rejects.toMatchObject({ status: 422 })
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('copies a folder and everything under it, leaving the original whole', async () => {
|
|
333
|
+
const disk = manager.disk('public')
|
|
334
|
+
await disk.write('images/logo.png', 'a')
|
|
335
|
+
await disk.write('images/icons/favicon.png', 'b')
|
|
336
|
+
|
|
337
|
+
const result = await duplicateDashboardFile({ path: 'images' }, manager)
|
|
338
|
+
|
|
339
|
+
expect(result).toEqual({ from: 'images', to: 'images copy', type: 'directory', copied: 2 })
|
|
340
|
+
expect(await disk.readToString('images copy/logo.png')).toBe('a')
|
|
341
|
+
expect(await disk.readToString('images copy/icons/favicon.png')).toBe('b')
|
|
342
|
+
expect(await disk.readToString('images/logo.png')).toBe('a')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The copy lands beside the original under the same parent, so a deep listing
|
|
347
|
+
* taken while copying could see the files it is itself creating. Paths are
|
|
348
|
+
* collected first; this is what says so.
|
|
349
|
+
*/
|
|
350
|
+
test('does not copy the copy it is making', async () => {
|
|
351
|
+
const disk = manager.disk('public')
|
|
352
|
+
await disk.write('media/one.txt', '1')
|
|
353
|
+
await disk.write('media/two.txt', '2')
|
|
354
|
+
|
|
355
|
+
expect((await duplicateDashboardFile({ path: 'media' }, manager)).copied).toBe(2)
|
|
356
|
+
expect(await disk.fileExists('media copy/media copy/one.txt')).toBe(false)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('is a 404 when the item does not exist', async () => {
|
|
360
|
+
await expect(duplicateDashboardFile({ path: 'nope.txt' }, manager))
|
|
361
|
+
.rejects.toMatchObject({ status: 404 })
|
|
362
|
+
})
|
|
363
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ResponseStatus } from '@stacksjs/bun-router'
|
|
2
2
|
import { statfs } from 'node:fs/promises'
|
|
3
3
|
import { posix } from 'node:path'
|
|
4
|
-
import type { StorageAdapter, StorageManager, UploadedFileLike } from '@stacksjs/storage'
|
|
4
|
+
import type { StorageAdapter, StorageManager, UploadedFileLike, Visibility } from '@stacksjs/storage'
|
|
5
5
|
import { Storage } from '@stacksjs/storage'
|
|
6
6
|
|
|
7
7
|
const DEFAULT_DISK = 'public'
|
|
@@ -443,6 +443,213 @@ export async function deleteDashboardFile(
|
|
|
443
443
|
throw new DashboardFileError(`Storage item "${path}" was not found.`, 404)
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Rename a file or a folder in place.
|
|
448
|
+
*
|
|
449
|
+
* The parent stays put and only the last segment changes, which is what a
|
|
450
|
+
* rename in a file manager means - moving something elsewhere is a different
|
|
451
|
+
* gesture and would want a different endpoint.
|
|
452
|
+
*
|
|
453
|
+
* A directory is renamed by moving what is inside it rather than by moving the
|
|
454
|
+
* directory. On a local disk `moveFile` is `fs.rename` and would happily move a
|
|
455
|
+
* whole tree, but object storage has no directories at all: a folder there is a
|
|
456
|
+
* shared key prefix, and renaming it means rewriting the key of every object
|
|
457
|
+
* under it. Doing it the same way on both is the only version that is not
|
|
458
|
+
* quietly wrong on one of them.
|
|
459
|
+
*
|
|
460
|
+
* See stacksjs/stacks#245.
|
|
461
|
+
*/
|
|
462
|
+
export async function renameDashboardFile(
|
|
463
|
+
input: { disk?: string, path: unknown, name: unknown },
|
|
464
|
+
manager: Manager = Storage,
|
|
465
|
+
): Promise<{ from: string, to: string, type: 'file' | 'directory', moved: number }> {
|
|
466
|
+
const selected = resolveDisk(manager, input.disk)
|
|
467
|
+
const from = normalizeDashboardFilePath(input.path)
|
|
468
|
+
const name = normalizeDashboardFileName(input.name)
|
|
469
|
+
|
|
470
|
+
const separator = from.lastIndexOf('/')
|
|
471
|
+
const parent = separator === -1 ? '' : from.slice(0, separator)
|
|
472
|
+
const to = [parent, name].filter(Boolean).join('/')
|
|
473
|
+
|
|
474
|
+
if (to === from)
|
|
475
|
+
throw new DashboardFileError('The new name matches the current one.', 422, { name: 'Choose a different name.' })
|
|
476
|
+
|
|
477
|
+
if (await selected.adapter.fileExists(to) || await selected.adapter.directoryExists(to))
|
|
478
|
+
throw new DashboardFileError(`An item named "${name}" already exists.`, 409, { name: 'Choose a different name.' })
|
|
479
|
+
|
|
480
|
+
if (await selected.adapter.fileExists(from)) {
|
|
481
|
+
await selected.adapter.moveFile(from, to)
|
|
482
|
+
return { from, to, type: 'file', moved: 1 }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!(await selected.adapter.directoryExists(from)))
|
|
486
|
+
throw new DashboardFileError(`Storage item "${from}" was not found.`, 404)
|
|
487
|
+
|
|
488
|
+
// Collected before anything moves. Mutating a tree while iterating it is how
|
|
489
|
+
// a rename half-completes and leaves files under both names.
|
|
490
|
+
const files: string[] = []
|
|
491
|
+
for await (const entry of selected.adapter.list(from, { deep: true })) {
|
|
492
|
+
const path = normalizeListedPath(String(entry.path))
|
|
493
|
+
if (entry.type === 'file' && path)
|
|
494
|
+
files.push(path)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const file of files) {
|
|
498
|
+
// `list` may answer absolute-from-root or relative-to-`from` paths
|
|
499
|
+
// depending on the adapter; both end with the part that has to be kept.
|
|
500
|
+
const relative = file.startsWith(`${from}/`) ? file.slice(from.length + 1) : file
|
|
501
|
+
await selected.adapter.moveFile(file.startsWith(`${from}/`) ? file : `${from}/${relative}`, `${to}/${relative}`)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// An empty source is what is left, and it should not be: a rename leaves one
|
|
505
|
+
// item, not two. Directories are implicit on object storage, so this is a
|
|
506
|
+
// no-op there rather than a failure.
|
|
507
|
+
await selected.adapter.deleteDirectory(from)
|
|
508
|
+
|
|
509
|
+
return { from, to, type: 'directory', moved: files.length }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Make a file, or everything in a folder, public or private.
|
|
514
|
+
*
|
|
515
|
+
* A folder is applied file by file rather than to the folder itself, for the
|
|
516
|
+
* same reason a rename is: object storage has no directories, only a shared key
|
|
517
|
+
* prefix, and an ACL belongs to an object. `directoryExists` there is a
|
|
518
|
+
* prefix-has-objects check with nothing at that key, so asking to change the
|
|
519
|
+
* "directory" would address something that does not exist.
|
|
520
|
+
*
|
|
521
|
+
* It is also the right answer on a local disk, where a mode on a directory
|
|
522
|
+
* controls listing and traversal but not whether a file inside it can be read.
|
|
523
|
+
* The files are what gate access on both, so the files are what this sets.
|
|
524
|
+
*
|
|
525
|
+
* See stacksjs/stacks#245.
|
|
526
|
+
*/
|
|
527
|
+
export async function setDashboardFileVisibility(
|
|
528
|
+
input: { disk?: string, path: unknown, visibility: unknown },
|
|
529
|
+
manager: Manager = Storage,
|
|
530
|
+
): Promise<{ path: string, visibility: Visibility, type: 'file' | 'directory', changed: number }> {
|
|
531
|
+
const selected = resolveDisk(manager, input.disk)
|
|
532
|
+
const path = normalizeDashboardFilePath(input.path)
|
|
533
|
+
|
|
534
|
+
if (input.visibility !== 'public' && input.visibility !== 'private') {
|
|
535
|
+
throw new DashboardFileError('Visibility must be "public" or "private".', 422, {
|
|
536
|
+
visibility: 'Choose either public or private.',
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
const visibility = input.visibility as Visibility
|
|
540
|
+
|
|
541
|
+
if (await selected.adapter.fileExists(path)) {
|
|
542
|
+
await selected.adapter.changeVisibility(path, visibility)
|
|
543
|
+
return { path, visibility, type: 'file', changed: 1 }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!(await selected.adapter.directoryExists(path)))
|
|
547
|
+
throw new DashboardFileError(`Storage item "${path}" was not found.`, 404)
|
|
548
|
+
|
|
549
|
+
let changed = 0
|
|
550
|
+
for await (const entry of selected.adapter.list(path, { deep: true })) {
|
|
551
|
+
if (entry.type !== 'file')
|
|
552
|
+
continue
|
|
553
|
+
const listed = normalizeListedPath(String(entry.path))
|
|
554
|
+
if (!listed)
|
|
555
|
+
continue
|
|
556
|
+
await selected.adapter.changeVisibility(listed.startsWith(`${path}/`) ? listed : `${path}/${listed}`, visibility)
|
|
557
|
+
changed++
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { path, visibility, type: 'directory', changed }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The name a duplicate gets when the caller does not choose one.
|
|
565
|
+
*
|
|
566
|
+
* `readme.txt` becomes `readme copy.txt`, then `readme copy 2.txt` - the
|
|
567
|
+
* suffix goes before the extension, because `readme.txt copy` is a file whose
|
|
568
|
+
* type the operating system, the browser and this dashboard's own type
|
|
569
|
+
* grouping all read as unknown.
|
|
570
|
+
*
|
|
571
|
+
* A directory has no extension to preserve, and `posix.extname` returns `''`
|
|
572
|
+
* for one, so the same code handles both.
|
|
573
|
+
*/
|
|
574
|
+
async function availableCopyName(path: string, exists: (candidate: string) => Promise<boolean>): Promise<string> {
|
|
575
|
+
const base = posix.basename(path)
|
|
576
|
+
const extension = posix.extname(base)
|
|
577
|
+
const stem = extension ? base.slice(0, -extension.length) : base
|
|
578
|
+
const parent = path.slice(0, Math.max(0, path.length - base.length - 1))
|
|
579
|
+
|
|
580
|
+
for (let attempt = 1; attempt <= 100; attempt++) {
|
|
581
|
+
const suffix = attempt === 1 ? 'copy' : `copy ${attempt}`
|
|
582
|
+
const candidate = `${stem} ${suffix}${extension}`
|
|
583
|
+
const full = [parent, candidate].filter(Boolean).join('/')
|
|
584
|
+
if (!(await exists(full)))
|
|
585
|
+
return candidate
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
throw new DashboardFileError('Too many copies of this item already exist.', 409, {
|
|
589
|
+
name: 'Rename some copies, or choose a name.',
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Copy a file, or a folder and everything in it, beside the original.
|
|
595
|
+
*
|
|
596
|
+
* Deep-walks a directory for the same reason rename and visibility do: object
|
|
597
|
+
* storage has no folder to copy, only a shared key prefix, so duplicating one
|
|
598
|
+
* means copying every object beneath it.
|
|
599
|
+
*
|
|
600
|
+
* The name is optional. A file manager's "Duplicate" is a single gesture that
|
|
601
|
+
* has to produce something, so an omitted name becomes `<name> copy`, then
|
|
602
|
+
* `<name> copy 2` - checked for availability rather than assumed, since the
|
|
603
|
+
* first copy is usually not the only one.
|
|
604
|
+
*
|
|
605
|
+
* See stacksjs/stacks#245.
|
|
606
|
+
*/
|
|
607
|
+
export async function duplicateDashboardFile(
|
|
608
|
+
input: { disk?: string, path: unknown, name?: unknown },
|
|
609
|
+
manager: Manager = Storage,
|
|
610
|
+
): Promise<{ from: string, to: string, type: 'file' | 'directory', copied: number }> {
|
|
611
|
+
const selected = resolveDisk(manager, input.disk)
|
|
612
|
+
const from = normalizeDashboardFilePath(input.path)
|
|
613
|
+
const taken = async (candidate: string): Promise<boolean> =>
|
|
614
|
+
await selected.adapter.fileExists(candidate) || await selected.adapter.directoryExists(candidate)
|
|
615
|
+
|
|
616
|
+
const name = input.name === undefined || input.name === null || input.name === ''
|
|
617
|
+
? await availableCopyName(from, taken)
|
|
618
|
+
: normalizeDashboardFileName(input.name)
|
|
619
|
+
|
|
620
|
+
const separator = from.lastIndexOf('/')
|
|
621
|
+
const parent = separator === -1 ? '' : from.slice(0, separator)
|
|
622
|
+
const to = [parent, name].filter(Boolean).join('/')
|
|
623
|
+
|
|
624
|
+
if (to === from)
|
|
625
|
+
throw new DashboardFileError('A copy needs a different name.', 422, { name: 'Choose a different name.' })
|
|
626
|
+
if (await taken(to))
|
|
627
|
+
throw new DashboardFileError(`An item named "${name}" already exists.`, 409, { name: 'Choose a different name.' })
|
|
628
|
+
|
|
629
|
+
if (await selected.adapter.fileExists(from)) {
|
|
630
|
+
await selected.adapter.copyFile(from, to)
|
|
631
|
+
return { from, to, type: 'file', copied: 1 }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (!(await selected.adapter.directoryExists(from)))
|
|
635
|
+
throw new DashboardFileError(`Storage item "${from}" was not found.`, 404)
|
|
636
|
+
|
|
637
|
+
// Collected before anything is written, so a copy cannot pick up the files
|
|
638
|
+
// it is itself creating - `to` sits beside `from` under the same parent, and
|
|
639
|
+
// a deep listing that ran while copying could see them.
|
|
640
|
+
const files: string[] = []
|
|
641
|
+
for await (const entry of selected.adapter.list(from, { deep: true })) {
|
|
642
|
+
const listed = normalizeListedPath(String(entry.path))
|
|
643
|
+
if (entry.type === 'file' && listed)
|
|
644
|
+
files.push(listed.startsWith(`${from}/`) ? listed : `${from}/${listed}`)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (const file of files)
|
|
648
|
+
await selected.adapter.copyFile(file, `${to}/${file.slice(from.length + 1)}`)
|
|
649
|
+
|
|
650
|
+
return { from, to, type: 'directory', copied: files.length }
|
|
651
|
+
}
|
|
652
|
+
|
|
446
653
|
export async function uploadDashboardFiles(
|
|
447
654
|
input: { disk?: string, path?: string, files: UploadedFileLike[] },
|
|
448
655
|
manager: Manager = Storage,
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.33",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.74.
|
|
58
|
+
"@stacksjs/mobile": "^0.74.33",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
package/routes/dashboard-api.ts
CHANGED
|
@@ -245,6 +245,9 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
|
|
|
245
245
|
guard(route.get('/files', 'Actions/Dashboard/Content/FileIndexAction'))
|
|
246
246
|
guard(route.post('/files/directories', 'Actions/Dashboard/Content/FileDirectoryStoreAction'))
|
|
247
247
|
guard(route.post('/files/uploads', 'Actions/Dashboard/Content/FileUploadAction'))
|
|
248
|
+
guard(route.patch('/files', 'Actions/Dashboard/Content/FileRenameAction'))
|
|
249
|
+
guard(route.put('/files/visibility', 'Actions/Dashboard/Content/FileVisibilityAction'))
|
|
250
|
+
guard(route.post('/files/duplicates', 'Actions/Dashboard/Content/FileDuplicateAction'))
|
|
248
251
|
guard(route.delete('/files', 'Actions/Dashboard/Content/FileDestroyAction'))
|
|
249
252
|
|
|
250
253
|
guard(route.get('/ci/status', 'Actions/Dashboard/Ci/StatusAction'))
|
|
Binary file
|