diff --git a/Dockerfile b/Dockerfile index e374d52..9975d02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,63 +2,75 @@ # Stage 1: Build the Astro application FROM node:20-alpine as build -# Set working directory WORKDIR /app -# Copy package files and install dependencies -COPY package.json package-lock.json ./ +# Copy package files. If src/api has its own package.json, handle it here or in a dedicated API build stage. +COPY package.json package-lock.json* ./ +# Ensure src/api/package.json is copied if it exists and is used for API dependencies +# COPY src/api/package.json src/api/package-lock.json* ./src/api/ + 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 +# Build the Astro project (generates into /app/dist) RUN npm run build +# If API has separate build step, include it here. +# Example: RUN npm run build --workspace=api (if using npm workspaces) + # Stage 2: Serve with NGINX and Node API FROM nginx:alpine -# Install Node.js for the API server -RUN apk add --update nodejs npm +# Create a non-root user and group +RUN addgroup -S appgroup && adduser -S -G appgroup appuser -# Set up directory structure +# Install Node.js for the API server and su-exec for user switching +RUN apk add --no-cache nodejs npm su-exec + +# Set base working directory 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 files +# Copy API files from build stage +# Ensure that node_modules for the API are correctly copied or installed here. COPY --from=build /app/src/api /app/api +# If API node_modules were part of the build stage under /app/api/node_modules, copy them: +# COPY --from=build --chown=appuser:appgroup /app/api/node_modules /app/api/node_modules +# Or, if API has its own package.json and it was copied to /app/api in build stage: +COPY --from=build /app/api/package.json /app/api/package-lock.json* /app/api/ WORKDIR /app/api -RUN npm install --production +RUN npm ci --omit=dev --ignore-scripts # Install production API dependencies +RUN chown -R appuser:appgroup /app/api -# 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 +# This replaces the RUN echo '...' command +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 +# Ensure Nginx can write to its log directory (usually /var/log/nginx) +# Alpine Nginx base image usually sets this up correctly. If not, create and chown /var/log/nginx. +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 +# Copy start script from build stage COPY --from=build /app/start.sh /app/start.sh -RUN chmod +x /app/start.sh +RUN chmod +x /app/start.sh && chown appuser:appgroup /app/start.sh -# Create a directory for content if it doesn't exist -RUN mkdir -p /app/content/books +# Create and set permissions for content directory +RUN mkdir -p /app/content/books && chown -R appuser:appgroup /app/content -# Expose port 80 +# Expose port 80 (Nginx will listen on this port) EXPOSE 80 -# Start NGINX and API server +# Switch to non-root user for running the application +USER appuser + +# Start NGINX and API server using the start script +# The start.sh script will use su-exec for the Node API part if needed, +# but since we USER appuser, nginx and node will run as appuser. CMD ["/app/start.sh"] diff --git a/astro.config.mjs b/astro.config.mjs index 721996b..18aea68 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,5 +4,6 @@ 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 new file mode 100644 index 0000000..c4d4fd3 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,47 @@ +user appuser; # Run Nginx worker processes as appuser +worker_processes auto; +daemon off; # Run in the foreground, managed by Docker/start.sh + +# Ensure appuser can write to pid path if it's in a restricted location. +# Default for alpine nginx is /run/nginx/nginx.pid, which should be fine. +# If issues, uncomment and adjust: +# pid /tmp/nginx.pid; + +error_log /var/log/nginx/error.log warn; # Ensure appuser can write here or adjust + +events { + worker_connections 1024; +} + +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 9f3e526..2b2c26e 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -4,79 +4,160 @@ 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 USERNAME = process.env.ADMIN_USERNAME || 'admin'; -const PASSWORD = process.env.ADMIN_PASSWORD || 'password'; +const BOOKS_DIR = path.join(CONTENT_DIR, 'books'); // Define books directory + +const USERNAME = process.env.ADMIN_USERNAME; +const PASSWORD = process.env.ADMIN_PASSWORD; // Middleware -app.use(cors()); -app.use(bodyParser.json()); +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 -// Basic authentication -app.use(basicAuth({ - users: { [USERNAME]: PASSWORD }, - challenge: true, - realm: 'Content Admin' -})); +// Basic authentication - only if credentials are set +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 }; +} // List all content files app.get('/api/content', async (req, res) => { try { - const files = await fs.readdir(path.join(CONTENT_DIR, 'books')); + await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists + const files = await fs.readdir(BOOKS_DIR); const contentFiles = files.filter(file => file.endsWith('.md')); res.json({ files: contentFiles }); } catch (error) { - res.status(500).json({ error: error.message }); + console.error('Error listing content:', error); + res.status(500).json({ error: 'Failed to list content.' }); } }); // 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) { - res.status(404).json({ error: 'File not found' }); + 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.' }); + } } }); // 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 { - const filePath = path.join(CONTENT_DIR, 'books', req.params.filename); + await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists await fs.writeFile(filePath, req.body.content); res.json({ success: true }); } catch (error) { - res.status(500).json({ error: error.message }); + console.error(`Error writing file ${req.params.filename}:`, error); + res.status(500).json({ error: 'Failed to update file.' }); } }); // 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 { - const filePath = path.join(CONTENT_DIR, 'books', req.body.filename); + 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 + } await fs.writeFile(filePath, req.body.content); res.json({ success: true }); } catch (error) { - res.status(500).json({ error: error.message }); + console.error(`Error creating file ${req.body.filename}:`, error); + res.status(500).json({ error: 'Failed to create file.' }); } }); // 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) { - res.status(500).json({ error: error.message }); + 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.' }); + } } }); 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 new file mode 100644 index 0000000..e3fa2ba --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,9 @@ +// 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/start.sh b/start.sh index 7bf2553..d2bf6c5 100644 --- a/start.sh +++ b/start.sh @@ -1,6 +1,27 @@ #!/bin/sh -# Start API server -cd /app/api && node server.js & +set -e # Exit immediately if a command exits with a non-zero status. -# Start NGINX -nginx -g "daemon off;" +# 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