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

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 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)

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
* 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 });

View File

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

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",
"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"

View File

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