Files
app/backend/gitea.py
T
2026-10-04 02:47:15 +00:00

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