feat: implement secure data API with basic auth and Docker support

This commit is contained in:
Greg 2025-05-29 15:57:20 +02:00
parent abfd0bf636
commit b9d5e29207
5 changed files with 187 additions and 26 deletions

View File

@ -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 FROM nginx:alpine
# Copy the custom Nginx configuration # Install Supervisor, Node.js runtime, and apache2-utils (for htpasswd)
# This replaces the default Nginx configuration RUN apk add --no-cache supervisor nodejs npm apache2-utils
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy all static website files to the Nginx serving directory # Remove default Nginx configuration
# This includes HTML, CSS, JavaScript, images, and the js/dataManager.js for client-side logic RUN rm /etc/nginx/conf.d/default.conf
COPY . /usr/share/nginx/html
# Remove unnecessary files and directories from the Nginx serving directory # Declare build arguments for credentials
# This helps to keep the final image size small and clean ARG AUTH_USERNAME
RUN rm -rf /usr/share/nginx/html/Dockerfile \ ARG AUTH_PASSWORD
/usr/share/nginx/html/docker-compose.yml \
/usr/share/nginx/html/nginx.conf \ # Copy custom Nginx configuration
/usr/share/nginx/html/nginx-auth.conf \ COPY nginx.conf /etc/nginx/nginx.conf
/usr/share/nginx/html/supervisord.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/data-api.js \
/usr/share/nginx/html/auth-middleware.js \ /usr/share/nginx/html/auth-middleware.js \
/usr/share/nginx/html/backup-s3.js \ /usr/share/nginx/html/backup-s3.js \

View File

@ -1,3 +1,81 @@
// This server-side data API (data-api.js) has been deprecated. const express = require('express');
// Data handling is now managed client-side using browser file APIs const bodyParser = require('body-parser');
// in accordance with the project vision for a static site. 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);
});

View File

@ -5,6 +5,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- AUTH_USERNAME=${AUTH_USERNAME}
- AUTH_PASSWORD=${AUTH_PASSWORD}
container_name: weight-tracker container_name: weight-tracker
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@ -20,13 +20,26 @@ const DataManager = (() => {
*/ */
const init = async () => { const init = async () => {
try { try {
console.log('Initializing data manager...'); console.log('Initializing data manager by fetching from API...');
loadFromLocalStorage(); // Load from localStorage or set defaults 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) { } catch (error) {
console.error('Error initializing data:', error); console.error('Error initializing data via API:', error);
appData = {...defaultData}; // Fallback to default data appData = { ...defaultData }; // Fallback to default data on critical error
// 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); }
} }
}; };
@ -46,11 +59,37 @@ const DataManager = (() => {
}; };
/** /**
* Save data to localStorage. * Save data to API.
* Actual file saving will be a separate user-initiated action (handled by exportData).
*/ */
const saveData = async () => { 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;
}
}; };
/** /**

View File

@ -9,12 +9,29 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Main application location # 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 / { location / {
include /etc/nginx/nginx-auth.conf; # Apply Basic Auth to the whole site
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Enable browser caching for static assets # Enable browser caching for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
include /etc/nginx/nginx-auth.conf; # Apply Basic Auth to static assets too
expires 30d; expires 30d;
add_header Cache-Control "public, no-transform"; add_header Cache-Control "public, no-transform";
} }