mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 20:20:30 -06:00
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user