from flask import Flask, jsonify, request, render_template_string, redirect, url_for import json import os import logging import threading import time from datetime import datetime, timezone from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError from dotenv import load_dotenv import requests import uuid import hashlib import hmac from urllib.parse import unquote, parse_qsl load_dotenv() app = Flask(__name__) app.secret_key = 'baaluu_telegram_mini_app_secret_final_version' CHAIN_FILE = 'blockchain.json' USERS_FILE = 'users.json' SYNC_FILES = [CHAIN_FILE, USERS_FILE] REPO_ID = "Kgshop/web3test" HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") BOT_TOKEN = "7908604005:AAEcIpTnBEB4F_-2l219mF6ssg5cloHxUW4" # Replace with your actual bot token DOWNLOAD_RETRIES = 3 DOWNLOAD_DELAY = 5 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') class Blockchain: def __init__(self): self.chain = [] self.pending_contract_actions = [] self.difficulty = 4 def create_genesis_block(self): self.create_block(nonce=1, previous_hash='0') def create_block(self, nonce, previous_hash): block = { 'index': len(self.chain) + 1, 'timestamp': datetime.now(timezone.utc).isoformat(), 'contract_actions': self.pending_contract_actions, 'nonce': nonce, 'previous_hash': previous_hash, } self.pending_contract_actions = [] block['hash'] = self.hash_block(block) self.chain.append(block) return block @staticmethod def hash_block(block): block_string = json.dumps(block, sort_keys=True).encode() return hashlib.sha256(block_string).hexdigest() def add_contract_action(self, contract_id, user_id, action_type, details=None): action = { 'action_id': str(uuid.uuid4()), 'contract_id': contract_id, 'user_id': user_id, 'type': action_type, 'timestamp': datetime.now(timezone.utc).isoformat(), 'details': details if details is not None else {} } self.pending_contract_actions.append(action) return self.last_block['index'] + 1 if self.last_block else 1 @property def last_block(self): return self.chain[-1] if self.chain else None def proof_of_work(self, last_nonce): nonce = 0 while self.valid_proof(last_nonce, nonce) is False: nonce += 1 return nonce def valid_proof(self, last_nonce, nonce): guess = f'{last_nonce}{nonce}'.encode() guess_hash = hashlib.sha256(guess).hexdigest() return guess_hash[:self.difficulty] == '0' * self.difficulty def get_contract_details_from_chain(self, contract_id): contract_details = None actions = [] for block in self.chain: for action in block.get('contract_actions', []): if action.get('contract_id') == contract_id: actions.append(action) if action.get('type') == 'create': contract_details = action['details'] contract_details['contract_id'] = contract_id contract_details['created_at'] = action['timestamp'] return contract_details, sorted(actions, key=lambda x: x['timestamp']) def to_dict(self): return { 'chain': self.chain, 'pending_contract_actions': self.pending_contract_actions, 'difficulty': self.difficulty } @classmethod def from_dict(cls, data): blockchain = cls.__new__(cls) blockchain.chain = data.get('chain', []) blockchain.pending_contract_actions = data.get('pending_contract_actions', []) blockchain.difficulty = data.get('difficulty', 4) if not blockchain.chain: blockchain.create_genesis_block() return blockchain def validate_telegram_data(init_data: str, bot_token: str) -> dict or None: try: parsed_data = dict(parse_qsl(init_data)) if 'hash' not in parsed_data: return None data_hash = parsed_data.pop('hash') data_check_string = "\n".join(f"{key}={value}" for key, value in sorted(parsed_data.items())) secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest() h = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256) if h.hexdigest() == data_hash: user_json = parsed_data.get('user') if user_json: return json.loads(user_json) except Exception: return None return None def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE if not token_to_use: logging.warning("HF_TOKEN not set. Download might fail for private repos.") files_to_download = [specific_file] if specific_file else SYNC_FILES logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...") 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 HfHubHTTPError as e: if e.response.status_code == 404: logging.warning(f"File {file_name} not found in repo (404). Will create new local file.") success = True break logging.error(f"HTTP error downloading {file_name}: {e}. Retrying...") except Exception as e: logging.error(f"Unexpected error downloading {file_name}: {e}. Retrying...", exc_info=True) if attempt < retries: time.sleep(delay) if not success: all_successful = False return all_successful def upload_db_to_hf(): if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) not set. Skipping upload.") return try: api = HfApi() for file_name in SYNC_FILES: if os.path.exists(file_name): 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(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}" ) except Exception as e: logging.error(f"General error during HF upload: {e}", exc_info=True) def periodic_backup(): while True: time.sleep(1800) logging.info("Starting periodic backup...") save_all_data() upload_db_to_hf() logging.info("Periodic backup finished.") def load_data(file_path, default_data_factory): try: with open(file_path, 'r', encoding='utf-8') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): if download_db_from_hf(specific_file=file_path): try: with open(file_path, 'r', encoding='utf-8') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): pass return default_data_factory() def save_all_data(): with open(CHAIN_FILE, 'w', encoding='utf-8') as f: json.dump(blockchain.to_dict(), f, indent=2) with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump(users_data, f, indent=2) logging.info("All data saved locally.") def create_block_from_pending_actions(): if not blockchain.pending_contract_actions: return None last_block = blockchain.last_block last_nonce = last_block['nonce'] nonce = blockchain.proof_of_work(last_nonce) previous_hash = blockchain.hash_block(last_block) new_block = blockchain.create_block(nonce, previous_hash) save_all_data() upload_db_to_hf() return new_block users_data = load_data(USERS_FILE, lambda: {'users': {}, 'usernames': {}}) blockchain = Blockchain.from_dict(load_data(CHAIN_FILE, lambda: Blockchain().to_dict())) MINI_APP_TEMPLATE = """ BaaluuContracts
Загрузка...

Создать Новый Договор

@

Мои Договоры (Созданные)

Загрузка...

Договоры Мне (Полученные)

Загрузка...

""" VIEW_CONTRACT_TEMPLATE = """ {{ contract.title }}

{{ contract.title }}

Договор ID: {{ contract.contract_id[:8] }}...

Участники

Создатель: @{{ creator_username }}

Получатель: @{{ recipient_username }}

Статус: {{ get_status_text(current_status) }}

{% if expiration_display %}

Срок действия до: {{ expiration_display }}

{% else %}

Срок действия: Без срока

{% endif %}

Предмет договора

{{ contract.subject }}

История действий

{% if actions %} {% for action in actions %}
{{ action.type|capitalize }}: {{ users_data.get(action.user_id, {}).get('username', 'Неизвестно') }} ({{ action.timestamp.replace('T', ' ')[:19] }} UTC)
{% endfor %} {% else %}

Нет зарегистрированных действий по этому договору (ошибка).

{% endif %}
Вернуться к договорам
""" ADMIN_TEMPLATE = """ Админ-панель BaaluuChain

Обозреватель сети BaaluuChain (Договоры)

{% for block in chain|reverse %}

Блок #{{ block.index }}

{{ block.timestamp.replace('T', ' ')[:19] }} UTC

Hash: {{ block.hash }}

Prev Hash: {{ block.previous_hash }}

Nonce: {{ block.nonce }}

Действия с договорами в блоке ({{ block.contract_actions|length }})

{% for action in block.contract_actions %}

Действие: {{ action.type|capitalize }}

Договор ID: {{ action.contract_id }}

Пользователь ID: {{ action.user_id }} ({{ users_data.get(action.user_id, {}).get('username', 'Неизвестно') }})

{% if action.type == 'create' %} {% if action.details %}

Название: {{ action.details.get('title', 'N/A') }}

Получатель ID: {{ action.details.get('recipient_id', 'N/A') }} ({{ users_data.get(action.details.get('recipient_id'), {}).get('username', 'Неизвестно') }})

Срок до: {{ action.details.get('expiration_datetime', 'Без срока') }}

Предмет: {{ action.details.get('subject', 'N/A')|truncate(150, True) }}

{% endif %} {% endif %}
{% else %}

Нет действий с договорами в этом блоке.

{% endfor %}
{% endfor %}
""" # Helper functions for templates def get_username_by_id(user_id): return users_data['users'].get(user_id, {}).get('username', 'Неизвестно') def get_status_text(status): texts = { 'signed_by_creator': 'Создан, ожидает', 'signed_by_recipient': 'Подписан', 'rejected': 'Отклонен' } return texts.get(status, 'Неизвестно') def get_status_class(status): classes = { 'signed_by_creator': 'status-signed_by_creator', 'signed_by_recipient': 'status-signed_by_recipient', 'rejected': 'status-rejected' } return classes.get(status, 'status-unknown') def format_expiration_datetime(iso_string): if not iso_string: return None try: dt_obj = datetime.fromisoformat(iso_string.replace('Z', '+00:00')) # Handle Z timezone format return dt_obj.strftime('%Y-%m-%d %H:%M UTC') except ValueError: return iso_string # Return original if formatting fails @app.route('/') def index(): return render_template_string(MINI_APP_TEMPLATE) @app.route('/admin') def admin_panel(): return render_template_string(ADMIN_TEMPLATE, chain=blockchain.chain, users_data=users_data['users']) @app.route('/login', methods=['POST']) def login(): data = request.json user_info = validate_telegram_data(data.get('initData'), BOT_TOKEN) if not user_info: return jsonify({"error": "Неверная аутентификация Telegram"}), 403 user_id = str(user_info['id']) username = user_info.get('username') first_name = user_info.get('first_name', '') last_name = user_info.get('last_name', '') if user_id not in users_data['users']: logging.info(f"Новый пользователь: ID {user_id}, @{username or 'N/A'}") users_data['users'][user_id] = { "username": username, "first_name": first_name, "last_name": last_name, "created_contracts": [], "received_contracts": [] } if username: users_data['usernames'][username.lower()] = user_id save_all_data() upload_db_to_hf() else: current_user_data = users_data['users'][user_id] # Update username/name if changed if current_user_data.get('username') != username: old_username = current_user_data.get('username') if old_username and old_username.lower() in users_data['usernames']: del users_data['usernames'][old_username.lower()] current_user_data['username'] = username if username: users_data['usernames'][username.lower()] = user_id # Note: save_all_data/upload happens after updating both lists below if current_user_data.get('first_name') != first_name: current_user_data['first_name'] = first_name if current_user_data.get('last_name') != last_name: current_user_data['last_name'] = last_name user = users_data['users'][user_id] # For the UI, we need partner username and update status from chain if necessary def enhance_contracts_list(contracts_list): enhanced_list = [] for contract_summary in contracts_list: partner_id = contract_summary.get('partner_id') partner_username = users_data['users'].get(partner_id, {}).get('username', 'Неизвестно') # Check chain for latest status contract_details, actions = blockchain.get_contract_details_from_chain(contract_summary['id']) current_status = 'signed_by_creator' # Default after creation for action in actions: if action['type'] in ['sign', 'reject'] and action['user_id'] == contract_summary['partner_id']: if action['type'] == 'sign': current_status = 'signed_by_recipient' elif action['type'] == 'reject': current_status = 'rejected' # Update status in users_data if it differs from chain if contract_summary.get('status') != current_status: contract_summary['status'] = current_status # Mark for saving enhanced_list.append({**contract_summary, 'partner_username': partner_username}) return enhanced_list # Update user data structure in memory with latest statuses from chain user['created_contracts'] = enhance_contracts_list(user.get('created_contracts', [])) user['received_contracts'] = enhance_contracts_list(user.get('received_contracts', [])) # Save updated statuses and user info (like username change) save_all_data() upload_db_to_hf() return jsonify({ "user_id": user_id, "username": user.get('username'), "first_name": user.get('first_name'), "last_name": user.get('last_name'), "created_contracts": user['created_contracts'], "received_contracts": user['received_contracts'] }) @app.route('/contracts/new', methods=['POST']) def new_contract(): data = request.json user_info = validate_telegram_data(data.get('initData'), BOT_TOKEN) if not user_info: return jsonify({"error": "Неверная аутентификация"}), 403 creator_id = str(user_info['id']) title = data.get('title', '').strip() subject = data.get('subject', '').strip() recipient_username = data.get('recipient_username', '').strip().lstrip('@') expiration_datetime_str = data.get('expiration_datetime') if not all([title, subject, recipient_username]): return jsonify({"error": "Все обязательные поля (название, предмет, получатель) должны быть заполнены"}), 400 if '@' in recipient_username: recipient_username = recipient_username.lstrip('@') recipient_id = users_data['usernames'].get(recipient_username.lower()) if not recipient_id or recipient_id not in users_data['users']: return jsonify({"error": f"Пользователь с username @{recipient_username} не найден"}), 404 if creator_id == recipient_id: return jsonify({"error": "Нельзя создать договор самому себе"}), 400 # Validate and format expiration date expiration_iso = None if expiration_datetime_str: try: # Assume datetime-local format yyyy-MM-ddThh:mm # Convert to UTC and ISO format local_dt = datetime.fromisoformat(expiration_datetime_str) # Assuming the input is local time, convert to UTC # Note: This is a simplification. A real app should handle timezones properly. # For now, we'll just store it as provided or simple ISO. # Let's just store the string provided by datetime-local input expiration_iso = expiration_datetime_str # Store as is for simplicity or convert if needed except ValueError: return jsonify({"error": "Неверный формат даты/времени срока действия"}), 400 # Basic check against current time try: # Create a datetime object from the input string for comparison input_dt = datetime.fromisoformat(expiration_datetime_str) # Get current time in a timezone-aware format, or naive if comparison is naive # Comparing naive datetimes: if input_dt < datetime.now(): return jsonify({"error": "Срок действия не может быть в прошлом"}), 400 except ValueError: pass # Already caught invalid format above contract_id = str(uuid.uuid4()) # 1. Record creation action in blockchain pending list blockchain.add_contract_action( contract_id=contract_id, user_id=creator_id, # Creator performs the 'create' action action_type='create', details={ 'title': title, 'subject': subject, 'recipient_id': recipient_id, 'creator_id': creator_id, 'expiration_datetime': expiration_iso # Store expiration date/time } ) # 2. Update users_data with contract summary creator_user_data = users_data['users'][creator_id] recipient_user_data = users_data['users'][recipient_id] # Initial status is signed by creator, pending recipient contract_summary_for_creator = { 'id': contract_id, 'title': title, 'status': 'signed_by_creator', # Creator automatically signs on creation 'partner_id': recipient_id # Expiration is not needed in summary, retrieved on view } contract_summary_for_recipient = { 'id': contract_id, 'title': title, 'status': 'signed_by_creator', # Status is pending recipient signature 'partner_id': creator_id } creator_user_data['created_contracts'].append(contract_summary_for_creator) recipient_user_data['received_contracts'].append(contract_summary_for_recipient) # 3. Mine block, save and upload create_block_from_pending_actions() return jsonify({"message": "Договор успешно создан и ожидает подписи получателя", "contract_id": contract_id}), 201 @app.route('/contracts//', methods=['POST']) def contract_action(contract_id, action_type): data = request.json user_info = validate_telegram_data(data.get('initData'), BOT_TOKEN) if not user_info: return jsonify({"error": "Неверная аутентификация"}), 403 user_id = str(user_info['id']) valid_actions = ['sign', 'reject'] if action_type not in valid_actions: return jsonify({"error": "Неверное действие"}), 400 user_data = users_data['users'].get(user_id) if not user_data: return jsonify({"error": "Пользователь не найден"}), 404 # Find the contract in user's received contracts list and check status is_recipient = False contract_summary_index = -1 for i, c in enumerate(user_data.get('received_contracts', [])): if c.get('id') == contract_id: is_recipient = True contract_summary_index = i break if not is_recipient: # Could be the creator trying to sign/reject their own contract? Prevent this. # Or maybe the contract simply doesn't exist or isn't for this user. return jsonify({"error": "Действие невозможно для этого договора или вы не являетесь его получателем"}), 403 contract_summary = user_data['received_contracts'][contract_summary_index] # Check if the contract is in a state where this action is allowed (must be pending recipient signature) if contract_summary.get('status') != 'signed_by_creator': return jsonify({"error": f"Договор уже имеет статус '{get_status_text(contract_summary.get('status'))}'"}), 400 # Record action in blockchain pending list blockchain.add_contract_action( contract_id=contract_id, user_id=user_id, # Recipient performs the action action_type=action_type, details={} # No extra details needed for sign/reject ) # Update contract status in users_data for both parties new_status = 'signed_by_recipient' if action_type == 'sign' else 'rejected' creator_id = contract_summary.get('partner_id') # Partner for recipient is the creator # Update recipient's list user_data['received_contracts'][contract_summary_index]['status'] = new_status # Update creator's list if creator_id and creator_id in users_data['users']: creator_user_data = users_data['users'][creator_id] for i, c in enumerate(creator_user_data.get('created_contracts', [])): if c.get('id') == contract_id: creator_user_data['created_contracts'][i]['status'] = new_status break # Mine block, save and upload create_block_from_pending_actions() action_message = "подписан" if action_type == 'sign' else "отклонен" return jsonify({"message": f"Договор успешно {action_message}"}), 200 @app.route('/contracts//view', methods=['GET']) def view_contract(contract_id): init_data = request.args.get('initData') user_info = validate_telegram_data(init_data, BOT_TOKEN) # Allow viewing even without initData if the contract is public? # For this implementation, we assume users only view contracts they are a part of. # So authentication is required. if not user_info: return "Неверная аутентификация", 403 user_id = str(user_info['id']) # Check if the user is a party to this contract (creator or recipient) user_is_party = False if user_id in users_data['users']: user_contracts = users_data['users'][user_id].get('created_contracts', []) + users_data['users'][user_id].get('received_contracts', []) for contract_summary in user_contracts: if contract_summary.get('id') == contract_id: user_is_party = True break if not user_is_party: return "Договор не найден или у вас нет доступа", 404 # Get contract details and actions from the blockchain contract_details, actions = blockchain.get_contract_details_from_chain(contract_id) if not contract_details: return "Договор не найден в блокчейне", 404 creator_id = contract_details.get('creator_id') recipient_id = contract_details.get('recipient_id') creator_username = get_username_by_id(creator_id) recipient_username = get_username_by_id(recipient_id) # Determine current status from actions current_status = 'signed_by_creator' # Initial status for action in actions: if action['type'] in ['sign', 'reject'] and action['user_id'] == recipient_id: if action['type'] == 'sign': current_status = 'signed_by_recipient' elif action['type'] == 'reject': current_status = 'rejected' break # Once signed or rejected by recipient, status is final expiration_datetime = contract_details.get('expiration_datetime') expiration_display = format_expiration_datetime(expiration_datetime) return render_template_string( VIEW_CONTRACT_TEMPLATE, contract=contract_details, creator_username=creator_username, recipient_username=recipient_username, actions=actions, current_status=current_status, users_data=users_data['users'], # Pass users_data for username lookup in template get_status_text=get_status_text, # Pass helper functions get_status_class=get_status_class, expiration_display=expiration_display ) if __name__ == '__main__': logging.info("Application starting up.") if HF_TOKEN_WRITE: threading.Thread(target=periodic_backup, daemon=True).start() logging.info("Periodic backup thread started.") else: logging.warning("Periodic backup will NOT run (HF_TOKEN for writing not set).") port = int(os.environ.get('PORT', 7860)) logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}") app.run(host='0.0.0.0', port=port)