feat: add isolated static website setup with FIPS and Tor publishing
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<RouterLink
|
||||
v-for="(goal, idx) in goals"
|
||||
:key="goal.id"
|
||||
:to="`/dashboard/goals/${goal.id}`"
|
||||
:to="goal.route || `/dashboard/goals/${goal.id}`"
|
||||
class="goal-card glass-card p-6 block"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
:style="{ '--card-stagger': idx }"
|
||||
@@ -84,6 +84,8 @@ function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
|
||||
|
||||
function goalIcon(icon: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
globe: '🌐',
|
||||
website: '📝',
|
||||
shop: '🏪',
|
||||
payments: '⚡',
|
||||
photos: '📸',
|
||||
|
||||
+14
-28
@@ -22,6 +22,20 @@ const FUND_WALLET_STEP: GoalStep = {
|
||||
}
|
||||
|
||||
export const GOALS: GoalDefinition[] = [
|
||||
{
|
||||
id: 'external-access', title: 'Allow external connections',
|
||||
subtitle: 'Choose FIPS, public web and Tor access for your services',
|
||||
icon: 'globe', category: 'network', requiredApps: [],
|
||||
route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner',
|
||||
steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }],
|
||||
},
|
||||
{
|
||||
id: 'publish-website', title: 'Publish a website',
|
||||
subtitle: 'Create a website on your node and choose where to publish it',
|
||||
icon: 'website', category: 'community', requiredApps: [],
|
||||
route: '/dashboard/setup/website', estimatedTime: 'Guided setup', difficulty: 'beginner',
|
||||
steps: [{ id: 'publish-website', title: 'Create and publish', description: 'Preview your website and reuse existing connections.', action: 'configure', isAutomatic: false }],
|
||||
},
|
||||
{
|
||||
id: 'open-a-shop',
|
||||
title: 'Open a Shop',
|
||||
@@ -116,34 +130,6 @@ export const GOALS: GoalDefinition[] = [
|
||||
estimatedTime: '~30 min + sync time',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'file-browser',
|
||||
title: 'File Browser',
|
||||
subtitle: 'Browse, upload, and manage files on your server',
|
||||
icon: 'files',
|
||||
category: 'storage',
|
||||
requiredApps: ['filebrowser'],
|
||||
steps: [
|
||||
{
|
||||
id: 'install-filebrowser',
|
||||
title: 'Install FileBrowser',
|
||||
description: 'FileBrowser is a lightweight web file manager. Upload, download, and organize files on your server from any browser.',
|
||||
appId: 'filebrowser',
|
||||
action: 'install',
|
||||
isAutomatic: true,
|
||||
},
|
||||
{
|
||||
id: 'configure-filebrowser',
|
||||
title: 'Log In',
|
||||
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
|
||||
appId: 'filebrowser',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
},
|
||||
],
|
||||
estimatedTime: '~5 min',
|
||||
difficulty: 'beginner',
|
||||
},
|
||||
{
|
||||
id: 'store-files',
|
||||
title: 'Store My Files',
|
||||
|
||||
@@ -245,6 +245,16 @@ const router = createRouter({
|
||||
name: 'app-registries',
|
||||
component: () => import('../views/AppRegistries.vue'),
|
||||
},
|
||||
{
|
||||
path: 'setup/external-access',
|
||||
name: 'external-access',
|
||||
component: () => import('@/views/publishing/PublishingSetup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'setup/website',
|
||||
name: 'publish-website',
|
||||
component: () => import('@/views/publishing/PublishingSetup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/router', () => ({ default: { push: vi.fn() } }))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
@@ -22,6 +24,8 @@ import { ContextBroker } from '../contextBroker'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
import router from '@/router'
|
||||
import { pendingWebsiteHtml } from '../websiteImport'
|
||||
|
||||
describe('ContextBroker', () => {
|
||||
let broker: ContextBroker
|
||||
@@ -31,6 +35,7 @@ describe('ContextBroker', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
pendingWebsiteHtml.value = null
|
||||
|
||||
mockPostMessage = vi.fn()
|
||||
iframeRef = ref<HTMLIFrameElement | null>({
|
||||
@@ -46,6 +51,23 @@ describe('ContextBroker', () => {
|
||||
expect(broker).toBeDefined()
|
||||
})
|
||||
|
||||
it('only accepts website drafts from the registered AIUI frame and origin', async () => {
|
||||
const receive = (origin: string, source: MessageEventSource | null) => {
|
||||
;(broker as unknown as { handleMessage(event: MessageEvent): void }).handleMessage(new MessageEvent('message', {
|
||||
origin, source, data: { type: 'action:request', id: 'website-draft', action: 'prepare-website', params: { html: '<h1>Draft</h1>' } },
|
||||
}))
|
||||
}
|
||||
receive('https://untrusted.example', iframeRef.value!.contentWindow)
|
||||
receive('http://localhost:8100', window)
|
||||
expect(pendingWebsiteHtml.value).toBeNull()
|
||||
expect(router.push).not.toHaveBeenCalled()
|
||||
receive('http://localhost:8100', iframeRef.value!.contentWindow)
|
||||
await vi.waitFor(() => expect(router.push).toHaveBeenCalledWith('/dashboard/setup/website'))
|
||||
expect(pendingWebsiteHtml.value).toBe('<h1>Draft</h1>')
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'action:response', id: 'website-draft', success: true }), expect.any(String))
|
||||
})
|
||||
|
||||
it('start registers message listener', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
broker.start()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
import { PUBLISH_ROUTES, publishing, websitePreview } from '../publishing'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
describe('publishing trust boundaries', () => {
|
||||
it('places restrictive CSP before untrusted website content', () => {
|
||||
const hostile = '<script>fetch("/rpc")</script><meta http-equiv="Content-Security-Policy" content="default-src *">'
|
||||
const preview = websitePreview(hostile)
|
||||
expect(preview.indexOf("default-src 'none'")).toBeLessThan(preview.indexOf(hostile))
|
||||
expect(preview).toContain("form-action 'none'")
|
||||
expect(preview).not.toContain("script-src 'unsafe-inline'")
|
||||
})
|
||||
it('supports all four routes without Tailscale', () => {
|
||||
expect(PUBLISH_ROUTES.map(r => r.id)).toEqual(['fips', 'public-web', 'tor', 'nostr'])
|
||||
})
|
||||
it('does not retry ambiguous writes and carries the node version', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ state: { version: 8 }, project_id: null })
|
||||
await publishing.update(7, { action: 'connections', routes: ['fips', 'tor'] })
|
||||
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.update', params: { version: 7, change: { action: 'connections', routes: ['fips', 'tor'] } }, maxRetries: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { pendingWebsiteHtml, prepareWebsiteImport } from '../websiteImport'
|
||||
beforeEach(() => { pendingWebsiteHtml.value = null })
|
||||
describe('AIUI website handoff', () => {
|
||||
it('holds HTML only in memory for explicit import', () => {
|
||||
expect(prepareWebsiteImport('<h1>My page</h1>')).toBe(true)
|
||||
expect(pendingWebsiteHtml.value).toBe('<h1>My page</h1>')
|
||||
})
|
||||
it('rejects invalid or oversized content without destroying a pending draft', () => {
|
||||
prepareWebsiteImport('existing')
|
||||
for (const bad of [null, {}, '', '\0', 'a'.repeat(512 * 1024 + 1)]) expect(prepareWebsiteImport(bad)).toBe(false)
|
||||
expect(pendingWebsiteHtml.value).toBe('existing')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { prepareWebsiteImport } from '@/services/websiteImport'
|
||||
import type {
|
||||
AIUIRequest,
|
||||
ArchyResponse,
|
||||
@@ -237,6 +238,7 @@ export class ContextBroker {
|
||||
this.handleContextRequest(msg.id, msg.category, msg.query)
|
||||
break
|
||||
case 'action:request':
|
||||
if (msg.action === 'prepare-website' && event.source !== this.iframe.value?.contentWindow) return
|
||||
this.handleActionRequest(msg.id, msg.action, msg.params)
|
||||
break
|
||||
case 'theme:request':
|
||||
@@ -640,6 +642,14 @@ export class ContextBroker {
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'prepare-website':
|
||||
if (prepareWebsiteImport(params?.html)) {
|
||||
void import('@/router').then(({ default: router }) => router.push('/dashboard/setup/website'))
|
||||
success = true
|
||||
} else {
|
||||
error = 'Provide a nonempty HTML draft up to 512 KiB'
|
||||
}
|
||||
break
|
||||
case 'navigate':
|
||||
if (params.path) {
|
||||
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr'
|
||||
export interface PublishDomain { hostname: string; destination: string | null }
|
||||
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
|
||||
}
|
||||
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
|
||||
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 }[]
|
||||
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean }[]
|
||||
}
|
||||
export interface DnsPlan {
|
||||
records: { record_type: string; name: string; value: string; ttl: number }[]
|
||||
verified: boolean; notes: string[]; instructions_url: string
|
||||
}
|
||||
export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [
|
||||
{ id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' },
|
||||
{ id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' },
|
||||
{ id: 'tor', title: 'Tor', description: 'An onion address controlled by your node. Visitors use Tor Browser.' },
|
||||
{ id: 'nostr', title: 'Nostr / nsites', description: 'Publish a static website through Nostr and Blossom. Public copies may remain after you unpublish.' },
|
||||
]
|
||||
export const publishing = {
|
||||
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
|
||||
update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({
|
||||
method: 'publishing.update', params: { version, change }, maxRetries: 0,
|
||||
}),
|
||||
dns: (domain: PublishDomain) => rpcClient.call<DnsPlan>({ method: 'publishing.dns', params: { ...domain }, maxRetries: 0 }),
|
||||
generate: (prompt: string, model: string) => rpcClient.call<{html: string; provider: string}>({
|
||||
method: 'publishing.generate', params: { prompt, model }, timeout: 150000, maxRetries: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
// This document is also sandboxed with no allow-* tokens by its iframe. The CSP
|
||||
// precedes model content and cannot be relaxed by a second meta tag. No fetches,
|
||||
// scripts, forms, navigation of the parent, cookies or management origin access.
|
||||
export function websitePreview(html: string): string {
|
||||
return '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; font-src \'none\'; base-uri \'none\'; form-action \'none\'"><meta name="referrer" content="no-referrer"></head><body>' + html + '</body></html>'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
// In-memory handoff only. Receiving model content never writes files, opens a
|
||||
// route or publishes. The trusted setup screen requires an explicit import.
|
||||
export const pendingWebsiteHtml = shallowRef<string | null>(null)
|
||||
export function prepareWebsiteImport(html: unknown): boolean {
|
||||
if (typeof html !== 'string' || !html.trim() || new TextEncoder().encode(html).length > 512 * 1024 || html.includes('\0')) return false
|
||||
pendingWebsiteHtml.value = html
|
||||
return true
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type AIContextCategory =
|
||||
| 'bitcoin'
|
||||
|
||||
/** Actions AIUI can request Archy to perform */
|
||||
export type AIActionType = 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
|
||||
export type AIActionType = 'prepare-website' | 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
|
||||
|
||||
// ─── AIUI → Archy (Requests) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface GoalDefinition {
|
||||
subtitle: string
|
||||
icon: string
|
||||
category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup' | 'community'
|
||||
route?: string
|
||||
requiredApps: string[]
|
||||
steps: GoalStep[]
|
||||
estimatedTime: string
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { pendingWebsiteHtml } from '@/services/websiteImport'
|
||||
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
|
||||
import type { DnsPlan, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
|
||||
|
||||
const route = useRoute()
|
||||
const websiteMode = computed(() => route.name === 'publish-website')
|
||||
const status = ref<PublishingStatus | null>(null)
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
const busy = ref(false)
|
||||
const projectId = ref('')
|
||||
const name = ref('My website')
|
||||
const selected = ref<PublishRoute[]>([])
|
||||
const html = ref('')
|
||||
const hostname = ref('')
|
||||
const destination = ref('')
|
||||
const prompt = ref('')
|
||||
const model = ref('')
|
||||
const dns = ref<DnsPlan | null>(null)
|
||||
const acknowledgeFips = ref(false)
|
||||
const acknowledgeTor = ref(false)
|
||||
const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value))
|
||||
const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value))
|
||||
const current = computed(() => status.value?.state.projects[projectId.value])
|
||||
const projects = computed(() => Object.values(status.value?.state.projects ?? {}))
|
||||
const publicName = computed(() => selected.value.includes('public-web') || selected.value.includes('nostr'))
|
||||
const preview = computed(() => websitePreview(html.value))
|
||||
const routes = computed(() => PUBLISH_ROUTES.filter(r => websiteMode.value || r.id !== 'nostr'))
|
||||
|
||||
async function perform(work: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = ''; message.value = ''
|
||||
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : 'The operation failed. Your saved project has been retained.' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
function selectProject(p: WebsiteProject) {
|
||||
projectId.value = p.id; name.value = p.name; selected.value = [...p.routes]
|
||||
html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false
|
||||
}
|
||||
async function refresh() {
|
||||
await perform(async () => {
|
||||
status.value = await publishing.status()
|
||||
if (!websiteMode.value) selected.value = [...status.value.state.connections]
|
||||
else if (current.value) selectProject(current.value)
|
||||
else if (projects.value[0]) selectProject(projects.value[0])
|
||||
})
|
||||
}
|
||||
async function create() {
|
||||
await perform(async () => {
|
||||
if (!status.value) return
|
||||
const result = await publishing.update(status.value.state.version, { action: 'create', name: name.value })
|
||||
status.value.state = result.state
|
||||
if (result.project_id) selectProject(result.state.projects[result.project_id]!)
|
||||
message.value = 'Website project created on your node.'
|
||||
})
|
||||
}
|
||||
async function importFromAiui() {
|
||||
await perform(async () => {
|
||||
if (!status.value || pendingWebsiteHtml.value === null) return
|
||||
const incoming = pendingWebsiteHtml.value
|
||||
const result = await publishing.update(status.value.state.version, { action: 'create', name: 'Website from AIUI' })
|
||||
status.value.state = result.state
|
||||
if (result.project_id) {
|
||||
selectProject(result.state.projects[result.project_id]!)
|
||||
html.value = incoming
|
||||
pendingWebsiteHtml.value = null
|
||||
message.value = 'AIUI draft imported into a new project. Preview it, then save before publishing.'
|
||||
}
|
||||
})
|
||||
}
|
||||
async function save() {
|
||||
await perform(async () => {
|
||||
if (!status.value) return
|
||||
const change = websiteMode.value ? {
|
||||
action: 'save', id: projectId.value, name: name.value, routes: selected.value,
|
||||
domain: publicName.value && hostname.value.trim() ? { hostname: hostname.value, destination: destination.value.trim() || null } : null,
|
||||
html: html.value,
|
||||
} : { action: 'connections', routes: selected.value }
|
||||
const result = await publishing.update(status.value.state.version, change)
|
||||
status.value.state = result.state
|
||||
message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.'
|
||||
})
|
||||
}
|
||||
async function restore(revision: string) {
|
||||
await perform(async () => {
|
||||
if (!status.value) return
|
||||
const result = await publishing.update(status.value.state.version, { action: 'restore', id: projectId.value, revision })
|
||||
status.value.state = result.state
|
||||
selectProject(result.state.projects[projectId.value]!)
|
||||
message.value = 'Previous draft restored. Published content has not changed.'
|
||||
})
|
||||
}
|
||||
async function setFipsPublication(enable: boolean) {
|
||||
await perform(async () => {
|
||||
if (!status.value || !current.value) return
|
||||
const result = await publishing.update(status.value.state.version, enable
|
||||
? { action: 'publish-fips', id: projectId.value, acknowledge_public: acknowledgeFips.value }
|
||||
: { action: 'unpublish-fips', id: projectId.value })
|
||||
status.value.state = result.state
|
||||
status.value = await publishing.status()
|
||||
acknowledgeFips.value = false
|
||||
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 generate() {
|
||||
await perform(async () => {
|
||||
const result = await publishing.generate(prompt.value, model.value.trim())
|
||||
html.value = result.html
|
||||
message.value = 'Local model draft ready to preview. Save it to keep this revision.'
|
||||
})
|
||||
}
|
||||
async function setTorPublication(enable: boolean) {
|
||||
await perform(async () => {
|
||||
if (!status.value || !current.value) return
|
||||
const result = await publishing.update(status.value.state.version, enable
|
||||
? { action: 'publish-tor', id: projectId.value, acknowledge_public: acknowledgeTor.value }
|
||||
: { action: 'unpublish-tor', id: projectId.value })
|
||||
status.value.state = result.state
|
||||
status.value = await publishing.status()
|
||||
acknowledgeTor.value = false
|
||||
message.value = enable ? 'Onion publication requested. Tor may take a few minutes to connect; reload to check its address.' : 'Onion website unpublished. Its address keys are retained so you can publish again at the same address.'
|
||||
})
|
||||
}
|
||||
async function prepareDns() {
|
||||
await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) })
|
||||
}
|
||||
function download() {
|
||||
const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="max-w-5xl mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
<RouterLink to="/dashboard?tab=setup" class="text-sm text-white/60 hover:text-white">← Setup</RouterLink>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div><h1 class="text-2xl font-semibold">{{ websiteMode ? 'Publish a website' : 'Allow external connections' }}</h1>
|
||||
<p class="text-white/60 mt-2">{{ websiteMode ? 'Create a website on your node and choose where people can find it.' : 'Choose how people will connect to selected services on your node.' }}</p></div>
|
||||
<button class="glass-button px-4 py-2" :disabled="busy" @click="refresh">Reload</button>
|
||||
</div>
|
||||
<p v-if="error" role="alert" class="rounded-xl p-4 bg-red-500/10 text-red-200">{{ error }}</p>
|
||||
<p v-if="message" role="status" class="rounded-xl p-4 bg-green-500/10 text-green-200">{{ message }}</p>
|
||||
<p v-if="busy" role="status" class="text-white/60">Working…</p>
|
||||
<template v-if="status">
|
||||
<div class="rounded-xl p-4 border border-amber-300/20 bg-amber-400/10 text-amber-100 text-sm">{{ status.notice }}</div>
|
||||
<fieldset :disabled="busy" class="space-y-6">
|
||||
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Continue from AIUI</h2>
|
||||
<p class="text-sm text-white/60">Create a new project from the HTML you selected in AIUI. Existing projects remain unchanged.</p>
|
||||
<button class="glass-button px-4 py-2" @click="importFromAiui">Import into a new website</button>
|
||||
<button class="text-sm underline ml-3" @click="pendingWebsiteHtml = null">Discard import</button>
|
||||
</section>
|
||||
<section v-if="websiteMode" class="glass-card p-5 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Your websites</h2>
|
||||
<div v-if="projects.length" class="flex flex-wrap gap-2">
|
||||
<button v-for="p in projects" :key="p.id" class="glass-button px-3 py-2" :aria-pressed="p.id === projectId" @click="selectProject(p)">{{ p.name }}</button>
|
||||
</div>
|
||||
<label class="block">Website name<input v-model="name" maxlength="100" class="field mt-2" /></label>
|
||||
<button class="glass-button px-4 py-2" @click="create">Create another website</button>
|
||||
</section>
|
||||
<section v-if="websiteMode && current" class="glass-card p-5 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Describe and preview</h2>
|
||||
<p class="text-sm text-white/60">Generate a simple page with a model installed on this node, or paste HTML from AIUI. Generation stays on your node.</p>
|
||||
<label class="block">What would you like to make?<textarea v-model="prompt" maxlength="16000" rows="3" class="field mt-2" placeholder="A simple website for my community garden…" /></label>
|
||||
<label class="block">Installed Ollama model<input v-model="model" class="field mt-2" placeholder="Enter the exact installed model name" /></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="!prompt.trim() || !model.trim()" @click="generate">Generate locally</button>
|
||||
<label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label>
|
||||
<iframe :srcdoc="preview" sandbox="" referrerpolicy="no-referrer" title="Isolated website preview" class="w-full h-96 rounded-xl bg-white" />
|
||||
<p class="text-xs text-white/50">Preview blocks scripts, forms and external requests. It cannot access your dashboard.</p>
|
||||
<button class="glass-button px-4 py-2" :disabled="!html" @click="download">Download HTML</button>
|
||||
</section>
|
||||
<section class="glass-card p-5 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Where should it be available?</h2>
|
||||
<p class="text-sm text-white/60">Choose any combination. Each connection will be checked separately.</p>
|
||||
<label v-for="option in routes" :key="option.id" class="flex items-start gap-3 rounded-xl border border-white/10 p-4 cursor-pointer">
|
||||
<input v-model="selected" type="checkbox" :value="option.id" class="mt-1" />
|
||||
<span><span class="font-medium">{{ option.title }}</span><span class="block text-sm text-white/60 mt-1">{{ option.description }}</span>
|
||||
<span v-if="websiteMode && status.state.connections.includes(option.id)" class="block text-xs text-amber-200 mt-2">Already selected in connection setup; reachability still needs verification.</span>
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'A local FIPS address exists. This does not yet verify a website or app route.' : 'No local FIPS address detected. FIPS must be connected before its routes can be verified.' }}</p>
|
||||
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
|
||||
</section>
|
||||
<section v-if="websiteMode && publicName" class="glass-card p-5 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Your domain</h2>
|
||||
<p class="text-sm text-white/60">Use a domain you own, or buy one with Bitcoin or Lightning. FIPS and Tor addresses do not need a domain purchase.</p>
|
||||
<a href="https://mynymbox.io/domainregistration" target="_blank" rel="noopener noreferrer" class="glass-button inline-block px-4 py-2">Buy a domain through Mynymbox ↗</a>
|
||||
<p class="text-xs text-white/50">Mynymbox is the registrant of record; you retain contractual control and transfer rights. Complete checkout yourself, then return here. No hosting purchase is needed.</p>
|
||||
<label class="block">Website hostname<input v-model="hostname" class="field mt-2" placeholder="www.yourdomain.com" /></label>
|
||||
<label class="block">Gateway hostname or public IP<input v-model="destination" class="field mt-2" placeholder="Use the destination supplied by your gateway" /></label>
|
||||
<p class="text-sm text-white/60">For a tunnel, point DNS at the public gateway. For a direct connection, use the node’s public IP. Do not use a home-network, FIPS or onion address for public web DNS.</p>
|
||||
<div v-if="selected.includes('public-web') && current?.fips_publication && status.fips_address" class="space-y-2 rounded-lg border border-white/10 p-4">
|
||||
<h3 class="font-medium">Use an existing reverse proxy</h3>
|
||||
<p class="text-sm text-white/60">If your proxy can reach this node over FIPS, you can reuse that connection. In Nginx Proxy Manager, add a separate Proxy Host with these settings:</p>
|
||||
<dl class="text-sm grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
|
||||
<dt>Domain</dt><dd class="font-mono break-all">{{ hostname || 'Your website hostname' }}</dd>
|
||||
<dt>Scheme</dt><dd>http</dd>
|
||||
<dt>Forward host</dt><dd class="font-mono break-all">[{{ status.fips_address }}]</dd>
|
||||
<dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd>
|
||||
</dl>
|
||||
<p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p>
|
||||
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. This setup is manual; the dashboard has not verified it. Removing this FIPS publication also disconnects this proxy route.</p>
|
||||
</div>
|
||||
<button class="glass-button px-4 py-2" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button>
|
||||
<div v-if="dns" class="space-y-3">
|
||||
<p>In Mynymbox, open Domains → DNS Management → your domain → Manage records → Add Record.</p>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm text-left"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th>TTL</th></tr></thead><tbody><tr v-for="record in dns.records" :key="record.name"><td>{{ record.record_type }}</td><td class="select-all">{{ record.name }}</td><td class="select-all">{{ record.value }}</td><td>{{ record.ttl }}</td></tr></tbody></table></div>
|
||||
<p v-for="note in dns.notes" :key="note" class="text-sm text-white/60">{{ note }}</p>
|
||||
<p class="text-amber-200 text-sm">Instructions prepared — DNS and HTTPS have not been verified.</p>
|
||||
<a href="https://mynymbox.io/docs?doc=domains/dns-records" target="_blank" rel="noopener noreferrer" class="underline text-sm">Mynymbox’s DNS guide ↗</a>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="!websiteMode" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Existing app access</h2>
|
||||
<p class="text-sm text-white/60">This inventory shows existing access policies. Local-only APIs are excluded. A local listener does not prove external reachability.</p>
|
||||
<ul class="space-y-2"><li v-for="app in status.apps" :key="app.id + app.port" class="flex flex-wrap justify-between gap-2 text-sm"><span>{{ app.name }} · {{ app.port }}</span><span class="text-white/60">{{ app.listener_claimed ? 'Local proxy listening' : 'Listener not confirmed' }} · {{ app.authentication === 'node-session' ? 'Node login required' : 'App access policy' }}</span></li></ul>
|
||||
</section>
|
||||
<button class="glass-button px-5 py-3" :disabled="websiteMode && !current" @click="save">{{ websiteMode ? 'Save website draft and choices' : 'Save connection choices' }}</button>
|
||||
<section v-if="websiteMode && current && status.publication_enabled" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Publish the saved version on FIPS</h2>
|
||||
<p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</p>
|
||||
<label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || !current.routes.includes('fips') || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
|
||||
<button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button>
|
||||
<div v-if="current.fips_publication" class="text-sm space-y-2">
|
||||
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
|
||||
<p v-if="listener?.error" role="alert">{{ listener.error }}</p>
|
||||
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
|
||||
<p class="text-amber-200">External access is not verified. The FIPS firewall must allow this website’s port, {{ current.fips_publication.port }}.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="websiteMode && current && status.publication_enabled && selected.includes('tor')" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Publish the saved version on Tor</h2>
|
||||
<p class="text-sm text-white/60">Share an onion address without buying a domain. Anyone who knows the address can read the page in Tor Browser. Your node keeps the address keys when you unpublish.</p>
|
||||
<label class="flex items-start gap-3"><input v-model="acknowledgeTor" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors using Tor.</span></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="!acknowledgeTor || !current.routes.includes('tor') || !current.draft" @click="setTorPublication(true)">{{ current.tor_publication ? 'Publish saved update on Tor' : 'Publish saved website on Tor' }}</button>
|
||||
<button v-if="current.tor_publication" class="glass-button px-4 py-2 ml-2" @click="setTorPublication(false)">Unpublish from Tor</button>
|
||||
<div v-if="current.tor_publication" class="text-sm space-y-2">
|
||||
<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>
|
||||
<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>
|
||||
<section v-if="websiteMode && current?.revisions.length" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Saved revisions</h2>
|
||||
<div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
|
||||
</section>
|
||||
</fieldset>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field { display: block; width: 100%; border: 1px solid rgb(255 255 255 / .15); border-radius: .5rem; padding: .75rem; background: rgb(0 0 0 / .2); color: white; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
th, td { padding: .5rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn() }))
|
||||
const page = vi.hoisted(() => ({ name: 'external-access' }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => page, RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api }))
|
||||
import PublishingSetup from '../PublishingSetup.vue'
|
||||
|
||||
const state = () => ({ schema: 1, version: 2, connections: ['fips'], projects: {} })
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); page.name = 'external-access'
|
||||
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [], publication_enabled: false, notice: 'Saving does not publish.' })
|
||||
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
|
||||
})
|
||||
describe('publishing setup', () => {
|
||||
it('loads choices from the node and saves multiple routes without activating them', async () => {
|
||||
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||
const inputs = wrapper.findAll('input[type="checkbox"]')
|
||||
expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
|
||||
await inputs[2]!.setValue(true)
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.update).toHaveBeenCalledWith(2, { action: 'connections', routes: ['fips', 'tor'] })
|
||||
expect(wrapper.text()).toContain('Existing app access has not changed')
|
||||
})
|
||||
it('keeps a failed save visible and does not pretend it succeeded', async () => {
|
||||
api.update.mockRejectedValue(new Error('Reload before saving'))
|
||||
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('Reload before saving')
|
||||
expect(wrapper.text()).not.toContain('Connection preferences saved')
|
||||
})
|
||||
it('isolates saved HTML and presents Nostr as an independent choice', async () => {
|
||||
page.name = 'publish-website'
|
||||
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<script>parent.fetch("/rpc")</script>', routes: ['fips', 'nostr'], domain: null, revisions: [] } } }, apps: [], fips_address: 'fd00::1', publication_enabled: false, notice: 'Saving does not publish.' })
|
||||
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain("default-src 'none'")
|
||||
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(4)
|
||||
expect(wrapper.text()).toContain('reachability still needs verification')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ interface TunnelStatus {
|
||||
connected?: boolean
|
||||
provider?: string
|
||||
ip_address?: string
|
||||
wg_ip?: string
|
||||
wg_ip?: string | null
|
||||
}
|
||||
|
||||
interface FipsStatus {
|
||||
|
||||
Reference in New Issue
Block a user