@kernhq/module-quire 0.12.0 → 0.13.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.
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +1 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +86 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +73 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +56 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +86 -14
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/services/publications.d.ts +36 -22
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +226 -7
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/versions.d.ts +22 -1
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +44 -15
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0009_public_asset_references.sql +35 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/PublishDialog.svelte +75 -12
- package/src/client/components/SidebarRecents.svelte +17 -1
- package/src/client/i18n.ts +14 -0
- package/src/client/mock.ts +10 -0
- package/src/client/pages/PageView.svelte +11 -11
- package/src/client/public-url.ts +25 -13
- package/src/contract/permissions.ts +1 -0
- package/src/contract/router.ts +78 -0
|
@@ -36,12 +36,11 @@ import { quireKeys } from '../query.js'
|
|
|
36
36
|
interface Props {
|
|
37
37
|
open?: boolean
|
|
38
38
|
workspaceId: string
|
|
39
|
-
workspaceSlug: string
|
|
40
39
|
spaceId: string
|
|
41
40
|
/** the page the site would be rooted at — its own published version is the front page */
|
|
42
41
|
page: Pick<Page, 'id' | 'title' | 'publishedVersionId'>
|
|
43
42
|
}
|
|
44
|
-
let { open = $bindable(false), workspaceId,
|
|
43
|
+
let { open = $bindable(false), workspaceId, spaceId, page }: Props = $props()
|
|
45
44
|
|
|
46
45
|
const api = getQuireApi()
|
|
47
46
|
const client = useQueryClient()
|
|
@@ -163,6 +162,16 @@ $effect(() => {
|
|
|
163
162
|
|
|
164
163
|
const slugValid = $derived(slug.length >= 2 && slug.length <= 64 && SLUG_PATTERN.test(slug))
|
|
165
164
|
|
|
165
|
+
/**
|
|
166
|
+
* A site that exists and serves nothing, which is a third state and used to be told as the first.
|
|
167
|
+
*
|
|
168
|
+
* The publication row and the root page's published version are independent: making the one does
|
|
169
|
+
* not publish the other, so "there is a publication" is not "this page is public". Every sentence
|
|
170
|
+
* in this dialog that used to branch on `publication` alone said the wrong thing for as long as
|
|
171
|
+
* somebody had made a site out of a page they had not published.
|
|
172
|
+
*/
|
|
173
|
+
const emptySite = $derived(publication !== null && page.publishedVersionId === null)
|
|
174
|
+
|
|
166
175
|
/**
|
|
167
176
|
* The address that is **actually serving**, not the one in the box.
|
|
168
177
|
*
|
|
@@ -172,11 +181,11 @@ const slugValid = $derived(slug.length >= 2 && slug.length <= 64 && SLUG_PATTERN
|
|
|
172
181
|
* be wrong about, so it follows the field — that is the whole point of showing it then.
|
|
173
182
|
*/
|
|
174
183
|
const url = $derived(
|
|
175
|
-
publicSiteUrl({
|
|
184
|
+
publicSiteUrl({ workspaceId, slug: publication?.slug ?? (slugValid ? slug : 'your-page') }),
|
|
176
185
|
)
|
|
177
186
|
/** The address it becomes on save — shown as a sentence, so there are never two links on screen. */
|
|
178
187
|
const pendingUrl = $derived(
|
|
179
|
-
publication && slugValid && slug !== publication.slug ? publicSiteUrl({
|
|
188
|
+
publication && slugValid && slug !== publication.slug ? publicSiteUrl({ workspaceId, slug }) : null,
|
|
180
189
|
)
|
|
181
190
|
|
|
182
191
|
/**
|
|
@@ -266,6 +275,33 @@ function reasonFor(row: Descendant): string | null {
|
|
|
266
275
|
let busy = false
|
|
267
276
|
let rowsBusy = $state<string[]>([])
|
|
268
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Where focus goes when the confirmation arms and disarms.
|
|
280
|
+
*
|
|
281
|
+
* Pressing "Unpublish" destroys the button being pressed and draws two others in its place, so the
|
|
282
|
+
* browser has nothing to keep focus on and the dialog's own trap hands it to the **Close** button —
|
|
283
|
+
* eight Tab stops away from the confirmation, and one Enter away from dismissing the whole dialog.
|
|
284
|
+
* A keyboard user pressing Enter twice, which is what confirming feels like, closes the dialog and
|
|
285
|
+
* leaves the site published with nothing said. Same family as disabling a focused control, and it
|
|
286
|
+
* has the same fix: put focus somewhere deliberate rather than letting it fall.
|
|
287
|
+
*
|
|
288
|
+
* It lands on **Cancel**, not on the confirming button: a repeated Enter must not be able to take a
|
|
289
|
+
* site down, and Cancel is one Tab from the answer either way. Disarming sends it back to the
|
|
290
|
+
* control that armed it, so the round trip leaves the keyboard where it started.
|
|
291
|
+
*/
|
|
292
|
+
let confirmRef = $state<HTMLElement | null>(null)
|
|
293
|
+
let unpublishRef = $state<HTMLElement | null>(null)
|
|
294
|
+
/* A plain `let`, like `busy`: it records what the last run saw and must not be a dependency of the
|
|
295
|
+
effect that writes it. Equal values mean this run is a re-render rather than a change, and the
|
|
296
|
+
first run is always equal — so opening the dialog never steals focus from the address field. */
|
|
297
|
+
let confirmWas = false
|
|
298
|
+
$effect(() => {
|
|
299
|
+
const asking = confirmingUnpublish
|
|
300
|
+
if (confirmWas === asking) return
|
|
301
|
+
confirmWas = asking
|
|
302
|
+
;(asking ? confirmRef : unpublishRef)?.focus()
|
|
303
|
+
})
|
|
304
|
+
|
|
269
305
|
const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
|
|
270
306
|
|
|
271
307
|
async function refresh() {
|
|
@@ -287,7 +323,9 @@ async function publish() {
|
|
|
287
323
|
password = ''
|
|
288
324
|
dropPassword = false
|
|
289
325
|
await refresh()
|
|
290
|
-
toast.success(
|
|
326
|
+
toast.success(
|
|
327
|
+
page.publishedVersionId === null ? t('share_published_empty_toast') : t('share_published_toast'),
|
|
328
|
+
)
|
|
291
329
|
} catch (err) {
|
|
292
330
|
error = message(err)
|
|
293
331
|
} finally {
|
|
@@ -377,10 +415,14 @@ async function copyLink() {
|
|
|
377
415
|
It is not a warning that appears when something is wrong — it is a description of what the
|
|
378
416
|
button does, and it stays true after the button has been pressed.
|
|
379
417
|
-->
|
|
380
|
-
<section class="tell" class:live={publication !== null}>
|
|
381
|
-
<Icon name={publication ? 'globe' : 'triangle-alert'} size={17} />
|
|
418
|
+
<section class="tell" class:live={publication !== null && !emptySite}>
|
|
419
|
+
<Icon name={publication && !emptySite ? 'globe' : 'triangle-alert'} size={17} />
|
|
382
420
|
<div class="tell-body">
|
|
383
|
-
<p class="tell-title">
|
|
421
|
+
<p class="tell-title">
|
|
422
|
+
{#if emptySite}{t('share_published_empty')}
|
|
423
|
+
{:else if publication}{t('share_published')}
|
|
424
|
+
{:else}{t('share_warn_title')}{/if}
|
|
425
|
+
</p>
|
|
384
426
|
<p>{t('share_warn_body')}</p>
|
|
385
427
|
<p>{t('share_warn_version')}</p>
|
|
386
428
|
<p>{t('share_warn_unpublished')}</p>
|
|
@@ -388,7 +430,15 @@ async function copyLink() {
|
|
|
388
430
|
</div>
|
|
389
431
|
</section>
|
|
390
432
|
|
|
391
|
-
|
|
433
|
+
<!--
|
|
434
|
+
Not gated on there being no publication yet, which is what made this dialog say three things
|
|
435
|
+
at once and mean two of them. Publishing a page that has never been published put "This page
|
|
436
|
+
is public" at the top and a success toast over it, while the measured line forty pixels below
|
|
437
|
+
read "A signed-out visitor cannot open this address yet" — the loudest affordances asserting
|
|
438
|
+
what only the quietest one had checked. The site being made does not make the page published,
|
|
439
|
+
so the sentence that says so belongs on screen for exactly as long as it is true.
|
|
440
|
+
-->
|
|
441
|
+
{#if page.publishedVersionId === null}
|
|
392
442
|
<p class="caution" role="status">{t('share_needs_publish')}</p>
|
|
393
443
|
{/if}
|
|
394
444
|
|
|
@@ -612,15 +662,28 @@ async function copyLink() {
|
|
|
612
662
|
<Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
|
|
613
663
|
{:else if publication}
|
|
614
664
|
{#if confirmingUnpublish}
|
|
615
|
-
|
|
616
|
-
|
|
665
|
+
<!-- `alert`, because the question is drawn where nobody is looking: it appears at the
|
|
666
|
+
foot of a long dialog, and a reader who is not watching that corner is given no other
|
|
667
|
+
sign that pressing the button asked something rather than doing it. -->
|
|
668
|
+
<p class="ask" role="alert">{t('share_unpublish_ask')}</p>
|
|
669
|
+
<Button
|
|
670
|
+
bind:ref={confirmRef}
|
|
671
|
+
variant="secondary"
|
|
672
|
+
size="sm"
|
|
673
|
+
onclick={() => (confirmingUnpublish = false)}
|
|
674
|
+
>
|
|
617
675
|
{t('cancel')}
|
|
618
676
|
</Button>
|
|
619
677
|
<Button variant="danger" size="sm" onclick={() => void unpublish()}>
|
|
620
678
|
{t('share_unpublish')}
|
|
621
679
|
</Button>
|
|
622
680
|
{:else}
|
|
623
|
-
<Button
|
|
681
|
+
<Button
|
|
682
|
+
bind:ref={unpublishRef}
|
|
683
|
+
variant="danger"
|
|
684
|
+
size="sm"
|
|
685
|
+
onclick={() => (confirmingUnpublish = true)}
|
|
686
|
+
>
|
|
624
687
|
{t('share_unpublish')}
|
|
625
688
|
</Button>
|
|
626
689
|
<span class="spacer"></span>
|
|
@@ -67,7 +67,9 @@ function open(entry: RecentEntry) {
|
|
|
67
67
|
A relative time, not a date: this list is only ever read as "how long ago", and
|
|
68
68
|
`relativeTime` renders it in the interface language rather than as a raw number.
|
|
69
69
|
-->
|
|
70
|
-
<span class="when"
|
|
70
|
+
<span class="when" class:on-active={activePageId === entry.pageId}>
|
|
71
|
+
{relativeTime(entry.viewedAt)}
|
|
72
|
+
</span>
|
|
71
73
|
{/snippet}
|
|
72
74
|
</SidebarItem>
|
|
73
75
|
{/each}
|
|
@@ -87,6 +89,17 @@ function open(entry: RecentEntry) {
|
|
|
87
89
|
/*
|
|
88
90
|
* Muted with a colour, never with `opacity` — and never smaller than 11.5px, which is where this
|
|
89
91
|
* pane's ink stops being legible against its own background.
|
|
92
|
+
*
|
|
93
|
+
* **"Its own background" is two backgrounds, and the second one is the row you are standing on.**
|
|
94
|
+
* `SidebarItem` paints an active row `--kern-ink-900` and switches its text to
|
|
95
|
+
* `--kern-ink-inverse`; a timestamp that kept its own muted ink through that landed at **2.83:1 in
|
|
96
|
+
* light and 2.50:1 in dark** — mid-grey on near-black, on the row of the page the reader currently
|
|
97
|
+
* has open, in every locale. Off the active row the same colour is 4.88:1 or better against every
|
|
98
|
+
* ground this pane uses, which is why the token was never the thing that was wrong.
|
|
99
|
+
*
|
|
100
|
+
* So the active row inherits instead of overriding: `--kern-ink-inverse` on `--kern-ink-900` is
|
|
101
|
+
* 16.63:1 light and 15.52:1 dark. Inheriting rather than naming a second colour is also what keeps
|
|
102
|
+
* the two in step if the active row is ever repainted.
|
|
90
103
|
*/
|
|
91
104
|
.when {
|
|
92
105
|
flex: none;
|
|
@@ -95,4 +108,7 @@ function open(entry: RecentEntry) {
|
|
|
95
108
|
color: var(--kern-ink-350);
|
|
96
109
|
letter-spacing: -0.01em;
|
|
97
110
|
}
|
|
111
|
+
.when.on-active {
|
|
112
|
+
color: inherit;
|
|
113
|
+
}
|
|
98
114
|
</style>
|
package/src/client/i18n.ts
CHANGED
|
@@ -474,6 +474,9 @@ export const en: Record<string, Message> = {
|
|
|
474
474
|
'quire.share_published': 'This page is public',
|
|
475
475
|
'quire.share_published_when': 'Published {when}',
|
|
476
476
|
'quire.share_published_toast': 'This page is public. Anyone with the link can read it.',
|
|
477
|
+
'quire.share_published_empty': 'The site is made, and it has nothing to show',
|
|
478
|
+
'quire.share_published_empty_toast':
|
|
479
|
+
'The address works, but this page has never been published, so there is nothing at it yet.',
|
|
477
480
|
'quire.share_check_running': 'Checking what a signed-out visitor sees…',
|
|
478
481
|
'quire.share_check_ok': {
|
|
479
482
|
one: 'A signed-out visitor sees {n} page.',
|
|
@@ -1002,6 +1005,8 @@ const ar: Record<string, Message> = {
|
|
|
1002
1005
|
'quire.share_published': 'هذه الصفحة عامة',
|
|
1003
1006
|
'quire.share_published_when': 'نُشرت {when}',
|
|
1004
1007
|
'quire.share_published_toast': 'هذه الصفحة عامة الآن. كل من يملك الرابط يقرؤها.',
|
|
1008
|
+
'quire.share_published_empty': 'أُنشئ الموقع، ولا شيء لديه ليعرضه',
|
|
1009
|
+
'quire.share_published_empty_toast': 'العنوان يعمل، لكن هذه الصفحة لم تُنشر قط، فلا يوجد عندها شيء بعد.',
|
|
1005
1010
|
'quire.share_check_running': 'جارٍ التحقّق ممّا يراه زائر غير مسجّل…',
|
|
1006
1011
|
'quire.share_check_ok': {
|
|
1007
1012
|
zero: 'الزائر غير المسجّل لا يرى أي صفحة.',
|
|
@@ -1501,6 +1506,9 @@ const de: Record<string, Message> = {
|
|
|
1501
1506
|
'quire.share_published': 'Diese Seite ist öffentlich',
|
|
1502
1507
|
'quire.share_published_when': 'Veröffentlicht {when}',
|
|
1503
1508
|
'quire.share_published_toast': 'Diese Seite ist öffentlich. Wer den Link hat, kann sie lesen.',
|
|
1509
|
+
'quire.share_published_empty': 'Die Website steht, und sie hat nichts zu zeigen',
|
|
1510
|
+
'quire.share_published_empty_toast':
|
|
1511
|
+
'Die Adresse funktioniert, aber diese Seite wurde nie veröffentlicht — dort ist also noch nichts.',
|
|
1504
1512
|
'quire.share_check_running': 'Wird geprüft, was ein abgemeldeter Besuch sieht …',
|
|
1505
1513
|
'quire.share_check_ok': {
|
|
1506
1514
|
one: 'Ein abgemeldeter Besuch sieht {n} Seite.',
|
|
@@ -1986,6 +1994,9 @@ const fa: Record<string, Message> = {
|
|
|
1986
1994
|
'quire.share_published': 'این صفحه عمومی است',
|
|
1987
1995
|
'quire.share_published_when': '{when} منتشر شد',
|
|
1988
1996
|
'quire.share_published_toast': 'این صفحه عمومی شد. هر کس پیوند را داشته باشد میخواند.',
|
|
1997
|
+
'quire.share_published_empty': 'سایت ساخته شد و چیزی برای نشاندادن ندارد',
|
|
1998
|
+
'quire.share_published_empty_toast':
|
|
1999
|
+
'نشانی کار میکند، اما این صفحه هرگز منتشر نشده است، پس هنوز چیزی در آن نیست.',
|
|
1989
2000
|
'quire.share_check_running': 'بررسی آنچه بازدیدکنندهٔ واردنشده میبیند…',
|
|
1990
2001
|
'quire.share_check_ok': {
|
|
1991
2002
|
one: 'بازدیدکنندهٔ واردنشده {n} صفحه میبیند.',
|
|
@@ -2473,6 +2484,9 @@ const tr: Record<string, Message> = {
|
|
|
2473
2484
|
'quire.share_published': 'Bu sayfa herkese açık',
|
|
2474
2485
|
'quire.share_published_when': '{when} yayımlandı',
|
|
2475
2486
|
'quire.share_published_toast': 'Bu sayfa herkese açık. Bağlantısı olan herkes okuyabilir.',
|
|
2487
|
+
'quire.share_published_empty': 'Site kuruldu ve gösterecek bir şeyi yok',
|
|
2488
|
+
'quire.share_published_empty_toast':
|
|
2489
|
+
'Adres çalışıyor, ama bu sayfa hiç yayımlanmadı; dolayısıyla orada henüz bir şey yok.',
|
|
2476
2490
|
'quire.share_check_running': 'Oturum açmamış bir ziyaretçinin ne gördüğü denetleniyor…',
|
|
2477
2491
|
'quire.share_check_ok': {
|
|
2478
2492
|
one: 'Oturum açmamış bir ziyaretçi {n} sayfa görüyor.',
|
package/src/client/mock.ts
CHANGED
|
@@ -1631,6 +1631,16 @@ export function createMockQuireApi() {
|
|
|
1631
1631
|
return { indexable: true, sitemapPath: 'sitemap.xml' }
|
|
1632
1632
|
},
|
|
1633
1633
|
|
|
1634
|
+
/*
|
|
1635
|
+
* There are no files behind the mock, so nothing resolves — and that is the honest answer
|
|
1636
|
+
* rather than a gap. `publicHtmlOf` draws a version's preview as one paragraph and never
|
|
1637
|
+
* emits an `<img>`, so no page here carries a reference to resolve; a demo that invented
|
|
1638
|
+
* bytes would be showing a picture the real surface would have refused.
|
|
1639
|
+
*/
|
|
1640
|
+
asset: async () => {
|
|
1641
|
+
throw notFound('Asset')
|
|
1642
|
+
},
|
|
1643
|
+
|
|
1634
1644
|
unlock: async ({ slug, password }: { slug: string; password: string }) => {
|
|
1635
1645
|
const pub = publicationBySlug(slug)
|
|
1636
1646
|
// A site with no password has no door, and saying so would confirm the slug exists.
|
|
@@ -314,7 +314,7 @@ const publicVia = $derived.by((): { slug: string; root: boolean } | null => {
|
|
|
314
314
|
return null
|
|
315
315
|
})
|
|
316
316
|
|
|
317
|
-
const publicUrl = $derived(publicVia ? publicSiteUrl({
|
|
317
|
+
const publicUrl = $derived(publicVia ? publicSiteUrl({ workspaceId, slug: publicVia.slug }) : '')
|
|
318
318
|
|
|
319
319
|
// -----------------------------------------------------------------------------------------------
|
|
320
320
|
// Watching, recording, and the way this page is deleted
|
|
@@ -755,13 +755,7 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
755
755
|
publishedVersionId={doc.publishedVersionId}
|
|
756
756
|
/>
|
|
757
757
|
|
|
758
|
-
<PublishDialog
|
|
759
|
-
bind:open={shareOpen}
|
|
760
|
-
{workspaceId}
|
|
761
|
-
{workspaceSlug}
|
|
762
|
-
spaceId={doc.spaceId}
|
|
763
|
-
page={doc}
|
|
764
|
-
/>
|
|
758
|
+
<PublishDialog bind:open={shareOpen} {workspaceId} spaceId={doc.spaceId} page={doc} />
|
|
765
759
|
|
|
766
760
|
<!--
|
|
767
761
|
The body says nothing about numbers until it knows them. Naming a count before the tree has
|
|
@@ -835,8 +829,14 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
835
829
|
}
|
|
836
830
|
/*
|
|
837
831
|
* The one chip that is a control, so it is the one that reads as one — a tinted pill with a hit
|
|
838
|
-
* area a finger can find.
|
|
839
|
-
*
|
|
832
|
+
* area a finger can find.
|
|
833
|
+
*
|
|
834
|
+
* `--kern-accent-deep`, and the arithmetic rather than the impression. On `--kern-accent-tint` the
|
|
835
|
+
* flat accent is a fill colour and nowhere near readable; `--kern-accent-text` looks like the
|
|
836
|
+
* answer and measures **4.22:1 in the light palette** (#a85a18 on #f3e9da) against the 4.5:1 this
|
|
837
|
+
* text needs at 13px/500 — it only passes in the dark one, which is how it shipped. The deep tone
|
|
838
|
+
* is 5.94:1 light and 5.95:1 dark, and 6.01/5.69 on the hover tint. Computed against the surface
|
|
839
|
+
* this actually sits on, in both palettes, because a colour pair is arithmetic and not taste.
|
|
840
840
|
*/
|
|
841
841
|
.chip.public {
|
|
842
842
|
gap: 5px;
|
|
@@ -844,7 +844,7 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
844
844
|
padding-inline: 8px;
|
|
845
845
|
border-radius: var(--kern-r-full);
|
|
846
846
|
background: var(--kern-accent-tint);
|
|
847
|
-
color: var(--kern-accent-
|
|
847
|
+
color: var(--kern-accent-deep);
|
|
848
848
|
font-weight: 500;
|
|
849
849
|
text-decoration: none;
|
|
850
850
|
}
|
package/src/client/public-url.ts
CHANGED
|
@@ -16,16 +16,25 @@
|
|
|
16
16
|
* a workspace. The cost is that a workspace whose slug is exactly `p` would collide, which is why
|
|
17
17
|
* the segment is here as a constant rather than typed out at three call sites.
|
|
18
18
|
*
|
|
19
|
-
* The workspace is named by
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* **The workspace is named by id, and it was named by slug until that address stopped resolving.**
|
|
20
|
+
* A slug is the nicer half of the trade — a customer publishing a handbook is publishing a URL they
|
|
21
|
+
* will print — and it is only nicer if it works. Turning a slug into the workspace id the public
|
|
22
|
+
* procedures require is a lookup nothing can do signed out: every workspace read in `core` is
|
|
23
|
+
* behind a membership check, and a published site has no member reading it. So the route layer
|
|
24
|
+
* accepts the id form and refuses everything else, and this dialog was handing customers an address
|
|
25
|
+
* that answered 404 in every deployment that is not the mock — measured by copying the link out of
|
|
26
|
+
* the share dialog and fetching it.
|
|
27
|
+
*
|
|
28
|
+
* The id is not a secret: it is already the first segment of the API path the address resolves to,
|
|
29
|
+
* and it names a tenant rather than anything inside one. When `core` grows a signed-out
|
|
30
|
+
* `workspaces.publicBySlug`, the slug form becomes correct as well and this is the one function
|
|
31
|
+
* that has to change.
|
|
23
32
|
*/
|
|
24
33
|
export const PUBLIC_SITE_PREFIX = 'p'
|
|
25
34
|
|
|
26
35
|
export interface PublicSiteAddress {
|
|
27
|
-
/** the workspace's
|
|
28
|
-
|
|
36
|
+
/** the workspace's id, which is what the public procedures resolve a tenant by */
|
|
37
|
+
workspaceId: string
|
|
29
38
|
/** the publication's slug */
|
|
30
39
|
slug: string
|
|
31
40
|
/**
|
|
@@ -36,14 +45,16 @@ export interface PublicSiteAddress {
|
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/**
|
|
39
|
-
*
|
|
48
|
+
* `/p/<workspace>/<publication>` — **no trailing slash**, both segments already encoded.
|
|
49
|
+
*
|
|
50
|
+
* The route serves the canonical form without one and answers the trailing-slash form with a 308,
|
|
51
|
+
* so a base that ended in `/` put an extra hop into every link a customer printed or pasted.
|
|
40
52
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* path, and a caller who could set it would repoint every link on somebody's published site.
|
|
53
|
+
* This is not the `basePath` argument `public.page` takes: that one has to start *and* end with a
|
|
54
|
+
* slash, and the route layer builds it from this by adding one.
|
|
44
55
|
*/
|
|
45
|
-
export function publicSiteBasePath({
|
|
46
|
-
return `/${PUBLIC_SITE_PREFIX}/${encodeURIComponent(
|
|
56
|
+
export function publicSiteBasePath({ workspaceId, slug }: PublicSiteAddress): string {
|
|
57
|
+
return `/${PUBLIC_SITE_PREFIX}/${encodeURIComponent(workspaceId)}/${encodeURIComponent(slug)}`
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/**
|
|
@@ -60,5 +71,6 @@ export function publicSiteUrl(address: PublicSiteAddress, origin?: string): stri
|
|
|
60
71
|
.filter((segment) => segment.length > 0)
|
|
61
72
|
.map(encodeURIComponent)
|
|
62
73
|
.join('/')
|
|
63
|
-
|
|
74
|
+
const base = `${root}${publicSiteBasePath(address)}`
|
|
75
|
+
return trail ? `${base}/${trail}` : base
|
|
64
76
|
}
|
|
@@ -242,5 +242,6 @@ export const quireProcedureAuthz: Record<string, ProcedureAuthz> = {
|
|
|
242
242
|
'public.search': { check: 'public', permission: 'quire.page.publish' },
|
|
243
243
|
'public.sitemap': { check: 'public', permission: 'quire.page.publish' },
|
|
244
244
|
'public.robots': { check: 'public', permission: 'quire.page.publish' },
|
|
245
|
+
'public.asset': { check: 'public', permission: 'quire.page.publish' },
|
|
245
246
|
'public.unlock': { check: 'public', permission: 'quire.page.publish' },
|
|
246
247
|
}
|
package/src/contract/router.ts
CHANGED
|
@@ -168,6 +168,44 @@ export type PublicSearchHit = z.infer<typeof PublicSearchHit>
|
|
|
168
168
|
|
|
169
169
|
export const PublicSitemapEntry = z.object({ path: z.string(), lastModified: Timestamp })
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* One picture from a published page, **as bytes rather than as an address**.
|
|
173
|
+
*
|
|
174
|
+
* A published page used to carry its pictures as presigned storage URLs, written into the stored
|
|
175
|
+
* HTML at publish time. That was wrong twice over, and both halves were measured rather than
|
|
176
|
+
* argued. The URL is the storage key — `ws/<workspaceId>/<module>/<yyyy>/<mm>/<fileId>/<name>` —
|
|
177
|
+
* so every published page with a picture on it handed a stranger the tenant's workspace uuid and a
|
|
178
|
+
* file uuid, on the one surface whose whole rule is that no response carries an id; and a presigned
|
|
179
|
+
* GET expires in an hour while the HTML it was baked into is rendered once and stored for ever, so
|
|
180
|
+
* every image on every published site broke sixty minutes after it was published.
|
|
181
|
+
*
|
|
182
|
+
* So the HTML carries an opaque, workspace-sealed reference and the bytes come through here. The
|
|
183
|
+
* route layer fetches this from **its own server** and streams the body back under a URL of its
|
|
184
|
+
* own; nothing in the answer may reach a browser as-is, which is why it is bytes and not a link —
|
|
185
|
+
* there is no address in it to leak, and none to expire.
|
|
186
|
+
*
|
|
187
|
+
* Capped rather than streamed on purpose: a published handbook's illustration is tens of kilobytes,
|
|
188
|
+
* this path is anonymous, and an unbounded body on an unauthenticated endpoint is a way to spend
|
|
189
|
+
* somebody else's memory. Over the cap answers the same 404 as a picture that is not there.
|
|
190
|
+
*/
|
|
191
|
+
export const PublicAsset = z.object({
|
|
192
|
+
/**
|
|
193
|
+
* The stored content type, narrowed to an image type before it is answered.
|
|
194
|
+
*
|
|
195
|
+
* The route layer serves these from the application's own origin, so the two things it owes back
|
|
196
|
+
* are `X-Content-Type-Options: nosniff` and, because `image/svg+xml` is a document that can carry
|
|
197
|
+
* script, a `Content-Security-Policy: default-src 'none'` on the response. Anything the server
|
|
198
|
+
* could not narrow to an image is refused here rather than sent for the route layer to be careful
|
|
199
|
+
* with.
|
|
200
|
+
*/
|
|
201
|
+
contentType: z.string(),
|
|
202
|
+
/** base64 of the whole object */
|
|
203
|
+
bytes: z.string(),
|
|
204
|
+
/** how long the route layer may cache it; a version is immutable, so this is long */
|
|
205
|
+
maxAge: z.number().int().min(0),
|
|
206
|
+
})
|
|
207
|
+
export type PublicAsset = z.infer<typeof PublicAsset>
|
|
208
|
+
|
|
171
209
|
/**
|
|
172
210
|
* The URL prefix the route layer serves this site under, so a link between two published pages is a
|
|
173
211
|
* link and not a dead mention.
|
|
@@ -185,6 +223,21 @@ export const PublicBasePath = z
|
|
|
185
223
|
.regex(/^\/(?:[A-Za-z0-9._~-]+\/)*$/, 'an absolute path ending in a slash')
|
|
186
224
|
.default('/')
|
|
187
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The one thing the module does ask of whatever serves a published site: a place for its pictures.
|
|
228
|
+
*
|
|
229
|
+
* `public.page` writes every `<img src>` as `<basePath><segment>/<reference>`, so the route layer
|
|
230
|
+
* has to answer that address by calling `public.asset` and streaming the bytes back. It is a
|
|
231
|
+
* constant rather than a convention because two sides have to agree on it and only one of them can
|
|
232
|
+
* be wrong quietly — a route layer that does not serve it renders a published page with no
|
|
233
|
+
* pictures, which looks like a rendering bug and is a missing route.
|
|
234
|
+
*
|
|
235
|
+
* The leading `__` is what keeps it out of the way: a page's own path segment is `slugifyTitle`'s
|
|
236
|
+
* output, which is Unicode letters and digits separated by hyphens, so no title can ever produce a
|
|
237
|
+
* segment starting with an underscore and no published page can be shadowed by this one.
|
|
238
|
+
*/
|
|
239
|
+
export const PUBLIC_ASSET_SEGMENT = '__media'
|
|
240
|
+
|
|
188
241
|
export const quireContract = {
|
|
189
242
|
spaces: {
|
|
190
243
|
list: baseContract
|
|
@@ -808,6 +861,31 @@ export const quireContract = {
|
|
|
808
861
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/robots', ...t('public') })
|
|
809
862
|
.input(z.object({ workspaceId: WorkspaceId, slug: Publication.shape.slug }))
|
|
810
863
|
.output(z.object({ indexable: z.boolean(), sitemapPath: z.string().nullable() })),
|
|
864
|
+
/**
|
|
865
|
+
* The bytes of one picture on a published page.
|
|
866
|
+
*
|
|
867
|
+
* `asset` is the opaque reference the page's own HTML carries — an AES-GCM envelope sealed with
|
|
868
|
+
* the instance secret and bound by its associated data to this workspace, so it names nothing
|
|
869
|
+
* on its own and cannot be carried to another instance. Resolving it is not enough on its own:
|
|
870
|
+
* the file has to be referenced by a version that is *currently* public in this publication, so
|
|
871
|
+
* opting a page out stops its pictures resolving in the same breath as its prose.
|
|
872
|
+
*
|
|
873
|
+
* Everything unresolvable is the same 404 as everything else here — a reference that will not
|
|
874
|
+
* decrypt, one for a file nothing public uses, a file that has been deleted, an object over the
|
|
875
|
+
* cap. And a locked publication answers the door first: the pictures are behind the password
|
|
876
|
+
* along with the pages they are on.
|
|
877
|
+
*/
|
|
878
|
+
asset: baseContract
|
|
879
|
+
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/asset', ...t('public') })
|
|
880
|
+
.input(
|
|
881
|
+
z.object({
|
|
882
|
+
workspaceId: WorkspaceId,
|
|
883
|
+
slug: Publication.shape.slug,
|
|
884
|
+
asset: z.string().min(1).max(2048),
|
|
885
|
+
token: z.string().max(4096).nullable().default(null),
|
|
886
|
+
}),
|
|
887
|
+
)
|
|
888
|
+
.output(PublicAsset),
|
|
811
889
|
/** Present the password, get a token. A site with no password has no door, and answers 404. */
|
|
812
890
|
unlock: baseContract
|
|
813
891
|
.route({ method: 'POST', path: '/public/{workspaceId}/{slug}/unlock', ...t('public') })
|