06/04 Optimize app
This commit is contained in:
@@ -82,6 +82,18 @@ def create_app():
|
|||||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||||
|
"font-src 'self' https://fonts.gstatic.com; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'self'"
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# ── 413 handler: file too large ────────────────────────────────────────────
|
# ── 413 handler: file too large ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def log_action(user_id, action, entity=None, entity_id=None, detail=None):
|
|||||||
|
|
||||||
# ─── Authentication ────────────────────────────────────────────────────────────
|
# ─── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def authenticate(username: str, password: str):
|
def authenticate(username: str, password: str, ip_address: str = None):
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -66,7 +66,7 @@ def authenticate(username: str, password: str):
|
|||||||
if not user or not _verify_password(password, user["password"]):
|
if not user or not _verify_password(password, user["password"]):
|
||||||
logger.warning(f"Failed login attempt for username='{username}'.")
|
logger.warning(f"Failed login attempt for username='{username}'.")
|
||||||
cur.close()
|
cur.close()
|
||||||
record_failed_attempt(username)
|
record_failed_attempt(username, ip_address)
|
||||||
return None
|
return None
|
||||||
if _needs_rehash(user["password"]):
|
if _needs_rehash(user["password"]):
|
||||||
new_hash = _hash_password(password)
|
new_hash = _hash_password(password)
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ def login():
|
|||||||
error = f"Account locked. Try again in {mins}m {secs}s."
|
error = f"Account locked. Try again in {mins}m {secs}s."
|
||||||
logger.warning(f"Login blocked for '{username}' — still locked ({seconds_remaining}s remaining).")
|
logger.warning(f"Login blocked for '{username}' — still locked ({seconds_remaining}s remaining).")
|
||||||
else:
|
else:
|
||||||
user = authenticate(username, password)
|
user = authenticate(username, password, ip_address=ip)
|
||||||
if user:
|
if user:
|
||||||
session.clear() # prevent session fixation
|
session.clear() # prevent session fixation
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
|||||||
from models import (
|
from models import (
|
||||||
get_today_checks, mark_website_checked, unmark_website_checked,
|
get_today_checks, mark_website_checked, unmark_website_checked,
|
||||||
update_check_note, get_user_active_shifts, get_website_credentials,
|
update_check_note, get_user_active_shifts, get_website_credentials,
|
||||||
get_website_url,
|
get_website_url, log_action,
|
||||||
)
|
)
|
||||||
from utils.decorators import login_required
|
from utils.decorators import login_required
|
||||||
|
|
||||||
@@ -86,6 +86,9 @@ def view_credentials(website_id):
|
|||||||
"""JSON endpoint: return decrypted credentials for a site."""
|
"""JSON endpoint: return decrypted credentials for a site."""
|
||||||
try:
|
try:
|
||||||
creds = get_website_credentials(website_id)
|
creds = get_website_credentials(website_id)
|
||||||
|
user_id = session["user"]["id"]
|
||||||
|
log_action(user_id, "VIEW_CREDENTIALS", "websites", website_id,
|
||||||
|
f"User viewed credentials for website {website_id}.")
|
||||||
return jsonify([{
|
return jsonify([{
|
||||||
"label": c.get("label", ""),
|
"label": c.get("label", ""),
|
||||||
"username": c.get("username", ""),
|
"username": c.get("username", ""),
|
||||||
|
|||||||
+48
-18
@@ -24,9 +24,9 @@ function closeModal(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal when clicking the backdrop
|
// Close modal when clicking the backdrop (skips modals with data-no-backdrop-close)
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target.classList.contains('modal-overlay')) {
|
if (e.target.classList.contains('modal-overlay') && !e.target.hasAttribute('data-no-backdrop-close')) {
|
||||||
e.target.classList.remove('open');
|
e.target.classList.remove('open');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
@@ -162,30 +162,60 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
/* ── Session timeout warning ───────────────────────────────── */
|
/* ── Session timeout warning ───────────────────────────────── */
|
||||||
(function sessionWarning() {
|
(function sessionWarning() {
|
||||||
const WARN_BEFORE_MS = 5 * 60 * 1000; // warn 5 min before expiry
|
const WARN_BEFORE_MS = 5 * 60 * 1000;
|
||||||
const SESSION_MS = 30 * 60 * 1000; // match Flask SESSION_LIFETIME
|
const SESSION_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
let warningTimer = null;
|
var warningTimer = null, expireTimer = null, countdownInterval = null;
|
||||||
let expireTimer = null;
|
|
||||||
|
function isWarningShowing() {
|
||||||
|
var m = document.getElementById('modal-session-warning');
|
||||||
|
return m && m.classList.contains('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCountdown() {
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
var remaining = Math.round(WARN_BEFORE_MS / 1000);
|
||||||
|
var el = document.getElementById('session-countdown');
|
||||||
|
clearCountdown();
|
||||||
|
countdownInterval = setInterval(function() {
|
||||||
|
remaining--;
|
||||||
|
if (el) {
|
||||||
|
var m = Math.floor(remaining / 60), s = remaining % 60;
|
||||||
|
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
||||||
|
}
|
||||||
|
if (remaining <= 0) { clearCountdown(); window.location.href = '/login'; }
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function resetTimers() {
|
function resetTimers() {
|
||||||
clearTimeout(warningTimer);
|
clearTimeout(warningTimer); clearTimeout(expireTimer); clearCountdown();
|
||||||
clearTimeout(expireTimer);
|
if (isWarningShowing()) closeModal('modal-session-warning');
|
||||||
warningTimer = setTimeout(() => {
|
|
||||||
if (confirm('Your session will expire in 5 minutes. Click OK to stay logged in.')) {
|
warningTimer = setTimeout(function() {
|
||||||
fetch('/ping', { credentials: 'same-origin' }).catch(() => {});
|
openModal('modal-session-warning');
|
||||||
resetTimers();
|
startCountdown();
|
||||||
}
|
|
||||||
}, SESSION_MS - WARN_BEFORE_MS);
|
}, SESSION_MS - WARN_BEFORE_MS);
|
||||||
|
|
||||||
expireTimer = setTimeout(() => {
|
expireTimer = setTimeout(function() {
|
||||||
alert('Your session has expired. You will be redirected to login.');
|
window.location.href = '/login';
|
||||||
window.location.href = '/auth/login';
|
|
||||||
}, SESSION_MS);
|
}, SESSION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(evt => {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
document.addEventListener(evt, () => resetTimers(), { passive: true });
|
var btn = document.getElementById('btn-stay-logged-in');
|
||||||
|
if (btn) btn.addEventListener('click', function() {
|
||||||
|
fetch('/ping', { credentials: 'same-origin' }).catch(function(){});
|
||||||
|
resetTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(function(evt) {
|
||||||
|
document.addEventListener(evt, function() {
|
||||||
|
if (!isWarningShowing()) resetTimers();
|
||||||
|
}, { passive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
resetTimers();
|
resetTimers();
|
||||||
|
|||||||
@@ -69,6 +69,22 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- ── Session timeout warning modal ──────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="modal-session-warning" data-no-backdrop-close>
|
||||||
|
<div class="modal" style="max-width:380px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">⏱ Session Expiring Soon</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="text-align:center">
|
||||||
|
<p style="margin:0 0 .5rem">Your session expires in <strong id="session-countdown">5:00</strong>.</p>
|
||||||
|
<p class="text-muted" style="font-size:.875rem;margin:0">Click below to stay logged in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="justify-content:center">
|
||||||
|
<button class="btn btn-primary" id="btn-stay-logged-in">Stay Logged In</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -258,9 +258,12 @@ function renderBidList(bids) {
|
|||||||
var dueD = new Date(b.due_date.slice(0, 10)); dueD.setHours(0, 0, 0, 0);
|
var dueD = new Date(b.due_date.slice(0, 10)); dueD.setHours(0, 0, 0, 0);
|
||||||
var diff = Math.round((dueD - today) / 86400000);
|
var diff = Math.round((dueD - today) / 86400000);
|
||||||
if (diff < 0) {
|
if (diff < 0) {
|
||||||
urgencyBadge = '<span class="badge badge-danger badge-sm">Overdue</span>';
|
var od = Math.abs(diff);
|
||||||
|
urgencyBadge = '<span class="badge badge-danger badge-sm">Overdue by ' + od + (od === 1 ? ' day' : ' days') + '</span>';
|
||||||
|
} else if (diff === 0) {
|
||||||
|
urgencyBadge = '<span class="badge badge-danger badge-sm">Due today</span>';
|
||||||
} else if (diff <= 7) {
|
} else if (diff <= 7) {
|
||||||
urgencyBadge = '<span class="badge badge-warning badge-sm">Due Soon</span>';
|
urgencyBadge = '<span class="badge badge-warning badge-sm">Due in ' + diff + (diff === 1 ? ' day' : ' days') + '</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
|
|
||||||
<div class="sc-select">
|
<div class="sc-select">
|
||||||
<input type="checkbox" class="bulk-cb" data-id="{{ site.id }}"
|
<input type="checkbox" class="bulk-cb" data-id="{{ site.id }}"
|
||||||
|
aria-label="Select {{ site.name }} for bulk check"
|
||||||
{% if site.check_id %}disabled{% endif %}>
|
{% if site.check_id %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
<div class="text-center text-muted">Loading…</div>
|
<div class="text-center text-muted">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="closeModal('modal-creds')">Close</button>
|
<button class="btn btn-secondary" onclick="closeModal('modal-creds'); document.getElementById('creds-body').innerHTML=''">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,13 +233,16 @@ function toggleGroup(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Search — shows matching cards, auto-expands collapsed groups ── */
|
/* ── Search — shows matching cards, auto-expands collapsed groups ── */
|
||||||
|
var _searchTimer = null;
|
||||||
document.getElementById('site-search').addEventListener('input', function() {
|
document.getElementById('site-search').addEventListener('input', function() {
|
||||||
var q = this.value.trim().toLowerCase();
|
var val = this.value;
|
||||||
|
clearTimeout(_searchTimer);
|
||||||
|
_searchTimer = setTimeout(function() {
|
||||||
|
var q = val.trim().toLowerCase();
|
||||||
document.querySelectorAll('.site-card').forEach(function(card) {
|
document.querySelectorAll('.site-card').forEach(function(card) {
|
||||||
var match = !q || card.dataset.name.includes(q) || card.dataset.url.includes(q);
|
var match = !q || card.dataset.name.includes(q) || card.dataset.url.includes(q);
|
||||||
card.style.display = match ? '' : 'none';
|
card.style.display = match ? '' : 'none';
|
||||||
|
|
||||||
// Auto-expand the group containing a matched card
|
|
||||||
if (match && q) {
|
if (match && q) {
|
||||||
var group = card.closest('.site-group');
|
var group = card.closest('.site-group');
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -250,6 +254,7 @@ document.getElementById('site-search').addEventListener('input', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Bulk select ───────────────────────────────────────────── */
|
/* ── Bulk select ───────────────────────────────────────────── */
|
||||||
@@ -282,11 +287,20 @@ document.getElementById('site-list').addEventListener('click', function(e) {
|
|||||||
|
|
||||||
/* Mark Checked */
|
/* Mark Checked */
|
||||||
if (btn.classList.contains('js-check')) {
|
if (btn.classList.contains('js-check')) {
|
||||||
|
var siteName = btn.dataset.name;
|
||||||
|
btn.disabled = true;
|
||||||
fetch('/dashboard/check/' + btn.dataset.id, {
|
fetch('/dashboard/check/' + btn.dataset.id, {
|
||||||
method: 'POST', credentials: 'same-origin',
|
method: 'POST', credentials: 'same-origin',
|
||||||
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrfToken()},
|
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrfToken()},
|
||||||
body: 'user_note='
|
body: 'user_note='
|
||||||
}).then(function() { location.reload(); });
|
}).then(function() {
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.className = 'alert alert-success';
|
||||||
|
toast.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;min-width:220px;box-shadow:var(--shadow)';
|
||||||
|
toast.textContent = '✔ ' + siteName + ' marked checked';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(function() { location.reload(); }, 700);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +354,11 @@ document.getElementById('site-list').addEventListener('click', function(e) {
|
|||||||
|
|
||||||
/* ── Credential modal actions ──────────────────────────────── */
|
/* ── Credential modal actions ──────────────────────────────── */
|
||||||
document.getElementById('modal-creds').addEventListener('click', function(e) {
|
document.getElementById('modal-creds').addEventListener('click', function(e) {
|
||||||
|
/* Clear body when clicking the backdrop */
|
||||||
|
if (e.target === this) {
|
||||||
|
document.getElementById('creds-body').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
var copyBtn = e.target.closest('[data-copy]');
|
var copyBtn = e.target.closest('[data-copy]');
|
||||||
if (copyBtn) { copyToClipboard(copyBtn.dataset.copy, copyBtn); return; }
|
if (copyBtn) { copyToClipboard(copyBtn.dataset.copy, copyBtn); return; }
|
||||||
var toggleBtn = e.target.closest('[data-toggle-pw]');
|
var toggleBtn = e.target.closest('[data-toggle-pw]');
|
||||||
@@ -359,11 +378,17 @@ function esc(str) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Health dots (server-side probe) ───────────────────────── */
|
/* ── Health dots — batched with max 4 concurrent requests ─── */
|
||||||
document.querySelectorAll('.site-card').forEach(function(card) {
|
(function() {
|
||||||
|
var cards = Array.from(document.querySelectorAll('.site-card'));
|
||||||
|
var CONCURRENCY = 4, idx = 0;
|
||||||
|
|
||||||
|
function probe() {
|
||||||
|
if (idx >= cards.length) return;
|
||||||
|
var card = cards[idx++];
|
||||||
var id = card.dataset.id;
|
var id = card.dataset.id;
|
||||||
var dot = document.getElementById('health-' + id);
|
var dot = document.getElementById('health-' + id);
|
||||||
if (!dot) return;
|
if (!dot) { probe(); return; }
|
||||||
fetch('/dashboard/health/' + id, {credentials: 'same-origin'})
|
fetch('/dashboard/health/' + id, {credentials: 'same-origin'})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
@@ -381,8 +406,12 @@ document.querySelectorAll('.site-card').forEach(function(card) {
|
|||||||
.catch(function() {
|
.catch(function() {
|
||||||
dot.style.color = '#9ca3af';
|
dot.style.color = '#9ca3af';
|
||||||
dot.title = 'Health check failed';
|
dot.title = 'Health check failed';
|
||||||
});
|
})
|
||||||
});
|
.finally(probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < Math.min(CONCURRENCY, cards.length); i++) probe();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user