hudaakram commited on
Commit
4bb52ae
·
verified ·
1 Parent(s): 6e0b74c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +374 -404
app.py CHANGED
@@ -1,453 +1,423 @@
1
- # app.py
2
- """
3
- Flask Face-Gate + Chatbot demo
4
- Dependencies:
5
- pip install flask itsdangerous pillow python-multipart transformers torch gradio aiofiles
6
- (If you won't run ASR locally, you can skip installing transformers/torch and use an external ASR endpoint.)
7
-
8
- How to run:
9
- python app.py
10
- Then open http://127.0.0.1:5000/
11
- """
12
-
13
- import io
14
- import os
15
- import time
16
- import json
17
- import base64
18
- import secrets
19
- from typing import Tuple
20
- from datetime import datetime, timedelta
21
-
22
- from flask import Flask, request, redirect, make_response, send_file, jsonify, abort, url_for
23
- from itsdangerous import URLSafeTimedSerializer
24
- from PIL import Image
25
-
26
- # Optional: for sending real emails (if configured)
27
- import smtplib
28
- from email.message import EmailMessage
29
-
30
- # Optional: for local ASR or face model (uncomment if available)
31
- # from transformers import pipeline
32
-
33
- # ===== CONFIG =====
34
- PARTNER_URL = "https://your-partner-system.example.com/entry" # change
35
  TITLE = "Face Verify Gate"
36
  CAPTION = "Verify your identity to continue"
37
  BACKGROUND_GIF = "https://i.pinimg.com/originals/d9/09/57/d90957d7462b87ba8171fce62d2bf816.gif"
38
-
39
  SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
40
- COOKIE_NAME = "emmys_access"
41
- COOKIE_MAX_AGE = 60 * 60 * 2 # 2 hours
42
 
43
- # Optional SMTP (for real email delivery). If empty, notifications are stored/logged only.
44
- SMTP_HOST = os.environ.get("SMTP_HOST", "")
45
- SMTP_PORT = int(os.environ.get("SMTP_PORT", "587") or 0)
46
- SMTP_USER = os.environ.get("SMTP_USER", "")
47
- SMTP_PASS = os.environ.get("SMTP_PASS", "")
 
 
 
 
 
 
48
 
49
- # Demo admin email to receive alerts (if SMTP configured)
50
- ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "admin@example.com")
51
- # ===================
 
 
 
52
 
53
  app = Flask(__name__)
54
  app.secret_key = SECRET_KEY
55
- signer = URLSafeTimedSerializer(SECRET_KEY, salt="emmys-access")
56
-
57
- # In-memory stores (for demo)
58
- SENT_NOTIFICATIONS = [] # list of dicts {to, subject, body, when}
59
- ACCESS_LOGS = [] # records of attempts
60
- SENT_EMAIL_FILES_DIR = "/tmp/emmys_notifications"
61
- os.makedirs(SENT_EMAIL_FILES_DIR, exist_ok=True)
62
-
63
- # Uncomment & configure a local ASR model if desired
64
- # asr_pipeline = pipeline("automatic-speech-recognition", model="openai/whisper-small")
65
-
66
- # -------------------------
67
- # Hook: integrate your face model here.
68
- # face_verify should return (ok: bool, confidence: float, matched_username: str|None)
69
- # - ok True means we consider the image to match the user `name` (or the stored encoding for that name)
70
- # - If you have a DB of users, compare the incoming image to the stored profile of `name`.
71
- # For now this is a stub that accepts if the filename contains 'ok' or always fails for others.
72
- # Replace contents with your model invocation; the function receives raw image bytes in `image_bytes`.
73
- # -------------------------
74
- def face_verify(name: str, image_bytes: bytes) -> Tuple[bool, float, str]:
75
- """
76
- Replace this with your real recognition call.
77
- Example:
78
- - load image_bytes into your model
79
- - compute similarity to stored embedding for `name`
80
- - return (similarity >= threshold, similarity, matched_username)
81
- """
82
- # Demo heuristics (replace):
83
- lower = name.strip().lower()
84
- if not image_bytes:
85
- return False, 0.0, None
86
-
87
- # quick PIL check (ensures it's a valid image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  try:
89
- img = Image.open(io.BytesIO(image_bytes))
90
- img.verify()
91
  except Exception:
92
- return False, 0.0, None
93
-
94
- # Demo acceptance rule: if name contains 'grant' or 'ok' -> allow
95
- if "grant" in lower or "ok" in lower:
96
- return True, 0.98, name
97
- # If filename bytes happen to include ascii 'match' -> accept (just a demo hack)
98
- if b"match" in image_bytes[:1024].lower()[:1024]:
99
- return True, 0.92, name
100
- return False, 0.12, None
101
-
102
- # -------------------------
103
- # Notification helper: create a "fake" owner email and either send it via SMTP (if configured)
104
- # or store it to disk and in-memory list for viewing. This satisfies your request for "fake emails".
105
- # -------------------------
106
- def make_fake_owner_email(name: str) -> str:
107
- local = name.strip().lower().replace(" ", ".")
108
- if not local:
109
- local = "user"
110
- # If it's the same name used by real people, you should map to actual email in production.
111
- return f"{local}@example-portal.com"
112
-
113
- def notify_owner_of_attempt(target_name: str, attempt_info: dict):
114
- to_addr = make_fake_owner_email(target_name)
115
- subject = f"[ALERT] Unauthorized access attempt to {target_name}'s account"
116
- body = (
117
- f"Hello {target_name},\n\n"
118
- "We detected a failed access attempt to your Emmys portal profile.\n\n"
119
- "Details:\n"
120
- f"- Time: {attempt_info.get('when')}\n"
121
- f"- IP: {attempt_info.get('ip')}\n"
122
- f"- Provided name: {attempt_info.get('provided_name')}\n"
123
- f"- Confidence: {attempt_info.get('confidence')}\n\n"
124
- "If this wasn't you, please consider rotating your access credentials.\n\n"
125
- "— Emmys Portal Security Bot (simulated)"
126
- )
127
- record = {"to": to_addr, "subject": subject, "body": body, "when": attempt_info.get("when")}
128
- SENT_NOTIFICATIONS.append(record)
129
-
130
- # Save to file for demo/inspection
131
- ts = int(time.time() * 1000)
132
- fname = os.path.join(SENT_EMAIL_FILES_DIR, f"notif_{local_stub(target_name)}_{ts}.txt")
133
- with open(fname, "w", encoding="utf-8") as f:
134
- f.write(f"To: {to_addr}\nSubject: {subject}\n\n{body}")
135
-
136
- # If SMTP configured, attempt to actually send
137
- if SMTP_HOST and SMTP_USER and SMTP_PASS and SMTP_PORT:
138
- try:
139
- msg = EmailMessage()
140
- msg["Subject"] = subject
141
- msg["From"] = SMTP_USER
142
- msg["To"] = to_addr
143
- msg.set_content(body)
144
- with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
145
- server.starttls()
146
- server.login(SMTP_USER, SMTP_PASS)
147
- server.send_message(msg)
148
- record["sent_via_smtp"] = True
149
- except Exception as e:
150
- record["smtp_error"] = str(e)
151
-
152
- print(f"[NOTIFY] {to_addr} | saved: {fname}")
153
- return record
154
-
155
- def local_stub(name:str)->str:
156
- return name.strip().lower().replace(" ", "_") or "unknown"
157
-
158
- # -------------------------
159
- # Templates (login page and chat page)
160
- # -------------------------
161
- def login_page(status_msg=""):
162
- status_msg = (status_msg or "")
163
- return f"""<!doctype html>
164
  <html lang="en">
165
  <head>
166
- <meta charset="utf-8"/>
167
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
168
  <title>{TITLE}</title>
169
  <style>
170
- :root {{ --glass: rgba(10,16,24,0.55); --border: rgba(62,231,255,0.35); --accent: #3ee7ff; --accent2: #7bf5c8; --text: #e8f3ff; --muted: #a9c2d0; }}
171
- *{{box-sizing:border-box}} html,body{{height:100%;margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;color:var(--text);}}
172
- body{{background:#000 center/cover no-repeat fixed; background-image: url('{BACKGROUND_GIF}');}}
173
- .shade{{position:fixed;inset:0;background:radial-gradient(60% 60% at 50% 40%, rgba(0,0,32,0.10) 0%, rgba(0,0,0,0.65) 100%);}}
174
- .card{{position:relative;max-width:520px;margin:min(10vh,8rem) auto 0;padding:1.75rem 1.75rem 1.25rem;background:linear-gradient(180deg, rgba(10,16,24,0.72), var(--glass));backdrop-filter: blur(10px) saturate(130%);border:1px solid var(--border);border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,0.6), inset 0 0 40px rgba(62,231,255,0.12);}}
175
- h1{{margin:0 0 .25rem 0;font-weight:800;letter-spacing:.5px}} .caption{{margin:0 0 1rem 0;color:var(--muted)}}
176
- label{{display:block;margin:.6rem 0 .35rem;color:var(--muted);font-weight:600}}
177
- input[type="text"], input[type="file"]{{width:100%;padding:.7rem .85rem;border-radius:10px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);color:var(--text);outline:none}}
178
- button{{margin-top:1rem;padding:.8rem 1rem;width:100%;border-radius:10px;border:none;background:linear-gradient(90deg,var(--accent),var(--accent2));color:#0a0f18;font-weight:800;cursor:pointer}}
179
- #status{{min-height:1.25rem;margin-top:.6rem;color:#ffd166}} #status.err{{color:#ff7b7b}} #status.ok{{color:#7bf5c8}}
180
- .stub{{margin-top:.6rem;color:var(--muted);font-size:.9rem}} @media(max-width:520px){{.card{{margin:8vh 1rem 0}}}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  </style>
182
- </head><body><div class="shade"></div>
 
 
183
  <main class="card">
184
  <h1>{TITLE}</h1>
185
  <p class="caption">{CAPTION}</p>
 
186
  <form method="POST" action="/verify" enctype="multipart/form-data" onsubmit="onSubmit()">
187
  <label for="name">Name</label>
188
- <input id="name" name="name" type="text" placeholder="Enter your name" required />
 
189
  <label for="photo">Photo</label>
190
  <input id="photo" name="photo" type="file" accept="image/*" required />
 
191
  <button type="submit">Verify &amp; Enter</button>
192
  <p id="status" aria-live="polite">{status_msg}</p>
193
  </form>
194
- <p class="stub">This demo uses a face recognition model you will plug into <code>face_verify()</code>.</p>
195
- <p class="stub">If verification fails, the real profile owner will be notified (simulated).</p>
 
 
 
196
  </main>
197
  <script>
198
- function onSubmit(){ document.getElementById('status').textContent='Verifying…'; }
 
 
 
 
199
  </script>
200
- </body></html>
201
- """
202
-
203
- def chat_page(username: str):
204
- # Small chat UI with voice-to-text
205
- # The JS records audio, posts to /api/asr, fills input and sends to /api/chat
206
- username_escaped = username.replace('"', '\\"')
207
- return f"""<!doctype html>
208
- <html>
209
- <head>
210
- <meta charset="utf-8"/>
211
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
212
- <title>Emmys Chat — {username_escaped}</title>
213
- <style>
214
- body{{font-family:system-ui,Segoe UI,Roboto,Arial;background:#05060a;color:#fff;padding:1rem}}
215
- .container{{max-width:900px;margin:0 auto}}
216
- .header{{display:flex;align-items:center;justify-content:space-between}}
217
- .chatbox{{border-radius:12px;padding:1rem;background:linear-gradient(180deg,#081018,#0b1119);height:60vh;overflow:auto}}
218
- .row{{display:flex;gap:.5rem;margin-top:.6rem}}
219
- .msg{{
220
- padding:.6rem .8rem;border-radius:10px;max-width:75%;line-height:1.25rem
221
- }}
222
- .msg.me{{background:#e6fffa;color:#001;align-self:flex-end}}
223
- .msg.bot{{background:#0b3b2e;color:#bff;align-self:flex-start}}
224
- .input-area{{display:flex;gap:.5rem;margin-top:.6rem}}
225
- input[type="text"]{{flex:1;padding:.6rem;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:#fff}}
226
- button{{padding:.55rem .8rem;border-radius:8px;border:none;background:#3ee7ff;color:#002;font-weight:700;cursor:pointer}}
227
- .recording{{outline:2px solid #ffcb05}}
228
- .note{{color:#9fb; font-size:.9rem;margin-top:.6rem}}
229
- </style>
230
- </head><body>
231
- <div class="container">
232
- <div class="header"><h2>Emmys Assistant</h2><div>Signed in as <strong>{username_escaped}</strong></div></div>
233
- <div id="chat" class="chatbox" role="log" aria-live="polite"></div>
234
-
235
- <div class="input-area">
236
- <input id="text" placeholder="Type or press mic…" />
237
- <button id="mic">🎤</button>
238
- <button id="send">Send</button>
239
- </div>
240
- <div class="note">Tip: Speak, let the transcript appear, then press Send. You can also edit the transcript before sending.</div>
241
- <div style="margin-top:1rem">
242
- <button id="logout" style="background:#ff7b7b;color:#fff;padding:.6rem .9rem;border-radius:8px;border:none;cursor:pointer">Sign out</button>
243
- </div>
244
- </div>
245
-
246
- <script>
247
- const chat = document.getElementById('chat');
248
- const txt = document.getElementById('text');
249
- const micBtn = document.getElementById('mic');
250
- const sendBtn = document.getElementById('send');
251
- const logoutBtn = document.getElementById('logout');
252
-
253
- function append(msg, who='bot'){
254
- const d = document.createElement('div');
255
- d.className = 'row';
256
- const m = document.createElement('div');
257
- m.className = 'msg ' + (who==='me' ? 'me' : 'bot');
258
- m.textContent = msg;
259
- d.appendChild(m);
260
- chat.appendChild(d);
261
- chat.scrollTop = chat.scrollHeight;
262
- }
263
-
264
- // send text to chat endpoint
265
- async function sendText(text){
266
- append(text, 'me');
267
- txt.value='';
268
- const res = await fetch('/api/chat', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message: text})});
269
- const j = await res.json();
270
- append(j.reply || '(no reply)', 'bot');
271
- }
272
 
273
- sendBtn.addEventListener('click', ()=>{ if (txt.value.trim()) sendText(txt.value.trim()); });
274
-
275
- logoutBtn.addEventListener('click', async ()=>{
276
- await fetch('/logout', {method:'POST'});
277
- location.href = '/';
278
- });
279
-
280
- // Voice recording using MediaRecorder
281
- let mediaRecorder = null, audioChunks = [];
282
- micBtn.addEventListener('click', async ()=>{
283
- if (!mediaRecorder || mediaRecorder.state === 'inactive') {
284
- // start
285
- try {
286
- const stream = await navigator.mediaDevices.getUserMedia({audio:true});
287
- mediaRecorder = new MediaRecorder(stream);
288
- audioChunks = [];
289
- mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
290
- mediaRecorder.onstop = async ()=>{
291
- const blob = new Blob(audioChunks, {type:'audio/webm'});
292
- const fd = new FormData();
293
- fd.append('file', blob, 'record.webm');
294
- // show temporary
295
- micBtn.textContent = '⏳';
296
- const r = await fetch('/api/asr', {method:'POST', body: fd});
297
- const j = await r.json();
298
- micBtn.textContent = '🎤';
299
- if (j.text) {
300
- txt.value = j.text;
301
- } else {
302
- txt.value = '';
303
- alert('No transcript: ' + (j.error || 'Unknown'));
304
- }
305
- };
306
- mediaRecorder.start();
307
- micBtn.classList.add('recording');
308
- micBtn.textContent = '⏹️';
309
- } catch(e){
310
- alert('Microphone access denied or unavailable.');
311
- }
312
- } else {
313
- // stop
314
- mediaRecorder.stop();
315
- micBtn.classList.remove('recording');
316
- micBtn.textContent = '🎤';
317
- }
318
- });
319
- </script>
320
- </body></html>
321
- """
322
 
323
- # -------------------------
324
- # Routes
325
- # -------------------------
326
  @app.get("/")
327
  def index():
328
- return login_page("")
329
 
330
  @app.post("/verify")
331
  def verify():
332
  name = (request.form.get("name") or "").strip()
333
  file = request.files.get("photo")
334
- ip = request.remote_addr or "unknown"
335
 
336
  if not name or not file or not file.filename.strip():
337
- return login_page("Please enter your name and select a photo."), 400
338
-
339
- # read image bytes
340
- image_bytes = file.read()
341
- ok, confidence, matched_username = face_verify(name, image_bytes)
342
-
343
- # audit log
344
- attempt = {
345
- "when": datetime.utcnow().isoformat() + "Z",
346
- "ip": ip,
347
- "provided_name": name,
348
- "confidence": float(confidence),
349
- "ok": bool(ok)
350
- }
351
- ACCESS_LOGS.append(attempt)
352
-
353
- if ok:
354
- # create signed token cookie
355
- token = signer.dumps({"user": name, "ts": int(time.time())})
356
- resp = make_response(redirect("/chat"))
357
- resp.set_cookie(COOKIE_NAME, token, max_age=COOKIE_MAX_AGE, httponly=True, samesite="Lax")
358
- return resp
359
-
360
- # notify owner (simulated or real if SMTP configured)
361
- notif = notify_owner_of_attempt(name, attempt)
362
-
363
- msg = "Verification failed. Owner has been notified."
364
- return login_page(msg), 401
365
 
366
  @app.get("/chat")
 
367
  def chat():
368
- token = request.cookies.get(COOKIE_NAME)
369
- if not token:
370
- return redirect("/", 302)
371
- try:
372
- data = signer.loads(token, max_age=COOKIE_MAX_AGE)
373
- username = data.get("user", "Guest")
374
- except Exception:
375
- return redirect("/", 302)
376
- return chat_page(username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
- @app.post("/logout")
379
- def logout():
380
- resp = make_response(redirect("/"))
381
- resp.delete_cookie(COOKIE_NAME)
382
- return resp
 
 
383
 
384
- # Small endpoint to view sent notifications (demo only; in prod restrict access)
385
- @app.get("/_notifications")
386
- def view_notifications():
387
- # return the in-memory notifications and file list
388
- files = os.listdir(SENT_EMAIL_FILES_DIR)
389
- return jsonify({"sent_notifications": SENT_NOTIFICATIONS, "files": files})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
- # Simple chat endpoint: replace body with real LM call
392
  @app.post("/api/chat")
 
393
  def api_chat():
394
- data = request.get_json(force=True)
395
- message = (data.get("message") or "").strip()
396
- if not message:
397
- return jsonify({"reply": ""})
398
-
399
- # Demo reply generation: be creative & contextual
400
- # Replace this with an actual LM call (OpenAI, HF Inference, or your local model).
401
- reply = demo_chatbot_reply(message)
402
- return jsonify({"reply": reply})
403
-
404
- def demo_chatbot_reply(message: str) -> str:
405
- # Some creative, Emmys-themed behavior:
406
- lower = message.lower()
407
- if "nominee" in lower or "who" in lower:
408
- return "Looking through the nominees… I can fetch categories, trivia, or gossip. Try: 'Tell me nominees for Outstanding Drama.'"
409
- if "hello" in lower or "hi" in lower:
410
- return "Hello! I'm the Emmys Assistant — I can tell you schedules, nominees, or accept voice notes. What would you like?"
411
- # short "persona" reply
412
- return "I hear you. (demo reply) — In production, this response comes from your chosen language model."
413
-
414
- # ASR endpoint: accepts multipart/form-data with 'file'. Return {"text": "..."}
415
- @app.post("/api/asr")
416
- def api_asr():
417
- # If you have a local ASR pipeline uncomment and use asr_pipeline
418
- f = request.files.get("file")
419
- if not f:
420
- return jsonify({"error": "no file provided"}), 400
421
- audio_bytes = f.read()
422
- # placeholder: pretend we transcribed it
423
- # If you installed transformers+torch and configured asr_pipeline, you could do:
424
- # with open('/tmp/rec.webm','wb') as out: out.write(audio_bytes)
425
- # text = asr_pipeline('/tmp/rec.webm')["text"]
426
- # return jsonify({"text": text})
427
- # For demo, we'll use a naive heuristic
428
- demo_text = heuristic_transcribe(audio_bytes)
429
- return jsonify({"text": demo_text})
430
-
431
- def heuristic_transcribe(audio_bytes: bytes) -> str:
432
- # Very small heuristic: if audio contains keyword in bytes, return it.
433
- sample = audio_bytes[:1200].lower()
434
- if b"hello" in sample or b"hi" in sample:
435
- return "Hello Emmys, show me the nominees."
436
- if b"nominee" in sample:
437
- return "Who are the nominees for outstanding drama?"
438
- # fallback
439
- return "Demo transcript (no ASR model configured)."
440
-
441
- # -------------------------
442
- # Admin/simple debug endpoints
443
- # -------------------------
444
- @app.get("/_logs")
445
- def logs():
446
- # Danger: In production lock this down
447
- return jsonify({"access_logs": ACCESS_LOGS, "notifications": SENT_NOTIFICATIONS})
448
-
449
- # -------------------------
450
- # Run server
451
- # -------------------------
 
 
 
 
 
452
  if __name__ == "__main__":
453
- app.run(host="0.0.0.0", port=5000, debug=True)
 
 
1
+ from flask import Flask, request, redirect, make_response, session, jsonify, url_for
2
+ import html, json, os, io, time, datetime, functools
3
+ import numpy as np
4
+
5
+ # ======== CONFIG ========
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  TITLE = "Face Verify Gate"
7
  CAPTION = "Verify your identity to continue"
8
  BACKGROUND_GIF = "https://i.pinimg.com/originals/d9/09/57/d90957d7462b87ba8171fce62d2bf816.gif"
 
9
  SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
 
 
10
 
11
+ MODEL_DIR = os.environ.get("MODEL_DIR", "models") # where gallery/labels/threshold live
12
+ LABELS_PATH = os.path.join(MODEL_DIR, "labels.json") # ["Alice","Bob",...]
13
+ GALLERY_PATH = os.path.join(MODEL_DIR, "gallery_mean.npy") # shape (N,D), L2-normalized mean embeddings
14
+ THRESH_PATH = os.path.join(MODEL_DIR, "threshold.json") # {"cosine_threshold": 0.42}
15
+
16
+ # Demo mapping: name -> email to notify if a mismatch happens
17
+ REGISTERED = {
18
+ "alice": "alice@emmys.local",
19
+ "bob": "bob@emmys.local",
20
+ "banan": "banan@emmys.local", # add your legit users here (lowercase)
21
+ }
22
 
23
+ # Where to “send” fake emails (local store + admin UI)
24
+ OUTBOX_PATH = os.environ.get("OUTBOX_PATH", "outbox.json")
25
+
26
+ # Optional partner URL if you want to deep-link elsewhere after verify
27
+ PARTNER_URL = os.environ.get("PARTNER_URL", "") # leave blank to go to /chat
28
+ # ========================
29
 
30
  app = Flask(__name__)
31
  app.secret_key = SECRET_KEY
32
+
33
+ # ------- Load gallery + threshold once -------
34
+ def _load_assets():
35
+ if not (os.path.exists(LABELS_PATH) and os.path.exists(GALLERY_PATH) and os.path.exists(THRESH_PATH)):
36
+ raise RuntimeError(
37
+ f"Missing model assets. Expect {LABELS_PATH}, {GALLERY_PATH}, {THRESH_PATH} "
38
+ "generated by your training notebook."
39
+ )
40
+ labels = json.load(open(LABELS_PATH, "r", encoding="utf-8"))
41
+ G = np.load(GALLERY_PATH).astype("float32") # NxD, L2-normalized
42
+ thr = json.load(open(THRESH_PATH, "r", encoding="utf-8")).get("cosine_threshold", 0.35)
43
+ name_to_idx = {str(n).strip().lower(): i for i, n in enumerate(labels)}
44
+ return labels, G, float(thr), name_to_idx
45
+
46
+ LABELS, GALLERY, COSINE_THR, NAME2IDX = _load_assets()
47
+
48
+ # ------- InsightFace embedder (same family as in your notebook) -------
49
+ # (cosine of L2-normalized embeddings; choose biggest detected face)
50
+ try:
51
+ from insightface.app import FaceAnalysis
52
+ _fa = FaceAnalysis(name='buffalo_l', providers=['CPUExecutionProvider'])
53
+ _fa.prepare(ctx_id=-1, det_size=(640,640))
54
+ except Exception as e:
55
+ raise RuntimeError(
56
+ "Failed to initialize InsightFace. Install deps:\n"
57
+ " pip install insightface onnxruntime opencv-python pillow\n"
58
+ f"Original error: {e}"
59
+ )
60
+
61
+ def embed_bytes(img_bytes: bytes) -> np.ndarray | None:
62
+ import cv2
63
+ arr = np.frombuffer(img_bytes, dtype=np.uint8)
64
+ bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
65
+ if bgr is None:
66
+ return None
67
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
68
+ faces = _fa.get(rgb)
69
+ if not faces:
70
+ return None
71
+ f = max(faces, key=lambda z: (z.bbox[2]-z.bbox[0])*(z.bbox[3]-z.bbox[1]))
72
+ e = f.normed_embedding.astype("float32") # already L2-normalized
73
+ return e
74
+
75
+ # ------- Fake email outbox helpers -------
76
+ def _load_outbox():
77
+ if not os.path.exists(OUTBOX_PATH):
78
+ return []
79
  try:
80
+ return json.load(open(OUTBOX_PATH, "r", encoding="utf-8"))
 
81
  except Exception:
82
+ return []
83
+
84
+ def _save_outbox(messages):
85
+ with open(OUTBOX_PATH, "w", encoding="utf-8") as f:
86
+ json.dump(messages, f, indent=2, ensure_ascii=False)
87
+
88
+ def send_fake_email(to_addr: str, subject: str, body: str):
89
+ messages = _load_outbox()
90
+ messages.append({
91
+ "to": to_addr,
92
+ "subject": subject,
93
+ "body": body,
94
+ "ts": datetime.datetime.utcnow().isoformat() + "Z"
95
+ })
96
+ _save_outbox(messages)
97
+
98
+ # ------- UI (Jarvis glass look) -------
99
+ def page(status_msg=""):
100
+ status_msg = html.escape(status_msg or "")
101
+ html_page = f"""<!DOCTYPE html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  <html lang="en">
103
  <head>
104
+ <meta charset="UTF-8" />
105
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
106
  <title>{TITLE}</title>
107
  <style>
108
+ :root {{
109
+ --glass: rgba(10,16,24,0.55);
110
+ --border: rgba(62,231,255,0.35);
111
+ --accent: #3ee7ff;
112
+ --accent2: #7bf5c8;
113
+ --text: #e8f3ff;
114
+ --muted: #a9c2d0;
115
+ }}
116
+ * {{ box-sizing: border-box; }}
117
+ html, body {{
118
+ height: 100%; margin: 0;
119
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
120
+ color: var(--text);
121
+ }}
122
+ body {{
123
+ background: #000 center/cover no-repeat fixed;
124
+ background-image: url('{BACKGROUND_GIF}');
125
+ }}
126
+ .shade {{ position: fixed; inset:0; background: radial-gradient(60% 60% at 50% 40%, rgba(0,0,32,0.10) 0%, rgba(0,0,0,0.65) 100%); }}
127
+ .card {{
128
+ position: relative; max-width: 520px; margin: min(10vh, 8rem) auto 0;
129
+ padding: 1.75rem 1.75rem 1.25rem; background: linear-gradient(180deg, rgba(10,16,24,0.72), var(--glass));
130
+ backdrop-filter: blur(10px) saturate(130%); border: 1px solid var(--border); border-radius: 16px;
131
+ box-shadow: 0 20px 60px rgba(0,0,0,0.6), inset 0 0 40px rgba(62,231,255,0.12);
132
+ }}
133
+ h1 {{ margin: 0 0 .25rem 0; font-weight: 800; letter-spacing: .5px; }}
134
+ .caption {{ margin: 0 0 1rem 0; color: var(--muted); }}
135
+ label {{ display:block; margin:.6rem 0 .35rem; color: var(--muted); font-weight:600; }}
136
+ input[type="text"], input[type="file"] {{
137
+ width: 100%; padding: .7rem .85rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18);
138
+ background: rgba(255,255,255,0.08); color: var(--text); outline: none;
139
+ }}
140
+ input[type="text"]:focus {{ border-color: var(--accent); box-shadow: 0 0 0 3px rgba(62,231,255,0.25); }}
141
+ button {{
142
+ margin-top: 1rem; padding: .8rem 1rem; width: 100%; border-radius: 10px; border: none;
143
+ background: linear-gradient(90deg, var(--accent), var(--accent2)); color: #0a0f18; font-weight: 800; cursor: pointer;
144
+ }}
145
+ #status {{ min-height: 1.25rem; margin-top: .6rem; color: #ffd166; }}
146
+ #status.err {{ color: #ff7b7b; }} #status.ok {{ color: #7bf5c8; }}
147
+ .stub {{ margin-top: .6rem; color: var(--muted); font-size:.9rem; }}
148
+ .foot {{ margin-top: 1rem; text-align:center; font-size:.9rem; color: var(--muted); }}
149
+ .foot a {{ color: var(--accent); text-decoration: none; }}
150
+ @media (max-width: 520px) {{ .card {{ margin: 8vh 1rem 0; }} }}
151
  </style>
152
+ </head>
153
+ <body>
154
+ <div class="shade"></div>
155
  <main class="card">
156
  <h1>{TITLE}</h1>
157
  <p class="caption">{CAPTION}</p>
158
+
159
  <form method="POST" action="/verify" enctype="multipart/form-data" onsubmit="onSubmit()">
160
  <label for="name">Name</label>
161
+ <input id="name" name="name" type="text" placeholder="Type your registered name" required />
162
+
163
  <label for="photo">Photo</label>
164
  <input id="photo" name="photo" type="file" accept="image/*" required />
165
+
166
  <button type="submit">Verify &amp; Enter</button>
167
  <p id="status" aria-live="polite">{status_msg}</p>
168
  </form>
169
+
170
+ <p class="stub">Face & name are verified against a secure gallery. If a mismatch occurs, the rightful user is notified.</p>
171
+ <div class="foot">
172
+ <a href="/admin/outbox" target="_blank">Admin · Outbox</a>
173
+ </div>
174
  </main>
175
  <script>
176
+ function onSubmit() {{
177
+ const s = document.getElementById('status');
178
+ s.textContent = 'Verifying…';
179
+ s.className = '';
180
+ }}
181
  </script>
182
+ </body>
183
+ </html>"""
184
+ resp = make_response(html_page)
185
+ resp.headers["Content-Type"] = "text/html; charset=utf-8"
186
+ return resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
+ # ------- Small helpers -------
189
+ def norm_name(s: str) -> str:
190
+ return " ".join((s or "").strip().lower().split())
191
+
192
+ def require_login(fn):
193
+ @functools.wraps(fn)
194
+ def _inner(*args, **kwargs):
195
+ if not session.get("user"):
196
+ return redirect(url_for("index"))
197
+ return fn(*args, **kwargs)
198
+ return _inner
199
+
200
+ # ------- Verification logic -------
201
+ def verify_face_and_name(name: str, img_bytes: bytes):
202
+ uname = norm_name(name)
203
+ idx = NAME2IDX.get(uname)
204
+ if idx is None:
205
+ # name not in gallery at all
206
+ return {"ok": False, "reason": "name_not_found", "score": None, "uname": uname}
207
+
208
+ q = embed_bytes(img_bytes)
209
+ if q is None or not np.isfinite(q).all():
210
+ return {"ok": False, "reason": "no_face_or_bad_image", "score": None, "uname": uname}
211
+
212
+ # cosine for normalized vectors == dot product
213
+ score = float(np.dot(GALLERY[idx], q))
214
+ ok = score >= COSINE_THR
215
+ return {"ok": ok, "reason": None if ok else "below_threshold", "score": score, "uname": uname}
216
+
217
+ def notify_rightful_user_if_needed(typed_name: str, requester_ip: str, score: float | None, reason: str):
218
+ uname = norm_name(typed_name)
219
+ to_addr = REGISTERED.get(uname)
220
+ if not to_addr:
221
+ return
222
+ subj = f"[ALERT] Suspicious access attempt for '{uname}'"
223
+ body = (
224
+ f"Hello {uname},\n\n"
225
+ f"We detected a failed access attempt to your account.\n\n"
226
+ f"Details:\n"
227
+ f" Time (UTC): {datetime.datetime.utcnow().isoformat()}Z\n"
228
+ f" • From IP: {requester_ip}\n"
229
+ f" • Reason: {reason}\n"
230
+ f" • Similarity score: {score if score is not None else 'N/A'}\n\n"
231
+ f"If this was you and you’re having trouble, please re-try with a clearer image.\n"
232
+ f"If this wasn’t you, no action is required—your access remains protected.\n\n"
233
+ f"— EMMYS Security Bot"
234
+ )
235
+ send_fake_email(to_addr, subj, body)
 
236
 
237
+ # ------- Routes -------
 
 
238
  @app.get("/")
239
  def index():
240
+ return page("")
241
 
242
  @app.post("/verify")
243
  def verify():
244
  name = (request.form.get("name") or "").strip()
245
  file = request.files.get("photo")
 
246
 
247
  if not name or not file or not file.filename.strip():
248
+ return page("Please enter your name and select a photo."), 400
249
+
250
+ img_bytes = file.read()
251
+ result = verify_face_and_name(name, img_bytes)
252
+
253
+ if not result["ok"]:
254
+ # notify rightful user if we know an email for this name
255
+ notify_rightful_user_if_needed(
256
+ typed_name=name,
257
+ requester_ip=request.headers.get("X-Forwarded-For", request.remote_addr or "?"),
258
+ score=result.get("score"),
259
+ reason=result.get("reason") or "unknown"
260
+ )
261
+ msg = {
262
+ "name_not_found":"Name not found in gallery.",
263
+ "no_face_or_bad_image":"No face detected or unreadable image.",
264
+ "below_threshold":"Verification failed. (face/name mismatch)",
265
+ }.get(result.get("reason"), "Verification failed.")
266
+ return page(msg), 401
267
+
268
+ # Success: start session + go to partner URL or /chat
269
+ session["user"] = norm_name(name)
270
+ if PARTNER_URL:
271
+ return redirect(PARTNER_URL, code=302)
272
+ return redirect(url_for("chat"), code=302)
 
 
 
273
 
274
  @app.get("/chat")
275
+ @require_login
276
  def chat():
277
+ user = session.get("user")
278
+ html_page = f"""<!DOCTYPE html>
279
+ <html lang="en"><head>
280
+ <meta charset="UTF-8" />
281
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
282
+ <title>EMMYS · Jarvis Chat</title>
283
+ <style>
284
+ body {{
285
+ margin: 0; background: #0a1018; color: #e8f3ff; font-family: ui-sans-serif, system-ui;
286
+ background-image: radial-gradient(800px 400px at 20% 0%, rgba(62,231,255,.07), transparent 60%),
287
+ radial-gradient(800px 400px at 80% 0%, rgba(123,245,200,.07), transparent 60%);
288
+ }}
289
+ .shell {{ max-width: 900px; margin: 0 auto; padding: 24px; }}
290
+ .bar {{ display:flex; align-items:center; gap:12px; margin-bottom:16px; }}
291
+ .tag {{ padding:6px 10px; border:1px solid rgba(62,231,255,.35); border-radius:10px; font-weight:700; }}
292
+ .box {{ border:1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.03); border-radius:16px; padding:16px; height: 60vh; overflow:auto; }}
293
+ .msg {{ padding:10px 12px; margin:8px 0; border-radius:12px; max-width: 80%; }}
294
+ .me {{ background: rgba(62,231,255,.12); margin-left:auto; border:1px solid rgba(62,231,255,.25); }}
295
+ .ai {{ background: rgba(123,245,200,.10); border:1px solid rgba(123,245,200,.25); }}
296
+ form {{ display:flex; gap:8px; margin-top:12px; }}
297
+ input[type="text"] {{ flex:1; padding:12px; border-radius:12px; border:1px solid rgba(255,255,255,.2); background: rgba(255,255,255,.06); color:#e8f3ff; outline:none; }}
298
+ button {{ padding:12px 14px; border:none; border-radius:12px; background:linear-gradient(90deg,#3ee7ff,#7bf5c8); color:#0a1018; font-weight:800; cursor:pointer; }}
299
+ .top {{ display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }}
300
+ a.link {{ color:#3ee7ff; text-decoration:none; }}
301
+ </style>
302
+ </head>
303
+ <body>
304
+ <div class="shell">
305
+ <div class="top">
306
+ <div class="bar">
307
+ <div class="tag">JARVIS</div>
308
+ <div>Welcome, <strong>{html.escape(user)}</strong></div>
309
+ </div>
310
+ <div><a class="link" href="/logout">Logout</a></div>
311
+ </div>
312
 
313
+ <div id="box" class="box"></div>
314
+
315
+ <form id="chatForm">
316
+ <input id="msg" type="text" placeholder="Ask me anything about the EMMYS portal…" autofocus />
317
+ <button type="submit">Send</button>
318
+ </form>
319
+ </div>
320
 
321
+ <script>
322
+ const box = document.getElementById('box');
323
+ const form = document.getElementById('chatForm');
324
+ const msg = document.getElementById('msg');
325
+
326
+ const push = (who, text) => {{
327
+ const div = document.createElement('div');
328
+ div.className = 'msg ' + (who === 'me' ? 'me' : 'ai');
329
+ div.textContent = text;
330
+ box.appendChild(div);
331
+ box.scrollTop = box.scrollHeight;
332
+ }};
333
+
334
+ push('ai', "Systems online. How can I help, {html.escape(user)}?");
335
+
336
+ form.addEventListener('submit', async (e) => {{
337
+ e.preventDefault();
338
+ const t = msg.value.trim(); if(!t) return;
339
+ push('me', t);
340
+ msg.value = '';
341
+ const r = await fetch('/api/chat', {{
342
+ method:'POST',
343
+ headers: {{ 'Content-Type':'application/json' }},
344
+ body: JSON.stringify({{ message: t }})
345
+ }});
346
+ const j = await r.json();
347
+ push('ai', j.reply);
348
+ }});
349
+ </script>
350
+ </body></html>"""
351
+ resp = make_response(html_page)
352
+ resp.headers["Content-Type"] = "text/html; charset=utf-8"
353
+ return resp
354
 
 
355
  @app.post("/api/chat")
356
+ @require_login
357
  def api_chat():
358
+ data = request.get_json(silent=True) or {}
359
+ user_msg = (data.get("message") or "").strip()
360
+ u = session.get("user", "user")
361
+
362
+ # Minimal rule-based “assistant” (no external API)
363
+ reply = ""
364
+ lm = user_msg.lower()
365
+ if not user_msg:
366
+ reply = "Say something and I’ll help."
367
+ elif "help" in lm:
368
+ reply = "You can ask about access logs, last alerts, or say 'status'."
369
+ elif "status" in lm:
370
+ reply = f"Access control nominal. Threshold={COSINE_THR:.3f}. Gallery identities={len(LABELS)}."
371
+ elif "alerts" in lm or "outbox" in lm:
372
+ msgs = _load_outbox()
373
+ recent = [m for m in msgs if m.get("to") == REGISTERED.get(u)]
374
+ if not recent:
375
+ reply = "No recent alerts for your account."
376
+ else:
377
+ latest = recent[-1]
378
+ reply = f"Latest alert {latest['subject']} at {latest['ts']}."
379
+ else:
380
+ reply = f"Echo: {user_msg}"
381
+ return jsonify({"ok": True, "reply": reply})
382
+
383
+ @app.get("/admin/outbox")
384
+ def admin_outbox():
385
+ messages = _load_outbox()
386
+ rows = ""
387
+ for m in reversed(messages):
388
+ rows += f"""
389
+ <tr>
390
+ <td>{html.escape(m.get('ts',''))}</td>
391
+ <td>{html.escape(m.get('to',''))}</td>
392
+ <td>{html.escape(m.get('subject',''))}</td>
393
+ <td><pre style='white-space:pre-wrap;margin:0'>{html.escape(m.get('body',''))}</pre></td>
394
+ </tr>"""
395
+ html_page = f"""<!doctype html><html><head>
396
+ <meta charset="utf-8" />
397
+ <title>Outbox</title>
398
+ <style>
399
+ body {{ background:#0a1018; color:#e8f3ff; font-family: ui-sans-serif, system-ui; }}
400
+ table {{ width:100%; border-collapse: collapse; }}
401
+ th, td {{ border:1px solid rgba(255,255,255,.15); padding:8px; vertical-align: top; }}
402
+ th {{ background: rgba(62,231,255,.12); }}
403
+ a {{ color:#3ee7ff; }}
404
+ </style>
405
+ </head><body>
406
+ <h2>Fake Email Outbox</h2>
407
+ <p>Total: {len(messages)} &nbsp;|&nbsp; <a href="/">Back to Gate</a></p>
408
+ <table>
409
+ <tr><th>UTC Time</th><th>To</th><th>Subject</th><th>Body</th></tr>
410
+ {rows or "<tr><td colspan='4'>Empty</td></tr>"}
411
+ </table>
412
+ </body></html>"""
413
+ return html_page
414
+
415
+ @app.get("/logout")
416
+ def logout():
417
+ session.clear()
418
+ return redirect(url_for("index"))
419
+
420
+ # Dev entry
421
  if __name__ == "__main__":
422
+ # Tip: set FLASK_ENV=development for auto-reload
423
+ app.run(host="127.0.0.1", port=5000, debug=True)