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
+38 -35
View File
@@ -359,47 +359,50 @@ 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:
sig_header = request.headers.get('Teller-Signature', '') log.error('[teller] TELLER_WEBHOOK_SECRET not configured — rejecting webhook')
body = request.get_data() return jsonify({'error': 'Webhook verification not configured'}), 403
if not sig_header: sig_header = request.headers.get('Teller-Signature', '')
log.warning('[teller] missing Teller-Signature header') body = request.get_data()
return jsonify({'error': 'Missing signature'}), 401
# Parse header: t=<timestamp>,v1=<sig>,v1=<sig2>... if not sig_header:
parts = dict( log.warning('[teller] missing Teller-Signature header')
(p.split('=', 1) if '=' in p else (p, '')) return jsonify({'error': 'Missing signature'}), 401
for p in sig_header.split(',')
)
timestamp = parts.get('t', '')
# Collect all v1 signatures (may be multiple during key rotation)
signatures = [v for k, v in
[p.split('=', 1) for p in sig_header.split(',') if p.startswith('v1=')]
]
if not timestamp or not signatures: # Parse header: t=<timestamp>,v1=<sig>,v1=<sig2>...
log.warning('[teller] malformed Teller-Signature header') parts = dict(
return jsonify({'error': 'Invalid signature'}), 401 (p.split('=', 1) if '=' in p else (p, ''))
for p in sig_header.split(',')
)
timestamp = parts.get('t', '')
# Collect all v1 signatures (may be multiple during key rotation)
signatures = [v for k, v in
[p.split('=', 1) for p in sig_header.split(',') if p.startswith('v1=')]
]
# Reject replays older than 5 minutes if not timestamp or not signatures:
import time log.warning('[teller] malformed Teller-Signature header')
try: return jsonify({'error': 'Invalid signature'}), 401
if abs(time.time() - int(timestamp)) > 300:
log.warning('[teller] webhook replay attack detected')
return jsonify({'error': 'Timestamp too old'}), 401
except ValueError:
pass
# signed_message = timestamp + "." + raw_body # Reject replays older than 5 minutes
signed_message = f'{timestamp}.'.encode() + body import time
expected = hmac.new( try:
signing_secret.encode(), signed_message, hashlib.sha256 if abs(time.time() - int(timestamp)) > 300:
).hexdigest() log.warning('[teller] webhook replay attack detected')
return jsonify({'error': 'Timestamp too old'}), 401
except ValueError:
pass
if not any(hmac.compare_digest(expected, sig) for sig in signatures): # signed_message = timestamp + "." + raw_body
log.warning('[teller] webhook signature mismatch') signed_message = f'{timestamp}.'.encode() + body
return jsonify({'error': 'Invalid signature'}), 401 expected = hmac.new(
signing_secret.encode(), signed_message, hashlib.sha256
).hexdigest()
if not any(hmac.compare_digest(expected, sig) for sig in signatures):
log.warning('[teller] webhook signature mismatch')
return jsonify({'error': 'Invalid signature'}), 401
# Teller sends GET to verify the endpoint is reachable # Teller sends GET to verify the endpoint is reachable
if request.method == 'GET': if request.method == 'GET':
+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];