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:
parent
c5417640a2
commit
c4853fe746
@ -73,7 +73,15 @@ class FileBrowserClient {
|
|||||||
return h
|
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[]> {
|
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||||
|
await this.ensureAuth()
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
@ -100,6 +108,7 @@ class FileBrowserClient {
|
|||||||
* Use this for img/video/audio src attributes and download links.
|
* Use this for img/video/audio src attributes and download links.
|
||||||
*/
|
*/
|
||||||
async fetchBlobUrl(path: string): Promise<string> {
|
async fetchBlobUrl(path: string): Promise<string> {
|
||||||
|
await this.ensureAuth()
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
@ -125,6 +134,7 @@ class FileBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(dirPath: string, file: File): Promise<void> {
|
async upload(dirPath: string, file: File): Promise<void> {
|
||||||
|
await this.ensureAuth()
|
||||||
const sanitized = sanitizePath(dirPath)
|
const sanitized = sanitizePath(dirPath)
|
||||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||||
const encodedName = encodeURIComponent(file.name)
|
const encodedName = encodeURIComponent(file.name)
|
||||||
@ -143,6 +153,7 @@ class FileBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createFolder(parentPath: string, name: string): Promise<void> {
|
async createFolder(parentPath: string, name: string): Promise<void> {
|
||||||
|
await this.ensureAuth()
|
||||||
const sanitized = sanitizePath(parentPath)
|
const sanitized = sanitizePath(parentPath)
|
||||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||||
@ -154,6 +165,7 @@ class FileBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteItem(path: string): Promise<void> {
|
async deleteItem(path: string): Promise<void> {
|
||||||
|
await this.ensureAuth()
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@ -35,6 +35,54 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
|||||||
// The Public channel (always available on Meshcore)
|
// The Public channel (always available on Meshcore)
|
||||||
const publicChannel = { index: 0, name: 'Public' }
|
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 togglingOffGrid = ref(false)
|
||||||
const peerSessionInfo = ref<SessionStatus | null>(null)
|
const peerSessionInfo = ref<SessionStatus | null>(null)
|
||||||
|
|
||||||
@ -244,21 +292,38 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
// Active chat name for the header
|
// Active chat name for the header
|
||||||
const activeChatName = computed(() => {
|
const activeChatName = computed(() => {
|
||||||
|
if (archChannelActive.value) return 'Archipelago'
|
||||||
if (activeChatChannel.value) return activeChatChannel.value.name
|
if (activeChatChannel.value) return activeChatChannel.value.name
|
||||||
if (activeChatPeer.value) return activeChatPeer.value.advert_name
|
if (activeChatPeer.value) return activeChatPeer.value.advert_name
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeChatSub = computed(() => {
|
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)
|
if (activeChatPeer.value) return truncatePubkey(activeChatPeer.value.pubkey_hex)
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChannel.value)
|
const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChannel.value || archChannelActive.value)
|
||||||
|
|
||||||
// Messages filtered to the active chat
|
// Messages filtered to the active chat
|
||||||
const chatMessages = computed(() => {
|
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) {
|
if (activeChatChannel.value) {
|
||||||
// Channel messages have negative contact_id = -(channel_index + 1)
|
// Channel messages have negative contact_id = -(channel_index + 1)
|
||||||
const chanId = -(activeChatChannel.value.index + 1)
|
const chanId = -(activeChatChannel.value.index + 1)
|
||||||
@ -289,6 +354,7 @@ const sortedPeers = computed(() => {
|
|||||||
function openChat(peer: MeshPeer) {
|
function openChat(peer: MeshPeer) {
|
||||||
activeChatPeer.value = peer
|
activeChatPeer.value = peer
|
||||||
activeChatChannel.value = null
|
activeChatChannel.value = null
|
||||||
|
archChannelActive.value = false
|
||||||
sendError.value = ''
|
sendError.value = ''
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
activeTab.value = 'chat'
|
activeTab.value = 'chat'
|
||||||
@ -300,6 +366,7 @@ function openChat(peer: MeshPeer) {
|
|||||||
function openChannelChat(channel: { index: number; name: string }) {
|
function openChannelChat(channel: { index: number; name: string }) {
|
||||||
activeChatChannel.value = channel
|
activeChatChannel.value = channel
|
||||||
activeChatPeer.value = null
|
activeChatPeer.value = null
|
||||||
|
archChannelActive.value = false
|
||||||
sendError.value = ''
|
sendError.value = ''
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
activeTab.value = 'chat'
|
activeTab.value = 'chat'
|
||||||
@ -310,11 +377,18 @@ function openChannelChat(channel: { index: number; name: string }) {
|
|||||||
function closeChat() {
|
function closeChat() {
|
||||||
activeChatPeer.value = null
|
activeChatPeer.value = null
|
||||||
activeChatChannel.value = null
|
activeChatChannel.value = null
|
||||||
|
archChannelActive.value = false
|
||||||
|
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||||
mobileShowChat.value = false
|
mobileShowChat.value = false
|
||||||
mesh.clearViewingChat()
|
mesh.clearViewingChat()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
|
if (archChannelActive.value) {
|
||||||
|
await sendArchMessage()
|
||||||
|
nextTick(() => scrollChatToBottom())
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!activeChatPeer.value || !messageText.value.trim()) return
|
if (!activeChatPeer.value || !messageText.value.trim()) return
|
||||||
sendError.value = ''
|
sendError.value = ''
|
||||||
try {
|
try {
|
||||||
@ -498,7 +572,20 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mesh-peer-list">
|
<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
|
<div
|
||||||
class="mesh-peer-row is-channel"
|
class="mesh-peer-row is-channel"
|
||||||
:class="{ active: activeChatChannel?.index === 0 }"
|
: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-avatar channel">#</div>
|
||||||
<div class="mesh-peer-info">
|
<div class="mesh-peer-info">
|
||||||
<div class="mesh-peer-name">Public</div>
|
<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>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user