Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
91 lines
4.1 KiB
Python
91 lines
4.1 KiB
Python
"""Owner-authorized, fixed-origin payment destination update. No secret storage."""
|
|
import json
|
|
import re
|
|
import threading
|
|
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
|
|
|
CORE = 'https://justworks.cash'
|
|
LOCK = threading.Lock()
|
|
|
|
|
|
class PublishUnverified(Exception):
|
|
pass
|
|
|
|
|
|
class NoRedirect(HTTPRedirectHandler):
|
|
def redirect_request(self, *args, **kwargs):
|
|
return None
|
|
|
|
|
|
def request_core(method, path, body=None):
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
request = Request(CORE + path, data=data, method=method,
|
|
headers={'Content-Type': 'application/json', 'Accept': 'application/json',
|
|
'User-Agent': 'Archipelago-JustWorks/0.1'})
|
|
with build_opener(NoRedirect).open(request, timeout=100 if path.endswith('/deploy') else 20) as response:
|
|
raw = response.read(2 * 1024 * 1024 + 1)
|
|
if len(raw) > 2 * 1024 * 1024:
|
|
raise ValueError('Response too large')
|
|
return json.loads(raw)
|
|
|
|
|
|
def checked_input(body):
|
|
if not isinstance(body, dict):
|
|
raise ValueError('Invalid request')
|
|
slug = body.get('siteId', '')
|
|
address = body.get('address', '')
|
|
if not isinstance(slug, str) or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', slug) or len(slug) > 160 or slug.startswith(('nsec1', 'ncryptsec1')):
|
|
raise ValueError('Invalid website')
|
|
# This integration connects only the native wallet address returned by the OS.
|
|
if not isinstance(address, str) or not re.fullmatch(r'[a-z0-9._+-]{1,100}@minibits\.cash', address):
|
|
raise ValueError('Invalid native wallet address')
|
|
credentials = body.get('credentials')
|
|
if not isinstance(credentials, dict):
|
|
raise ValueError('Owner sign-in required')
|
|
allowed = ('ownerIdentifier', 'ownerPassword', 'ownerKey', 'password')
|
|
clean = {}
|
|
for key in allowed:
|
|
value = credentials.get(key, '')
|
|
if not isinstance(value, str) or len(value) > 2048:
|
|
raise ValueError('Invalid owner sign-in')
|
|
if value:
|
|
clean[key] = value
|
|
if not clean.get('ownerKey') and not (clean.get('ownerIdentifier') and clean.get('ownerPassword')):
|
|
raise ValueError('Owner sign-in required')
|
|
return slug, address, clean
|
|
|
|
|
|
def destination_matches(record, slug, address):
|
|
return (isinstance(record, dict) and record.get('id') == slug
|
|
and record.get('nsite', {}).get('deploymentStatus') == 'published'
|
|
and record.get('identity', {}).get('profile', {}).get('lud16') == address)
|
|
|
|
|
|
def connect_payment(body):
|
|
slug, address, credentials = checked_input(body)
|
|
if not LOCK.acquire(blocking=False):
|
|
raise ValueError('Another update is in progress. Wait and retry.')
|
|
try:
|
|
# Confirm ownership before attempting a change. Never keep the returned
|
|
# owner session or any recovery material from Core's response.
|
|
owner = request_core('POST', f'/api/nsites/{slug}/login', credentials).get('nsite', {})
|
|
if owner.get('id') != slug:
|
|
raise ValueError('Owner sign-in did not match this website')
|
|
try:
|
|
# Only payment changes; do not replay or overwrite other profile fields.
|
|
request_core('PATCH', f'/api/nsites/{slug}', {**credentials, 'payment': address})
|
|
published = request_core('POST', f'/api/nsites/{slug}/deploy', credentials)
|
|
if not published.get('artifact', {}).get('verified') or not destination_matches(published.get('nsite'), slug, address):
|
|
raise PublishUnverified()
|
|
# This is the public record consumed by the hosted customer tip page.
|
|
public = request_core('GET', f'/api/nsites/{slug}').get('nsite')
|
|
if not destination_matches(public, slug, address):
|
|
raise PublishUnverified()
|
|
except Exception as error:
|
|
raise PublishUnverified('The address may be saved, but publication could not be verified. Retry to publish and check again.') from error
|
|
return {'verified': True, 'siteId': slug, 'address': address,
|
|
'url': f'{CORE}/{slug}'}
|
|
finally:
|
|
credentials.clear()
|
|
LOCK.release()
|