feat: share reviewed website archives and repair companion setup flows

This commit is contained in:
archipelago
2026-10-08 12:18:45 -04:00
parent 08bffdb43d
commit e5d77916d4
17 changed files with 394 additions and 20 deletions
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
const props = defineProps<{ options: { id: string; name: string }[]; disabled?: boolean }>()
const selected = defineModel<string>({ required: true })
const root = ref<HTMLElement>()
const trigger = ref<HTMLButtonElement>()
const search = ref<HTMLInputElement>()
const open = ref(false)
const query = ref('')
const active = ref(0)
const uid = useId()
const filtered = computed(() => props.options.filter(option => `${option.name} ${option.id}`.toLowerCase().includes(query.value.trim().toLowerCase())))
const label = computed(() => props.options.find(option => option.id === selected.value)?.name || 'Choose an app')
watch(query, () => { active.value = 0 })
watch(() => props.disabled, value => { if (value) open.value = false })
watch(() => props.options, options => { if (selected.value && !options.some(option => option.id === selected.value)) selected.value = '' })
function toggle() { open.value = !open.value; query.value = ''; active.value = 0 }
function choose(id: string) { selected.value = id; open.value = false; trigger.value?.focus() }
function outside(event: Event) { if (!root.value?.contains(event.target as Node)) open.value = false }
async function keyboard(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); open.value = false; trigger.value?.focus(); return }
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return
if (!open.value) {
if (event.key === 'Enter') return // native button click opens it
event.preventDefault(); toggle(); await nextTick(); search.value?.focus(); return
}
if (event.key === 'Enter' && event.target === search.value) { event.preventDefault(); if (filtered.value[active.value]) choose(filtered.value[active.value]!.id); return }
if (event.key === 'Enter') return
event.preventDefault()
active.value = Math.max(0, Math.min(filtered.value.length - 1, active.value + (event.key === 'ArrowDown' ? 1 : -1)))
search.value?.focus()
await nextTick()
document.getElementById(`${uid}-option-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })
}
onMounted(() => { document.addEventListener('pointerdown', outside); document.addEventListener('focusin', outside) })
onBeforeUnmount(() => { document.removeEventListener('pointerdown', outside); document.removeEventListener('focusin', outside) })
</script>
<template>
<div ref="root" class="min-w-0" @keydown="keyboard">
<span :id="`${uid}-label`" class="block mb-2">Application</span>
<button ref="trigger" type="button" class="w-full flex items-center justify-between gap-3 rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-left text-sm text-white/90" :disabled="disabled" :aria-labelledby="`${uid}-label ${uid}-value`" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="`${uid}-list`" @click="toggle">
<span :id="`${uid}-value`" class="truncate">{{ label }}</span>
<svg class="w-4 h-4 shrink-0 text-white/55" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</button>
<!-- In normal flow so a walkthrough card, keyboard or WebView cannot clip it. -->
<div v-if="open" class="glass-card rounded-lg mt-2 p-2 border border-white/10 shadow-xl">
<input ref="search" v-model="query" type="search" role="combobox" aria-label="Search apps" aria-autocomplete="list" aria-expanded="true" :aria-controls="`${uid}-list`" :aria-activedescendant="filtered.length ? `${uid}-option-${active}` : undefined" autocomplete="off" class="w-full rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-sm text-white mb-2" placeholder="Search apps…" />
<div :id="`${uid}-list`" role="listbox" aria-label="Supported applications" class="max-h-56 overflow-y-auto overscroll-contain">
<button v-for="(option, index) in filtered" :id="`${uid}-option-${index}`" :key="option.id" type="button" role="option" :aria-selected="selected === option.id" class="w-full flex items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-sm text-white/80 hover:bg-white/10 focus-visible:bg-white/10" :class="{ 'bg-white/10 text-white': active === index || selected === option.id }" @click="choose(option.id)">
<span class="truncate">{{ option.name }}</span><span v-if="selected === option.id" aria-hidden="true" class="text-orange-300">✓</span>
</button>
</div>
<p v-if="!filtered.length" role="status" class="px-3 py-4 text-sm text-white/60">No matching apps. Try another name.</p>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { websitePreview } from '@/services/publishing'
const props = defineProps<{
publication: { html: string; public_archive?: string | null }
archive?: { sha256: string; size: number } | null
address?: string | null
busy?: boolean
}>()
const emit = defineEmits<{ change: [enabled: boolean] }>()
const approved = ref(false)
watch(() => [props.publication, props.archive], () => { approved.value = false })
const fileAddress = computed(() => props.address && props.publication.public_archive
? `${props.address.replace(/\/$/, '')}/${props.publication.public_archive}` : null)
</script>
<template>
<details class="rounded-xl border border-white/10 p-4">
<summary class="cursor-pointer text-sm font-medium text-white/80">Share the archived website file</summary>
<div class="space-y-4 pt-4">
<p class="text-sm text-white/60">Let visitors download this exact website snapshot by its Blossom content hash. Other files and the Blossom app stay private. No upload or Nostr announcement is made.</p>
<template v-if="publication.public_archive">
<p class="text-sm">This snapshot is shared on this connection.</p>
<p v-if="fileAddress" class="font-mono text-xs break-all select-all">{{ fileAddress }}</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="emit('change', false)">Stop sharing this file</button>
</template>
<template v-else-if="archive">
<p class="text-sm text-white/60">The archived file must match the published preview below. Store and publish the same version before sharing.</p>
<p class="font-mono text-xs break-all">Archived SHA-256: {{ archive.sha256 }} · {{ archive.size }} bytes</p>
<iframe :srcdoc="websitePreview(publication.html)" sandbox="" referrerpolicy="no-referrer" title="Archived file publication preview" class="w-full h-64 rounded-xl bg-white" />
<label class="flex items-start gap-3 text-sm"><input v-model="approved" type="checkbox" class="mt-1" /><span>I approve public downloads of this exact snapshot on this connection. Copies may remain after I stop sharing.</span></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !approved" @click="emit('change', true)">Share this archived file</button>
</template>
<p v-else class="text-sm text-white/60">In “Create your website”, save a signed copy in local Blossom first.</p>
<p class="text-xs text-white/50">Publishing an update turns file sharing off until you review that version again.</p>
</div>
</details>
</template>
@@ -0,0 +1,29 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SearchableAppSelect from '../SearchableAppSelect.vue'
const options = [{ id: 'homeassistant', name: 'Home Assistant' }, { id: 'immich', name: 'Immich' }]
describe('searchable app picker', () => {
it('renders actual touchable options, filters them and selects only an offered app', async () => {
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('[role="option"]')).toHaveLength(2)
await wrapper.get('input').setValue('imm')
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
await wrapper.get('[role="option"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([['immich']])
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('supports keyboard choice and escape without selecting arbitrary search text', async () => {
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
await wrapper.get('input').setValue('home')
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toEqual([['homeassistant']])
await wrapper.get('button').trigger('click')
await wrapper.get('input').setValue('unknown')
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await wrapper.get('input').trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
})
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import WebsiteArchiveSharing from '../WebsiteArchiveSharing.vue'
describe('archived website sharing consent', () => {
it('requires fresh approval after the reviewed snapshot changes', async () => {
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: '<h1>Reviewed</h1>' }, archive: { sha256: 'a'.repeat(64), size: 17 } } })
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<h1>Reviewed</h1>')
await wrapper.get('input').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('change')).toEqual([[true]])
await wrapper.setProps({ publication: { html: '<h1>Different</h1>' } })
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.emitted('change')).toHaveLength(1)
})
it('keeps removal available without new approval and exposes only the selected hash', async () => {
const hash = 'a'.repeat(64)
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: 'public', public_archive: hash }, address: 'https://example.com/' } })
expect(wrapper.text()).toContain('https://example.com/'+hash)
expect(wrapper.find('input').exists()).toBe(false)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('change')).toEqual([[false]])
})
})
+3 -3
View File
@@ -7,8 +7,8 @@ export interface WebsiteRevision { id: string; created_at: string; html: string
export interface WebsiteProject {
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
draft: string; revisions: WebsiteRevision[]
fips_publication?: { port: number; html: string; created_at: string } | null
tor_publication?: { port: number; html: string; created_at: string } | null
fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
nsite_receipt?: NsiteReceipt | null
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
}
@@ -16,7 +16,7 @@ export interface PublishingState {
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
}
export interface PublishingStatus {
state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string
state: PublishingState; fips_address: string | null; publication_enabled: boolean; public_archive_enabled?: boolean; notice: string
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
nostr_relays?: string[]
+8 -1
View File
@@ -74,7 +74,14 @@ function hideSigner(delay = 0) {
}
function sendIdentity(identity: SelectedIdentity) {
parentPost({ type: 'archipelago:signer-identity', identity })
// Picker entries are Vue reactive objects. postMessage cannot clone a Proxy;
// sending it directly closes the picker and then throws, stranding the app
// behind the otherwise empty signer frame. Copy only the public fields.
parentPost({ type: 'archipelago:signer-identity', identity: {
id: identity.id, name: identity.name, did: identity.did,
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey,
nostr_npub: identity.nostr_npub,
} })
}
const bridge = useNostrBridge(getStoredIdentity, {
@@ -1,3 +1,4 @@
import { reactive } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NostrTabSigner from '@/views/NostrTabSigner.vue'
@@ -16,6 +17,23 @@ describe('NostrTabSigner visibility', () => {
window.dispatchEvent(event)
}
it('sends cloneable public identity fields and hides the frame after picker selection', async () => {
vi.useFakeTimers()
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(message => {
structuredClone(message) // Browser postMessage rejects Vue reactive proxies.
})
const wrapper = shallowMount(NostrTabSigner)
try {
parentMessage({ type: 'archipelago:signer-init', appId: 'blossom', appName: 'Blossom' })
const identity = reactive({ id: 'profile', name: 'Alice', did: 'did:example:alice', pubkey: 'public', nostr_pubkey: 'a'.repeat(64) })
wrapper.findComponent({ name: 'NostrIdentityPicker' }).vm.$emit('select', identity)
await Promise.resolve()
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-identity', identity: expect.objectContaining({ id: 'profile' }) }), window.location.origin)
await vi.advanceTimersByTimeAsync(450)
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:signer-hide' }, window.location.origin)
} finally { wrapper.unmount(); vi.useRealTimers() }
})
it('does not reveal the full-screen frame for a silent remembered request', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
id: 'identity-a',
@@ -11,6 +11,8 @@ import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
import BackButton from '@/components/BackButton.vue'
import SetupWalkthrough from '@/components/SetupWalkthrough.vue'
import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue'
import SearchableAppSelect from '@/components/SearchableAppSelect.vue'
const router = useRouter()
const route = useRoute()
@@ -41,16 +43,12 @@ const relays = ref('')
const gateway = ref('')
const nsiteUrl = ref('')
const guestApp = ref('')
const guestSearch = ref('')
const guestLabel = ref('Guest')
const guestHours = ref(24)
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
onDeactivated(() => { issuedAccess.value = null })
onBeforeUnmount(() => { issuedAccess.value = null })
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
watch(guestSearch, value => {
guestApp.value = shareableApps.value.find(app => `${app.name} (${app.id})` === value)?.id ?? ''
})
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
const acknowledgeNostr = ref(false)
const acknowledgeUpload = ref(false)
@@ -195,6 +193,16 @@ async function setFipsPublication(enable: boolean) {
message.value = enable ? 'Saved version selected for FIPS publication. Check listener status, firewall and access from another FIPS device.' : 'FIPS website unpublished. Your draft and revisions are retained.'
})
}
async function setArchiveSharing(route: 'fips' | 'tor', enable: boolean) {
await perform(async () => {
if (!status.value || !current.value) return
const result = await publishing.update(status.value.state.version, enable
? { action: 'share-archive', id: projectId.value, route, acknowledge_public: true }
: { action: 'unshare-archive', id: projectId.value, route })
status.value.state = result.state
message.value = enable ? 'This archived snapshot is available on the selected website connection. Other Blossom files remain private.' : 'File sharing stopped on this connection. Copies already downloaded may remain.'
})
}
async function generate() {
await perform(async () => {
const result = await publishing.generate(prompt.value, model.value.trim())
@@ -471,9 +479,7 @@ onMounted(refresh)
<p v-if="!shareableApps.length" class="rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white/70">No installed apps currently support guest sharing. <RouterLink to="/dashboard/marketplace" class="underline">Browse apps</RouterLink></p>
<p v-if="shareableApps.length" class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
<template v-if="shareableApps.length">
<label class="block">Application<input v-model="guestSearch" list="guest-sharing-apps" autocomplete="off" class="field mt-2" placeholder="Search or choose an app" aria-describedby="guest-app-help" /></label>
<datalist id="guest-sharing-apps"><option v-for="app in shareableApps" :key="app.id" :value="`${app.name} (${app.id})`" /></datalist>
<p id="guest-app-help" class="text-sm text-white/60">Choose a supported app from the suggestions to continue.</p>
<SearchableAppSelect v-model="guestApp" :options="shareableApps" :disabled="busy" />
<template v-if="guestTarget">
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
@@ -519,6 +525,7 @@ onMounted(refresh)
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
<p class="text-white/60">Open this address from another FIPS device to check visitor access.</p>
<button v-if="selected.includes('public-web')" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="walkthroughStep = 'domain'">Connect or check my HTTPS domain</button>
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.fips_publication" :archive="current.local_archive" :address="selected.includes('public-web') && current.domain?.hostname ? `https://${current.domain.hostname}/` : listener?.address" :busy="busy" @change="setArchiveSharing('fips', $event)" />
<details class="text-xs text-white/50"><summary>Connection details</summary><p class="mt-2">Website port {{ current.fips_publication.port }}. A local listener does not confirm access from another device.</p></details>
</div>
</section>
@@ -534,6 +541,7 @@ onMounted(refresh)
<p v-if="onion?.error" role="alert">{{ onion.error }}</p>
<p v-if="onion?.onion_address" class="font-mono select-all break-all">http://{{ onion.onion_address }}/</p>
<p>{{ onion?.listening ? 'Local website listener is ready. Open the address in Tor Browser to check external access.' : 'Waiting for the website listener. Reload to check.' }}</p>
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.tor_publication" :archive="current.local_archive" :address="onion?.onion_address ? `http://${onion.onion_address}/` : null" :busy="busy" @change="setArchiveSharing('tor', $event)" />
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
</div>
</section>
@@ -82,9 +82,12 @@ describe('publishing setup', () => {
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-sharing"]').trigger('click')
expect(rpcClient.call).not.toHaveBeenCalled()
await wrapper.get('input[list="guest-sharing-apps"]').setValue('not a supported app')
await wrapper.get('button[aria-haspopup="listbox"]').trigger('click')
await wrapper.get('input[role="combobox"]').setValue('not a supported app')
expect(wrapper.text()).not.toContain('Create app-only access')
await wrapper.get('input[list="guest-sharing-apps"]').setValue('Nextcloud (nextcloud)')
expect(wrapper.text()).toContain('No matching apps')
await wrapper.get('input[role="combobox"]').setValue('Nextcloud')
await wrapper.get('[role="option"]').trigger('click')
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
expect(wrapper.text()).toContain('synthetic-test-token')