Add FCM device registration and Gitea bug reporter
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""The only component allowed to contact Gitea. Never logs payloads or credentials."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
def __init__(self, kind, uncertain=False):
|
||||
self.kind = kind
|
||||
self.uncertain = uncertain
|
||||
super().__init__(kind)
|
||||
|
||||
|
||||
def redact(text, secrets=()):
|
||||
for secret in secrets:
|
||||
if secret and len(secret) >= 8:
|
||||
text = text.replace(secret, '[REDACTED]')
|
||||
text = re.sub(r'-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----',
|
||||
'[REDACTED PRIVATE KEY]', text, flags=re.S)
|
||||
text = re.sub(r'(?im)((?:password|passwd|authorization|cookie|[\w]*(?:token|secret|credential|api_key)[\w]*)\s*[:=]\s*)[^\r\n]+',
|
||||
r'\1[REDACTED]', text)
|
||||
text = re.sub(r'[A-Za-z0-9_-]{20,}:[A-Za-z0-9_-]{80,}', '[REDACTED FCM TOKEN]', text)
|
||||
text = re.sub(r'(https?://)[^/\s:@]+:[^/\s@]+@', r'\1[REDACTED]@', text)
|
||||
return text.replace('\x00', '')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaConfig:
|
||||
url: str
|
||||
token: str = field(repr=False)
|
||||
owner: str
|
||||
repo: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls(*(os.environ.get(key, '').strip() for key in
|
||||
('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO')))
|
||||
|
||||
def validate(self):
|
||||
try:
|
||||
parsed = urlsplit(self.url)
|
||||
except ValueError:
|
||||
raise GiteaError('configuration') from None
|
||||
if (not all((self.url, self.token, self.owner, self.repo)) or
|
||||
parsed.scheme not in ('http', 'https') or not parsed.netloc or
|
||||
parsed.username or parsed.password or parsed.query or parsed.fragment or
|
||||
not re.fullmatch(r'[A-Za-z0-9_.-]+', self.owner) or
|
||||
not re.fullmatch(r'[A-Za-z0-9_.-]+', self.repo)):
|
||||
raise GiteaError('configuration')
|
||||
|
||||
|
||||
class GiteaService:
|
||||
def __init__(self, config=None, transport=None):
|
||||
self.config = config or GiteaConfig.from_env()
|
||||
self.transport = transport
|
||||
|
||||
def create_issue(self, title, body, category):
|
||||
self.config.validate()
|
||||
repository = '/repos/' + quote(self.config.owner, safe='') + '/' + quote(self.config.repo, safe='')
|
||||
wanted = {'reported-from-metalcircle', 'bug'}
|
||||
wanted.update({'android': {'android'}, 'web': {'web', 'frontend'}, 'push': {'push'}}.get(category, set()))
|
||||
attempted = False
|
||||
try:
|
||||
with httpx.Client(base_url=self.config.url.rstrip('/') + '/api/v1/',
|
||||
headers={'Authorization': 'token ' + self.config.token, 'Accept': 'application/json'},
|
||||
timeout=httpx.Timeout(15.0, connect=5.0), follow_redirects=False,
|
||||
transport=self.transport, trust_env=False) as client:
|
||||
# Fail closed if a personal account token is accidentally configured.
|
||||
identity = client.get('user')
|
||||
identity.raise_for_status()
|
||||
if identity.json().get('login') != 'metalcircle-bot':
|
||||
raise GiteaError('wrong_account')
|
||||
labels = []
|
||||
try:
|
||||
for page in range(1, 11):
|
||||
result = client.get(repository.lstrip('/') + '/labels', params={'limit': 50, 'page': page})
|
||||
result.raise_for_status()
|
||||
items = result.json()
|
||||
if not isinstance(items, list):
|
||||
break
|
||||
labels.extend(item['id'] for item in items if isinstance(item, dict) and
|
||||
isinstance(item.get('name'), str) and
|
||||
item.get('name', '').lower() in wanted and type(item.get('id')) is int)
|
||||
if len(items) < 50:
|
||||
break
|
||||
except (httpx.HTTPError, ValueError, TypeError, KeyError):
|
||||
# Labels are optional; permissions/missing labels must not block a report.
|
||||
labels = []
|
||||
attempted = True
|
||||
result = client.post(repository.lstrip('/') + '/issues', json={
|
||||
'title': redact('[MetalCircle Bug] ' + title, (self.config.token,)),
|
||||
'body': redact(body, (self.config.token,)), 'labels': sorted(set(labels)),
|
||||
})
|
||||
result.raise_for_status()
|
||||
issue = result.json()
|
||||
if (not isinstance(issue, dict) or type(issue.get('number')) is not int or
|
||||
issue['number'] <= 0 or not isinstance(issue.get('user'), dict) or
|
||||
issue['user'].get('login') != 'metalcircle-bot'):
|
||||
raise GiteaError('invalid_response', uncertain=True)
|
||||
return issue['number']
|
||||
except GiteaError as error:
|
||||
logger.warning('Gitea issue submission failed: %s', error.kind)
|
||||
raise
|
||||
except httpx.HTTPStatusError as error:
|
||||
code = error.response.status_code
|
||||
logger.warning('Gitea issue submission failed: HTTP %d', code)
|
||||
raise GiteaError('http_' + str(code), uncertain=attempted and code >= 500) from None
|
||||
except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError):
|
||||
logger.warning('Gitea issue submission failed: network_or_timeout')
|
||||
raise GiteaError('network_or_timeout', uncertain=attempted) from None
|
||||
except (ValueError, TypeError, KeyError, AttributeError, httpx.HTTPError):
|
||||
logger.warning('Gitea issue submission failed: invalid_response')
|
||||
raise GiteaError('invalid_response', uncertain=attempted) from None
|
||||
Reference in New Issue
Block a user