153 lines
6.2 KiB
Python
153 lines
6.2 KiB
Python
"""Fixed dedicated identity backend; no caller-selected hosts or admin proxy."""
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
ORIGIN = os.environ.get('PERSIMMONS_GITEA_ORIGIN', 'https://teahub.xyz').rstrip('/')
|
|
ALLOW_REGISTRATION = os.environ.get('PERSIMMONS_GITEA_ALLOW_REGISTRATION', '1') == '1'
|
|
|
|
|
|
class Failure(Exception):
|
|
def __init__(self, code=503):
|
|
self.code = code
|
|
super().__init__('Gitea request unavailable')
|
|
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *args, **kwargs):
|
|
return None
|
|
|
|
|
|
def call(path, method='GET', body=None, login=None):
|
|
if login:
|
|
auth = 'Basic ' + base64.b64encode((login[0] + ':' + login[1]).encode()).decode()
|
|
else:
|
|
token = os.environ.get('PERSIMMONS_GITEA_TOKEN', '')
|
|
if not token:
|
|
raise Failure()
|
|
auth = 'token ' + token
|
|
request = urllib.request.Request(ORIGIN + '/api/v1' + path, method=method,
|
|
headers={'Authorization': auth, 'Accept': 'application/json', 'Content-Type': 'application/json'},
|
|
data=json.dumps(body).encode() if body is not None else None)
|
|
try:
|
|
with urllib.request.build_opener(NoRedirect()).open(request, timeout=15) as response:
|
|
raw = response.read()
|
|
return json.loads(raw) if raw else None
|
|
except urllib.error.HTTPError as error:
|
|
raise Failure(error.code) from None
|
|
except (OSError, ValueError):
|
|
raise Failure() from None
|
|
|
|
|
|
def identity(user):
|
|
if (not isinstance(user, dict) or type(user.get('id')) is not int or user['id'] < 1 or
|
|
user.get('is_admin') is not False or not isinstance(user.get('login'), str) or
|
|
not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}', user['login']) or
|
|
user.get('active') is False or user.get('prohibit_login') is True):
|
|
raise Failure(403)
|
|
return {'id': user['id'], 'login': user['login'], 'owner': 'pg-' + str(user['id'])}
|
|
|
|
|
|
def authenticate(name, password, register):
|
|
if register and not ALLOW_REGISTRATION:
|
|
raise Failure(403)
|
|
if register:
|
|
try:
|
|
created = call('/admin/users', 'POST', {
|
|
'username': name, 'password': password,
|
|
'email': name + '@users.persimmons.app', 'visibility': 'private',
|
|
'must_change_password': False, 'send_notify': False,
|
|
'restricted': False})
|
|
except Failure as error:
|
|
# Gitea reports duplicate usernames/emails with 422.
|
|
raise Failure(409 if error.code in (409, 422) else error.code) from None
|
|
identity(created) # Fail closed if the server ever creates an admin.
|
|
# Basic auth is sent only over HTTPS to the pinned dedicated host, never retained.
|
|
user = identity(call('/user', login=(name, password)))
|
|
if user['login'].lower() != name.lower():
|
|
raise Failure(403)
|
|
if register and user['id'] != created['id']:
|
|
raise Failure(403)
|
|
return user
|
|
|
|
|
|
def current(user):
|
|
found = identity(call('/users/' + urllib.parse.quote(user['login'], safe='')))
|
|
if found['id'] != user['id'] or found['login'] != user['login']:
|
|
raise Failure(401)
|
|
return found
|
|
|
|
|
|
def repo_name(value):
|
|
if not isinstance(value, str) or not 1 <= len(value) <= 100:
|
|
raise ValueError()
|
|
name = re.sub(r'\s+', '-', value.strip())
|
|
if (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}', name) or
|
|
name.lower().endswith(('.git', '.wiki'))):
|
|
raise ValueError()
|
|
return name
|
|
|
|
|
|
def checked_repo(repo, user, name, empty=False, repo_id=None):
|
|
if (not isinstance(repo, dict) or type(repo.get('id')) is not int or
|
|
repo.get('owner', {}).get('id') != user['id'] or
|
|
repo.get('owner', {}).get('login') != user['login'] or
|
|
repo.get('name') != name or repo.get('private') is not True or
|
|
(empty and repo.get('empty') is not True) or
|
|
(repo_id is not None and repo.get('id') != repo_id)):
|
|
raise Failure(502)
|
|
return {'id': repo['id'], 'name': repo['name'], 'owner': user['login'],
|
|
'private': True, 'empty': repo.get('empty') is True,
|
|
'url': ORIGIN + '/' + urllib.parse.quote(user['login'], safe='') + '/' + urllib.parse.quote(name, safe='')}
|
|
|
|
|
|
def create_repo(user, name):
|
|
current(user)
|
|
try:
|
|
repo = call('/admin/users/' + urllib.parse.quote(user['login'], safe='') + '/repos', 'POST',
|
|
{'name': name, 'private': True, 'auto_init': False, 'template': False})
|
|
except Failure as error:
|
|
raise Failure(409 if error.code in (409, 422) else error.code) from None
|
|
checked_repo(repo, user, name, empty=True)
|
|
# Verify the persisted resource independently before recording it as a vault.
|
|
return checked_repo(call('/repos/' + user['login'] + '/' + name), user, name,
|
|
empty=True, repo_id=repo['id'])
|
|
|
|
|
|
def list_repos(user, rows):
|
|
current(user)
|
|
return [checked_repo(call('/repos/' + user['login'] + '/' + name), user, name, repo_id=repo_id)
|
|
for repo_id, name in rows]
|
|
|
|
|
|
def actions(user, vault, kind, name=None, method='GET', body=None):
|
|
"""Dedicated, identity-checked Actions storage. Secrets have no read helper."""
|
|
if kind not in ('secrets', 'variables') or (kind == 'secrets' and method not in ('PUT', 'DELETE')):
|
|
raise ValueError()
|
|
if name is not None and not re.fullmatch(r'PERSIM_JUICE_[0-9a-f]{32}', name):
|
|
raise ValueError()
|
|
current(user)
|
|
base = '/repos/' + user['login'] + '/' + vault['name']
|
|
checked_repo(call(base), user, vault['name'], repo_id=vault['id'])
|
|
path = base + '/actions/' + kind
|
|
if name:
|
|
result = call(path + '/' + name.upper(), method, body)
|
|
else:
|
|
result = []
|
|
for page in range(1, 11):
|
|
checked_repo(call(base), user, vault['name'], repo_id=vault['id'])
|
|
rows = call(path + '?limit=100&page=' + str(page))
|
|
if not isinstance(rows, list):
|
|
raise Failure(502)
|
|
result.extend(rows)
|
|
if len(rows) < 100:
|
|
break
|
|
else:
|
|
raise Failure(409) # Never silently omit cleanup records.
|
|
checked_repo(call(base), user, vault['name'], repo_id=vault['id'])
|
|
return result
|