import { Context, Hono } from 'hono'; import { html, raw } from 'hono/html'; import { z } from 'zod'; import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem } from '../types'; import { generateFeedId } from '../utils/id-generator'; import { designSystem } from '../styles/index'; import { interactiveScripts, authHelpers } from '../scripts/index'; /** * Admin routes handler for Email-to-RSS * Provides a secure interface for managing RSS feeds and viewing emails * * Security: * - All routes except /login are protected by server-side cookie authentication * - Uses HttpOnly cookies to prevent XSS attacks * - Implements SameSite=Strict to prevent CSRF attacks */ const app = new Hono(); // Export for testing export default app; // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const path = new URL(c.req.url).pathname; // Skip auth check for login page - note that path includes /admin prefix if (path === '/admin/login') { return next(); } const authCookie = c.req.cookie('admin_auth'); if (!authCookie || authCookie !== 'true') { return c.redirect('/admin/login'); } await next(); } // Apply auth middleware to all admin routes app.use('*', authMiddleware); // Schema for feed creation const createFeedSchema = z.object({ title: z.string().min(1, 'Title is required'), description: z.string().optional(), language: z.string().optional().default('en') }); // Schema for feed updates const updateFeedSchema = z.object({ title: z.string().min(1, 'Title is required'), description: z.string().optional(), language: z.string().optional().default('en') }); // Authentication schema const authSchema = z.object({ password: z.string().min(1, 'Password is required') }); // Base HTML layout with design system const layout = (title: string, content: any) => { return html` ${title} - Email to RSS Admin ${content} `; }; // Login page app.get('/login', (c) => { const error = c.req.query('error'); const errorMessage = error === 'invalid' ? 'Invalid password. Please try again.' : ''; return c.html(layout('Login', html`

Email to RSS Admin

${errorMessage ? html`
${errorMessage}
` : ''}
`)); }); // Handle login app.post('/login', async (c) => { const env = c.env as unknown as Env; try { const formData = await c.req.formData(); const password = formData.get('password')?.toString() || ''; // Validate password authSchema.parse({ password }); // Check password against environment variable if (password === env.ADMIN_PASSWORD) { // Set a cookie for server-side authentication c.header('Set-Cookie', `admin_auth=true; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24 * 7}`); // 1 week // Also use localStorage for client-side checks return c.html(html` `); } else { // Incorrect password - redirect back to login with an error message return c.redirect('/admin/login?error=invalid'); } } catch (error) { console.error('Login error:', error); return c.redirect('/admin/login?error=invalid'); } }); // Logout route app.get('/logout', (c) => { return c.html(html` `); }); // Admin dashboard route app.get('/', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; // List all feeds const feedList = await listAllFeeds(emailStorage); // Fetch full feed configs to get descriptions const feedsWithConfig = await Promise.all( feedList.map(async (feed) => { const configKey = `feed:${feed.id}:config`; const config = await emailStorage.get(configKey, { type: 'json' }) as FeedConfig | null; return { ...feed, description: config?.description || '' }; }) ); return c.html(layout('Dashboard', html`

Email to RSS Admin

Manage your email newsletter feeds

Create New Feed

Your Feeds

${feedsWithConfig.length > 0 ? html`` : html`

You don't have any feeds yet. Create one above.

` }
`)); }); // Create a new feed app.post('/feeds/create', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; try { const formData = await c.req.formData(); const title = formData.get('title')?.toString() || ''; const description = formData.get('description')?.toString(); const language = formData.get('language')?.toString() || 'en'; // Validate inputs const parsedData = createFeedSchema.parse({ title, description, language }); // Generate a feed ID const feedId = generateFeedId(); // Create feed configuration const feedConfig: FeedConfig = { title: parsedData.title, description: parsedData.description, language: parsedData.language, site_url: `https://${env.DOMAIN}/rss/${feedId}`, feed_url: `https://${env.DOMAIN}/rss/${feedId}`, created_at: Date.now(), updated_at: Date.now() }; // Create feed metadata const feedMetadata: FeedMetadata = { emails: [] }; // Store feed configuration and metadata await emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)); await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)); // Add feed to the list of all feeds await addFeedToList(emailStorage, feedId, parsedData.title); // Redirect back to admin page return c.redirect('/admin'); } catch (error) { console.error('Error creating feed:', error); return c.text('Error creating feed. Please try again.', 400); } }); // Edit feed page app.get('/feeds/:feedId/edit', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param('feedId'); // Get feed configuration const feedConfigKey = `feed:${feedId}:config`; const feedConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; if (!feedConfig) { return c.text('Feed not found', 404); } return c.html(layout('Edit Feed', html`

${feedConfig.title} - Edit Feed

`)); }); // Update feed app.post('/feeds/:feedId/edit', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param('feedId'); try { const formData = await c.req.formData(); const title = formData.get('title')?.toString() || ''; const description = formData.get('description')?.toString(); const language = formData.get('language')?.toString() || 'en'; // Validate inputs const parsedData = updateFeedSchema.parse({ title, description, language }); // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; const existingConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; if (!existingConfig) { return c.text('Feed not found', 404); } // Update feed configuration await emailStorage.put(feedConfigKey, JSON.stringify({ ...existingConfig, title: parsedData.title, description: parsedData.description, language: parsedData.language, updated_at: Date.now() })); // Update feed in the list of all feeds await updateFeedInList(emailStorage, feedId, parsedData.title); // Redirect back to admin page return c.redirect('/admin'); } catch (error) { console.error('Error updating feed:', error); return c.text('Error updating feed. Please try again.', 400); } }); // Delete feed app.post('/feeds/:feedId/delete', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param('feedId'); try { // Get feed metadata to find all email keys const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; if (!feedMetadata) { return c.text('Feed not found', 404); } // Delete all emails for this feed for (const email of feedMetadata.emails) { await emailStorage.delete(email.key); } // Delete feed configuration and metadata await emailStorage.delete(`feed:${feedId}:config`); await emailStorage.delete(feedMetadataKey); // Remove feed from the list of all feeds await removeFeedFromList(emailStorage, feedId); // Redirect back to admin page return c.redirect('/admin'); } catch (error) { console.error('Error deleting feed:', error); return c.text('Error deleting feed. Please try again.', 400); } }); // View all emails for a feed app.get('/feeds/:feedId/emails', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param('feedId'); // Get feed configuration and metadata const feedConfigKey = `feed:${feedId}:config`; const feedMetadataKey = `feed:${feedId}:metadata`; const feedConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; if (!feedConfig || !feedMetadata) { return c.text('Feed not found', 404); } return c.html(layout(`${feedConfig.title} - Emails`, html`

${feedConfig.title} - Emails

Feed Details

Email Address:
${feedId}@${env.DOMAIN}
RSS Feed:
https://${env.DOMAIN}/rss/${feedId}

Emails (${feedMetadata.emails.length})

${feedMetadata.emails.length > 0 ? html`` : html`

No emails received yet. Subscribe to newsletters using the email address above.

` }
`)); }); // View email content app.get('/emails/:emailKey', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param('emailKey'); // Get email data const emailData = await emailStorage.get(emailKey, { type: 'json' }) as EmailData | null; if (!emailData) { return c.text('Email not found', 404); } // Extract feed ID from the key format (feed:ID:emails:timestamp) const keyParts = emailKey.split(':'); const feedId = keyParts[1]; // Create a sanitized HTML content with CSS for the iframe const htmlContent = ` ${emailData.content} `; // Properly encode the HTML content to handle Unicode characters const encodedHtmlContent = (() => { // Convert the string to UTF-8 const encoder = new TextEncoder(); const bytes = encoder.encode(htmlContent); // Convert bytes to base64 return btoa(String.fromCharCode(...new Uint8Array(bytes))); })(); return c.html(layout(`Email: ${emailData.subject}`, html`

Email Content

`)); }); // Delete email app.post('/emails/:emailKey/delete', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param('emailKey'); try { // Get feedId from query parameters instead of form data const feedId = c.req.query('feedId'); if (!feedId) { return c.text('Feed ID is required', 400); } // Delete the email await emailStorage.delete(emailKey); // Remove the email from the feed metadata const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; if (feedMetadata) { // Filter out the deleted email feedMetadata.emails = feedMetadata.emails.filter(email => email.key !== emailKey); // Update feed metadata await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); } // Redirect back to the feed emails page return c.redirect(`/admin/feeds/${feedId}/emails`); } catch (error) { console.error('Error deleting email:', error); return c.text('Error deleting email. Please try again.', 400); } }); // Helper function to list all feeds async function listAllFeeds(emailStorage: KVNamespace): Promise { try { const feedListKey = 'feeds:list'; const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null; return feedList?.feeds || []; } catch (error) { console.error('Error listing feeds:', error); return []; } } // Helper function to add a feed to the list of all feeds async function addFeedToList(emailStorage: KVNamespace, feedId: string, title: string): Promise { try { const feedListKey = 'feeds:list'; const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null || { feeds: [] }; // Add new feed to the list feedList.feeds.push({ id: feedId, title }); // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } catch (error) { console.error('Error adding feed to list:', error); } } // Helper function to update a feed in the list of all feeds async function updateFeedInList(emailStorage: KVNamespace, feedId: string, title: string): Promise { try { const feedListKey = 'feeds:list'; const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null || { feeds: [] }; // Find and update the feed in the list const feedIndex = feedList.feeds.findIndex(feed => feed.id === feedId); if (feedIndex !== -1) { feedList.feeds[feedIndex].title = title; // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } } catch (error) { console.error('Error updating feed in list:', error); } } // Helper function to remove a feed from the list of all feeds async function removeFeedFromList(emailStorage: KVNamespace, feedId: string): Promise { try { const feedListKey = 'feeds:list'; const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null || { feeds: [] }; // Filter out the removed feed feedList.feeds = feedList.feeds.filter(feed => feed.id !== feedId); // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } catch (error) { console.error('Error removing feed from list:', error); } } // Update feed via API (for in-place editing) app.post('/api/feeds/:feedId/update', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param('feedId'); try { // Parse JSON data from request const data = await c.req.json(); const { title, description } = data; // Validate inputs const parsedData = updateFeedSchema.parse({ title, description, language: 'en' // We're defaulting to English }); // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; const existingConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; if (!existingConfig) { return c.json({ error: 'Feed not found' }, 404); } // Update feed configuration await emailStorage.put(feedConfigKey, JSON.stringify({ ...existingConfig, title: parsedData.title, description: parsedData.description, updated_at: Date.now() })); // Update feed in the list of all feeds await updateFeedInList(emailStorage, feedId, parsedData.title); // Return success response return c.json({ success: true }); } catch (error) { console.error('Error updating feed via API:', error); return c.json({ error: 'Error updating feed' }, 400); } }); // Export the Hono app export const handle = app;