From b9d5e2920786e08a7f6030c157df50a34073ff49 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 29 May 2025 15:57:20 +0200 Subject: [PATCH] feat: implement secure data API with basic auth and Docker support --- Dockerfile | 52 ++++++++++++++++++++-------- data-api.js | 84 ++++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 3 ++ js/dataManager.js | 57 ++++++++++++++++++++++++++----- nginx.conf | 17 ++++++++++ 5 files changed, 187 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index 24783c6..0b0f8e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,45 @@ -# Use Nginx as the base image for serving static content +# Stage 1: Build Node.js application +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package.json and package-lock.json (if available) +COPY package.json ./ +# COPY package-lock.json ./ + +# Install dependencies +RUN npm install --production + +# Copy the rest of the application files (API code) +COPY data-api.js ./ +# Add any other necessary files for the API here + +# Stage 2: Setup Nginx and Supervisor FROM nginx:alpine -# Copy the custom Nginx configuration -# This replaces the default Nginx configuration -COPY nginx.conf /etc/nginx/conf.d/default.conf +# Install Supervisor, Node.js runtime, and apache2-utils (for htpasswd) +RUN apk add --no-cache supervisor nodejs npm apache2-utils -# Copy all static website files to the Nginx serving directory -# This includes HTML, CSS, JavaScript, images, and the js/dataManager.js for client-side logic -COPY . /usr/share/nginx/html +# Remove default Nginx configuration +RUN rm /etc/nginx/conf.d/default.conf -# Remove unnecessary files and directories from the Nginx serving directory -# This helps to keep the final image size small and clean -RUN rm -rf /usr/share/nginx/html/Dockerfile \ - /usr/share/nginx/html/docker-compose.yml \ - /usr/share/nginx/html/nginx.conf \ - /usr/share/nginx/html/nginx-auth.conf \ - /usr/share/nginx/html/supervisord.conf \ +# Declare build arguments for credentials +ARG AUTH_USERNAME +ARG AUTH_PASSWORD + +# Copy custom Nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy Basic Auth config (nginx-auth.conf) +COPY nginx-auth.conf /etc/nginx/nginx-auth.conf + +# Generate .htpasswd file using build arguments +RUN htpasswd -cb /etc/nginx/.htpasswd ${AUTH_USERNAME} ${AUTH_PASSWORD} \ + && chown nginx:nginx /etc/nginx/.htpasswd \ + && chmod 600 /etc/nginx/.htpasswd + +# Copy application files +COPY --from=builder /app/data-api.js \ /usr/share/nginx/html/data-api.js \ /usr/share/nginx/html/auth-middleware.js \ /usr/share/nginx/html/backup-s3.js \ diff --git a/data-api.js b/data-api.js index bd18628..e7f0fa4 100644 --- a/data-api.js +++ b/data-api.js @@ -1,3 +1,81 @@ -// This server-side data API (data-api.js) has been deprecated. -// Data handling is now managed client-side using browser file APIs -// in accordance with the project vision for a static site. +const express = require('express'); +const bodyParser = require('body-parser'); +const fs = require('fs').promises; +const path = require('path'); + +const app = express(); +const PORT = 3000; +const DATA_DIR = process.env.DATA_DIR || '/data'; // Fallback for local dev, Docker will set via supervisord +const DATA_FILE_NAME = 'weight-tracker-data.json'; +const DATA_FILE_PATH = path.join(DATA_DIR, DATA_FILE_NAME); + +const DEFAULT_DATA = { weights: [], meals: [] }; + +app.use(bodyParser.json({ limit: '2mb' })); // Use body-parser to handle JSON POST requests, limit size + +// Middleware for basic logging +app.use((req, res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); + next(); +}); + +// GET endpoint to retrieve data +app.get('/', async (req, res) => { + try { + await fs.access(DATA_FILE_PATH); + const fileContent = await fs.readFile(DATA_FILE_PATH, 'utf8'); + res.json(JSON.parse(fileContent)); + } catch (error) { + if (error.code === 'ENOENT') { + // File doesn't exist, return default data + console.log(`Data file not found at ${DATA_FILE_PATH}, returning default data.`); + res.json(DEFAULT_DATA); + } else { + console.error('Error reading data file:', error); + res.status(500).json({ message: 'Error reading data file', error: error.message }); + } + } +}); + +// PUT endpoint to save data +app.put('/', async (req, res) => { + const newData = req.body; + + // Basic Input Validation + if (typeof newData !== 'object' || newData === null) { + return res.status(400).json({ message: 'Invalid data format: body must be an object.' }); + } + if (!Array.isArray(newData.weights)) { + return res.status(400).json({ message: 'Invalid data format: weights must be an array.' }); + } + if (!Array.isArray(newData.meals)) { + return res.status(400).json({ message: 'Invalid data format: meals must be an array.' }); + } + // Add more specific validation for array items if needed + + try { + await fs.writeFile(DATA_FILE_PATH, JSON.stringify(newData, null, 2), 'utf8'); + res.status(200).json({ message: 'Data saved successfully.' }); + } catch (error) { + console.error('Error writing data file:', error); + res.status(500).json({ message: 'Error writing data file', error: error.message }); + } +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Data API server listening on port ${PORT}`); + console.log(`Expecting data file at: ${DATA_FILE_PATH}`); +}); + +// Basic error handling for uncaught exceptions +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + // Optionally, exit the process or perform other cleanup + // process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + // Optionally, exit the process or perform other cleanup + // process.exit(1); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 811572e..a838fdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: build: context: . dockerfile: Dockerfile + args: + - AUTH_USERNAME=${AUTH_USERNAME} + - AUTH_PASSWORD=${AUTH_PASSWORD} container_name: weight-tracker restart: unless-stopped ports: diff --git a/js/dataManager.js b/js/dataManager.js index e77041e..d828e4b 100644 --- a/js/dataManager.js +++ b/js/dataManager.js @@ -20,13 +20,26 @@ const DataManager = (() => { */ const init = async () => { try { - console.log('Initializing data manager...'); - loadFromLocalStorage(); // Load from localStorage or set defaults + console.log('Initializing data manager by fetching from API...'); + const response = await fetch('/app-data/'); // Nginx proxies this to Node API + if (response.ok) { + const data = await response.json(); + if (data && typeof data === 'object') { + appData = data; + console.log('Successfully loaded data from API.'); + // Optionally, update localStorage for offline or quick display, but API is truth + // saveDataToLocalStorage(); + } else { + console.warn('Data from API was not valid JSON or empty. Using default data.'); + appData = { ...defaultData }; + } + } else { + console.error(`Failed to fetch data from API: ${response.status} ${response.statusText}. Using default data.`); + appData = { ...defaultData }; + } } catch (error) { - console.error('Error initializing data:', error); - appData = {...defaultData}; // Fallback to default data - // Attempt to save default data to localStorage if init failed badly - try { saveDataToLocalStorage(); } catch (e) { console.error('Failed to save default data to LS during init error handling', e); } + console.error('Error initializing data via API:', error); + appData = { ...defaultData }; // Fallback to default data on critical error } }; @@ -46,11 +59,37 @@ const DataManager = (() => { }; /** - * Save data to localStorage. - * Actual file saving will be a separate user-initiated action (handled by exportData). + * Save data to API. */ const saveData = async () => { - return saveDataToLocalStorage(); + console.log('Attempting to save data via API...'); + try { + const response = await fetch('/app-data/', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(appData), + }); + + if (response.ok) { + const result = await response.json(); + console.log('Data saved successfully to API:', result.message); + // Optionally, update localStorage after successful API save + // saveDataToLocalStorage(); + return true; + } else { + const errorResult = await response.json(); + console.error(`Failed to save data to API: ${response.status} ${response.statusText}`, errorResult.message); + // Consider how to inform the user: alert, UI message, etc. + alert(`Failed to save data: ${errorResult.message || response.statusText}`); + return false; + } + } catch (error) { + console.error('Error saving data via API:', error); + alert(`Error saving data: ${error.message}`); + return false; + } }; /** diff --git a/nginx.conf b/nginx.conf index d9bbb3c..cd7bdfe 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,12 +9,29 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # Main application location + # API proxy: All requests to /app-data/* will be forwarded to the Node.js API + location /app-data/ { + include /etc/nginx/nginx-auth.conf; # Apply Basic Auth to API + + proxy_pass http://localhost:3000/; # Assuming Node API runs on port 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; + } + location / { + include /etc/nginx/nginx-auth.conf; # Apply Basic Auth to the whole site try_files $uri $uri/ /index.html; } # Enable browser caching for static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + include /etc/nginx/nginx-auth.conf; # Apply Basic Auth to static assets too expires 30d; add_header Cache-Control "public, no-transform"; }