diff --git a/Dockerfile b/Dockerfile index 90ee635..e374d52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,68 +1,64 @@ # Dockerfile for Astro webapp with NGINX and API for content management # Stage 1: Build the Astro application -# Fixed 'as' to 'AS' in the following line -FROM node:20-alpine AS build +FROM node:20-alpine as build +# Set working directory WORKDIR /app -# Copy root package files. Assumes API dependencies are in the root package.json. -COPY package.json package-lock.json* ./ - -# Install ALL dependencies (including for API) from root package-lock.json +# Copy package files and install dependencies +COPY package.json package-lock.json ./ RUN npm ci # Copy the rest of the application code -# Ensure .dockerignore is properly set up to exclude node_modules etc. from host COPY . . -# Build the Astro project (generates into /app/dist) +# Build the Astro project RUN npm run build # Stage 2: Serve with NGINX and Node API FROM nginx:alpine -# Create a non-root user and group -RUN addgroup -S appgroup && adduser -S -G appgroup appuser +# Install Node.js for the API server +RUN apk add --update nodejs npm -# Install Node.js for the API server (su-exec is not strictly needed if USER appuser is used for CMD) -RUN apk add --no-cache nodejs npm # su-exec can be removed if not used elsewhere - -# Set base working directory for the final stage +# Set up directory structure WORKDIR /app # Copy built static files from build stage to NGINX html directory COPY --from=build /app/dist /usr/share/nginx/html -RUN chown -R appuser:appgroup /usr/share/nginx/html && chmod -R 755 /usr/share/nginx/html -# Copy API source files from build stage +# Copy API files COPY --from=build /app/src/api /app/api -RUN chown -R appuser:appgroup /app/api +WORKDIR /app/api +RUN npm install --production -# Copy node_modules from the build stage (contains all dependencies) -# The API running in /app/api will be able to resolve modules from /app/node_modules -COPY --from=build --chown=appuser:appgroup /app/node_modules /app/node_modules +# Add custom NGINX configuration with API proxy +RUN echo 'server {\ + listen 80;\ + server_name _;\ + root /usr/share/nginx/html;\ + index index.html;\ + location / {\ + try_files $uri $uri/ /index.html;\ + }\ + location /api/ {\ + proxy_pass http://localhost:3000/api/;\ + proxy_http_version 1.1;\ + proxy_set_header Upgrade $http_upgrade;\ + proxy_set_header Connection "upgrade";\ + proxy_set_header Host $host;\ + }\ +}' > /etc/nginx/conf.d/default.conf -# Copy custom NGINX configuration -COPY nginx.conf /etc/nginx/conf.d/default.conf -RUN chown appuser:appgroup /etc/nginx/conf.d/default.conf && chmod 644 /etc/nginx/conf.d/default.conf -RUN mkdir -p /var/log/nginx && chown -R appuser:appgroup /var/log/nginx -RUN mkdir -p /run/nginx && chown -R appuser:appgroup /run/nginx # For PID file - -# Copy start script from build stage +# Copy start script COPY --from=build /app/start.sh /app/start.sh -RUN chmod +x /app/start.sh && chown appuser:appgroup /app/start.sh +RUN chmod +x /app/start.sh -# Create and set permissions for content directory -# This WORKDIR /app is important for relative paths in start.sh if any -WORKDIR /app -RUN mkdir -p ./content/books && chown -R appuser:appgroup ./content +# Create a directory for content if it doesn't exist +RUN mkdir -p /app/content/books -# Expose port 80 (Nginx will listen on this port) +# Expose port 80 EXPOSE 80 -# Switch to non-root user for running the application -USER appuser - -# Start NGINX and API server using the start script -# Both processes will run as 'appuser' +# Start NGINX and API server CMD ["/app/start.sh"] diff --git a/astro.config.mjs b/astro.config.mjs index 18aea68..721996b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,5 @@ import { defineConfig } from 'astro/config'; import tailwind from '@astrojs/tailwind'; export default defineConfig({ - output: 'static', integrations: [tailwind()] }); \ No newline at end of file diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 91654e7..0000000 --- a/nginx.conf +++ /dev/null @@ -1,32 +0,0 @@ -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - sendfile on; - keepalive_timeout 65; - # gzip on; # Consider enabling gzip for better performance - - server { - listen 80 default_server; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - location /api/ { - proxy_pass http://localhost:3000/api/; # Node API listens on 3000 - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - } -} diff --git a/src/api/server.js b/src/api/server.js index 5730b99..9f3e526 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -4,163 +4,79 @@ const path = require('path'); const cors = require('cors'); const bodyParser = require('body-parser'); const basicAuth = require('express-basic-auth'); -const helmet = require('helmet'); // Added helmet const app = express(); const PORT = process.env.PORT || 3000; const CONTENT_DIR = process.env.CONTENT_DIR || path.join(__dirname, '../../content'); -const BOOKS_DIR = path.join(CONTENT_DIR, 'books'); // Define books directory - -const USERNAME = process.env.ADMIN_USERNAME; -const PASSWORD = process.env.ADMIN_PASSWORD; +const USERNAME = process.env.ADMIN_USERNAME || 'admin'; +const PASSWORD = process.env.ADMIN_PASSWORD || 'password'; // Middleware -app.use(helmet({ contentSecurityPolicy: false })); // Added helmet, disable its CSP if Astro handles it -// For CORS, consider restricting the origin in production: -// app.use(cors({ origin: 'https://your-admin-domain.com' })); -app.use(cors()); -app.use(bodyParser.json({ limit: '1mb' })); // Added body parser size limit +app.use(cors()); +app.use(bodyParser.json()); -// Basic authentication - only if credentials are set -// Commenting out API-level Basic Auth as Caddy reverse proxy will handle it. -/* -if (USERNAME && PASSWORD) { - app.use(basicAuth({ - users: { [USERNAME]: PASSWORD }, - challenge: true, - realm: 'Content Admin' - })); -} else { - console.error('CRITICAL: ADMIN_USERNAME and/or ADMIN_PASSWORD environment variables are not set. API will not be secured.'); - // Optionally, you could prevent the API routes from being registered or exit. - // For now, it will run unsecured if no credentials, which is a major risk. - // A better approach for production would be to throw an error and exit: - // throw new Error('Admin credentials not configured'); -} -*/ - -// Helper function for filename validation and path construction -function getSafeFilePath(filename) { - if (!filename || typeof filename !== 'string' || !/^[a-zA-Z0-9_-]+\.md$/.test(filename)) { - return { error: 'Invalid filename format. Must be alphanumeric, underscores, or hyphens, and end with .md.', path: null }; - } - const cleanFilename = path.basename(filename); // Ensures no path characters are in the filename itself - if (cleanFilename !== filename) { - return { error: 'Invalid filename (contains path characters).', path: null }; - } - - const resolvedPath = path.join(BOOKS_DIR, cleanFilename); - - // Final check to prevent escaping BOOKS_DIR - if (!resolvedPath.startsWith(BOOKS_DIR + path.sep)) { - return { error: 'Invalid path (attempt to escape content directory).', path: null }; - } - return { error: null, path: resolvedPath }; -} +// Basic authentication +app.use(basicAuth({ + users: { [USERNAME]: PASSWORD }, + challenge: true, + realm: 'Content Admin' +})); // List all content files app.get('/api/content', async (req, res) => { try { - await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists - const files = await fs.readdir(BOOKS_DIR); + const files = await fs.readdir(path.join(CONTENT_DIR, 'books')); const contentFiles = files.filter(file => file.endsWith('.md')); res.json({ files: contentFiles }); } catch (error) { - console.error('Error listing content:', error); - res.status(500).json({ error: 'Failed to list content.' }); + res.status(500).json({ error: error.message }); } }); // Get content by filename app.get('/api/content/:filename', async (req, res) => { - const { error: filePathError, path: filePath } = getSafeFilePath(req.params.filename); - if (filePathError) { - return res.status(400).json({ error: filePathError }); - } - try { + const filePath = path.join(CONTENT_DIR, 'books', req.params.filename); const content = await fs.readFile(filePath, 'utf-8'); res.json({ content }); } catch (error) { - if (error.code === 'ENOENT') { - res.status(404).json({ error: 'File not found.' }); - } else { - console.error(`Error reading file ${req.params.filename}:`, error); - res.status(500).json({ error: 'Failed to read file.' }); - } + res.status(404).json({ error: 'File not found' }); } }); // Update content app.put('/api/content/:filename', async (req, res) => { - const { error: filePathError, path: filePath } = getSafeFilePath(req.params.filename); - if (filePathError) { - return res.status(400).json({ error: filePathError }); - } - if (typeof req.body.content !== 'string') { - return res.status(400).json({ error: 'Invalid content.'}); - } - try { - await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists + const filePath = path.join(CONTENT_DIR, 'books', req.params.filename); await fs.writeFile(filePath, req.body.content); res.json({ success: true }); } catch (error) { - console.error(`Error writing file ${req.params.filename}:`, error); - res.status(500).json({ error: 'Failed to update file.' }); + res.status(500).json({ error: error.message }); } }); // Create new content file app.post('/api/content', async (req, res) => { - const { error: filePathError, path: filePath } = getSafeFilePath(req.body.filename); - if (filePathError) { - return res.status(400).json({ error: filePathError }); - } - if (typeof req.body.content !== 'string') { - return res.status(400).json({ error: 'Invalid content.'}); - } - try { - await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists - // Check if file already exists to prevent accidental overwrite via POST - try { - await fs.access(filePath); - return res.status(409).json({ error: 'File already exists. Use PUT to update.' }); - } catch (accessError) { - // File does not exist, proceed to write - } + const filePath = path.join(CONTENT_DIR, 'books', req.body.filename); await fs.writeFile(filePath, req.body.content); res.json({ success: true }); } catch (error) { - console.error(`Error creating file ${req.body.filename}:`, error); - res.status(500).json({ error: 'Failed to create file.' }); + res.status(500).json({ error: error.message }); } }); // Delete content file app.delete('/api/content/:filename', async (req, res) => { - const { error: filePathError, path: filePath } = getSafeFilePath(req.params.filename); - if (filePathError) { - return res.status(400).json({ error: filePathError }); - } - try { + const filePath = path.join(CONTENT_DIR, 'books', req.params.filename); await fs.unlink(filePath); res.json({ success: true }); } catch (error) { - if (error.code === 'ENOENT') { - res.status(404).json({ error: 'File not found.' }); - } else { - console.error(`Error deleting file ${req.params.filename}:`, error); - res.status(500).json({ error: 'Failed to delete file.' }); - } + res.status(500).json({ error: error.message }); } }); app.listen(PORT, () => { console.log(`API server running on port ${PORT}`); - if (!USERNAME || !PASSWORD) { - console.warn('WARNING: API is running WITHOUT authentication due to missing ADMIN_USERNAME or ADMIN_PASSWORD environment variables. This is a major security risk.'); - } }); diff --git a/src/middleware.js b/src/middleware.js deleted file mode 100644 index e3fa2ba..0000000 --- a/src/middleware.js +++ /dev/null @@ -1,9 +0,0 @@ -// src/middleware.js -export async function onRequest(context, next) { - const response = await next(); // Get the response from the next middleware or page - response.headers.set( - 'Content-Security-Policy', - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none';" - ); - return response; // Return the modified response -} diff --git a/src/pages/admin/edit.astro b/src/pages/admin/edit.astro index 92bab62..2834787 100644 --- a/src/pages/admin/edit.astro +++ b/src/pages/admin/edit.astro @@ -55,7 +55,9 @@ import SiteLayout from '../../components/SiteLayout.astro'; // Base API URL - adapt for production vs. development const getApiUrl = (path) => { - return `/api/${path}`; // Always use relative path for Nginx proxy + return window.location.hostname === 'localhost' + ? `http://localhost:3000/api/${path}` + : `/api/${path}`; }; // Load existing content if editing @@ -68,7 +70,7 @@ import SiteLayout from '../../components/SiteLayout.astro'; const response = await fetch(getApiUrl(`content/${fileParam}`), { headers: { - // Authorization header removed; browser will handle Basic Auth prompt + 'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth } }); @@ -109,8 +111,8 @@ import SiteLayout from '../../components/SiteLayout.astro'; const response = await fetch(url, { method: isNewFile ? 'POST' : 'PUT', headers: { - 'Content-Type': 'application/json' - // Authorization header removed; browser will handle Basic Auth prompt + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth }, body: JSON.stringify({ filename, content }) }); diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index eb47a9f..eb445b8 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -25,11 +25,13 @@ import SiteLayout from '../../components/SiteLayout.astro'; // This would fetch from your API in production async function fetchBooks() { try { - const apiUrl = '/api/content'; // Always use relative path for Nginx proxy + const apiUrl = window.location.hostname === 'localhost' + ? 'http://localhost:3000/api/content' + : '/api/content'; const response = await fetch(apiUrl, { headers: { - // Authorization header removed; browser will handle Basic Auth prompt + 'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth } }); @@ -65,12 +67,14 @@ import SiteLayout from '../../components/SiteLayout.astro'; const file = btn.getAttribute('data-file'); try { - const apiUrl = `/api/content/${file}`; // Always use relative path for Nginx proxy + const apiUrl = window.location.hostname === 'localhost' + ? `http://localhost:3000/api/content/${file}` + : `/api/content/${file}`; const response = await fetch(apiUrl, { method: 'DELETE', headers: { - 'Content-Type': 'application/json' + 'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth } }); diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro index b6c6f08..ff87482 100644 --- a/src/pages/admin/login.astro +++ b/src/pages/admin/login.astro @@ -1,6 +1,49 @@ --- -// src/pages/admin/login.astro -// This page now just redirects to the main admin dashboard. -// The API's Basic Auth will protect the actual data. -return Astro.redirect('/admin'); +import SiteLayout from '../../components/SiteLayout.astro'; + +let error = ''; +if (Astro.request.method === 'POST') { + try { + const data = await Astro.request.formData(); + const username = data.get('username'); + const password = data.get('password'); + + // Simple client-side auth - real auth happens in the API + if (username === 'admin' && password === 'password') { + // Store authentication in a cookie or localStorage in a real app + return Astro.redirect('/admin'); + } else { + error = 'Invalid username or password'; + } + } catch (e) { + error = 'An error occurred during login'; + } +} --- + + +
+

Content Admin Login

+ + {error &&
{error}
} + +
+
+ + +
+ +
+ + +
+ + +
+
+
diff --git a/start.sh b/start.sh index d2bf6c5..7bf2553 100644 --- a/start.sh +++ b/start.sh @@ -1,27 +1,6 @@ #!/bin/sh -set -e # Exit immediately if a command exits with a non-zero status. +# Start API server +cd /app/api && node server.js & -# Navigate to API directory and start Node.js server in the background -# It will run as 'appuser' because of USER appuser in Dockerfile -echo "Starting Node.js API server..." -cd /app/api -node server.js & -NODE_PID=$! -echo "Node.js API server started with PID $NODE_PID" - -# Start NGINX in the foreground -# It will also run as 'appuser' (master and workers, due to USER appuser and nginx.conf user directive) -echo "Starting NGINX..." -nginx -NGINX_STATUS=$? - -# If nginx exits, try to kill node process if it's still running -if ! kill -0 $NODE_PID > /dev/null 2>&1; then - echo "Node process already stopped." -else - echo "Nginx exited with status $NGINX_STATUS, stopping Node.js API server..." - kill $NODE_PID - wait $NODE_PID || true # Wait for node to stop, ignore error if already stopped -fi - -exit $NGINX_STATUS +# Start NGINX +nginx -g "daemon off;"