baaluu2test / app.py
Kgshop's picture
Update app.py
247d3c0 verified
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 = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>BaaluuContracts</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root { --tg-theme-bg-color: #f0f2f5; --tg-theme-text-color: #000000; --tg-theme-button-color: #007aff; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #999999; --tg-theme-secondary-bg-color: #ffffff;}
@media (prefers-color-scheme: dark) {
:root { --tg-theme-bg-color: #181c23; --tg-theme-text-color: #ffffff; --tg-theme-button-color: #67a8eb; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #a0a0a0; --tg-theme-secondary-bg-color: #242b33;}
}
html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; }
body { margin: 0; font-family: 'Inter', sans-serif; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: flex; flex-direction: column; min-height: 100vh; }
.container { padding: 20px; padding-top: 30px; display: flex; flex-direction: column; flex-grow: 1; }
#loader { display: flex; justify-content: center; align-items: center; height: 100vh; font-size: 1.2rem; color: var(--tg-theme-hint-color); }
#app { display: none; flex-direction: column; flex-grow: 1; }
.card { background-color: var(--tg-theme-secondary-bg-color); padding: 25px; border-radius: 20px; margin-bottom: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.card h2 { margin-top: 0; font-size: 1.2rem; font-weight: 600; color: var(--tg-theme-text-color); margin-bottom: 20px;}
.form-group { margin-bottom: 18px; }
label { display: block; font-size: 0.9rem; font-weight: 500; margin-bottom: 8px; color: var(--tg-theme-text-color);}
.input-wrapper { position: relative; }
.input-wrapper .icon { position: absolute; left: 15px; top: 50%; transform: translateY(-50%); color: var(--tg-theme-hint-color); pointer-events: none; }
input[type="text"], input[type="datetime-local"], textarea { width: 100%; padding: 14px; padding-left: 45px; border: 1px solid var(--tg-theme-bg-color); border-radius: 12px; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); font-size: 1rem; font-family: 'Inter', sans-serif; transition: border-color 0.2s; resize: vertical;}
textarea { padding-left: 15px; } /* No icon for textarea */
input[type="text"]:focus, input[type="datetime-local"]:focus, textarea:focus { outline: none; border-color: var(--tg-theme-button-color); }
.checkbox-group { display: flex; align-items: center; margin-top: 5px;}
.checkbox-group input[type="checkbox"] { margin-right: 10px; width: auto; padding: 0; margin-left: 0;}
.checkbox-group label { margin-bottom: 0; font-weight: 400; font-size: 1rem;}
button { width: 100%; padding: 16px; border: none; border-radius: 12px; background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 10px; }
button:disabled { background-color: var(--tg-theme-hint-color); opacity: 0.7; cursor: not-allowed; }
.contract-list { display: flex; flex-direction: column; gap: 15px; }
.contract-item { background-color: var(--tg-theme-bg-color); padding: 15px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; cursor: pointer; transition: background-color 0.2s;}
.contract-item:hover { background-color: var(--tg-theme-hint-color, #999999)11; }
.contract-info { flex-grow: 1; margin-right: 10px; }
.contract-info h3 { margin: 0 0 5px 0; font-size: 1rem; font-weight: 600; color: var(--tg-theme-text-color);}
.contract-info p { margin: 0; font-size: 0.9rem; color: var(--tg-theme-hint-color); }
.contract-actions { display: flex; gap: 10px; }
.contract-actions button { width: auto; padding: 8px 15px; font-size: 0.9rem; margin: 0; flex-shrink: 0; }
.status-badge { display: inline-block; padding: 4px 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 500; margin-top: 5px; white-space: nowrap;}
.status-pending { background-color: #ffc10733; color: #ffc107; }
.status-signed_by_creator { background-color: #007bff33; color: #007bff; } /* Initial status after creation */
.status-signed_by_recipient { background-color: #28a74533; color: #28a745; }
.status-rejected { background-color: #dc354533; color: #dc3545; }
.status-unknown { background-color: #6c757d33; color: #6c757d; }
.button-sign { background-color: #28a745; }
.button-reject { background-color: #dc3545; }
#notification { position: fixed; top: -100px; left: 20px; right: 20px; background-color: rgba(0,0,0,0.7); color: white; padding: 15px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); transition: top 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 1000; text-align: center; backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); }
#notification.show { top: 20px; }
</style>
</head>
<body>
<div id="loader">Загрузка...</div>
<div id="app" class="container">
<div class="card">
<h2>Создать Новый Договор</h2>
<form id="createContractForm">
<div class="form-group">
<label for="contractTitle">Название договора</label>
<input type="text" id="contractTitle" name="title" placeholder="Например: Аренда помещения" required>
</div>
<div class="form-group">
<label for="contractSubject">Предмет договора</label>
<textarea id="contractSubject" name="subject" rows="4" placeholder="Подробное описание предмета..." required></textarea>
</div>
<div class="form-group">
<label for="contractRecipient">Получатель (username Telegram)</label>
<div class="input-wrapper">
<span class="icon">@</span>
<input type="text" id="contractRecipient" name="recipient_username" placeholder="username" required>
</div>
</div>
<div class="form-group">
<label>Срок действия до</label>
<input type="datetime-local" id="contractExpiration" name="expiration_datetime">
<div class="checkbox-group">
<input type="checkbox" id="noExpiration" name="no_expiration">
<label for="noExpiration">Без срока действия</label>
</div>
</div>
<button type="submit" id="createButton">Создать Договор</button>
</form>
</div>
<div class="card">
<h2>Мои Договоры (Созданные)</h2>
<div id="createdContractsList" class="contract-list">
<p>Загрузка...</p>
</div>
</div>
<div class="card">
<h2>Договоры Мне (Полученные)</h2>
<div id="receivedContractsList" class="contract-list">
<p>Загрузка...</p>
</div>
</div>
</div>
<div id="notification"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tg = window.Telegram.WebApp;
tg.expand();
tg.ready();
tg.setHeaderColor(tg.themeParams.secondary_bg_color || '#ffffff');
const loader = document.getElementById('loader');
const app = document.getElementById('app');
const createdContractsList = document.getElementById('createdContractsList');
const receivedContractsList = document.getElementById('receivedContractsList');
const createContractForm = document.getElementById('createContractForm');
const createButton = document.getElementById('createButton');
const notification = document.getElementById('notification');
const expirationInput = document.getElementById('contractExpiration');
const noExpirationCheckbox = document.getElementById('noExpiration');
let currentUser = null;
function showNotification(message, duration = 3000) {
notification.textContent = message;
notification.classList.add('show');
setTimeout(() => { notification.classList.remove('show'); }, duration);
}
async function login() {
if (!tg.initData) { loader.textContent = "Ошибка аутентификации."; return; }
try {
const response = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ initData: tg.initData }) });
if (!response.ok) { const err = await response.json(); throw new Error(err.error); }
currentUser = await response.json();
updateUI();
} catch (error) { loader.textContent = `Ошибка: ${error.message}`; }
}
function getContractStatusText(status) {
const texts = {
'signed_by_creator': 'Создан, ожидает',
'signed_by_recipient': 'Подписан',
'rejected': 'Отклонен'
};
return texts[status] || 'Неизвестно';
}
function getContractStatusClass(status) {
const classes = {
'signed_by_creator': 'status-signed_by_creator',
'signed_by_recipient': 'status-signed_by_recipient',
'rejected': 'status-rejected'
};
return classes[status] || 'status-unknown';
}
function renderContracts(contracts, listElement, isCreated) {
listElement.innerHTML = '';
if (!contracts || contracts.length === 0) {
listElement.innerHTML = `<p>${isCreated ? 'У вас пока нет созданных договоров.' : 'У вас пока нет полученных договоров.'}</p>`;
return;
}
contracts.forEach(contract => {
const contractItem = document.createElement('div');
contractItem.classList.add('contract-item');
contractItem.setAttribute('data-contract-id', contract.id);
const statusClass = getContractStatusClass(contract.status);
const statusText = getContractStatusText(contract.status);
const partnerInfo = isCreated ? `Получатель: @${contract.partner_username}` : `Отправитель: @${contract.partner_username}`;
contractItem.innerHTML = `
<div class="contract-info">
<h3>${contract.title}</h3>
<p>${partnerInfo}</p>
<span class="status-badge ${statusClass}">${statusText}</span>
</div>
<div class="contract-actions">
${!isCreated && contract.status === 'signed_by_creator' ? `
<button class="button-sign" data-action="sign">Подписать</button>
<button class="button-reject" data-action="reject">Отклонить</button>
` : ''}
</div>
`;
listElement.appendChild(contractItem);
// Add click listener to open view page, but not on buttons
contractItem.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') {
window.location.href = '/contracts/' + contract.id + '/view?initData=' + encodeURIComponent(tg.initData);
}
});
});
}
function updateUI() {
if (!currentUser) return;
renderContracts(currentUser.created_contracts || [], createdContractsList, true);
renderContracts(currentUser.received_contracts || [], receivedContractsList, false);
loader.style.display = 'none';
app.style.display = 'flex';
}
// Handle expiration date/time input state
noExpirationCheckbox.addEventListener('change', function() {
expirationInput.disabled = this.checked;
if (this.checked) {
expirationInput.value = ''; // Clear value when unchecked
}
});
createContractForm.addEventListener('submit', async function(e) {
e.preventDefault();
const recipientUsername = document.getElementById('contractRecipient').value.trim();
if (currentUser.username && recipientUsername.toLowerCase() === currentUser.username.toLowerCase()) {
showNotification("Нельзя создать договор самому себе.");
return;
}
tg.MainButton.showProgress();
createButton.disabled = true;
const formData = new FormData(createContractForm);
const noExpiration = formData.get('no_expiration') === 'on';
const expirationDatetime = formData.get('expiration_datetime');
if (!noExpiration && !expirationDatetime) {
showNotification("Пожалуйста, укажите срок действия или выберите 'Без срока действия'.");
tg.MainButton.hideProgress();
createButton.disabled = false;
return;
}
if (!noExpiration) {
// Basic check if date is in the past
const selectedDate = new Date(expirationDatetime);
const now = new Date();
if (selectedDate < now) {
showNotification("Срок действия не может быть в прошлом.");
tg.MainButton.hideProgress();
createButton.disabled = false;
return;
}
}
const data = {
initData: tg.initData,
title: formData.get('title'),
subject: formData.get('subject'),
recipient_username: recipientUsername,
expiration_datetime: noExpiration ? null : expirationDatetime
};
try {
const response = await fetch('/contracts/new', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Ошибка при создании договора');
}
tg.HapticFeedback.notificationOccurred('success');
showNotification('Договор успешно создан и ожидает подписи получателя!');
createContractForm.reset();
expirationInput.disabled = false; // Reset state
noExpirationCheckbox.checked = false; // Reset state
setTimeout(login, 500); // Reload user data to update lists
} catch (error) {
tg.HapticFeedback.notificationOccurred('error');
showNotification(`Ошибка: ${error.message}`);
} finally {
tg.MainButton.hideProgress();
createButton.disabled = false;
}
});
receivedContractsList.addEventListener('click', async function(e) {
const target = e.target;
if (target.tagName === 'BUTTON') {
e.stopPropagation(); // Prevent opening contract view
const action = target.getAttribute('data-action');
const contractItem = target.closest('.contract-item');
const contractId = contractItem.getAttribute('data-contract-id');
if (!action || !contractId) return;
tg.MainButton.showProgress();
contractItem.querySelectorAll('button').forEach(btn => btn.disabled = true);
try {
const response = await fetch(`/contracts/${contractId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: tg.initData })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `Ошибка при выполнении действия "${action}"`);
}
tg.HapticFeedback.notificationOccurred('success');
showNotification(`Договор успешно ${action === 'sign' ? 'подписан' : 'отклонен'}!`);
setTimeout(login, 500); // Reload user data to update lists
} catch (error) {
tg.HapticFeedback.notificationOccurred('error');
showNotification(`Ошибка: ${error.message}`);
} finally {
tg.MainButton.hideProgress();
}
}
});
login(); // Initial login on load
});
</script>
</body>
</html>
"""
VIEW_CONTRACT_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>{{ contract.title }}</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root { --tg-theme-bg-color: #f0f2f5; --tg-theme-text-color: #000000; --tg-theme-button-color: #007aff; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #999999; --tg-theme-secondary-bg-color: #ffffff;}
@media (prefers-color-scheme: dark) {
:root { --tg-theme-bg-color: #181c23; --tg-theme-text-color: #ffffff; --tg-theme-button-color: #67a8eb; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #a0a0a0; --tg-theme-secondary-bg-color: #242b33;}
}
html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; }
body { margin: 0; font-family: 'Inter', sans-serif; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
.container { padding: 20px; padding-top: 30px; }
h1 { font-size: 1.8rem; margin-top: 0; margin-bottom: 10px; color: var(--tg-theme-text-color); }
.subtitle { font-size: 1rem; color: var(--tg-theme-hint-color); margin-bottom: 20px; }
.card { background-color: var(--tg-theme-secondary-bg-color); padding: 25px; border-radius: 20px; margin-bottom: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.card h2 { font-size: 1.2rem; font-weight: 600; color: var(--tg-theme-text-color); margin-top: 0; margin-bottom: 15px;}
.details p { margin: 8px 0; font-size: 1rem; line-height: 1.5; color: var(--tg-theme-text-color);}
.details strong { color: var(--tg-theme-text-color); font-weight: 500;}
.subject-content { font-family: 'Roboto Mono', monospace; white-space: pre-wrap; word-break: break-word; background-color: var(--tg-theme-bg-color); padding: 15px; border-radius: 12px; margin-top: 15px; font-size: 0.9rem;}
.status-badge { display: inline-block; padding: 6px 12px; border-radius: 12px; font-size: 0.9rem; font-weight: 500; margin-top: 10px;}
.status-signed_by_creator { background-color: #007bff33; color: #007bff; }
.status-signed_by_recipient { background-color: #28a74533; color: #28a745; }
.status-rejected { background-color: #dc354533; color: #dc3545; }
.status-unknown { background-color: #6c757d33; color: #6c757d; }
.actions-history { margin-top: 25px; border-top: 1px solid var(--tg-theme-bg-color); padding-top: 20px; }
.actions-history h3 { font-size: 1.1rem; margin-bottom: 15px; }
.action-item { background-color: var(--tg-theme-bg-color); padding: 12px; border-radius: 8px; margin-bottom: 10px; font-size: 0.9rem; color: var(--tg-theme-text-color);}
.action-item strong { font-weight: 500; }
.action-item span { color: var(--tg-theme-hint-color); font-size: 0.85rem; }
.back-button-container { margin-top: 30px; }
.back-button { display: block; width: 100%; padding: 16px; border: none; border-radius: 12px; background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); font-size: 1.1rem; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; }
.expiry-info { margin-top: 10px; font-size: 0.95rem; color: var(--tg-theme-hint-color); }
.expiry-info strong { color: var(--tg-theme-text-color); }
</style>
</head>
<body>
<div class="container">
<h1>{{ contract.title }}</h1>
<p class="subtitle">Договор ID: {{ contract.contract_id[:8] }}...</p>
<div class="card">
<h2>Участники</h2>
<div class="details">
<p><strong>Создатель:</strong> @{{ creator_username }}</p>
<p><strong>Получатель:</strong> @{{ recipient_username }}</p>
<p><strong>Статус:</strong> <span class="status-badge {{ get_status_class(current_status) }}">{{ get_status_text(current_status) }}</span></p>
{% if expiration_display %}
<p class="expiry-info"><strong>Срок действия до:</strong> {{ expiration_display }}</p>
{% else %}
<p class="expiry-info"><strong>Срок действия:</strong> Без срока</p>
{% endif %}
</div>
</div>
<div class="card">
<h2>Предмет договора</h2>
<div class="subject-content">{{ contract.subject }}</div>
</div>
<div class="card actions-history">
<h3>История действий</h3>
{% if actions %}
{% for action in actions %}
<div class="action-item">
<strong>{{ action.type|capitalize }}:</strong> {{ users_data.get(action.user_id, {}).get('username', 'Неизвестно') }}
<span>({{ action.timestamp.replace('T', ' ')[:19] }} UTC)</span>
</div>
{% endfor %}
{% else %}
<p>Нет зарегистрированных действий по этому договору (ошибка).</p>
{% endif %}
</div>
<div class="back-button-container">
<a href="/" class="back-button">Вернуться к договорам</a>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
});
</script>
</body>
</html>
"""
ADMIN_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель BaaluuChain</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500&family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #eef0f2; color: #333; }
.container { max-width: 1200px; margin: 20px auto; padding: 20px; }
h1 { text-align: center; color: #1d2633; margin-bottom: 30px; }
.chain { display: flex; flex-direction: column; gap: 25px; }
.block { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 5px 15px rgba(0,0,0,0.08); border-left: 6px solid #007aff; }
.block:first-child { border-left-color: #28a745; } /* Genesis block */
.block-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e9ecef; padding-bottom: 10px; margin-bottom: 15px; }
.block-header h2 { margin: 0; font-size: 1.3rem; font-weight: 600; }
.block-header .timestamp { font-family: 'Roboto Mono', monospace; font-size: 0.9rem; color: #6c757d; }
.block-details { font-family: 'Roboto Mono', monospace; font-size: 0.95rem; line-height: 1.8; word-break: break-all; }
.block-details p { margin: 8px 0; }
.block-details strong { color: #343a40; font-weight: 500;}
.actions { margin-top: 20px; padding-top: 20px; border-top: 1px dashed #ced4da; }
.actions h3 { font-family: 'Inter', sans-serif; font-size: 1.1rem; margin-top: 0; margin-bottom: 15px; }
.action { background-color: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #6c757d; }
.action.type-create { border-left-color: #007bff; }
.action.type-sign { border-left-color: #28a745; }
.action.type-reject { border-left-color: #dc3545; }
.action-details { font-size: 0.9rem; }
.action-details p { margin: 4px 0; }
.action-details p strong { font-weight: 500;}
.subject-snippet { font-family: 'Roboto Mono', monospace; font-size: 0.85rem; color: #555; margin-top: 5px; word-break: break-word; white-space: pre-wrap;}
</style>
</head>
<body>
<div class="container">
<h1>Обозреватель сети BaaluuChain (Договоры)</h1>
<div class="chain">
{% for block in chain|reverse %}
<div class="block">
<div class="block-header">
<h2>Блок #{{ block.index }}</h2>
<span class="timestamp">{{ block.timestamp.replace('T', ' ')[:19] }} UTC</span>
</div>
<div class="block-details">
<p><strong>Hash:</strong> {{ block.hash }}</p>
<p><strong>Prev Hash:</strong> {{ block.previous_hash }}</p>
<p><strong>Nonce:</strong> {{ block.nonce }}</p>
</div>
<div class="actions">
<h3>Действия с договорами в блоке ({{ block.contract_actions|length }})</h3>
{% for action in block.contract_actions %}
<div class="action type-{{ action.type }}">
<div class="action-details">
<p><strong>Действие:</strong> {{ action.type|capitalize }}</p>
<p><strong>Договор ID:</strong> {{ action.contract_id }}</p>
<p><strong>Пользователь ID:</strong> {{ action.user_id }} ({{ users_data.get(action.user_id, {}).get('username', 'Неизвестно') }})</p>
{% if action.type == 'create' %}
{% if action.details %}
<p><strong>Название:</strong> {{ action.details.get('title', 'N/A') }}</p>
<p><strong>Получатель ID:</strong> {{ action.details.get('recipient_id', 'N/A') }} ({{ users_data.get(action.details.get('recipient_id'), {}).get('username', 'Неизвестно') }})</p>
<p><strong>Срок до:</strong> {{ action.details.get('expiration_datetime', 'Без срока') }}</p>
<p class="subject-snippet"><strong>Предмет:</strong> {{ action.details.get('subject', 'N/A')|truncate(150, True) }}</p>
{% endif %}
{% endif %}
</div>
</div>
{% else %}
<p>Нет действий с договорами в этом блоке.</p>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>
"""
# 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/<contract_id>/<action_type>', 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/<contract_id>/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)