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