feat: add password protection and S3 backup functionality with authentication middleware

This commit is contained in:
Greg 2025-05-27 00:04:56 +02:00
parent 047c9baf64
commit 7a789e7060
11 changed files with 627 additions and 4 deletions

50
.gitignore vendored Normal file
View File

@ -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/

View File

@ -25,9 +25,12 @@ RUN apk add --no-cache nodejs npm supervisor
# Copy the static website files to the Nginx serving directory # Copy the static website files to the Nginx serving directory
COPY --from=web-builder /app /usr/share/nginx/html 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 --from=base /app/node_modules /usr/share/nginx/api/node_modules
COPY data-api.js /usr/share/nginx/api/ 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 a custom Nginx configuration that includes the data API proxy
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@ -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 Visualization**: View your weight progress on an interactive chart
- **Data Export/Import**: Export and import your data as JSON or CSV - **Data Export/Import**: Export and import your data as JSON or CSV
- **Privacy-First**: All data stays on your device using browser's localStorage - **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 - **Mobile-Friendly**: Responsive design works on all devices
## Getting Started ## 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. 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 ## Roadmap
### Phase 1 (Current) ### Phase 1 (Current)

103
auth-middleware.js Normal file
View File

@ -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<string>} - Hashed password
*/
function generateHash(password) {
return bcrypt.hash(password, 10);
}
module.exports = {
initAuth,
generateHash
};

149
backup-s3.js Normal file
View File

@ -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();

View File

@ -1,6 +1,7 @@
/** /**
* Simple data API for Weight Tracker * Simple data API for Weight Tracker
* This script handles data storage operations when deployed in Docker * This script handles data storage operations when deployed in Docker
* Includes authentication for password protection
*/ */
const fs = require('fs'); const fs = require('fs');
@ -8,6 +9,7 @@ const path = require('path');
const express = require('express'); const express = require('express');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const cors = require('cors'); const cors = require('cors');
const { initAuth, generateHash } = require('./auth-middleware');
// Create Express app // Create Express app
const app = express(); const app = express();
@ -20,8 +22,12 @@ const DATA_FILE = path.join(DATA_DIR, 'weight-tracker-data.json');
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(bodyParser.json({ limit: '5mb' })); app.use(bodyParser.json({ limit: '5mb' }));
app.use(bodyParser.urlencoded({ extended: true })); // For parsing form data
app.use(express.static('public')); // Serve static files app.use(express.static('public')); // Serve static files
// Initialize authentication middleware
initAuth(app);
// Ensure data directory exists // Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) { if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });

View File

@ -13,8 +13,35 @@ services:
- weight-tracker-data:/data - weight-tracker-data:/data
networks: networks:
- weight-tracker-network - 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: labels:
- "coolify.volume.weight-tracker-data=/data" - "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: networks:
weight-tracker-network: weight-tracker-network:

44
generate-password-hash.js Normal file
View File

@ -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 <your-password>
*/
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 <your-password>');
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();

168
login.html Normal file
View File

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Keep My Weight Tracker</title>
<style>
:root {
--primary-color: #4a6fa5;
--primary-dark: #3a5a8c;
--background-color: #f8f9fa;
--card-bg: #ffffff;
--text-color: #333;
--border-color: #e0e0e0;
--error-color: #d9534f;
--border-radius: 8px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
--font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background-color: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
padding: 30px;
}
.logo {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: var(--primary-color);
font-size: 2rem;
margin-bottom: 5px;
}
.tagline {
color: #666;
font-size: 1rem;
margin-bottom: 30px;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
input[type="password"] {
width: 100%;
padding: 12px;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
font-family: var(--font-family);
font-size: 1rem;
}
input[type="password"]:focus {
outline: none;
border-color: var(--primary-color);
}
.btn {
width: 100%;
padding: 12px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
font-size: 1rem;
transition: background-color 0.3s;
}
.btn:hover {
background-color: var(--primary-dark);
}
.error-message {
color: var(--error-color);
margin-top: 20px;
text-align: center;
font-size: 0.9rem;
}
.privacy-note {
margin-top: 30px;
text-align: center;
font-size: 0.8rem;
color: #666;
}
@media (max-width: 480px) {
.login-container {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<h1>Keep My Weight</h1>
</div>
<p class="tagline">Simple, private weight & meal tracking</p>
<form id="login-form" action="/auth/login" method="POST">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">Login</button>
<div id="error-message" class="error-message">
<!-- Error messages will be displayed here -->
<!-- If there was an error in the URL, it will be displayed via JavaScript -->
</div>
</form>
<p class="privacy-note">Your data stays private, always. This password protects your personal health information.</p>
</div>
<script>
// Check for error parameter in URL
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
if (error) {
const errorElement = document.getElementById('error-message');
if (error === 'invalid') {
errorElement.textContent = 'Invalid password. Please try again.';
} else if (error === 'session') {
errorElement.textContent = 'Your session has expired. Please login again.';
}
}
});
</script>
</body>
</html>

View File

@ -5,12 +5,19 @@
"main": "data-api.js", "main": "data-api.js",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node data-api.js" "start": "node data-api.js",
"backup": "node backup-s3.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "@aws-sdk/client-s3": "^3.425.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2", "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": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@ -24,3 +24,14 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 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