Payment systems...

parent 946dfd79
......@@ -1375,7 +1375,7 @@ class DatabaseManager:
''', (new_email, user_id))
conn.commit()
def update_user_profile(self, user_id: int, username: str, email: str, display_name: str = None):
def update_user_profile(self, user_id: int, username: str, email: str, display_name: str = None, profile_pic: str = None):
"""
Update user profile (username and display_name, email is read-only).
......@@ -1384,23 +1384,25 @@ class DatabaseManager:
username: New username
email: Email (ignored, kept for backward compatibility)
display_name: New display name (optional)
profile_pic: Base64-encoded profile picture data URL (optional)
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
fields = ['username = ' + placeholder]
params = [username]
if display_name is not None:
cursor.execute(f'''
UPDATE users
SET username = {placeholder}, display_name = {placeholder}
WHERE id = {placeholder}
''', (username, display_name, user_id))
else:
cursor.execute(f'''
UPDATE users
SET username = {placeholder}
WHERE id = {placeholder}
''', (username, user_id))
fields.append('display_name = ' + placeholder)
params.append(display_name)
if profile_pic is not None:
fields.append('profile_pic = ' + placeholder)
params.append(profile_pic)
params.append(user_id)
cursor.execute(f'''
UPDATE users SET {', '.join(fields)} WHERE id = {placeholder}
''', tuple(params))
conn.commit()
def sanitize_username(self, input_str: str) -> str:
......@@ -4315,7 +4317,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
('subscription_expires', 'TIMESTAMP NULL', 'tier_id'),
('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'),
('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'),
('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token')
('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token'),
('profile_pic', 'TEXT', 'reset_password_token_expires')
]
for col_name, col_def, after_col in required_columns:
......
This diff is collapsed.
......@@ -55,25 +55,21 @@ class PaymentService:
async def get_user_crypto_addresses(self, user_id: int) -> dict:
"""Get or create crypto addresses for user"""
addresses = {}
# Get enabled crypto types
with self.db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
cursor.execute(f"""
SELECT crypto_type FROM crypto_consolidation_settings
WHERE enabled = {placeholder}
""", (True,))
enabled_cryptos = cursor.fetchall()
for crypto_config in enabled_cryptos:
crypto_type = crypto_config[0]
address = await self.wallet_manager.get_or_create_user_address(
user_id,
crypto_type
)
addresses[crypto_type] = address
# Get enabled crypto gateways from payment gateway settings
gateways = self.db.get_payment_gateway_settings()
crypto_types = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
for gateway_name, crypto_type in crypto_types.items():
gw = gateways.get(gateway_name, {})
if not gw.get('enabled', False):
continue
try:
address = await self.wallet_manager.get_or_create_user_address(user_id, crypto_type)
addresses[gateway_name] = address
except Exception as e:
logger.warning(f"Could not get/create {crypto_type} address for user {user_id}: {e}")
return addresses
async def get_user_wallet_balances(self, user_id: int) -> dict:
......
......@@ -59,6 +59,7 @@ from datetime import datetime, timedelta
from collections import defaultdict
from pathlib import Path
import json
import re
import markdown
from urllib.parse import urljoin, urlencode
from cryptography.fernet import Fernet
......@@ -3198,7 +3199,7 @@ async def dashboard_profile(request: Request):
@app.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form("")):
async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form(""), profile_pic: UploadFile = File(None)):
"""Save user profile changes (username and display_name)"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
......@@ -3208,7 +3209,18 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di
db = DatabaseRegistry.get_config_database()
try:
db.update_user_profile(user_id, username, None, display_name if display_name else None)
profile_pic_data = None
if profile_pic and profile_pic.filename:
content_type = profile_pic.content_type or ''
if not content_type.startswith('image/'):
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Invalid file type. Please upload an image."), status_code=303)
data = await profile_pic.read(1024 * 1024 + 1) # read up to 1MB+1 to detect oversized
if len(data) > 1024 * 1024:
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Image too large. Maximum size is 1MB."), status_code=303)
import base64
profile_pic_data = f"data:{content_type};base64,{base64.b64encode(data).decode()}"
db.update_user_profile(user_id, username, None, display_name if display_name else None, profile_pic_data)
# Update session with new username and display_name
request.session['username'] = username
request.session['display_name'] = display_name or ''
......@@ -8205,12 +8217,18 @@ async def dashboard_wallet_topup(request: Request):
if not gw.get('enabled', False):
return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400)
# Crypto: return deposit address (manual transfer)
crypto_methods = {'bitcoin', 'ethereum', 'usdt', 'usdc'}
# Crypto: generate per-user HD wallet address
crypto_methods = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
if method in crypto_methods:
address = gw.get('address', '')
if not address:
return JSONResponse({"error": "Crypto address not configured"}, status_code=503)
crypto_type = crypto_methods[method]
ps = getattr(request.app.state, 'payment_service', None)
if ps is None:
return JSONResponse({"error": "Payment service unavailable"}, status_code=503)
try:
address = await ps.wallet_manager.get_or_create_user_address(user_id, crypto_type)
except Exception as e:
logger.error(f"Crypto address generation error: {e}")
return JSONResponse({"error": "Could not generate deposit address"}, status_code=503)
return JSONResponse({
"type": "crypto",
"method": method,
......@@ -8257,22 +8275,14 @@ async def dashboard_wallet_topup(request: Request):
if method == 'paypal':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'paypal_handler'):
from decimal import Decimal
order = await payment_service.paypal_handler.create_order(
user_id, Decimal(str(amount)), metadata={"type": "wallet_topup"}
)
return JSONResponse({"type": "paypal", "order_id": order.id})
# Fallback: direct PayPal redirect
client_id = gw.get('client_id', '')
sandbox = gw.get('sandbox', True)
paypal_base = "https://www.sandbox.paypal.com" if sandbox else "https://www.paypal.com"
return JSONResponse({
"type": "paypal",
"paypal_base": paypal_base,
"client_id": client_id,
"amount": amount,
})
if not payment_service or not hasattr(payment_service, 'paypal_handler'):
return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
from decimal import Decimal
result = await payment_service.paypal_handler.create_topup_order(user_id, Decimal(str(amount)))
if not result.get('success'):
logger.error(f"PayPal top-up error: {result.get('error')}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
return JSONResponse({"type": "paypal", "approval_url": result['approval_url']})
except Exception as e:
logger.error(f"PayPal top-up error: {e}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
......@@ -8994,7 +9004,7 @@ async def dashboard_docs(request: Request):
# Convert markdown to HTML with extensions for better formatting
html_content = markdown.markdown(
markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists', 'toc']
)
else:
html_content = "<p>Documentation file not found.</p>"
......@@ -9039,6 +9049,17 @@ async def dashboard_about(request: Request):
markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
)
# Rewrite DOCUMENTATION.md links to /dashboard/docs
html_content = re.sub(
r'href="DOCUMENTATION\.md#([^"]*)"',
r'href="/dashboard/docs#\1"',
html_content
)
html_content = re.sub(
r'href="DOCUMENTATION\.md"',
'href="/dashboard/docs"',
html_content
)
else:
html_content = "<p>README file not found.</p>"
......
......@@ -135,4 +135,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="markdown-content">
{{ content|safe }}
</div>
<script>
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView();
}
</script>
{% endblock %}
......@@ -18,7 +18,7 @@
<div class="card">
<h2>Account Information</h2>
<form method="POST" action="{{ url_for(request, '/dashboard/profile') }}">
<form method="POST" action="{{ url_for(request, '/dashboard/profile') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ session.username }}" required>
......@@ -48,9 +48,22 @@
<div class="form-group">
<label>Profile Picture</label>
<div style="display: flex; align-items: center; gap: 1rem;">
<img src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Current avatar" style="border-radius: 8px;">
<p style="color: #a0a0a0;">Profile pictures are managed via <a href="https://gravatar.com" target="_blank">Gravatar</a> using your email address</p>
<div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<div style="position: relative; cursor: pointer;" onclick="document.getElementById('profile_pic').click()">
{% if user.profile_pic %}
<img id="avatar-preview" src="{{ user.profile_pic }}" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% else %}
<img id="avatar-preview" src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% endif %}
<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s;" id="avatar-overlay">
<span style="color: #fff; font-size: 0.8rem; text-align: center;">Change</span>
</div>
</div>
<div>
<input type="file" id="profile_pic" name="profile_pic" accept="image/*" style="display: none;" onchange="previewAvatar(this)">
<button type="button" class="btn" style="background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;" onclick="document.getElementById('profile_pic').click()">Upload Image</button>
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Max 1 MB. JPG, PNG, GIF, WebP.</small>
</div>
</div>
</div>
......@@ -106,4 +119,19 @@
border-color: #667eea;
}
</style>
<script>
function previewAvatar(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
reader.onload = e => { document.getElementById('avatar-preview').src = e.target.result; };
reader.readAsDataURL(input.files[0]);
}
const avatarWrap = document.querySelector('[onclick="document.getElementById(\'profile_pic\').click()"]');
const overlay = document.getElementById('avatar-overlay');
if (avatarWrap && overlay) {
avatarWrap.addEventListener('mouseenter', () => overlay.style.opacity = '1');
avatarWrap.addEventListener('mouseleave', () => overlay.style.opacity = '0');
}
</script>
{% endblock %}
\ No newline at end of file
......@@ -31,7 +31,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div style="margin-bottom: 20px;">
<label style="font-weight: 500; margin-bottom: 10px; display: block;">Select Prompt File:</label>
<select id="prompt-selector" onchange="switchPrompt()" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;">
<select id="prompt-selector" onchange="switchPrompt()">
{% for prompt in prompts %}
<option value="{{ prompt.key }}" {% if loop.first %}selected{% endif %}>{{ prompt.name }}</option>
{% endfor %}
......@@ -42,9 +42,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<input type="hidden" name="prompt_key" id="prompt_key" value="">
<div class="form-group">
<label for="prompt_content" style="font-weight: 500; margin-bottom: 10px; display: block;">Prompt Content:</label>
<textarea id="prompt_content" name="prompt_content" style="width: 100%; min-height: 400px; padding: 10px; border: 1px solid #ddd; border-radius: 3px; font-family: monospace; font-size: 13px; line-height: 1.5;"></textarea>
<small style="color: #666; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small>
<label for="prompt_content">Prompt Content:</label>
<textarea id="prompt_content" name="prompt_content" style="min-height: 400px;"></textarea>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
......@@ -52,7 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if not is_admin %}
<button type="button" class="btn btn-secondary" onclick="resetPrompt()">Reset to Default</button>
{% endif %}
<a href="/dashboard" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
......@@ -96,33 +96,4 @@ if (prompts.length > 0) {
document.getElementById('prompt_key').value = prompts[0].key;
}
</script>
<style>
.form-group {
margin-bottom: 20px;
}
textarea {
resize: vertical;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
{% endblock %}
......@@ -94,8 +94,7 @@
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %}
{% set address = cfg.get('wallet_address') or cfg.get('address') or '' %}
<button onclick="openCryptoModal('{{ name }}','{{ address }}')"
<button onclick="openCryptoModal('{{ name }}')"
style="background:#1a1a2e; border:1px solid #0f3460; color:#e0e0e0; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; transition:border-color .15s;"
onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i>
......@@ -243,9 +242,10 @@
document.addEventListener('DOMContentLoaded', function () {
// ── Amount buttons ──────────────────────────────────────────
let selectedAmount = null;
let selectedAmount = 15;
document.querySelectorAll('.amount-btn').forEach(btn => {
if (parseFloat(btn.dataset.amount) === selectedAmount) btn.classList.add('active');
btn.addEventListener('click', function () {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
......@@ -423,25 +423,50 @@ function copyAddress(addr, btn) {
let _cryptoAddr = '';
function openCryptoModal(name, address) {
function openCryptoModal(name) {
const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' };
document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase();
document.getElementById('cryptoAddress').textContent = address || 'Address not configured';
_cryptoAddr = address;
document.getElementById('cryptoAddress').textContent = 'Loading…';
_cryptoAddr = '';
const qrEl = document.getElementById('cryptoQR');
qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>';
document.getElementById('cryptoModal').classList.add('active');
if (address) {
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`;
const img = new Image();
img.onload = () => { qrEl.innerHTML = ''; qrEl.appendChild(img); };
img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; };
img.src = qrUrl;
img.style.cssText = 'width:200px;height:200px;border-radius:8px;';
}
// Fetch a per-user deposit address from the server
const amount = getAmount() || 15;
fetch('/dashboard/wallet/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, payment_method: name })
})
.then(r => r.json())
.then(data => {
if (data.error) {
document.getElementById('cryptoAddress').textContent = data.error;
qrEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Error</span>';
return;
}
const address = data.address || '';
_cryptoAddr = address;
document.getElementById('cryptoAddress').textContent = address || 'Address unavailable';
if (address) {
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`;
const img = new Image();
img.onload = () => { qrEl.innerHTML = ''; qrEl.appendChild(img); };
img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; };
img.src = qrUrl;
img.style.cssText = 'width:200px;height:200px;border-radius:8px;';
} else {
qrEl.innerHTML = '<span style="color:#888;font-size:12px;">No address</span>';
}
})
.catch(() => {
document.getElementById('cryptoAddress').textContent = 'Failed to load address';
qrEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Error</span>';
});
}
function closeCryptoModal() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment