refactor: update dependencies and remove unused code

- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`.
- Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27.
- Removed the `backup.rs` file as it is no longer needed.
- Introduced tests for configuration and credential management.
- Enhanced the `identity` module to generate W3C compliant DID documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 00:19:30 +00:00
parent 2a867b32a8
commit 6fee6befed
347 changed files with 18703 additions and 46785 deletions

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the rpc-client module
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
import { containerClient } from '../container-client'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('containerClient', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('installApp calls container-install with manifest path', async () => {
mockedRpc.call.mockResolvedValue('container-abc123')
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-install',
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
})
expect(result).toBe('container-abc123')
})
it('startContainer calls container-start with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.startContainer('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-start',
params: { app_id: 'bitcoin-knots' },
})
})
it('stopContainer calls container-stop with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopContainer('lnd')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-stop',
params: { app_id: 'lnd' },
})
})
it('removeContainer calls container-remove with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.removeContainer('mempool')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-remove',
params: { app_id: 'mempool' },
})
})
it('getContainerStatus returns status for a container', async () => {
const mockStatus = {
id: '1',
name: 'bitcoin-knots',
state: 'running' as const,
image: 'bitcoinknots:29',
created: '2026-01-01',
ports: ['8332'],
lan_address: 'http://localhost:8332',
}
mockedRpc.call.mockResolvedValue(mockStatus)
const result = await containerClient.getContainerStatus('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-status',
params: { app_id: 'bitcoin-knots' },
})
expect(result).toEqual(mockStatus)
})
it('getContainerLogs returns log lines with default line count', async () => {
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
mockedRpc.call.mockResolvedValue(mockLogs)
const result = await containerClient.getContainerLogs('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'bitcoin-knots', lines: 100 },
})
expect(result).toEqual(mockLogs)
})
it('getContainerLogs respects custom line count', async () => {
mockedRpc.call.mockResolvedValue([])
await containerClient.getContainerLogs('lnd', 50)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'lnd', lines: 50 },
})
})
it('listContainers returns all containers', async () => {
const mockContainers = [
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
]
mockedRpc.call.mockResolvedValue(mockContainers)
const result = await containerClient.listContainers()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-list',
params: {},
})
expect(result).toHaveLength(2)
})
it('getHealthStatus returns health map', async () => {
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
mockedRpc.call.mockResolvedValue(mockHealth)
const result = await containerClient.getHealthStatus()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-health',
params: {},
})
expect(result).toEqual(mockHealth)
})
it('startBundledApp sends full app config', async () => {
mockedRpc.call.mockResolvedValue(undefined)
const app = {
id: 'filebrowser',
name: 'FileBrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
}
await containerClient.startBundledApp(app)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-start',
params: {
app_id: 'filebrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
},
})
})
it('stopBundledApp calls bundled-app-stop', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopBundledApp('filebrowser')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-stop',
params: { app_id: 'filebrowser' },
})
})
it('propagates RPC errors from the client', async () => {
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
})
})

View File

@@ -0,0 +1,330 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sanitizePath } from '../filebrowser-client'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in constructor, so stub it
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
writable: true,
})
// Import after stubs
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
})
describe('login', () => {
it('authenticates and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
// We need a fresh instance to test login — use the exported singleton
const result = await fileBrowserClient.login('admin', 'admin')
expect(result).toBe(true)
expect(fileBrowserClient.isAuthenticated).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/app/filebrowser/api/login'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ username: 'admin', password: 'admin' }),
}),
)
})
it('returns false on failed login', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
const result = await fileBrowserClient.login('admin', 'wrong')
expect(result).toBe(false)
})
it('returns false on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
})
describe('listDirectory', () => {
it('lists items in a directory', async () => {
// Ensure authenticated first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
const mockItems = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
],
numDirs: 1,
numFiles: 1,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
const items = await fileBrowserClient.listDirectory('/')
expect(items).toHaveLength(2)
expect(items[0]!.name).toBe('photos')
expect(items[1]!.extension).toBe('txt')
})
it('adds leading slash if missing', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
await fileBrowserClient.listDirectory('photos')
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos')
})
it('throws on non-OK response', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
})
})
describe('downloadUrl', () => {
it('constructs download URL for file path', async () => {
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
expect(url).toContain('/api/raw/photos/sunset.jpg')
})
it('adds leading slash if missing', async () => {
const url = fileBrowserClient.downloadUrl('file.txt')
expect(url).toContain('/api/raw/file.txt')
})
})
describe('upload', () => {
it('uploads a file to the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
await fileBrowserClient.upload('/documents', file)
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/test.txt')
expect(url).toContain('override=true')
expect(init.method).toBe('POST')
expect(init.body).toBe(file)
})
it('throws on upload failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
const file = new File(['data'], 'big.bin')
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
})
})
describe('createFolder', () => {
it('creates a folder at the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.createFolder('/documents', 'photos')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/photos/')
expect(init.method).toBe('POST')
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
})
})
describe('deleteItem', () => {
it('sends DELETE request for the item', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.deleteItem('/photos/old.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('DELETE')
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
})
})
describe('getUsage', () => {
it('returns usage summary for root directory', async () => {
// Login first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
const mockData = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
],
numDirs: 1,
numFiles: 2,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
const usage = await fileBrowserClient.getUsage()
expect(usage.totalSize).toBe(2000)
expect(usage.folderCount).toBe(1)
expect(usage.fileCount).toBe(2)
})
it('returns zeros on failed request', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
const usage = await fileBrowserClient.getUsage()
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
})
})
describe('isTextFile', () => {
it('identifies text file extensions', () => {
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
})
it('returns false for binary files', () => {
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
})
})
describe('rename', () => {
it('sends PATCH request with new destination', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('PATCH')
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
})
it('throws on rename failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
})
})
})
describe('sanitizePath', () => {
it('returns / for empty path', () => {
expect(sanitizePath('')).toBe('/')
})
it('preserves simple paths', () => {
expect(sanitizePath('/photos')).toBe('/photos')
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
})
it('adds leading slash', () => {
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
})
it('resolves . segments', () => {
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
})
it('resolves .. segments', () => {
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
})
it('prevents traversal past root', () => {
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
expect(sanitizePath('/../../..')).toBe('/')
})
it('handles multiple consecutive .. at root', () => {
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
})
it('handles mixed . and .. segments', () => {
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
})
it('removes trailing slashes in segments', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
})
})

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
const { rpcClient } = await import('../rpc-client')
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status, statusText),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(body)),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('marketplaceDiscover', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns apps array and relay_count on success', async () => {
const payload = {
apps: [
{
manifest: {
app_id: 'bitcoin',
name: 'Bitcoin Core',
version: '27.0',
description: { short: 'Full node', long: 'Bitcoin Core full node' },
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
category: 'bitcoin',
icon_url: '/icons/bitcoin.png',
repo_url: 'https://github.com/bitcoin/bitcoin',
license: 'MIT',
},
trust_score: 95,
trust_tier: 'verified',
relay_count: 8,
first_seen: '2025-01-15T00:00:00Z',
nostr_pubkey: 'npub1abc',
},
],
relay_count: 12,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toHaveLength(1)
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
expect(result.apps[0]!.trust_score).toBe(95)
expect(result.relay_count).toBe(12)
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('marketplace.discover')
expect(body.params).toEqual({})
})
it('handles empty results', async () => {
const payload = {
apps: [],
relay_count: 0,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toEqual([])
expect(result.apps).toHaveLength(0)
expect(result.relay_count).toBe(0)
})
})
describe('diskStatus', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns expected fields', async () => {
const payload = {
used_bytes: 500_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 500_000_000_000,
used_percent: 50,
level: 'ok' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.used_bytes).toBe(500_000_000_000)
expect(result.total_bytes).toBe(1_000_000_000_000)
expect(result.free_bytes).toBe(500_000_000_000)
expect(result.used_percent).toBe(50)
expect(result.level).toBe('ok')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-status')
})
it('level is warning when percent >= 85', async () => {
const payload = {
used_bytes: 850_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 150_000_000_000,
used_percent: 85,
level: 'warning' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('warning')
expect(result.used_percent).toBe(85)
})
it('level is critical when percent >= 90', async () => {
const payload = {
used_bytes: 950_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 50_000_000_000,
used_percent: 95,
level: 'critical' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('critical')
expect(result.used_percent).toBe(95)
})
})
describe('diskCleanup', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns freed_bytes and actions array', async () => {
const payload = {
freed_bytes: 2_000_000_000,
freed_human: '2 GB',
actions: ['Removed 5 dangling images', 'Cleared build cache'],
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskCleanup()
expect(result.freed_bytes).toBe(2_000_000_000)
expect(result.freed_human).toBe('2 GB')
expect(result.actions).toHaveLength(2)
expect(result.actions[0]).toBe('Removed 5 dangling images')
expect(result.actions[1]).toBe('Cleared build cache')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-cleanup')
})
it('uses 60s timeout', async () => {
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockFetch.mockRejectedValue(abortError)
const promise = rpcClient.diskCleanup()
// The call should eventually reject with timeout after retries
await expect(promise).rejects.toThrow('Request timeout')
// Verify all 3 attempts used the signal (timeout is set via AbortController)
expect(mockFetch).toHaveBeenCalledTimes(3)
for (const call of mockFetch.mock.calls) {
expect(call[1].signal).toBeInstanceOf(AbortSignal)
}
})
})

View File

@@ -15,6 +15,26 @@ interface FileBrowserListResponse {
sorting: { by: string; asc: boolean }
}
/**
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
* Always returns a path starting with `/` and never containing `..`.
*/
export function sanitizePath(path: string): string {
const segments = path.split('/').filter(Boolean)
const resolved: string[] = []
for (const seg of segments) {
if (seg === '.') continue
if (seg === '..') {
resolved.pop() // go up one level, but never past root
} else {
resolved.push(seg)
}
}
return '/' + resolved.join('/')
}
class FileBrowserClient {
private token: string | null = null
private baseUrl: string
@@ -38,6 +58,8 @@ class FileBrowserClient {
const text = await res.text()
// FileBrowser returns the JWT as a plain string (possibly quoted)
this.token = text.replace(/^"|"$/g, '')
// Store token as cookie for img/video/audio src requests (avoids token in URL)
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict`
return true
} catch {
return false
@@ -51,7 +73,7 @@ class FileBrowserClient {
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = path.startsWith('/') ? path : `/${path}`
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
headers: this.headers(),
})
@@ -63,14 +85,47 @@ class FileBrowserClient {
}))
}
/**
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
* Returns a plain URL (no token in query string).
*/
downloadUrl(path: string): string {
const safePath = path.startsWith('/') ? path : `/${path}`
// Token is passed as query param for direct downloads (img src, audio src, etc.)
return `${this.baseUrl}/api/raw${safePath}?auth=${this.token}`
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
}
/**
* Fetch a file as a blob URL using header-based auth (no token in URL).
* Use this for img/video/audio src attributes and download links.
*/
async fetchBlobUrl(path: string): Promise<string> {
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob()
return URL.createObjectURL(blob)
}
/**
* Trigger a file download using header-based auth (no token in URL).
*/
async downloadFile(path: string): Promise<void> {
const blobUrl = await this.fetchBlobUrl(path)
const filename = path.split('/').pop() || 'download'
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File): Promise<void> {
const safePath = dirPath.endsWith('/') ? dirPath : `${dirPath}/`
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
const res = await fetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
@@ -87,8 +142,10 @@ class FileBrowserClient {
}
async createFolder(parentPath: string, name: string): Promise<void> {
const safePath = parentPath.endsWith('/') ? parentPath : `${parentPath}/`
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${name}/`, {
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
method: 'POST',
headers: this.headers(),
})
@@ -96,7 +153,7 @@ class FileBrowserClient {
}
async deleteItem(path: string): Promise<void> {
const safePath = path.startsWith('/') ? path : `/${path}`
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',
headers: this.headers(),
@@ -142,7 +199,7 @@ class FileBrowserClient {
if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = path.startsWith('/') ? path : `/${path}`
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
@@ -156,15 +213,16 @@ class FileBrowserClient {
}
async rename(oldPath: string, newName: string): Promise<void> {
const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}`
const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH',
headers: {
...this.headers(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ destination: `${dir}${newName}` }),
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
})
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
}

View File

@@ -211,6 +211,62 @@ class RPCClient {
})
}
async resolveDid(did?: string): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.resolve-did',
params: did ? { did } : {},
})
}
async createPresentation(params: {
holderId: string
credentialIds: string[]
}): Promise<Record<string, unknown>> {
return this.call({
method: 'identity.create-presentation',
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
})
}
async verifyPresentation(presentation: Record<string, unknown>): Promise<{
valid: boolean
holder_valid: boolean
credentials: Array<{ id: string; valid: boolean; revoked: boolean }>
}> {
return this.call({
method: 'identity.verify-presentation',
params: { presentation },
})
}
async createPsbt(params: {
outputs: Array<{ address: string; amount_sats: number }>
feeRateSatPerVbyte?: number
}): Promise<{
psbt_base64: string
change_output_index: number
total_amount_sats: number
fee_rate_sat_per_vbyte: number
}> {
return this.call({
method: 'lnd.create-psbt',
params: {
outputs: params.outputs,
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
},
})
}
async finalizePsbt(signedPsbtBase64: string): Promise<{
raw_final_tx: string
broadcast: boolean
}> {
return this.call({
method: 'lnd.finalize-psbt',
params: { signed_psbt_base64: signedPsbtBase64 },
})
}
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
return this.call({
method: 'node.nostr-publish',
@@ -325,6 +381,21 @@ class RPCClient {
})
}
async detectUsbDevices(): Promise<{
devices: Array<{
type: string
vendor_id: string
product_id: string
manufacturer: string
product: string
}>
}> {
return this.call({
method: 'system.detect-usb-devices',
params: {},
})
}
async restartServer(): Promise<void> {
return this.call({
method: 'server.restart',
@@ -381,6 +452,226 @@ class RPCClient {
})
}
// Federation
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
return this.call({
method: 'federation.invite',
params: {},
})
}
async federationJoin(code: string): Promise<{
joined: boolean
node: { did: string; onion: string; pubkey: string; trust_level: string }
}> {
return this.call({
method: 'federation.join',
params: { code },
})
}
async federationListNodes(): Promise<{
nodes: Array<{
did: string
pubkey: string
onion: string
trust_level: string
added_at: string
name?: string
last_seen?: string
last_state?: {
timestamp: string
apps: Array<{ id: string; status: string; version?: string }>
cpu_usage_percent?: number
mem_used_bytes?: number
mem_total_bytes?: number
disk_used_bytes?: number
disk_total_bytes?: number
uptime_secs?: number
tor_active?: boolean
}
}>
}> {
return this.call({
method: 'federation.list-nodes',
params: {},
})
}
async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> {
return this.call({
method: 'federation.remove-node',
params: { did },
})
}
async federationSetTrust(
did: string,
trustLevel: 'trusted' | 'observer' | 'untrusted',
): Promise<{ updated: boolean; did: string; trust_level: string }> {
return this.call({
method: 'federation.set-trust',
params: { did, trust_level: trustLevel },
})
}
async federationSyncState(): Promise<{
synced: number
failed: number
results: Array<{ did: string; status: string; apps?: number; error?: string }>
}> {
return this.call({
method: 'federation.sync-state',
params: {},
timeout: 120000,
})
}
async federationDeployApp(params: {
did: string
appId: string
version?: string
marketplaceUrl?: string
}): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> {
return this.call({
method: 'federation.deploy-app',
params: {
did: params.did,
app_id: params.appId,
version: params.version ?? 'latest',
marketplace_url: params.marketplaceUrl ?? '',
},
timeout: 180000,
})
}
// VPN
async vpnStatus(): Promise<{
connected: boolean
provider?: string
interface?: string
ip_address?: string
hostname?: string
peers_connected: number
bytes_in: number
bytes_out: number
configured: boolean
configured_provider: string
}> {
return this.call({
method: 'vpn.status',
params: {},
})
}
async vpnConfigure(params: {
provider: 'tailscale' | 'wireguard'
auth_key?: string
address?: string
dns?: string
peer?: {
public_key: string
endpoint: string
allowed_ips?: string
persistent_keepalive?: number
}
}): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> {
return this.call({
method: 'vpn.configure',
params,
timeout: 60000,
})
}
async vpnDisconnect(): Promise<{ disconnected: boolean }> {
return this.call({
method: 'vpn.disconnect',
params: {},
})
}
// Marketplace
async marketplaceDiscover(): Promise<{
apps: Array<{
manifest: {
app_id: string
name: string
version: string
description: { short: string; long: string } | string
author: { name: string; did: string; nostr_pubkey: string }
container: { image: string; ports: Array<{ container: number; host: number }>; }
category: string
icon_url: string
repo_url: string
license: string
}
trust_score: number
trust_tier: string
relay_count: number
first_seen: string
nostr_pubkey: string
}>
relay_count: number
}> {
return this.call({
method: 'marketplace.discover',
params: {},
timeout: 30000,
})
}
// DNS
async dnsStatus(): Promise<{
provider: string
servers: string[]
doh_enabled: boolean
doh_url: string | null
resolv_conf_servers: string[]
}> {
return this.call({
method: 'network.dns-status',
params: {},
})
}
async configureDns(params: {
provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
servers?: string[]
}): Promise<{
ok: boolean
provider: string
servers: string[]
doh_enabled: boolean
doh_url: string | null
}> {
return this.call({
method: 'network.configure-dns',
params,
})
}
// Disk management
async diskStatus(): Promise<{
used_bytes: number
total_bytes: number
free_bytes: number
used_percent: number
level: 'ok' | 'warning' | 'critical'
}> {
return this.call({ method: 'system.disk-status' })
}
async diskCleanup(): Promise<{
freed_bytes: number
freed_human: string
actions: string[]
}> {
return this.call({
method: 'system.disk-cleanup',
timeout: 60000,
})
}
}
export const rpcClient = new RPCClient()

View File

@@ -3,8 +3,10 @@
import type { Update, PatchOperation } from '../types/api'
import { applyPatch, type Operation } from 'fast-json-patch'
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
type WebSocketCallback = (update: Update) => void
type ConnectionStateCallback = (connected: boolean) => void
type ConnectionStateCallback = (state: ConnectionState) => void
export class WebSocketClient {
private ws: WebSocket | null = null
@@ -13,14 +15,18 @@ export class WebSocketClient {
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private maxReconnectDelay = 30000
private shouldReconnect = true
private url: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private visibilityChangeHandler: (() => void) | null = null
private onlineHandler: (() => void) | null = null
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private lastMessageTime: number = Date.now()
private heartbeatInterval = 10000 // Check connection every 10 seconds
private pingInterval = 30000 // Send ping every 30 seconds
private _state: ConnectionState = 'disconnected'
constructor(url: string = '/ws/db') {
this.url = url
@@ -33,13 +39,13 @@ export class WebSocketClient {
// Handle page visibility changes (tab switching, browser minimizing)
this.visibilityChangeHandler = () => {
if (document.visibilityState === 'visible') {
console.log('[WebSocket] Page became visible, checking connection...')
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
// Only reconnect if we haven't been explicitly disconnected
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
this.reconnectAttempts = 0
this.connect().catch(err => {
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
})
}
}
@@ -50,11 +56,11 @@ export class WebSocketClient {
this.onlineHandler = () => {
// Only reconnect if we haven't been explicitly disconnected
if (!this.shouldReconnect) return
console.log('[WebSocket] Network came online, reconnecting...')
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.reconnectAttempts = 0
this.connect().catch(err => {
console.error('[WebSocket] Failed to reconnect when network came online:', err)
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
})
}
}
@@ -65,14 +71,14 @@ export class WebSocketClient {
return new Promise((resolve, reject) => {
// If already connected, resolve immediately
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] Already connected, skipping')
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
resolve()
return
}
// If connecting, wait for it
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.log('[WebSocket] Already connecting, waiting...')
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
const checkInterval = setInterval(() => {
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
@@ -107,7 +113,7 @@ export class WebSocketClient {
// If we have an active WebSocket, don't create a new one
if (this.ws) {
console.log('[WebSocket] Connection exists, reusing it')
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
resolve()
return
}
@@ -124,14 +130,15 @@ export class WebSocketClient {
const host = window.location.host
const wsUrl = `${protocol}//${host}${this.url}`
console.log('[WebSocket] Connecting to:', wsUrl)
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
this.setConnectionState('connecting')
this.ws = new WebSocket(wsUrl)
// Timeout handler in case connection hangs
const connectionTimeout = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.warn('WebSocket connection timeout, retrying...')
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
this.ws.close()
reject(new Error('Connection timeout'))
}
@@ -141,15 +148,15 @@ export class WebSocketClient {
clearTimeout(connectionTimeout)
this.reconnectAttempts = 0
this.lastMessageTime = Date.now()
console.log('[WebSocket] Connected successfully')
this.notifyConnectionState(true)
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
this.setConnectionState('connected')
this.startHeartbeat()
resolve()
}
this.ws.onerror = (error) => {
clearTimeout(connectionTimeout)
console.error('[WebSocket] Connection error:', error)
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
// Don't reject immediately - let onclose handle reconnection
// This prevents errors from blocking reconnection
}
@@ -160,24 +167,24 @@ export class WebSocketClient {
const update: Update = JSON.parse(event.data)
this.callbacks.forEach((callback) => callback(update))
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
if (import.meta.env.DEV) console.error('Failed to parse WebSocket message:', error)
}
}
this.ws.onclose = (event) => {
clearTimeout(connectionTimeout)
this.stopHeartbeat()
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
// Notify connection state changed
this.notifyConnectionState(false)
this.setConnectionState('disconnected')
// Clear the WebSocket reference
this.ws = null
// Don't reconnect if we explicitly disconnected
if (!this.shouldReconnect) {
console.log('[WebSocket] Reconnection disabled')
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
return
}
@@ -190,11 +197,11 @@ export class WebSocketClient {
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
const delay = needsImmediateReconnect ? 0 :
(this.reconnectAttempts === 0 ? 100 :
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
const delay = needsImmediateReconnect ? 0 :
(this.reconnectAttempts === 0 ? 100 :
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
// Clear any existing reconnect timer
if (this.reconnectTimer) {
@@ -213,9 +220,9 @@ export class WebSocketClient {
this.reconnectAttempts++
}
console.log('[WebSocket] Attempting reconnection...')
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
this.connect().catch((err) => {
console.error('[WebSocket] Reconnection failed:', err)
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
// onclose will be called again and will retry
})
}
@@ -230,7 +237,7 @@ export class WebSocketClient {
}, delay)
}
} else {
console.warn('[WebSocket] Max reconnection attempts reached')
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
this.shouldReconnect = false
}
}
@@ -244,6 +251,10 @@ export class WebSocketClient {
}
}
get state(): ConnectionState {
return this._state
}
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
this.connectionStateCallbacks.add(callback)
return () => {
@@ -251,26 +262,38 @@ export class WebSocketClient {
}
}
private notifyConnectionState(connected: boolean): void {
this.connectionStateCallbacks.forEach((callback) => callback(connected))
private setConnectionState(state: ConnectionState): void {
this._state = state
this.connectionStateCallbacks.forEach((callback) => callback(state))
}
private startHeartbeat(): void {
this.stopHeartbeat()
// Send ping messages every 30s
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'ping' }))
} catch {
// Send failed, connection likely broken
}
}
}, this.pingInterval)
// Check connection health every 10s
this.heartbeatTimer = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('[WebSocket] Heartbeat detected closed connection')
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
this.stopHeartbeat()
return
}
// Check if we've received a message recently
const timeSinceLastMessage = Date.now() - this.lastMessageTime
// If no message for more than 5 minutes, assume connection is stale
if (timeSinceLastMessage > 300000) {
console.warn('[WebSocket] No messages for 5m, reconnecting...')
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
this.ws.close()
return
}
@@ -282,11 +305,16 @@ export class WebSocketClient {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
disconnect(): void {
this.shouldReconnect = false
this.reconnectAttempts = 0
this.setConnectionState('disconnecting')
this.stopHeartbeat()
// Clear reconnect timer
@@ -345,7 +373,7 @@ function getWebSocketClient(): WebSocketClient {
if (existing && existing instanceof WebSocketClient) {
// Check if the WebSocket is still valid
if (existing.isConnected()) {
console.log('[WebSocket] Using existing connected client from HMR')
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
wsClientInstance = existing
return existing
}
@@ -374,7 +402,7 @@ export const wsClient: WebSocketClient = (() => {
_wsClient = getWebSocketClient()
return _wsClient
} catch (error) {
console.error('[WebSocket] Error initializing client:', error)
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
// Fallback to new instance
_wsClient = new WebSocketClient()
return _wsClient
@@ -385,7 +413,7 @@ export const wsClient: WebSocketClient = (() => {
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
// Validate patch is an array before applying
if (!Array.isArray(patch) || patch.length === 0) {
console.warn('Invalid or empty patch received, returning original data')
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
return data
}
@@ -393,7 +421,7 @@ export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
const result = applyPatch(data, patch as Operation[], false, false)
return result.newDocument as T
} catch (error) {
console.error('Failed to apply patch:', error, 'Patch:', patch)
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
return data // Return original data on error
}
}