Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
A
aisbf
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
nexlab
aisbf
Commits
13b80367
Commit
13b80367
authored
Apr 22, 2026
by
Stefy Lanza (nextime / spora )
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Payment systems...
parent
946dfd79
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
371 additions
and
230 deletions
+371
-230
database.py
aisbf/database.py
+17
-14
monitor.py
aisbf/payments/crypto/monitor.py
+212
-121
service.py
aisbf/payments/service.py
+15
-19
main.py
main.py
+45
-24
docs.html
templates/dashboard/docs.html
+6
-0
profile.html
templates/dashboard/profile.html
+32
-4
prompts.html
templates/dashboard/prompts.html
+5
-34
wallet.html
templates/dashboard/wallet.html
+39
-14
No files found.
aisbf/database.py
View file @
13b80367
...
...
@@ -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
:
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 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
))
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
:
...
...
aisbf/payments/crypto/monitor.py
View file @
13b80367
...
...
@@ -115,73 +115,61 @@ class BlockchainMonitor:
logger
.
error
(
f
"Error checking Bitcoin address {address}: {e}"
)
async
def
_get_bitcoin_transactions
(
self
,
address
:
str
)
->
List
[
Dict
]:
"""
Fetch Bitcoin transactions from Blockchain.com API.
Args:
address: Bitcoin address to check
Returns:
List of transaction dicts with keys: hash, from_address, amount, confirmations
"""
url
=
f
"https://blockchain.info/rawaddr/{address}"
"""Fetch Bitcoin transactions from Blockchain.com API."""
async
with
httpx
.
AsyncClient
(
timeout
=
30.0
)
as
client
:
response
=
await
client
.
get
(
url
)
response
.
raise_for_status
()
data
=
response
.
json
()
addr_resp
=
await
client
.
get
(
f
"https://blockchain.info/rawaddr/{address}?limit=25"
)
addr_resp
.
raise_for_status
()
data
=
addr_resp
.
json
()
# Fetch latest block height for confirmation calculation
try
:
block_resp
=
await
client
.
get
(
"https://blockchain.info/latestblock"
)
block_resp
.
raise_for_status
()
data
[
'latest_block'
]
=
block_resp
.
json
()
except
Exception
:
pass
# confirmations will be 0 if unavailable
return
self
.
_parse_blockchain_com_btc
(
data
,
address
)
def
_parse_blockchain_com_btc
(
self
,
data
:
Dict
,
target_address
:
str
)
->
List
[
Dict
]:
"""
Parse Blockchain.com API response for Bitcoin transactions.
Args:
data: API response data
target_address: The address we're monitoring (to identify incoming txs)
Returns:
List of incoming transaction dicts
"""
"""Parse Blockchain.com API response for Bitcoin transactions."""
transactions
=
[]
latest_block
=
data
.
get
(
'latest_block'
,
{})
.
get
(
'height'
,
0
)
for
tx
in
data
.
get
(
'txs'
,
[]):
# Check if this transaction sends to our address
for
output
in
tx
.
get
(
'out'
,
[]):
if
output
.
get
(
'addr'
)
==
target_address
:
# This is an incoming transaction
amount_satoshi
=
output
.
get
(
'value'
,
0
)
amount_btc
=
amount_satoshi
/
100
000000
# Convert satoshi to BTC
amount_btc
=
amount_satoshi
/
100
_000_000
# Get sender address (first input address)
from_address
=
None
if
tx
.
get
(
'inputs'
):
prev_out
=
tx
[
'inputs'
][
0
]
.
get
(
'prev_out'
,
{})
from_address
=
prev_out
.
get
(
'addr'
,
'unknown'
)
tx_block
=
tx
.
get
(
'block_height'
)
confirmations
=
(
latest_block
-
tx_block
+
1
)
if
tx_block
else
0
transactions
.
append
({
'hash'
:
tx
[
'hash'
],
'from_address'
:
from_address
or
'unknown'
,
'amount'
:
amount_btc
,
'confirmations'
:
data
.
get
(
'n_tx'
,
0
)
# Use block height as proxy
'confirmations'
:
confirmations
,
})
return
transactions
async
def
check_ethereum_addresses
(
self
):
"""
Check all Ethereum addresses for transactions.
Placeholder - would use Etherscan/Infura API in production.
Check all Ethereum addresses (and ERC-20 tokens) for transactions.
Uses Etherscan public API (no key required for basic polling).
"""
# Get all Ethereum addresses
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"""
SELECT id, user_id, address
SELECT id, user_id, address
, crypto_type
FROM user_crypto_addresses
WHERE crypto_type
= 'eth'
WHERE crypto_type
IN ('eth', 'usdt', 'usdc')
"""
)
addresses
=
cursor
.
fetchall
()
...
...
@@ -189,8 +177,104 @@ class BlockchainMonitor:
logger
.
debug
(
"No Ethereum addresses to check"
)
return
logger
.
info
(
f
"Checking {len(addresses)} Ethereum addresses (placeholder)..."
)
# TODO: Implement Ethereum checking with Etherscan/Infura API
logger
.
info
(
f
"Checking {len(addresses)} Ethereum addresses..."
)
# ERC-20 contract addresses (mainnet)
ERC20_CONTRACTS
=
{
'usdt'
:
'0xdac17f958d2ee523a2206206994597c13d831ec7'
,
'usdc'
:
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'
,
}
for
address_id
,
user_id
,
address
,
crypto_type
in
addresses
:
try
:
if
crypto_type
==
'eth'
:
transactions
=
await
self
.
_get_ethereum_transactions
(
address
)
else
:
contract
=
ERC20_CONTRACTS
.
get
(
crypto_type
)
if
not
contract
:
continue
transactions
=
await
self
.
_get_erc20_transactions
(
address
,
contract
,
crypto_type
)
for
tx
in
transactions
:
await
self
.
process_transaction
(
user_id
=
user_id
,
crypto_type
=
crypto_type
,
tx_hash
=
tx
[
'hash'
],
from_address
=
tx
[
'from_address'
],
to_address
=
address
,
amount
=
tx
[
'amount'
],
confirmations
=
tx
[
'confirmations'
]
)
except
Exception
as
e
:
logger
.
error
(
f
"Error checking Ethereum address {address}: {e}"
)
async
def
_get_ethereum_transactions
(
self
,
address
:
str
)
->
List
[
Dict
]:
"""Fetch ETH transactions from Etherscan public API."""
url
=
(
f
"https://api.etherscan.io/api"
f
"?module=account&action=txlist&address={address}"
f
"&startblock=0&endblock=99999999&sort=desc&offset=25&page=1"
)
async
with
httpx
.
AsyncClient
(
timeout
=
30.0
)
as
client
:
response
=
await
client
.
get
(
url
)
response
.
raise_for_status
()
data
=
response
.
json
()
if
data
.
get
(
'status'
)
!=
'1'
:
return
[]
results
=
[]
for
tx
in
data
.
get
(
'result'
,
[]):
# Only incoming transactions
if
tx
.
get
(
'to'
,
''
)
.
lower
()
!=
address
.
lower
():
continue
amount_wei
=
int
(
tx
.
get
(
'value'
,
0
))
amount_eth
=
amount_wei
/
1e18
if
amount_eth
<=
0
:
continue
confirmations
=
int
(
tx
.
get
(
'confirmations'
,
0
))
results
.
append
({
'hash'
:
tx
[
'hash'
],
'from_address'
:
tx
.
get
(
'from'
,
'unknown'
),
'amount'
:
amount_eth
,
'confirmations'
:
confirmations
,
})
return
results
async
def
_get_erc20_transactions
(
self
,
address
:
str
,
contract
:
str
,
crypto_type
:
str
)
->
List
[
Dict
]:
"""Fetch ERC-20 token transfers from Etherscan public API."""
url
=
(
f
"https://api.etherscan.io/api"
f
"?module=account&action=tokentx&contractaddress={contract}"
f
"&address={address}&startblock=0&endblock=99999999&sort=desc&offset=25&page=1"
)
async
with
httpx
.
AsyncClient
(
timeout
=
30.0
)
as
client
:
response
=
await
client
.
get
(
url
)
response
.
raise_for_status
()
data
=
response
.
json
()
if
data
.
get
(
'status'
)
!=
'1'
:
return
[]
decimals_map
=
{
'usdt'
:
6
,
'usdc'
:
6
}
decimals
=
decimals_map
.
get
(
crypto_type
,
18
)
results
=
[]
for
tx
in
data
.
get
(
'result'
,
[]):
if
tx
.
get
(
'to'
,
''
)
.
lower
()
!=
address
.
lower
():
continue
amount_raw
=
int
(
tx
.
get
(
'value'
,
0
))
amount
=
amount_raw
/
(
10
**
decimals
)
if
amount
<=
0
:
continue
confirmations
=
int
(
tx
.
get
(
'confirmations'
,
0
))
results
.
append
({
'hash'
:
tx
[
'hash'
],
'from_address'
:
tx
.
get
(
'from'
,
'unknown'
),
'amount'
:
amount
,
'confirmations'
:
confirmations
,
})
return
results
async
def
process_transaction
(
self
,
...
...
@@ -219,30 +303,29 @@ class BlockchainMonitor:
"""
crypto_type
=
crypto_type
.
lower
()
required_confs
=
self
.
required_confirmations
.
get
(
crypto_type
,
3
)
placeholder
=
'?'
if
self
.
db
.
db_type
==
'sqlite'
else
'
%
s'
# Check if transaction already exists
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT id, status, confirmations FROM crypto_transactions WHERE tx_hash = ?
"
,
f
"SELECT id, status, confirmations FROM crypto_transactions WHERE tx_hash = {placeholder}
"
,
(
tx_hash
,)
)
existing
=
cursor
.
fetchone
()
if
existing
:
# Update existing transaction
tx_id
,
status
,
old_confirmations
=
existing
# Only update if confirmations increased or status changed
if
confirmations
>
old_confirmations
or
status
==
'pending'
:
new_status
=
'confirmed'
if
confirmations
>=
required_confs
else
'pending'
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"""
cursor
.
execute
(
f
"""
UPDATE crypto_transactions
SET confirmations =
?, status = ?, confirmed_at = ?
WHERE id =
?
SET confirmations =
{placeholder}, status = {placeholder}, confirmed_at = {placeholder}
WHERE id =
{placeholder}
"""
,
(
confirmations
,
new_status
,
...
...
@@ -253,18 +336,15 @@ class BlockchainMonitor:
logger
.
info
(
f
"Updated transaction {tx_hash}: {confirmations} confirmations, status={new_status}"
)
# Credit wallet if newly confirmed
if
new_status
==
'confirmed'
and
status
==
'pending'
:
await
self
.
credit_user_wallet
(
user_id
,
crypto_type
,
amount
,
tx_id
)
else
:
# Create new transaction
status
=
'confirmed'
if
confirmations
>=
required_confs
else
'pending'
# Get address_id
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT id FROM user_crypto_addresses WHERE user_id = ? AND address = ?
"
,
f
"SELECT id FROM user_crypto_addresses WHERE user_id = {placeholder} AND address = {placeholder}
"
,
(
user_id
,
to_address
)
)
address_row
=
cursor
.
fetchone
()
...
...
@@ -274,31 +354,23 @@ class BlockchainMonitor:
logger
.
error
(
f
"Address {to_address} not found for user {user_id}"
)
return
# Convert to fiat
try
:
amount_fiat
=
await
self
.
price_service
.
convert_crypto_to_fiat
(
crypto_type
,
amount
)
except
Exception
as
e
:
logger
.
warning
(
f
"Could not convert to fiat: {e}"
)
amount_fiat
=
None
# Insert transaction
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"""
cursor
.
execute
(
f
"""
INSERT INTO crypto_transactions
(user_id, address_id, crypto_type, tx_hash, amount_crypto, amount_fiat,
confirmations, required_confirmations, status, detected_at, confirmed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES ({placeholder},{placeholder},{placeholder},{placeholder},{placeholder},
{placeholder},{placeholder},{placeholder},{placeholder},{placeholder},{placeholder})
"""
,
(
user_id
,
address_id
,
crypto_type
,
tx_hash
,
amount
,
amount_fiat
,
confirmations
,
required_confs
,
status
,
user_id
,
address_id
,
crypto_type
,
tx_hash
,
amount
,
amount_fiat
,
confirmations
,
required_confs
,
status
,
datetime
.
utcnow
(),
datetime
.
utcnow
()
if
status
==
'confirmed'
else
None
))
...
...
@@ -307,68 +379,87 @@ class BlockchainMonitor:
logger
.
info
(
f
"Created transaction {tx_hash}: {amount} {crypto_type}, status={status}"
)
# Credit wallet if confirmed
if
status
==
'confirmed'
:
await
self
.
credit_user_wallet
(
user_id
,
crypto_type
,
amount
,
tx_id
)
async
def
credit_user_wallet
(
self
,
user_id
:
int
,
crypto_type
:
str
,
amount
:
float
,
tx_id
:
int
):
"""
Credit user's crypto wallet with confirmed transaction amount.
Args:
user_id: User ID
crypto_type: Cryptocurrency type
amount: Amount in crypto
tx_id: Transaction ID
Credit user's fiat wallet with the confirmed transaction amount.
"""
# Convert to fiat
try
:
amount_fiat
=
await
self
.
price_service
.
convert_crypto_to_fiat
(
crypto_type
,
amount
)
except
Exception
as
e
:
logger
.
error
(
f
"Could not convert to fiat for crediting: {e}"
)
amount_fiat
=
0
now
=
datetime
.
utcnow
()
# Update crypto wallet balance
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"""
placeholder
=
'?'
if
self
.
db
.
db_type
==
'sqlite'
else
'
%
s'
cursor
.
execute
(
f
"""
UPDATE user_crypto_wallets
SET balance_crypto = balance_crypto + ?,
balance_fiat = balance_fiat + ?,
last_updated = ?
WHERE user_id = ? AND crypto_type = ?
"""
,
(
amount
,
amount_fiat
,
datetime
.
utcnow
(),
user_id
,
crypto_type
))
# Mark transaction as credited
cursor
.
execute
(
"""
UPDATE crypto_transactions
SET credited_at = ?
WHERE id = ?
"""
,
(
datetime
.
utcnow
(),
tx_id
))
SET balance_crypto = balance_crypto + {placeholder},
balance_fiat = balance_fiat + {placeholder},
last_updated = {placeholder}
WHERE user_id = {placeholder} AND crypto_type = {placeholder}
"""
,
(
amount
,
amount_fiat
,
now
,
user_id
,
crypto_type
))
cursor
.
execute
(
f
"""
UPDATE crypto_transactions SET credited_at = {placeholder} WHERE id = {placeholder}
"""
,
(
now
,
tx_id
))
conn
.
commit
()
#
Also credit fiat wallet
#
Credit fiat wallet directly via DB
if
amount_fiat
>
0
:
from
aisbf.payments.wallet.manager
import
WalletManager
from
sqlalchemy.ext.asyncio
import
AsyncSession
try
:
async
with
AsyncSession
(
self
.
db
.
engine
)
as
session
:
wallet_manager
=
WalletManager
(
session
)
await
wallet_manager
.
credit_wallet
(
user_id
=
user_id
,
amount
=
Decimal
(
str
(
amount_fiat
)),
transaction_details
=
{
'payment_gateway'
:
f
'crypto_{crypto_type}'
,
'gateway_transaction_id'
:
f
'crypto_tx_{tx_id}'
,
'description'
:
f
'Wallet top up via {crypto_type.upper()} payment'
,
'metadata'
:
{
'crypto_amount'
:
amount
,
'crypto_type'
:
crypto_type
,
'tx_id'
:
tx_id
}
}
)
await
session
.
commit
()
with
self
.
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
placeholder
=
'?'
if
self
.
db
.
db_type
==
'sqlite'
else
'
%
s'
# Ensure wallet row exists
if
self
.
db
.
db_type
==
'sqlite'
:
cursor
.
execute
(
f
"""
INSERT OR IGNORE INTO user_wallets (user_id, balance, currency_code)
VALUES ({placeholder}, 0.00, 'USD')
"""
,
(
user_id
,))
else
:
cursor
.
execute
(
f
"""
INSERT IGNORE INTO user_wallets (user_id, balance, currency_code)
VALUES ({placeholder}, 0.00, 'USD')
"""
,
(
user_id
,))
cursor
.
execute
(
f
"""
UPDATE user_wallets
SET balance = balance + {placeholder}, updated_at = {placeholder}
WHERE user_id = {placeholder}
"""
,
(
amount_fiat
,
now
,
user_id
))
# Get wallet id
cursor
.
execute
(
f
"SELECT id FROM user_wallets WHERE user_id = {placeholder}"
,
(
user_id
,))
wallet_row
=
cursor
.
fetchone
()
wallet_id
=
wallet_row
[
0
]
if
wallet_row
else
None
if
wallet_id
:
import
json
cursor
.
execute
(
f
"""
INSERT INTO wallet_transactions
(user_id, wallet_id, amount, type, status,
payment_gateway, gateway_transaction_id, description, metadata)
VALUES ({placeholder},{placeholder},{placeholder},'credit','completed',
{placeholder},{placeholder},{placeholder},{placeholder})
"""
,
(
user_id
,
wallet_id
,
amount_fiat
,
f
'crypto_{crypto_type}'
,
f
'crypto_tx_{tx_id}'
,
f
'Wallet top-up via {crypto_type.upper()} payment'
,
json
.
dumps
({
'crypto_amount'
:
amount
,
'crypto_type'
:
crypto_type
,
'tx_id'
:
tx_id
})
))
conn
.
commit
()
logger
.
info
(
f
"Fiat wallet credited {amount_fiat:.2f}
USD for user {user_id} from crypto
payment"
)
logger
.
info
(
f
"Fiat wallet credited {amount_fiat:.2f}
for user {user_id} from {crypto_type}
payment"
)
except
Exception
as
e
:
logger
.
error
(
f
"Error crediting fiat wallet from crypto payment: {e}"
)
...
...
aisbf/payments/service.py
View file @
13b80367
...
...
@@ -56,23 +56,19 @@ class PaymentService:
"""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
...
...
main.py
View file @
13b80367
...
...
@@ -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'
):
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
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
,
})
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>"
...
...
templates/dashboard/docs.html
View file @
13b80367
...
...
@@ -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 %}
templates/dashboard/profile.html
View file @
13b80367
...
...
@@ -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
templates/dashboard/prompts.html
View file @
13b80367
...
...
@@ -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 %}
templates/dashboard/wallet.html
View file @
13b80367
...
...
@@ -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,17 +423,35 @@ 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'
);
// 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
();
...
...
@@ -441,7 +459,14 @@ function openCryptoModal(name, address) {
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
()
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment