Merge pull request #82 from DRYTRIX/develop

Develop
This commit is contained in:
Dries Peeters
2025-10-12 22:05:31 +02:00
committed by GitHub
5 changed files with 102 additions and 20 deletions

View File

@@ -16,6 +16,7 @@ import re
from jinja2 import ChoiceLoader, FileSystemLoader
from urllib.parse import urlparse
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.http import parse_options_header
# Load environment variables
load_dotenv()
@@ -233,13 +234,13 @@ def create_app(config=None):
# Setup logging
setup_logging(app)
# Fail-fast on weak secret in production
# Fail-fast on weak/missing secret in production
if not app.debug and app.config.get("FLASK_ENV", "production") == "production":
if app.config.get("SECRET_KEY") == "dev-secret-key-change-in-production":
app.logger.error(
"Weak SECRET_KEY configured in production; refusing to start"
)
raise RuntimeError("Weak SECRET_KEY in production")
secret = app.config.get("SECRET_KEY")
placeholder_values = {"dev-secret-key-change-in-production", "your-secret-key-change-this", "your-secret-key-here"}
if (not secret) or (secret in placeholder_values) or (isinstance(secret, str) and len(secret) < 32):
app.logger.error("Invalid SECRET_KEY configured in production; refusing to start")
raise RuntimeError("Invalid SECRET_KEY in production")
# Apply security headers and a basic CSP
@app.after_request
@@ -276,6 +277,53 @@ def create_app(config=None):
# CSRF error handler with HTML-friendly fallback
@app.errorhandler(CSRFError)
def handle_csrf_error(e):
# Prefer HTML flow for classic form posts regardless of Accept header quirks
try:
mimetype, _ = parse_options_header(request.headers.get("Content-Type", ""))
is_classic_form = mimetype in ("application/x-www-form-urlencoded", "multipart/form-data")
except Exception:
is_classic_form = False
# Log details for diagnostics
try:
try:
from flask_login import current_user as _cu
user_id = getattr(_cu, "id", None) if getattr(_cu, "is_authenticated", False) else None
except Exception:
user_id = None
app.logger.warning(
"CSRF failure: path=%s method=%s form=%s json=%s ref=%s user=%s reason=%s",
request.path,
request.method,
bool(request.form),
request.is_json,
request.referrer,
user_id,
getattr(e, "description", "")
)
except Exception:
pass
if request.method == "POST" and (is_classic_form or (request.form and not request.is_json)):
try:
flash(_("Your session expired or the page was open too long. Please try again."), "warning")
except Exception:
flash("Your session expired or the page was open too long. Please try again.", "warning")
# Redirect back to a safe same-origin referrer if available, else to dashboard
dest = url_for("main.dashboard")
try:
ref = request.referrer
if ref:
ref_host = urlparse(ref).netloc
cur_host = urlparse(request.host_url).netloc
if ref_host and ref_host == cur_host:
dest = ref
except Exception:
pass
return redirect(dest)
# JSON/XHR fall-through
try:
wants_json = (
request.is_json
@@ -289,12 +337,11 @@ def create_app(config=None):
if wants_json:
return jsonify(error="csrf_token_missing_or_invalid"), 400
# Default to HTML-friendly behavior
try:
flash(_("Your session expired or the page was open too long. Please try again."), "warning")
except Exception:
flash("Your session expired or the page was open too long. Please try again.", "warning")
# Redirect back to a safe same-origin referrer if available, else to dashboard
dest = url_for("main.dashboard")
try:
ref = request.referrer

View File

@@ -23,7 +23,7 @@ class Config:
# Session settings
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true'
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
PERMANENT_SESSION_LIFETIME = timedelta(
seconds=int(os.getenv('PERMANENT_SESSION_LIFETIME', 86400))
)
@@ -32,7 +32,7 @@ class Config:
REMEMBER_COOKIE_DURATION = timedelta(days=int(os.getenv('REMEMBER_COOKIE_DAYS', 365)))
REMEMBER_COOKIE_SECURE = os.getenv('REMEMBER_COOKIE_SECURE', 'false').lower() == 'true'
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = 'Lax'
REMEMBER_COOKIE_SAMESITE = os.getenv('REMEMBER_COOKIE_SAMESITE', 'Lax')
# Application settings
TZ = os.getenv('TZ', 'Europe/Rome')

View File

@@ -198,6 +198,9 @@ def edit_task(task_id):
return redirect(url_for('tasks.view_task', task_id=task.id))
if request.method == 'POST':
# Preload context for potential validation errors
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
users = User.query.order_by(User.username).all()
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
@@ -208,14 +211,14 @@ def edit_task(task_id):
# Validate required fields
if not name:
flash('Task name is required', 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
# Parse estimated hours
try:
estimated_hours = float(estimated_hours) if estimated_hours else None
except ValueError:
flash('Invalid estimated hours format', 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
# Parse due date
due_date = None
@@ -224,7 +227,7 @@ def edit_task(task_id):
due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date()
except ValueError:
flash('Invalid due date format', 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
# Update task
task.name = name
@@ -250,7 +253,7 @@ def edit_task(task_id):
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress'))
if not safe_commit('edit_task_reopen_in_progress', {'task_id': task.id}):
flash('Could not update status due to a database error. Please check server logs.', 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
else:
task.start_task()
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress"))
@@ -274,17 +277,17 @@ def edit_task(task_id):
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {selected_status}"))
if not safe_commit('edit_task_status_change', {'task_id': task.id, 'status': selected_status}):
flash('Could not update status due to a database error. Please check server logs.', 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
except ValueError as e:
flash(str(e), 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
# Always update the updated_at timestamp to local time after edits
task.updated_at = now_in_app_timezone()
if not safe_commit('edit_task', {'task_id': task.id}):
flash('Could not update task due to a database error. Please check server logs.', 'error')
return render_template('tasks/edit.html', task=task)
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
flash(f'Task "{name}" updated successfully', 'success')
return redirect(url_for('tasks.view_task', task_id=task.id))

View File

@@ -616,15 +616,16 @@
})();
</script>
<script>
// CSRF auto-injection for forms and AJAX/fetch + optional token refresh
// CSRF auto-injection for forms and AJAX/fetch + token refresh & robustness
(function(){
try {
var meta = document.querySelector('meta[name="csrf-token"]');
var lastRefreshAt = Date.now();
function getToken(){
return meta ? (meta.getAttribute('content') || '') : '';
}
function setToken(t){
if (meta && typeof t === 'string' && t) meta.setAttribute('content', t);
if (meta && typeof t === 'string' && t) { meta.setAttribute('content', t); lastRefreshAt = Date.now(); }
}
function isPostForm(form){
var m = (form.getAttribute('method') || form.method || '').toString().toUpperCase();
@@ -711,6 +712,37 @@
});
// Refresh every 20 minutes (default token TTL is 60 minutes)
try { setInterval(refreshCsrfToken, 20 * 60 * 1000); } catch(e) {}
// Also refresh on window focus (covers cases visibility doesn't fire)
window.addEventListener('focus', function(){ try { refreshCsrfToken(); } catch(_) {} }, { passive: true });
// Observe dynamically added forms and ensure token present
try {
new MutationObserver(function(muts){
var token = getToken();
muts.forEach(function(m){
Array.prototype.forEach.call(m.addedNodes || [], function(n){
if (n && n.nodeType === 1) {
if (n.tagName === 'FORM') ensureFormHasToken(n, token);
n.querySelectorAll && n.querySelectorAll('form').forEach(function(f){ ensureFormHasToken(f, token); });
}
});
});
}).observe(document.body, { childList: true, subtree: true });
} catch(_) {}
// Pre-submit refresh if token is stale (>15 minutes old)
document.addEventListener('submit', function(ev){
var form = ev.target;
if (!form || form.tagName !== 'FORM') return;
var now = Date.now();
if (now - lastRefreshAt > 15 * 60 * 1000) {
try {
ev.preventDefault();
refreshCsrfToken().then(function(){ try { form.submit(); } catch(_) {} });
} catch(_) {}
}
}, true);
} catch(e) {}
})();
</script>

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='2.3.3',
version='2.3.4',
packages=find_packages(),
include_package_data=True,
install_requires=[