// composables/usePoll.ts import { ref, watch, onUnmounted } from 'vue'; import * as Y from 'yjs'; import { stringToCryptoKey, verifyAllVotesForOption } from '~/utils/crypto'; import type { SignedData, PollMetadata } from '~/utils/types'; export const usePoll = (pollId: Ref, user: Ref) => { const pollData = ref({}); const isConnected = ref(false); const connectionAttempFailed = ref(false); const connectedPeers = ref(1); let ydoc: Y.Doc | null = null; let provider: any = null; let yMap: Y.Map[]> | null = null; let publicKeysCache: Record = {}; const cleanup = () => { if (provider) provider.disconnect(); if (ydoc) ydoc.destroy(); isConnected.value = false; pollData.value = {}; publicKeysCache = {}; }; const initPoll = async (id: string) => { cleanup(); // Clear previous session ydoc = new Y.Doc(); // 1. Fetch Snapshot from Nuxt API try { const response = await $fetch<{ update: number[] | null, publicKeys: Record }>(`/api/polls/${id}`).catch((e) => { console.error("Failed to get poll: " + id,e) }); // Cache public keys from snapshot if (response?.publicKeys) { for (const [userId, publicKeyStr] of Object.entries(response.publicKeys)) { try { publicKeysCache[userId] = await stringToCryptoKey(publicKeyStr, 'public'); } catch (e) { console.error(`Failed to cache public key for user ${userId}:`, e); } } console.log(`Cached ${Object.keys(publicKeysCache).length} public keys`); } //trust the server without verification. if (response?.update) { Y.applyUpdate(ydoc, new Uint8Array(response.update)); } } catch (err) { console.error('Persistence fetch failed', err); } yMap = ydoc.getMap[]>('shared-poll'); // 2. Local State Sync yMap.observe(async () => { await performUpdateAndVerify(); saveStateToServer(id); }); await performUpdateAndVerify(); // 3. P2P Connection const { WebrtcProvider } = await import('y-webrtc'); provider = new WebrtcProvider(`nuxt-p2p-${id}`, ydoc, { signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"] }); provider.on('synced', (arg: {synced: boolean}) => { isConnected.value = arg.synced; console.log('Connection synced:', arg.synced) // "connected" or "disconnected" }); provider.on('status', (event: { connected: boolean }) => { console.log('Connection status:', event.connected) // "connected" or "disconnected" }) provider.on('peers', (data: any) => { connectedPeers.value = data.webrtcPeers.length + 1 }); }; const saveStateToServer = async (id: string) => { if (!ydoc) return; const stateUpdate = Y.encodeStateAsUpdate(ydoc); try { await $fetch(`/api/polls/${id}`, { method: 'POST', body: { update: Array.from(stateUpdate) } }); } catch (e: any) { console.error("Failed to update poll", e); if (e.data?.message) { alert(`Error: ${e.data.message}`); } else if (e.statusMessage) { alert(`Error: ${e.statusMessage}`); } else { alert('Failed to save poll. Please try again.'); } } }; // Watch for ID changes (e.g., user clicks a link or goes back) watch(pollId, (newId) => { if (newId && import.meta.client) { initPoll(newId); } else { cleanup(); } }, { immediate: true }); onUnmounted(cleanup); const addOption = (optionName: string) => { if (yMap && !yMap.has(optionName)) yMap.set(optionName, []); }; const performUpdateAndVerify = async () => { const pollDataUpdate = yMap!.toJSON(); console.log("Poll Data Update: ", pollDataUpdate) // Extract poll metadata if it exists const metadataSigned = pollDataUpdate['_metadata'] as SignedData | undefined; const pollMetadata = metadataSigned?.data; for(var option in pollDataUpdate){ // Skip metadata key when iterating over options if (option === '_metadata') continue; console.log("verifying votes for option: " + option); const votes = pollDataUpdate[option] || []; const verified = await verifyAllVotesForOption(votes, publicKeysCache, pollMetadata); if(!verified){ console.error("Failed to verify option: "+option) return; } } console.log("All options verified! :)") pollData.value = pollDataUpdate } const vote = async (optionName: string) => { const currentUser = user.value; if (currentUser == undefined) { alert('You must be logged in to vote. Please create a user or login with your key file.'); return; } if (yMap?.has(optionName)) { const voteData = [...(yMap.get(optionName) || [])]; // Check if user has already voted for this option const hasAlreadyVoted = voteData.some(v => v.data.userid === currentUser.userid); if (hasAlreadyVoted) { console.error(`User ${currentUser.userid} has already voted for option ${optionName}`); alert('You have already voted for this option.'); return; } if(voteData != undefined && currentUser.private_key){ var unsignedVoteData : VoteData = { userid: currentUser.userid, timestamp: new Date().toISOString() } var newVote : SignedData = { data: unsignedVoteData, signature: "", } voteData?.push(newVote) const signature = await signVote(voteData,currentUser.private_key); newVote.signature=signature yMap?.set(optionName, voteData); } } }; return { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote }; };