CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
+213
-3
@@ -1229,22 +1229,30 @@ def enrich_apply(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'),
|
||||
fields = res['proposed']
|
||||
sources = res['sources']
|
||||
out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email)
|
||||
applied = out.get('applied') or {}
|
||||
# R4-A3: write to pgz_sport.sys_audit so the audit page sees enrichment events
|
||||
try:
|
||||
from audit_seal_router import audit_log as _audit_log
|
||||
if out.get('applied'):
|
||||
if applied:
|
||||
_audit_log(
|
||||
action='enrich.apply',
|
||||
target_type=kind,
|
||||
target_id=eid,
|
||||
payload={'applied': out.get('applied'),
|
||||
payload={'applied': applied,
|
||||
'sources': [s.get('url') for s in (sources or []) if isinstance(s, dict)]},
|
||||
user_id=x_user_id,
|
||||
user_email=x_user_email,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {'kind': kind, 'id': eid, **out}
|
||||
return {
|
||||
'status': 'success' if applied else 'no_changes',
|
||||
'kind': kind,
|
||||
'id': eid,
|
||||
'applied_count': len(applied),
|
||||
'applied_fields': list(applied.keys()),
|
||||
**out,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/enrich/log")
|
||||
@@ -1478,3 +1486,205 @@ def forensic_scan(req: dict = Body(...)):
|
||||
'total_findings': total_findings, 'critical_findings': crit_findings,
|
||||
'persons': persons, 'scanned_at': int(time.time())}
|
||||
|
||||
|
||||
|
||||
# ─── SB-3 — Bulk enrichment ─────────────────────────────────────────────
|
||||
_BULK_KEY_MAP = {
|
||||
'klub': ('pgz_sport.klubovi',
|
||||
('oib','sport','grad','predsjednik','tajnik','web','email','telefon',
|
||||
'sjediste','godina_osnutka','ciljevi','opis_djelatnosti')),
|
||||
'savez': ('pgz_sport.savezi',
|
||||
('oib','sport','predsjednik','tajnik','email','telefon','web',
|
||||
'adresa','godina_osnutka')),
|
||||
'sportas': ('pgz_sport.clanovi',
|
||||
('sport','profile_url','slika_url','hns_igrac_id','biografija',
|
||||
'datum_rodenja','mjesto_rodenja','broj_dresa')),
|
||||
}
|
||||
|
||||
|
||||
def _coverage_sql(prefix: str, keys: tuple[str, ...]) -> str:
|
||||
parts = [f"(CASE WHEN {prefix}{k} IS NOT NULL AND ({prefix}{k}::text) <> '' THEN 1 ELSE 0 END)"
|
||||
for k in keys]
|
||||
return f"((({' + '.join(parts)})::numeric * 100) / {len(keys)})"
|
||||
|
||||
|
||||
def _bulk_pick(kind: str, limit: int, coverage_max: int) -> list[int]:
|
||||
if kind not in _BULK_KEY_MAP:
|
||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||
table, keys = _BULK_KEY_MAP[kind]
|
||||
cov = _coverage_sql('', keys)
|
||||
extra_where = ''
|
||||
if kind == 'klub':
|
||||
extra_where = "AND aktivan = TRUE"
|
||||
elif kind == 'sportas':
|
||||
extra_where = "AND aktivan = TRUE"
|
||||
sql = (f"SELECT id FROM {table} "
|
||||
f"WHERE 1=1 {extra_where} "
|
||||
f"AND {cov} < %s "
|
||||
f"ORDER BY random() LIMIT %s")
|
||||
with _db() as c, c.cursor() as cur:
|
||||
cur.execute(sql, (coverage_max, limit))
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/enrich/bulk")
|
||||
def enrich_bulk(body: dict = Body(default=None),
|
||||
x_user_email: Optional[str] = Header(default=None),
|
||||
x_user_id: Optional[int] = Header(default=None)):
|
||||
"""Run preview+apply over N random under-enriched rows of one kind.
|
||||
|
||||
Body: {kind: 'klub'|'savez'|'sportas', limit: 50, coverage_max: 70}
|
||||
Returns aggregate stats. Synchronous (use polling, not SSE).
|
||||
"""
|
||||
body = body or {}
|
||||
kind = (body.get('kind') or '').strip().lower()
|
||||
if kind not in _BULK_KEY_MAP:
|
||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||
limit = max(1, min(int(body.get('limit') or 50), 200))
|
||||
coverage_max = max(0, min(int(body.get('coverage_max') or 70), 100))
|
||||
|
||||
ids = _bulk_pick(kind, limit, coverage_max)
|
||||
items: list[dict] = []
|
||||
fields_total = 0
|
||||
started = time.time()
|
||||
|
||||
for eid in ids:
|
||||
try:
|
||||
row = _load_row(kind, eid)
|
||||
if kind == 'klub': res = _propose_for_klub(row)
|
||||
elif kind == 'savez': res = _propose_for_savez(row)
|
||||
else: res = _propose_for_sportas(row)
|
||||
proposed = res.get('proposed') or {}
|
||||
srcs = res.get('sources') or []
|
||||
if not proposed:
|
||||
items.append({'id': eid, 'applied': 0, 'fields': []})
|
||||
continue
|
||||
out = _apply_to_db(kind, eid, proposed, srcs, x_user_email)
|
||||
applied = out.get('applied') or {}
|
||||
fields_total += len(applied)
|
||||
items.append({'id': eid, 'applied': len(applied), 'fields': list(applied.keys())})
|
||||
try:
|
||||
from audit_seal_router import audit_log as _audit_log
|
||||
if applied:
|
||||
_audit_log(action='enrich.bulk.apply',
|
||||
target_type=kind, target_id=eid,
|
||||
payload={'applied': applied},
|
||||
user_id=x_user_id, user_email=x_user_email)
|
||||
except Exception:
|
||||
pass
|
||||
except HTTPException as e:
|
||||
items.append({'id': eid, 'error': e.detail})
|
||||
except Exception as e:
|
||||
items.append({'id': eid, 'error': f'{type(e).__name__}: {e}'})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'kind': kind,
|
||||
'requested': limit,
|
||||
'processed': len(items),
|
||||
'fields_total': fields_total,
|
||||
'elapsed_s': round(time.time() - started, 1),
|
||||
'items': items,
|
||||
}
|
||||
|
||||
|
||||
# ─── SB-4 — Worker status / control ─────────────────────────────────────
|
||||
_REDIS_KEYS = {
|
||||
'heartbeat': 'cc:pgz-enricher:heartbeat',
|
||||
'pause': 'cc:pgz-enricher:pause',
|
||||
'run_now': 'cc:pgz-enricher:run_now',
|
||||
'last_cycle': 'cc:pgz-enricher:last_cycle',
|
||||
'confidence': 'cc:pgz-enricher:confidence',
|
||||
'fields_24h': 'cc:pgz-enricher:fields_24h',
|
||||
}
|
||||
|
||||
|
||||
def _redis_client():
|
||||
try:
|
||||
import redis
|
||||
except Exception:
|
||||
return None
|
||||
host = os.environ.get('REDIS_HOST', 'localhost')
|
||||
port = int(os.environ.get('REDIS_PORT', '6379'))
|
||||
pwd = (os.environ.get('REDIS_PASS') or '').strip().strip("'").strip('"') or None
|
||||
# Try with password first (prod); fall back to anonymous (dev box) on AUTH failure.
|
||||
for p in (pwd, None):
|
||||
try:
|
||||
r = redis.Redis(host=host, port=port, password=p,
|
||||
decode_responses=True, socket_connect_timeout=2)
|
||||
r.ping()
|
||||
return r
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/enrich/worker/status")
|
||||
def enrich_worker_status():
|
||||
r = _redis_client()
|
||||
out = {'available': bool(r)}
|
||||
if not r:
|
||||
return out
|
||||
try:
|
||||
hb = r.get(_REDIS_KEYS['heartbeat'])
|
||||
out['heartbeat'] = int(hb) if hb else None
|
||||
out['heartbeat_age_s'] = (int(time.time()) - int(hb)) if hb else None
|
||||
out['paused'] = (r.get(_REDIS_KEYS['pause']) or '0') == '1'
|
||||
out['run_now_pending'] = (r.get(_REDIS_KEYS['run_now']) or '0') == '1'
|
||||
last = r.get(_REDIS_KEYS['last_cycle'])
|
||||
if last:
|
||||
try: out['last_cycle'] = json.loads(last)
|
||||
except: out['last_cycle'] = last
|
||||
conf = r.get(_REDIS_KEYS['confidence'])
|
||||
out['confidence_threshold'] = float(conf) if conf else 0.7
|
||||
f24 = r.get(_REDIS_KEYS['fields_24h'])
|
||||
out['fields_24h'] = int(f24) if f24 and f24.isdigit() else 0
|
||||
except Exception as e:
|
||||
out['error'] = f'{type(e).__name__}: {e}'
|
||||
# Recent enrichment_log rows for live activity
|
||||
try:
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("""SELECT id, kind, target_id, source, fields_set, user_email, created_at
|
||||
FROM pgz_sport.enrichment_log
|
||||
ORDER BY id DESC LIMIT 25""")
|
||||
rows = []
|
||||
for rr in cur.fetchall():
|
||||
rr = dict(rr)
|
||||
if rr.get('created_at'): rr['created_at'] = rr['created_at'].isoformat()
|
||||
rows.append(rr)
|
||||
out['recent'] = rows
|
||||
except Exception:
|
||||
out['recent'] = []
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/enrich/worker/pause")
|
||||
def enrich_worker_pause(body: dict = Body(default=None)):
|
||||
body = body or {}
|
||||
pause = bool(body.get('paused', True))
|
||||
r = _redis_client()
|
||||
if not r: raise HTTPException(503, 'redis unavailable')
|
||||
r.set(_REDIS_KEYS['pause'], '1' if pause else '0')
|
||||
return {'status': 'success', 'paused': pause}
|
||||
|
||||
|
||||
@router.post("/enrich/worker/run-now")
|
||||
def enrich_worker_run_now():
|
||||
r = _redis_client()
|
||||
if not r: raise HTTPException(503, 'redis unavailable')
|
||||
r.set(_REDIS_KEYS['run_now'], '1')
|
||||
return {'status': 'success', 'queued': True}
|
||||
|
||||
|
||||
@router.post("/enrich/worker/confidence")
|
||||
def enrich_worker_confidence(body: dict = Body(...)):
|
||||
try:
|
||||
v = float(body.get('value'))
|
||||
except Exception:
|
||||
raise HTTPException(400, 'value must be number 0..1')
|
||||
if not (0.0 <= v <= 1.0):
|
||||
raise HTTPException(400, 'value out of range 0..1')
|
||||
r = _redis_client()
|
||||
if not r: raise HTTPException(503, 'redis unavailable')
|
||||
r.set(_REDIS_KEYS['confidence'], str(v))
|
||||
return {'status': 'success', 'confidence_threshold': v}
|
||||
|
||||
Reference in New Issue
Block a user