feat: add support for SQLite alongside MySQL #5

Merged
jamie merged 1 commits from v1.2.0 into master 2026-01-08 17:32:39 +00:00
7 changed files with 219 additions and 160 deletions
+1
View File
@@ -2,3 +2,4 @@ tailwindcss
static/output.css static/output.css
.env .env
__pycache__ __pycache__
echolog.db
+28 -111
View File
@@ -14,62 +14,42 @@ A Flask-based web application for personal homelab journaling. Track your daily
- **PWA Support**: Progressive Web App making this installable on mobile devices - **PWA Support**: Progressive Web App making this installable on mobile devices
- **Modern UI**: Beautiful gradient design with Tailwind CSS and responsive layout - **Modern UI**: Beautiful gradient design with Tailwind CSS and responsive layout
- **Optional Authentication**: Enable login protection with environment variables - **Optional Authentication**: Enable login protection with environment variables
- **MySQL Backend**: Robust data persistence with MySQL/MariaDB - **Multiple Database Support**: Use MySQL/MariaDB or SQLite for data persistence
- **Docker Ready**: Easy deployment with Docker and Docker Compose - **Docker Ready**: Easy deployment with Docker and Docker Compose
- **Kubernetes Support**: Includes Kubernetes deployment manifest - **Kubernetes Support**: Includes Kubernetes deployment manifest
## Quick Start with Docker ## Quick Start with Docker
### Docker Run For deployment examples, see the [examples folder](examples/).
```bash
docker run -d \
--name echolog \
-p 5000:5000 \
-e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=echolog \
-e MYSQL_PASSWORD=your_password \
-e MYSQL_DATABASE=echolog \
-e SECRET_KEY=your_secret_key \
-e TZ=Europe/London \
-e LOGIN_ENABLED=true \
-e LOGIN_USERNAME=admin \
-e LOGIN_PASSWORD=your_password \
cr.jdbnet.co.uk/public/echolog:latest
```
### Docker Compose
```yaml
version: '3.8'
services:
echolog:
image: cr.jdbnet.co.uk/public/echolog:latest
container_name: echolog
restart: unless-stopped
ports:
- "5000:5000"
environment:
- MYSQL_HOST=10.10.2.27
- MYSQL_USER=echolog
- MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=echolog
- SECRET_KEY=your_secret_key
- TZ=Europe/London
- LOGIN_ENABLED=true
- LOGIN_USERNAME=admin
- LOGIN_PASSWORD=your_password
```
## Configuration ## Configuration
### Database Selection
EchoLog supports both MySQL/MariaDB and SQLite:
- SQLite (default for quick start)
- MySQL/MariaDB
See the [examples folder](examples/) for docker-compose files for both database types.
### Environment Variables ### Environment Variables
#### Database Configuration
- `DB_TYPE`: Database type - `mysql` (default) or `sqlite`
**MySQL/MariaDB options:**
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost) - `MYSQL_HOST`: MySQL/MariaDB host (default: localhost)
- `MYSQL_USER`: Database user (default: root) - `MYSQL_USER`: Database user (default: root)
- `MYSQL_PASSWORD`: Database password (default: empty) - `MYSQL_PASSWORD`: Database password (default: empty)
- `MYSQL_DATABASE`: Database name (default: echolog) - `MYSQL_DATABASE`: Database name (default: echolog)
**SQLite options:**
- `SQLITE_DB`: Path to SQLite database file (default: echolog.db)
#### Application Configuration
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**) - `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `TZ`: Timezone for date handling (default: UTC) - `TZ`: Timezone for date handling (default: UTC)
- `LOGIN_ENABLED`: Enable login protection (default: false) - `LOGIN_ENABLED`: Enable login protection (default: false)
@@ -78,6 +58,8 @@ services:
### Database Setup ### Database Setup
#### MySQL/MariaDB
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate permissions: The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate permissions:
```sql ```sql
@@ -87,6 +69,10 @@ GRANT ALL PRIVILEGES ON echolog.* TO 'echolog'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
#### SQLite
SQLite requires no setup - the database file is created automatically on first run. Ensure the directory where the database file is stored is writable by the application.
## Usage ## Usage
### Creating Entries ### Creating Entries
@@ -112,75 +98,6 @@ FLUSH PRIVILEGES;
The streak counter automatically tracks consecutive days with journal entries. The streak includes today or yesterday to start, and continues as long as you have entries on consecutive days. The streak counter automatically tracks consecutive days with journal entries. The streak includes today or yesterday to start, and continues as long as you have entries on consecutive days.
## Kubernetes Deployment
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
**Example Kubernetes deployment:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: echolog
namespace: echolog
spec:
replicas: 1
template:
spec:
containers:
- name: echolog
image: cr.jdbnet.co.uk/public/echolog:latest
ports:
- containerPort: 5000
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: echolog-secrets
key: secret-key
- name: MYSQL_HOST
value: "mysql-service"
- name: MYSQL_USER
value: "echolog"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: echolog-secrets
key: mysql-password
- name: MYSQL_DATABASE
value: "echolog"
- name: TZ
value: "Europe/London"
- name: LOGIN_ENABLED
value: "true"
- name: LOGIN_USERNAME
valueFrom:
secretKeyRef:
name: echolog-secrets
key: username
- name: LOGIN_PASSWORD
valueFrom:
secretKeyRef:
name: echolog-secrets
key: password
```
## Progressive Web App (PWA)
EchoLog can be installed as a Progressive Web App on supported devices:
- **Mobile**: Add to home screen from browser menu
- **Desktop**: Install from browser address bar
## Security Notes
- **ENABLE LOGIN** in production by setting `LOGIN_ENABLED=true`
- **CHANGE THE DEFAULT CREDENTIALS** if using authentication
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
- Use strong passwords for database access
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
## Troubleshooting ## Troubleshooting
### Database Connection Issues ### Database Connection Issues
+115 -45
View File
@@ -1,9 +1,9 @@
import os import os
import pytz import pytz
import logging import logging
import sqlite3
from flask import Flask, render_template, request, redirect, url_for, session, jsonify from flask import Flask, render_template, request, redirect, url_for, session, jsonify
from datetime import datetime, date as datedate, datetime as dt from datetime import datetime, date as datedate, datetime as dt
import mysql.connector
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -18,28 +18,58 @@ tz = pytz.timezone(TIMEZONE)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'defaultsecret') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'defaultsecret')
app.config['LOGIN_ENABLED'] = os.getenv('LOGIN_ENABLED', 'false').lower() == 'true' app.config['LOGIN_ENABLED'] = os.getenv('LOGIN_ENABLED', 'false').lower() == 'true'
mysql_config = { # Database configuration
'user': os.getenv('MYSQL_USER', 'root'), DB_TYPE = os.getenv('DB_TYPE', 'mysql').lower()
'password': os.getenv('MYSQL_PASSWORD', ''),
'host': os.getenv('MYSQL_HOST', 'localhost'), if DB_TYPE == 'mysql':
'database': os.getenv('MYSQL_DATABASE', 'echolog') import mysql.connector
} mysql_config = {
'user': os.getenv('MYSQL_USER', 'root'),
'password': os.getenv('MYSQL_PASSWORD', ''),
'host': os.getenv('MYSQL_HOST', 'localhost'),
'database': os.getenv('MYSQL_DATABASE', 'echolog')
}
elif DB_TYPE == 'sqlite':
SQLITE_DB = os.getenv('SQLITE_DB', 'echolog.db')
else:
raise ValueError(f"Unsupported DB_TYPE: {DB_TYPE}. Use 'sqlite' or 'mysql'")
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s') logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
def get_db_connection(): def get_db_connection():
return mysql.connector.connect(**mysql_config) if DB_TYPE == 'mysql':
return mysql.connector.connect(**mysql_config)
elif DB_TYPE == 'sqlite':
conn = sqlite3.connect(SQLITE_DB)
conn.row_factory = sqlite3.Row
return conn
def dict_from_row(row):
"""Convert database row to dictionary"""
if DB_TYPE == 'mysql':
return row
elif DB_TYPE == 'sqlite':
return dict(row) if row else None
def init_db(): def init_db():
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" if DB_TYPE == 'mysql':
CREATE TABLE IF NOT EXISTS journal_entry ( cursor.execute("""
id INT AUTO_INCREMENT PRIMARY KEY, CREATE TABLE IF NOT EXISTS journal_entry (
date DATE NOT NULL, id INT AUTO_INCREMENT PRIMARY KEY,
content TEXT NOT NULL date DATE NOT NULL,
); content TEXT NOT NULL
""") );
""")
elif DB_TYPE == 'sqlite':
cursor.execute("""
CREATE TABLE IF NOT EXISTS journal_entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
content TEXT NOT NULL
);
""")
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -48,7 +78,10 @@ def calculate_streak():
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT date FROM journal_entry ORDER BY date DESC") cursor.execute("SELECT date FROM journal_entry ORDER BY date DESC")
dates = [row[0] for row in cursor.fetchall()] if DB_TYPE == 'mysql':
dates = [row[0] for row in cursor.fetchall()]
elif DB_TYPE == 'sqlite':
dates = [row[0] for row in cursor.fetchall()]
cursor.close() cursor.close()
conn.close() conn.close()
if not dates: if not dates:
@@ -84,14 +117,25 @@ def index():
per_page = 7 per_page = 7
offset = (page - 1) * per_page offset = (page - 1) * per_page
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor()
cursor.execute("SELECT SQL_CALC_FOUND_ROWS * FROM journal_entry ORDER BY date DESC LIMIT %s OFFSET %s", (per_page, offset))
entries = cursor.fetchall() if DB_TYPE == 'mysql':
cursor.execute("SELECT FOUND_ROWS() as total") cursor.execute("SELECT SQL_CALC_FOUND_ROWS * FROM journal_entry ORDER BY date DESC LIMIT %s OFFSET %s", (per_page, offset))
total = cursor.fetchone()['total'] entries = cursor.fetchall()
cursor.execute("SELECT FOUND_ROWS() as total")
total = cursor.fetchone()['total']
elif DB_TYPE == 'sqlite':
cursor.execute("SELECT COUNT(*) as total FROM journal_entry")
total = cursor.fetchone()[0]
cursor.execute("SELECT * FROM journal_entry ORDER BY date DESC LIMIT ? OFFSET ?", (per_page, offset))
entries = [dict_from_row(row) for row in cursor.fetchall()]
today = datetime.now(tz).date().isoformat() today = datetime.now(tz).date().isoformat()
cursor.execute("SELECT * FROM journal_entry WHERE date = %s", (today,)) cursor.execute("SELECT * FROM journal_entry WHERE date = ?" if DB_TYPE == 'sqlite' else "SELECT * FROM journal_entry WHERE date = %s", (today,))
todays_entry = cursor.fetchone() todays_entry = cursor.fetchone()
if DB_TYPE == 'sqlite' and todays_entry:
todays_entry = dict_from_row(todays_entry)
cursor.close() cursor.close()
conn.close() conn.close()
has_prev = page > 1 has_prev = page > 1
@@ -107,12 +151,13 @@ def add_entry():
if content.strip(): if content.strip():
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id FROM journal_entry WHERE date = %s", (date,)) param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"SELECT id FROM journal_entry WHERE date = {param_marker}", (date,))
existing = cursor.fetchone() existing = cursor.fetchone()
if existing: if existing:
cursor.execute("UPDATE journal_entry SET content = %s WHERE date = %s", (content, date)) cursor.execute(f"UPDATE journal_entry SET content = {param_marker} WHERE date = {param_marker}", (content, date))
else: else:
cursor.execute("INSERT INTO journal_entry (date, content) VALUES (%s, %s)", (date, content)) cursor.execute(f"INSERT INTO journal_entry (date, content) VALUES ({param_marker}, {param_marker})", (date, content))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -124,30 +169,49 @@ def entry_for_date():
if not date: if not date:
return jsonify({'content': ''}) return jsonify({'content': ''})
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor()
cursor.execute("SELECT content FROM journal_entry WHERE date = %s", (date,)) param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"SELECT content FROM journal_entry WHERE date = {param_marker}", (date,))
entry = cursor.fetchone() entry = cursor.fetchone()
cursor.close() cursor.close()
conn.close() conn.close()
return jsonify({'content': entry['content'] if entry else ''}) if DB_TYPE == 'sqlite':
content = entry[0] if entry else ''
else:
content = entry['content'] if entry else ''
return jsonify({'content': content})
@app.route('/search', methods=['GET']) @app.route('/search', methods=['GET'])
def search(): def search():
query = request.args.get('query', '') query = request.args.get('query', '')
date = request.args.get('date', None) date = request.args.get('date', None)
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor()
sql = "SELECT * FROM journal_entry WHERE 1=1" if DB_TYPE == 'mysql':
params = [] sql = "SELECT * FROM journal_entry WHERE 1=1"
if query: params = []
sql += " AND content LIKE %s" if query:
params.append(f"%{query}%") sql += " AND content LIKE %s"
if date: params.append(f"%{query}%")
sql += " AND date = %s" if date:
params.append(date) sql += " AND date = %s"
sql += " ORDER BY date DESC" params.append(date)
cursor.execute(sql, params) sql += " ORDER BY date DESC"
entries = cursor.fetchall() cursor.execute(sql, params)
entries = cursor.fetchall()
elif DB_TYPE == 'sqlite':
sql = "SELECT * FROM journal_entry WHERE 1=1"
params = []
if query:
sql += " AND content LIKE ?"
params.append(f"%{query}%")
if date:
sql += " AND date = ?"
params.append(date)
sql += " ORDER BY date DESC"
cursor.execute(sql, params)
entries = [dict_from_row(row) for row in cursor.fetchall()]
cursor.close() cursor.close()
conn.close() conn.close()
today = datetime.now(tz).date().isoformat() today = datetime.now(tz).date().isoformat()
@@ -186,22 +250,26 @@ def require_login():
@app.route('/edit/<int:entry_id>', methods=['GET', 'POST']) @app.route('/edit/<int:entry_id>', methods=['GET', 'POST'])
def edit_entry(entry_id): def edit_entry(entry_id):
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor()
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
if request.method == 'POST': if request.method == 'POST':
date = request.form.get('date') date = request.form.get('date')
content = request.form.get('content') content = request.form.get('content')
cursor.execute("UPDATE journal_entry SET date=%s, content=%s WHERE id=%s", (date, content, entry_id)) cursor.execute(f"UPDATE journal_entry SET date={param_marker}, content={param_marker} WHERE id={param_marker}", (date, content, entry_id))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
return redirect(url_for('index')) return redirect(url_for('index'))
else: else:
cursor.execute("SELECT * FROM journal_entry WHERE id=%s", (entry_id,)) cursor.execute(f"SELECT * FROM journal_entry WHERE id={param_marker}", (entry_id,))
entry = cursor.fetchone() entry = cursor.fetchone()
cursor.close() cursor.close()
conn.close() conn.close()
if not entry: if not entry:
return redirect(url_for('index')) return redirect(url_for('index'))
if DB_TYPE == 'sqlite':
entry = dict_from_row(entry)
return render_template('edit.html', entry=entry) return render_template('edit.html', entry=entry)
@app.route('/edit_modal', methods=['POST']) @app.route('/edit_modal', methods=['POST'])
@@ -212,7 +280,8 @@ def edit_entry_modal():
if entry_id and date and content: if entry_id and date and content:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("UPDATE journal_entry SET date=%s, content=%s WHERE id=%s", (date, content, entry_id)) param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"UPDATE journal_entry SET date={param_marker}, content={param_marker} WHERE id={param_marker}", (date, content, entry_id))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -222,7 +291,8 @@ def edit_entry_modal():
def delete_entry(entry_id): def delete_entry(entry_id):
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM journal_entry WHERE id=%s", (entry_id,)) param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
cursor.execute(f"DELETE FROM journal_entry WHERE id={param_marker}", (entry_id,))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
+44
View File
@@ -0,0 +1,44 @@
version: '3.8'
services:
mysql:
image: mariadb:latest
container_name: echolog-mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=echolog
- MYSQL_USER=echolog
- MYSQL_PASSWORD=echolog_password
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
echolog:
image: cr.jdbnet.co.uk/public/echolog:latest
container_name: echolog
restart: unless-stopped
ports:
- "5000:5000"
depends_on:
mysql:
condition: service_healthy
environment:
- DB_TYPE=mysql
- MYSQL_HOST=mysql
- MYSQL_USER=echolog
- MYSQL_PASSWORD=echolog_password
- MYSQL_DATABASE=echolog
- SECRET_KEY=change-me-in-production
- TZ=Europe/London
- LOGIN_ENABLED=false
# Uncomment below to enable login protection
# - LOGIN_ENABLED=true
# - LOGIN_USERNAME=admin
# - LOGIN_PASSWORD=change-me
volumes:
mysql-data:
+24
View File
@@ -0,0 +1,24 @@
version: '3.8'
services:
echolog:
image: cr.jdbnet.co.uk/public/echolog:latest
container_name: echolog
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- echolog-data:/data
environment:
- DB_TYPE=sqlite
- SQLITE_DB=/data/echolog.db
- SECRET_KEY=change-me-in-production
- TZ=Europe/London
- LOGIN_ENABLED=false
# Uncomment below to enable login protection
# - LOGIN_ENABLED=true
# - LOGIN_USERNAME=admin
# - LOGIN_PASSWORD=change-me
volumes:
echolog-data:
+4 -1
View File
@@ -1,5 +1,8 @@
Flask Flask
mysql-connector-python
python-dotenv python-dotenv
pytz pytz
gunicorn gunicorn
# Optional database drivers:
# For MySQL/MariaDB support, uncomment the line below:
# mysql-connector-python
# SQLite is built-in with Python
+1 -1
View File
@@ -213,7 +213,7 @@
<footer class="mt-8 py-6 border-t border-gray-800 text-center text-gray-500 text-sm"> <footer class="mt-8 py-6 border-t border-gray-800 text-center text-gray-500 text-sm">
<span class="mr-1"><a href="https://www.jdbnet.co.uk" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">JDB-NET</a> &copy; {{ now.year }}</span> <span class="mr-1"><a href="https://www.jdbnet.co.uk" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">JDB-NET</a> &copy; {{ now.year }}</span>
&middot; &middot;
<span class="mx-1"><a href="https://github.com/JDB-NET/echolog" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">{{ version }}</a></span> <span class="mx-1"><a href="https://git.jdbnet.co.uk/jamie/echolog" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">{{ version }}</a></span>
</footer> </footer>
<script> <script>