06/01 App enhanced

This commit is contained in:
2026-06-01 20:34:21 -04:00
parent ea79ae5fdb
commit 5bdeb7b13a
5 changed files with 114 additions and 38 deletions
+10
View File
@@ -116,6 +116,16 @@ def create_app(config_name=None):
) )
from app.models.teller_enrollment import TellerEnrollment, TellerAccount from app.models.teller_enrollment import TellerEnrollment, TellerAccount
@app.after_request
def security_headers(response):
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
if not app.debug:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
return response
app.jinja_env.globals['format_currency'] = format_currency app.jinja_env.globals['format_currency'] = format_currency
app.jinja_env.globals['format_percent'] = format_percent app.jinja_env.globals['format_percent'] = format_percent
app.jinja_env.globals['format_large_number'] = format_large_number app.jinja_env.globals['format_large_number'] = format_large_number
+4 -1
View File
@@ -359,7 +359,10 @@ def webhook():
# Header format: Teller-Signature: t=<timestamp>,v1=<sig1>,v1=<sig2> # Header format: Teller-Signature: t=<timestamp>,v1=<sig1>,v1=<sig2>
# Signed message: <timestamp>.<raw_body> # Signed message: <timestamp>.<raw_body>
signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '') signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '')
if signing_secret: if not signing_secret:
log.error('[teller] TELLER_WEBHOOK_SECRET not configured — rejecting webhook')
return jsonify({'error': 'Webhook verification not configured'}), 403
sig_header = request.headers.get('Teller-Signature', '') sig_header = request.headers.get('Teller-Signature', '')
body = request.get_data() body = request.get_data()
+9 -3
View File
@@ -261,9 +261,15 @@ def ocr_receipt():
if size > 10 * 1024 * 1024: if size > 10 * 1024 * 1024:
return jsonify({'error': 'File too large (max 10MB)'}), 400 return jsonify({'error': 'File too large (max 10MB)'}), 400
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', # Validate actual file content via magic bytes
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp'} from app.routes.settings import _check_magic
mime_type = mime_map.get(ext, 'image/jpeg') header = f.read(12)
f.seek(0)
magic_ext, magic_mime = _check_magic(header)
if magic_ext is None or magic_ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp'):
return jsonify({'error': 'File content does not match an allowed image type'}), 400
mime_type = magic_mime
image_bytes = f.read() image_bytes = f.read()
result = extract_from_bytes(image_bytes, mime_type) result = extract_from_bytes(image_bytes, mime_type)
+2
View File
@@ -10,6 +10,7 @@ from datetime import date, timedelta
from flask import current_app from flask import current_app
from sqlalchemy import func from sqlalchemy import func
from app.extensions import db from app.extensions import db
from sqlalchemy.orm import joinedload
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.category import Category from app.models.category import Category
from app.models.account import Account from app.models.account import Account
@@ -127,6 +128,7 @@ def build_context(days=90):
# ── Recent 20 transactions ──────────────────────────────────────────────── # ── Recent 20 transactions ────────────────────────────────────────────────
recent = Transaction.query\ recent = Transaction.query\
.options(joinedload(Transaction.category))\
.filter( .filter(
Transaction.transaction_type.in_(['income', 'expense']), Transaction.transaction_type.in_(['income', 'expense']),
Transaction.date >= since, Transaction.date >= since,
+55
View File
@@ -304,6 +304,61 @@ function ocrExistingReceipt(filename) {
}); });
} }
// ── Client-side form validation ───────────────────────────────────────────────
document.getElementById('txnForm').addEventListener('submit', function(e) {
let valid = true;
const desc = document.getElementById('field_description');
if (!desc.value.trim()) {
desc.classList.add('is-invalid');
if (!desc.nextElementSibling || !desc.nextElementSibling.classList.contains('invalid-feedback')) {
const err = document.createElement('div');
err.className = 'invalid-feedback';
err.textContent = 'Description is required.';
desc.after(err);
}
valid = false;
} else {
desc.classList.remove('is-invalid');
}
const amt = document.getElementById('field_amount');
const amtVal = parseFloat(amt.value);
const amtParent = amt.closest('.input-group') || amt;
if (!amt.value || isNaN(amtVal) || amtVal <= 0) {
amt.classList.add('is-invalid');
let errEl = amtParent.parentElement.querySelector('.amt-error');
if (!errEl) {
errEl = document.createElement('div');
errEl.className = 'text-danger mt-1 amt-error';
errEl.style.fontSize = '12px';
amtParent.after(errEl);
}
errEl.textContent = amtVal < 0 ? 'Amount must be positive.' : 'Amount is required.';
valid = false;
} else {
amt.classList.remove('is-invalid');
const errEl = amtParent.parentElement.querySelector('.amt-error');
if (errEl) errEl.remove();
}
const dt = document.getElementById('field_date');
if (!dt.value) {
dt.classList.add('is-invalid');
valid = false;
} else {
dt.classList.remove('is-invalid');
}
if (!valid) e.preventDefault();
});
// Clear validation state on input
['field_description', 'field_amount', 'field_date'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => el.classList.remove('is-invalid'));
});
// ── Auto-OCR when receipt is selected in edit form ──────────────────────────── // ── Auto-OCR when receipt is selected in edit form ────────────────────────────
function autoOcrOnUpload(input) { function autoOcrOnUpload(input) {
const file = input.files[0]; const file = input.files[0];