diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b3b688 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Node.js dependencies +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log +package-lock.json +yarn.lock + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Build directories +dist/ +build/ +out/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Docker +.docker-volumes/ + +# Testing +coverage/ + +# Temporary files +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile index fcc865a..ea62406 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,12 @@ RUN apk add --no-cache nodejs npm supervisor # Copy the static website files to the Nginx serving directory COPY --from=web-builder /app /usr/share/nginx/html -# Copy the Node.js dependencies and data API +# Copy the Node.js dependencies and API scripts COPY --from=base /app/node_modules /usr/share/nginx/api/node_modules COPY data-api.js /usr/share/nginx/api/ +COPY backup-s3.js /usr/share/nginx/api/ +COPY auth-middleware.js /usr/share/nginx/api/ +COPY login.html /usr/share/nginx/api/ # Copy a custom Nginx configuration that includes the data API proxy COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/README.md b/README.md index 2d6bd13..28fd3cd 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A minimalist, privacy-first web application for tracking your weight and daily m - **Data Visualization**: View your weight progress on an interactive chart - **Data Export/Import**: Export and import your data as JSON or CSV - **Privacy-First**: All data stays on your device using browser's localStorage +- **Password Protection**: Secure access to your health data +- **Automated Backups**: Optional S3-compatible storage backups - **Mobile-Friendly**: Responsive design works on all devices ## Getting Started @@ -78,6 +80,59 @@ After deployment: Even with persistent storage configured, it's recommended to occasionally use the "Export Data" feature in the Settings tab as an additional backup. +### S3 Backup Configuration + +The application includes automated backups to S3-compatible storage (like Minio): + +1. **Configure S3 Credentials in Coolify**: + - In your Coolify dashboard, go to your Weight Tracker service + - Under the "Environment Variables" section, set the following: + - `S3_ENDPOINT`: Your Minio server URL (e.g., `https://minio.example.com`) + - `S3_REGION`: Region name (e.g., `us-east-1`, can be any value for Minio) + - `S3_BUCKET`: Bucket name for backups (e.g., `weight-tracker-backups`) + - `S3_ACCESS_KEY`: Your Minio access key + - `S3_SECRET_KEY`: Your Minio secret key + - `S3_USE_SSL`: Set to `true` if your Minio server uses HTTPS + +2. **Backup Schedule (Optional)**: + - `BACKUP_SCHEDULE`: Cron expression for backup schedule (default: `0 0 * * *` - daily at midnight) + - `BACKUP_RETENTION`: Number of backups to keep (default: `7`) + +3. **Verify Backups**: + - Check your Minio bucket for backup files named `weight-tracker-backup-[timestamp].json` + - Backups are automatically rotated based on the retention setting + +### Password Protection + +The application includes password protection to secure your health data: + +1. **Generate a Password Hash**: + ```bash + # Install dependencies locally + npm install bcryptjs + + # Generate a hash for your password + node generate-password-hash.js your-secure-password + ``` + This will output a bcrypt hash that you can use in your environment variables. + +2. **Configure Password in Coolify**: + - In your Coolify dashboard, go to your Weight Tracker service + - Under the "Environment Variables" section, set: + - `PASSWORD_HASH`: The bcrypt hash generated in step 1 + - `SESSION_SECRET`: A random string for session encryption + - `COOKIE_SECURE`: Set to `true` if using HTTPS + +3. **Alternative: Use Your Caddy Hash**: + - If you already have a bcrypt hash from Caddy, you can use it directly + - Set the `PASSWORD_HASH` environment variable to your existing bcrypt hash + +4. **Security Notes**: + - The password is never stored in plain text + - Only the hash is stored in the environment variable + - Authentication uses secure HTTP-only cookies + - Sessions expire after 24 hours of inactivity + ## Roadmap ### Phase 1 (Current) diff --git a/auth-middleware.js b/auth-middleware.js new file mode 100644 index 0000000..e782a34 --- /dev/null +++ b/auth-middleware.js @@ -0,0 +1,103 @@ +/** + * Authentication Middleware for Weight Tracker + * Handles password protection and session management + */ + +const bcrypt = require('bcryptjs'); +const session = require('express-session'); +const cookieParser = require('cookie-parser'); + +// Default session configuration +const sessionConfig = { + secret: process.env.SESSION_SECRET || 'weight-tracker-secret', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.COOKIE_SECURE === 'true', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000 // 24 hours + } +}; + +/** + * Initialize authentication middleware + * @param {Object} app - Express app + */ +function initAuth(app) { + // Parse cookies + app.use(cookieParser()); + + // Session management + app.use(session(sessionConfig)); + + // Serve login page + app.get('/login', (req, res) => { + if (req.session.authenticated) { + return res.redirect('/'); + } + res.sendFile('login.html', { root: __dirname }); + }); + + // Handle login form submission + app.post('/auth/login', (req, res) => { + const { password } = req.body; + const storedHash = process.env.PASSWORD_HASH; + + if (!storedHash) { + console.error('PASSWORD_HASH environment variable not set'); + return res.redirect('/login?error=config'); + } + + // Verify password + bcrypt.compare(password, storedHash, (err, isMatch) => { + if (err) { + console.error('Error verifying password:', err); + return res.redirect('/login?error=server'); + } + + if (isMatch) { + // Set session as authenticated + req.session.authenticated = true; + res.redirect('/'); + } else { + res.redirect('/login?error=invalid'); + } + }); + }); + + // Logout endpoint + app.get('/auth/logout', (req, res) => { + req.session.destroy(); + res.redirect('/login'); + }); + + // Authentication middleware for all other routes + app.use((req, res, next) => { + // Skip auth for login-related routes + if (req.path === '/login' || req.path === '/auth/login') { + return next(); + } + + // Check if user is authenticated + if (req.session.authenticated) { + return next(); + } + + // Redirect to login page + res.redirect('/login'); + }); +} + +/** + * Generate a password hash (utility function) + * @param {string} password - Plain text password + * @returns {Promise} - Hashed password + */ +function generateHash(password) { + return bcrypt.hash(password, 10); +} + +module.exports = { + initAuth, + generateHash +}; diff --git a/backup-s3.js b/backup-s3.js new file mode 100644 index 0000000..7c86fae --- /dev/null +++ b/backup-s3.js @@ -0,0 +1,149 @@ +/** + * S3 Backup Script for Weight Tracker + * + * This script creates automated backups of the Weight Tracker data + * and uploads them to an S3-compatible storage (like Minio) + */ + +const fs = require('fs'); +const path = require('path'); +const { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +const cron = require('node-cron'); + +// Configuration (can be overridden by environment variables) +const config = { + // S3 Configuration + s3: { + endpoint: process.env.S3_ENDPOINT || 'https://your-minio-server.example.com', + region: process.env.S3_REGION || 'us-east-1', // Default region, can be any value for Minio + bucket: process.env.S3_BUCKET || 'weight-tracker-backups', + accessKey: process.env.S3_ACCESS_KEY || 'your-access-key', + secretKey: process.env.S3_SECRET_KEY || 'your-secret-key', + useSSL: process.env.S3_USE_SSL !== 'false' // Default to true unless explicitly set to 'false' + }, + + // Backup Configuration + backup: { + dataPath: process.env.DATA_PATH || '/data/weight-tracker-data.json', + schedule: process.env.BACKUP_SCHEDULE || '0 0 * * *', // Default: daily at midnight + retention: parseInt(process.env.BACKUP_RETENTION || '7') // Default: keep 7 backups + } +}; + +// Initialize S3 client +const s3Client = new S3Client({ + endpoint: config.s3.endpoint, + region: config.s3.region, + credentials: { + accessKeyId: config.s3.accessKey, + secretAccessKey: config.s3.secretKey + }, + forcePathStyle: true, // Required for Minio + tls: config.s3.useSSL +}); + +/** + * Create a backup and upload to S3 + */ +async function createBackup() { + try { + // Check if data file exists + if (!fs.existsSync(config.backup.dataPath)) { + console.error(`Data file not found: ${config.backup.dataPath}`); + return; + } + + // Read data file + const data = fs.readFileSync(config.backup.dataPath, 'utf8'); + + // Generate backup filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFilename = `weight-tracker-backup-${timestamp}.json`; + + // Upload to S3 + const uploadParams = { + Bucket: config.s3.bucket, + Key: backupFilename, + Body: data, + ContentType: 'application/json' + }; + + const uploadCommand = new PutObjectCommand(uploadParams); + await s3Client.send(uploadCommand); + + console.log(`Backup created successfully: ${backupFilename}`); + + // Cleanup old backups + await cleanupOldBackups(); + + } catch (error) { + console.error('Error creating backup:', error); + } +} + +/** + * Clean up old backups based on retention policy + */ +async function cleanupOldBackups() { + try { + // List all backups + const listParams = { + Bucket: config.s3.bucket, + Prefix: 'weight-tracker-backup-' + }; + + const listCommand = new ListObjectsV2Command(listParams); + const response = await s3Client.send(listCommand); + + if (!response.Contents || response.Contents.length <= config.backup.retention) { + return; // No cleanup needed + } + + // Sort by date (oldest first) + const backups = response.Contents.sort((a, b) => + new Date(a.LastModified) - new Date(b.LastModified) + ); + + // Delete oldest backups that exceed retention count + const backupsToDelete = backups.slice(0, backups.length - config.backup.retention); + + for (const backup of backupsToDelete) { + const deleteParams = { + Bucket: config.s3.bucket, + Key: backup.Key + }; + + const deleteCommand = new DeleteObjectCommand(deleteParams); + await s3Client.send(deleteCommand); + + console.log(`Deleted old backup: ${backup.Key}`); + } + + } catch (error) { + console.error('Error cleaning up old backups:', error); + } +} + +/** + * Initialize the backup system + */ +function init() { + console.log('Starting Weight Tracker S3 backup system'); + console.log(`Backup schedule: ${config.backup.schedule}`); + console.log(`Backup retention: ${config.backup.retention} backups`); + console.log(`S3 endpoint: ${config.s3.endpoint}`); + console.log(`S3 bucket: ${config.s3.bucket}`); + + // Schedule regular backups + cron.schedule(config.backup.schedule, () => { + console.log('Running scheduled backup...'); + createBackup(); + }); + + // Create initial backup + console.log('Creating initial backup...'); + createBackup(); +} + +// Start the backup system +init(); diff --git a/data-api.js b/data-api.js index 90e137b..5dcc4e5 100644 --- a/data-api.js +++ b/data-api.js @@ -1,6 +1,7 @@ /** * Simple data API for Weight Tracker * This script handles data storage operations when deployed in Docker + * Includes authentication for password protection */ const fs = require('fs'); @@ -8,6 +9,7 @@ const path = require('path'); const express = require('express'); const bodyParser = require('body-parser'); const cors = require('cors'); +const { initAuth, generateHash } = require('./auth-middleware'); // Create Express app const app = express(); @@ -20,8 +22,12 @@ const DATA_FILE = path.join(DATA_DIR, 'weight-tracker-data.json'); // Middleware app.use(cors()); app.use(bodyParser.json({ limit: '5mb' })); +app.use(bodyParser.urlencoded({ extended: true })); // For parsing form data app.use(express.static('public')); // Serve static files +// Initialize authentication middleware +initAuth(app); + // Ensure data directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); diff --git a/docker-compose.yml b/docker-compose.yml index 43e4d0e..1833a00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,35 @@ services: - weight-tracker-data:/data networks: - weight-tracker-network + environment: + # Authentication Configuration + - PASSWORD_HASH=${PASSWORD_HASH:-$2a$10$EgxHKjDDFcZKtQY9hl/N4.QvEQHCXVnQXw9dzFYlUDVKOcLMGp9eq} + - SESSION_SECRET=${SESSION_SECRET:-change-this-to-a-random-string} + - COOKIE_SECURE=${COOKIE_SECURE:-false} + + # S3 Backup Configuration + - S3_ENDPOINT=${S3_ENDPOINT:-https://your-minio-server.example.com} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_BUCKET=${S3_BUCKET:-weight-tracker-backups} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-your-access-key} + - S3_SECRET_KEY=${S3_SECRET_KEY:-your-secret-key} + - S3_USE_SSL=${S3_USE_SSL:-true} + - BACKUP_SCHEDULE=${BACKUP_SCHEDULE:-0 0 * * *} + - BACKUP_RETENTION=${BACKUP_RETENTION:-7} labels: - "coolify.volume.weight-tracker-data=/data" + # Coolify environment variable labels for authentication + - "coolify.env.PASSWORD_HASH=Bcrypt hash of your password" + - "coolify.env.SESSION_SECRET=Secret for session encryption (random string)" + - "coolify.env.COOKIE_SECURE=Set to true if using HTTPS (default: false)" + # Coolify environment variable labels for S3 backup + - "coolify.env.S3_ENDPOINT=S3 endpoint URL (e.g., https://minio.example.com)" + - "coolify.env.S3_REGION=S3 region (e.g., us-east-1)" + - "coolify.env.S3_BUCKET=S3 bucket name for backups" + - "coolify.env.S3_ACCESS_KEY=S3 access key" + - "coolify.env.S3_SECRET_KEY=S3 secret key" + - "coolify.env.BACKUP_SCHEDULE=Cron schedule for backups (default: 0 0 * * *)" + - "coolify.env.BACKUP_RETENTION=Number of backups to retain (default: 7)" networks: weight-tracker-network: diff --git a/generate-password-hash.js b/generate-password-hash.js new file mode 100644 index 0000000..115456f --- /dev/null +++ b/generate-password-hash.js @@ -0,0 +1,44 @@ +/** + * Password Hash Generator for Weight Tracker + * + * This utility script generates a bcrypt hash for your password + * that can be used in the PASSWORD_HASH environment variable. + * + * Usage: node generate-password-hash.js + */ + +const bcrypt = require('bcryptjs'); + +async function generateHash() { + // Get password from command line argument + const password = process.argv[2]; + + if (!password) { + console.error('Error: No password provided'); + console.log('Usage: node generate-password-hash.js '); + process.exit(1); + } + + try { + // Generate hash with bcrypt (cost factor 10) + const hash = await bcrypt.hash(password, 10); + + console.log('\nPassword Hash Generated Successfully\n'); + console.log('Copy this hash to your PASSWORD_HASH environment variable in Coolify:'); + console.log('----------------------------------------------------------------'); + console.log(hash); + console.log('----------------------------------------------------------------\n'); + + console.log('For docker-compose.yml, use:'); + console.log(`PASSWORD_HASH=${hash}\n`); + + console.log('For .env file, use:'); + console.log(`PASSWORD_HASH=${hash}\n`); + + } catch (error) { + console.error('Error generating hash:', error); + } +} + +// Run the function +generateHash(); diff --git a/login.html b/login.html new file mode 100644 index 0000000..172e845 --- /dev/null +++ b/login.html @@ -0,0 +1,168 @@ + + + + + + Login - Keep My Weight Tracker + + + + + + + + diff --git a/package.json b/package.json index d8ec9fc..d284e6a 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,19 @@ "main": "data-api.js", "private": true, "scripts": { - "start": "node data-api.js" + "start": "node data-api.js", + "backup": "node backup-s3.js" }, "dependencies": { - "express": "^4.18.2", + "@aws-sdk/client-s3": "^3.425.0", + "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", - "cors": "^2.8.5" + "connect-redis": "^7.1.0", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-session": "^1.17.3", + "node-cron": "^3.0.2" }, "engines": { "node": ">=14.0.0" diff --git a/supervisord.conf b/supervisord.conf index d898e20..47d20d7 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -24,3 +24,14 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:s3-backup] +command=node /usr/share/nginx/api/backup-s3.js +directory=/usr/share/nginx/api +environment=DATA_PATH="/data/weight-tracker-data.json",S3_ENDPOINT="%(ENV_S3_ENDPOINT)s",S3_BUCKET="%(ENV_S3_BUCKET)s",S3_ACCESS_KEY="%(ENV_S3_ACCESS_KEY)s",S3_SECRET_KEY="%(ENV_S3_SECRET_KEY)s",BACKUP_SCHEDULE="%(ENV_BACKUP_SCHEDULE)s",BACKUP_RETENTION="%(ENV_BACKUP_RETENTION)s" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0