@globalbrain/sefirot 4.61.2 → 4.63.0
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.
|
@@ -1768,6 +1768,9 @@ defineExpose({
|
|
|
1768
1768
|
:width="sheetWidth"
|
|
1769
1769
|
@close="sheet.off"
|
|
1770
1770
|
>
|
|
1771
|
+
<template v-if="$slots['sheet-title-after']" #title-after="s">
|
|
1772
|
+
<slot name="sheet-title-after" v-bind="s" />
|
|
1773
|
+
</template>
|
|
1771
1774
|
<template v-if="$slots['sheet-before']" #before="s">
|
|
1772
1775
|
<slot name="sheet-before" v-bind="s" />
|
|
1773
1776
|
</template>
|
|
@@ -301,15 +301,15 @@ function requestClose() {
|
|
|
301
301
|
|
|
302
302
|
// --- Slot context -----------------------------------------------------------
|
|
303
303
|
//
|
|
304
|
-
// Content passed to the `#before` / `#after` slots is declared in the page
|
|
304
|
+
// Content passed to the `#title-after` / `#before` / `#after` slots is declared in the page
|
|
305
305
|
// that hosts the catalog, so it cannot `inject` the edit context (which is
|
|
306
306
|
// provided inside `LensCatalog`, a child of that page). Expose the pieces a
|
|
307
|
-
// slot needs as slot props instead: the resolved record id, a
|
|
308
|
-
// partial `save`, the entity key, the sheet `mode` (so a slot can
|
|
309
|
-
// different content for create vs view), and `registerCreateExtension`
|
|
310
|
-
// create-mode slot can contribute extra keys to the create payload). This
|
|
311
|
-
// the generic sheet free of one-off concerns (avatar upload,
|
|
312
|
-
// linked records) while still letting a page implement them.
|
|
307
|
+
// slot needs as slot props instead: the rendered title, resolved record id, a
|
|
308
|
+
// record-bound partial `save`, the entity key, the sheet `mode` (so a slot can
|
|
309
|
+
// render different content for create vs view), and `registerCreateExtension`
|
|
310
|
+
// (so a create-mode slot can contribute extra keys to the create payload). This
|
|
311
|
+
// keeps the generic sheet free of one-off concerns (badges, avatar upload,
|
|
312
|
+
// social links, linked records) while still letting a page implement them.
|
|
313
313
|
|
|
314
314
|
const resolvedId = computed(() => (props.record && edit ? edit.resolveId(props.record) : null))
|
|
315
315
|
|
|
@@ -331,6 +331,7 @@ function saveRecord(values: Record<string, any>): Promise<void> {
|
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
const slotProps = computed(() => ({
|
|
334
|
+
title: title.value,
|
|
334
335
|
record: props.record ?? null,
|
|
335
336
|
id: resolvedId.value,
|
|
336
337
|
entity: props.entity,
|
|
@@ -357,7 +358,12 @@ const slotProps = computed(() => ({
|
|
|
357
358
|
<SSheet :open :closable="!saving" :width="width ?? '480px'" @close="requestClose">
|
|
358
359
|
<div class="LensSheet">
|
|
359
360
|
<div class="header">
|
|
360
|
-
<div class="
|
|
361
|
+
<div class="heading">
|
|
362
|
+
<div class="title">{{ title }}</div>
|
|
363
|
+
<div v-if="$slots['title-after']" class="title-after">
|
|
364
|
+
<slot name="title-after" v-bind="slotProps" />
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
361
367
|
<button
|
|
362
368
|
class="close"
|
|
363
369
|
type="button"
|
|
@@ -462,6 +468,15 @@ const slotProps = computed(() => ({
|
|
|
462
468
|
border-bottom: 1px solid var(--c-divider);
|
|
463
469
|
}
|
|
464
470
|
|
|
471
|
+
.heading {
|
|
472
|
+
display: flex;
|
|
473
|
+
align-items: center;
|
|
474
|
+
flex-wrap: wrap;
|
|
475
|
+
flex: 1;
|
|
476
|
+
gap: 8px;
|
|
477
|
+
min-width: 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
465
480
|
.title {
|
|
466
481
|
font-size: 16px;
|
|
467
482
|
font-weight: 500;
|
|
@@ -469,10 +484,17 @@ const slotProps = computed(() => ({
|
|
|
469
484
|
color: var(--c-text-1);
|
|
470
485
|
}
|
|
471
486
|
|
|
487
|
+
.title-after {
|
|
488
|
+
display: flex;
|
|
489
|
+
align-items: center;
|
|
490
|
+
flex: none;
|
|
491
|
+
}
|
|
492
|
+
|
|
472
493
|
.close {
|
|
473
494
|
display: flex;
|
|
474
495
|
align-items: center;
|
|
475
496
|
justify-content: center;
|
|
497
|
+
flex: none;
|
|
476
498
|
width: 32px;
|
|
477
499
|
height: 32px;
|
|
478
500
|
border-radius: 6px;
|
package/lib/http/Http.ts
CHANGED
|
@@ -3,9 +3,107 @@ import { parse as parseCookie } from '@tinyhttp/cookie'
|
|
|
3
3
|
import { FetchError, type FetchOptions, type FetchResponse } from 'ofetch'
|
|
4
4
|
import { stringify } from 'qs'
|
|
5
5
|
import { saveAs } from '../support/File'
|
|
6
|
-
import { objectToFormData } from '../support/Http'
|
|
6
|
+
import { getHttpStatusCode, objectToFormData } from '../support/Http'
|
|
7
7
|
|
|
8
8
|
type Config = ReturnType<typeof import('../stores/HttpConfig').useHttpConfig>
|
|
9
|
+
type BuiltRequest = [url: string, options: FetchOptions]
|
|
10
|
+
|
|
11
|
+
export type HttpRequestOptions = FetchOptions & {
|
|
12
|
+
/**
|
|
13
|
+
* Prevent this request from recursively recovering the application session.
|
|
14
|
+
* Authentication bootstrap requests should disable session recovery.
|
|
15
|
+
*/
|
|
16
|
+
sessionRecovery?: false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const xsrfRefreshes = new WeakMap<Config, Promise<void>>()
|
|
20
|
+
const sessionRecoveries = new WeakMap<Config, Promise<boolean>>()
|
|
21
|
+
const sessionRecoveryGenerations = new WeakMap<Config, number>()
|
|
22
|
+
const relativeUrlBases = [
|
|
23
|
+
new URL('http://a.sefirot.invalid'),
|
|
24
|
+
new URL('http://b.sefirot.invalid')
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function getSessionRecoveryGeneration(config: Config): number {
|
|
28
|
+
return sessionRecoveryGenerations.get(config) ?? 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isLocalUrlReference(url: string): boolean {
|
|
32
|
+
if (URL.canParse(url)) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return relativeUrlBases.every(
|
|
37
|
+
(base) => URL.canParse(url, base)
|
|
38
|
+
&& new URL(url, base).origin === base.origin
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isReplayableBody(body: unknown): boolean {
|
|
43
|
+
if (body == null) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof body !== 'object') {
|
|
48
|
+
return ['string', 'number', 'boolean'].includes(typeof body)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
Array.isArray(body)
|
|
53
|
+
|| body instanceof Blob
|
|
54
|
+
|| body instanceof FormData
|
|
55
|
+
|| body instanceof URLSearchParams
|
|
56
|
+
|| body instanceof ArrayBuffer
|
|
57
|
+
|| ArrayBuffer.isView(body)
|
|
58
|
+
) {
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const source = body as {
|
|
63
|
+
constructor?: { name?: string }
|
|
64
|
+
pipeTo?: unknown
|
|
65
|
+
pipe?: unknown
|
|
66
|
+
next?: unknown
|
|
67
|
+
toJSON?: unknown
|
|
68
|
+
[Symbol.iterator]?: unknown
|
|
69
|
+
[Symbol.asyncIterator]?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
typeof source.pipeTo === 'function'
|
|
74
|
+
|| typeof source.pipe === 'function'
|
|
75
|
+
|| typeof source.next === 'function'
|
|
76
|
+
|| typeof source[Symbol.iterator] === 'function'
|
|
77
|
+
|| typeof source[Symbol.asyncIterator] === 'function'
|
|
78
|
+
) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return source.constructor?.name === 'Object'
|
|
83
|
+
|| typeof source.toJSON === 'function'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function runSingleFlight<T>(
|
|
87
|
+
activeRequests: WeakMap<Config, Promise<T>>,
|
|
88
|
+
config: Config,
|
|
89
|
+
request: () => Promise<T>
|
|
90
|
+
): Promise<T> {
|
|
91
|
+
const activeRequest = activeRequests.get(config)
|
|
92
|
+
if (activeRequest) {
|
|
93
|
+
return activeRequest
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nextRequest = Promise.resolve().then(request)
|
|
97
|
+
activeRequests.set(config, nextRequest)
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return await nextRequest
|
|
101
|
+
} finally {
|
|
102
|
+
if (activeRequests.get(config) === nextRequest) {
|
|
103
|
+
activeRequests.delete(config)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
9
107
|
|
|
10
108
|
export class Http {
|
|
11
109
|
private config: Config
|
|
@@ -22,16 +120,77 @@ export class Http {
|
|
|
22
120
|
let xsrfToken = parseCookie(document.cookie)['XSRF-TOKEN']
|
|
23
121
|
|
|
24
122
|
if (!xsrfToken) {
|
|
25
|
-
await this.
|
|
123
|
+
await this.refreshXsrfToken()
|
|
26
124
|
xsrfToken = parseCookie(document.cookie)['XSRF-TOKEN']
|
|
27
125
|
}
|
|
28
126
|
|
|
29
127
|
return xsrfToken
|
|
30
128
|
}
|
|
31
129
|
|
|
32
|
-
private
|
|
130
|
+
private isApplicationRequest(url: string, options: HttpRequestOptions = {}): boolean {
|
|
131
|
+
const pageUrl = typeof location === 'undefined' ? undefined : location.href
|
|
132
|
+
const applicationBaseUrl = this.config.baseUrl || pageUrl
|
|
133
|
+
const requestBaseUrl = Object.hasOwn(options, 'baseURL')
|
|
134
|
+
? options.baseURL
|
|
135
|
+
: this.config.baseUrl
|
|
136
|
+
if (
|
|
137
|
+
!applicationBaseUrl
|
|
138
|
+
|| (!pageUrl && isLocalUrlReference(applicationBaseUrl))
|
|
139
|
+
) {
|
|
140
|
+
return isLocalUrlReference(url)
|
|
141
|
+
&& (requestBaseUrl == null || isLocalUrlReference(requestBaseUrl))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const applicationUrl = new URL(applicationBaseUrl, pageUrl)
|
|
146
|
+
const resolvedRequestBaseUrl = requestBaseUrl
|
|
147
|
+
? new URL(requestBaseUrl, pageUrl)
|
|
148
|
+
: pageUrl
|
|
149
|
+
|
|
150
|
+
return new URL(url, resolvedRequestBaseUrl).origin === applicationUrl.origin
|
|
151
|
+
} catch {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async refreshXsrfToken(): Promise<void> {
|
|
157
|
+
const xsrfUrl = this.config.xsrfUrl
|
|
158
|
+
if (!xsrfUrl) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return runSingleFlight(xsrfRefreshes, this.config, async () => {
|
|
163
|
+
await this.config.client(...(await this.buildRequest(
|
|
164
|
+
xsrfUrl,
|
|
165
|
+
{ method: 'HEAD' }
|
|
166
|
+
)))
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async recoverSession(): Promise<boolean> {
|
|
171
|
+
const recoverSession = this.config.recoverSession
|
|
172
|
+
if (!recoverSession) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return runSingleFlight(sessionRecoveries, this.config, async () => {
|
|
177
|
+
const recovered = await recoverSession()
|
|
178
|
+
if (recovered) {
|
|
179
|
+
sessionRecoveryGenerations.set(
|
|
180
|
+
this.config,
|
|
181
|
+
getSessionRecoveryGeneration(this.config) + 1
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return recovered
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async buildRequest(url: string, _options: HttpRequestOptions = {}): Promise<BuiltRequest> {
|
|
33
189
|
const { method, params, query, ...options } = _options
|
|
34
|
-
|
|
190
|
+
delete options.sessionRecovery
|
|
191
|
+
|
|
192
|
+
const xsrfToken = this.isApplicationRequest(url, _options)
|
|
193
|
+
&& ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '')
|
|
35
194
|
&& (await this.ensureXsrfToken())
|
|
36
195
|
|
|
37
196
|
const queryString = stringify(
|
|
@@ -57,25 +216,90 @@ export class Http {
|
|
|
57
216
|
]
|
|
58
217
|
}
|
|
59
218
|
|
|
60
|
-
private async
|
|
61
|
-
|
|
219
|
+
private async execute<T>(
|
|
220
|
+
url: string,
|
|
221
|
+
options: HttpRequestOptions,
|
|
222
|
+
send: (request: BuiltRequest) => Promise<T>
|
|
223
|
+
): Promise<T> {
|
|
224
|
+
const applicationRequest = this.isApplicationRequest(url, options)
|
|
225
|
+
const replayable = isReplayableBody(options.body)
|
|
226
|
+
|
|
227
|
+
const attempt = async (
|
|
228
|
+
xsrfRecovered = false,
|
|
229
|
+
sessionRecovered = false
|
|
230
|
+
): Promise<T> => {
|
|
231
|
+
const request = await this.buildRequest(url, options)
|
|
232
|
+
const sessionRecoveryGeneration = getSessionRecoveryGeneration(this.config)
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return await send(request)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const status = getHttpStatusCode(error)
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
status === 419
|
|
241
|
+
&& applicationRequest
|
|
242
|
+
&& replayable
|
|
243
|
+
&& this.config.xsrfUrl
|
|
244
|
+
&& !xsrfRecovered
|
|
245
|
+
) {
|
|
246
|
+
await this.refreshXsrfToken()
|
|
247
|
+
return attempt(true, sessionRecovered)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const canRecoverSession =
|
|
251
|
+
applicationRequest
|
|
252
|
+
&& replayable
|
|
253
|
+
&& options.sessionRecovery !== false
|
|
254
|
+
&& this.config.recoverSession != null
|
|
255
|
+
&& !sessionRecovered
|
|
256
|
+
|
|
257
|
+
if (status === 401 && canRecoverSession) {
|
|
258
|
+
const alreadyRecovered =
|
|
259
|
+
sessionRecoveryGeneration !== getSessionRecoveryGeneration(this.config)
|
|
260
|
+
|
|
261
|
+
if (alreadyRecovered || await this.recoverSession()) {
|
|
262
|
+
return attempt(xsrfRecovered, true)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return attempt()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async performRequest<T>(url: string, options: HttpRequestOptions = {}): Promise<T> {
|
|
274
|
+
return this.execute(
|
|
275
|
+
url,
|
|
276
|
+
options,
|
|
277
|
+
(request) => this.config.client(...request)
|
|
278
|
+
)
|
|
62
279
|
}
|
|
63
280
|
|
|
64
|
-
private async performRequestRaw<T>(
|
|
281
|
+
private async performRequestRaw<T>(
|
|
282
|
+
url: string,
|
|
283
|
+
options: HttpRequestOptions = {}
|
|
284
|
+
): Promise<FetchResponse<T>> {
|
|
65
285
|
// 'raw' is unavailable in useRequestFetch() during SSR, but performRequestRaw is only
|
|
66
286
|
// called by download, which runs client-side, so asserting raw's existence is safe
|
|
67
|
-
return this.
|
|
287
|
+
return this.execute(
|
|
288
|
+
url,
|
|
289
|
+
options,
|
|
290
|
+
(request) => this.config.client.raw!(...request)
|
|
291
|
+
)
|
|
68
292
|
}
|
|
69
293
|
|
|
70
|
-
async get<T = any>(url: string, options?:
|
|
294
|
+
async get<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
71
295
|
return this.performRequest<T>(url, { method: 'GET', ...options })
|
|
72
296
|
}
|
|
73
297
|
|
|
74
|
-
async head<T = any>(url: string, options?:
|
|
298
|
+
async head<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
75
299
|
return this.performRequest<T>(url, { method: 'HEAD', ...options })
|
|
76
300
|
}
|
|
77
301
|
|
|
78
|
-
async post<T = any>(url: string, body?: any, options?:
|
|
302
|
+
async post<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
79
303
|
if (body && !(body instanceof FormData)) {
|
|
80
304
|
let hasFile = false
|
|
81
305
|
|
|
@@ -99,23 +323,23 @@ export class Http {
|
|
|
99
323
|
return this.performRequest<T>(url, { method: 'POST', body, ...options })
|
|
100
324
|
}
|
|
101
325
|
|
|
102
|
-
async put<T = any>(url: string, body?: any, options?:
|
|
326
|
+
async put<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
103
327
|
return this.performRequest<T>(url, { method: 'PUT', body, ...options })
|
|
104
328
|
}
|
|
105
329
|
|
|
106
|
-
async patch<T = any>(url: string, body?: any, options?:
|
|
330
|
+
async patch<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
107
331
|
return this.performRequest<T>(url, { method: 'PATCH', body, ...options })
|
|
108
332
|
}
|
|
109
333
|
|
|
110
|
-
async delete<T = any>(url: string, options?:
|
|
334
|
+
async delete<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
111
335
|
return this.performRequest<T>(url, { method: 'DELETE', ...options })
|
|
112
336
|
}
|
|
113
337
|
|
|
114
|
-
async upload<T = any>(url: string, body?: any, options?:
|
|
338
|
+
async upload<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
115
339
|
return this.post<T>(url, objectToFormData(body), options)
|
|
116
340
|
}
|
|
117
341
|
|
|
118
|
-
async download(url: string, options?:
|
|
342
|
+
async download(url: string, options?: HttpRequestOptions): Promise<void> {
|
|
119
343
|
const { _data: blob, headers } =
|
|
120
344
|
await this.performRequestRaw<Blob>(url, { method: 'GET', responseType: 'blob', ...options })
|
|
121
345
|
|
package/lib/stores/HttpConfig.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface HttpOptions {
|
|
|
15
15
|
baseUrl?: string
|
|
16
16
|
xsrfUrl?: string | false
|
|
17
17
|
client?: HttpClient
|
|
18
|
+
recoverSession?: false | (() => Awaitable<boolean>)
|
|
18
19
|
lang?: Lang
|
|
19
20
|
payloadKey?: string
|
|
20
21
|
headers?: () => Awaitable<Record<string, string>>
|
|
@@ -25,6 +26,7 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
25
26
|
const baseUrl = ref<string | undefined>(undefined)
|
|
26
27
|
const xsrfUrl = ref<string | false>('/api/csrf-cookie')
|
|
27
28
|
const client = ref<HttpClient>(ofetch)
|
|
29
|
+
const recoverSession = ref<(() => Awaitable<boolean>) | undefined>(undefined)
|
|
28
30
|
const lang = ref<Lang | undefined>(undefined)
|
|
29
31
|
const payloadKey = ref<string>('__payload__')
|
|
30
32
|
const headers = ref<() => Awaitable<Record<string, string>>>(async () => ({}))
|
|
@@ -34,6 +36,9 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
34
36
|
if (options.baseUrl != null) { baseUrl.value = options.baseUrl }
|
|
35
37
|
if (options.xsrfUrl != null) { xsrfUrl.value = options.xsrfUrl }
|
|
36
38
|
if (options.client != null) { client.value = options.client }
|
|
39
|
+
if (options.recoverSession != null) {
|
|
40
|
+
recoverSession.value = options.recoverSession || undefined
|
|
41
|
+
}
|
|
37
42
|
if (options.lang != null) { lang.value = options.lang }
|
|
38
43
|
if (options.payloadKey != null) { payloadKey.value = options.payloadKey }
|
|
39
44
|
if (options.headers != null) { headers.value = options.headers }
|
|
@@ -44,6 +49,7 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
44
49
|
baseUrl: computed(() => baseUrl.value),
|
|
45
50
|
xsrfUrl: computed(() => xsrfUrl.value),
|
|
46
51
|
client: computed(() => client.value),
|
|
52
|
+
recoverSession: computed(() => recoverSession.value),
|
|
47
53
|
lang: computed(() => lang.value),
|
|
48
54
|
payloadKey: computed(() => payloadKey.value),
|
|
49
55
|
headers: computed(() => headers.value),
|