06/01 App enhanced
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user