feat: Archipelago public channel (Tor), FileBrowser auto-login

Public Channel:
- "Archipelago" channel in Mesh — broadcasts to all federation peers over Tor
- Shows received messages from all peers with pubkey label
- Auto-polls every 15s for new messages
- Orange-branded channel icon with unread badge
- Send handler routes to Tor broadcast when arch channel is active

FileBrowser Auto-Login:
- All filebrowser-client methods now call ensureAuth() before requests
- Auto-authenticates with default credentials if not logged in
- Fixes "files don't work when FileBrowser hasn't been logged into"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-19 22:24:27 +00:00
parent c5417640a2
commit c4853fe746
2 changed files with 103 additions and 4 deletions

View File

@ -73,7 +73,15 @@ class FileBrowserClient {
return h
}
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
private async ensureAuth(): Promise<void> {
if (this.token) return
const ok = await this.login()
if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
await this.ensureAuth()
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
headers: this.headers(),
@ -100,6 +108,7 @@ class FileBrowserClient {
* Use this for img/video/audio src attributes and download links.
*/
async fetchBlobUrl(path: string): Promise<string> {
await this.ensureAuth()
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
@ -125,6 +134,7 @@ class FileBrowserClient {
}
async upload(dirPath: string, file: File): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
@ -143,6 +153,7 @@ class FileBrowserClient {
}
async createFolder(parentPath: string, name: string): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
@ -154,6 +165,7 @@ class FileBrowserClient {
}
async deleteItem(path: string): Promise<void> {
await this.ensureAuth()
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',

View File

@ -35,6 +35,54 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
// The Public channel (always available on Meshcore)
const publicChannel = { index: 0, name: 'Public' }
// Archipelago Channel Tor-based messaging to all federated/peered nodes
const archChannelActive = ref(false)
const archMessages = ref<Array<{ from_pubkey: string; message: string; timestamp: string }>>([])
const archUnread = ref(0)
let archPollInterval: ReturnType<typeof setInterval> | null = null
function openArchChannel() {
activeChatPeer.value = null
activeChatChannel.value = null
archChannelActive.value = true
archUnread.value = 0
mobileShowChat.value = true
loadArchMessages()
if (!archPollInterval) {
archPollInterval = setInterval(loadArchMessages, 15000)
}
}
async function loadArchMessages() {
try {
const res = await rpcClient.getReceivedMessages()
archMessages.value = res.messages || []
} catch { /* silent */ }
}
async function sendArchMessage() {
if (!messageText.value.trim()) return
sendError.value = ''
try {
// Broadcast to all federated peers over Tor
const nodes = await rpcClient.federationListNodes()
const msg = messageText.value.trim()
let sent = 0
for (const node of nodes.nodes) {
try {
await rpcClient.sendMessageToPeer(node.onion || node.did, msg)
sent++
} catch { /* some peers may be offline */ }
}
messageText.value = ''
if (sent === 0) sendError.value = 'No peers reachable'
// Reload to see the message
setTimeout(loadArchMessages, 2000)
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'Send failed'
}
}
const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
@ -244,21 +292,38 @@ onUnmounted(() => {
// Active chat name for the header
const activeChatName = computed(() => {
if (archChannelActive.value) return 'Archipelago'
if (activeChatChannel.value) return activeChatChannel.value.name
if (activeChatPeer.value) return activeChatPeer.value.advert_name
return ''
})
const activeChatSub = computed(() => {
if (activeChatChannel.value) return 'Mesh channel'
if (archChannelActive.value) return 'All nodes over Tor'
if (activeChatChannel.value) return 'Mesh radio'
if (activeChatPeer.value) return truncatePubkey(activeChatPeer.value.pubkey_hex)
return ''
})
const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChannel.value)
const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChannel.value || archChannelActive.value)
// Messages filtered to the active chat
const chatMessages = computed(() => {
if (archChannelActive.value) {
// Map Tor messages to mesh message format for rendering
return archMessages.value.map((m, i) => ({
id: i,
peer_contact_id: -99,
peer_name: m.from_pubkey.slice(0, 12) + '...',
direction: 'received' as const,
plaintext: m.message,
timestamp: m.timestamp,
delivered: true,
encrypted: false,
message_type: undefined,
typed_payload: undefined,
}))
}
if (activeChatChannel.value) {
// Channel messages have negative contact_id = -(channel_index + 1)
const chanId = -(activeChatChannel.value.index + 1)
@ -289,6 +354,7 @@ const sortedPeers = computed(() => {
function openChat(peer: MeshPeer) {
activeChatPeer.value = peer
activeChatChannel.value = null
archChannelActive.value = false
sendError.value = ''
messageText.value = ''
activeTab.value = 'chat'
@ -300,6 +366,7 @@ function openChat(peer: MeshPeer) {
function openChannelChat(channel: { index: number; name: string }) {
activeChatChannel.value = channel
activeChatPeer.value = null
archChannelActive.value = false
sendError.value = ''
messageText.value = ''
activeTab.value = 'chat'
@ -310,11 +377,18 @@ function openChannelChat(channel: { index: number; name: string }) {
function closeChat() {
activeChatPeer.value = null
activeChatChannel.value = null
archChannelActive.value = false
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
mobileShowChat.value = false
mesh.clearViewingChat()
}
async function handleSendMessage() {
if (archChannelActive.value) {
await sendArchMessage()
nextTick(() => scrollChatToBottom())
return
}
if (!activeChatPeer.value || !messageText.value.trim()) return
sendError.value = ''
try {
@ -498,7 +572,20 @@ function truncatePubkey(hex: string | null): string {
</div>
<div v-else class="mesh-peer-list">
<!-- Public channel -->
<!-- Archipelago Channel (Tor all nodes) -->
<div
class="mesh-peer-row is-channel"
:class="{ active: archChannelActive }"
@click="openArchChannel"
>
<div class="mesh-peer-avatar channel" style="background: rgba(251,146,60,0.2); color: #fb923c;">A</div>
<div class="mesh-peer-info">
<div class="mesh-peer-name">Archipelago</div>
<div class="mesh-peer-sub">All nodes over Tor</div>
</div>
<span v-if="archUnread > 0" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ archUnread }}</span>
</div>
<!-- Public channel (Mesh radio) -->
<div
class="mesh-peer-row is-channel"
:class="{ active: activeChatChannel?.index === 0 }"
@ -507,7 +594,7 @@ function truncatePubkey(hex: string | null): string {
<div class="mesh-peer-avatar channel">#</div>
<div class="mesh-peer-info">
<div class="mesh-peer-name">Public</div>
<div class="mesh-peer-sub">Mesh channel</div>
<div class="mesh-peer-sub">Mesh radio</div>
</div>
</div>
<div