add vote uniqueness, public key caching, relative poll timeframe, auth/rate limiting, modern UI styling, and error handling
This commit is contained in:
178
app/composables/usePoll.ts
Normal file
178
app/composables/usePoll.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// 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<string | null>, user: Ref<UserData | null>) => {
|
||||
const pollData = ref<PollData>({});
|
||||
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<SignedData<VoteData>[]> | null = null;
|
||||
let publicKeysCache: Record<string, CryptoKey> = {};
|
||||
|
||||
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<string, string> }>(`/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<SignedData<VoteData>[]>('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<PollMetadata> | 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<VoteData> = {
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user