From c4285e32038f4f2e5251313cf33afa75878c7ada Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 16:39:14 +1100 Subject: [PATCH 01/25] x --- login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/login.html b/login.html index cb5b1cd..e84ca15 100644 --- a/login.html +++ b/login.html @@ -1,4 +1,4 @@ -V + From d57edc21cac16543e5ea0c723602bb1e4db42c59 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 16:54:21 +1100 Subject: [PATCH 02/25] x --- cloudflare worker | 2315 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2315 insertions(+) create mode 100644 cloudflare worker diff --git a/cloudflare worker b/cloudflare worker new file mode 100644 index 0000000..9a5f925 --- /dev/null +++ b/cloudflare worker @@ -0,0 +1,2315 @@ +export default { + async fetch(request, env, ctx) { + try { + const url = new URL(request.url); + + // Acceptable hostnames + const validHostnames = [ + 'theradicalparty.com', + 'www.theradicalparty.com', + 'radical.omar-c29.workers.dev' + ]; + + // Check if the hostname is valid + if (!validHostnames.includes(url.hostname)) { + console.error(`Unexpected hostname: ${url.hostname}`); + return new Response('Invalid hostname', { status: 400 }); + } + + const path = url.pathname; + const proposalId = url.searchParams.get('proposal'); + + console.log(`Processing request: ${url.toString()}`); + console.log(`User-Agent: ${request.headers.get('User-Agent')}`); + + // Handle CORS preflight requests first + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + // For GET requests, try to serve from cache first + if (request.method === "GET") { + // Create a cache key from the request URL + const cacheKey = new Request(request.url, { + headers: request.headers + }); + + // Try to get from cache first + let cachedResponse = await caches.default.match(cacheKey); + + if (cachedResponse) { + // Add header to indicate cache hit + cachedResponse = new Response(cachedResponse.body, cachedResponse); + cachedResponse.headers.set("CF-Cache-Status", "HIT"); + + // Ensure CORS headers are present + Object.keys(corsHeaders).forEach(key => { + cachedResponse.headers.set(key, corsHeaders[key]); + }); + + return cachedResponse; + } + } + + // Handle styles.css route + if (path === '/styles.css') { + try { + // Fetch CSS from GitHub Pages + const response = await fetch('https://kingomarnajjar.github.io/radical/styles.css', { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' + } + }); + + if (!response.ok) { + console.error('Failed to fetch styles.css', response.status, response.statusText); + return new Response('CSS File Not Found', { status: 404 }); + } + + // Get the CSS content + const cssContent = await response.text(); + + // Return CSS response with caching headers + const cssResponse = new Response(cssContent, { + status: 200, + headers: { + 'Content-Type': 'text/css', + 'Cache-Control': 'public, max-age=86400', // 1 day + ...corsHeaders // Add CORS headers + } + }); + + // Store in cache if this is a GET request + if (request.method === "GET") { + const cacheKey = new Request(request.url, { + headers: request.headers + }); + ctx.waitUntil(caches.default.put(cacheKey, cssResponse.clone())); + } + + return cssResponse; + } catch (error) { + console.error('Error fetching styles.css:', error); + return new Response('Internal Server Error', { status: 500 }); + } + } + + const isTestMeta = url.searchParams.get('test_meta') === 'true'; + + // Handle root path and index.html with potential meta tag testing + if (path === '/' || path === '/index.html') { + // If test_meta is true or there's a proposal, use customization + if (isTestMeta || proposalId) { + return await serveCustomizedHtml(proposalId || 'test_proposal', request, env); + } + + // Otherwise, serve main page normally + return await serveMainPage(request, env); + } + + if (path === '/api/petition-svg') { + return await servePetitionSVG(request, env); + } + + // Meta tag testing + if (url.searchParams.get('test_meta') === 'true') { + console.log('Meta tag test detected'); + + // If there's a proposal during meta test, use customization + if (proposalId && (path === '/' || path === '/index.html')) { + console.log(`Detected proposal in URL during meta test: ${proposalId}`); + return await serveCustomizedHtml(proposalId, request, env); + } + } + + console.log(`Received ${request.method} request to ${path}`); + + // Check if the DB binding exists + if (!env.DB) { + console.error('DB binding is missing! Check your wrangler.toml configuration.'); + return createResponse({ + error: 'Database configuration error. Missing DB binding.', + hint: 'Check your wrangler.toml file and make sure the DB binding is correctly set up.' + }, 500); + } + + // Handle media files (memes and audio) + if (path.startsWith('/memes/') || path.startsWith('/audio/')) { + const mediaResponse = await serveMedia(request, env); + + // Cache media responses for GET requests + if (request.method === "GET") { + const cacheKey = new Request(request.url, { + headers: request.headers + }); + + // Clone the response before modifying + const responseToCache = new Response(mediaResponse.body, mediaResponse); + + // Cache media files for 7 days + responseToCache.headers.set('Cache-Control', 'public, max-age=604800'); + + ctx.waitUntil(caches.default.put(cacheKey, responseToCache.clone())); + } + + return mediaResponse; + } else if (path === '/api/memes' && request.method === 'POST') { + return await uploadMeme(request, env); + } + + // Route requests to appropriate handlers + let response; + + if (path === '/api/health') { + response = await healthCheck(env); + } else if (path === '/api/debug-meta') { + response = await debugMetaTags(request, env); + } else if (path === '/login' || path === '/login.html') { + response = await serveLoginPage(request, env); + } else if (path === '/api/login' && request.method === 'POST') { + response = await validateLogin(request, env); + } else if (path === '/api/proposals' && request.method === 'GET') { + response = await getProposals(request, env); + } else if (path === '/api/proposals' && request.method === 'POST') { + response = await createProposal(request, env); + } else if (path.startsWith('/api/proposals/') && request.method === 'GET') { + response = await getProposalById(request, env); + } else if (path.startsWith('/api/proposals/') && request.method === 'PUT') { + const id = path.split('/').pop(); + response = await updateProposal(id, request, env); + } else if (path === '/api/votes' && request.method === 'POST') { + response = await createOrUpdateVote(request, env); + } else if (path === '/api/users' && request.method === 'POST') { + response = await createOrGetUser(request, env); + } else if (path === '/api/petition-stats' && request.method === 'GET') { + response = await getPetitionStats(request, env); + } else if (path === '/api/comments' && request.method === 'GET') { + response = await getComments(request, env); + } else if (path === '/api/comments' && request.method === 'POST') { + response = await createComment(request, env); + } else if (path === '/api/comment-votes' && request.method === 'POST') { + response = await createOrUpdateCommentVote(request, env); + } else if (path === '/api/comment-votes' && request.method === 'GET') { + response = await getCommentVotes(request, env); + } else { + response = createResponse({ + error: 'Not found', + path, + method: request.method, + availableRoutes: [ + { path: '/api/health', method: 'GET', description: 'Check system health' }, + { path: '/api/proposals', method: 'GET', description: 'Get proposals' }, + { path: '/api/proposals/:id', method: 'GET', description: 'Get a single proposal by ID' }, + { path: '/api/proposals/:id', method: 'PUT', description: 'Update proposal' }, + { path: '/api/votes', method: 'POST', description: 'Create/update vote' }, + { path: '/api/users', method: 'POST', description: 'Create/get user' }, + { path: '/api/petition-stats', method: 'GET', description: 'Get petition statistics' } + ] + }, 404); + } + + // Cache certain GET responses + if (request.method === "GET") { + const shouldCache = path.startsWith('/api/proposals') || + path.startsWith('/api/comments') || + path === '/api/petition-stats' || + path === '/api/health'; + + if (shouldCache && response) { + // We need to clone the response before reading its body + const responseToCache = new Response(response.body, response); + + // Set appropriate cache duration based on endpoint + let cacheDuration = 300; // 5 minutes default for API + if (path === '/api/health') { + cacheDuration = 3600; // 1 hour for health checks + } + + responseToCache.headers.set('Cache-Control', `public, max-age=${cacheDuration}`); + responseToCache.headers.set('CF-Cache-Status', 'MISS'); + + // Ensure all CORS headers are present + Object.keys(corsHeaders).forEach(key => { + responseToCache.headers.set(key, corsHeaders[key]); + }); + + const cacheKey = new Request(request.url, { + headers: request.headers + }); + + ctx.waitUntil(caches.default.put(cacheKey, responseToCache.clone())); + + // Make sure the response we return also has CORS headers + Object.keys(corsHeaders).forEach(key => { + response.headers.set(key, corsHeaders[key]); + }); + } + } + + return response; + + } catch (error) { + console.error('Critical error in request handling:', error); + return new Response(JSON.stringify({ + error: 'Internal Server Error', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders // Add CORS headers to error responses too + } + }); + } + } +}; + + +async function serveMainPage(request, env) { + try { + // Fetch from GitHub Pages + const response = await fetch('https://kingomarnajjar.github.io/radical/index.html', { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' + } + }); + + if (!response.ok) { + console.error('Failed to fetch main page', response.status, response.statusText); + return new Response('Failed to load page', { status: 500 }); + } + + // Read the HTML content + const html = await response.text(); + + // Create a new response with the HTML content + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=86400', + // Add CORS headers if needed + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Comprehensive error serving main page:', error); + return new Response(`Internal Server Error: ${error.message}`, { status: 500 }); + } +} + +async function serveLoginPage(request, env) { + try { + // Fetch the login HTML file from your storage + // You could store this in R2 bucket or GitHub Pages like your other static files + const response = await fetch('https://kingomarnajjar.github.io/radical/login.html', { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' + } + }); + + if (!response.ok) { + console.error('Failed to fetch login page', response.status, response.statusText); + return new Response('Failed to load login page', { status: 500 }); + } + + // Read the HTML content + const html = await response.text(); + + // Create a new response with the HTML content + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=86400', + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Error serving login page:', error); + return new Response(`Internal Server Error: ${error.message}`, { status: 500 }); + } +} + +// Function to validate user login with emoji combination and selections +async function validateLogin(request, env) { + try { + const data = await request.json(); + const { + emojiCombination, + selectedDictator, + selectedTarget + } = data; + + console.log(`Processing login attempt: ${JSON.stringify({ + emojiCount: emojiCombination?.length || 0, + dictator: selectedDictator, + target: selectedTarget + })}`); + + // Basic validation + if (!emojiCombination || !selectedDictator || !selectedTarget) { + return createResponse({ + error: 'Missing required fields', + received: { + hasEmojis: !!emojiCombination, + hasDictator: !!selectedDictator, + hasTarget: !!selectedTarget + } + }, 400); + } + + // Convert emoji array to string for storage/comparison + const emojiCombinationString = Array.isArray(emojiCombination) + ? emojiCombination.join('') + : emojiCombination; + + // Check if dictator exists + const dictatorCheck = await env.DB.prepare( + `SELECT id FROM dictators WHERE id = ?` + ).bind(selectedDictator).first(); + + if (!dictatorCheck) { + return createResponse({ + error: 'Invalid dictator selection', + dictatorId: selectedDictator + }, 400); + } + + // Check if target exists + const targetCheck = await env.DB.prepare( + `SELECT id FROM time_targets WHERE id = ?` + ).bind(selectedTarget).first(); + + if (!targetCheck) { + return createResponse({ + error: 'Invalid time target selection', + targetId: selectedTarget + }, 400); + } + + // Record the login attempt (for security tracking) + const attemptId = 'login_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const timestamp = Date.now(); + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + + try { + // Try to find a user with the exact combination + const user = await env.DB.prepare(` + SELECT id, name, emoji_combination, selected_dictator_id, selected_target_id + FROM users + WHERE emoji_combination = ? + AND selected_dictator_id = ? + AND selected_target_id = ? + `).bind( + emojiCombinationString, + selectedDictator, + selectedTarget + ).first(); + + // Log the attempt, regardless of outcome + await env.DB.prepare(` + INSERT INTO login_attempts ( + id, + user_id, + emoji_combination, + dictator_id, + target_id, + timestamp, + ip_address, + success + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + attemptId, + user ? user.id : null, + emojiCombinationString, + selectedDictator, + selectedTarget, + timestamp, + clientIP, + user ? 1 : 0 + ).run(); + + if (user) { + // Login successful - Update last login time + await env.DB.prepare(` + UPDATE users + SET last_login = ? + WHERE id = ? + `).bind(timestamp, user.id).run(); + + console.log(`Successful login for user ${user.id}`); + + // Return success with user info (excluding sensitive data) + return createResponse({ + success: true, + userId: user.id, + username: user.name, + message: "Login successful" + }); + } else { + // No matching user found - this could be a new signup + // Check if this is a unique emoji combination + const existingCombo = await env.DB.prepare(` + SELECT COUNT(*) as count + FROM users + WHERE emoji_combination = ? + `).bind(emojiCombinationString).first(); + + if (existingCombo.count > 0) { + // Emoji combination exists but other parameters don't match + return createResponse({ + error: "Invalid credentials", + message: "The combination you entered is incorrect" + }, 401); + } + + // This is a new user registration + const newUserId = 'citizen_' + Math.random().toString(36).substr(2, 9); + const newUsername = 'Citizen ' + Math.floor(Math.random() * 9000 + 1000); + + // Create the new user + await env.DB.prepare(` + INSERT INTO users ( + id, + name, + created_at, + emoji_combination, + selected_dictator_id, + selected_target_id, + last_login + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind( + newUserId, + newUsername, + timestamp, + emojiCombinationString, + selectedDictator, + selectedTarget, + timestamp + ).run(); + + console.log(`Created new user ${newUserId} with emoji login`); + + // Return success with new user info + return createResponse({ + success: true, + isNewUser: true, + userId: newUserId, + username: newUsername, + message: "Account created successfully" + }); + } + } catch (dbError) { + console.error('Database error during login validation:', dbError); + + return createResponse({ + error: "Login processing error", + details: "An error occurred while processing your login" + }, 500); + } + } catch (error) { + console.error('Error in login validation:', error); + + return createResponse({ + error: "Login request error", + message: "Could not process login request" + }, 500); + } +} + +async function createOrUpdateCommentVote(request, env) { + try { + const data = await request.json(); + const { commentId, userId, voteType } = data; + + console.log(`Processing comment vote: ${JSON.stringify({ + commentId, + userId, + voteType + })}`); + + if (!commentId || !userId || !voteType) { + return createResponse({ + error: 'Missing required fields', + received: { commentId, userId, voteType: voteType || 'undefined' } + }, 400); + } + + if (voteType !== 'upvote' && voteType !== 'downvote') { + return createResponse({ + error: 'Invalid vote type', + voteType, + allowedValues: ['upvote', 'downvote'] + }, 400); + } + + // Check if entities exist + try { + const userCheck = await env.DB.prepare(`SELECT id FROM users WHERE id = ?`).bind(userId).first(); + if (!userCheck) { + return createResponse({ error: 'User does not exist', userId }, 404); + } + + const commentCheck = await env.DB.prepare(`SELECT id FROM comments WHERE id = ?`).bind(commentId).first(); + if (!commentCheck) { + return createResponse({ error: 'Comment does not exist', commentId }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking user or comment existence', + details: logError(checkError, { action: 'check_entities', userId, commentId }) + }, 500); + } + + // Check if vote already exists + let existingVote; + try { + const checkQuery = ` + SELECT id, vote_type FROM comment_votes + WHERE comment_id = ? AND user_id = ? + `; + + existingVote = await env.DB.prepare(checkQuery).bind(commentId, userId).first(); + console.log(`Existing comment vote check: ${existingVote ? JSON.stringify(existingVote) : 'None found'}`); + } catch (voteCheckError) { + return createResponse({ + error: 'Error checking existing comment vote', + details: logError(voteCheckError, { action: 'check_comment_vote', userId, commentId }) + }, 500); + } + + const timestamp = Date.now(); + const voteId = existingVote ? existingVote.id : 'comment_vote_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + + try { + // Process vote + if (existingVote) { + // If vote exists and it's the same type, remove it (toggle off) + if (existingVote.vote_type === voteType) { + await env.DB.prepare(` + DELETE FROM comment_votes + WHERE id = ? + `).bind(existingVote.id).run(); + + console.log(`Deleted comment vote ${existingVote.id} (toggle off)`); + + return createResponse({ + commentId, + userId, + voteType: null, + action: 'removed' + }); + } else { + // If vote exists but is different type, update it + await env.DB.prepare(` + UPDATE comment_votes + SET vote_type = ?, timestamp = ? + WHERE id = ? + `).bind( + voteType, + timestamp, + existingVote.id + ).run(); + + console.log(`Updated comment vote ${existingVote.id} to ${voteType}`); + + return createResponse({ + commentId, + userId, + voteType, + action: 'updated' + }); + } + } else { + // If no vote exists, create new one + await env.DB.prepare(` + INSERT INTO comment_votes (id, comment_id, user_id, vote_type, timestamp) + VALUES (?, ?, ?, ?, ?) + `).bind( + voteId, + commentId, + userId, + voteType, + timestamp + ).run(); + + console.log(`Created new comment vote with ID: ${voteId}`); + + return createResponse({ + commentId, + userId, + voteType, + action: 'created' + }); + } + } catch (dbError) { + return createResponse({ + error: 'Failed to process comment vote in database', + details: logError(dbError, { + action: 'process_comment_vote', + existingVote: existingVote || null, + newVote: { commentId, userId, voteType } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comment vote request', + details: logError(error, { action: 'comment_vote_outer' }) + }, 500); + } +} + +// Function to get votes for a specific comment +async function getCommentVotes(request, env) { + try { + const url = new URL(request.url); + const commentId = url.searchParams.get('commentId'); + const userId = url.searchParams.get('userId'); + + if (!commentId) { + return createResponse({ error: 'Missing commentId parameter' }, 400); + } + + // Get vote counts + const query = ` + SELECT + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = ? AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = ? AND vote_type = 'downvote') as downvotes + `; + + try { + const voteCounts = await env.DB.prepare(query).bind(commentId, commentId).first(); + + let userVote = null; + // If userId is provided, get the user's vote + if (userId) { + const userVoteQuery = ` + SELECT vote_type FROM comment_votes + WHERE comment_id = ? AND user_id = ? + `; + + const userVoteResult = await env.DB.prepare(userVoteQuery).bind(commentId, userId).first(); + if (userVoteResult) { + userVote = userVoteResult.vote_type; + } + } + + return createResponse({ + commentId, + upvotes: voteCounts.upvotes || 0, + downvotes: voteCounts.downvotes || 0, + userVote + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve comment votes', + details: logError(dbError, { action: 'get_comment_votes', commentId }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comment votes request', + details: logError(error, { action: 'get_comment_votes_outer' }) + }, 500); + } +} + + + +// Serve meme files +async function serveMeme(request, env) { + const url = new URL(request.url); + const path = url.pathname; + const key = path.replace('/memes/', ''); + + // Handle CORS preflight requests + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + try { + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + return new Response('Meme not found', { status: 404 }); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year + + // Add CORS headers + Object.keys(corsHeaders).forEach(key => { + headers.set(key, corsHeaders[key]); + }); + + return new Response(object.body, { + headers + }); + } catch (error) { + console.error('Error serving meme:', error); + return new Response('Internal Server Error', { status: 500 }); + } +} + +// Debug endpoint to help diagnose meta tag issues +async function debugMetaTags(request, env) { + try { + // Create a sample proposal for testing + const testProposal = { + id: 'test_proposal', + text: 'This is a test proposal for meta tags', + author_name: 'Test User', + timestamp: Date.now() + }; + + // Generate a shareable image URL + const shareImageUrl = getShareableImageUrl(testProposal); + + // Prepare detailed meta tag information + const metaTagInfo = { + og_image: shareImageUrl, + og_title: 'RADICAL Meta Tag Test', + og_description: 'Testing meta tag generation and injection', + twitter_image: shareImageUrl, + twitter_title: 'RADICAL Meta Tag Verification', + twitter_description: 'Checking social media sharing metadata' + }; + + // Return detailed information about the meta tags + return createResponse({ + status: 'success', + message: 'Meta tag debug information', + shareImageUrl: shareImageUrl, + metaTagDetails: metaTagInfo + }); + } catch (error) { + return createResponse({ + error: 'Debug meta tags endpoint error', + details: logError(error, { action: 'debug_meta_tags' }) + }, 500); + } +} + +// Handle meme uploads to R2 storage +async function uploadMeme(request, env) { + try { + // Check if R2 binding exists + if (!env.MEMES_BUCKET) { + return createResponse({ + error: 'Storage binding missing. Check your settings configuration.' + }, 500); + } + + // Handle multipart form data + const formData = await request.formData(); + + // Get the proposal ID + const proposalId = formData.get('proposalId'); + if (!proposalId) { + return createResponse({ error: 'Missing proposalId' }, 400); + } + + // Get the file + const file = formData.get('meme'); + if (!file || !(file instanceof File)) { + return createResponse({ error: 'No file uploaded or invalid file' }, 400); + } + + // Validate file type + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + return createResponse({ + error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.', + received: file.type + }, 400); + } + + // Validate file size (limit to 2MB) + const MAX_SIZE = 2 * 1024 * 1024; // 2MB + if (file.size > MAX_SIZE) { + return createResponse({ + error: 'File too large. Maximum size is 2MB.', + size: file.size, + maxSize: MAX_SIZE + }, 400); + } + + // Create a unique filename + const fileExt = file.name.split('.').pop(); + const uniqueFilename = `${proposalId}_${Date.now()}.${fileExt}`; + + // Upload to R2 + await env.MEMES_BUCKET.put(uniqueFilename, file.stream(), { + httpMetadata: { + contentType: file.type, + }, + }); + + // Generate the public URL + const memeUrl = `https://radical.omar-c29.workers.dev/memes/${uniqueFilename}`; + + // Helper function to fix broken meme URLs + function fixMemeUrl(url) { + // Check if the URL contains the problematic domain + if (url && url.includes('memes.undefined')) { + // Extract the filename from the URL + const filename = url.split('/').pop(); + // Replace with the correct domain + return `https://radical.omar-c29.workers.dev/memes/${filename}`; + } + return url; + } + + // Update the proposal with the meme URL + try { + await env.DB.prepare(` + UPDATE proposals + SET meme_url = ? + WHERE id = ? + `).bind(memeUrl, proposalId).run(); + + console.log(`Added meme ${uniqueFilename} to proposal ${proposalId}`); + + return createResponse({ + success: true, + proposalId, + memeUrl + }); + } catch (dbError) { + // If DB update fails, try to delete the uploaded file + try { + await env.MEMES_BUCKET.delete(uniqueFilename); + } catch (deleteError) { + console.error('Failed to delete orphaned file:', deleteError); + } + + return createResponse({ + error: 'Failed to update proposal with meme URL', + details: logError(dbError, { action: 'update_proposal_meme', proposalId }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process meme upload', + details: logError(error, { action: 'upload_meme' }) + }, 500); + } +}// Get petition statistics +async function getPetitionStats(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('proposalId'); + + if (!proposalId) { + return createResponse({ error: 'Missing proposalId parameter' }, 400); + } + + // Get petition statistics + const statsQuery = ` + SELECT + COUNT(DISTINCT pd.user_id) as verified_count, + (SELECT COUNT(*) FROM votes WHERE proposal_id = ? AND vote_type = 'upvote' AND is_petition = 1) as total_signatures, + (SELECT text FROM proposals WHERE id = ?) as proposal_text + FROM petition_details pd + WHERE pd.proposal_id = ? AND pd.verified = 1 + `; + + const stats = await env.DB.prepare(statsQuery).bind(proposalId, proposalId, proposalId).first(); + + if (!stats) { + return createResponse({ error: 'Proposal not found' }, 404); + } + + // Get top postcodes for this petition + const postcodesQuery = ` + SELECT postcode, COUNT(*) as count + FROM petition_details + WHERE proposal_id = ? AND verified = 1 + GROUP BY postcode + ORDER BY count DESC + LIMIT 5 + `; + + const postcodes = await env.DB.prepare(postcodesQuery).bind(proposalId).all(); + + return createResponse({ + proposalId, + proposalText: stats.proposal_text, + verifiedSignatures: stats.verified_count, + totalSignatures: stats.total_signatures, + topPostcodes: postcodes.results + }); + } catch (error) { + return createResponse({ + error: 'Failed to process petition stats request', + details: logError(error, { action: 'petition_stats', proposalId }) + }, 500); + } +}// Define CORS headers +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +// Helper to log detailed errors +function logError(error, context = {}) { + const errorDetails = { + message: error.message, + stack: error.stack, + name: error.name, + context: context + }; + + console.error(JSON.stringify(errorDetails)); + return errorDetails; +} + +// Handle OPTIONS request for CORS preflight +function handleOptions() { + return new Response(null, { + headers: corsHeaders + }); +} + +// Helper to create standardized responses +function createResponse(body, status = 200, shouldCache = false, cacheDuration = 300) { + const headers = { + 'Content-Type': 'application/json', + ...corsHeaders + }; + + if (shouldCache) { + headers['Cache-Control'] = `public, max-age=${cacheDuration}`; + } else { + headers['Cache-Control'] = 'no-store'; + } + + return new Response(JSON.stringify(body), { + status, + headers + }); +} + + +async function verifyTemplateImage(env) { + try { + const key = "petition-template.png"; + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + console.error("❌ Template image not found in R2 bucket!"); + return false; + } + + console.log("✅ Template image found in R2 bucket"); + return true; + } catch (error) { + console.error("Error verifying template image:", error); + return false; + } +} + + +/** + * Handle requests for dynamic SVG images for petitions + * @param {Request} request - The incoming request + * @param {Object} env - Environment bindings + * @returns {Response} - SVG response + */ +async function servePetitionSVG(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('id'); + + if (!proposalId) { + return new Response('Missing proposal ID', { status: 400 }); + } + + console.log(`Generating SVG image for proposal: ${proposalId}`); + + // Fetch the proposal data from the database + const proposal = await env.DB.prepare(` + SELECT + p.id, p.text, p.timestamp, + u.name as author_name, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `).bind(proposalId).first(); + + if (!proposal) { + return new Response('Proposal not found', { status: 404 }); + } + + // Generate the SVG + const svgMarkup = generatePetitionSVG(proposal); + + // Return the SVG with appropriate headers + return new Response(svgMarkup, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=360000', // Cache for 1 hour + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Error generating petition SVG:', error); + return new Response('Internal Server Error', { status: 500 }); + } +} + +/** + * Generates a dynamic SVG for a petition with the given data + * @param {Object} proposal - The petition/proposal data + * @returns {string} - SVG markup as a string + */ +function generatePetitionSVG(proposal) { + // Get data with fallbacks + const petitionText = proposal.text || "Unknown petition"; + const upvotes = proposal.upvotes || 0; + const downvotes = proposal.downvotes || 0; + const netVotes = upvotes - downvotes; + const netVotesDisplay = netVotes > 0 ? `+${netVotes}` : netVotes; + const netVotesColor = netVotes > 0 ? "#11cc77" : (netVotes < 0 ? "#ff0099" : "#ffffff"); + const proposalId = proposal.id || "unknown"; + + // Format timestamp + const timestamp = new Date(proposal.timestamp || Date.now()).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + // Base SVG template + const svgTemplate = ` + + + + + + + + + RADICAL + + + + VIBE, VOTE, VETO + + + + + + ${generateSVGTextLines(petitionText, 120, 220, 960)} + + + + + + ${upvotes} + + + + ${downvotes} + + + NET: + ${netVotesDisplay} + + + + + Petition ID: ${proposalId} • Created: ${timestamp} + + + + + + + RADICAL + PETITION + #${proposalId.slice(-6)} + + + + theradicalparty.com +`; + + return svgTemplate; +} + +/** + * Generates SVG text elements with proper line wrapping + * @param {string} text - The text to wrap + * @param {number} x - Starting x position + * @param {number} y - Starting y position + * @param {number} width - Maximum width for text wrapping + * @returns {string} - SVG text elements + */ +function generateSVGTextLines(text, x, y, width) { + // Simple word wrapping algorithm + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + // Estimate characters per line based on width and average character width + const avgCharWidth = 22; // Rough estimate for font size ~36px + const charsPerLine = Math.floor(width / avgCharWidth); + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (testLine.length <= charsPerLine) { + currentLine = testLine; + } else { + lines.push(currentLine); + currentLine = word; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + // Limit to 6 lines to fit in the container + const limitedLines = lines.slice(0, 6); + if (lines.length > 6) { + // Add ellipsis to the last line if truncated + limitedLines[5] = limitedLines[5].slice(0, -3) + '...'; + } + + // Generate SVG text elements + const lineHeight = 46; + let svgTextElements = ''; + + limitedLines.forEach((line, index) => { + const lineY = y + index * lineHeight; + svgTextElements += `${escapeHTML(line)}\n`; + }); + + return svgTextElements; +} + +/** + * Escapes HTML special characters in a string + * @param {string} text - The text to escape + * @returns {string} - Escaped text + */ +function escapeHTML(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Update the getShareableImageUrl function to use the SVG endpoint +function getShareableImageUrl(proposal) { + // Use the dynamic SVG endpoint with the proposal ID + return `https://${new URL(request.url).hostname}/api/petition-svg?id=${proposal.id}`; +} + +// Update the serveCustomizedHtml function to use the SVG URL for meta tags +async function serveCustomizedHtml(proposalId, request, env) { + console.log(`===== BEGIN serveCustomizedHtml for proposal ${proposalId} =====`); + + try { + const url = new URL(request.url); + const isTestMeta = url.searchParams.get('test_meta') === 'true'; + + // Determine which proposal to use + let proposal; + let shareImageUrl; + + if (isTestMeta) { + // Use test proposal data + proposal = { + id: 'test_proposal', + text: 'This is a test proposal for meta tags', + timestamp: Date.now(), + author_name: 'Test User' + }; + shareImageUrl = `https://${url.hostname}/api/petition-svg?id=test_proposal`; + } else { + // Fetch the actual proposal from the database + proposal = await env.DB.prepare(` + SELECT + p.id, p.text, p.timestamp, + u.name as author_name + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `).bind(proposalId).first(); + + if (!proposal) { + console.error(`Proposal not found: ${proposalId}`); + return await fetch(request); + } + + // Generate SVG URL + shareImageUrl = `https://${url.hostname}/api/petition-svg?id=${proposalId}`; + } + + console.log(`Found proposal: ${JSON.stringify(proposal)}`); + console.log(`Using share image URL: ${shareImageUrl}`); + + // Get the full URL for this proposal + const fullProposalUrl = `https://${url.hostname}/?proposal=${proposalId}`; + + // Fetch the original HTML + const response = await fetch(request); + const html = await response.text(); + + // First, remove all existing og:image and twitter:image tags + let modifiedHtml = html.replace(/]*>/g, ''); + modifiedHtml = modifiedHtml.replace(/]*>/g, ''); + modifiedHtml = modifiedHtml.replace(/]*>/g, ''); + + // Find the head end tag + const headEndIndex = modifiedHtml.indexOf(''); + if (headEndIndex === -1) { + console.error("Could not find tag in HTML"); + return new Response(modifiedHtml, { + headers: response.headers + }); + } + + // Create comprehensive meta tags + const customMetaTags = ` + + + + + + + + + + + + + + + + + + + + + + + `; + + // Insert our meta tags right before + let customizedHtml = modifiedHtml.substring(0, headEndIndex) + customMetaTags + modifiedHtml.substring(headEndIndex); + + console.log(`===== END serveCustomizedHtml for proposal ${proposalId} =====`); + + // Return the customized HTML with appropriate headers + const originalHeaders = new Headers(response.headers); + originalHeaders.set('Cache-Control', 'no-store, max-age=0'); + originalHeaders.set('X-Custom-Meta', isTestMeta ? 'test' : 'proposal'); + originalHeaders.set('X-Share-Image-Url', shareImageUrl); // Debug header + + return new Response(customizedHtml, { + headers: originalHeaders + }); + } catch (error) { + console.error('Error generating customized HTML:', error); + // Fall back to regular page if something goes wrong + return await fetch(request); + } +} + + + + +// Function to check if a table exists +async function tableExists(env, tableName) { + try { + const query = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?"; + const result = await env.DB.prepare(query).bind(tableName).first(); + return !!result; + } catch (error) { + logError(error, { action: 'check_table_exists', tableName }); + return false; + } +} + +// Simple health check endpoint +async function healthCheck(env) { + try { + // Check if DB binding exists + if (!env.DB) { + return createResponse({ + status: 'error', + error: 'Database binding missing. Check wrangler.toml configuration.' + }, 500); + } + + // Check if we can execute a simple query + try { + const result = await env.DB.prepare("SELECT 1 as db_check").first(); + if (!result || result.db_check !== 1) { + return createResponse({ + status: 'error', + error: 'Database query failed to return expected result' + }, 500); + } + } catch (dbError) { + return createResponse({ + status: 'error', + error: 'Database query execution failed', + details: logError(dbError, { action: 'health_check_query' }) + }, 500); + } + + // Check if required tables exist + const tables = ['users', 'proposals', 'votes', 'petition_details']; + const tableStatus = {}; + + for (const table of tables) { + tableStatus[table] = await tableExists(env, table); + } + + const missingTables = Object.keys(tableStatus).filter(table => !tableStatus[table]); + + if (missingTables.length > 0) { + return createResponse({ + status: 'warning', + message: 'Database connected but some tables are missing', + missingTables, + tableStatus + }, 200); + } + + return createResponse({ + status: 'ok', + message: 'Database connection and tables verified', + database: env.DB.name || 'unknown', + tableStatus + }, 200); + } catch (error) { + return createResponse({ + status: 'error', + error: 'Health check failed', + details: logError(error, { action: 'health_check' }) + }, 500); + } +} + +// Get proposals with optional sorting +async function getProposals(request, env) { + try { + const url = new URL(request.url); + const sortBy = url.searchParams.get('sortBy') || 'newest'; + + // Log request details + console.log(`Getting proposals with sort: ${sortBy}`); + + let query = ` + SELECT + p.id, p.text, p.timestamp, p.trending, p.meme_url, + u.name as author_name, u.id as author_id, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures, + (SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners + FROM proposals p + JOIN users u ON p.author_id = u.id + `; + // Add sorting + if (sortBy === 'newest') { + query += ' ORDER BY p.timestamp DESC'; + } else if (sortBy === 'popular') { + query += ` + ORDER BY ( + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') - + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') + ) DESC + `; + } else if (sortBy === 'controversial') { + query += ` + ORDER BY ( + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') + + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') + ) DESC + `; + } else if (sortBy === 'petitions') { + // New sorting option for most petitioned + query += ` + ORDER BY ( + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) + ) DESC + `; + } + + console.log(`Executing query: ${query}`); + + // Get user ID for checking existing votes + const userId = url.searchParams.get('userId'); + let proposals; + + try { + proposals = await env.DB.prepare(query).all(); + console.log(`Retrieved ${proposals.results.length} proposals`); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve proposals', + details: logError(dbError, { action: 'get_proposals', query }) + }, 500); + } + + // If we have a userId, get their votes for these proposals + if (userId && proposals.results.length > 0) { + const proposalIds = proposals.results.map(p => p.id); + + try { + // Use parameter binding with placeholders + let placeholderStr = ''; + for (let i = 0; i < proposalIds.length; i++) { + placeholderStr += (i === 0 ? '?' : ',?'); + } + + const votesQuery = ` + SELECT proposal_id, vote_type, is_petition + FROM votes + WHERE user_id = ? AND proposal_id IN (${placeholderStr}) + `; + + console.log(`Executing votes query for user ${userId} with ${proposalIds.length} proposals`); + + const bindParams = [userId, ...proposalIds]; + const votes = await env.DB.prepare(votesQuery).bind(...bindParams).all(); + + console.log(`Retrieved ${votes.results.length} votes for user ${userId}`); + + // Create a map of proposal_id to vote info + const voteMap = {}; + for (const vote of votes.results) { + voteMap[vote.proposal_id] = { + voteType: vote.vote_type, + isPetition: vote.is_petition === 1 + }; + } + + // Also get petition details status + const petitionQuery = ` + SELECT proposal_id, verified + FROM petition_details + WHERE user_id = ? AND proposal_id IN (${placeholderStr}) + `; + + const petitionDetails = await env.DB.prepare(petitionQuery).bind(...bindParams).all(); + const petitionMap = {}; + + for (const petition of petitionDetails.results) { + petitionMap[petition.proposal_id] = { + verified: petition.verified === 1 + }; + } + + // Add user's vote and petition status to each proposal + for (const proposal of proposals.results) { + const voteInfo = voteMap[proposal.id]; + proposal.userVote = voteInfo ? voteInfo.voteType : null; + proposal.userPetitionSigned = voteInfo ? voteInfo.isPetition : false; + proposal.userPetitionVerified = petitionMap[proposal.id] ? petitionMap[proposal.id].verified : false; + } + } catch (voteError) { + // Just log the error but continue - we'll return proposals without vote data + logError(voteError, { + action: 'get_user_votes', + userId, + proposalCount: proposalIds.length + }); + console.log('Warning: Failed to retrieve user votes, continuing without vote data'); + } + } + + return createResponse(proposals.results); + } catch (error) { + return createResponse({ + error: 'Failed to process proposals request', + details: logError(error, { action: 'get_proposals_outer' }) + }, 500); + } +} + +// Get a single proposal by ID +async function getProposalById(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.pathname.split('/api/proposals/')[1]; + const userId = url.searchParams.get('userId'); + + if (!proposalId) { + return createResponse({ error: 'Missing proposal ID' }, 400); + } + + console.log(`Getting proposal with ID: ${proposalId}`); + + let query = ` + SELECT + p.id, p.text, p.timestamp, p.trending, p.meme_url, + u.name as author_name, u.id as author_id, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures, + (SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `; + + try { + const proposal = await env.DB.prepare(query).bind(proposalId).first(); + + if (!proposal) { + return createResponse({ error: 'Proposal not found' }, 404); + } + + // If we have a userId, get their votes for this proposal + if (userId) { + try { + const votesQuery = ` + SELECT proposal_id, vote_type, is_petition + FROM votes + WHERE user_id = ? AND proposal_id = ? + `; + + const vote = await env.DB.prepare(votesQuery).bind(userId, proposalId).first(); + + if (vote) { + proposal.userVote = vote.vote_type; + proposal.userPetitionSigned = vote.is_petition === 1; + } else { + proposal.userVote = null; + proposal.userPetitionSigned = false; + } + + // Also get petition details status + const petitionQuery = ` + SELECT proposal_id, verified + FROM petition_details + WHERE user_id = ? AND proposal_id = ? + `; + + const petition = await env.DB.prepare(petitionQuery).bind(userId, proposalId).first(); + + if (petition) { + proposal.userPetitionVerified = petition.verified === 1; + } else { + proposal.userPetitionVerified = false; + } + } catch (voteError) { + // Just log the error but continue - we'll return the proposal without vote data + logError(voteError, { + action: 'get_user_votes', + userId, + proposalId + }); + console.log('Warning: Failed to retrieve user votes, continuing without vote data'); + } + } + + return createResponse(proposal); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve proposal', + details: logError(dbError, { action: 'get_proposal_by_id', proposalId, query }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process proposal request', + details: logError(error, { action: 'get_proposal_by_id_outer' }) + }, 500); + } +} + +async function generateAndStoreShareImage(proposal, env) { + try { + // Get base text for the image + const text = proposal.text || "Unknown petition"; + const authorName = proposal.author_name || "Anonymous"; + + // Create Cloudinary URL for transformation + const cloudinaryUrl = "https://res.cloudinary.com/dh8apmjya/image/fetch"; + + // Encode text for URL parameters + const encodedText = encodeURIComponent(text.substring(0, 80)) + .replace(/'/g, '%27') + .replace(/"/g, '%22'); + + // Create transformation parameters + const params = `b_black,w_1200,h_630,c_fill/l_text:Arial_32_bold:${encodedText},co_white,w_1000,c_fit,g_center`; + + // Template image URL + const templateUrl = encodeURIComponent("https://radical.omar-c29.workers.dev/memes/petition-template.png"); + + // Create a unique identifier for this image + const imageId = `share_${proposal.id}_${Date.now()}`; + + // Final Cloudinary URL + const cloudinaryImageUrl = `${cloudinaryUrl}/${params}/${templateUrl}`; + + console.log(`Generating share image: ${cloudinaryImageUrl}`); + + // Fetch the image from Cloudinary + const imageResponse = await fetch(cloudinaryImageUrl); + + if (!imageResponse.ok) { + throw new Error(`Failed to fetch image: ${imageResponse.status}`); + } + + // Get the image as a blob + const imageBlob = await imageResponse.blob(); + + // Upload the image to your R2 bucket + await env.MEMES_BUCKET.put(imageId, imageBlob, { + httpMetadata: { + contentType: 'image/png', + }, + }); + + // Generate the public URL for the stored image + const shareImageUrl = `https://radical.omar-c29.workers.dev/memes/${imageId}`; + + console.log(`Stored share image at: ${shareImageUrl}`); + + // Update the proposal in the database with the share image URL + await env.DB.prepare(` + UPDATE proposals + SET share_image_url = ? + WHERE id = ? + `).bind(shareImageUrl, proposal.id).run(); + + return shareImageUrl; + } catch (error) { + console.error('Error generating share image:', error); + // Return a fallback image URL in case of failure + return "https://radical.omar-c29.workers.dev/memes/mickeymeta.PNG"; + } +} + +// Create a new proposal +async function createProposal(request, env) { + try { + const data = await request.json(); + const { text, authorId, trending = false } = data; + + console.log(`Creating proposal: ${JSON.stringify(data)}`); + + if (!text || !authorId) { + return createResponse({ error: 'Missing required fields', received: { text, authorId } }, 400); + } + + // Check if user exists first + try { + const userCheck = await env.DB.prepare(`SELECT id, name FROM users WHERE id = ?`).bind(authorId).first(); + if (!userCheck) { + return createResponse({ + error: 'Author does not exist', + authorId, + suggestion: 'Create the user first before posting a proposal' + }, 400); + } + + // Create a temporary proposal object with author name for image generation + const tempProposal = { + id: 'proposal_' + Date.now(), + text: text, + author_name: userCheck.name + }; + + const id = tempProposal.id; + const timestamp = Date.now(); + + // Insert the proposal first + const query = ` + INSERT INTO proposals (id, author_id, text, timestamp, trending) + VALUES (?, ?, ?, ?, ?) + `; + + await env.DB.prepare(query).bind(id, authorId, text, timestamp, trending ? 1 : 0).run(); + + // Generate and store the share image + const shareImageUrl = await generateAndStoreShareImage(tempProposal, env); + + // Update the proposal with the share image URL + if (shareImageUrl) { + await env.DB.prepare(` + UPDATE proposals + SET share_image_url = ? + WHERE id = ? + `).bind(shareImageUrl, id).run(); + } + + console.log(`Successfully created proposal with ID: ${id}`); + + return createResponse({ + id, + authorId, + text, + timestamp, + trending, + shareImageUrl + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to create proposal in database', + details: logError(dbError, { + action: 'insert_proposal', + proposal: { id, authorId, timestamp, trending } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process proposal creation', + details: logError(error, { action: 'create_proposal_outer' }) + }, 500); + } +} +// Update a proposal (e.g., mark as trending) +async function updateProposal(id, request, env) { + try { + const data = await request.json(); + const { trending } = data; + + console.log(`Updating proposal ${id}: ${JSON.stringify(data)}`); + + if (trending === undefined) { + return createResponse({ error: 'No fields to update', received: data }, 400); + } + + // Check if proposal exists + try { + const proposalCheck = await env.DB.prepare(`SELECT id FROM proposals WHERE id = ?`).bind(id).first(); + if (!proposalCheck) { + return createResponse({ error: 'Proposal does not exist', id }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking proposal existence', + details: logError(checkError, { action: 'check_proposal', id }) + }, 500); + } + + const query = ` + UPDATE proposals + SET trending = ? + WHERE id = ? + `; + + try { + await env.DB.prepare(query).bind(trending ? 1 : 0, id).run(); + console.log(`Successfully updated proposal ${id} trending status to ${trending}`); + + return createResponse({ id, trending }); + } catch (dbError) { + return createResponse({ + error: 'Failed to update proposal', + details: logError(dbError, { action: 'update_proposal', id, trending }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process proposal update', + details: logError(error, { action: 'update_proposal_outer', id }) + }, 500); + } +} + + + // to serve audio files from R2 + + async function serveMedia(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + // Determine if this is a meme or audio file request + let key; + if (path.startsWith('/memes/')) { + key = path.replace('/memes/', ''); + } else if (path.startsWith('/audio/')) { + key = path.replace('/audio/', ''); + } else { + return new Response('Not found', { status: 404 }); + } + + // Handle CORS preflight requests + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + try { + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + return new Response('File not found', { status: 404 }); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year + + // Set the appropriate content-type based on file extension + if (key.endsWith('.mp3')) { + headers.set('Content-Type', 'audio/mpeg'); + } else if (key.endsWith('.wav')) { + headers.set('Content-Type', 'audio/wav'); + } else if (key.endsWith('.ogg')) { + headers.set('Content-Type', 'audio/ogg'); + } + + // Add CORS headers + Object.keys(corsHeaders).forEach(key => { + headers.set(key, corsHeaders[key]); + }); + + return new Response(object.body, { + headers + }); + } catch (error) { + console.error('Error serving media:', error); + return new Response('Internal Server Error', { status: 500 }); + } + } + + + +// Create or update a vote and handle petition data +async function createOrUpdateVote(request, env) { + try { + const data = await request.json(); + const { proposalId, userId, voteType, isPetition = false, petitionDetails = null } = data; + + console.log(`Processing vote/petition: ${JSON.stringify({ + proposalId, + userId, + voteType, + isPetition, + hasDetails: petitionDetails !== null + })}`); + + if (!proposalId || !userId || !voteType) { + return createResponse({ + error: 'Missing required fields', + received: { proposalId, userId, voteType: voteType || 'undefined' } + }, 400); + } + + if (voteType !== 'upvote' && voteType !== 'downvote') { + return createResponse({ + error: 'Invalid vote type', + voteType, + allowedValues: ['upvote', 'downvote'] + }, 400); + } + + // Check if entities exist + try { + const userCheck = await env.DB.prepare(`SELECT id FROM users WHERE id = ?`).bind(userId).first(); + if (!userCheck) { + return createResponse({ error: 'User does not exist', userId }, 404); + } + + const proposalCheck = await env.DB.prepare(`SELECT id FROM proposals WHERE id = ?`).bind(proposalId).first(); + if (!proposalCheck) { + return createResponse({ error: 'Proposal does not exist', proposalId }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking user or proposal existence', + details: logError(checkError, { action: 'check_entities', userId, proposalId }) + }, 500); + } + + // Check if vote already exists + let existingVote; + try { + const checkQuery = ` + SELECT id, vote_type, is_petition FROM votes + WHERE proposal_id = ? AND user_id = ? + `; + + existingVote = await env.DB.prepare(checkQuery).bind(proposalId, userId).first(); + console.log(`Existing vote check: ${existingVote ? JSON.stringify(existingVote) : 'None found'}`); + } catch (voteCheckError) { + return createResponse({ + error: 'Error checking existing vote', + details: logError(voteCheckError, { action: 'check_vote', userId, proposalId }) + }, 500); + } + + const timestamp = Date.now(); + + try { + // Process vote + if (existingVote) { + // If vote exists and it's the same type, remove it (toggle off) + if (existingVote.vote_type === voteType) { + await env.DB.prepare(` + DELETE FROM votes + WHERE id = ? + `).bind(existingVote.id).run(); + + console.log(`Deleted vote ${existingVote.id} (toggle off)`); + + return createResponse({ proposalId, userId, voteType: null }); + } else { + // If vote exists but is different type, update it + await env.DB.prepare(` + UPDATE votes + SET vote_type = ?, timestamp = ?, is_petition = ? + WHERE id = ? + `).bind( + voteType, + timestamp, + voteType === 'upvote' && isPetition ? 1 : 0, + existingVote.id + ).run(); + + console.log(`Updated vote ${existingVote.id} to ${voteType}`); + } + } else { + // If no vote exists, create new one + const result = await env.DB.prepare(` + INSERT INTO votes (proposal_id, user_id, vote_type, timestamp, is_petition) + VALUES (?, ?, ?, ?, ?) + `).bind( + proposalId, + userId, + voteType, + timestamp, + voteType === 'upvote' && isPetition ? 1 : 0 + ).run(); + + console.log(`Created new vote with ID: ${result.meta?.last_row_id || 'unknown'}`); + } + + // Process petition details if provided + if (voteType === 'upvote' && isPetition && petitionDetails) { + try { + // Validate petition details + const { fullName, address, postcode, dob, email } = petitionDetails; + + if (!fullName || !address || !postcode || !dob || !email) { + return createResponse({ error: 'Missing required petition details' }, 400); + } + + // Check if petition details already exist + const petitionCheck = await env.DB.prepare(` + SELECT id FROM petition_details + WHERE user_id = ? AND proposal_id = ? + `).bind(userId, proposalId).first(); + + if (!petitionCheck) { + // Store petition details + await env.DB.prepare(` + INSERT INTO petition_details + (user_id, proposal_id, full_name, address, postcode, dob, email, timestamp, verified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + userId, + proposalId, + fullName, + address, + postcode, + dob, + email, + timestamp, + 1 // Assuming verification was successful + ).run(); + + console.log(`Stored petition details for user ${userId} on proposal ${proposalId}`); + } else { + // Update existing petition details + await env.DB.prepare(` + UPDATE petition_details + SET full_name = ?, address = ?, postcode = ?, dob = ?, email = ?, timestamp = ?, verified = ? + WHERE user_id = ? AND proposal_id = ? + `).bind( + fullName, + address, + postcode, + dob, + email, + timestamp, + 1, // Assuming verification was successful + userId, + proposalId + ).run(); + + console.log(`Updated petition details for user ${userId} on proposal ${proposalId}`); + } + } catch (petitionError) { + return createResponse({ + error: 'Failed to process petition details', + details: logError(petitionError, { action: 'store_petition', userId, proposalId }) + }, 500); + } + } + + return createResponse({ + proposalId, + userId, + voteType, + isPetition: voteType === 'upvote' && isPetition, + petitionVerified: voteType === 'upvote' && isPetition + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to process vote/petition in database', + details: logError(dbError, { + action: 'process_vote_petition', + existingVote: existingVote || null, + newVote: { proposalId, userId, voteType, isPetition } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process vote/petition request', + details: logError(error, { action: 'vote_petition_outer' }) + }, 500); + } +} + +// 3. Update the getComments function to include vote counts +async function getComments(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('proposalId'); + const userId = url.searchParams.get('userId'); + + if (!proposalId) { + return createResponse({ error: 'Missing proposalId parameter' }, 400); + } + + const query = ` + SELECT + c.id, c.user_id, c.comment_text, c.timestamp, + u.name as user_name, + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'downvote') as downvotes + FROM comments c + JOIN users u ON c.user_id = u.id + WHERE c.proposal_id = ? + ORDER BY c.timestamp DESC + `; + + try { + const comments = await env.DB.prepare(query).bind(proposalId).all(); + console.log(`Retrieved ${comments.results.length} comments for proposal ${proposalId}`); + + // If userId is provided, get the user's votes for these comments + if (userId && comments.results.length > 0) { + const commentIds = comments.results.map(c => c.id); + + // Build placeholders for the SQL query + let placeholders = commentIds.map(() => '?').join(','); + + const votesQuery = ` + SELECT comment_id, vote_type + FROM comment_votes + WHERE user_id = ? AND comment_id IN (${placeholders}) + `; + + // Prepare bind parameters + const bindParams = [userId, ...commentIds]; + + const votes = await env.DB.prepare(votesQuery).bind(...bindParams).all(); + + // Create a map of commentId to vote type + const voteMap = {}; + votes.results.forEach(vote => { + voteMap[vote.comment_id] = vote.vote_type; + }); + + // Add user's vote to each comment + comments.results.forEach(comment => { + comment.userVote = voteMap[comment.id] || null; + }); + } + + return createResponse(comments.results); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve comments', + details: logError(dbError, { action: 'get_comments', proposalId }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comments request', + details: logError(error, { action: 'get_comments_outer' }) + }, 500); + } +} + +// Function to create a new comment +async function createComment(request, env) { + try { + const data = await request.json(); + const { proposalId, userId, commentText } = data; + + console.log(`Creating comment: ${JSON.stringify(data)}`); + + if (!proposalId || !userId || !commentText) { + return createResponse({ + error: 'Missing required fields', + received: { proposalId, userId, commentText: commentText ? '[present]' : '[missing]' } + }, 400); + } + + // Check if proposal exists + try { + const proposalCheck = await env.DB.prepare( + `SELECT id FROM proposals WHERE id = ?` + ).bind(proposalId).first(); + + if (!proposalCheck) { + return createResponse({ error: 'Proposal does not exist', proposalId }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking proposal existence', + details: logError(checkError, { action: 'check_proposal', proposalId }) + }, 500); + } + + // Check if user exists + try { + const userCheck = await env.DB.prepare( + `SELECT id FROM users WHERE id = ?` + ).bind(userId).first(); + + if (!userCheck) { + return createResponse({ error: 'User does not exist', userId }, 404); + } + } catch (userError) { + return createResponse({ + error: 'Error checking user existence', + details: logError(userError, { action: 'check_user', userId }) + }, 500); + } + + const id = 'comment_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const timestamp = Date.now(); + + try { + await env.DB.prepare(` + INSERT INTO comments (id, proposal_id, user_id, comment_text, timestamp) + VALUES (?, ?, ?, ?, ?) + `).bind(id, proposalId, userId, commentText, timestamp).run(); + + console.log(`Successfully created comment with ID: ${id}`); + + // Get user name for response + const userQuery = await env.DB.prepare( + `SELECT name FROM users WHERE id = ?` + ).bind(userId).first(); + + return createResponse({ + id, + proposalId, + userId, + userName: userQuery.name, + commentText, + timestamp + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to create comment in database', + details: logError(dbError, { + action: 'insert_comment', + comment: { id, proposalId, userId, timestamp } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comment creation', + details: logError(error, { action: 'create_comment_outer' }) + }, 500); + } +} + +// Create or get a user +async function createOrGetUser(request, env) { + try { + const data = await request.json(); + const { id, name } = data; + + console.log(`Processing user request: ${JSON.stringify(data)}`); + + if (!id || !name) { + return createResponse({ + error: 'Missing required fields', + received: { id: id || 'undefined', name: name || 'undefined' } + }, 400); + } + + // Check if user exists + let existingUser; + try { + const checkQuery = ` + SELECT id, name FROM users + WHERE id = ? + `; + + existingUser = await env.DB.prepare(checkQuery).bind(id).first(); + console.log(`Existing user check: ${existingUser ? JSON.stringify(existingUser) : 'None found'}`); + } catch (userCheckError) { + return createResponse({ + error: 'Error checking user existence', + details: logError(userCheckError, { action: 'check_user', id }) + }, 500); + } + + if (!existingUser) { + try { + // Create new user if doesn't exist + const timestamp = Date.now(); + const insertQuery = ` + INSERT INTO users (id, name, created_at) + VALUES (?, ?, ?) + `; + await env.DB.prepare(insertQuery).bind(id, name, timestamp).run(); + console.log(`Created new user with ID: ${id}`); + } catch (insertError) { + return createResponse({ + error: 'Failed to create user', + details: logError(insertError, { action: 'insert_user', user: { id, name } }) + }, 500); + } + } else { + console.log(`Found existing user: ${existingUser.id}`); + } + + return createResponse({ id, name }); + } catch (error) { + return createResponse({ + error: 'Failed to process user request', + details: logError(error, { action: 'user_outer' }) + }, 500); + } + +} \ No newline at end of file From b1ea441e6d70e720a579f435d1b1dc9213526d64 Mon Sep 17 00:00:00 2001 From: Parsa Yazdani Date: Mon, 31 Mar 2025 16:58:04 +1100 Subject: [PATCH 03/25] Rename cloudflare worker to cloudflare worker.js --- cloudflare worker => cloudflare worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename cloudflare worker => cloudflare worker.js (99%) diff --git a/cloudflare worker b/cloudflare worker.js similarity index 99% rename from cloudflare worker rename to cloudflare worker.js index 9a5f925..619dd26 100644 --- a/cloudflare worker +++ b/cloudflare worker.js @@ -2312,4 +2312,4 @@ async function createOrGetUser(request, env) { }, 500); } -} \ No newline at end of file +} From 6b379f33d244f8996d70b1c91a9c97bff3b55f8a Mon Sep 17 00:00:00 2001 From: Parsa Yazdani Date: Mon, 31 Mar 2025 17:18:28 +1100 Subject: [PATCH 04/25] Update cloudflare worker.js --- cloudflare worker.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cloudflare worker.js b/cloudflare worker.js index 619dd26..ce6bfa6 100644 --- a/cloudflare worker.js +++ b/cloudflare worker.js @@ -979,14 +979,15 @@ function handleOptions() { } // Helper to create standardized responses -function createResponse(body, status = 200, shouldCache = false, cacheDuration = 300) { +function createResponse(body, status = 200, cacheDuration = 60*60*24) { const headers = { 'Content-Type': 'application/json', ...corsHeaders }; - if (shouldCache) { + if (cacheDuration > 0) { headers['Cache-Control'] = `public, max-age=${cacheDuration}`; + headers['CDN-Cache-Control'] = `public, max-age=${cacheDuration}`; } else { headers['Cache-Control'] = 'no-store'; } From b94762ae5fb993d67cc07334df856f769ddc270e Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 17:22:05 +1100 Subject: [PATCH 05/25] x --- cloudflare worker.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cloudflare worker.js b/cloudflare worker.js index 619dd26..de815a3 100644 --- a/cloudflare worker.js +++ b/cloudflare worker.js @@ -55,7 +55,7 @@ export default { if (path === '/styles.css') { try { // Fetch CSS from GitHub Pages - const response = await fetch('https://kingomarnajjar.github.io/radical/styles.css', { + const response = await fetch('https://radical.pages.dev/radical/styles.css', { headers: { 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' } @@ -267,8 +267,8 @@ export default { async function serveMainPage(request, env) { try { - // Fetch from GitHub Pages - const response = await fetch('https://kingomarnajjar.github.io/radical/index.html', { + // Fetch directly from Cloudflare Pages + const response = await fetch('https://radical.pages.dev/index.html', { headers: { 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' } @@ -302,7 +302,7 @@ async function serveLoginPage(request, env) { try { // Fetch the login HTML file from your storage // You could store this in R2 bucket or GitHub Pages like your other static files - const response = await fetch('https://kingomarnajjar.github.io/radical/login.html', { + const response = await fetch('https://radical.pages.dev/radical/login.html', { headers: { 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' } @@ -2312,4 +2312,4 @@ async function createOrGetUser(request, env) { }, 500); } -} +} \ No newline at end of file From bbd4c8c8706d06da16ac610074fe5d65be7eaa8f Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 17:24:34 +1100 Subject: [PATCH 06/25] x --- worker.js | 2315 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2315 insertions(+) create mode 100644 worker.js diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..de815a3 --- /dev/null +++ b/worker.js @@ -0,0 +1,2315 @@ +export default { + async fetch(request, env, ctx) { + try { + const url = new URL(request.url); + + // Acceptable hostnames + const validHostnames = [ + 'theradicalparty.com', + 'www.theradicalparty.com', + 'radical.omar-c29.workers.dev' + ]; + + // Check if the hostname is valid + if (!validHostnames.includes(url.hostname)) { + console.error(`Unexpected hostname: ${url.hostname}`); + return new Response('Invalid hostname', { status: 400 }); + } + + const path = url.pathname; + const proposalId = url.searchParams.get('proposal'); + + console.log(`Processing request: ${url.toString()}`); + console.log(`User-Agent: ${request.headers.get('User-Agent')}`); + + // Handle CORS preflight requests first + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + // For GET requests, try to serve from cache first + if (request.method === "GET") { + // Create a cache key from the request URL + const cacheKey = new Request(request.url, { + headers: request.headers + }); + + // Try to get from cache first + let cachedResponse = await caches.default.match(cacheKey); + + if (cachedResponse) { + // Add header to indicate cache hit + cachedResponse = new Response(cachedResponse.body, cachedResponse); + cachedResponse.headers.set("CF-Cache-Status", "HIT"); + + // Ensure CORS headers are present + Object.keys(corsHeaders).forEach(key => { + cachedResponse.headers.set(key, corsHeaders[key]); + }); + + return cachedResponse; + } + } + + // Handle styles.css route + if (path === '/styles.css') { + try { + // Fetch CSS from GitHub Pages + const response = await fetch('https://radical.pages.dev/radical/styles.css', { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' + } + }); + + if (!response.ok) { + console.error('Failed to fetch styles.css', response.status, response.statusText); + return new Response('CSS File Not Found', { status: 404 }); + } + + // Get the CSS content + const cssContent = await response.text(); + + // Return CSS response with caching headers + const cssResponse = new Response(cssContent, { + status: 200, + headers: { + 'Content-Type': 'text/css', + 'Cache-Control': 'public, max-age=86400', // 1 day + ...corsHeaders // Add CORS headers + } + }); + + // Store in cache if this is a GET request + if (request.method === "GET") { + const cacheKey = new Request(request.url, { + headers: request.headers + }); + ctx.waitUntil(caches.default.put(cacheKey, cssResponse.clone())); + } + + return cssResponse; + } catch (error) { + console.error('Error fetching styles.css:', error); + return new Response('Internal Server Error', { status: 500 }); + } + } + + const isTestMeta = url.searchParams.get('test_meta') === 'true'; + + // Handle root path and index.html with potential meta tag testing + if (path === '/' || path === '/index.html') { + // If test_meta is true or there's a proposal, use customization + if (isTestMeta || proposalId) { + return await serveCustomizedHtml(proposalId || 'test_proposal', request, env); + } + + // Otherwise, serve main page normally + return await serveMainPage(request, env); + } + + if (path === '/api/petition-svg') { + return await servePetitionSVG(request, env); + } + + // Meta tag testing + if (url.searchParams.get('test_meta') === 'true') { + console.log('Meta tag test detected'); + + // If there's a proposal during meta test, use customization + if (proposalId && (path === '/' || path === '/index.html')) { + console.log(`Detected proposal in URL during meta test: ${proposalId}`); + return await serveCustomizedHtml(proposalId, request, env); + } + } + + console.log(`Received ${request.method} request to ${path}`); + + // Check if the DB binding exists + if (!env.DB) { + console.error('DB binding is missing! Check your wrangler.toml configuration.'); + return createResponse({ + error: 'Database configuration error. Missing DB binding.', + hint: 'Check your wrangler.toml file and make sure the DB binding is correctly set up.' + }, 500); + } + + // Handle media files (memes and audio) + if (path.startsWith('/memes/') || path.startsWith('/audio/')) { + const mediaResponse = await serveMedia(request, env); + + // Cache media responses for GET requests + if (request.method === "GET") { + const cacheKey = new Request(request.url, { + headers: request.headers + }); + + // Clone the response before modifying + const responseToCache = new Response(mediaResponse.body, mediaResponse); + + // Cache media files for 7 days + responseToCache.headers.set('Cache-Control', 'public, max-age=604800'); + + ctx.waitUntil(caches.default.put(cacheKey, responseToCache.clone())); + } + + return mediaResponse; + } else if (path === '/api/memes' && request.method === 'POST') { + return await uploadMeme(request, env); + } + + // Route requests to appropriate handlers + let response; + + if (path === '/api/health') { + response = await healthCheck(env); + } else if (path === '/api/debug-meta') { + response = await debugMetaTags(request, env); + } else if (path === '/login' || path === '/login.html') { + response = await serveLoginPage(request, env); + } else if (path === '/api/login' && request.method === 'POST') { + response = await validateLogin(request, env); + } else if (path === '/api/proposals' && request.method === 'GET') { + response = await getProposals(request, env); + } else if (path === '/api/proposals' && request.method === 'POST') { + response = await createProposal(request, env); + } else if (path.startsWith('/api/proposals/') && request.method === 'GET') { + response = await getProposalById(request, env); + } else if (path.startsWith('/api/proposals/') && request.method === 'PUT') { + const id = path.split('/').pop(); + response = await updateProposal(id, request, env); + } else if (path === '/api/votes' && request.method === 'POST') { + response = await createOrUpdateVote(request, env); + } else if (path === '/api/users' && request.method === 'POST') { + response = await createOrGetUser(request, env); + } else if (path === '/api/petition-stats' && request.method === 'GET') { + response = await getPetitionStats(request, env); + } else if (path === '/api/comments' && request.method === 'GET') { + response = await getComments(request, env); + } else if (path === '/api/comments' && request.method === 'POST') { + response = await createComment(request, env); + } else if (path === '/api/comment-votes' && request.method === 'POST') { + response = await createOrUpdateCommentVote(request, env); + } else if (path === '/api/comment-votes' && request.method === 'GET') { + response = await getCommentVotes(request, env); + } else { + response = createResponse({ + error: 'Not found', + path, + method: request.method, + availableRoutes: [ + { path: '/api/health', method: 'GET', description: 'Check system health' }, + { path: '/api/proposals', method: 'GET', description: 'Get proposals' }, + { path: '/api/proposals/:id', method: 'GET', description: 'Get a single proposal by ID' }, + { path: '/api/proposals/:id', method: 'PUT', description: 'Update proposal' }, + { path: '/api/votes', method: 'POST', description: 'Create/update vote' }, + { path: '/api/users', method: 'POST', description: 'Create/get user' }, + { path: '/api/petition-stats', method: 'GET', description: 'Get petition statistics' } + ] + }, 404); + } + + // Cache certain GET responses + if (request.method === "GET") { + const shouldCache = path.startsWith('/api/proposals') || + path.startsWith('/api/comments') || + path === '/api/petition-stats' || + path === '/api/health'; + + if (shouldCache && response) { + // We need to clone the response before reading its body + const responseToCache = new Response(response.body, response); + + // Set appropriate cache duration based on endpoint + let cacheDuration = 300; // 5 minutes default for API + if (path === '/api/health') { + cacheDuration = 3600; // 1 hour for health checks + } + + responseToCache.headers.set('Cache-Control', `public, max-age=${cacheDuration}`); + responseToCache.headers.set('CF-Cache-Status', 'MISS'); + + // Ensure all CORS headers are present + Object.keys(corsHeaders).forEach(key => { + responseToCache.headers.set(key, corsHeaders[key]); + }); + + const cacheKey = new Request(request.url, { + headers: request.headers + }); + + ctx.waitUntil(caches.default.put(cacheKey, responseToCache.clone())); + + // Make sure the response we return also has CORS headers + Object.keys(corsHeaders).forEach(key => { + response.headers.set(key, corsHeaders[key]); + }); + } + } + + return response; + + } catch (error) { + console.error('Critical error in request handling:', error); + return new Response(JSON.stringify({ + error: 'Internal Server Error', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders // Add CORS headers to error responses too + } + }); + } + } +}; + + +async function serveMainPage(request, env) { + try { + // Fetch directly from Cloudflare Pages + const response = await fetch('https://radical.pages.dev/index.html', { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' + } + }); + + if (!response.ok) { + console.error('Failed to fetch main page', response.status, response.statusText); + return new Response('Failed to load page', { status: 500 }); + } + + // Read the HTML content + const html = await response.text(); + + // Create a new response with the HTML content + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=86400', + // Add CORS headers if needed + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Comprehensive error serving main page:', error); + return new Response(`Internal Server Error: ${error.message}`, { status: 500 }); + } +} + +async function serveLoginPage(request, env) { + try { + // Fetch the login HTML file from your storage + // You could store this in R2 bucket or GitHub Pages like your other static files + const response = await fetch('https://radical.pages.dev/radical/login.html', { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' + } + }); + + if (!response.ok) { + console.error('Failed to fetch login page', response.status, response.statusText); + return new Response('Failed to load login page', { status: 500 }); + } + + // Read the HTML content + const html = await response.text(); + + // Create a new response with the HTML content + return new Response(html, { + status: 200, + headers: { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=86400', + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Error serving login page:', error); + return new Response(`Internal Server Error: ${error.message}`, { status: 500 }); + } +} + +// Function to validate user login with emoji combination and selections +async function validateLogin(request, env) { + try { + const data = await request.json(); + const { + emojiCombination, + selectedDictator, + selectedTarget + } = data; + + console.log(`Processing login attempt: ${JSON.stringify({ + emojiCount: emojiCombination?.length || 0, + dictator: selectedDictator, + target: selectedTarget + })}`); + + // Basic validation + if (!emojiCombination || !selectedDictator || !selectedTarget) { + return createResponse({ + error: 'Missing required fields', + received: { + hasEmojis: !!emojiCombination, + hasDictator: !!selectedDictator, + hasTarget: !!selectedTarget + } + }, 400); + } + + // Convert emoji array to string for storage/comparison + const emojiCombinationString = Array.isArray(emojiCombination) + ? emojiCombination.join('') + : emojiCombination; + + // Check if dictator exists + const dictatorCheck = await env.DB.prepare( + `SELECT id FROM dictators WHERE id = ?` + ).bind(selectedDictator).first(); + + if (!dictatorCheck) { + return createResponse({ + error: 'Invalid dictator selection', + dictatorId: selectedDictator + }, 400); + } + + // Check if target exists + const targetCheck = await env.DB.prepare( + `SELECT id FROM time_targets WHERE id = ?` + ).bind(selectedTarget).first(); + + if (!targetCheck) { + return createResponse({ + error: 'Invalid time target selection', + targetId: selectedTarget + }, 400); + } + + // Record the login attempt (for security tracking) + const attemptId = 'login_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const timestamp = Date.now(); + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + + try { + // Try to find a user with the exact combination + const user = await env.DB.prepare(` + SELECT id, name, emoji_combination, selected_dictator_id, selected_target_id + FROM users + WHERE emoji_combination = ? + AND selected_dictator_id = ? + AND selected_target_id = ? + `).bind( + emojiCombinationString, + selectedDictator, + selectedTarget + ).first(); + + // Log the attempt, regardless of outcome + await env.DB.prepare(` + INSERT INTO login_attempts ( + id, + user_id, + emoji_combination, + dictator_id, + target_id, + timestamp, + ip_address, + success + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + attemptId, + user ? user.id : null, + emojiCombinationString, + selectedDictator, + selectedTarget, + timestamp, + clientIP, + user ? 1 : 0 + ).run(); + + if (user) { + // Login successful - Update last login time + await env.DB.prepare(` + UPDATE users + SET last_login = ? + WHERE id = ? + `).bind(timestamp, user.id).run(); + + console.log(`Successful login for user ${user.id}`); + + // Return success with user info (excluding sensitive data) + return createResponse({ + success: true, + userId: user.id, + username: user.name, + message: "Login successful" + }); + } else { + // No matching user found - this could be a new signup + // Check if this is a unique emoji combination + const existingCombo = await env.DB.prepare(` + SELECT COUNT(*) as count + FROM users + WHERE emoji_combination = ? + `).bind(emojiCombinationString).first(); + + if (existingCombo.count > 0) { + // Emoji combination exists but other parameters don't match + return createResponse({ + error: "Invalid credentials", + message: "The combination you entered is incorrect" + }, 401); + } + + // This is a new user registration + const newUserId = 'citizen_' + Math.random().toString(36).substr(2, 9); + const newUsername = 'Citizen ' + Math.floor(Math.random() * 9000 + 1000); + + // Create the new user + await env.DB.prepare(` + INSERT INTO users ( + id, + name, + created_at, + emoji_combination, + selected_dictator_id, + selected_target_id, + last_login + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind( + newUserId, + newUsername, + timestamp, + emojiCombinationString, + selectedDictator, + selectedTarget, + timestamp + ).run(); + + console.log(`Created new user ${newUserId} with emoji login`); + + // Return success with new user info + return createResponse({ + success: true, + isNewUser: true, + userId: newUserId, + username: newUsername, + message: "Account created successfully" + }); + } + } catch (dbError) { + console.error('Database error during login validation:', dbError); + + return createResponse({ + error: "Login processing error", + details: "An error occurred while processing your login" + }, 500); + } + } catch (error) { + console.error('Error in login validation:', error); + + return createResponse({ + error: "Login request error", + message: "Could not process login request" + }, 500); + } +} + +async function createOrUpdateCommentVote(request, env) { + try { + const data = await request.json(); + const { commentId, userId, voteType } = data; + + console.log(`Processing comment vote: ${JSON.stringify({ + commentId, + userId, + voteType + })}`); + + if (!commentId || !userId || !voteType) { + return createResponse({ + error: 'Missing required fields', + received: { commentId, userId, voteType: voteType || 'undefined' } + }, 400); + } + + if (voteType !== 'upvote' && voteType !== 'downvote') { + return createResponse({ + error: 'Invalid vote type', + voteType, + allowedValues: ['upvote', 'downvote'] + }, 400); + } + + // Check if entities exist + try { + const userCheck = await env.DB.prepare(`SELECT id FROM users WHERE id = ?`).bind(userId).first(); + if (!userCheck) { + return createResponse({ error: 'User does not exist', userId }, 404); + } + + const commentCheck = await env.DB.prepare(`SELECT id FROM comments WHERE id = ?`).bind(commentId).first(); + if (!commentCheck) { + return createResponse({ error: 'Comment does not exist', commentId }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking user or comment existence', + details: logError(checkError, { action: 'check_entities', userId, commentId }) + }, 500); + } + + // Check if vote already exists + let existingVote; + try { + const checkQuery = ` + SELECT id, vote_type FROM comment_votes + WHERE comment_id = ? AND user_id = ? + `; + + existingVote = await env.DB.prepare(checkQuery).bind(commentId, userId).first(); + console.log(`Existing comment vote check: ${existingVote ? JSON.stringify(existingVote) : 'None found'}`); + } catch (voteCheckError) { + return createResponse({ + error: 'Error checking existing comment vote', + details: logError(voteCheckError, { action: 'check_comment_vote', userId, commentId }) + }, 500); + } + + const timestamp = Date.now(); + const voteId = existingVote ? existingVote.id : 'comment_vote_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + + try { + // Process vote + if (existingVote) { + // If vote exists and it's the same type, remove it (toggle off) + if (existingVote.vote_type === voteType) { + await env.DB.prepare(` + DELETE FROM comment_votes + WHERE id = ? + `).bind(existingVote.id).run(); + + console.log(`Deleted comment vote ${existingVote.id} (toggle off)`); + + return createResponse({ + commentId, + userId, + voteType: null, + action: 'removed' + }); + } else { + // If vote exists but is different type, update it + await env.DB.prepare(` + UPDATE comment_votes + SET vote_type = ?, timestamp = ? + WHERE id = ? + `).bind( + voteType, + timestamp, + existingVote.id + ).run(); + + console.log(`Updated comment vote ${existingVote.id} to ${voteType}`); + + return createResponse({ + commentId, + userId, + voteType, + action: 'updated' + }); + } + } else { + // If no vote exists, create new one + await env.DB.prepare(` + INSERT INTO comment_votes (id, comment_id, user_id, vote_type, timestamp) + VALUES (?, ?, ?, ?, ?) + `).bind( + voteId, + commentId, + userId, + voteType, + timestamp + ).run(); + + console.log(`Created new comment vote with ID: ${voteId}`); + + return createResponse({ + commentId, + userId, + voteType, + action: 'created' + }); + } + } catch (dbError) { + return createResponse({ + error: 'Failed to process comment vote in database', + details: logError(dbError, { + action: 'process_comment_vote', + existingVote: existingVote || null, + newVote: { commentId, userId, voteType } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comment vote request', + details: logError(error, { action: 'comment_vote_outer' }) + }, 500); + } +} + +// Function to get votes for a specific comment +async function getCommentVotes(request, env) { + try { + const url = new URL(request.url); + const commentId = url.searchParams.get('commentId'); + const userId = url.searchParams.get('userId'); + + if (!commentId) { + return createResponse({ error: 'Missing commentId parameter' }, 400); + } + + // Get vote counts + const query = ` + SELECT + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = ? AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = ? AND vote_type = 'downvote') as downvotes + `; + + try { + const voteCounts = await env.DB.prepare(query).bind(commentId, commentId).first(); + + let userVote = null; + // If userId is provided, get the user's vote + if (userId) { + const userVoteQuery = ` + SELECT vote_type FROM comment_votes + WHERE comment_id = ? AND user_id = ? + `; + + const userVoteResult = await env.DB.prepare(userVoteQuery).bind(commentId, userId).first(); + if (userVoteResult) { + userVote = userVoteResult.vote_type; + } + } + + return createResponse({ + commentId, + upvotes: voteCounts.upvotes || 0, + downvotes: voteCounts.downvotes || 0, + userVote + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve comment votes', + details: logError(dbError, { action: 'get_comment_votes', commentId }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comment votes request', + details: logError(error, { action: 'get_comment_votes_outer' }) + }, 500); + } +} + + + +// Serve meme files +async function serveMeme(request, env) { + const url = new URL(request.url); + const path = url.pathname; + const key = path.replace('/memes/', ''); + + // Handle CORS preflight requests + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + try { + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + return new Response('Meme not found', { status: 404 }); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year + + // Add CORS headers + Object.keys(corsHeaders).forEach(key => { + headers.set(key, corsHeaders[key]); + }); + + return new Response(object.body, { + headers + }); + } catch (error) { + console.error('Error serving meme:', error); + return new Response('Internal Server Error', { status: 500 }); + } +} + +// Debug endpoint to help diagnose meta tag issues +async function debugMetaTags(request, env) { + try { + // Create a sample proposal for testing + const testProposal = { + id: 'test_proposal', + text: 'This is a test proposal for meta tags', + author_name: 'Test User', + timestamp: Date.now() + }; + + // Generate a shareable image URL + const shareImageUrl = getShareableImageUrl(testProposal); + + // Prepare detailed meta tag information + const metaTagInfo = { + og_image: shareImageUrl, + og_title: 'RADICAL Meta Tag Test', + og_description: 'Testing meta tag generation and injection', + twitter_image: shareImageUrl, + twitter_title: 'RADICAL Meta Tag Verification', + twitter_description: 'Checking social media sharing metadata' + }; + + // Return detailed information about the meta tags + return createResponse({ + status: 'success', + message: 'Meta tag debug information', + shareImageUrl: shareImageUrl, + metaTagDetails: metaTagInfo + }); + } catch (error) { + return createResponse({ + error: 'Debug meta tags endpoint error', + details: logError(error, { action: 'debug_meta_tags' }) + }, 500); + } +} + +// Handle meme uploads to R2 storage +async function uploadMeme(request, env) { + try { + // Check if R2 binding exists + if (!env.MEMES_BUCKET) { + return createResponse({ + error: 'Storage binding missing. Check your settings configuration.' + }, 500); + } + + // Handle multipart form data + const formData = await request.formData(); + + // Get the proposal ID + const proposalId = formData.get('proposalId'); + if (!proposalId) { + return createResponse({ error: 'Missing proposalId' }, 400); + } + + // Get the file + const file = formData.get('meme'); + if (!file || !(file instanceof File)) { + return createResponse({ error: 'No file uploaded or invalid file' }, 400); + } + + // Validate file type + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + return createResponse({ + error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.', + received: file.type + }, 400); + } + + // Validate file size (limit to 2MB) + const MAX_SIZE = 2 * 1024 * 1024; // 2MB + if (file.size > MAX_SIZE) { + return createResponse({ + error: 'File too large. Maximum size is 2MB.', + size: file.size, + maxSize: MAX_SIZE + }, 400); + } + + // Create a unique filename + const fileExt = file.name.split('.').pop(); + const uniqueFilename = `${proposalId}_${Date.now()}.${fileExt}`; + + // Upload to R2 + await env.MEMES_BUCKET.put(uniqueFilename, file.stream(), { + httpMetadata: { + contentType: file.type, + }, + }); + + // Generate the public URL + const memeUrl = `https://radical.omar-c29.workers.dev/memes/${uniqueFilename}`; + + // Helper function to fix broken meme URLs + function fixMemeUrl(url) { + // Check if the URL contains the problematic domain + if (url && url.includes('memes.undefined')) { + // Extract the filename from the URL + const filename = url.split('/').pop(); + // Replace with the correct domain + return `https://radical.omar-c29.workers.dev/memes/${filename}`; + } + return url; + } + + // Update the proposal with the meme URL + try { + await env.DB.prepare(` + UPDATE proposals + SET meme_url = ? + WHERE id = ? + `).bind(memeUrl, proposalId).run(); + + console.log(`Added meme ${uniqueFilename} to proposal ${proposalId}`); + + return createResponse({ + success: true, + proposalId, + memeUrl + }); + } catch (dbError) { + // If DB update fails, try to delete the uploaded file + try { + await env.MEMES_BUCKET.delete(uniqueFilename); + } catch (deleteError) { + console.error('Failed to delete orphaned file:', deleteError); + } + + return createResponse({ + error: 'Failed to update proposal with meme URL', + details: logError(dbError, { action: 'update_proposal_meme', proposalId }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process meme upload', + details: logError(error, { action: 'upload_meme' }) + }, 500); + } +}// Get petition statistics +async function getPetitionStats(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('proposalId'); + + if (!proposalId) { + return createResponse({ error: 'Missing proposalId parameter' }, 400); + } + + // Get petition statistics + const statsQuery = ` + SELECT + COUNT(DISTINCT pd.user_id) as verified_count, + (SELECT COUNT(*) FROM votes WHERE proposal_id = ? AND vote_type = 'upvote' AND is_petition = 1) as total_signatures, + (SELECT text FROM proposals WHERE id = ?) as proposal_text + FROM petition_details pd + WHERE pd.proposal_id = ? AND pd.verified = 1 + `; + + const stats = await env.DB.prepare(statsQuery).bind(proposalId, proposalId, proposalId).first(); + + if (!stats) { + return createResponse({ error: 'Proposal not found' }, 404); + } + + // Get top postcodes for this petition + const postcodesQuery = ` + SELECT postcode, COUNT(*) as count + FROM petition_details + WHERE proposal_id = ? AND verified = 1 + GROUP BY postcode + ORDER BY count DESC + LIMIT 5 + `; + + const postcodes = await env.DB.prepare(postcodesQuery).bind(proposalId).all(); + + return createResponse({ + proposalId, + proposalText: stats.proposal_text, + verifiedSignatures: stats.verified_count, + totalSignatures: stats.total_signatures, + topPostcodes: postcodes.results + }); + } catch (error) { + return createResponse({ + error: 'Failed to process petition stats request', + details: logError(error, { action: 'petition_stats', proposalId }) + }, 500); + } +}// Define CORS headers +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +// Helper to log detailed errors +function logError(error, context = {}) { + const errorDetails = { + message: error.message, + stack: error.stack, + name: error.name, + context: context + }; + + console.error(JSON.stringify(errorDetails)); + return errorDetails; +} + +// Handle OPTIONS request for CORS preflight +function handleOptions() { + return new Response(null, { + headers: corsHeaders + }); +} + +// Helper to create standardized responses +function createResponse(body, status = 200, shouldCache = false, cacheDuration = 300) { + const headers = { + 'Content-Type': 'application/json', + ...corsHeaders + }; + + if (shouldCache) { + headers['Cache-Control'] = `public, max-age=${cacheDuration}`; + } else { + headers['Cache-Control'] = 'no-store'; + } + + return new Response(JSON.stringify(body), { + status, + headers + }); +} + + +async function verifyTemplateImage(env) { + try { + const key = "petition-template.png"; + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + console.error("❌ Template image not found in R2 bucket!"); + return false; + } + + console.log("✅ Template image found in R2 bucket"); + return true; + } catch (error) { + console.error("Error verifying template image:", error); + return false; + } +} + + +/** + * Handle requests for dynamic SVG images for petitions + * @param {Request} request - The incoming request + * @param {Object} env - Environment bindings + * @returns {Response} - SVG response + */ +async function servePetitionSVG(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('id'); + + if (!proposalId) { + return new Response('Missing proposal ID', { status: 400 }); + } + + console.log(`Generating SVG image for proposal: ${proposalId}`); + + // Fetch the proposal data from the database + const proposal = await env.DB.prepare(` + SELECT + p.id, p.text, p.timestamp, + u.name as author_name, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `).bind(proposalId).first(); + + if (!proposal) { + return new Response('Proposal not found', { status: 404 }); + } + + // Generate the SVG + const svgMarkup = generatePetitionSVG(proposal); + + // Return the SVG with appropriate headers + return new Response(svgMarkup, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=360000', // Cache for 1 hour + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Error generating petition SVG:', error); + return new Response('Internal Server Error', { status: 500 }); + } +} + +/** + * Generates a dynamic SVG for a petition with the given data + * @param {Object} proposal - The petition/proposal data + * @returns {string} - SVG markup as a string + */ +function generatePetitionSVG(proposal) { + // Get data with fallbacks + const petitionText = proposal.text || "Unknown petition"; + const upvotes = proposal.upvotes || 0; + const downvotes = proposal.downvotes || 0; + const netVotes = upvotes - downvotes; + const netVotesDisplay = netVotes > 0 ? `+${netVotes}` : netVotes; + const netVotesColor = netVotes > 0 ? "#11cc77" : (netVotes < 0 ? "#ff0099" : "#ffffff"); + const proposalId = proposal.id || "unknown"; + + // Format timestamp + const timestamp = new Date(proposal.timestamp || Date.now()).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + // Base SVG template + const svgTemplate = ` + + + + + + + + + RADICAL + + + + VIBE, VOTE, VETO + + + + + + ${generateSVGTextLines(petitionText, 120, 220, 960)} + + + + + + ${upvotes} + + + + ${downvotes} + + + NET: + ${netVotesDisplay} + + + + + Petition ID: ${proposalId} • Created: ${timestamp} + + + + + + + RADICAL + PETITION + #${proposalId.slice(-6)} + + + + theradicalparty.com +`; + + return svgTemplate; +} + +/** + * Generates SVG text elements with proper line wrapping + * @param {string} text - The text to wrap + * @param {number} x - Starting x position + * @param {number} y - Starting y position + * @param {number} width - Maximum width for text wrapping + * @returns {string} - SVG text elements + */ +function generateSVGTextLines(text, x, y, width) { + // Simple word wrapping algorithm + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + // Estimate characters per line based on width and average character width + const avgCharWidth = 22; // Rough estimate for font size ~36px + const charsPerLine = Math.floor(width / avgCharWidth); + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (testLine.length <= charsPerLine) { + currentLine = testLine; + } else { + lines.push(currentLine); + currentLine = word; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + // Limit to 6 lines to fit in the container + const limitedLines = lines.slice(0, 6); + if (lines.length > 6) { + // Add ellipsis to the last line if truncated + limitedLines[5] = limitedLines[5].slice(0, -3) + '...'; + } + + // Generate SVG text elements + const lineHeight = 46; + let svgTextElements = ''; + + limitedLines.forEach((line, index) => { + const lineY = y + index * lineHeight; + svgTextElements += `${escapeHTML(line)}\n`; + }); + + return svgTextElements; +} + +/** + * Escapes HTML special characters in a string + * @param {string} text - The text to escape + * @returns {string} - Escaped text + */ +function escapeHTML(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Update the getShareableImageUrl function to use the SVG endpoint +function getShareableImageUrl(proposal) { + // Use the dynamic SVG endpoint with the proposal ID + return `https://${new URL(request.url).hostname}/api/petition-svg?id=${proposal.id}`; +} + +// Update the serveCustomizedHtml function to use the SVG URL for meta tags +async function serveCustomizedHtml(proposalId, request, env) { + console.log(`===== BEGIN serveCustomizedHtml for proposal ${proposalId} =====`); + + try { + const url = new URL(request.url); + const isTestMeta = url.searchParams.get('test_meta') === 'true'; + + // Determine which proposal to use + let proposal; + let shareImageUrl; + + if (isTestMeta) { + // Use test proposal data + proposal = { + id: 'test_proposal', + text: 'This is a test proposal for meta tags', + timestamp: Date.now(), + author_name: 'Test User' + }; + shareImageUrl = `https://${url.hostname}/api/petition-svg?id=test_proposal`; + } else { + // Fetch the actual proposal from the database + proposal = await env.DB.prepare(` + SELECT + p.id, p.text, p.timestamp, + u.name as author_name + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `).bind(proposalId).first(); + + if (!proposal) { + console.error(`Proposal not found: ${proposalId}`); + return await fetch(request); + } + + // Generate SVG URL + shareImageUrl = `https://${url.hostname}/api/petition-svg?id=${proposalId}`; + } + + console.log(`Found proposal: ${JSON.stringify(proposal)}`); + console.log(`Using share image URL: ${shareImageUrl}`); + + // Get the full URL for this proposal + const fullProposalUrl = `https://${url.hostname}/?proposal=${proposalId}`; + + // Fetch the original HTML + const response = await fetch(request); + const html = await response.text(); + + // First, remove all existing og:image and twitter:image tags + let modifiedHtml = html.replace(/]*>/g, ''); + modifiedHtml = modifiedHtml.replace(/]*>/g, ''); + modifiedHtml = modifiedHtml.replace(/]*>/g, ''); + + // Find the head end tag + const headEndIndex = modifiedHtml.indexOf(''); + if (headEndIndex === -1) { + console.error("Could not find tag in HTML"); + return new Response(modifiedHtml, { + headers: response.headers + }); + } + + // Create comprehensive meta tags + const customMetaTags = ` + + + + + + + + + + + + + + + + + + + + + + + `; + + // Insert our meta tags right before + let customizedHtml = modifiedHtml.substring(0, headEndIndex) + customMetaTags + modifiedHtml.substring(headEndIndex); + + console.log(`===== END serveCustomizedHtml for proposal ${proposalId} =====`); + + // Return the customized HTML with appropriate headers + const originalHeaders = new Headers(response.headers); + originalHeaders.set('Cache-Control', 'no-store, max-age=0'); + originalHeaders.set('X-Custom-Meta', isTestMeta ? 'test' : 'proposal'); + originalHeaders.set('X-Share-Image-Url', shareImageUrl); // Debug header + + return new Response(customizedHtml, { + headers: originalHeaders + }); + } catch (error) { + console.error('Error generating customized HTML:', error); + // Fall back to regular page if something goes wrong + return await fetch(request); + } +} + + + + +// Function to check if a table exists +async function tableExists(env, tableName) { + try { + const query = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?"; + const result = await env.DB.prepare(query).bind(tableName).first(); + return !!result; + } catch (error) { + logError(error, { action: 'check_table_exists', tableName }); + return false; + } +} + +// Simple health check endpoint +async function healthCheck(env) { + try { + // Check if DB binding exists + if (!env.DB) { + return createResponse({ + status: 'error', + error: 'Database binding missing. Check wrangler.toml configuration.' + }, 500); + } + + // Check if we can execute a simple query + try { + const result = await env.DB.prepare("SELECT 1 as db_check").first(); + if (!result || result.db_check !== 1) { + return createResponse({ + status: 'error', + error: 'Database query failed to return expected result' + }, 500); + } + } catch (dbError) { + return createResponse({ + status: 'error', + error: 'Database query execution failed', + details: logError(dbError, { action: 'health_check_query' }) + }, 500); + } + + // Check if required tables exist + const tables = ['users', 'proposals', 'votes', 'petition_details']; + const tableStatus = {}; + + for (const table of tables) { + tableStatus[table] = await tableExists(env, table); + } + + const missingTables = Object.keys(tableStatus).filter(table => !tableStatus[table]); + + if (missingTables.length > 0) { + return createResponse({ + status: 'warning', + message: 'Database connected but some tables are missing', + missingTables, + tableStatus + }, 200); + } + + return createResponse({ + status: 'ok', + message: 'Database connection and tables verified', + database: env.DB.name || 'unknown', + tableStatus + }, 200); + } catch (error) { + return createResponse({ + status: 'error', + error: 'Health check failed', + details: logError(error, { action: 'health_check' }) + }, 500); + } +} + +// Get proposals with optional sorting +async function getProposals(request, env) { + try { + const url = new URL(request.url); + const sortBy = url.searchParams.get('sortBy') || 'newest'; + + // Log request details + console.log(`Getting proposals with sort: ${sortBy}`); + + let query = ` + SELECT + p.id, p.text, p.timestamp, p.trending, p.meme_url, + u.name as author_name, u.id as author_id, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures, + (SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners + FROM proposals p + JOIN users u ON p.author_id = u.id + `; + // Add sorting + if (sortBy === 'newest') { + query += ' ORDER BY p.timestamp DESC'; + } else if (sortBy === 'popular') { + query += ` + ORDER BY ( + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') - + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') + ) DESC + `; + } else if (sortBy === 'controversial') { + query += ` + ORDER BY ( + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') + + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') + ) DESC + `; + } else if (sortBy === 'petitions') { + // New sorting option for most petitioned + query += ` + ORDER BY ( + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) + ) DESC + `; + } + + console.log(`Executing query: ${query}`); + + // Get user ID for checking existing votes + const userId = url.searchParams.get('userId'); + let proposals; + + try { + proposals = await env.DB.prepare(query).all(); + console.log(`Retrieved ${proposals.results.length} proposals`); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve proposals', + details: logError(dbError, { action: 'get_proposals', query }) + }, 500); + } + + // If we have a userId, get their votes for these proposals + if (userId && proposals.results.length > 0) { + const proposalIds = proposals.results.map(p => p.id); + + try { + // Use parameter binding with placeholders + let placeholderStr = ''; + for (let i = 0; i < proposalIds.length; i++) { + placeholderStr += (i === 0 ? '?' : ',?'); + } + + const votesQuery = ` + SELECT proposal_id, vote_type, is_petition + FROM votes + WHERE user_id = ? AND proposal_id IN (${placeholderStr}) + `; + + console.log(`Executing votes query for user ${userId} with ${proposalIds.length} proposals`); + + const bindParams = [userId, ...proposalIds]; + const votes = await env.DB.prepare(votesQuery).bind(...bindParams).all(); + + console.log(`Retrieved ${votes.results.length} votes for user ${userId}`); + + // Create a map of proposal_id to vote info + const voteMap = {}; + for (const vote of votes.results) { + voteMap[vote.proposal_id] = { + voteType: vote.vote_type, + isPetition: vote.is_petition === 1 + }; + } + + // Also get petition details status + const petitionQuery = ` + SELECT proposal_id, verified + FROM petition_details + WHERE user_id = ? AND proposal_id IN (${placeholderStr}) + `; + + const petitionDetails = await env.DB.prepare(petitionQuery).bind(...bindParams).all(); + const petitionMap = {}; + + for (const petition of petitionDetails.results) { + petitionMap[petition.proposal_id] = { + verified: petition.verified === 1 + }; + } + + // Add user's vote and petition status to each proposal + for (const proposal of proposals.results) { + const voteInfo = voteMap[proposal.id]; + proposal.userVote = voteInfo ? voteInfo.voteType : null; + proposal.userPetitionSigned = voteInfo ? voteInfo.isPetition : false; + proposal.userPetitionVerified = petitionMap[proposal.id] ? petitionMap[proposal.id].verified : false; + } + } catch (voteError) { + // Just log the error but continue - we'll return proposals without vote data + logError(voteError, { + action: 'get_user_votes', + userId, + proposalCount: proposalIds.length + }); + console.log('Warning: Failed to retrieve user votes, continuing without vote data'); + } + } + + return createResponse(proposals.results); + } catch (error) { + return createResponse({ + error: 'Failed to process proposals request', + details: logError(error, { action: 'get_proposals_outer' }) + }, 500); + } +} + +// Get a single proposal by ID +async function getProposalById(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.pathname.split('/api/proposals/')[1]; + const userId = url.searchParams.get('userId'); + + if (!proposalId) { + return createResponse({ error: 'Missing proposal ID' }, 400); + } + + console.log(`Getting proposal with ID: ${proposalId}`); + + let query = ` + SELECT + p.id, p.text, p.timestamp, p.trending, p.meme_url, + u.name as author_name, u.id as author_id, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures, + (SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `; + + try { + const proposal = await env.DB.prepare(query).bind(proposalId).first(); + + if (!proposal) { + return createResponse({ error: 'Proposal not found' }, 404); + } + + // If we have a userId, get their votes for this proposal + if (userId) { + try { + const votesQuery = ` + SELECT proposal_id, vote_type, is_petition + FROM votes + WHERE user_id = ? AND proposal_id = ? + `; + + const vote = await env.DB.prepare(votesQuery).bind(userId, proposalId).first(); + + if (vote) { + proposal.userVote = vote.vote_type; + proposal.userPetitionSigned = vote.is_petition === 1; + } else { + proposal.userVote = null; + proposal.userPetitionSigned = false; + } + + // Also get petition details status + const petitionQuery = ` + SELECT proposal_id, verified + FROM petition_details + WHERE user_id = ? AND proposal_id = ? + `; + + const petition = await env.DB.prepare(petitionQuery).bind(userId, proposalId).first(); + + if (petition) { + proposal.userPetitionVerified = petition.verified === 1; + } else { + proposal.userPetitionVerified = false; + } + } catch (voteError) { + // Just log the error but continue - we'll return the proposal without vote data + logError(voteError, { + action: 'get_user_votes', + userId, + proposalId + }); + console.log('Warning: Failed to retrieve user votes, continuing without vote data'); + } + } + + return createResponse(proposal); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve proposal', + details: logError(dbError, { action: 'get_proposal_by_id', proposalId, query }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process proposal request', + details: logError(error, { action: 'get_proposal_by_id_outer' }) + }, 500); + } +} + +async function generateAndStoreShareImage(proposal, env) { + try { + // Get base text for the image + const text = proposal.text || "Unknown petition"; + const authorName = proposal.author_name || "Anonymous"; + + // Create Cloudinary URL for transformation + const cloudinaryUrl = "https://res.cloudinary.com/dh8apmjya/image/fetch"; + + // Encode text for URL parameters + const encodedText = encodeURIComponent(text.substring(0, 80)) + .replace(/'/g, '%27') + .replace(/"/g, '%22'); + + // Create transformation parameters + const params = `b_black,w_1200,h_630,c_fill/l_text:Arial_32_bold:${encodedText},co_white,w_1000,c_fit,g_center`; + + // Template image URL + const templateUrl = encodeURIComponent("https://radical.omar-c29.workers.dev/memes/petition-template.png"); + + // Create a unique identifier for this image + const imageId = `share_${proposal.id}_${Date.now()}`; + + // Final Cloudinary URL + const cloudinaryImageUrl = `${cloudinaryUrl}/${params}/${templateUrl}`; + + console.log(`Generating share image: ${cloudinaryImageUrl}`); + + // Fetch the image from Cloudinary + const imageResponse = await fetch(cloudinaryImageUrl); + + if (!imageResponse.ok) { + throw new Error(`Failed to fetch image: ${imageResponse.status}`); + } + + // Get the image as a blob + const imageBlob = await imageResponse.blob(); + + // Upload the image to your R2 bucket + await env.MEMES_BUCKET.put(imageId, imageBlob, { + httpMetadata: { + contentType: 'image/png', + }, + }); + + // Generate the public URL for the stored image + const shareImageUrl = `https://radical.omar-c29.workers.dev/memes/${imageId}`; + + console.log(`Stored share image at: ${shareImageUrl}`); + + // Update the proposal in the database with the share image URL + await env.DB.prepare(` + UPDATE proposals + SET share_image_url = ? + WHERE id = ? + `).bind(shareImageUrl, proposal.id).run(); + + return shareImageUrl; + } catch (error) { + console.error('Error generating share image:', error); + // Return a fallback image URL in case of failure + return "https://radical.omar-c29.workers.dev/memes/mickeymeta.PNG"; + } +} + +// Create a new proposal +async function createProposal(request, env) { + try { + const data = await request.json(); + const { text, authorId, trending = false } = data; + + console.log(`Creating proposal: ${JSON.stringify(data)}`); + + if (!text || !authorId) { + return createResponse({ error: 'Missing required fields', received: { text, authorId } }, 400); + } + + // Check if user exists first + try { + const userCheck = await env.DB.prepare(`SELECT id, name FROM users WHERE id = ?`).bind(authorId).first(); + if (!userCheck) { + return createResponse({ + error: 'Author does not exist', + authorId, + suggestion: 'Create the user first before posting a proposal' + }, 400); + } + + // Create a temporary proposal object with author name for image generation + const tempProposal = { + id: 'proposal_' + Date.now(), + text: text, + author_name: userCheck.name + }; + + const id = tempProposal.id; + const timestamp = Date.now(); + + // Insert the proposal first + const query = ` + INSERT INTO proposals (id, author_id, text, timestamp, trending) + VALUES (?, ?, ?, ?, ?) + `; + + await env.DB.prepare(query).bind(id, authorId, text, timestamp, trending ? 1 : 0).run(); + + // Generate and store the share image + const shareImageUrl = await generateAndStoreShareImage(tempProposal, env); + + // Update the proposal with the share image URL + if (shareImageUrl) { + await env.DB.prepare(` + UPDATE proposals + SET share_image_url = ? + WHERE id = ? + `).bind(shareImageUrl, id).run(); + } + + console.log(`Successfully created proposal with ID: ${id}`); + + return createResponse({ + id, + authorId, + text, + timestamp, + trending, + shareImageUrl + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to create proposal in database', + details: logError(dbError, { + action: 'insert_proposal', + proposal: { id, authorId, timestamp, trending } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process proposal creation', + details: logError(error, { action: 'create_proposal_outer' }) + }, 500); + } +} +// Update a proposal (e.g., mark as trending) +async function updateProposal(id, request, env) { + try { + const data = await request.json(); + const { trending } = data; + + console.log(`Updating proposal ${id}: ${JSON.stringify(data)}`); + + if (trending === undefined) { + return createResponse({ error: 'No fields to update', received: data }, 400); + } + + // Check if proposal exists + try { + const proposalCheck = await env.DB.prepare(`SELECT id FROM proposals WHERE id = ?`).bind(id).first(); + if (!proposalCheck) { + return createResponse({ error: 'Proposal does not exist', id }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking proposal existence', + details: logError(checkError, { action: 'check_proposal', id }) + }, 500); + } + + const query = ` + UPDATE proposals + SET trending = ? + WHERE id = ? + `; + + try { + await env.DB.prepare(query).bind(trending ? 1 : 0, id).run(); + console.log(`Successfully updated proposal ${id} trending status to ${trending}`); + + return createResponse({ id, trending }); + } catch (dbError) { + return createResponse({ + error: 'Failed to update proposal', + details: logError(dbError, { action: 'update_proposal', id, trending }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process proposal update', + details: logError(error, { action: 'update_proposal_outer', id }) + }, 500); + } +} + + + // to serve audio files from R2 + + async function serveMedia(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + // Determine if this is a meme or audio file request + let key; + if (path.startsWith('/memes/')) { + key = path.replace('/memes/', ''); + } else if (path.startsWith('/audio/')) { + key = path.replace('/audio/', ''); + } else { + return new Response('Not found', { status: 404 }); + } + + // Handle CORS preflight requests + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + try { + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + return new Response('File not found', { status: 404 }); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year + + // Set the appropriate content-type based on file extension + if (key.endsWith('.mp3')) { + headers.set('Content-Type', 'audio/mpeg'); + } else if (key.endsWith('.wav')) { + headers.set('Content-Type', 'audio/wav'); + } else if (key.endsWith('.ogg')) { + headers.set('Content-Type', 'audio/ogg'); + } + + // Add CORS headers + Object.keys(corsHeaders).forEach(key => { + headers.set(key, corsHeaders[key]); + }); + + return new Response(object.body, { + headers + }); + } catch (error) { + console.error('Error serving media:', error); + return new Response('Internal Server Error', { status: 500 }); + } + } + + + +// Create or update a vote and handle petition data +async function createOrUpdateVote(request, env) { + try { + const data = await request.json(); + const { proposalId, userId, voteType, isPetition = false, petitionDetails = null } = data; + + console.log(`Processing vote/petition: ${JSON.stringify({ + proposalId, + userId, + voteType, + isPetition, + hasDetails: petitionDetails !== null + })}`); + + if (!proposalId || !userId || !voteType) { + return createResponse({ + error: 'Missing required fields', + received: { proposalId, userId, voteType: voteType || 'undefined' } + }, 400); + } + + if (voteType !== 'upvote' && voteType !== 'downvote') { + return createResponse({ + error: 'Invalid vote type', + voteType, + allowedValues: ['upvote', 'downvote'] + }, 400); + } + + // Check if entities exist + try { + const userCheck = await env.DB.prepare(`SELECT id FROM users WHERE id = ?`).bind(userId).first(); + if (!userCheck) { + return createResponse({ error: 'User does not exist', userId }, 404); + } + + const proposalCheck = await env.DB.prepare(`SELECT id FROM proposals WHERE id = ?`).bind(proposalId).first(); + if (!proposalCheck) { + return createResponse({ error: 'Proposal does not exist', proposalId }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking user or proposal existence', + details: logError(checkError, { action: 'check_entities', userId, proposalId }) + }, 500); + } + + // Check if vote already exists + let existingVote; + try { + const checkQuery = ` + SELECT id, vote_type, is_petition FROM votes + WHERE proposal_id = ? AND user_id = ? + `; + + existingVote = await env.DB.prepare(checkQuery).bind(proposalId, userId).first(); + console.log(`Existing vote check: ${existingVote ? JSON.stringify(existingVote) : 'None found'}`); + } catch (voteCheckError) { + return createResponse({ + error: 'Error checking existing vote', + details: logError(voteCheckError, { action: 'check_vote', userId, proposalId }) + }, 500); + } + + const timestamp = Date.now(); + + try { + // Process vote + if (existingVote) { + // If vote exists and it's the same type, remove it (toggle off) + if (existingVote.vote_type === voteType) { + await env.DB.prepare(` + DELETE FROM votes + WHERE id = ? + `).bind(existingVote.id).run(); + + console.log(`Deleted vote ${existingVote.id} (toggle off)`); + + return createResponse({ proposalId, userId, voteType: null }); + } else { + // If vote exists but is different type, update it + await env.DB.prepare(` + UPDATE votes + SET vote_type = ?, timestamp = ?, is_petition = ? + WHERE id = ? + `).bind( + voteType, + timestamp, + voteType === 'upvote' && isPetition ? 1 : 0, + existingVote.id + ).run(); + + console.log(`Updated vote ${existingVote.id} to ${voteType}`); + } + } else { + // If no vote exists, create new one + const result = await env.DB.prepare(` + INSERT INTO votes (proposal_id, user_id, vote_type, timestamp, is_petition) + VALUES (?, ?, ?, ?, ?) + `).bind( + proposalId, + userId, + voteType, + timestamp, + voteType === 'upvote' && isPetition ? 1 : 0 + ).run(); + + console.log(`Created new vote with ID: ${result.meta?.last_row_id || 'unknown'}`); + } + + // Process petition details if provided + if (voteType === 'upvote' && isPetition && petitionDetails) { + try { + // Validate petition details + const { fullName, address, postcode, dob, email } = petitionDetails; + + if (!fullName || !address || !postcode || !dob || !email) { + return createResponse({ error: 'Missing required petition details' }, 400); + } + + // Check if petition details already exist + const petitionCheck = await env.DB.prepare(` + SELECT id FROM petition_details + WHERE user_id = ? AND proposal_id = ? + `).bind(userId, proposalId).first(); + + if (!petitionCheck) { + // Store petition details + await env.DB.prepare(` + INSERT INTO petition_details + (user_id, proposal_id, full_name, address, postcode, dob, email, timestamp, verified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + userId, + proposalId, + fullName, + address, + postcode, + dob, + email, + timestamp, + 1 // Assuming verification was successful + ).run(); + + console.log(`Stored petition details for user ${userId} on proposal ${proposalId}`); + } else { + // Update existing petition details + await env.DB.prepare(` + UPDATE petition_details + SET full_name = ?, address = ?, postcode = ?, dob = ?, email = ?, timestamp = ?, verified = ? + WHERE user_id = ? AND proposal_id = ? + `).bind( + fullName, + address, + postcode, + dob, + email, + timestamp, + 1, // Assuming verification was successful + userId, + proposalId + ).run(); + + console.log(`Updated petition details for user ${userId} on proposal ${proposalId}`); + } + } catch (petitionError) { + return createResponse({ + error: 'Failed to process petition details', + details: logError(petitionError, { action: 'store_petition', userId, proposalId }) + }, 500); + } + } + + return createResponse({ + proposalId, + userId, + voteType, + isPetition: voteType === 'upvote' && isPetition, + petitionVerified: voteType === 'upvote' && isPetition + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to process vote/petition in database', + details: logError(dbError, { + action: 'process_vote_petition', + existingVote: existingVote || null, + newVote: { proposalId, userId, voteType, isPetition } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process vote/petition request', + details: logError(error, { action: 'vote_petition_outer' }) + }, 500); + } +} + +// 3. Update the getComments function to include vote counts +async function getComments(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('proposalId'); + const userId = url.searchParams.get('userId'); + + if (!proposalId) { + return createResponse({ error: 'Missing proposalId parameter' }, 400); + } + + const query = ` + SELECT + c.id, c.user_id, c.comment_text, c.timestamp, + u.name as user_name, + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM comment_votes WHERE comment_id = c.id AND vote_type = 'downvote') as downvotes + FROM comments c + JOIN users u ON c.user_id = u.id + WHERE c.proposal_id = ? + ORDER BY c.timestamp DESC + `; + + try { + const comments = await env.DB.prepare(query).bind(proposalId).all(); + console.log(`Retrieved ${comments.results.length} comments for proposal ${proposalId}`); + + // If userId is provided, get the user's votes for these comments + if (userId && comments.results.length > 0) { + const commentIds = comments.results.map(c => c.id); + + // Build placeholders for the SQL query + let placeholders = commentIds.map(() => '?').join(','); + + const votesQuery = ` + SELECT comment_id, vote_type + FROM comment_votes + WHERE user_id = ? AND comment_id IN (${placeholders}) + `; + + // Prepare bind parameters + const bindParams = [userId, ...commentIds]; + + const votes = await env.DB.prepare(votesQuery).bind(...bindParams).all(); + + // Create a map of commentId to vote type + const voteMap = {}; + votes.results.forEach(vote => { + voteMap[vote.comment_id] = vote.vote_type; + }); + + // Add user's vote to each comment + comments.results.forEach(comment => { + comment.userVote = voteMap[comment.id] || null; + }); + } + + return createResponse(comments.results); + } catch (dbError) { + return createResponse({ + error: 'Failed to retrieve comments', + details: logError(dbError, { action: 'get_comments', proposalId }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comments request', + details: logError(error, { action: 'get_comments_outer' }) + }, 500); + } +} + +// Function to create a new comment +async function createComment(request, env) { + try { + const data = await request.json(); + const { proposalId, userId, commentText } = data; + + console.log(`Creating comment: ${JSON.stringify(data)}`); + + if (!proposalId || !userId || !commentText) { + return createResponse({ + error: 'Missing required fields', + received: { proposalId, userId, commentText: commentText ? '[present]' : '[missing]' } + }, 400); + } + + // Check if proposal exists + try { + const proposalCheck = await env.DB.prepare( + `SELECT id FROM proposals WHERE id = ?` + ).bind(proposalId).first(); + + if (!proposalCheck) { + return createResponse({ error: 'Proposal does not exist', proposalId }, 404); + } + } catch (checkError) { + return createResponse({ + error: 'Error checking proposal existence', + details: logError(checkError, { action: 'check_proposal', proposalId }) + }, 500); + } + + // Check if user exists + try { + const userCheck = await env.DB.prepare( + `SELECT id FROM users WHERE id = ?` + ).bind(userId).first(); + + if (!userCheck) { + return createResponse({ error: 'User does not exist', userId }, 404); + } + } catch (userError) { + return createResponse({ + error: 'Error checking user existence', + details: logError(userError, { action: 'check_user', userId }) + }, 500); + } + + const id = 'comment_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const timestamp = Date.now(); + + try { + await env.DB.prepare(` + INSERT INTO comments (id, proposal_id, user_id, comment_text, timestamp) + VALUES (?, ?, ?, ?, ?) + `).bind(id, proposalId, userId, commentText, timestamp).run(); + + console.log(`Successfully created comment with ID: ${id}`); + + // Get user name for response + const userQuery = await env.DB.prepare( + `SELECT name FROM users WHERE id = ?` + ).bind(userId).first(); + + return createResponse({ + id, + proposalId, + userId, + userName: userQuery.name, + commentText, + timestamp + }); + } catch (dbError) { + return createResponse({ + error: 'Failed to create comment in database', + details: logError(dbError, { + action: 'insert_comment', + comment: { id, proposalId, userId, timestamp } + }) + }, 500); + } + } catch (error) { + return createResponse({ + error: 'Failed to process comment creation', + details: logError(error, { action: 'create_comment_outer' }) + }, 500); + } +} + +// Create or get a user +async function createOrGetUser(request, env) { + try { + const data = await request.json(); + const { id, name } = data; + + console.log(`Processing user request: ${JSON.stringify(data)}`); + + if (!id || !name) { + return createResponse({ + error: 'Missing required fields', + received: { id: id || 'undefined', name: name || 'undefined' } + }, 400); + } + + // Check if user exists + let existingUser; + try { + const checkQuery = ` + SELECT id, name FROM users + WHERE id = ? + `; + + existingUser = await env.DB.prepare(checkQuery).bind(id).first(); + console.log(`Existing user check: ${existingUser ? JSON.stringify(existingUser) : 'None found'}`); + } catch (userCheckError) { + return createResponse({ + error: 'Error checking user existence', + details: logError(userCheckError, { action: 'check_user', id }) + }, 500); + } + + if (!existingUser) { + try { + // Create new user if doesn't exist + const timestamp = Date.now(); + const insertQuery = ` + INSERT INTO users (id, name, created_at) + VALUES (?, ?, ?) + `; + await env.DB.prepare(insertQuery).bind(id, name, timestamp).run(); + console.log(`Created new user with ID: ${id}`); + } catch (insertError) { + return createResponse({ + error: 'Failed to create user', + details: logError(insertError, { action: 'insert_user', user: { id, name } }) + }, 500); + } + } else { + console.log(`Found existing user: ${existingUser.id}`); + } + + return createResponse({ id, name }); + } catch (error) { + return createResponse({ + error: 'Failed to process user request', + details: logError(error, { action: 'user_outer' }) + }, 500); + } + +} \ No newline at end of file From ac53015842d47f7cbe6dd920fa01b5a70f32c662 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 17:30:42 +1100 Subject: [PATCH 07/25] x --- worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/worker.js b/worker.js index de815a3..8153c4b 100644 --- a/worker.js +++ b/worker.js @@ -2312,4 +2312,5 @@ async function createOrGetUser(request, env) { }, 500); } + } \ No newline at end of file From 3076e6fd885f3a7978efbfca1c031ee36726cfb1 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 17:45:31 +1100 Subject: [PATCH 08/25] x --- worker.js | 1 - 1 file changed, 1 deletion(-) diff --git a/worker.js b/worker.js index 8153c4b..de815a3 100644 --- a/worker.js +++ b/worker.js @@ -2312,5 +2312,4 @@ async function createOrGetUser(request, env) { }, 500); } - } \ No newline at end of file From 3424ca9456433836f97918cc829c1bae48876e28 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 17:53:35 +1100 Subject: [PATCH 09/25] x --- worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/worker.js b/worker.js index de815a3..8153c4b 100644 --- a/worker.js +++ b/worker.js @@ -2312,4 +2312,5 @@ async function createOrGetUser(request, env) { }, 500); } + } \ No newline at end of file From 8a0e32ce382612f465e12ec5c848b7a7599ba981 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 18:09:41 +1100 Subject: [PATCH 10/25] x --- worker.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/worker.js b/worker.js index 8153c4b..cce334d 100644 --- a/worker.js +++ b/worker.js @@ -27,6 +27,12 @@ export default { return handleOptions(); } + // Check if this is an API request (starts with /api/) + if (!path.startsWith('/api/')) { + // For non-API routes, pass through to Cloudflare Pages + return fetch(request); + } + // For GET requests, try to serve from cache first if (request.method === "GET") { // Create a cache key from the request URL @@ -2311,6 +2317,4 @@ async function createOrGetUser(request, env) { details: logError(error, { action: 'user_outer' }) }, 500); } - - } \ No newline at end of file From b088ea717086057c0de8b01649f7d8157dcacb43 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 18:19:40 +1100 Subject: [PATCH 11/25] Create wrangler.toml --- wrangler.toml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 wrangler.toml diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..b08893e --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,32 @@ +name = "radical-worker" +main = "worker.js" +compatibility_date = "2024-03-31" + +# D1 Database Binding +[[d1_databases]] +binding = "DB" # Use this name in your code +database_name = "radical-db" +database_id = "1f2a5273-bfcb-45c8-a759-5d675c05620c" + +# R2 Bucket Binding +[[r2_buckets]] +binding = "MEMES_BUCKET" +bucket_name = "radical-memes" + +# Environment variables (optional) +[vars] +ENVIRONMENT = "production" + +# Cloudflare Pages deployment settings +[build] +command = "npm run build" +publish = "dist" + +# Routes (if using workers.dev) +[[routes]] +pattern = "theradicalparty.com/api/*" +zone_name = "theradicalparty.com" + +# Optional: Specific Node.js version +[build.environment] +NODE_VERSION = "18.16.0" \ No newline at end of file From 896e636e6aaebb225b3ff6b1f76a5575b2b2b896 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 18:22:01 +1100 Subject: [PATCH 12/25] x --- wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrangler.toml b/wrangler.toml index b08893e..d5b142a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "radical-worker" +name = "radical" main = "worker.js" compatibility_date = "2024-03-31" From 4a4731e0af26066514b1889533d0e6a0f8a1fac7 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 18:24:54 +1100 Subject: [PATCH 13/25] x --- wrangler.toml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index d5b142a..aa625ef 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,16 +17,7 @@ bucket_name = "radical-memes" [vars] ENVIRONMENT = "production" -# Cloudflare Pages deployment settings -[build] -command = "npm run build" -publish = "dist" - # Routes (if using workers.dev) [[routes]] pattern = "theradicalparty.com/api/*" zone_name = "theradicalparty.com" - -# Optional: Specific Node.js version -[build.environment] -NODE_VERSION = "18.16.0" \ No newline at end of file From 3ceccb03928c408adf13ea531c5c7c6c26d00990 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 18:27:41 +1100 Subject: [PATCH 14/25] x --- wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrangler.toml b/wrangler.toml index aa625ef..b8b5d2e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -11,7 +11,7 @@ database_id = "1f2a5273-bfcb-45c8-a759-5d675c05620c" # R2 Bucket Binding [[r2_buckets]] binding = "MEMES_BUCKET" -bucket_name = "radical-memes" +bucket_name = "memes" # Environment variables (optional) [vars] From 9f98c853db90ad876470e4dd21edc0d4bb24b10a Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Mon, 31 Mar 2025 18:36:54 +1100 Subject: [PATCH 15/25] x --- worker.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/worker.js b/worker.js index cce334d..938561a 100644 --- a/worker.js +++ b/worker.js @@ -27,11 +27,11 @@ export default { return handleOptions(); } - // Check if this is an API request (starts with /api/) - if (!path.startsWith('/api/')) { - // For non-API routes, pass through to Cloudflare Pages - return fetch(request); - } + // // Check if this is an API request (starts with /api/) + // if (!path.startsWith('/api/')) { + // // For non-API routes, pass through to Cloudflare Pages + // return fetch(request); + // } // For GET requests, try to serve from cache first if (request.method === "GET") { From 3cb78a90d8cd56bac4c277803a35ccc815fc9694 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 20:37:49 +1100 Subject: [PATCH 16/25] x --- index.html | 137 +++++++++++++++++++++++++++++--------------------- worker.js | 102 ++++++++++++++++++++++++++++--------- wrangler.toml | 6 ++- 3 files changed, 162 insertions(+), 83 deletions(-) diff --git a/index.html b/index.html index 3f7599f..99559ea 100644 --- a/index.html +++ b/index.html @@ -417,34 +417,27 @@ function smartCopyShare(proposalId, proposalText) { // Create or get user in DB async function createUser(user) { - try { - console.log("Creating user:", user); // Log the user data - - const response = await fetch(`${API_BASE_URL}/users`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: user.id, - name: user.name - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error("User creation API error:", errorData); - throw new Error('Failed to create user'); - } - - const data = await response.json(); - console.log("User creation successful:", data); - return data; - } catch (error) { - console.error('Error creating user:', error); - throw error; - } - } + try { + console.log("Creating user:", user); + + const data = await fetchWithErrorHandling(`${API_BASE_URL}/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: user.id, + name: user.name + }) + }); + + console.log("User creation successful:", data); + return data; + } catch (error) { + console.error('Error creating user:', error); + throw error; + } +} // Animation for successful post function animatePostSuccess() { @@ -543,28 +536,58 @@ function smartCopyShare(proposalId, proposalText) { }, 500); }, 3000); } + + async function fetchWithErrorHandling(url, options = {}) { + try { + // Add mode: 'cors' explicitly to ensure CORS is respected + const response = await fetch(url, { + ...options, + mode: 'cors', + credentials: 'same-origin' + }); + + if (!response.ok) { + // Try to parse error message from response + let errorData; + try { + errorData = await response.json(); + } catch (e) { + errorData = { error: `HTTP error: ${response.status} ${response.statusText}` }; + } + + throw new Error( + errorData.error || + errorData.message || + `Request failed with status ${response.status}` + ); + } + + return await response.json(); + } catch (error) { + console.error(`Fetch error for ${url}:`, error); + throw error; + } +} // Fetch proposals from API async function fetchProposals() { - try { - // Show loading indicator - showLoadingIndicator(); - - const sortSelect = document.getElementById('sort-select'); - const sortBy = sortSelect ? sortSelect.value : 'newest'; - const response = await fetch(`${API_BASE_URL}/proposals?sortBy=${sortBy}&userId=${currentUser.id}`); - - if (!response.ok) { - throw new Error('Failed to fetch proposals'); - } - - const proposals = await response.json(); - renderProposals(proposals); - } catch (error) { - console.error('Error fetching proposals:', error); - showErrorMessage('Oops! Failed to load petitions. Parliament might be censoring us already!'); - } - } + try { + // Show loading indicator + showLoadingIndicator(); + + const sortSelect = document.getElementById('sort-select'); + const sortBy = sortSelect ? sortSelect.value : 'newest'; + + const data = await fetchWithErrorHandling( + `${API_BASE_URL}/proposals?sortBy=${sortBy}&userId=${currentUser.id}` + ); + + renderProposals(data); + } catch (error) { + console.error('Error fetching proposals:', error); + showErrorMessage('Oops! Failed to load petitions. Try refreshing the page.'); + } +} // Handle voting async function handleVote(proposalId, voteType) { @@ -760,7 +783,7 @@ async function submitVoteToServer(proposalId, voteType, isPetition = false, peti voteData.petitionDetails = petitionDetails; } - const response = await fetch(`${API_BASE_URL}/votes`, { + const response = await fetchWithErrorHandling(`${API_BASE_URL}/votes`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1117,7 +1140,7 @@ uploadIcon.style.marginRight = '8px'; const trending = Math.random() > 0.8; // Randomly mark some as trending // Create the proposal first - const createResponse = await fetch(`${API_BASE_URL}/proposals`, { + const createResponse = await fetchWithErrorHandling(`${API_BASE_URL}/proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1466,7 +1489,7 @@ uploadIcon.style.marginRight = '8px'; showToastMessage('Loading petition...', '#11cc77'); // Fetch the specific proposal - const response = await fetch(`${API_BASE_URL}/proposals?userId=${currentUser.id}`); + const response = await fetchWithErrorHandling(`${API_BASE_URL}/proposals?userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch proposal'); @@ -1761,7 +1784,7 @@ async function loadModalComments(proposalId) { try { // Include the current user ID to get their votes - const response = await fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); + const response = await fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch comments'); @@ -1880,7 +1903,7 @@ async function handleCommentVote(commentId, voteType) { // Submit vote to server try { - const response = await fetch(`${API_BASE_URL}/comment-votes`, { + const response = await fetchWithErrorHandling(`${API_BASE_URL}/comment-votes`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1938,7 +1961,7 @@ async function loadComments(proposalId) { try { // Include the current user ID to get their votes - const response = await fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); + const response = await fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch comments'); @@ -2028,7 +2051,7 @@ async function submitComment(proposalId, form) { submitButton.disabled = true; try { - const response = await fetch(`${API_BASE_URL}/comments`, { + const response = await fetchWithErrorHandling(`${API_BASE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -2342,7 +2365,7 @@ function updateCommentCount(proposalId) { const commentsToggle = document.getElementById(`comments-toggle-${proposalId}`); if (!commentsToggle) return; - fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) + fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}`) .then(response => response.json()) .then(comments => { const commentCount = comments.length; @@ -2360,7 +2383,7 @@ function loadComments(proposalId) { commentsList.innerHTML = '
Loading comments...
'; - fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) + fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}`) .then(response => response.json()) .then(comments => { if (comments.length === 0) { @@ -2389,7 +2412,7 @@ function loadComments(proposalId) { // Update submitComment function to handle promises function submitComment(proposalId, commentText) { - return fetch(`${API_BASE_URL}/comments`, { + return fetchWithErrorHandling(`${API_BASE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/worker.js b/worker.js index 938561a..3dd155b 100644 --- a/worker.js +++ b/worker.js @@ -1,3 +1,38 @@ +// Define CORS headers - These need to be applied to *all* responses +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', // 24 hours +}; + +// Handle OPTIONS request for CORS preflight +function handleOptions() { + return new Response(null, { + headers: corsHeaders + }); +} + +// Helper to create standardized responses with CORS headers +function createResponse(body, status = 200, cacheDuration = 60*60*24) { + const headers = { + 'Content-Type': 'application/json', + ...corsHeaders // Make sure CORS headers are added to every response + }; + + if (cacheDuration > 0) { + headers['Cache-Control'] = `public, max-age=${cacheDuration}`; + headers['CDN-Cache-Control'] = `public, max-age=${cacheDuration}`; + } else { + headers['Cache-Control'] = 'no-store'; + } + + return new Response(JSON.stringify(body), { + status, + headers + }); +} + export default { async fetch(request, env, ctx) { try { @@ -13,7 +48,10 @@ export default { // Check if the hostname is valid if (!validHostnames.includes(url.hostname)) { console.error(`Unexpected hostname: ${url.hostname}`); - return new Response('Invalid hostname', { status: 400 }); + return new Response('Invalid hostname', { + status: 400, + headers: corsHeaders // Add CORS headers even to error responses + }); } const path = url.pathname; @@ -22,17 +60,11 @@ export default { console.log(`Processing request: ${url.toString()}`); console.log(`User-Agent: ${request.headers.get('User-Agent')}`); - // Handle CORS preflight requests first + // Handle CORS preflight requests first - this is critical if (request.method === 'OPTIONS') { return handleOptions(); } - // // Check if this is an API request (starts with /api/) - // if (!path.startsWith('/api/')) { - // // For non-API routes, pass through to Cloudflare Pages - // return fetch(request); - // } - // For GET requests, try to serve from cache first if (request.method === "GET") { // Create a cache key from the request URL @@ -69,7 +101,10 @@ export default { if (!response.ok) { console.error('Failed to fetch styles.css', response.status, response.statusText); - return new Response('CSS File Not Found', { status: 404 }); + return new Response('CSS File Not Found', { + status: 404, + headers: corsHeaders // Add CORS headers + }); } // Get the CSS content @@ -96,7 +131,10 @@ export default { return cssResponse; } catch (error) { console.error('Error fetching styles.css:', error); - return new Response('Internal Server Error', { status: 500 }); + return new Response('Internal Server Error', { + status: 500, + headers: corsHeaders // Add CORS headers + }); } } @@ -252,22 +290,36 @@ export default { } } - return response; - - } catch (error) { - console.error('Critical error in request handling:', error); - return new Response(JSON.stringify({ - error: 'Internal Server Error', - details: error.message - }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - ...corsHeaders // Add CORS headers to error responses too - } - }); - } + // If no other handler catches the request, return a 404 + return createResponse({ + error: 'Not found', + path, + method: request.method, + availableRoutes: [ + { path: '/api/health', method: 'GET', description: 'Check system health' }, + { path: '/api/proposals', method: 'GET', description: 'Get proposals' }, + { path: '/api/proposals/:id', method: 'GET', description: 'Get a single proposal by ID' }, + { path: '/api/proposals/:id', method: 'PUT', description: 'Update proposal' }, + { path: '/api/votes', method: 'POST', description: 'Create/update vote' }, + { path: '/api/users', method: 'POST', description: 'Create/get user' }, + { path: '/api/petition-stats', method: 'GET', description: 'Get petition statistics' } + ] + }, 404); + + } catch (error) { + console.error('Critical error in request handling:', error); + return new Response(JSON.stringify({ + error: 'Internal Server Error', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders // Add CORS headers to error responses too + } + }); } +} }; diff --git a/wrangler.toml b/wrangler.toml index b8b5d2e..77c93d3 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,7 +17,11 @@ bucket_name = "memes" [vars] ENVIRONMENT = "production" -# Routes (if using workers.dev) +# Routes - Ensure ALL routes are properly configured [[routes]] pattern = "theradicalparty.com/api/*" zone_name = "theradicalparty.com" + +[[routes]] +pattern = "radical.omar-c29.workers.dev/*" +zone_name = "radical.omar-c29.workers.dev" From 29f3c669c101ff7447f08628cfabac82b112706a Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 21:05:48 +1100 Subject: [PATCH 17/25] x --- worker.js | 896 ++++++++++++++++++++++++++---------------------------- 1 file changed, 425 insertions(+), 471 deletions(-) diff --git a/worker.js b/worker.js index 3dd155b..badd218 100644 --- a/worker.js +++ b/worker.js @@ -1,9 +1,8 @@ -// Define CORS headers - These need to be applied to *all* responses +// Define CORS headers const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400', // 24 hours }; // Handle OPTIONS request for CORS preflight @@ -13,11 +12,11 @@ function handleOptions() { }); } -// Helper to create standardized responses with CORS headers +// Helper to create standardized responses function createResponse(body, status = 200, cacheDuration = 60*60*24) { const headers = { 'Content-Type': 'application/json', - ...corsHeaders // Make sure CORS headers are added to every response + ...corsHeaders // Add CORS headers }; if (cacheDuration > 0) { @@ -48,10 +47,7 @@ export default { // Check if the hostname is valid if (!validHostnames.includes(url.hostname)) { console.error(`Unexpected hostname: ${url.hostname}`); - return new Response('Invalid hostname', { - status: 400, - headers: corsHeaders // Add CORS headers even to error responses - }); + return new Response('Invalid hostname', { status: 400 }); } const path = url.pathname; @@ -60,7 +56,7 @@ export default { console.log(`Processing request: ${url.toString()}`); console.log(`User-Agent: ${request.headers.get('User-Agent')}`); - // Handle CORS preflight requests first - this is critical + // Handle CORS preflight requests first if (request.method === 'OPTIONS') { return handleOptions(); } @@ -103,7 +99,7 @@ export default { console.error('Failed to fetch styles.css', response.status, response.statusText); return new Response('CSS File Not Found', { status: 404, - headers: corsHeaders // Add CORS headers + headers: corsHeaders }); } @@ -133,7 +129,7 @@ export default { console.error('Error fetching styles.css:', error); return new Response('Internal Server Error', { status: 500, - headers: corsHeaders // Add CORS headers + headers: corsHeaders }); } } @@ -290,36 +286,22 @@ export default { } } - // If no other handler catches the request, return a 404 - return createResponse({ - error: 'Not found', - path, - method: request.method, - availableRoutes: [ - { path: '/api/health', method: 'GET', description: 'Check system health' }, - { path: '/api/proposals', method: 'GET', description: 'Get proposals' }, - { path: '/api/proposals/:id', method: 'GET', description: 'Get a single proposal by ID' }, - { path: '/api/proposals/:id', method: 'PUT', description: 'Update proposal' }, - { path: '/api/votes', method: 'POST', description: 'Create/update vote' }, - { path: '/api/users', method: 'POST', description: 'Create/get user' }, - { path: '/api/petition-stats', method: 'GET', description: 'Get petition statistics' } - ] - }, 404); - - } catch (error) { - console.error('Critical error in request handling:', error); - return new Response(JSON.stringify({ - error: 'Internal Server Error', - details: error.message - }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - ...corsHeaders // Add CORS headers to error responses too - } - }); + return response; + + } catch (error) { + console.error('Critical error in request handling:', error); + return new Response(JSON.stringify({ + error: 'Internal Server Error', + details: error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders // Add CORS headers to error responses too + } + }); + } } -} }; @@ -958,7 +940,9 @@ async function uploadMeme(request, env) { details: logError(error, { action: 'upload_meme' }) }, 500); } -}// Get petition statistics +} + +// Get petition statistics async function getPetitionStats(request, env) { try { const url = new URL(request.url); @@ -1009,12 +993,7 @@ async function getPetitionStats(request, env) { details: logError(error, { action: 'petition_stats', proposalId }) }, 500); } -}// Define CORS headers -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', -}; +} // Helper to log detailed errors function logError(error, context = {}) { @@ -1029,372 +1008,6 @@ function logError(error, context = {}) { return errorDetails; } -// Handle OPTIONS request for CORS preflight -function handleOptions() { - return new Response(null, { - headers: corsHeaders - }); -} - -// Helper to create standardized responses -function createResponse(body, status = 200, shouldCache = false, cacheDuration = 300) { - const headers = { - 'Content-Type': 'application/json', - ...corsHeaders - }; - - if (shouldCache) { - headers['Cache-Control'] = `public, max-age=${cacheDuration}`; - } else { - headers['Cache-Control'] = 'no-store'; - } - - return new Response(JSON.stringify(body), { - status, - headers - }); -} - - -async function verifyTemplateImage(env) { - try { - const key = "petition-template.png"; - const object = await env.MEMES_BUCKET.get(key); - - if (object === null) { - console.error("❌ Template image not found in R2 bucket!"); - return false; - } - - console.log("✅ Template image found in R2 bucket"); - return true; - } catch (error) { - console.error("Error verifying template image:", error); - return false; - } -} - - -/** - * Handle requests for dynamic SVG images for petitions - * @param {Request} request - The incoming request - * @param {Object} env - Environment bindings - * @returns {Response} - SVG response - */ -async function servePetitionSVG(request, env) { - try { - const url = new URL(request.url); - const proposalId = url.searchParams.get('id'); - - if (!proposalId) { - return new Response('Missing proposal ID', { status: 400 }); - } - - console.log(`Generating SVG image for proposal: ${proposalId}`); - - // Fetch the proposal data from the database - const proposal = await env.DB.prepare(` - SELECT - p.id, p.text, p.timestamp, - u.name as author_name, - (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, - (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes - FROM proposals p - JOIN users u ON p.author_id = u.id - WHERE p.id = ? - `).bind(proposalId).first(); - - if (!proposal) { - return new Response('Proposal not found', { status: 404 }); - } - - // Generate the SVG - const svgMarkup = generatePetitionSVG(proposal); - - // Return the SVG with appropriate headers - return new Response(svgMarkup, { - headers: { - 'Content-Type': 'image/svg+xml', - 'Cache-Control': 'public, max-age=360000', // Cache for 1 hour - 'Access-Control-Allow-Origin': '*' - } - }); - } catch (error) { - console.error('Error generating petition SVG:', error); - return new Response('Internal Server Error', { status: 500 }); - } -} - -/** - * Generates a dynamic SVG for a petition with the given data - * @param {Object} proposal - The petition/proposal data - * @returns {string} - SVG markup as a string - */ -function generatePetitionSVG(proposal) { - // Get data with fallbacks - const petitionText = proposal.text || "Unknown petition"; - const upvotes = proposal.upvotes || 0; - const downvotes = proposal.downvotes || 0; - const netVotes = upvotes - downvotes; - const netVotesDisplay = netVotes > 0 ? `+${netVotes}` : netVotes; - const netVotesColor = netVotes > 0 ? "#11cc77" : (netVotes < 0 ? "#ff0099" : "#ffffff"); - const proposalId = proposal.id || "unknown"; - - // Format timestamp - const timestamp = new Date(proposal.timestamp || Date.now()).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - }); - - // Base SVG template - const svgTemplate = ` - - - - - - - - - RADICAL - - - - VIBE, VOTE, VETO - - - - - - ${generateSVGTextLines(petitionText, 120, 220, 960)} - - - - - - ${upvotes} - - - - ${downvotes} - - - NET: - ${netVotesDisplay} - - - - - Petition ID: ${proposalId} • Created: ${timestamp} - - - - - - - RADICAL - PETITION - #${proposalId.slice(-6)} - - - - theradicalparty.com -`; - - return svgTemplate; -} - -/** - * Generates SVG text elements with proper line wrapping - * @param {string} text - The text to wrap - * @param {number} x - Starting x position - * @param {number} y - Starting y position - * @param {number} width - Maximum width for text wrapping - * @returns {string} - SVG text elements - */ -function generateSVGTextLines(text, x, y, width) { - // Simple word wrapping algorithm - const words = text.split(' '); - const lines = []; - let currentLine = ''; - - // Estimate characters per line based on width and average character width - const avgCharWidth = 22; // Rough estimate for font size ~36px - const charsPerLine = Math.floor(width / avgCharWidth); - - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - if (testLine.length <= charsPerLine) { - currentLine = testLine; - } else { - lines.push(currentLine); - currentLine = word; - } - } - - if (currentLine) { - lines.push(currentLine); - } - - // Limit to 6 lines to fit in the container - const limitedLines = lines.slice(0, 6); - if (lines.length > 6) { - // Add ellipsis to the last line if truncated - limitedLines[5] = limitedLines[5].slice(0, -3) + '...'; - } - - // Generate SVG text elements - const lineHeight = 46; - let svgTextElements = ''; - - limitedLines.forEach((line, index) => { - const lineY = y + index * lineHeight; - svgTextElements += `${escapeHTML(line)}\n`; - }); - - return svgTextElements; -} - -/** - * Escapes HTML special characters in a string - * @param {string} text - The text to escape - * @returns {string} - Escaped text - */ -function escapeHTML(text) { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// Update the getShareableImageUrl function to use the SVG endpoint -function getShareableImageUrl(proposal) { - // Use the dynamic SVG endpoint with the proposal ID - return `https://${new URL(request.url).hostname}/api/petition-svg?id=${proposal.id}`; -} - -// Update the serveCustomizedHtml function to use the SVG URL for meta tags -async function serveCustomizedHtml(proposalId, request, env) { - console.log(`===== BEGIN serveCustomizedHtml for proposal ${proposalId} =====`); - - try { - const url = new URL(request.url); - const isTestMeta = url.searchParams.get('test_meta') === 'true'; - - // Determine which proposal to use - let proposal; - let shareImageUrl; - - if (isTestMeta) { - // Use test proposal data - proposal = { - id: 'test_proposal', - text: 'This is a test proposal for meta tags', - timestamp: Date.now(), - author_name: 'Test User' - }; - shareImageUrl = `https://${url.hostname}/api/petition-svg?id=test_proposal`; - } else { - // Fetch the actual proposal from the database - proposal = await env.DB.prepare(` - SELECT - p.id, p.text, p.timestamp, - u.name as author_name - FROM proposals p - JOIN users u ON p.author_id = u.id - WHERE p.id = ? - `).bind(proposalId).first(); - - if (!proposal) { - console.error(`Proposal not found: ${proposalId}`); - return await fetch(request); - } - - // Generate SVG URL - shareImageUrl = `https://${url.hostname}/api/petition-svg?id=${proposalId}`; - } - - console.log(`Found proposal: ${JSON.stringify(proposal)}`); - console.log(`Using share image URL: ${shareImageUrl}`); - - // Get the full URL for this proposal - const fullProposalUrl = `https://${url.hostname}/?proposal=${proposalId}`; - - // Fetch the original HTML - const response = await fetch(request); - const html = await response.text(); - - // First, remove all existing og:image and twitter:image tags - let modifiedHtml = html.replace(/]*>/g, ''); - modifiedHtml = modifiedHtml.replace(/]*>/g, ''); - modifiedHtml = modifiedHtml.replace(/]*>/g, ''); - - // Find the head end tag - const headEndIndex = modifiedHtml.indexOf(''); - if (headEndIndex === -1) { - console.error("Could not find tag in HTML"); - return new Response(modifiedHtml, { - headers: response.headers - }); - } - - // Create comprehensive meta tags - const customMetaTags = ` - - - - - - - - - - - - - - - - - - - - - - - `; - - // Insert our meta tags right before - let customizedHtml = modifiedHtml.substring(0, headEndIndex) + customMetaTags + modifiedHtml.substring(headEndIndex); - - console.log(`===== END serveCustomizedHtml for proposal ${proposalId} =====`); - - // Return the customized HTML with appropriate headers - const originalHeaders = new Headers(response.headers); - originalHeaders.set('Cache-Control', 'no-store, max-age=0'); - originalHeaders.set('X-Custom-Meta', isTestMeta ? 'test' : 'proposal'); - originalHeaders.set('X-Share-Image-Url', shareImageUrl); // Debug header - - return new Response(customizedHtml, { - headers: originalHeaders - }); - } catch (error) { - console.error('Error generating customized HTML:', error); - // Fall back to regular page if something goes wrong - return await fetch(request); - } -} - - - - // Function to check if a table exists async function tableExists(env, tableName) { try { @@ -1843,6 +1456,7 @@ async function createProposal(request, env) { }, 500); } } + // Update a proposal (e.g., mark as trending) async function updateProposal(id, request, env) { try { @@ -1889,68 +1503,73 @@ async function updateProposal(id, request, env) { return createResponse({ error: 'Failed to process proposal update', details: logError(error, { action: 'update_proposal_outer', id }) - }, 500); + }, 500); } } - - // to serve audio files from R2 - - async function serveMedia(request, env) { - const url = new URL(request.url); - const path = url.pathname; - - // Determine if this is a meme or audio file request - let key; - if (path.startsWith('/memes/')) { - key = path.replace('/memes/', ''); - } else if (path.startsWith('/audio/')) { - key = path.replace('/audio/', ''); - } else { - return new Response('Not found', { status: 404 }); - } - - // Handle CORS preflight requests - if (request.method === 'OPTIONS') { - return handleOptions(); - } - - try { - const object = await env.MEMES_BUCKET.get(key); - - if (object === null) { - return new Response('File not found', { status: 404 }); - } - - const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set('etag', object.httpEtag); - headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year - - // Set the appropriate content-type based on file extension - if (key.endsWith('.mp3')) { - headers.set('Content-Type', 'audio/mpeg'); - } else if (key.endsWith('.wav')) { - headers.set('Content-Type', 'audio/wav'); - } else if (key.endsWith('.ogg')) { - headers.set('Content-Type', 'audio/ogg'); - } - - // Add CORS headers - Object.keys(corsHeaders).forEach(key => { - headers.set(key, corsHeaders[key]); - }); - - return new Response(object.body, { - headers - }); - } catch (error) { - console.error('Error serving media:', error); - return new Response('Internal Server Error', { status: 500 }); - } - } - +// Handle serving media files (memes and audio) +async function serveMedia(request, env) { + const url = new URL(request.url); + const path = url.pathname; + // Determine if this is a meme or audio file request + let key; + if (path.startsWith('/memes/')) { + key = path.replace('/memes/', ''); + } else if (path.startsWith('/audio/')) { + key = path.replace('/audio/', ''); + } else { + return new Response('Not found', { + status: 404, + headers: corsHeaders + }); + } + + // Handle CORS preflight requests + if (request.method === 'OPTIONS') { + return handleOptions(); + } + + try { + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + return new Response('File not found', { + status: 404, + headers: corsHeaders + }); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year + + // Set the appropriate content-type based on file extension + if (key.endsWith('.mp3')) { + headers.set('Content-Type', 'audio/mpeg'); + } else if (key.endsWith('.wav')) { + headers.set('Content-Type', 'audio/wav'); + } else if (key.endsWith('.ogg')) { + headers.set('Content-Type', 'audio/ogg'); + } + + // Add CORS headers + Object.keys(corsHeaders).forEach(key => { + headers.set(key, corsHeaders[key]); + }); + + return new Response(object.body, { + headers + }); + } catch (error) { + console.error('Error serving media:', error); + return new Response('Internal Server Error', { + status: 500, + headers: corsHeaders + }); + } +} // Create or update a vote and handle petition data async function createOrUpdateVote(request, env) { @@ -2369,4 +1988,339 @@ async function createOrGetUser(request, env) { details: logError(error, { action: 'user_outer' }) }, 500); } +} + +async function verifyTemplateImage(env) { + try { + const key = "petition-template.png"; + const object = await env.MEMES_BUCKET.get(key); + + if (object === null) { + console.error("❌ Template image not found in R2 bucket!"); + return false; + } + + console.log("✅ Template image found in R2 bucket"); + return true; + } catch (error) { + console.error("Error verifying template image:", error); + return false; + } +} + +/** + * Handle requests for dynamic SVG images for petitions + * @param {Request} request - The incoming request + * @param {Object} env - Environment bindings + * @returns {Response} - SVG response + */ +async function servePetitionSVG(request, env) { + try { + const url = new URL(request.url); + const proposalId = url.searchParams.get('id'); + + if (!proposalId) { + return new Response('Missing proposal ID', { status: 400 }); + } + + console.log(`Generating SVG image for proposal: ${proposalId}`); + + // Fetch the proposal data from the database + const proposal = await env.DB.prepare(` + SELECT + p.id, p.text, p.timestamp, + u.name as author_name, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes, + (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `).bind(proposalId).first(); + + if (!proposal) { + return new Response('Proposal not found', { status: 404 }); + } + + // Generate the SVG + const svgMarkup = generatePetitionSVG(proposal); + + // Return the SVG with appropriate headers + return new Response(svgMarkup, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=360000', // Cache for 1 hour + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Error generating petition SVG:', error); + return new Response('Internal Server Error', { status: 500 }); + } +} + +/** + * Generates a dynamic SVG for a petition with the given data + * @param {Object} proposal - The petition/proposal data + * @returns {string} - SVG markup as a string + */ +function generatePetitionSVG(proposal) { + // Get data with fallbacks + const petitionText = proposal.text || "Unknown petition"; + const upvotes = proposal.upvotes || 0; + const downvotes = proposal.downvotes || 0; + const netVotes = upvotes - downvotes; + const netVotesDisplay = netVotes > 0 ? `+${netVotes}` : netVotes; + const netVotesColor = netVotes > 0 ? "#11cc77" : (netVotes < 0 ? "#ff0099" : "#ffffff"); + const proposalId = proposal.id || "unknown"; + + // Format timestamp + const timestamp = new Date(proposal.timestamp || Date.now()).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + // Base SVG template + const svgTemplate = ` + + + + + + + + + RADICAL + + + + VIBE, VOTE, VETO + + + + + + ${generateSVGTextLines(petitionText, 120, 220, 960)} + + + + + + ${upvotes} + + + + ${downvotes} + + + NET: + ${netVotesDisplay} + + + + + Petition ID: ${proposalId} • Created: ${timestamp} + + + + + + + RADICAL + PETITION + #${proposalId.slice(-6)} + + + + theradicalparty.com +`; + + return svgTemplate; +} + +/** + * Generates SVG text elements with proper line wrapping + * @param {string} text - The text to wrap + * @param {number} x - Starting x position + * @param {number} y - Starting y position + * @param {number} width - Maximum width for text wrapping + * @returns {string} - SVG text elements + */ +function generateSVGTextLines(text, x, y, width) { + // Simple word wrapping algorithm + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + // Estimate characters per line based on width and average character width + const avgCharWidth = 22; // Rough estimate for font size ~36px + const charsPerLine = Math.floor(width / avgCharWidth); + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (testLine.length <= charsPerLine) { + currentLine = testLine; + } else { + lines.push(currentLine); + currentLine = word; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + // Limit to 6 lines to fit in the container + const limitedLines = lines.slice(0, 6); + if (lines.length > 6) { + // Add ellipsis to the last line if truncated + limitedLines[5] = limitedLines[5].slice(0, -3) + '...'; + } + + // Generate SVG text elements + const lineHeight = 46; + let svgTextElements = ''; + + limitedLines.forEach((line, index) => { + const lineY = y + index * lineHeight; + svgTextElements += `${escapeHTML(line)}\n`; + }); + + return svgTextElements; +} + +/** + * Escapes HTML special characters in a string + * @param {string} text - The text to escape + * @returns {string} - Escaped text + */ +function escapeHTML(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Update the getShareableImageUrl function to use the SVG endpoint +function getShareableImageUrl(proposal) { + // Use the dynamic SVG endpoint with the proposal ID + return `https://radical.omar-c29.workers.dev/api/petition-svg?id=${proposal.id}`; +} + +// Update the serveCustomizedHtml function to use the SVG URL for meta tags +async function serveCustomizedHtml(proposalId, request, env) { + console.log(`===== BEGIN serveCustomizedHtml for proposal ${proposalId} =====`); + + try { + const url = new URL(request.url); + const isTestMeta = url.searchParams.get('test_meta') === 'true'; + + // Determine which proposal to use + let proposal; + let shareImageUrl; + + if (isTestMeta) { + // Use test proposal data + proposal = { + id: 'test_proposal', + text: 'This is a test proposal for meta tags', + timestamp: Date.now(), + author_name: 'Test User' + }; + shareImageUrl = `https://${url.hostname}/api/petition-svg?id=test_proposal`; + } else { + // Fetch the actual proposal from the database + proposal = await env.DB.prepare(` + SELECT + p.id, p.text, p.timestamp, + u.name as author_name + FROM proposals p + JOIN users u ON p.author_id = u.id + WHERE p.id = ? + `).bind(proposalId).first(); + + if (!proposal) { + console.error(`Proposal not found: ${proposalId}`); + return await fetch(request); + } + + // Generate SVG URL + shareImageUrl = `https://${url.hostname}/api/petition-svg?id=${proposalId}`; + } + + console.log(`Found proposal: ${JSON.stringify(proposal)}`); + console.log(`Using share image URL: ${shareImageUrl}`); + + // Get the full URL for this proposal + const fullProposalUrl = `https://${url.hostname}/?proposal=${proposalId}`; + + // Fetch the original HTML + const response = await fetch(request); + const html = await response.text(); + + // First, remove all existing og:image and twitter:image tags + let modifiedHtml = html.replace(/]*>/g, ''); + modifiedHtml = modifiedHtml.replace(/]*>/g, ''); + modifiedHtml = modifiedHtml.replace(/]*>/g, ''); + + // Find the head end tag + const headEndIndex = modifiedHtml.indexOf(''); + if (headEndIndex === -1) { + console.error("Could not find tag in HTML"); + return new Response(modifiedHtml, { + headers: response.headers + }); + } + + // Create comprehensive meta tags + const customMetaTags = ` + + + + + + + + + + + + + + + + + + + + + + + `; + + // Insert our meta tags right before + let customizedHtml = modifiedHtml.substring(0, headEndIndex) + customMetaTags + modifiedHtml.substring(headEndIndex); + + console.log(`===== END serveCustomizedHtml for proposal ${proposalId} =====`); + + // Return the customized HTML with appropriate headers + const originalHeaders = new Headers(response.headers); + originalHeaders.set('Cache-Control', 'no-store, max-age=0'); + originalHeaders.set('X-Custom-Meta', isTestMeta ? 'test' : 'proposal'); + originalHeaders.set('X-Share-Image-Url', shareImageUrl); // Debug header + + return new Response(customizedHtml, { + headers: originalHeaders + }); + } catch (error) { + console.error('Error generating customized HTML:', error); + // Fall back to regular page if something goes wrong + return await fetch(request); + } } \ No newline at end of file From 2ebdde777a134f8aa7a1327d7c5817943bf802e4 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 21:08:18 +1100 Subject: [PATCH 18/25] x --- wrangler.toml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/wrangler.toml b/wrangler.toml index 77c93d3..f6bd668 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,11 +17,4 @@ bucket_name = "memes" [vars] ENVIRONMENT = "production" -# Routes - Ensure ALL routes are properly configured -[[routes]] -pattern = "theradicalparty.com/api/*" -zone_name = "theradicalparty.com" -[[routes]] -pattern = "radical.omar-c29.workers.dev/*" -zone_name = "radical.omar-c29.workers.dev" From 0bed3f7b27b8b26d8f0493c69497dfb6d859e07e Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 21:27:40 +1100 Subject: [PATCH 19/25] x --- index.html | 2 +- wrangler.toml | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 99559ea..735322c 100644 --- a/index.html +++ b/index.html @@ -266,7 +266,7 @@ function smartCopyShare(proposalId, proposalText) { } } // Configuration - const API_BASE_URL = 'https://radical.omar-c29.workers.dev/api'; // Replace with your actual worker URL + const API_BASE_URL = 'https://theradicalparty.com/api'; // Replace with your actual worker URL // Character limit const CHAR_LIMIT = 280; diff --git a/wrangler.toml b/wrangler.toml index f6bd668..a4be079 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,4 +17,15 @@ bucket_name = "memes" [vars] ENVIRONMENT = "production" +# wrangler.toml (wrangler v3.88.0^) +[observability.logs] +enabled = true + + +# Route API requests to your worker +[[routes]] +pattern = "theradicalparty.com/api/*" +zone_name = "theradicalparty.com" + + From d24883d4a42f477d833c6e0905772edacde3c11f Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 21:36:26 +1100 Subject: [PATCH 20/25] x --- index.html | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index 735322c..53baddd 100644 --- a/index.html +++ b/index.html @@ -420,7 +420,7 @@ function smartCopyShare(proposalId, proposalText) { try { console.log("Creating user:", user); - const data = await fetchWithErrorHandling(`${API_BASE_URL}/users`, { + const data = await fetch(`${API_BASE_URL}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -537,7 +537,7 @@ function smartCopyShare(proposalId, proposalText) { }, 3000); } - async function fetchWithErrorHandling(url, options = {}) { + async function fetch(url, options = {}) { try { // Add mode: 'cors' explicitly to ensure CORS is respected const response = await fetch(url, { @@ -578,7 +578,7 @@ function smartCopyShare(proposalId, proposalText) { const sortSelect = document.getElementById('sort-select'); const sortBy = sortSelect ? sortSelect.value : 'newest'; - const data = await fetchWithErrorHandling( + const data = await fetch( `${API_BASE_URL}/proposals?sortBy=${sortBy}&userId=${currentUser.id}` ); @@ -783,7 +783,7 @@ async function submitVoteToServer(proposalId, voteType, isPetition = false, peti voteData.petitionDetails = petitionDetails; } - const response = await fetchWithErrorHandling(`${API_BASE_URL}/votes`, { + const response = await fetch(`${API_BASE_URL}/votes`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1140,7 +1140,7 @@ uploadIcon.style.marginRight = '8px'; const trending = Math.random() > 0.8; // Randomly mark some as trending // Create the proposal first - const createResponse = await fetchWithErrorHandling(`${API_BASE_URL}/proposals`, { + const createResponse = await fetch(`${API_BASE_URL}/proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1489,7 +1489,7 @@ uploadIcon.style.marginRight = '8px'; showToastMessage('Loading petition...', '#11cc77'); // Fetch the specific proposal - const response = await fetchWithErrorHandling(`${API_BASE_URL}/proposals?userId=${currentUser.id}`); + const response = await fetch(`${API_BASE_URL}/proposals?userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch proposal'); @@ -1784,7 +1784,7 @@ async function loadModalComments(proposalId) { try { // Include the current user ID to get their votes - const response = await fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); + const response = await fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch comments'); @@ -1903,7 +1903,7 @@ async function handleCommentVote(commentId, voteType) { // Submit vote to server try { - const response = await fetchWithErrorHandling(`${API_BASE_URL}/comment-votes`, { + const response = await fetch(`${API_BASE_URL}/comment-votes`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1961,7 +1961,7 @@ async function loadComments(proposalId) { try { // Include the current user ID to get their votes - const response = await fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); + const response = await fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch comments'); @@ -2051,7 +2051,7 @@ async function submitComment(proposalId, form) { submitButton.disabled = true; try { - const response = await fetchWithErrorHandling(`${API_BASE_URL}/comments`, { + const response = await fetch(`${API_BASE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -2365,7 +2365,7 @@ function updateCommentCount(proposalId) { const commentsToggle = document.getElementById(`comments-toggle-${proposalId}`); if (!commentsToggle) return; - fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}`) + fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) .then(response => response.json()) .then(comments => { const commentCount = comments.length; @@ -2383,7 +2383,7 @@ function loadComments(proposalId) { commentsList.innerHTML = '
Loading comments...
'; - fetchWithErrorHandling(`${API_BASE_URL}/comments?proposalId=${proposalId}`) + fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) .then(response => response.json()) .then(comments => { if (comments.length === 0) { @@ -2412,7 +2412,7 @@ function loadComments(proposalId) { // Update submitComment function to handle promises function submitComment(proposalId, commentText) { - return fetchWithErrorHandling(`${API_BASE_URL}/comments`, { + return fetch(`${API_BASE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' From 5e32daa7b401ac09217e9488154e2f1689e17dc7 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 21:48:07 +1100 Subject: [PATCH 21/25] x --- worker.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/worker.js b/worker.js index badd218..e12729e 100644 --- a/worker.js +++ b/worker.js @@ -892,7 +892,7 @@ async function uploadMeme(request, env) { }); // Generate the public URL - const memeUrl = `https://radical.omar-c29.workers.dev/memes/${uniqueFilename}`; + const memeUrl = `https://theradicalparty.com/memes/${uniqueFilename}`; // Helper function to fix broken meme URLs function fixMemeUrl(url) { @@ -901,7 +901,7 @@ async function uploadMeme(request, env) { // Extract the filename from the URL const filename = url.split('/').pop(); // Replace with the correct domain - return `https://radical.omar-c29.workers.dev/memes/${filename}`; + return `https://theradicalparty.com/memes/${filename}`; } return url; } @@ -1330,7 +1330,7 @@ async function generateAndStoreShareImage(proposal, env) { const params = `b_black,w_1200,h_630,c_fill/l_text:Arial_32_bold:${encodedText},co_white,w_1000,c_fit,g_center`; // Template image URL - const templateUrl = encodeURIComponent("https://radical.omar-c29.workers.dev/memes/petition-template.png"); + const templateUrl = encodeURIComponent("https://theradicalparty.com/memes/petition-template.png"); // Create a unique identifier for this image const imageId = `share_${proposal.id}_${Date.now()}`; @@ -1358,7 +1358,7 @@ async function generateAndStoreShareImage(proposal, env) { }); // Generate the public URL for the stored image - const shareImageUrl = `https://radical.omar-c29.workers.dev/memes/${imageId}`; + const shareImageUrl = `https://theradicalparty.com/memes/${imageId}`; console.log(`Stored share image at: ${shareImageUrl}`); @@ -1373,7 +1373,7 @@ async function generateAndStoreShareImage(proposal, env) { } catch (error) { console.error('Error generating share image:', error); // Return a fallback image URL in case of failure - return "https://radical.omar-c29.workers.dev/memes/mickeymeta.PNG"; + return "https://theradicalparty.com/memes/mickeymeta.PNG"; } } @@ -2210,7 +2210,7 @@ function escapeHTML(text) { // Update the getShareableImageUrl function to use the SVG endpoint function getShareableImageUrl(proposal) { // Use the dynamic SVG endpoint with the proposal ID - return `https://radical.omar-c29.workers.dev/api/petition-svg?id=${proposal.id}`; + return `https://theradicalparty.com/api//petition-svg?id=${proposal.id}`; } // Update the serveCustomizedHtml function to use the SVG URL for meta tags From 62d4d8f2da0992b18e2b475b5410a7cf33dd2343 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 21:52:18 +1100 Subject: [PATCH 22/25] x --- index.html | 7 +++- worker.js | 113 ++++++++++++++++++++++++++++++++--------------------- 2 files changed, 73 insertions(+), 47 deletions(-) diff --git a/index.html b/index.html index 53baddd..66e1240 100644 --- a/index.html +++ b/index.html @@ -266,8 +266,11 @@ function smartCopyShare(proposalId, proposalText) { } } // Configuration - const API_BASE_URL = 'https://theradicalparty.com/api'; // Replace with your actual worker URL - + const API_BASE_URL = + window.location.hostname === 'localhost' + ? 'http://localhost:8787/api' + : 'https://theradicalparty.com/api'; + // Character limit const CHAR_LIMIT = 280; diff --git a/worker.js b/worker.js index e12729e..35145f5 100644 --- a/worker.js +++ b/worker.js @@ -86,53 +86,76 @@ export default { } // Handle styles.css route - if (path === '/styles.css') { - try { - // Fetch CSS from GitHub Pages - const response = await fetch('https://radical.pages.dev/radical/styles.css', { - headers: { - 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' - } - }); - - if (!response.ok) { - console.error('Failed to fetch styles.css', response.status, response.statusText); - return new Response('CSS File Not Found', { - status: 404, - headers: corsHeaders - }); - } - - // Get the CSS content - const cssContent = await response.text(); - - // Return CSS response with caching headers - const cssResponse = new Response(cssContent, { - status: 200, - headers: { - 'Content-Type': 'text/css', - 'Cache-Control': 'public, max-age=86400', // 1 day - ...corsHeaders // Add CORS headers - } - }); +if (path === '/styles.css') { + try { + // Determine the correct source based on the request's origin + const cssSourceUrl = + request.headers.get('Origin')?.includes('localhost') + ? 'http://localhost:3000/styles.css' + : 'https://radical.pages.dev/radical/styles.css'; - // Store in cache if this is a GET request - if (request.method === "GET") { - const cacheKey = new Request(request.url, { - headers: request.headers - }); - ctx.waitUntil(caches.default.put(cacheKey, cssResponse.clone())); - } - - return cssResponse; - } catch (error) { - console.error('Error fetching styles.css:', error); - return new Response('Internal Server Error', { - status: 500, - headers: corsHeaders - }); - } + // Fetch CSS + const response = await fetch(cssSourceUrl, { + headers: { + 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker' } + }); + + if (!response.ok) { + console.error('Failed to fetch styles.css', response.status, response.statusText); + return new Response('CSS File Not Found', { + status: 404, + headers: { + 'Content-Type': 'text/css', + ...corsHeaders + } + }); + } + + // Get the CSS content + const cssContent = await response.text(); + + // Prepare CORS headers specific to the requesting origin + const requestOrigin = request.headers.get('Origin') || '*'; + const specificCorsHeaders = { + 'Content-Type': 'text/css', + 'Access-Control-Allow-Origin': requestOrigin, + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Max-Age': '86400', + 'Cache-Control': 'public, max-age=86400' // 1 day cache + }; + + // Create CSS response with precise headers + const cssResponse = new Response(cssContent, { + status: 200, + headers: specificCorsHeaders + }); + + // Store in cache if this is a GET request + if (request.method === "GET") { + const cacheKey = new Request(request.url, { + headers: request.headers + }); + ctx.waitUntil(caches.default.put(cacheKey, cssResponse.clone())); + } + + return cssResponse; + } catch (error) { + console.error('Comprehensive styles.css fetch error:', { + message: error.message, + stack: error.stack, + name: error.name + }); + + return new Response('Failed to load stylesheet', { + status: 500, + headers: { + 'Content-Type': 'text/css', + ...corsHeaders + } + }); + } +} const isTestMeta = url.searchParams.get('test_meta') === 'true'; From e38cbc75e620bff2902ff046547f0aae2984e152 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 22:07:54 +1100 Subject: [PATCH 23/25] x --- index.html | 95 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/index.html b/index.html index 66e1240..0870df6 100644 --- a/index.html +++ b/index.html @@ -25,62 +25,62 @@ + content="https://theradicalparty.com/memes/favicon.jpg"> + content="https://theradicalparty.com/memes/favicon.jpg"> - - + - + content="https://theradicalparty.com/memes/mickeymeta.PNG"> + content="https://theradicalparty.com/memes/mickeymeta.PNG"> + content="https://theradicalparty.com/memes/mickeymeta.PNG"> - + + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> + href="https://theradicalparty.com/memes/favicon.jpg"> - - - - + + + + - + - + - + @@ -156,7 +156,7 @@ "@type": "WebSite", "name": "RADICAL", "description": "Australia's direct democracy movement. Share policy ideas, vote on proposals, take back control. Vibe, Vote, Veto.", - "image": "https://radical.omar-c29.workers.dev/memes/mickeymeta.PNG", + "image": "https://theradicalparty.com/memes/mickeymeta.PNG", "url": "https://theradicalparty.com", "author": { "@type": "Organization", @@ -265,12 +265,33 @@ function smartCopyShare(proposalId, proposalText) { copyToClipboard(link); } } - // Configuration - const API_BASE_URL = - window.location.hostname === 'localhost' - ? 'http://localhost:8787/api' - : 'https://theradicalparty.com/api'; - +function getAPIBaseURL() { + const hostname = window.location.hostname; + const protocol = window.location.protocol; + + switch(hostname) { + case 'localhost': + return 'http://localhost:8787/api'; + case 'theradicalparty.com': + case 'www.theradicalparty.com': + return `${protocol}//theradicalparty.com/api`; + case 'radical.omar-c29.workers.dev': + return `${protocol}//radical.omar-c29.workers.dev/api`; + default: + return `${protocol}//${hostname}/api`; + } +} + +const API_BASE_URL = getAPIBaseURL(); + +function getMediaURL(path) { + const baseURL = window.location.hostname === 'localhost' + ? 'http://localhost:8787' + : `https://${window.location.hostname}`; + return `${baseURL}${path}`; +} + + // Character limit const CHAR_LIMIT = 280; @@ -907,7 +928,7 @@ async function submitVote(proposalId, voteType, isPetition = false, petitionDeta ${netVotes > 0 ? '+' : ''}${netVotes}
- Share + Share
${formatDate(proposal.timestamp)}
@@ -1007,7 +1028,7 @@ updateCommentCount(proposal.id); const uploadIcon = document.createElement('img'); uploadIcon.className = 'meme-upload-icon'; -uploadIcon.src = 'https://radical.omar-c29.workers.dev/memes/pepe.jpg'; +uploadIcon.src = 'https://theradicalparty.com/memes/pepe.jpg'; uploadIcon.alt = 'Pepe'; uploadIcon.style.width = '20px'; uploadIcon.style.height = '20px'; @@ -2283,7 +2304,7 @@ document.addEventListener('DOMContentLoaded', () => { console.log("Music prompt element found, adding click listener"); // Audio file URL - this should point to your audio file in R2 - const audioUrl = 'https://radical.omar-c29.workers.dev/memes/steppingout.mp3'; + const audioUrl = getMediaURL('/audio/steppingout.mp3'); // Initialize music only when the "hit me" button is clicked musicPrompt.addEventListener('click', function(e) { From 9e8260d1cb49542802713da088e8cfe3bde1a4b4 Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 22:14:26 +1100 Subject: [PATCH 24/25] x --- worker.js | 95 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/worker.js b/worker.js index 35145f5..292f73a 100644 --- a/worker.js +++ b/worker.js @@ -1535,61 +1535,90 @@ async function serveMedia(request, env) { const url = new URL(request.url); const path = url.pathname; - // Determine if this is a meme or audio file request - let key; - if (path.startsWith('/memes/')) { - key = path.replace('/memes/', ''); - } else if (path.startsWith('/audio/')) { - key = path.replace('/audio/', ''); - } else { - return new Response('Not found', { + console.log('Media Request Details:', { + fullPath: path, + hostname: url.hostname, + method: request.method + }); + + // More explicit route matching + const audioRoutes = ['/audio/', '/memes/']; + const mediaRoute = audioRoutes.find(route => path.startsWith(route)); + + if (!mediaRoute) { + console.log('No matching media route found'); + return new Response('Not Found', { status: 404, - headers: corsHeaders + headers: { + ...corsHeaders, + 'Content-Type': 'text/plain' + } }); } + + const key = path.replace(mediaRoute, ''); - // Handle CORS preflight requests - if (request.method === 'OPTIONS') { - return handleOptions(); - } - + console.log('Attempting to serve media:', { + bucket: 'MEMES_BUCKET', + key: key + }); + try { + // Try to get the object from R2 const object = await env.MEMES_BUCKET.get(key); if (object === null) { - return new Response('File not found', { + console.log(`File not found in bucket: ${key}`); + return new Response('File Not Found', { status: 404, - headers: corsHeaders + headers: { + ...corsHeaders, + 'Content-Type': 'text/plain' + } }); } const headers = new Headers(); - object.writeHttpMetadata(headers); + + // Determine content type based on file extension + const contentTypeMap = { + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif' + }; + + const ext = key.slice(key.lastIndexOf('.')); + const contentType = contentTypeMap[ext] || 'application/octet-stream'; + + headers.set('Content-Type', contentType); headers.set('etag', object.httpEtag); headers.set('Cache-Control', 'public, max-age=31536000'); // Cache for a year - // Set the appropriate content-type based on file extension - if (key.endsWith('.mp3')) { - headers.set('Content-Type', 'audio/mpeg'); - } else if (key.endsWith('.wav')) { - headers.set('Content-Type', 'audio/wav'); - } else if (key.endsWith('.ogg')) { - headers.set('Content-Type', 'audio/ogg'); - } - // Add CORS headers - Object.keys(corsHeaders).forEach(key => { - headers.set(key, corsHeaders[key]); + Object.keys(corsHeaders).forEach(corsKey => { + headers.set(corsKey, corsHeaders[corsKey]); }); - return new Response(object.body, { - headers - }); + console.log('Serving media with headers:', Object.fromEntries(headers)); + + return new Response(object.body, { headers }); } catch (error) { - console.error('Error serving media:', error); + console.error('Critical error serving media:', { + message: error.message, + stack: error.stack, + key: key + }); + return new Response('Internal Server Error', { status: 500, - headers: corsHeaders + headers: { + ...corsHeaders, + 'Content-Type': 'text/plain' + } }); } } From 626e635bee6b2f61701fdee9d5f5a59b0ea7c02b Mon Sep 17 00:00:00 2001 From: Omar Najjar Date: Wed, 2 Apr 2025 22:22:26 +1100 Subject: [PATCH 25/25] x --- index.html | 101 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/index.html b/index.html index 0870df6..bdf75f1 100644 --- a/index.html +++ b/index.html @@ -444,7 +444,7 @@ function getMediaURL(path) { try { console.log("Creating user:", user); - const data = await fetch(`${API_BASE_URL}/users`, { + const data = await safeFetch(`${API_BASE_URL}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -561,38 +561,81 @@ function getMediaURL(path) { }, 3000); } - async function fetch(url, options = {}) { + // Improved fetch implementation +async function safeFetch(url, options = {}) { try { - // Add mode: 'cors' explicitly to ensure CORS is respected - const response = await fetch(url, { - ...options, + // Ensure URL is a string + const requestUrl = typeof url === 'string' ? url : url.toString(); + + // Default options + const defaultOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + // Add any additional default headers + }, mode: 'cors', credentials: 'same-origin' + }; + + // Merge provided options with defaults + const fetchOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...(options.headers || {}) + } + }; + + // Log fetch details for debugging + console.log('Fetch Request:', { + url: requestUrl, + method: fetchOptions.method, + headers: Object.keys(fetchOptions.headers) }); + // Perform fetch with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + fetchOptions.signal = controller.signal; + + const response = await window.fetch(requestUrl, fetchOptions); + + clearTimeout(timeoutId); + + // Check for successful response if (!response.ok) { - // Try to parse error message from response - let errorData; - try { - errorData = await response.json(); - } catch (e) { - errorData = { error: `HTTP error: ${response.status} ${response.statusText}` }; - } + const errorBody = await response.text(); + console.error('Fetch Error Response:', { + status: response.status, + statusText: response.statusText, + body: errorBody + }); - throw new Error( - errorData.error || - errorData.message || - `Request failed with status ${response.status}` - ); + throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody}`); } return await response.json(); } catch (error) { - console.error(`Fetch error for ${url}:`, error); + console.error('Fetch Error:', { + message: error.message, + name: error.name, + stack: error.stack + }); + + // Handle specific error types + if (error.name === 'AbortError') { + throw new Error('Request timed out'); + } + throw error; } } + + // Fetch proposals from API async function fetchProposals() { try { @@ -602,7 +645,7 @@ function getMediaURL(path) { const sortSelect = document.getElementById('sort-select'); const sortBy = sortSelect ? sortSelect.value : 'newest'; - const data = await fetch( + const data = await safeFetch( `${API_BASE_URL}/proposals?sortBy=${sortBy}&userId=${currentUser.id}` ); @@ -807,7 +850,7 @@ async function submitVoteToServer(proposalId, voteType, isPetition = false, peti voteData.petitionDetails = petitionDetails; } - const response = await fetch(`${API_BASE_URL}/votes`, { + const response = await safeFetch(`${API_BASE_URL}/votes`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1164,7 +1207,7 @@ uploadIcon.style.marginRight = '8px'; const trending = Math.random() > 0.8; // Randomly mark some as trending // Create the proposal first - const createResponse = await fetch(`${API_BASE_URL}/proposals`, { + const createResponse = await safeFetch(`${API_BASE_URL}/proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1513,7 +1556,7 @@ uploadIcon.style.marginRight = '8px'; showToastMessage('Loading petition...', '#11cc77'); // Fetch the specific proposal - const response = await fetch(`${API_BASE_URL}/proposals?userId=${currentUser.id}`); + const response = await safeFetch(`${API_BASE_URL}/proposals?userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch proposal'); @@ -1808,7 +1851,7 @@ async function loadModalComments(proposalId) { try { // Include the current user ID to get their votes - const response = await fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); + const response = await safeFetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch comments'); @@ -1927,7 +1970,7 @@ async function handleCommentVote(commentId, voteType) { // Submit vote to server try { - const response = await fetch(`${API_BASE_URL}/comment-votes`, { + const response = await safeFetch(`${API_BASE_URL}/comment-votes`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1985,7 +2028,7 @@ async function loadComments(proposalId) { try { // Include the current user ID to get their votes - const response = await fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); + const response = await safeFetch(`${API_BASE_URL}/comments?proposalId=${proposalId}&userId=${currentUser.id}`); if (!response.ok) { throw new Error('Failed to fetch comments'); @@ -2075,7 +2118,7 @@ async function submitComment(proposalId, form) { submitButton.disabled = true; try { - const response = await fetch(`${API_BASE_URL}/comments`, { + const response = await safeFetch(`${API_BASE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -2389,7 +2432,7 @@ function updateCommentCount(proposalId) { const commentsToggle = document.getElementById(`comments-toggle-${proposalId}`); if (!commentsToggle) return; - fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) + safeFetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) .then(response => response.json()) .then(comments => { const commentCount = comments.length; @@ -2407,7 +2450,7 @@ function loadComments(proposalId) { commentsList.innerHTML = '
Loading comments...
'; - fetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) + safeFetch(`${API_BASE_URL}/comments?proposalId=${proposalId}`) .then(response => response.json()) .then(comments => { if (comments.length === 0) { @@ -2436,7 +2479,7 @@ function loadComments(proposalId) { // Update submitComment function to handle promises function submitComment(proposalId, commentText) { - return fetch(`${API_BASE_URL}/comments`, { + return safeFetch(`${API_BASE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json'