Spaces:
Running
Running
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| import requests | |
| import os | |
| import re | |
| import io | |
| import hashlib | |
| import datetime | |
| import base64 | |
| import time | |
| import torch | |
| import torch.nn as nn | |
| from PIL import Image | |
| from transformers import AutoTokenizer, AutoConfig, AutoModel, PreTrainedModel, pipeline | |
| app = Flask(__name__) | |
| CORS(app) | |
| HF_API_KEY = os.environ.get("HF_API_KEY", "") | |
| HF_TEXT_MODEL_URL = "https://router.huggingface.co/hf-inference/models/Hello-SimpleAI/chatgpt-detector-roberta" | |
| HF_TEXT_MODEL_URL_2 = "https://router.huggingface.co/hf-inference/models/openai-community/roberta-base-openai-detector" | |
| HF_IMAGE_MODEL_URL = "https://router.huggingface.co/hf-inference/models/prithivMLmods/deepfake-detector-model-v1" | |
| HF_AUDIO_MODEL_URL = "https://router.huggingface.co/hf-inference/models/Gustking/wav2vec2-large-xlsr-deepfake-audio-classification" | |
| HF_CAPTION_MODEL_URL = "https://router.huggingface.co/hf-inference/models/Salesforce/blip-image-captioning-large" | |
| SUPABASE_URL = os.environ.get("SUPABASE_URL", "") | |
| SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "") | |
| TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") | |
| TELEGRAM_API_URL = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}" | |
| # ── AI TEXT DETECTION MODEL (desklib, loaded locally inside the Space) ── | |
| # desklib/ai-text-detector-v1.01 — a deberta-v3-large detector that scores | |
| # near the top of the RAID benchmark. Much better on modern AI than the old | |
| # 2023 model. It uses a custom class (not the standard pipeline). Bigger model, | |
| # so first startup downloads ~1.7GB and each analysis takes a few seconds. | |
| TEXT_MODEL_NAME = "desklib/ai-text-detector-v1.01" | |
| class DesklibAIDetectionModel(PreTrainedModel): | |
| config_class = AutoConfig | |
| def __init__(self, config): | |
| super().__init__(config) | |
| self.model = AutoModel.from_config(config) | |
| self.classifier = nn.Linear(config.hidden_size, 1) | |
| self.post_init() | |
| # NOTE: we deliberately do NOT call init_weights()/post_init() here. | |
| # from_pretrained() loads the trained weights (including the classifier) | |
| # anyway, and calling init on some transformers versions throws | |
| # AttributeError: 'all_tied_weights_keys'. | |
| def forward(self, input_ids, attention_mask=None, labels=None): | |
| outputs = self.model(input_ids, attention_mask=attention_mask) | |
| last_hidden_state = outputs[0] | |
| mask = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float() | |
| summed = torch.sum(last_hidden_state * mask, dim=1) | |
| counts = torch.clamp(mask.sum(dim=1), min=1e-9) | |
| pooled = summed / counts | |
| logits = self.classifier(pooled) | |
| return {"logits": logits} | |
| try: | |
| _tokenizer = AutoTokenizer.from_pretrained(TEXT_MODEL_NAME) | |
| _text_model = DesklibAIDetectionModel.from_pretrained(TEXT_MODEL_NAME) | |
| _text_model.eval() | |
| print(f"✅ Text model loaded: {TEXT_MODEL_NAME}") | |
| except Exception as e: | |
| _tokenizer = None | |
| _text_model = None | |
| print(f"❌ Text model failed to load: {type(e).__name__}: {e}") | |
| # ── DEEPFAKE IMAGE MODEL (loaded locally inside the Space) ── | |
| # dima806/deepfake_vs_real_image_detection — a Vision Transformer for Real-vs-Fake | |
| # classification. Loaded locally (standard image-classification pipeline) instead | |
| # of the HF Inference API, which is what caused the old 500 errors. | |
| # NOTE: this model is trained on an older dataset and works best on human FACE | |
| # photos; it is less reliable on brand-new AI-generated images. | |
| IMAGE_MODEL_NAME = "dima806/deepfake_vs_real_image_detection" | |
| try: | |
| image_classifier = pipeline("image-classification", model=IMAGE_MODEL_NAME) | |
| print(f"✅ Image model loaded: {IMAGE_MODEL_NAME}") | |
| except Exception as e: | |
| image_classifier = None | |
| print(f"❌ Image model failed to load: {type(e).__name__}: {e}") | |
| def _ai_prob_for_chunk(chunk): | |
| enc = _tokenizer(chunk, truncation=True, max_length=512, return_tensors="pt") | |
| with torch.no_grad(): | |
| out = _text_model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]) | |
| prob = torch.sigmoid(out["logits"]).item() # probability that text is AI | |
| return prob | |
| def classify_text_local(text): | |
| """Runs desklib on the WHOLE text in chunks (not just the intro). | |
| Returns (human_pct, ai_pct, ok).""" | |
| if _text_model is None or _tokenizer is None: | |
| return None, None, False | |
| try: | |
| words = text.split() | |
| chunk_size = 350 | |
| chunks = [' '.join(words[i:i + chunk_size]) for i in range(0, len(words), chunk_size)] or [text] | |
| ai_scores = [] | |
| for chunk in chunks[:5]: # cap at 5 chunks to stay fast on CPU | |
| if not chunk.strip(): | |
| continue | |
| ai_scores.append(_ai_prob_for_chunk(chunk)) | |
| if not ai_scores: | |
| return None, None, False | |
| avg_ai = sum(ai_scores) / len(ai_scores) | |
| return round((1 - avg_ai) * 100), round(avg_ai * 100), True | |
| except Exception as e: | |
| print(f"Local text model error: {type(e).__name__}: {e}") | |
| return None, None, False | |
| def supabase_insert(table, data): | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return False, "Supabase not configured on server" | |
| try: | |
| url = f"{SUPABASE_URL}/rest/v1/{table}" | |
| headers = { | |
| "apikey": SUPABASE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_KEY}", | |
| "Content-Type": "application/json", | |
| "Prefer": "return=minimal" | |
| } | |
| response = requests.post(url, headers=headers, json=data, timeout=15) | |
| if response.status_code in (200, 201, 204): | |
| return True, None | |
| return False, f"Supabase error {response.status_code}: {response.text[:200]}" | |
| except Exception as e: | |
| return False, str(e) | |
| def supabase_select(table, filters=None): | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return None, "Supabase not configured on server" | |
| try: | |
| url = f"{SUPABASE_URL}/rest/v1/{table}" | |
| headers = { | |
| "apikey": SUPABASE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_KEY}", | |
| } | |
| response = requests.get(url, headers=headers, params=filters or {}, timeout=15) | |
| if response.status_code == 200: | |
| return response.json(), None | |
| return None, f"Supabase error {response.status_code}: {response.text[:200]}" | |
| except Exception as e: | |
| return None, str(e) | |
| def detect_with_hf(text): | |
| """Backed by the locally-loaded model now (name kept so other routes and | |
| the Telegram bot keep working without changes).""" | |
| return classify_text_local(text) | |
| def detect_with_hf_v2(text): | |
| """Kept for reference. No longer used by the ensemble (we run a single | |
| local model now).""" | |
| return classify_text_local(text) | |
| def ensemble_detect_text(text): | |
| """Runs the local AI-text model. Falls back to the heuristic ONLY if the | |
| model failed to load at startup.""" | |
| human, ai, ok = classify_text_local(text) | |
| if ok: | |
| return human, ai, True, TEXT_MODEL_NAME | |
| trust, ai_prob = fallback_analyze(text) | |
| return trust, ai_prob, False, "heuristic-fallback" | |
| def fallback_analyze(text): | |
| words = text.split() | |
| sentences = [s.strip() for s in re.split(r'[.!?]+', text) if len(s.strip()) > 4] | |
| transitions = ['furthermore','additionally','moreover','however','consequently','nevertheless','therefore','in conclusion','in summary','it is important','it should be noted','as a result','on the other hand','notably','significantly'] | |
| lc = text.lower() | |
| trans_score = min(sum(1 for t in transitions if t in lc) * 8, 32) | |
| avg_len = len(words) / max(len(sentences), 1) | |
| sent_score = 30 if 14 < avg_len < 28 else 0 | |
| human_punct = len(re.findall(r'[—–…\'\'""\(\)]', text)) | |
| punct_score = 20 if human_punct < 2 else 0 | |
| unique = set(w.lower().strip('.,!?') for w in words) | |
| rich_score = 10 if len(unique)/max(len(words),1) < 0.55 else 0 | |
| starters = [' '.join(s.split()[:2]).lower() for s in sentences] | |
| rep_score = 15 if len(set(starters))/max(len(starters),1) < 0.7 else 0 | |
| ai_score = min(trans_score + sent_score + punct_score + rich_score + rep_score, 97) | |
| return max(100 - ai_score, 3), ai_score | |
| AI_BUZZWORDS = [ | |
| 'delve', 'tapestry', 'testament', 'boasts', 'meticulous', 'meticulously', | |
| 'underscore', 'underscores', 'leverage', 'leveraging', 'robust', 'seamless', | |
| 'seamlessly', 'realm', 'landscape', 'navigate', 'navigating', 'foster', | |
| 'fostering', 'intricate', 'myriad', 'paramount', 'unprecedented', 'pivotal', | |
| 'holistic', 'synergy', 'cutting-edge', 'game-changer', 'game changer', | |
| 'in today\'s world', 'in the realm of', 'it is important to note', | |
| 'it is worth noting', 'plays a crucial role', 'plays a significant role', | |
| 'a testament to', 'stands as a', 'in conclusion', 'furthermore', 'moreover', | |
| 'nevertheless', 'notably', 'boundless', 'unwavering', 'multifaceted', | |
| 'ever-evolving', 'ever-changing', 'crucial role', 'vibrant tapestry' | |
| ] | |
| def extract_buzzwords(text): | |
| lc = text.lower() | |
| found = [] | |
| for word in AI_BUZZWORDS: | |
| pattern = r'\b' + re.escape(word) + r'\b' | |
| if re.search(pattern, lc): | |
| found.append(word) | |
| return found | |
| def build_signals(trust_score, ai_probability, used_hf): | |
| signals = [] | |
| if used_hf: | |
| if ai_probability > 70: | |
| signals.append({"type":"positive","icon":"⚡","title":"AI Detected — High Confidence","desc":f"Detection model found strong AI patterns. AI probability: {ai_probability}%."}) | |
| elif ai_probability > 40: | |
| signals.append({"type":"positive","icon":"⚡","title":"Mixed Signals Detected","desc":f"Both AI and human patterns found. AI probability: {ai_probability}%."}) | |
| else: | |
| signals.append({"type":"negative","icon":"✓","title":"Human Writing Detected","desc":f"Strong human writing patterns found. AI probability: {ai_probability}%."}) | |
| signals.append({"type":"negative" if trust_score > 60 else "positive","icon":"🤖","title":f"Powered by: {TEXT_MODEL_NAME}","desc":"Open-source ML model trained to detect AI-generated text."}) | |
| else: | |
| signals.append({"type":"positive" if ai_probability > 50 else "negative","icon":"⚡","title":"Heuristic Analysis","desc":"Pattern-based detection. AI model warming up — try again in 20 seconds."}) | |
| return signals | |
| def detect_image_deepfake(image_data): | |
| """Runs the locally-loaded ViT deepfake detector. No more HF Inference API, | |
| so no more 500s from a dead endpoint.""" | |
| if image_classifier is None: | |
| return {"success":False,"error":"Image model not loaded on server. Try again shortly.","deepfake_probability":0,"real_probability":0,"verdict":"UNKNOWN","confidence":0,"model":"error"} | |
| try: | |
| img = Image.open(io.BytesIO(image_data)).convert("RGB") | |
| results = image_classifier(img) # list of {label, score} | |
| fake_score = 0.0 | |
| real_score = 0.0 | |
| for item in results: | |
| label = str(item.get('label', '')).lower() | |
| score = item.get('score', 0) | |
| if label in ['fake', 'deepfake', 'label_1', 'artificial']: | |
| fake_score = score | |
| elif label in ['real', 'genuine', 'label_0', 'authentic']: | |
| real_score = score | |
| if fake_score == 0 and real_score == 0 and len(results) >= 2: | |
| real_score = results[0]['score'] | |
| fake_score = results[1]['score'] | |
| deepfake_prob = round(fake_score * 100) | |
| real_prob = round(real_score * 100) | |
| return {"success":True,"deepfake_probability":deepfake_prob,"real_probability":real_prob,"verdict":"DEEPFAKE" if deepfake_prob > 50 else "AUTHENTIC","confidence":max(deepfake_prob, real_prob),"model":IMAGE_MODEL_NAME} | |
| except Exception as e: | |
| print(f"Image detection error: {type(e).__name__}: {e}") | |
| return {"success":False,"error":"Could not analyze this image. Try a clear JPG/PNG.","deepfake_probability":0,"real_probability":0,"verdict":"UNKNOWN","confidence":0,"model":"error"} | |
| def detect_voice_deepfake(audio_data, content_type): | |
| try: | |
| headers = {"Authorization": f"Bearer {HF_API_KEY}", "Content-Type": content_type} | |
| response = requests.post(HF_AUDIO_MODEL_URL, headers=headers, data=audio_data, timeout=60) | |
| print(f"Audio HF Status: {response.status_code}, Response: {response.text[:300]}") | |
| result = response.json() | |
| if isinstance(result, list) and len(result) > 0: | |
| scores = result | |
| fake_score = 0 | |
| real_score = 0 | |
| for item in scores: | |
| label = item.get('label', '').lower() | |
| score = item.get('score', 0) | |
| if label in ['fake', 'spoof', 'synthetic', 'ai', 'label_1']: | |
| fake_score = score | |
| elif label in ['real', 'bonafide', 'genuine', 'human', 'label_0']: | |
| real_score = score | |
| if fake_score == 0 and real_score == 0 and len(scores) >= 2: | |
| real_score = scores[0]['score'] | |
| fake_score = scores[1]['score'] | |
| fake_prob = round(fake_score * 100) | |
| real_prob = round(real_score * 100) | |
| return {"success":True,"fake_probability":fake_prob,"real_probability":real_prob,"verdict":"AI VOICE / CLONED" if fake_prob > 50 else "LIKELY REAL VOICE","confidence":max(fake_prob, real_prob),"model":"wav2vec2-deepfake-audio-classification"} | |
| except Exception as e: | |
| print(f"Audio detection error: {type(e).__name__}: {e}") | |
| return {"success":False,"error":"Model loading. Please try again in 30 seconds.","fake_probability":0,"real_probability":0,"verdict":"UNKNOWN","confidence":0,"model":"error"} | |
| def generate_prompt_from_image(image_data, content_type): | |
| try: | |
| headers = {"Authorization": f"Bearer {HF_API_KEY}", "Content-Type": content_type} | |
| response = requests.post(HF_CAPTION_MODEL_URL, headers=headers, data=image_data, timeout=60) | |
| print(f"Caption HF Status: {response.status_code}, Response: {response.text[:300]}") | |
| result = response.json() | |
| if isinstance(result, list) and len(result) > 0: | |
| caption = result[0].get('generated_text', '').strip() | |
| if caption: | |
| # Turn the plain caption into a more "prompt-style" phrase | |
| prompt_style = f"A highly detailed, realistic image of {caption}, cinematic lighting, high resolution, professional photography" | |
| return {"success": True, "caption": caption, "suggested_prompt": prompt_style} | |
| except Exception as e: | |
| print(f"Caption error: {type(e).__name__}: {e}") | |
| return {"success": False, "error": "Model loading. Please try again in 30 seconds."} | |
| import subprocess | |
| import tempfile | |
| from argon2 import PasswordHasher | |
| ph = PasswordHasher() | |
| def create_blockchain_timestamp(content_hash_hex): | |
| """Creates a real OpenTimestamps proof by calling the official `ots` | |
| CLI tool (installed via the opentimestamps-client pip package). This | |
| submits the hash to public OpenTimestamps calendar servers, which will | |
| anchor it into the Bitcoin blockchain within a few hours. Returns the | |
| base64-encoded .ots proof file, or None if it fails (fails gracefully — | |
| the certificate is still valid without it, just without blockchain proof).""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| digest_path = os.path.join(tmpdir, "digest.bin") | |
| with open(digest_path, "wb") as f: | |
| f.write(bytes.fromhex(content_hash_hex)) | |
| result = subprocess.run( | |
| ["ots", "stamp", digest_path], | |
| capture_output=True, timeout=30 | |
| ) | |
| ots_path = digest_path + ".ots" | |
| if os.path.exists(ots_path): | |
| with open(ots_path, "rb") as f: | |
| proof_bytes = f.read() | |
| return base64.b64encode(proof_bytes).decode('utf-8') | |
| print(f"OTS stamp stderr: {result.stderr.decode()[:300]}") | |
| except Exception as e: | |
| print(f"Blockchain timestamp error: {type(e).__name__}: {e}") | |
| return None | |
| def check_blockchain_timestamp(digest_hex, proof_b64): | |
| """Checks whether an OTS proof has been confirmed on the Bitcoin | |
| blockchain yet (takes a few hours after creation). Returns a status | |
| string: 'confirmed', 'pending', or 'unknown'.""" | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| digest_path = os.path.join(tmpdir, "digest.bin") | |
| ots_path = digest_path + ".ots" | |
| with open(digest_path, "wb") as f: | |
| f.write(bytes.fromhex(digest_hex)) | |
| with open(ots_path, "wb") as f: | |
| f.write(base64.b64decode(proof_b64)) | |
| result = subprocess.run( | |
| ["ots", "verify", ots_path], capture_output=True, timeout=30 | |
| ) | |
| output = (result.stdout.decode() + result.stderr.decode()).lower() | |
| if "bitcoin block" in output or "attests" in output: | |
| return "confirmed" | |
| elif "pending" in output: | |
| return "pending" | |
| except Exception as e: | |
| print(f"OTS verify error: {type(e).__name__}: {e}") | |
| return "unknown" | |
| def generate_cert_id(text, trust_score): | |
| raw = f"{text[:100]}{trust_score}{datetime.datetime.utcnow().isoformat()}" | |
| h = hashlib.sha256(raw.encode()).hexdigest()[:16].upper() | |
| return f"AXT-{h[:4]}-{h[4:8]}-{h[8:12]}" | |
| def extract_writing_style(text): | |
| words = text.split() | |
| sentences = [s.strip() for s in re.split(r'[.!?]+', text) if len(s.strip()) > 4] | |
| unique_words = set(w.lower().strip('.,!?;"\'') for w in words) | |
| return { | |
| "vocab_richness": round((len(unique_words)/max(len(words),1))*100), | |
| "avg_sentence_length": round(len(words)/max(len(sentences),1)), | |
| "punctuation_variety": len(re.findall(r'[—–…\'\'""\(\)!?;:]', text)), | |
| "avg_word_length": round(sum(len(w) for w in words)/max(len(words),1), 1), | |
| "word_count": len(words), | |
| "sentence_count": len(sentences) | |
| } | |
| # ── ORIGINAL ROUTES (kept for backward compatibility) ── | |
| def analyze(): | |
| data = request.get_json() | |
| if not data or 'text' not in data: | |
| return jsonify({"error": "No text provided"}), 400 | |
| text = data['text'].strip() | |
| if len(text) < 30: | |
| return jsonify({"error": "Text too short. Minimum 30 characters."}), 400 | |
| trust_score, ai_probability, used_hf = detect_with_hf(text) | |
| if trust_score is None: | |
| trust_score, ai_probability = fallback_analyze(text) | |
| used_hf = False | |
| return jsonify({"trust":trust_score,"ai_probability":ai_probability,"signals":build_signals(trust_score,ai_probability,used_hf),"model":TEXT_MODEL_NAME if used_hf else "heuristic-fallback"}) | |
| def analyze_image(): | |
| try: | |
| if 'image' in request.files: | |
| image_data = request.files['image'].read() | |
| elif request.is_json: | |
| data = request.get_json() | |
| if 'image_base64' not in data: | |
| return jsonify({"error": "No image provided"}), 400 | |
| b64 = data['image_base64'] | |
| if ',' in b64: | |
| b64 = b64.split(',')[1] | |
| image_data = base64.b64decode(b64) | |
| else: | |
| return jsonify({"error": "No image provided"}), 400 | |
| if len(image_data) > 5 * 1024 * 1024: | |
| return jsonify({"error": "Image too large. Maximum 5MB."}), 400 | |
| return jsonify(detect_image_deepfake(image_data)) | |
| except Exception as e: | |
| print(f"Image route error: {e}") | |
| return jsonify({"error": "Image processing failed."}), 500 | |
| def generate_certificate(): | |
| data = request.get_json() | |
| if not data or 'text' not in data: | |
| return jsonify({"error": "No text provided"}), 400 | |
| text = data.get('text','').strip() | |
| author_name = data.get('author_name','Anonymous').strip() | |
| document_title = data.get('document_title','Untitled Document').strip() | |
| if len(text) < 30: | |
| return jsonify({"error": "Text too short."}), 400 | |
| trust_score, ai_probability, used_hf = detect_with_hf(text) | |
| if trust_score is None: | |
| trust_score, ai_probability = fallback_analyze(text) | |
| used_hf = False | |
| if trust_score < 60: | |
| return jsonify({"error":"Certificate cannot be issued","reason":f"Trust score too low ({trust_score}/100).","trust_score":trust_score,"ai_probability":ai_probability}), 422 | |
| cert_id = generate_cert_id(text, trust_score) | |
| timestamp = datetime.datetime.utcnow() | |
| style = extract_writing_style(text) | |
| content_hash = hashlib.sha256(text.encode()).hexdigest()[:32].upper() | |
| return jsonify({"certificate_id":cert_id,"status":"VERIFIED","issued_at":timestamp.strftime("%Y-%m-%d %H:%M:%S UTC"),"issued_date":timestamp.strftime("%B %d, %Y"),"author_name":author_name,"document_title":document_title,"trust_score":trust_score,"ai_probability":ai_probability,"human_probability":100-ai_probability,"content_hash":content_hash,"word_count":style["word_count"],"writing_style":style,"verdict":"HUMAN AUTHORED","model_used":TEXT_MODEL_NAME if used_hf else "heuristic-fallback","verify_url":f"https://aethelx.com/verify.html?id={cert_id}","issuer":"AETHELX AI Trust Infrastructure","version":"1.0"}) | |
| # ── ROUTES matching the frontend tool pages ── | |
| def detect_text(): | |
| data = request.get_json() | |
| if not data or 'text' not in data: | |
| return jsonify({"error": "No text provided"}), 400 | |
| text = data['text'].strip() | |
| if len(text) < 30: | |
| return jsonify({"error": "Text too short. Minimum 30 characters."}), 400 | |
| trust_score, ai_probability, used_hf, model_note = ensemble_detect_text(text) | |
| buzzwords = extract_buzzwords(text) | |
| # Short text is unreliable for ANY detector — warn the user clearly so a | |
| # false positive can't be used to wrongly accuse someone. | |
| word_count = len(text.split()) | |
| reliable = word_count >= 50 | |
| if not reliable: | |
| details = (f"⚠️ Short text ({word_count} words) — detection is unreliable below " | |
| f"~50 words. Paste a longer passage for a meaningful result. " | |
| f"(Model: {model_note})") | |
| else: | |
| details = f"Analyzed with {model_note}" if used_hf else "Heuristic analysis (models warming up)" | |
| return jsonify({ | |
| "ai_probability": ai_probability / 100, | |
| "trust_score": trust_score, | |
| "reliable": reliable, | |
| "word_count": word_count, | |
| "details": details, | |
| "buzzwords_found": buzzwords | |
| }) | |
| def detect_image(): | |
| try: | |
| if 'image' in request.files: | |
| image_data = request.files['image'].read() | |
| elif request.is_json: | |
| data = request.get_json() | |
| if 'image_base64' not in data: | |
| return jsonify({"error": "No image provided"}), 400 | |
| b64 = data['image_base64'] | |
| if ',' in b64: | |
| b64 = b64.split(',')[1] | |
| image_data = base64.b64decode(b64) | |
| else: | |
| return jsonify({"error": "No image provided"}), 400 | |
| if len(image_data) > 5 * 1024 * 1024: | |
| return jsonify({"error": "Image too large. Maximum 5MB."}), 400 | |
| result = detect_image_deepfake(image_data) | |
| if not result.get("success"): | |
| return jsonify({"error": result.get("error", "Detection failed")}), 500 | |
| return jsonify({ | |
| "label": result["verdict"], | |
| "confidence": result["confidence"] / 100, | |
| "deepfake_probability": result["deepfake_probability"], | |
| "real_probability": result["real_probability"], | |
| "model": result["model"] | |
| }) | |
| except Exception as e: | |
| print(f"Image route error: {e}") | |
| return jsonify({"error": "Image processing failed."}), 500 | |
| # ── NEW: VOICE CLONING DETECTOR ── | |
| def detect_audio(): | |
| try: | |
| if 'audio' not in request.files: | |
| return jsonify({"error": "No audio file provided"}), 400 | |
| audio_file = request.files['audio'] | |
| audio_data = audio_file.read() | |
| if len(audio_data) > 10 * 1024 * 1024: | |
| return jsonify({"error": "Audio file too large. Maximum 10MB."}), 400 | |
| if len(audio_data) < 100: | |
| return jsonify({"error": "Audio file seems empty or corrupted."}), 400 | |
| filename = (audio_file.filename or '').lower() | |
| if filename.endswith('.mp3'): | |
| content_type = 'audio/mpeg' | |
| elif filename.endswith('.ogg'): | |
| content_type = 'audio/ogg' | |
| elif filename.endswith('.m4a'): | |
| content_type = 'audio/mp4' | |
| else: | |
| content_type = 'audio/wav' | |
| result = detect_voice_deepfake(audio_data, content_type) | |
| if not result.get("success"): | |
| return jsonify({"error": result.get("error", "Detection failed")}), 500 | |
| return jsonify({ | |
| "label": result["verdict"], | |
| "confidence": result["confidence"] / 100, | |
| "fake_probability": result["fake_probability"] | |
| }) | |
| except Exception as e: | |
| print(f"Audio route error: {e}") | |
| return jsonify({"error": "Audio processing failed."}), 500 | |
| # ── NEW: AI PROMPT REVERSE ENGINEERING ── | |
| def reverse_prompt(): | |
| try: | |
| if 'image' not in request.files: | |
| return jsonify({"error": "No image provided"}), 400 | |
| image_file = request.files['image'] | |
| image_data = image_file.read() | |
| if len(image_data) > 5 * 1024 * 1024: | |
| return jsonify({"error": "Image too large. Maximum 5MB."}), 400 | |
| filename = (image_file.filename or '').lower() | |
| content_type = 'image/png' if filename.endswith('.png') else 'image/jpeg' | |
| result = generate_prompt_from_image(image_data, content_type) | |
| if not result.get("success"): | |
| return jsonify({"error": result.get("error", "Could not generate prompt")}), 500 | |
| return jsonify({ | |
| "caption": result["caption"], | |
| "suggested_prompt": result["suggested_prompt"] | |
| }) | |
| except Exception as e: | |
| print(f"Reverse prompt route error: {e}") | |
| return jsonify({"error": "Image processing failed."}), 500 | |
| def generate_certificate_v2(): | |
| data = request.get_json() | |
| if not data: | |
| return jsonify({"error": "No data provided"}), 400 | |
| text = data.get('text', '').strip() | |
| author_name = data.get('name', 'Anonymous').strip() | |
| document_title = data.get('title', 'Untitled Document').strip() | |
| if len(text) < 30: | |
| return jsonify({"error": "Text too short."}), 400 | |
| trust_score, ai_probability, used_hf, model_note = ensemble_detect_text(text) | |
| cert_id = generate_cert_id(text, trust_score) | |
| content_hash = hashlib.sha256(text.encode()).hexdigest()[:12].upper() | |
| fingerprint = f"FP-{content_hash}" | |
| full_hash = hashlib.sha256(text.encode()).hexdigest() | |
| ots_proof = create_blockchain_timestamp(full_hash) | |
| ots_status = "pending" if ots_proof else "unavailable" | |
| ok, err = supabase_insert("certificates", { | |
| "cert_id": cert_id, | |
| "author_name": author_name, | |
| "document_title": document_title, | |
| "trust_score": trust_score, | |
| "ai_probability": ai_probability, | |
| "fingerprint": fingerprint, | |
| "full_hash": full_hash, | |
| "ots_proof": ots_proof, | |
| "ots_status": ots_status | |
| }) | |
| if not ok: | |
| print(f"Certificate save error: {err}") | |
| return jsonify({ | |
| "cert_id": cert_id, | |
| "fingerprint": fingerprint, | |
| "trust_score": trust_score, | |
| "ai_probability": ai_probability, | |
| "author_name": author_name, | |
| "document_title": document_title, | |
| "verify_url": f"https://aethelx.com/verify.html?id={cert_id}", | |
| "blockchain_status": ots_status | |
| }) | |
| def check_blockchain_status(): | |
| cert_id = request.args.get('id', '').strip() | |
| if not cert_id: | |
| return jsonify({"error": "No certificate ID provided"}), 400 | |
| rows, err = supabase_select("certificates", {"cert_id": f"eq.{cert_id}"}) | |
| if err or not rows: | |
| return jsonify({"error": "Certificate not found"}), 404 | |
| cert = rows[0] | |
| if not cert.get("full_hash") or not cert.get("ots_proof"): | |
| return jsonify({"status": "unavailable", "message": "No blockchain proof was created for this certificate."}) | |
| status = check_blockchain_timestamp(cert["full_hash"], cert["ots_proof"]) | |
| return jsonify({"status": status}) | |
| def verify_certificate(): | |
| cert_id = request.args.get('id', '').strip() | |
| if not cert_id: | |
| return jsonify({"error": "No certificate ID provided"}), 400 | |
| rows, err = supabase_select("certificates", {"cert_id": f"eq.{cert_id}"}) | |
| if err: | |
| print(f"Verify lookup error: {err}") | |
| return jsonify({"error": "Could not look up certificate"}), 500 | |
| if not rows: | |
| return jsonify({"error": "Certificate not found"}), 404 | |
| return jsonify(rows[0]) | |
| # ── WAITLIST + SIGNUP (saved to Supabase) ── | |
| def join_waitlist(): | |
| data = request.get_json() | |
| if not data or not data.get('name') or not data.get('email'): | |
| return jsonify({"error": "Name and email are required"}), 400 | |
| name = data['name'].strip() | |
| email = data['email'].strip().lower() | |
| plan = data.get('plan', 'Free').strip() | |
| ok, err = supabase_insert("waitlist", {"name": name, "email": email, "plan": plan}) | |
| if not ok: | |
| print(f"Waitlist insert error: {err}") | |
| return jsonify({"error": "Could not save to waitlist. Try again later."}), 500 | |
| return jsonify({"success": True, "message": "Added to waitlist"}) | |
| def signup(): | |
| data = request.get_json() | |
| if not data or not data.get('name') or not data.get('email') or not data.get('password'): | |
| return jsonify({"error": "Name, email and password are required"}), 400 | |
| name = data['name'].strip() | |
| email = data['email'].strip().lower() | |
| password_hash = ph.hash(data['password']) | |
| ok, err = supabase_insert("users", {"name": name, "email": email, "password_hash": password_hash}) | |
| if not ok: | |
| print(f"Signup insert error: {err}") | |
| return jsonify({"error": "Could not create account. Email may already be registered."}), 500 | |
| return jsonify({"success": True, "message": "Account created", "name": name, "email": email}) | |
| # — CONTACT (saved to Supabase) — | |
| def submit_contact(): | |
| data = request.get_json() | |
| if not data or not data.get('name') or not data.get('email') or not data.get('message'): | |
| return jsonify({"error": "Name, email and message are required"}), 400 | |
| name = data['name'].strip() | |
| email = data['email'].strip().lower() | |
| subject = data.get('subject', '').strip() | |
| message = data['message'].strip() | |
| ok, err = supabase_insert("contacts", {"name": name, "email": email, "subject": subject, "message": message}) | |
| if not ok: | |
| print(f"Contact insert error: {err}") | |
| return jsonify({"error": "Could not send message. Try again later."}), 500 | |
| return jsonify({"success": True, "message": "Message sent"}) | |
| # — ONBOARDING (attribution) — | |
| def save_onboarding(): | |
| data = request.get_json() or {} | |
| email = (data.get('email') or '').strip().lower() | |
| heard_from = (data.get('heard_from') or '').strip() | |
| role = (data.get('role') or '').strip() | |
| note = (data.get('note') or '').strip() | |
| ok, err = supabase_insert("onboarding", {"email": email, "heard_from": heard_from, "role": role, "note": note}) | |
| if not ok: | |
| print(f"Onboarding insert error: {err}") | |
| return jsonify({"error": "Could not save"}), 500 | |
| return jsonify({"success": True}) | |
| # ── TELEGRAM BOT ── | |
| def telegram_send(chat_id, text): | |
| try: | |
| requests.post(f"{TELEGRAM_API_URL}/sendMessage", json={ | |
| "chat_id": chat_id, | |
| "text": text, | |
| "parse_mode": "Markdown" | |
| }, timeout=10) | |
| except Exception as e: | |
| print(f"Telegram send error: {e}") | |
| def telegram_webhook(): | |
| update = request.get_json(silent=True) or {} | |
| message = update.get('message', {}) | |
| chat_id = message.get('chat', {}).get('id') | |
| text = message.get('text', '').strip() | |
| if not chat_id: | |
| return jsonify({"ok": True}) | |
| if text == '/start': | |
| telegram_send(chat_id, | |
| "👋 *Welcome to AETHELX Verify Bot!*\n\n" | |
| "Send me any text (30+ characters) and I'll check if it's likely " | |
| "AI-generated or human-written.\n\n" | |
| "⚠️ Beta model — treat results as one signal, not a final verdict.\n\n" | |
| "More tools: https://aethelx.com" | |
| ) | |
| return jsonify({"ok": True}) | |
| if not text: | |
| telegram_send(chat_id, "Please send me some text to analyze (30+ characters).") | |
| return jsonify({"ok": True}) | |
| if len(text) < 30: | |
| telegram_send(chat_id, "⚠️ That's too short — send at least 30 characters of text.") | |
| return jsonify({"ok": True}) | |
| trust_score, ai_probability, used_hf = detect_with_hf(text) | |
| if trust_score is None: | |
| trust_score, ai_probability = fallback_analyze(text) | |
| used_hf = False | |
| verdict = "⚠️ *Likely AI-generated*" if ai_probability > 50 else "✅ *Likely human-written*" | |
| model_note = TEXT_MODEL_NAME if used_hf else "heuristic fallback (model warming up)" | |
| reply = ( | |
| f"{verdict}\n\n" | |
| f"AI Probability: *{ai_probability}%*\n" | |
| f"Model: {model_note}\n\n" | |
| f"⚠️ Beta accuracy — treat as one signal, not a final verdict.\n" | |
| f"More tools: https://aethelx.com" | |
| ) | |
| telegram_send(chat_id, reply) | |
| return jsonify({"ok": True}) | |
| def telegram_set_webhook(): | |
| webhook_url = "https://aggnes99-lumora-backend.hf.space/telegram-webhook" | |
| try: | |
| res = requests.get(f"{TELEGRAM_API_URL}/setWebhook", params={"url": webhook_url}, timeout=10) | |
| return jsonify(res.json()) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| # ── NETWORK DIAGNOSTIC ── | |
| def network_test(): | |
| targets = { | |
| "google (general internet)": "https://www.google.com", | |
| "huggingface_router (text model domain)": "https://router.huggingface.co", | |
| "huggingface_api_old (deprecated domain)": "https://api-inference.huggingface.co", | |
| "supabase": SUPABASE_URL if SUPABASE_URL else "https://supabase.com", | |
| "telegram_api": "https://api.telegram.org", | |
| } | |
| results = {} | |
| for name, url in targets.items(): | |
| start = time.time() | |
| try: | |
| res = requests.get(url, timeout=8) | |
| elapsed = round(time.time() - start, 2) | |
| results[name] = {"status": "OK", "http_status": res.status_code, "seconds": elapsed} | |
| except requests.exceptions.Timeout: | |
| elapsed = round(time.time() - start, 2) | |
| results[name] = {"status": "TIMEOUT", "seconds": elapsed} | |
| except Exception as e: | |
| elapsed = round(time.time() - start, 2) | |
| results[name] = {"status": "ERROR", "error": f"{type(e).__name__}: {str(e)[:150]}", "seconds": elapsed} | |
| return jsonify(results) | |
| # ── MODEL STATUS (quick check that the models loaded) ── | |
| def model_status(): | |
| return jsonify({ | |
| "text_model": TEXT_MODEL_NAME, | |
| "text_loaded": _text_model is not None, | |
| "image_model": IMAGE_MODEL_NAME, | |
| "image_loaded": image_classifier is not None | |
| }) | |
| # ── EMAIL BREACH CHECK (via XposedOrNot, free, no API key) ── | |
| def check_email_breach(): | |
| email = request.args.get('email', '').strip().lower() | |
| if not email or '@' not in email: | |
| return jsonify({"error": "Please provide a valid email address"}), 400 | |
| try: | |
| r = requests.get( | |
| "https://api.xposedornot.com/v1/breach-analytics", | |
| params={"email": email}, | |
| timeout=15 | |
| ) | |
| if r.status_code == 404: | |
| return jsonify({"found": False, "breaches": [], "email": email}) | |
| data = r.json() | |
| exposed = data.get("ExposedBreaches") | |
| if not exposed or not exposed.get("breaches_details"): | |
| return jsonify({"found": False, "breaches": [], "email": email}) | |
| breaches = [] | |
| for b in exposed["breaches_details"]: | |
| breaches.append({ | |
| "name": b.get("breach", "Unknown"), | |
| "year": b.get("xposed_date", ""), | |
| "data": b.get("xposed_data", ""), | |
| "records": b.get("xposed_records", 0) | |
| }) | |
| risk = {} | |
| try: | |
| risk = (data.get("BreachMetrics") or {}).get("risk", [{}])[0] or {} | |
| except Exception: | |
| risk = {} | |
| return jsonify({ | |
| "found": True, | |
| "count": len(breaches), | |
| "breaches": breaches, | |
| "risk_label": risk.get("risk_label", ""), | |
| "risk_score": risk.get("risk_score", ""), | |
| "email": email | |
| }) | |
| except Exception as e: | |
| print(f"Email breach check error: {type(e).__name__}: {e}") | |
| return jsonify({"error": "Could not reach breach database. Try again shortly."}), 502 | |
| # ── HOME / STATUS ── | |
| def home(): | |
| return jsonify({"status": "AETHELX API running", "version": "1.0"}) | |
| # ── BREACH MONITORING: add an email to the watch list ── | |
| def add_monitor(): | |
| data = request.get_json() | |
| if not data or not data.get('email'): | |
| return jsonify({"error": "Email is required"}), 400 | |
| email = data['email'].strip().lower() | |
| if '@' not in email: | |
| return jsonify({"error": "Enter a valid email"}), 400 | |
| # Record current breaches as the baseline, so later we only alert on NEW ones. | |
| known = [] | |
| try: | |
| r = requests.get("https://api.xposedornot.com/v1/breach-analytics", | |
| params={"email": email}, timeout=15) | |
| if r.status_code == 200: | |
| dx = r.json() | |
| exposed = dx.get("ExposedBreaches") | |
| if exposed and exposed.get("breaches_details"): | |
| known = [b.get("breach", "") for b in exposed["breaches_details"] if b.get("breach")] | |
| except Exception as e: | |
| print(f"Monitor baseline error: {type(e).__name__}: {e}") | |
| ok, err = supabase_insert("monitors", {"email": email, "known_breaches": known}) | |
| if not ok: | |
| print(f"Add monitor error: {err}") | |
| return jsonify({"error": "This email may already be monitored, or we couldn't save it. Try again."}), 500 | |
| return jsonify({"success": True, "email": email, "baseline_count": len(known)}) | |
| # ── BREACH MONITORING: scan + email alerts (Phase B) ── | |
| RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") | |
| SCAN_SECRET = os.environ.get("SCAN_SECRET", "") | |
| ALERT_FROM = os.environ.get("ALERT_FROM", "AETHELX Alerts <onboarding@resend.dev>") | |
| def send_email(to_email, subject, html): | |
| if not RESEND_API_KEY: | |
| print("send_email: RESEND_API_KEY not set") | |
| return False | |
| try: | |
| r = requests.post( | |
| "https://api.resend.com/emails", | |
| headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"}, | |
| json={"from": ALERT_FROM, "to": [to_email], "subject": subject, "html": html}, | |
| timeout=15 | |
| ) | |
| if r.status_code in (200, 201): | |
| return True | |
| print(f"Resend error {r.status_code}: {r.text[:200]}") | |
| return False | |
| except Exception as e: | |
| print(f"send_email error: {type(e).__name__}: {e}") | |
| return False | |
| def _xon_breach_names(email): | |
| try: | |
| r = requests.get("https://api.xposedornot.com/v1/breach-analytics", | |
| params={"email": email}, timeout=15) | |
| if r.status_code == 200: | |
| dx = r.json() | |
| exposed = dx.get("ExposedBreaches") | |
| if exposed and exposed.get("breaches_details"): | |
| return [b.get("breach", "") for b in exposed["breaches_details"] if b.get("breach")] | |
| except Exception as e: | |
| print(f"xon lookup error: {type(e).__name__}: {e}") | |
| return [] | |
| def supabase_update_monitor(email, known_breaches): | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return | |
| try: | |
| url = f"{SUPABASE_URL}/rest/v1/monitors" | |
| headers = { | |
| "apikey": SUPABASE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_KEY}", | |
| "Content-Type": "application/json", | |
| "Prefer": "return=minimal" | |
| } | |
| payload = {"known_breaches": known_breaches, | |
| "last_checked": datetime.datetime.utcnow().isoformat()} | |
| requests.patch(url, headers=headers, params={"email": f"eq.{email}"}, | |
| json=payload, timeout=15) | |
| except Exception as e: | |
| print(f"supabase update error: {type(e).__name__}: {e}") | |
| def build_alert_html(email, new_breaches): | |
| items = "".join(f"<li><b>{b}</b></li>" for b in new_breaches) | |
| return f""" | |
| <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;color:#111"> | |
| <h2 style="color:#c93b40">⚠️ Your email appeared in a new data breach</h2> | |
| <p>We're monitoring <b>{email}</b>, and it just showed up in {len(new_breaches)} new breach(es):</p> | |
| <ul>{items}</ul> | |
| <p><b>What to do now:</b></p> | |
| <ol> | |
| <li>Change your password on the affected site(s), and anywhere you reused it.</li> | |
| <li>Turn on 2-step verification.</li> | |
| <li>Watch for phishing emails pretending to be these companies.</li> | |
| </ol> | |
| <p style="font-size:12px;color:#666">You're receiving this because you signed up for breach monitoring at aethelx.com. | |
| This is an automated security alert, not financial or legal advice.</p> | |
| </div> | |
| """ | |
| def run_breach_scan(): | |
| if not SCAN_SECRET or request.args.get('token') != SCAN_SECRET: | |
| return jsonify({"error": "Unauthorized"}), 401 | |
| rows, err = supabase_select("monitors") | |
| if err or rows is None: | |
| return jsonify({"error": f"Could not load monitors: {err}"}), 500 | |
| checked = 0 | |
| alerts_sent = 0 | |
| for row in rows[:50]: # safety cap per run | |
| email = (row.get("email") or "").strip().lower() | |
| if not email: | |
| continue | |
| old = row.get("known_breaches") or [] | |
| if isinstance(old, str): | |
| old = [] | |
| current = _xon_breach_names(email) | |
| checked += 1 | |
| new_ones = [b for b in current if b not in old] | |
| if new_ones: | |
| html = build_alert_html(email, new_ones) | |
| if send_email(email, "Security alert: your email appeared in a new data breach", html): | |
| alerts_sent += 1 | |
| supabase_update_monitor(email, current) | |
| time.sleep(1) # stay under XposedOrNot rate limits | |
| return jsonify({"status": "done", "checked": checked, "alerts_sent": alerts_sent}) | |
| # ── LINK SAFETY CHECK (via URLhaus, free, no key) ── | |
| def check_link(): | |
| from urllib.parse import urlparse | |
| url = request.args.get('url', '').strip() | |
| if not url: | |
| return jsonify({"error": "Please provide a URL"}), 400 | |
| if not url.startswith(('http://', 'https://')): | |
| url = 'http://' + url | |
| try: | |
| r = requests.post("https://urlhaus-api.abuse.ch/v1/url/", data={"url": url}, timeout=15) | |
| d = r.json() | |
| if d.get("query_status") == "ok": | |
| return jsonify({"verdict": "threat", "threat": d.get("threat", "malicious"), | |
| "tags": d.get("tags") or [], "url": url}) | |
| host = urlparse(url).hostname or "" | |
| if host: | |
| rh = requests.post("https://urlhaus-api.abuse.ch/v1/host/", data={"host": host}, timeout=15) | |
| dh = rh.json() | |
| if dh.get("query_status") == "ok" and int(dh.get("url_count", 0) or 0) > 0: | |
| return jsonify({"verdict": "suspicious", "url_count": dh.get("url_count"), | |
| "host": host, "url": url}) | |
| return jsonify({"verdict": "clean", "url": url}) | |
| except Exception as e: | |
| print(f"Link check error: {type(e).__name__}: {e}") | |
| return jsonify({"error": "Could not check the link right now. Try again shortly."}), 502 | |
| def check_waitlist(): | |
| if request.args.get('token') != SCAN_SECRET: | |
| return jsonify({"error": "Unauthorized"}), 401 | |
| ok, err = supabase_insert("waitlist", {"name": "Test", "email": "wltest@test.com", "plan": "Free"}) | |
| rows, rerr = supabase_select("waitlist") | |
| return jsonify({"insert_ok": ok, "insert_error": err, "row_count": len(rows) if rows else 0, "read_error": rerr}) | |
| def test_email(): | |
| if request.args.get('token') != SCAN_SECRET: | |
| return jsonify({"error": "Unauthorized"}), 401 | |
| to = request.args.get('to', '').strip() | |
| if not to: | |
| return jsonify({"error": "Add ?to=your@email.com"}), 400 | |
| ok = send_email(to, "AETHELX test email", | |
| "<p>If you can read this, your alerts are working! \u2705</p>") | |
| return jsonify({"sent": ok, "to": to, "from": ALERT_FROM}) | |
| if __name__ == '__main__': | |
| app.run(host="0.0.0.0", port=7860) |