archy/neode-ui/src/views/PeerFiles.vue
Dorian 84a56c80de security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation

Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)

UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet

Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:44:31 +00:00

260 lines
9.5 KiB
Vue

<template>
<div class="pb-6">
<!-- Header with back button -->
<div class="shrink-0 mb-4">
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Cloud
</button>
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Cloud</span>
</button>
</Teleport>
<!-- Peer Header -->
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
</div>
<div class="hidden md:block">
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
<p class="text-sm text-white/50">{{ currentPeer?.onion || 'Peer files' }}</p>
</div>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="glass-card p-8 text-center">
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">Connecting via Tor... This may take a few seconds.</p>
</div>
<!-- Error -->
<div v-else-if="catalogError" class="glass-card p-6">
<div class="alert-error mb-4">{{ catalogError }}</div>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
</div>
<!-- Empty -->
<div v-else-if="catalogItems.length === 0 && !loading" class="glass-card p-8 text-center">
<p class="text-white/50">This peer has no shared files.</p>
</div>
<!-- File Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in catalogItems"
:key="item.id"
class="glass-card p-4 flex items-center gap-4"
>
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }}</p>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="accessBadgeClass(item.access)"
>
{{ accessLabel(item.access) }}
</span>
<button
v-if="canDownload(item.access)"
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium"
:disabled="downloading === item.id"
@click="downloadFile(item)"
>
{{ downloading === item.id ? '...' : 'Download' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, Teleport } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{
peerId?: string
}>()
const router = useRouter()
interface PeerNode {
did: string
pubkey: string
onion: string
name?: string
trust_level: string
}
interface CatalogItem {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
}
const loading = ref(true)
const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null)
const peerDisplayName = computed(() => {
if (currentPeer.value?.name) return currentPeer.value.name
if (currentPeer.value?.did) return truncateDid(currentPeer.value.did)
return props.peerId ? truncateOnion(props.peerId) : 'Peer Files'
})
function goBack() {
router.push({ name: 'cloud' })
}
onMounted(async () => {
if (props.peerId) {
// Find the peer by onion address
try {
const result = await rpcClient.federationListNodes()
const peers = result?.nodes ?? []
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
} catch {
// Continue with just the onion address
}
await loadCatalog()
} else {
loading.value = false
}
})
async function loadCatalog() {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
loading.value = true
catalogError.value = ''
catalogItems.value = []
try {
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
params: { onion },
timeout: 30000,
})
catalogItems.value = result?.items ?? []
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
} finally {
loading.value = false
}
}
function truncateDid(did: string): string {
if (did.length <= 24) return did
return did.slice(0, 16) + '...' + did.slice(-8)
}
function truncateOnion(onion: string): string {
if (onion.length <= 20) return onion
return onion.slice(0, 12) + '...'
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
function fileIconBg(mime: string): string {
if (mime.startsWith('image/')) return 'bg-blue-500/15'
if (mime.startsWith('audio/')) return 'bg-orange-500/15'
if (mime.startsWith('video/')) return 'bg-pink-500/15'
if (mime.startsWith('text/')) return 'bg-green-500/15'
return 'bg-white/10'
}
function fileIconColor(mime: string): string {
if (mime.startsWith('image/')) return 'text-blue-400'
if (mime.startsWith('audio/')) return 'text-orange-400'
if (mime.startsWith('video/')) return 'text-pink-400'
if (mime.startsWith('text/')) return 'text-green-400'
return 'text-white/60'
}
function fileIconPath(mime: string): string {
if (mime.startsWith('image/')) return 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
if (mime.startsWith('audio/')) return 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'
if (mime.startsWith('video/')) return 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'
return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
}
function accessLabel(access: CatalogItem['access']): string {
if (access === 'free') return 'Free'
if (access === 'peersonly') return 'Peers Only'
if (typeof access === 'object' && 'paid' in access) return `${access.paid.price_sats} sats`
return String(access)
}
function accessBadgeClass(access: CatalogItem['access']): string {
if (access === 'free') return 'bg-green-500/15 text-green-400'
if (access === 'peersonly') return 'bg-blue-500/15 text-blue-400'
if (typeof access === 'object' && 'paid' in access) return 'bg-orange-500/15 text-orange-400'
return 'bg-white/10 text-white/50'
}
function canDownload(access: CatalogItem['access']): boolean {
return access === 'free' || access === 'peersonly'
}
async function downloadFile(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
downloading.value = item.id
try {
const result = await rpcClient.call<{ data?: string }>({
method: 'content.download-peer',
params: { onion, content_id: item.id },
timeout: 120000,
})
if (result?.data) {
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { type: item.mime_type })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = item.filename.split('/').pop() || item.filename
a.click()
URL.revokeObjectURL(url)
}
} catch (e: unknown) {
if (import.meta.env.DEV) console.warn('Download failed', e)
} finally {
downloading.value = null
}
}
</script>