@stacksjs/defaults 0.74.31 → 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.
@@ -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 and self-hosted driver support.
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 Analytics
20
- Privacy-focused, GDPR-compliant analytics. Requires a Fathom account.
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
- ### Self-Hosted Analytics
23
- Generate tracking scripts for self-hosted analytics:
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 { generateSelfHostedScript, getSelfHostedAnalyticsHead, generateInlineScript } from '@stacksjs/analytics'
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
- ## SelfHostedConfig Interface
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
- trackingId: '' // GA tracking ID
67
- },
68
- fathom: {
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
- - Default driver is `fathom` — privacy-focused by default
85
- - Self-hosted analytics require your own analytics endpoint
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
- - `escapeAttr()` is used internally to prevent XSS in generated scripts
88
- - Google Analytics tracking ID goes in config, not env (unless you override)
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`
@@ -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. (98 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (100 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -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/` (98 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (100 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`
@@ -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>.html` and keep the source JSON beside it.
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,13 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { createReferralCode } from '@stacksjs/auth'
3
+ import { response } from '@stacksjs/router'
4
+
5
+ export default new Action({
6
+ name: 'CreateReferralCodeAction',
7
+ method: 'POST',
8
+ async handle(request: RequestInstance) {
9
+ const user = await request.user()
10
+ if (!user?.id) return response.error('Unauthenticated', 401)
11
+ return response.json({ code: await createReferralCode(Number(user.id)) })
12
+ },
13
+ })
@@ -0,0 +1,13 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { referralSummary } from '@stacksjs/auth'
3
+ import { response } from '@stacksjs/router'
4
+
5
+ export default new Action({
6
+ name: 'ReferralSummaryAction',
7
+ method: 'GET',
8
+ async handle(request: RequestInstance) {
9
+ const user = await request.user()
10
+ if (!user?.id) return response.error('Unauthenticated', 401)
11
+ return response.json(await referralSummary(Number(user.id)))
12
+ },
13
+ })
@@ -30,7 +30,8 @@ export default new Action({
30
30
  const password = request.get('password')
31
31
  const name = request.get('name')
32
32
 
33
- const result = await register({ email, password, name })
33
+ const referralCode = request.get('referralCode')
34
+ const result = await register({ email, password, name, referralCode: typeof referralCode === 'string' ? referralCode : undefined })
34
35
 
35
36
  if (result) {
36
37
  const user = await Auth.getUserFromToken(result.token)
@@ -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,
@@ -3,6 +3,8 @@ import { authenticatedUser } from '@stacksjs/auth/middleware'
3
3
  import { HttpError } from '@stacksjs/error-handling'
4
4
  import { Middleware, resolveRouteModel, setRouteModelFallback } from '@stacksjs/router'
5
5
 
6
+ let ormModule: typeof import('@stacksjs/orm') | undefined
7
+
6
8
  /**
7
9
  * Convention binding: parameter `site` resolves through the `Site` model
8
10
  * (stacksjs/stacks#2231).
@@ -24,7 +26,7 @@ setRouteModelFallback(async (value, { param }) => {
24
26
  // touched: lowercasing the rest would turn `blogPost` into `Blogpost`.
25
27
  const modelName = param.charAt(0).toUpperCase() + param.slice(1)
26
28
 
27
- const orm = await import('@stacksjs/orm') as Record<string, any>
29
+ const orm = (ormModule ??= await import('@stacksjs/orm')) as Record<string, any>
28
30
  const model = orm[modelName]
29
31
 
30
32
  // No model of that name — decline, so the raw string passes through exactly
@@ -2,6 +2,8 @@ import { authenticatedUser } from '@stacksjs/auth/middleware'
2
2
  import { HttpError } from '@stacksjs/error-handling'
3
3
  import { Middleware } from '@stacksjs/router'
4
4
 
5
+ let rbacModule: typeof import('@stacksjs/auth/rbac') | undefined
6
+
5
7
  /**
6
8
  * Permission Middleware
7
9
  *
@@ -32,7 +34,7 @@ export default new Middleware({
32
34
  throw new HttpError(401, 'Unauthenticated.')
33
35
  }
34
36
 
35
- const { hasAnyPermission } = await import('@stacksjs/auth/rbac')
37
+ const { hasAnyPermission } = rbacModule ??= await import('@stacksjs/auth/rbac')
36
38
 
37
39
  const hasRequired = await hasAnyPermission(user, requiredPermissions)
38
40
 
@@ -2,6 +2,8 @@ import { authenticatedUser } from '@stacksjs/auth/middleware'
2
2
  import { HttpError } from '@stacksjs/error-handling'
3
3
  import { Middleware } from '@stacksjs/router'
4
4
 
5
+ let rbacModule: typeof import('@stacksjs/auth/rbac') | undefined
6
+
5
7
  /**
6
8
  * Role Middleware
7
9
  *
@@ -32,7 +34,7 @@ export default new Middleware({
32
34
  }
33
35
 
34
36
  // Dynamically import to avoid circular dependency
35
- const { hasAnyRole } = await import('@stacksjs/auth/rbac')
37
+ const { hasAnyRole } = rbacModule ??= await import('@stacksjs/auth/rbac')
36
38
 
37
39
  const hasRequired = await hasAnyRole(user, requiredRoles)
38
40
 
@@ -0,0 +1,16 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation'
3
+
4
+ export default defineModel({
5
+ name: 'Referral',
6
+ table: 'referrals',
7
+ traits: { useTimestamps: true },
8
+ indexes: [{ name: 'referrals_referrer_status', columns: ['referrer_id', 'status'] }],
9
+ attributes: {
10
+ referrerId: { required: true, validation: { rule: schema.number().integer().min(1) } },
11
+ referredUserId: { required: true, unique: true, validation: { rule: schema.number().integer().min(1) } },
12
+ code: { required: true, validation: { rule: schema.string().max(24) } },
13
+ status: { required: true, default: 'registered', validation: { rule: schema.enum(['registered', 'qualified']) } },
14
+ qualifiedAt: { nullable: true, validation: { rule: schema.date() } },
15
+ },
16
+ } as const)
@@ -0,0 +1,12 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation'
3
+
4
+ export default defineModel({
5
+ name: 'ReferralCode',
6
+ table: 'referral_codes',
7
+ traits: { useTimestamps: true },
8
+ attributes: {
9
+ userId: { required: true, unique: true, validation: { rule: schema.number().integer().min(1) } },
10
+ code: { required: true, unique: true, validation: { rule: schema.string().max(24) } },
11
+ },
12
+ } as const)
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.31",
5
+ "version": "0.74.33",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
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.31",
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.31",
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/auth.ts CHANGED
@@ -74,6 +74,8 @@ route.group({ prefix: '/auth' }, () => {
74
74
  })
75
75
 
76
76
  route.group({ middleware: 'auth' }, () => {
77
+ route.get('/referrals', 'Actions/Auth/ReferralSummaryAction').rateLimit(60, 'minute')
78
+ route.post('/referrals/code', 'Actions/Auth/CreateReferralCodeAction').rateLimit(10, 'minute')
77
79
  route.get('/me', 'Actions/Auth/AuthUserAction')
78
80
  route.post('/logout', 'Actions/Auth/LogoutAction')
79
81
  // Sign out everywhere: revoke every access/refresh token AND destroy
@@ -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