Spaces:
Running
Running
| import os | |
| import io | |
| import base64 | |
| import json | |
| import logging | |
| import threading | |
| import time | |
| from datetime import datetime, timedelta | |
| import random | |
| import string | |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, make_response, jsonify | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError | |
| from dotenv import load_dotenv | |
| import requests | |
| load_dotenv() | |
| app = Flask(__name__) | |
| app.secret_key = 'your_unique_secret_key_gippo_312_shop_54321_no_login_synkris' | |
| DATA_FILE = 'data.json' | |
| DATA_FILE_TEMP = 'data.json.tmp' | |
| SYNC_FILES = [DATA_FILE] | |
| REPO_ID = "Kgshop/neurospace" | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| DOWNLOAD_RETRIES = 3 | |
| DOWNLOAD_DELAY = 5 | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): | |
| if not HF_TOKEN_READ and not HF_TOKEN_WRITE: | |
| return False | |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE | |
| files_to_download = [specific_file] if specific_file else SYNC_FILES | |
| all_successful = True | |
| for file_name in files_to_download: | |
| success = False | |
| for attempt in range(retries + 1): | |
| try: | |
| hf_hub_download( | |
| repo_id=REPO_ID, | |
| filename=file_name, | |
| repo_type="dataset", | |
| token=token_to_use, | |
| local_dir=".", | |
| local_dir_use_symlinks=False, | |
| force_download=True, | |
| resume_download=False | |
| ) | |
| success = True | |
| break | |
| except RepositoryNotFoundError: | |
| all_successful = False | |
| break | |
| except HfHubHTTPError as e: | |
| if e.response.status_code == 404: | |
| if attempt == 0 and not os.path.exists(file_name): | |
| try: | |
| if file_name == DATA_FILE: | |
| with open(file_name, 'w', encoding='utf-8') as f: | |
| json.dump({}, f) | |
| except Exception: | |
| pass | |
| success = True | |
| break | |
| else: | |
| pass | |
| except Exception: | |
| pass | |
| if attempt < retries: | |
| time.sleep(delay) | |
| if not success: | |
| all_successful = False | |
| return all_successful | |
| def upload_db_to_hf(specific_file=None): | |
| if not HF_TOKEN_WRITE: | |
| return | |
| try: | |
| api = HfApi() | |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES | |
| for file_name in files_to_upload: | |
| if os.path.exists(file_name): | |
| try: | |
| api.upload_file( | |
| path_or_fileobj=file_name, | |
| path_in_repo=file_name, | |
| repo_id=REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN_WRITE, | |
| commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" | |
| ) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| def periodic_backup(): | |
| backup_interval = 1800 | |
| while True: | |
| time.sleep(backup_interval) | |
| upload_db_to_hf() | |
| def load_data(): | |
| data = {} | |
| if os.path.exists(DATA_FILE): | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| except json.JSONDecodeError: | |
| if download_db_from_hf(specific_file=DATA_FILE): | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| data = {} | |
| elif download_db_from_hf(specific_file=DATA_FILE): | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| data = {} | |
| if not isinstance(data, dict): | |
| data = {} | |
| return data | |
| def save_data(data): | |
| try: | |
| with open(DATA_FILE_TEMP, 'w', encoding='utf-8') as file: | |
| json.dump(data, file, ensure_ascii=False, indent=4) | |
| os.replace(DATA_FILE_TEMP, DATA_FILE) | |
| upload_db_to_hf(specific_file=DATA_FILE) | |
| except Exception: | |
| if os.path.exists(DATA_FILE_TEMP): | |
| os.remove(DATA_FILE_TEMP) | |
| LANDING_PAGE_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title> MetaStore - AI система для Вашего Бизнеса</title> | |
| <style> | |
| body, html { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| iframe { | |
| border: none; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe> | |
| </body> | |
| </html> | |
| ''' | |
| ADMHOSTO_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Админ-панель</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> | |
| <style> | |
| :root { | |
| --bg-light: #f4f6f9; | |
| --bg-medium: #135D66; | |
| --accent: #48D1CC; | |
| --accent-hover: #77E4D8; | |
| --text-dark: #333; | |
| --text-on-accent: #003C43; | |
| --danger: #E57373; | |
| --warning: #ffb74d; | |
| --info: #4fc3f7; | |
| --success: #81c784; | |
| --archive: #90a4ae; | |
| } | |
| * { box-sizing: border-box; } | |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); margin: 0; padding: 15px; } | |
| .container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 3px 15px rgba(0,0,0,0.08); } | |
| h1, h2 { font-weight: 600; color: var(--bg-medium); text-align: center; } | |
| h1 { margin-bottom: 25px; font-size: 1.5rem; } | |
| h2 { font-size: 1.3rem; margin-top: 40px; border-bottom: 2px solid var(--accent); padding-bottom: 10px; margin-bottom: 20px; } | |
| .section { margin-bottom: 25px; } | |
| .add-env-form { display: flex; flex-direction: column; gap: 15px; background: #f8f9fa; padding: 15px; border-radius: 10px; border: 1px solid #e9ecef; } | |
| input[type="text"] { | |
| width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; | |
| font-family: inherit; background: #fff; -webkit-appearance: none; | |
| } | |
| .controls-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; flex-wrap: wrap; } | |
| .radio-group { display: flex; gap: 15px; } | |
| .radio-group label { cursor: pointer; display: flex; align-items: center; gap: 6px; font-weight: 500; font-size: 0.95rem; } | |
| .button { | |
| padding: 10px 15px; border: none; border-radius: 8px; color: white; font-weight: 600; cursor: pointer; text-decoration: none; | |
| display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 0.9rem; transition: opacity 0.2s; | |
| } | |
| .button:hover { opacity: 0.85; } | |
| .button:active { transform: scale(0.98); } | |
| .button.primary { background-color: var(--accent); color: var(--text-on-accent); } | |
| .button.danger { background-color: var(--danger); } | |
| .button.warning { background-color: var(--warning); color: #333; } | |
| .button.info { background-color: var(--info); } | |
| .button.success { background-color: var(--success); } | |
| .env-list { list-style: none; padding: 0; margin: 0; } | |
| .env-item { | |
| background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; padding: 15px; margin-bottom: 12px; | |
| display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 15px; box-shadow: 0 2px 5px rgba(0,0,0,0.02); | |
| } | |
| .env-item-archived { border-left: 4px solid var(--archive); } | |
| .env-details { display: flex; flex-direction: column; gap: 4px; overflow: hidden; } | |
| .env-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| .env-id { font-weight: 700; color: var(--bg-medium); font-size: 1.1rem; } | |
| .env-keyword { font-style: italic; color: #666; font-size: 0.9rem;} | |
| .env-link { font-size: 0.9rem; color: #007bff; word-break: break-all; text-decoration: none; padding: 5px 0; display: block; } | |
| .env-type-badge { font-size: 0.75rem; padding: 3px 8px; border-radius: 20px; font-weight: bold; text-transform: uppercase; white-space: nowrap; } | |
| .type-open { background-color: #d4edda; color: #155724; } | |
| .type-closed { background-color: #f8d7da; color: #721c24; } | |
| .env-actions { display: flex; flex-wrap: wrap; gap: 8px; } | |
| .message { padding: 12px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-size: 0.95rem; } | |
| .message.success { background-color: #d4edda; color: #155724; } | |
| .message.error { background-color: #f8d7da; color: #721c24; } | |
| .modal { display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(2px); } | |
| .modal-content { background-color: #fff; margin: 15% auto; padding: 25px; width: 90%; max-width: 600px; border-radius: 12px; position: relative; box-shadow: 0 5px 20px rgba(0,0,0,0.2); } | |
| .close-modal { color: #888; position: absolute; right: 15px; top: 10px; font-size: 30px; font-weight: bold; cursor: pointer; padding: 5px; } | |
| .stats-table { width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 0.85rem; } | |
| .stats-table th, .stats-table td { border: 1px solid #eee; padding: 10px 8px; text-align: left; } | |
| .stats-table th { background-color: var(--bg-medium); color: white; } | |
| .stats-table tr:nth-child(even) { background-color: #f9f9f9; } | |
| .empty-list-placeholder { text-align:center; padding: 20px; color: #888; } | |
| .no-margin { margin-bottom: 0; } | |
| @media (max-width: 768px) { | |
| .env-item { grid-template-columns: 1fr; gap: 12px; } | |
| .env-actions { justify-content: flex-start; } | |
| .modal-content { margin: 10% auto; width: 95%; padding: 20px 15px; } | |
| } | |
| @media (max-width: 600px) { | |
| body { padding: 10px; } | |
| .container { padding: 15px; } | |
| h1 { font-size: 1.3rem; margin-bottom: 20px; } | |
| .controls-row { flex-direction: column; align-items: stretch; } | |
| .radio-group { justify-content: space-between; background: #fff; padding: 10px; border-radius: 8px; border: 1px solid #ddd; } | |
| .add-env-form .button { width: 100%; padding: 14px; } | |
| .stats-table th, .stats-table td { font-size: 0.75rem; padding: 6px 4px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1><i class="fas fa-server"></i> Управление Средами</h1> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="message {{ category }}">{{ message }}</div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <div class="section"> | |
| <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form"> | |
| <input type="text" id="keyword" name="keyword" placeholder="Ключевое слово (например, 'магазин')" required> | |
| <div class="controls-row"> | |
| <div class="radio-group"> | |
| <label><input type="radio" name="env_type" value="closed" checked> <i class="fas fa-lock"></i> Закрытая</label> | |
| <label><input type="radio" name="env_type" value="open"> <i class="fas fa-globe"></i> Открытая</label> | |
| </div> | |
| <button type="submit" class="button primary"><i class="fas fa-plus-circle"></i> Создать</button> | |
| </div> | |
| </form> | |
| </div> | |
| <div class="section"> | |
| <input type="text" id="search-env" placeholder="🔍 Поиск..."> | |
| </div> | |
| <div class="section"> | |
| {% if active_environments %} | |
| <ul class="env-list"> | |
| {% for env in active_environments %} | |
| <li class="env-item"> | |
| <div class="env-details"> | |
| <div class="env-header"> | |
| <span class="env-id">{{ env.id }}</span> | |
| <span class="env-type-badge type-{{ env.type }}"> | |
| {{ 'ЗАКРЫТАЯ' if env.type == 'closed' else 'ОТКРЫТАЯ' }} | |
| </span> | |
| <small style="color:#888">{{ env.hits }} <i class="fas fa-eye"></i></small> | |
| </div> | |
| <span class="env-keyword">{{ env.keyword }}</span> | |
| <a href="{{ env.link }}" class="env-link" target="_blank">{{ env.link }}</a> | |
| </div> | |
| <div class="env-actions"> | |
| <button class="button info" onclick="openStats('{{ env.id }}')"><i class="fas fa-chart-bar"></i> Инфо</button> | |
| <form method="POST" action="{{ url_for('toggle_type', env_id=env.id) }}" style="display:contents;"> | |
| <button type="submit" class="button warning"> | |
| <i class="fas fa-{{ 'lock-open' if env.type == 'closed' else 'lock' }}"></i> {{ 'Открыть' if env.type == 'closed' else 'Закрыть' }} | |
| </button> | |
| </form> | |
| {% if env.type == 'closed' %} | |
| <form method="POST" action="{{ url_for('clear_user', env_id=env.id) }}" style="display:contents;" onsubmit="return confirm('Отвязать пользователя от среды {{ env.id }}? Первый, кто зайдет по ссылке, станет владельцем.');"> | |
| <button type="submit" class="button success"><i class="fas fa-user-slash"></i> Сброс</button> | |
| </form> | |
| {% endif %} | |
| <form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:contents;" onsubmit="return confirm('Переместить среду {{ env.id }} в архив?');"> | |
| <button type="submit" class="button danger"><i class="fas fa-archive"></i></button> | |
| </form> | |
| </div> | |
| </li> | |
| {% endfor %} | |
| </ul> | |
| {% else %} | |
| <div class="empty-list-placeholder">Список активных сред пуст</div> | |
| {% endif %} | |
| </div> | |
| <div class="section no-margin"> | |
| <h2><i class="fas fa-archive"></i> Архив</h2> | |
| {% if archived_environments %} | |
| <ul class="env-list"> | |
| {% for env in archived_environments %} | |
| <li class="env-item env-item-archived"> | |
| <div class="env-details"> | |
| <div class="env-header"> | |
| <span class="env-id">{{ env.id }}</span> | |
| <span class="env-type-badge type-{{ env.type }}"> | |
| {{ 'ЗАКРЫТАЯ' if env.type == 'closed' else 'ОТКРЫТАЯ' }} | |
| </span> | |
| </div> | |
| <span class="env-keyword">{{ env.keyword }}</span> | |
| </div> | |
| <div class="env-actions"> | |
| <form method="POST" action="{{ url_for('restore_environment', env_id=env.id) }}" style="display:contents;"> | |
| <button type="submit" class="button success"><i class="fas fa-undo"></i> Восстановить</button> | |
| </form> | |
| </div> | |
| </li> | |
| {% endfor %} | |
| </ul> | |
| {% else %} | |
| <div class="empty-list-placeholder">Архив пуст</div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <div id="statsModal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="close-modal" onclick="closeStats()">×</span> | |
| <h3 id="modalTitle" style="margin-top:0; color: var(--bg-medium)">Статистика</h3> | |
| <p style="font-size: 0.8rem; color: #666;">Время: Алматы (UTC+5)</p> | |
| <div id="statsContent" style="overflow-x: auto;">Загрузка...</div> | |
| </div> | |
| </div> | |
| <script> | |
| document.getElementById('search-env').addEventListener('input', function() { | |
| const searchTerm = this.value.toLowerCase().trim(); | |
| document.querySelectorAll('.env-item').forEach(item => { | |
| const text = item.innerText.toLowerCase(); | |
| item.style.display = text.includes(searchTerm) ? 'grid' : 'none'; | |
| }); | |
| }); | |
| function openStats(envId) { | |
| const modal = document.getElementById('statsModal'); | |
| const content = document.getElementById('statsContent'); | |
| const title = document.getElementById('modalTitle'); | |
| title.innerText = `Среда: ${envId}`; | |
| content.innerHTML = '<div style="text-align:center; padding: 20px;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>'; | |
| modal.style.display = 'block'; | |
| fetch(`/admhosto/stats/${envId}`) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.error) { | |
| content.innerHTML = `<p style="color:red">${data.error}</p>`; | |
| return; | |
| } | |
| let html = `<div style="display:flex; justify-content:space-between; margin-bottom:10px;"> | |
| <span><strong>Всего входов:</strong> ${data.hits}</span> | |
| <span><strong>Тип:</strong> ${data.type === 'closed' ? 'Закрытая' : 'Открытая'}</span> | |
| </div>`; | |
| if (data.logs && data.logs.length > 0) { | |
| html += `<table class="stats-table"> | |
| <thead><tr><th>Время</th><th>IP</th><th>Browser</th></tr></thead> | |
| <tbody>`; | |
| data.logs.forEach(log => { | |
| html += `<tr> | |
| <td>${log.time.split(' ')[1]}<br><small style="color:#999">${log.time.split(' ')[0]}</small></td> | |
| <td>${log.ip}</td> | |
| <td style="max-width: 100px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${log.ua}"> | |
| ${log.ua.includes('iPhone') ? '<i class="fab fa-apple"></i>' : (log.ua.includes('Android') ? '<i class="fab fa-android"></i>' : '<i class="fas fa-desktop"></i>')} | |
| </td> | |
| </tr>`; | |
| }); | |
| html += `</tbody></table>`; | |
| } else { | |
| html += `<p>Журнал пуст.</p>`; | |
| } | |
| content.innerHTML = html; | |
| }) | |
| .catch(err => { | |
| content.innerHTML = '<p style="color:red">Ошибка сети.</p>'; | |
| }); | |
| } | |
| function closeStats() { | |
| document.getElementById('statsModal').style.display = 'none'; | |
| } | |
| window.onclick = function(event) { | |
| const modal = document.getElementById('statsModal'); | |
| if (event.target == modal) { | |
| modal.style.display = 'none'; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| SYNKRIS_LOOK_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>NeuroSpace</title> | |
| <style> | |
| :root { | |
| --bg: #000000; | |
| --card-bg: #0a0a0a; | |
| --primary: #ccff00; | |
| --primary-hover: #b3e600; | |
| --primary-gradient: linear-gradient(45deg, #ccff00, #b3e600); | |
| --text: #ffffff; | |
| --text-secondary: #a1a1a1; | |
| --border: #333333; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| margin: 0; | |
| padding: 20px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| } | |
| .container { | |
| background-color: var(--card-bg); | |
| width: 100%; | |
| max-width: 500px; | |
| padding: 40px; | |
| border-radius: 20px; | |
| border: 1px solid #222; | |
| box-shadow: 0 0 40px rgba(204, 255, 0, 0.08); | |
| text-align: center; | |
| } | |
| h1 { | |
| color: var(--primary); | |
| margin-top: 0; | |
| margin-bottom: 35px; | |
| font-size: 2.5rem; | |
| text-transform: uppercase; | |
| letter-spacing: 3px; | |
| text-shadow: 0 0 10px rgba(204, 255, 0, 0.3); | |
| } | |
| .btn-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| .action-btn { | |
| background-image: var(--primary-gradient); | |
| color: #000; | |
| border: none; | |
| padding: 18px 30px; | |
| font-size: 1.2rem; | |
| font-weight: 800; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| width: 100%; | |
| transition: all 0.2s ease; | |
| box-shadow: 0 0 20px rgba(204, 255, 0, 0.4); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 12px; | |
| text-transform: uppercase; | |
| } | |
| .action-btn:hover { | |
| transform: scale(1.03); | |
| box-shadow: 0 0 30px rgba(204, 255, 0, 0.6); | |
| } | |
| .action-btn:active { | |
| transform: scale(0.99); | |
| } | |
| .action-btn.secondary { | |
| background-image: none; | |
| background-color: #111; | |
| border: 2px solid var(--border); | |
| color: var(--primary); | |
| box-shadow: none; | |
| } | |
| .action-btn.secondary:hover { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 15px rgba(204, 255, 0, 0.3); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>NeuroSpace</h1> | |
| <div class="btn-container"> | |
| <button type="button" class="action-btn" onclick="window.open('https://arena.ai/ru?chat-modality=image&mode=direct', '_blank')"> | |
| <span>Фоторедактор</span> | |
| <span style="font-size: 1.2em">⚡</span> | |
| </button> | |
| <button type="button" class="action-btn secondary" onclick="window.open('https://t.me/testoh199enbot?startapp=payload', '_blank')"> | |
| <span>Облако</span> | |
| <span style="font-size: 1.2em">☁️</span> | |
| </button> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| ''' | |
| def index(): | |
| return render_template_string(LANDING_PAGE_TEMPLATE) | |
| def admhosto(): | |
| data = load_data() | |
| active_environments = [] | |
| archived_environments = [] | |
| for env_id, env_data in data.items(): | |
| if not isinstance(env_data, dict): continue | |
| env_item = { | |
| "id": env_id, | |
| "keyword": env_data.get("keyword", "N/A"), | |
| "type": env_data.get("type", "closed"), | |
| "hits": env_data.get("hits", 0), | |
| "created_at": env_data.get("created_at", ""), | |
| "link": url_for('serve_env', env_id=env_id, _external=True) | |
| } | |
| if env_data.get("archived"): | |
| archived_environments.append(env_item) | |
| else: | |
| active_environments.append(env_item) | |
| active_environments.sort(key=lambda x: x.get('created_at', ''), reverse=True) | |
| archived_environments.sort(key=lambda x: x.get('created_at', ''), reverse=True) | |
| return render_template_string(ADMHOSTO_TEMPLATE, active_environments=active_environments, archived_environments=archived_environments) | |
| def create_environment(): | |
| all_data = load_data() | |
| keyword = request.form.get('keyword', '').strip() | |
| env_type = request.form.get('env_type', 'closed') | |
| if not keyword: | |
| flash('Ключевое слово не может быть пустым.', 'error') | |
| return redirect(url_for('admhosto')) | |
| while True: | |
| new_id = ''.join(random.choices(string.digits, k=6)) | |
| if new_id not in all_data: | |
| break | |
| all_data[new_id] = { | |
| "keyword": keyword, | |
| "type": env_type, | |
| "device_token": None, | |
| "hits": 0, | |
| "logs": [], | |
| "created_at": datetime.utcnow().isoformat(), | |
| "archived": False | |
| } | |
| save_data(all_data) | |
| flash(f'Новая {env_type} среда с ID {new_id} создана.', 'success') | |
| return redirect(url_for('admhosto')) | |
| def delete_environment(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| all_data[env_id]['archived'] = True | |
| save_data(all_data) | |
| flash(f'Среда {env_id} перемещена в архив.', 'success') | |
| else: | |
| flash(f'Среда {env_id} не найдена.', 'error') | |
| return redirect(url_for('admhosto')) | |
| def restore_environment(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| all_data[env_id]['archived'] = False | |
| save_data(all_data) | |
| flash(f'Среда {env_id} восстановлена из архива.', 'success') | |
| else: | |
| flash(f'Среда {env_id} не найдена.', 'error') | |
| return redirect(url_for('admhosto')) | |
| def clear_user(env_id): | |
| all_data = load_data() | |
| if env_id in all_data and all_data[env_id].get('type') == 'closed': | |
| all_data[env_id]['device_token'] = None | |
| save_data(all_data) | |
| flash(f'Пользователь отвязан от среды {env_id}.', 'success') | |
| else: | |
| flash(f'Ошибка: Среда не найдена или не является закрытой.', 'error') | |
| return redirect(url_for('admhosto')) | |
| def toggle_type(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| current_type = all_data[env_id].get('type', 'closed') | |
| if current_type == 'closed': | |
| all_data[env_id]['type'] = 'open' | |
| flash(f'Среда {env_id} теперь открыта.', 'success') | |
| else: | |
| all_data[env_id]['type'] = 'closed' | |
| all_data[env_id]['device_token'] = None | |
| flash(f'Среда {env_id} теперь закрыта. Пользователь сброшен.', 'success') | |
| save_data(all_data) | |
| else: | |
| flash(f'Среда {env_id} не найдена.', 'error') | |
| return redirect(url_for('admhosto')) | |
| def get_env_stats(env_id): | |
| data = load_data() | |
| env_data = data.get(env_id) | |
| if not env_data: | |
| return jsonify({"error": "Среда не найдена"}), 404 | |
| raw_logs = env_data.get("logs", []) | |
| formatted_logs = [] | |
| for log in reversed(raw_logs): | |
| try: | |
| utc_dt = datetime.fromisoformat(log['time']) | |
| almaty_dt = utc_dt + timedelta(hours=5) | |
| time_str = almaty_dt.strftime('%Y-%m-%d %H:%M:%S') | |
| formatted_logs.append({ | |
| "time": time_str, | |
| "ip": log.get('ip', 'unknown'), | |
| "ua": log.get('ua', 'unknown') | |
| }) | |
| except: | |
| continue | |
| response_data = { | |
| "id": env_id, | |
| "keyword": env_data.get("keyword"), | |
| "type": env_data.get("type", "closed"), | |
| "hits": env_data.get("hits", 0), | |
| "logs": formatted_logs | |
| } | |
| return jsonify(response_data) | |
| def serve_env(env_id): | |
| data = load_data() | |
| env_data = data.get(env_id) | |
| if not env_data or not isinstance(env_data, dict) or env_data.get("archived"): | |
| return "Среда не найдена или заархивирована.", 404 | |
| keyword = env_data.get("keyword", "") | |
| env_type = env_data.get("type", "closed") | |
| current_log = { | |
| "time": datetime.utcnow().isoformat(), | |
| "ip": request.headers.get('X-Forwarded-For', request.remote_addr), | |
| "ua": request.headers.get('User-Agent', '')[:150] | |
| } | |
| env_data['hits'] = env_data.get('hits', 0) + 1 | |
| if 'logs' not in env_data or not isinstance(env_data.get('logs'), list): | |
| env_data['logs'] = [] | |
| env_data['logs'].append(current_log) | |
| if len(env_data['logs']) > 30: | |
| env_data['logs'] = env_data['logs'][-30:] | |
| data[env_id] = env_data | |
| save_data(data) | |
| if env_type == 'open': | |
| return render_template_string(SYNKRIS_LOOK_TEMPLATE, keyword=keyword) | |
| stored_token = env_data.get("device_token") | |
| user_token = request.cookies.get(f'access_token_{env_id}') | |
| if stored_token: | |
| if user_token != stored_token: | |
| return """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Доступ запрещен</title> | |
| <style> | |
| body { font-family: 'Segoe UI', sans-serif; background: #000; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; text-align: center; } | |
| .container { padding: 20px; } | |
| h1 { color: #E57373; margin-bottom: 10px; } | |
| p { color: #aaa; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>⛔ Доступ запрещен</h1> | |
| <p>Эта ссылка уже привязана к другому устройству или браузеру.</p> | |
| </div> | |
| </body> | |
| </html> | |
| """, 403 | |
| return render_template_string(SYNKRIS_LOOK_TEMPLATE, keyword=keyword) | |
| else: | |
| new_token = ''.join(random.choices(string.ascii_letters + string.digits, k=40)) | |
| env_data['device_token'] = new_token | |
| data[env_id] = env_data | |
| save_data(data) | |
| resp = make_response(render_template_string(SYNKRIS_LOOK_TEMPLATE, keyword=keyword)) | |
| resp.set_cookie(f'access_token_{env_id}', new_token, max_age=31536000, httponly=True, samesite='Lax') | |
| return resp | |
| if __name__ == '__main__': | |
| download_db_from_hf() | |
| if HF_TOKEN_WRITE: | |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) | |
| backup_thread.start() | |
| else: | |
| logging.info("HF_TOKEN_WRITE is not set. Periodic backup is disabled.") | |
| port = int(os.environ.get('PORT', 7860)) | |
| app.run(debug=False, host='0.0.0.0', port=port) |