diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a7f73a --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/README.md b/README.md index 0217c70..e57ef73 100644 --- a/README.md +++ b/README.md @@ -1 +1,77 @@ -# P2P Poll App \ No newline at end of file +# P2P Poll App + +# Nuxt Minimal Starter + +Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. + +## Setup + +Make sure to install dependencies: + +```bash +# npm +npm install + +# pnpm +pnpm install + +# yarn +yarn install + +# bun +bun install +``` + +## Development Server + +Start the development server on `http://localhost:3000`: + +```bash +# npm +npm run dev + +# pnpm +pnpm dev + +# yarn +yarn dev + +# bun +bun run dev +``` + +## Production + +Build the application for production: + +```bash +# npm +npm run build + +# pnpm +pnpm build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 0000000..49459ce --- /dev/null +++ b/app/app.vue @@ -0,0 +1,94 @@ + + + + + P2P Polling App 🗳️ + + ← Back To List + + ● {{ isConnected ? 'Synced' : 'Waiting for other Peers...' }} + + | Peers online: {{ connectedPeers }} + + ⚠ Connection to Signaling Server Failed! + + + + + + + + + + \ No newline at end of file diff --git a/app/components/Poll.vue b/app/components/Poll.vue new file mode 100644 index 0000000..b676d4c --- /dev/null +++ b/app/components/Poll.vue @@ -0,0 +1,84 @@ + + + + + Poll: {{ activePollId }} + Note: Add at least one Option to save the Poll. + + + Add Option + + + + + {{ optionName }} + + {{ votes.length }} {{ votes.length === 1 ? 'vote' : 'votes' }} + +1 + + + + + + + \ No newline at end of file diff --git a/app/components/PollList.vue b/app/components/PollList.vue new file mode 100644 index 0000000..c729874 --- /dev/null +++ b/app/components/PollList.vue @@ -0,0 +1,62 @@ + + + + + Available Polls + + + + + {{ id }} → + + + + No polls found. Create the first one! + + + Create & Join + + + + + \ No newline at end of file diff --git a/app/composables/usePoll.ts b/app/composables/usePoll.ts new file mode 100644 index 0000000..0415828 --- /dev/null +++ b/app/composables/usePoll.ts @@ -0,0 +1,99 @@ +// composables/usePoll.ts +import { ref, watch, onUnmounted } from 'vue'; +import * as Y from 'yjs'; + +export const usePoll = (pollId: 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; + + const cleanup = () => { + if (provider) provider.disconnect(); + if (ydoc) ydoc.destroy(); + isConnected.value = false; + pollData.value = {}; + }; + + 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 }>(`/api/polls/${id}`); + 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(() => { + pollData.value = yMap!.toJSON(); + saveStateToServer(id); + }); + pollData.value = yMap.toJSON(); + + // 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); + provider.on('peers', (data: any) => connectedPeers.value = data.webrtcPeers.length + 1); + }; + + const saveStateToServer = async (id: string) => { + if (!ydoc) return; + const stateUpdate = Y.encodeStateAsUpdate(ydoc); + await $fetch(`/api/polls/${id}`, { + method: 'POST', + body: { update: Array.from(stateUpdate) } + }).catch(() => {}); + }; + + // 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 vote = (optionName: string, uuid: string) => { + if (yMap?.has(optionName)) { + var voteData : SignedData[] | undefined = yMap.get(optionName) + if(voteData != undefined){ + var unsignedVoteData : VoteData = { + userid: uuid, + timestamp: new Date().toISOString() + } + var newVote : SignedData = { + data: unsignedVoteData, + signature: "", + } + voteData?.push(newVote) + yMap.set(optionName, voteData); + } + } + }; + + return { pollData, isConnected, connectionAttempFailed, connectedPeers, addOption, vote }; +}; \ No newline at end of file diff --git a/app/utils/crypto.ts b/app/utils/crypto.ts new file mode 100644 index 0000000..80ae918 --- /dev/null +++ b/app/utils/crypto.ts @@ -0,0 +1,42 @@ +// utils/crypto.ts +export const generateUserKeyPair = async () => { + return await window.crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), // 65537 + hash: "SHA-256", + }, + true, // extractable + ["sign", "verify"] + ); +}; + +export const signVote = async (data: any, privateKey: CryptoKey) => { + const encoder = new TextEncoder(); + const encodedData = encoder.encode(JSON.stringify(data)); + + const signature = await window.crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + encodedData + ); + + // Convert to Base64 or Hex to store in Yjs easily + return btoa(String.fromCharCode(...new Uint8Array(signature))); +}; + +export const verifyVote = async (data: any, signatureStr: string, publicKey: CryptoKey) => { + const encoder = new TextEncoder(); + const encodedData = encoder.encode(JSON.stringify(data)); + + // Convert Base64 back to Uint8Array + const signature = Uint8Array.from(atob(signatureStr), c => c.charCodeAt(0)); + + return await window.crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + publicKey, + signature, + encodedData + ); +}; \ No newline at end of file diff --git a/app/utils/types.ts b/app/utils/types.ts new file mode 100644 index 0000000..c84cdb7 --- /dev/null +++ b/app/utils/types.ts @@ -0,0 +1,25 @@ +export interface PollProps { + activePollId: string, + pollData: PollData, + addOption: (name: string) => void, + vote: (optionName: string,uuid: string) => void +} + +export interface PollData extends Record[]> { +} + +export interface SignedData { + data: T, + signature: string +} + +export interface VoteData { + userid: string, + timestamp: string +} + +export interface OptionData { + userid: string, + timestamp: string, + optionName: string +} \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts new file mode 100644 index 0000000..dc88dc1 --- /dev/null +++ b/nuxt.config.ts @@ -0,0 +1,19 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + devtools: { enabled: true }, + vite: { + optimizeDeps: { + include: ['yjs', 'y-webrtc'] + } + }, + // ... existing config + nitro: { + storage: { + polls: { + driver: 'fs', + base: './.data/polls' + } + } + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9e99b5 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "p2p-poll", + "type": "module", + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "PORT=4444 npx y-webrtc & nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "dependencies": { + "nuxt": "^4.1.3", + "uuid": "^13.0.0", + "vue": "^3.5.30", + "vue-router": "^5.0.3", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.30" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..18993ad Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..0ad279c --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-Agent: * +Disallow: diff --git a/server/api/polls/[id].ts b/server/api/polls/[id].ts new file mode 100644 index 0000000..c289523 --- /dev/null +++ b/server/api/polls/[id].ts @@ -0,0 +1,33 @@ +// server/api/polls/[id].ts +export default defineEventHandler(async (event) => { + const method = event.node.req.method; + const pollId = getRouterParam(event, 'id'); + + // We use Nitro's built-in storage. + // 'polls' is the storage namespace. + const storage = useStorage('polls'); + + if (!pollId) { + throw createError({ statusCode: 400, statusMessage: 'Poll ID required' }); + } + + // GET: Fetch the saved Yjs document state + if (method === 'GET') { + const data = await storage.getItem(`poll:${pollId}`); + // Return the array of numbers (or null if it doesn't exist yet) + return { update: data || null }; + } + + // POST: Save a new Yjs document state + if (method === 'POST') { + const body = await readBody(event); + + if (body.update && Array.isArray(body.update)) { + // Save the binary update (sent as an array of numbers) to storage + await storage.setItem(`poll:${pollId}`, body.update); + return { success: true }; + } + + throw createError({ statusCode: 400, statusMessage: 'Invalid update payload' }); + } +}); \ No newline at end of file diff --git a/server/api/polls/index.get.ts b/server/api/polls/index.get.ts new file mode 100644 index 0000000..4798173 --- /dev/null +++ b/server/api/polls/index.get.ts @@ -0,0 +1,15 @@ +// server/api/polls/index.get.ts +export default defineEventHandler(async () => { + const storage = useStorage('polls'); + + // Get all keys in the 'polls' namespace + const allKeys = await storage.getKeys(); + + // Filter for our specific poll prefix and strip it for the UI + // poll:my-id -> my-id + const polls = allKeys + .filter(key => key.startsWith('poll:')) + .map(key => key.replace('poll:', '')); + + return { polls }; +}); \ No newline at end of file diff --git a/server/middleware/uuid.ts b/server/middleware/uuid.ts new file mode 100644 index 0000000..3ce8eb4 --- /dev/null +++ b/server/middleware/uuid.ts @@ -0,0 +1,24 @@ +import { v4 as uuidv4 } from 'uuid'; + +export default defineEventHandler((event) => { + // 1. Check if the cookie already exists + const cookie = getCookie(event, 'user_guid'); + + // 2. If it doesn't exist, generate and set it + if (!cookie) { + const newUuid = uuidv4(); + + setCookie(event, 'user_guid', newUuid, { + maxAge: 60 * 60 * 24 * 7, // 1 week + path: '/', + // httpOnly: true, // Set to true if you DON'T need to read it in Vue/JS + sameSite: 'lax', + }); + + // 3. Inject it into the context so it's available + // to other server routes/plugins during this same request + event.context.userGuid = newUuid; + } else { + event.context.userGuid = cookie; + } +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..307b213 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "files": [], + "references": [ + { + "path": "./.nuxt/tsconfig.app.json" + }, + { + "path": "./.nuxt/tsconfig.server.json" + }, + { + "path": "./.nuxt/tsconfig.shared.json" + }, + { + "path": "./.nuxt/tsconfig.node.json" + } + ] +}
Note: Add at least one Option to save the Poll.
No polls found. Create the first one!