diff --git a/LICENSE b/LICENSE index 4f29de3..24a5c98 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ -relay -Copyright Zoey Mae 2020 +Uncia Relay +Copyright Zoey Mae 2021 -NON-VIOLENT PUBLIC LICENSE v4 +NON-VIOLENT PUBLIC LICENSE v4+ Preamble diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41799f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +VENV := $$HOME/.local/share/venv/uncia +PYTHON := $(VENV)/bin/python +PYTHON_SYS := `which python3` + +install: setupvenv +install-dev: setupvenv setupdev +install-nodeb: setupvenv +uninstall: clean +update: update-deps + + +clean: + find . -name '__pycache__' -exec rm --recursive --force {} + + find . -name '*.pyc' -exec rm --force {} + + find . -name '*.pyo' -exec rm --force {} + + rm --recursive --force $(VENV) + +setupvenv: + $(PYTHON_SYS) -m venv $(VENV) + $(PYTHON) -m pip install -U setuptools pip + $(PYTHON) -m pip install wheel + $(PYTHON) -m pip install -r requirements.txt + +setupdev: + $(PYTHON) -m pip install vulture + $(PYTHON) -m pip install "git+https://git.barkshark.xyz/izaliamae/reload" + +update-deps: + git reset HEAD --hard + git pull + $(PYTHON) -m pip install -r requirements.txt + +run: + $(PYTHON) -m uncia + +dev: + env LOG_LEVEL='debug' $(PYTHON) -m reload diff --git a/README.md b/README.md index 882e861..5694749 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,29 @@ # Uncia Relay -A light, but featureful, ActivityPub relay. Public posts pushed to the relay will be forwarded to every other instance subscribed to the relay - -## Dependencies - -### Debian - - sudo apt install python3-dev libuv1 libuv1-dev - -Note: Still need to figure out all the dependencies - -### Python - - python3 -m pip install -r requirements.txt - -Note: Run this after installing pyenv +A light, but featureful, ActivityPub relay. Public posts pushed to the relay will be forwarded to every other subscribed instance. ## Installation -### pyenv (optional, but recommended) +### Easy - echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc - echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc - echo -e 'if command -v pyenv 1>/dev/null 2>&1; then\n eval "$(pyenv init -)"\nfi' >> ~/.bashrc + make install -Restart terminal session or run `bash` again +### Manual - env PYTHON_CONFIGURE_OPTS="--enabled-shared" pyenv install 3.8.0 +Create a virtual environment -### PostgreSQL + python3 -m venv ~/.local/share/venv/uncia -Create a postgresql user if you haven't already +Update pip and setuptools, install wheel to avoid compiling modules, and install dependencies - sudo -u postgres psql -c "CREATE USER $USER WITH createdb;" + ~/.local/share/venv/uncia/bin/python -m pip install -U pip setuptools + ~/.local/share/venv/uncia/bin/python -m pip install wheel + ~/.local/share/venv/uncia/bin/python -m pip install -r requirements.txt -###Uncia +Run the relay setup to configure it -Run the relay to generate a default environment file and then edit it if necessary + ~/.local/share/venv/uncia/bin/python -m uncia.manage setup - python3 -m relay - $EDITOR data/production.env +### Running -Copy the link in the terminal output and paste it in your browser to setup the rest of the relay. A new link will be displayed once you restart the relay to setup an admin account +You can run either `make run` or `~/.local/share/venv/uncia/bin/python -m uncia` diff --git a/reload.cfg b/reload.cfg deleted file mode 100644 index 507dd55..0000000 --- a/reload.cfg +++ /dev/null @@ -1,5 +0,0 @@ -exec = python3 -m uncia -watch_ext = py, env -ignore_dirs = build -ignore_files = reload.py, test.py, setup.py -log_level = INFO diff --git a/reload.json b/reload.json new file mode 100644 index 0000000..c4009d8 --- /dev/null +++ b/reload.json @@ -0,0 +1,20 @@ +{ + "bin": "python3", + "args": ["-m", "uncia"], + "env": {}, + "path": "./uncia", + "watch_ext": [ + "py", + "env" + ], + "ignore_dirs": [ + "build", + "config", + "data" + ], + "ignore_files": [ + "reload.py", + "test.py" + ], + "log_level": "INFO" +} diff --git a/requirements.txt b/requirements.txt index 1db40f0..028670c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,7 @@ -pygresql==5.1 -dbutils==1.3 -sanic==19.12.2 -pycryptodome==3.9.1 -urllib3==1.25.7 -watchdog==0.8.3 -markdown==3.1.1 -jinja2==2.10.1 -jinja2-markdown==0.0.3 -hamlpy3==0.84.0 -colour==0.1.5 -argon2-cffi==19.2.0 -passlib==1.7.2 +-e git+https://git.barkshark.xyz/izaliamae/izzylib.git@rework#egg=izzylib-base&subdirectory=base +-e git+https://git.barkshark.xyz/izaliamae/izzylib.git@rework#egg=izzylib-requests-client&subdirectory=requests_client +-e git+https://git.barkshark.xyz/izaliamae/izzylib.git@rework#egg=izzylib-http-server&subdirectory=http_server +-e git+https://git.barkshark.xyz/izaliamae/izzylib.git@rework#egg=izzylib-templates&subdirectory=template +-e git+https://git.barkshark.xyz/izaliamae/izzylib.git@rework#egg=izzylib-sql&subdirectory=sql + envbash==1.2.0 diff --git a/uncia/Lib/IzzyLib/__init__.py b/uncia/Lib/IzzyLib/__init__.py deleted file mode 100644 index 95c5684..0000000 --- a/uncia/Lib/IzzyLib/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -''' -IzzyLib by Zoey Mae -Licensed under the CNPL: https://git.pixie.town/thufie/CNPL -https://git.barkshark.xyz/izaliamae/izzylib -''' - -import sys -assert sys.version_info >= (3, 6) - - -__version__ = (0, 1, 1) diff --git a/uncia/Lib/IzzyLib/cache.py b/uncia/Lib/IzzyLib/cache.py deleted file mode 100644 index 75f70d3..0000000 --- a/uncia/Lib/IzzyLib/cache.py +++ /dev/null @@ -1,88 +0,0 @@ -'''Simple caches that uses ordered dicts''' - -import re - -from datetime import datetime -from collections import OrderedDict - - -def parse_ttl(ttl): - m = re.match(r'^(\d+)([smhdw]?)$', ttl) - - if not m: - raise ValueError(f'Invalid TTL length: {ttl}') - - amount = m.group(1) - unit = m.group(2) - - if not unit: - raise ValueError('Missing numerical length in TTL') - - units = { - 's': 1, - 'm': 60, - 'h': 60 * 60, - 'd': 24 * 60 * 60, - 'w': 7 * 24 * 60 * 60 - } - - multiplier = units.get(unit) - - if not multiplier: - raise ValueError(f'Invalid time unit: {unit}') - - return multiplier * int(amount) - - -class TTLCache(OrderedDict): - def __init__(self, maxsize=1024, ttl='1h'): - self.ttl = parse_ttl(ttl) - self.maxsize = maxsize - - def remove(self, key): - if self.get(key): - del self[key] - - def store(self, key, value): - timestamp = int(datetime.timestamp(datetime.now())) - item = self.get(key) - - while len(self) >= self.maxsize and self.maxsize != 0: - self.popitem(last=False) - - self[key] = {'data': value, 'timestamp': timestamp + self.ttl} - self.move_to_end(key) - - def fetch(self, key): - item = self.get(key) - timestamp = int(datetime.timestamp(datetime.now())) - - if not item: - return - - if timestamp >= self[key]['timestamp']: - del self[key] - return - - self[key]['timestamp'] = timestamp + self.ttl - self.move_to_end(key) - return self[key]['data'] - - -class LRUCache(OrderedDict): - def __init__(self, maxsize=1024): - self.maxsize = maxsize - - def remove(self, key): - if key in self: - del self[key] - - def store(self, key, value): - while len(self) >= self.maxsize and self.maxsize != 0: - self.popitem(last=False) - - self[key] = value - self.move_to_end(key) - - def fetch(self, key): - return self.get(key) diff --git a/uncia/Lib/IzzyLib/color.py b/uncia/Lib/IzzyLib/color.py deleted file mode 100644 index 9e1a019..0000000 --- a/uncia/Lib/IzzyLib/color.py +++ /dev/null @@ -1,56 +0,0 @@ -'''functions to alter colors in hex format''' -import re - -from colour import Color - - -check = lambda color: Color(f'#{str(color)}' if re.search(r'^(?:[0-9a-fA-F]{3}){1,2}$', color) else color) - -def _multi(multiplier): - if multiplier >= 1: - return 1 - - elif multiplier <= 0: - return 0 - - return multiplier - -def lighten(color, multiplier): - col = check(color) - col.luminance += ((1 - col.luminance) * _multi(multiplier)) - - return col.hex_l - -def darken(color, multiplier): - col = check(color) - col.luminance -= (col.luminance * _multi(multiplier)) - - return col.hex_l - - -def saturate(color, multiplier): - col = check(color) - col.saturation += ((1 - col.saturation) * _multi(multiplier)) - - return col.hex_l - - -def desaturate(color, multiplier): - col = check(color) - col.saturation -= (col.saturation * _multi(multiplier)) - - return col.hex_l - - -def rgba(color, transparency): - col = check(color) - - red = col.red*255 - green = col.green*255 - blue = col.blue*255 - trans = _multi(transparency) - - return f'rgba({red:0.2f}, {green:0.2f}, {blue:0.2f}, {trans:0.2f})' - - -__all__ = ['lighten', 'darken', 'saturate', 'desaturate', 'rgba'] diff --git a/uncia/Lib/IzzyLib/http.py b/uncia/Lib/IzzyLib/http.py deleted file mode 100644 index ef57a59..0000000 --- a/uncia/Lib/IzzyLib/http.py +++ /dev/null @@ -1,204 +0,0 @@ -import traceback, urllib3, json - -from base64 import b64decode, b64encode -from urllib.parse import urlparse -from datetime import datetime - -import httpsig - -from Crypto.PublicKey import RSA -#from Crypto.Hash import SHA, SHA256, SHA384, SHA512 -#from Crypto.Signature import PKCS1_v1_5 - -from . import logging, __version__ -from .cache import TTLCache, LRUCache -from .misc import formatUTC - - -version = '.'.join([str(num) for num in __version__]) - - -class Client(urllib3.PoolManager): - def __init__(self, pool=100, timeout=30, headers={}, agent=f'IzzyLib/{version}'): - super().__init__(num_pools=pool, ) - self.cache = LRUCache() - self.headers = headers - - self.client = urllib3.PoolManager(num_pools=self.pool, timeout=self.timeout) - self.headers['User-Agent'] = agent - - - def __fetch(self, url, headers={}, method='GET', data=None, cached=True): - cached_data = self.cache.fetch(url) - - if cached and cached_data: - logging.debug(f'Returning cached data for {url}') - return cached_data - - if not headers.get('User-Agent'): - headers.update({'User-Agent': self.agent}) - - logging.debug(f'Fetching new data for {url}') - - try: - if data: - if isinstance(data, dict): - data = json.dumps(data) - - resp = self.client.request(method, url, headers=headers, body=data) - - else: - resp = self.client.request(method, url, headers=headers) - - except Exception as e: - logging.debug(f'Failed to fetch url: {e}') - return - - if cached: - logging.debug(f'Caching {url}') - self.cache.store(url, resp) - - return resp - - - def raw(self, *args, **kwargs): - ''' - Return a response object - ''' - return self.__fetch(*args, **kwargs) - - - def text(self, *args, **kwargs): - ''' - Return the body as text - ''' - resp = self.__fetch(*args, **kwargs) - - return resp.data.decode() if resp else None - - - def json(self, *args, **kwargs): - ''' - Return the body as a dict if it's json - ''' - - headers = kwargs.get('headers') - - if not headers: - kwargs['headers'] = {} - - kwargs['headers'].update({'Accept': 'application/json'}) - resp = self.__fetch(*args, **kwargs) - - try: - data = json.loads(resp.data.decode()) - - except Exception as e: - logging.debug(f'Failed to load json: {e}') - return - - return data - - -def ParseSig(headers): - sig_header = headers.get('signature') - - if not sig_header: - logging.verbose('Missing signature header') - return - - split_sig = sig_header.split(',') - signature = {} - - for part in split_sig: - key, value = part.split('=', 1) - signature[key.lower()] = value.replace('"', '') - - if not signature.get('headers'): - logging.verbose('Missing headers section in signature') - return - - signature['headers'] = signature['headers'].split() - - return signature - - -def SignHeaders(headers, keyid, privkey, url, method='GET'): - ''' - Signs headers and returns them with a signature header - - headers (dict): Headers to be signed - keyid (str): Url to the public key used to verify the signature - privkey (str): Private key used to sign the headers - url (str): Url of the request for the signed headers - method (str): Http method of the request for the signed headers - ''' - - RSAkey = RSA.import_key(privkey) - key_size = int(RSAkey.size_in_bytes()/2) - logging.debug('Signing key size:', key_size) - - parsed_url = urlparse(url) - logging.debug(parsed_url) - - raw_headers = {'date': formatUTC(), 'host': parsed_url.netloc, '(request-target)': ' '.join([method, parsed_url.path])} - raw_headers.update(dict(headers)) - header_keys = raw_headers.keys() - - signer = httpsig.HeaderSigner(keyid, privkey, f'rsa-sha{key_size}', headers=header_keys, sign_header='signature') - new_headers = signer.sign(raw_headers, parsed_url.netloc, method, parsed_url.path) - logging.debug('Signed headers:', new_headers) - - del new_headers['(request-target)'] - - return dict(new_headers) - - -def ValidateSignature(headers, method, path, client=None, agent=None): - ''' - Validates the signature header. - - headers (dict): All of the headers to be used to check a signature. The signature header must be included too - method (str): The http method used in relation to the headers - path (str): The path of the request in relation to the headers - client (pool object): Specify a httpClient to use for fetching the actor. optional - agent (str): User agent used for fetching actor data. optional - ''' - - client = httpClient(agent=agent) if not client else client - headers = {k.lower(): v for k,v in headers.items()} - - signature = ParseSig(headers) - - actor_data = client.json(signature['keyid']) - logging.debug(actor_data) - - try: - pubkey = actor_data['publicKey']['publicKeyPem'] - - except Exception as e: - logging.verbose(f'Failed to get public key for actor {signature["keyid"]}') - return - - valid = httpsig.HeaderVerifier(headers, pubkey, signature['headers'], method, path, sign_header='signature').verify() - - if not valid: - if not isinstance(valid, tuple): - logging.verbose('Signature validation failed for unknown actor') - logging.verbose(valid) - - else: - logging.verbose(f'Signature validation failed for actor: {valid[1]}') - - return - - else: - return True - - -def ValidateRequest(request, client=None, agent=None): - ''' - Validates the headers in a Sanic or Aiohttp request (other frameworks may be supported) - See ValidateSignature for 'client' and 'agent' usage - ''' - return ValidateSignature(request.headers, request.method, request.path, client, agent) diff --git a/uncia/Lib/IzzyLib/logging.py b/uncia/Lib/IzzyLib/logging.py deleted file mode 100644 index 78dee25..0000000 --- a/uncia/Lib/IzzyLib/logging.py +++ /dev/null @@ -1,209 +0,0 @@ -'''Simple logging module''' - -import sys - -from os import environ as env -from datetime import datetime - - -stdout = sys.stdout - - -class Log(): - def __init__(self, config=dict()): - '''setup the logger''' - if not isinstance(config, dict): - raise TypeError(f'config is not a dict') - - self.levels = { - 'CRIT': 60, - 'ERROR': 50, - 'WARN': 40, - 'INFO': 30, - 'VERB': 20, - 'DEBUG': 10, - 'MERP': 0 - } - - self.long_levels = { - 'CRITICAL': 'CRIT', - 'ERROR': 'ERROR', - 'WARNING': 'WARN', - 'INFO': 'INFO', - 'VERBOSE': 'VERB', - 'DEBUG': 'DEBUG', - 'MERP': 'MERP' - } - - self.config = {'windows': sys.executable.endswith('pythonw.exe')} - self.setConfig(self._parseConfig(config)) - - - def _lvlCheck(self, level): - '''make sure the minimum logging level is an int''' - try: - value = int(level) - - except ValueError: - level = self.long_levels.get(level.upper(), level) - value = self.levels.get(level) - - if value not in self.levels.values(): - raise InvalidLevel(f'Invalid logging level: {level}') - - return value - - - def _getLevelName(self, level): - for name, num in self.levels.items(): - if level == num: - return name - - raise InvalidLevel(f'Invalid logging level: {level}') - - - def _parseConfig(self, config): - '''parse the new config and update the old values''' - date = config.get('date', self.config.get('date',True)) - systemd = config.get('systemd', self.config.get('systemd,', True)) - windows = config.get('windows', self.config.get('windows', False)) - - if not isinstance(date, bool): - raise TypeError(f'value for "date" is not a boolean: {date}') - - if not isinstance(systemd, bool): - raise TypeError(f'value for "systemd" is not a boolean: {date}') - - level_num = self._lvlCheck(config.get('level', self.config.get('level', 'INFO'))) - - newconfig = { - 'level': self._getLevelName(level_num), - 'levelnum': level_num, - 'datefmt': config.get('datefmt', self.config.get('datefmt', '%Y-%m-%d %H:%M:%S')), - 'date': date, - 'systemd': systemd, - 'windows': windows, - 'systemnotif': config.get('systemnotif', None) - } - - return newconfig - - - def setConfig(self, config): - '''set the config''' - self.config = self._parseConfig(config) - - - def getConfig(self, key=None): - '''return the current config''' - if key: - if self.config.get(key): - return self.config.get(key) - else: - raise ValueError(f'Invalid config option: {key}') - return self.config - - - def printConfig(self): - for k,v in self.config.items(): - stdout.write(f'{k}: {v}\n') - - stdout.flush() - - - def setLevel(self, level): - self.minimum = self._lvlCheck(level) - - - def log(self, level, *msg): - if self.config['windows']: - return - - '''log to the console''' - levelNum = self._lvlCheck(level) - - if type(level) == int: - level = _getLevelName(level) - - if levelNum < self.config['levelnum']: - return - - message = ' '.join([str(message) for message in msg]) - output = f'{level}: {message}\n' - - if self.config['systemnotif']: - self.config['systemnotif'].New(level, message) - - if self.config['date'] and (self.config['systemd'] and not env.get('INVOCATION_ID')): - '''only show date when not running in systemd and date var is True''' - date = datetime.now().strftime(self.config['datefmt']) - output = f'{date} {output}' - - stdout.write(output) - stdout.flush() - - - def critical(self, *msg): - self.log('CRIT', *msg) - - def error(self, *msg): - self.log('ERROR', *msg) - - def warning(self, *msg): - self.log('WARN', *msg) - - def info(self, *msg): - self.log('INFO', *msg) - - def verbose(self, *msg): - self.log('VERB', *msg) - - def debug(self, *msg): - self.log('DEBUG', *msg) - - def merp(self, *msg): - self.log('MERP', *msg) - - -def getLogger(loginst, config=None): - '''get a logging instance and create one if it doesn't exist''' - Logger = logger.get(loginst) - - if not Logger: - if config: - logger[loginst] = Log(config) - - else: - raise InvalidLogger(f'logger "{loginst}" doesn\'t exist') - - return logger[loginst] - -class InvalidLevel(Exception): - '''Raise when an invalid logging level was specified''' - -class InvalidLogger(Exception): - '''Raise when the specified logger doesn't exist''' - - -'''create a default logger''' -logger = { - 'default': Log() -} - -DefaultLog = logger['default'] - - -'''aliases for default logger's log output functions''' -critical = DefaultLog.critical -error = DefaultLog.error -warning = DefaultLog.warning -info = DefaultLog.info -verbose = DefaultLog.verbose -debug = DefaultLog.debug -merp = DefaultLog.merp - -'''aliases for the default logger's config functions''' -setConfig = DefaultLog.setConfig -getConfig = DefaultLog.getConfig -setLevel = DefaultLog.setLevel -printConfig = DefaultLog.printConfig diff --git a/uncia/Lib/IzzyLib/misc.py b/uncia/Lib/IzzyLib/misc.py deleted file mode 100644 index 05ef0b5..0000000 --- a/uncia/Lib/IzzyLib/misc.py +++ /dev/null @@ -1,44 +0,0 @@ -'''Miscellaneous functions''' -import random, string, sys, os - -from os import environ as env -from datetime import datetime -from pathlib import path -from os.path import abspath, dirname, basename, isdir, isfile - -from . import logging - - -def Boolean(v, return_value=False): - if type(v) not in [str, bool, int, type(None)]: - raise ValueError(f'Value is not a string, boolean, int, or nonetype: {value}') - - '''make the value lowercase if it's a string''' - value = v.lower() if isinstance(v, str) else v - - if value in [1, True, 'on', 'y', 'yes', 'true', 'enable']: - '''convert string to True''' - return True - - elif value in [0, False, None, 'off', 'n', 'no', 'false', 'disable', '']: - '''convert string to False''' - return False - - elif return_value: - '''just return the value''' - return v - - else: - return True - - -def RandomGen(chars=20): - if not isinstance(chars, int): - raise TypeError(f'Character length must be an integer, not a {type(char)}') - - return ''.join(random.choices(string.ascii_letters + string.digits, k=chars)) - - -def FormatUtc(timestamp=None): - date = datetime.fromtimestamp(timestamp) if timestamp else datetime.utcnow() - return date.strftime('%a, %d %b %Y %H:%M:%S GMT') diff --git a/uncia/Lib/IzzyLib/template.py b/uncia/Lib/IzzyLib/template.py deleted file mode 100644 index c6c7cdf..0000000 --- a/uncia/Lib/IzzyLib/template.py +++ /dev/null @@ -1,190 +0,0 @@ -'''functions for web template management and rendering''' -import codecs, traceback, os, json, aiohttp, xml - -from os import listdir, makedirs -from os.path import isfile, isdir, getmtime, abspath - -from jinja2 import Environment, FileSystemLoader, ChoiceLoader, select_autoescape, Markup -from sanic import response as Response -from hamlpy.hamlpy import Compiler -from markdown import markdown -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler - -from . import logging -from .color import * - - -class Template(Environment): - def __init__(self, build={}, search=[], global_vars={}, autoescape=None): - self.autoescape = ['html', 'css'] if not autoescape else autoescape - self.search = [] - self.build = {} - - for source, dest in build.items(): - self.__addBuildPath(source, dest) - - for path in search: - self.__addSearchPath(path) - - self.var = { - 'markdown': markdown, - 'markup': Markup, - 'cleanhtml': remove_tags, - 'lighten': lighten, - 'darken': darken, - 'saturate': saturate, - 'desaturate': desaturate, - 'rgba': rgba - } - - self.var.update(global_vars) - - super().__init__( - loader=ChoiceLoader([FileSystemLoader(path) for path in self.search]), - autoescape=select_autoescape(self.autoescape), - lstrip_blocks=True, - trim_blocks=True - ) - - - def __addSearchPath(self, path): - tplPath = abspath(str(path)) - - if tplPath not in self.search: - self.search.append(tplPath) - - - def __addBuildPath(self, source, destination): - src = abspath(str(source)) - dest = abspath(str(destination)) - - if not isdir(src): - raise FileNotFoundError('Source path doesn\'t exist: {src}') - - self.build[src] = dest - self.__addSearchPath(dest) - - - def addEnv(self, k, v): - self.var[k] = v - - - def delEnv(self, var): - if not self.var.get(var): - raise ValueError(f'"{var}" not in global variables') - - del self.var[var] - - - def render(self, tplfile, context, request=None, headers={}, cookies={}, **kwargs): - if not isinstance(context, dict): - raise TypeError(f'context for {tplfile} not a dict') - - data = global_variables.copy() - data['request'] = request if request else {'headers': headers, 'cookies': cookies} - data.update(context) - - return env.get_template(tplfile).render(data) - - - def response(self, *args, ctype='text/html', status=200, headers={}, **kwargs): - html = self.render(*args, **kwargs) - return Response.HTTPResponse(body=html, status=status, content_type=ctype, headers=headers) - - - def buildTemplates(self, src=None): - paths = {src: self.search.get(src)} if src else self.search - - for src, dest in paths.items(): - timefile = f'{dest}/times.json' - updated = False - - if not isdir(f'{dest}'): - makedirs(f'{dest}') - - if isfile(timefile): - try: - times = json.load(open(timefile)) - - except: - times = {} - - else: - times = {} - - for filename in listdir(src): - fullPath = f'{src}/{filename}' - modtime = getmtime(fullPath) - base, ext = filename.split('.', 1) - - if ext != 'haml': - pass - - elif base not in times or times.get(base) != modtime: - updated = True - logging.verbose(f"Template '{filename}' was changed. Building...") - - try: - destination = f'{dest}/{base}.html' - haml_lines = codecs.open(fullPath, 'r', encoding='utf-8').read().splitlines() - - compiler = Compiler() - output = compiler.process_lines(haml_lines) - outfile = codecs.open(destination, 'w', encoding='utf-8') - outfile.write(output) - - logging.info(f"Template '{filename}' has been built") - - except Exception as e: - '''I'm actually not sure what sort of errors can happen here, so generic catch-all for now''' - traceback.print_exc() - logging.error(f'Failed to build {filename}: {e}') - - times[base] = modtime - - if updated: - with open(timefile, 'w') as filename: - filename.write(json.dumps(times)) - - - def remove_tags(self, text): - return ''.join(xml.etree.ElementTree.fromstring(text).itertext()) - - - def setupWatcher(self): - watchPaths = [path['source'] for k, path in build_path_pairs.items()] - logging.info('Starting template watcher') - observer = Observer() - - for tplpath in watchPaths: - logging.debug(f'Watching template dir for changes: {tplpath}') - observer.schedule(templateWatchHandler(), tplpath, recursive=True) - - self.watcher = observer - - - def startWatcher(self): - if not self.watcher: - self.setupWatcher() - - self.watcher.start() - - - def stopWatcher(self, destroy=False): - self.watcher.stop() - - if destroy: - self.watcher = None - - -class TemplateWatchHandler(FileSystemEventHandler): - def on_any_event(self, event): - filename, ext = os.path.splitext(os.path.relpath(event.src_path)) - - if event.event_type in ['modified', 'created'] and ext[1:] == 'haml': - logging.info('Rebuilding templates') - buildTemplates() - - -__all__ = ['addSearchPath', 'delSearchPath', 'addBuildPath', 'delSearchPath', 'addEnv', 'delEnv', 'setup', 'renderTemplate', 'aiohttp', 'buildTemplates', 'templateWatcher'] diff --git a/uncia/__init__.py b/uncia/__init__.py index 0995fa8..b61de7a 100644 --- a/uncia/__init__.py +++ b/uncia/__init__.py @@ -1,6 +1,4 @@ -''' -Uncia Relay by Zoey Mae -https://git.barkshark.xyz/izaliamae/uncia -''' -import sys, os -sys.path.append(f'{os.getcwd()}/modules') +__package__ = 'Uncia Relay' +__version__ = '0.1.0' +__author__ = 'Zoey Mae' +__homepage__ = 'https://git.barkshark.xyz/izaliamae/uncia' diff --git a/uncia/__main__.py b/uncia/__main__.py index d8929d4..8da3766 100644 --- a/uncia/__main__.py +++ b/uncia/__main__.py @@ -1,9 +1,4 @@ -#!/usr/bin/env python3 -import sys -from .server import main +from .server import app -try: - main() -except KeyboardInterrupt: - sys.exit() +app.start() diff --git a/uncia/admin.py b/uncia/admin.py deleted file mode 100644 index a79b69c..0000000 --- a/uncia/admin.py +++ /dev/null @@ -1,261 +0,0 @@ -import re -import logging as logger - -from datetime import datetime -from ipaddress import ip_address as address -from urllib.parse import urlparse - -from .log import logging -from .functions import format_urls -from .database import get, put, bool_check -from . import messages - - -def get_instance_data(): - newcutoff = 60*60*24*2 - current_time = datetime.timestamp(datetime.now()) - instances = [] - - for inbox in get.inbox('all'): - retries = get.retries({'inbox': inbox['inbox']}) - tag = '' - - if retries: - tag = 'fail' - - elif current_time - newcutoff < inbox['timestamp']: - tag = 'new' - - instances.append({ - 'domain': inbox['domain'], - 'date': datetime.fromtimestamp(inbox['timestamp']).strftime('%Y-%m-%d'), - 'tag': tag, - 'retries': retries - }) - - return instances - - -def get_whitelist_data(): - instances = [] - - for instance in get.whitelist('all'): - instances.append({ - 'domain': instance['domain'], - 'date': datetime.fromtimestamp(instance['timestamp']).strftime('%Y-%m-%d') - }) - - return instances - - -def get_domainbans(): - instances = [] - - for instance in get.domainban('all'): - instances.append({ - 'domain': instance['domain'], - 'reason': instance['reason'] - }) - - return instances - - -def get_userbans(): - users = [] - - for user in get.userban(None, 'all'): - users.append({ - 'user': user['username'], - 'domain': user['domain'], - 'reason': user['reason'] - }) - - return users - - -def sanitize(data, extras=None): - if extras == 'spaces': - extra = '\s' - - elif extras == 'markdown': - extra = '>|`()[\]*#~\-:/\s' - - else: - extra = '' - - return re.sub(r'[^a-zA-Z0-9@_.\-\!\'d,%{}]+'.format(extra), '', data).strip() - - -def ip_check(ip): - try: - return address(ip) - - except: - return '127.0.0.1' - - -def port_check(port): - if int(port) < 1 or int(port) > 25565: - return 3621 - - else: - return int(port) - - -def settings(data): - #if not config('whitelist') and bool_check(sanitize(data['whitelist'])): - # for domain in [inbox['domain'] for inbox in table.inbox.all()]: - # if domain not in LIST['whitelist']: - # update_actor(remove, f'https://{domain}/inbox') - - new_data = {} - - for setting in ['info', 'rules']: - if not data.get(setting): - put.config({setting: None}) - - for k, v in data.items(): - if k == 'port': - try: - new_data.update({k: int(v)}) - - except ValueError: - logging.warning(f'{v} is not a valid value for \'port\'') - - else: - new_data.update({k: v}) - - put.config(new_data) - - logging.setLevel(data["log_level"]) - -def ban(data): - domain = data['name'] - reason = data.get('reason') - - if put.ban('add', domain, reason=reason): - logging.info(f'Added {domain} to the banlist') - - else: - logging.info(f'Failed to add {domain} to the banlist') - - -def unban(data): - domain = data['name'] - - if put.ban('remove', domain): - logging.info(f'Removed {domain} from the banlist') - - else: - logging.info(f'Failed to remove {domain} from the banlist') - - -def add(data): - domain = data['name'] - - if put.whitelist('add', domain): - logging.info(f'Added {domain} to the whitelist') - - else: - logging.info(f'Failed to add {domain} to the whitelist') - -def remove(data): - domain = data['name'] - - if put.whitelist('remove', domain): - logging.info(f'Removed {domain} from the whitelist') - - else: - logging.info(f'Failed to remove {domain} from the whitelist') - - -def accept(data): - row = get.request(data.get('name')) - - if not row: - return - - if not messages.accept(row['followid'], row): - return - - if put.inbox('add', row): - put.request('remove', row) - - return True - - -def deny(data): - row = get.request(data.get('name')) - - if not row: - return - - if put.request('remove', row): - return True - - -def eject(data): - row = get.inbox(data.get('name')) - - if not row: - return - - if put.inbox('remove', row): - return True - - -def retry(data): - rowid = data.get('name') - - if not rowid: - return - - messages.run_retries(msgid=rowid) - - -def remret(data): - rowid = data.get('name') - - if not rowid: - return - - try: - rowid = int(rowid) - except: - return - - put.del_retries(rowid) - - -def auth_code(data): - action = data.get('action', '').lower() - - if action in ['delete', 'regen']: - get.code(action) - act_msg = 'Updated' if action == 'regen' else 'Removed' - - return f'{act_msg} authentication code' - - return f'Invalid auth code action: {action}' - - -def run(action, data): - cmd = { - 'settings': settings, - 'ban': ban, - 'unban': unban, - 'add': add, - 'remove': remove, - 'accept': accept, - 'deny': deny, - 'eject': eject, - 'retry': retry, - 'remret': remret, - 'authcode': auth_code - } - - if action in cmd: - return cmd[action](data) - - else: - logging.error(f'Invalid admin post action: {action}') diff --git a/uncia/config.py b/uncia/config.py index 7ed27de..f952c32 100644 --- a/uncia/config.py +++ b/uncia/config.py @@ -1,83 +1,70 @@ -import os, sys, random, string - -from os import environ as env -from os.path import abspath, dirname, basename, isdir, isfile +import os from envbash import load_envbash - -from .log import logging +from getpass import getuser +from izzylib import DotDict, Path, boolean, izzylog, logging +from os import environ as env -pyv = sys.version_info -version = '0.9.1' +izzylog.set_config('level', 'VERBOSE') +logging.set_config('level', 'DEBUG') -if getattr(sys, 'frozen', False): - full_path = abspath(sys.executable) - exe_path = None - data_path = getattr(sys, '_MEIPASS', dirname(abspath(__file__))) - -else: - full_path = abspath(__file__) - exe_path = abspath(sys.executable) - data_path = dirname(full_path) - -script_path = dirname(full_path) -script_name = basename(full_path) - -if env.get('CONFDIR') != None: - stor_path = abspath(env['CONFDIR']) - - if not isdir(stor_path): - os.makedirs(stor_path, exist_ok=True) - -else: - stor_path = f'{os.getcwd()}/data' - -if not isdir (stor_path): - os.makedirs(stor_path) +scriptpath = Path(__file__).resolve.parent +configpath = scriptpath.parent.join('data') +configpath.mkdir() -envfile = f'{stor_path}/production.env' +path = DotDict( + script = scriptpath, + frontend = scriptpath.join('frontend'), + config = configpath, + envfile = configpath.join('env.production') +) -if isfile(envfile): - load_envbash(envfile) +if not path.envfile.exists: + logging.error(f'Uncia has not been configured yet. Please edit {config.envfile} first.') + write_config() + sys.exit() -else: - logging.warning(f'Cannot find config file. Creating one at {envfile}') - ranchars = ''.join(random.choices(string.ascii_letters + string.digits, k=12)) - newenv = f'''# Uncia DB Config -#DBHOST=localhost -#DBPORT=5432 -#DBUSER=$USER -#DBPASS= -#DBNAME=uncia -#DBCONNUM=25 +load_envbash(path.envfile) -# Web forward config -SECRET={ranchars} -# Development mode -#UNCIA_DEV=False -''' +config = DotDict( + version = 20210911, + listen = env.get('UNCIA_LISTEN', 'localhost'), + port = int(env.get('UNCIA_PORT', 3621)), + host = env.get('UNCIA_HOST', 'example.com'), + dbtype = env.get('UNCIA_DB_TYPE', 'sqlite'), + workers = int(env.get('UNCIA_WORKERS', os.cpu_count())) +) - with open(envfile, 'w') as newenvfile: - newenvfile.write(newenv) +dbconfig = DotDict( + name = path.config.join('database.sqlite3') if config.dbtype == 'sqlite' else env.get('UNCIA_DB_NAME', 'uncia'), + host = env.get('UNCIA_DB_HOST', '/var/run/postgresql'), + port = int(env.get('UNCIA_DB_PORT', 5432)), + username = env.get('UNCIA_DB_USER', getuser()), + password = env.get('UNCIA_DB_PASS'), + max_connections = int(env.get('UNCIA_DB_MAXCON', 25)), + timeout = int(env.get('UNCIA_TIMEOUT', 5)) +) -try: - db_connection_count = int(env.get('DBCONNUM', 25)) -except: - db_connection_count = 25 +def write_config(): + with path.envfile.open('w') as fd: + fd.write(f'''# Main config +UNCIA_LISTEN={config.listen} +UNCIA_PORT={config.port} +UNCIA_HOST={config.host} +UNCIA_WORKERS={config.workers} -dbconfig = { - 'host': env.get('DBHOST'), - 'port': int(env.get('DBPORT', 5432)), - 'user': env.get('DBUSER', env.get('USER')), - 'pass': env.get('DBPASS'), - 'name': env.get('DBNAME', 'uncia'), - 'connum': db_connection_count -} - -fwsecret = env.get('SECRET') -development = env.get('UNCIA_DEV') +# Database config +UNCIA_DB_TYPE={config.dbtype} +UNCIA_DB_NAME={dbconfig.name} +UNCIA_DB_HOST={dbconfig.host} +UNCIA_DB_PORT={dbconfig.port} +UNCIA_DB_NAME={dbconfig.username} +UNCIA_DB_PASS={dbconfig.password} +UNCIA_DB_MAXCON={dbconfig.max_connections} +UNCIA_DB_TIMEOUT={dbconfig.timeout} +''') diff --git a/uncia/database/__init__.py b/uncia/database/__init__.py index c851213..af85127 100644 --- a/uncia/database/__init__.py +++ b/uncia/database/__init__.py @@ -1,234 +1,124 @@ -import pg, uuid, sys, random, string, traceback +from datetime import datetime +from functools import partial +from izzylib import DotDict, boolean, logging +from izzylib.http_requests_client.signature import generate_rsa_key +from izzylib.sql import SqlDatabase, SqlSession +from izzylib.sql import SqlColumn as Column +from izzylib.sql.generic import OperationalError +from urllib.parse import urlparse -from os.path import isfile +from . import get, put, delete -from DBUtils.PooledPg import PooledPg -from pg import DatabaseError -from passlib.context import CryptContext - -from ..log import logging -from ..config import dbconfig, script_path, stor_path -from ..functions import LRUCache +from ..config import config, dbconfig -class cache_dicts(): - config = {} - key = LRUCache() - -dbcache = cache_dicts() +tables = { + 'config': [ + Column('id'), + Column('key', 'text', nullable=False), + Column('value', 'text') + ], + 'inbox': [ + Column('id'), + Column('domain', 'text', nullable=False), + Column('inbox', 'text', nullable=False, unique=True), + Column('actor', 'text', nullable=False, unique=True), + Column('followid', 'text'), + Column('timestamp') + ], + 'retry': [ + Column('id'), + Column('msgid', 'text', nullable=False), + Column('inboxid', 'integer', nullable=False, fkey='inbox.id'), + Column('data', 'json', nullable=False), + Column('headers', 'json') + ], + 'user': [ + Column('id'), + Column('handle', 'text', nullable=False, unique=True), + Column('username', 'text'), + Column('hash', 'text'), + Column('level', 'integer', nullable=False, default=0), + Column('timestamp') + ], + 'token': [ + Column('id'), + Column('userid', 'integer', fkey='user.id'), + Column('code', 'text', nullable=False, unique=True), + Column('timestamp') + ], + 'whitelist': [ + Column('id'), + Column('actor', 'text', nullable=False, unique=True) + ], + 'ban': [ + Column('id'), + Column('handle', 'text'), + Column('domain', 'text'), + Column('reason', 'text') + ] +} -def dbconn(database, pooled=True): - options = { - 'dbname': database, - 'user': dbconfig['user'], - 'passwd': dbconfig['pass'] - } +class Session(SqlSession): + #get = get + #put = put + #delete = delete - if dbconfig['host']: - options.update({ - 'host': dbconfig['host'], - 'port': dbconfig['port'] - }) - - if pooled: - cached = 5 if dbconfig['connum'] >= 5 else 0 - return PooledPg(maxconnections=dbconfig['connum'], mincached=cached, maxusage=1, **options) - - else: - return pg.DB(**options) + config_defaults = dict( + version = (0, int), + pubkey = (None, str), + privkey = (None, str), + name = ('Uncia Relay', str), + description = ('Small and fast ActivityPub relay', str), + require_approval = (True, boolean), + email = (None, str), + show_domain_bans = (False, boolean), + show_user_bans = (False, boolean) + ) -def db_check(): - database = dbconfig['name'] - pre_db = dbconn('postgres', pooled=False) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - if 'dropdb' in sys.argv: - pre_db.query(f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = \'{database}\';') - pre_db.query(f'DROP DATABASE {database};') + self.get = DotDict() + self.put = DotDict() + self.delete = DotDict() - if database not in pre_db.get_databases(): - logging.info('Database doesn\'t exist. Creating it now...') - - pre_db.query(f'CREATE DATABASE {database} WITH TEMPLATE = template0;') - - db_setup = dbconn(database, pooled=False) - database = open(f'{script_path}/database/database.sql').read().replace('\t', '').replace('\n', '') - db_setup.query(database) - db_setup.close() - - pre_db.close() + self._set_commands('get', get) + self._set_commands('put', put) + self._set_commands('delete', delete) -if 'skipdbcheck' not in sys.argv: - db_check() - -dbpool = dbconn(dbconfig['name']) + def _set_commands(self, name, mod): + for method in dir(mod): + if method.startswith('cmd_'): + getattr(self, name)[method[4:]] = partial(getattr(mod, method), self) -def connection(func): - def inner(*args, **kwargs): - conn = kwargs.get('db', dbpool.connection()) - - try: - result = func(*args, **kwargs, db=conn) - - except: - result = None - traceback.print_exc() - - conn.close() - - return result - return inner - - -@connection -def setup(db=None): - dbcheck = db.query('SELECT * FROM config WHERE key = \'setup\'').dictresult() - - if dbcheck == []: - - settings = { - 'host': 'relay.example.com', - 'address': '0.0.0.0', - 'port': 3621, - 'name': 'Uncia Relay', - 'email': None, - 'admin': None, - 'show_domainbans': False, - 'show_userbans': False, - 'whitelist': False, - 'block_relays': True, - 'require_approval': True, - 'notification': False, - 'log_level': 'INFO', - 'development': False, - 'setup': False - } - - for k,v in settings.items(): - db.insert('config', key=k, value=v) - - logging.info('Database setup finished :3') - - -@connection -def query(table, data, one=True, sort=None, db=None): - items = data.items() - k,v = list(items)[0] - SORT = f'ORDER BY {sort} ASC' if sort else '' - - row = db.query(f"SELECT * FROM {table} WHERE {k} = '{v}' {SORT}") +db = SqlDatabase( + config.dbtype, + tables = tables, + session_class = Session, + **dbconfig +) +with db.session as s: try: - result = row.dictresult() - return result[0] if one and len(result)> 0 else result + version = s.get.config('version') - except pg.NoResultError: - return + except OperationalError: + version = 0 + if version == 0: + keys = generate_rsa_key() + db.create_database() + s.put.config('version', config.version) + s.put.config('pubkey', keys.pubkey) + s.put.config('privkey', keys.privkey) -@connection -def query_all(table, sort=None, db=None): - SORT = f'ORDER BY {sort} ASC' if sort else '' - row = db.query(f"SELECT * FROM {table} {SORT}") - - try: - return row.dictresult() - - except pg.NoResultError: - return - - -@connection -def query_or(table, value, keys, one=True, sort=None, db=None): - if type(keys) != list: - return - - query_str = '' - - for key in keys: - query_str += f"{key} = '{value}'" - - if keys[-1] != key: - query_str += f' or ' - - SORT = f'ORDER BY {sort} ASC' if sort else '' - row = db.query(f'SELECT * FROM {table} WHERE {query_str} {SORT}') - - try: - return row.singledict() if one else row.dictresult() - - except pg.NoResultError: + elif version < config.version: pass - -@connection -def query_and(table, data, one=True, sort=None, db=None): - query_str = '' - - items = data.items() - - for k,v in items: - query_str += f"{k} = '{v}'" - - if list(items)[-1][0] != k: - query_str += f' and ' - - SORT = f'ORDER BY {sort} ASC' if sort else '' - row = db.query(f'SELECT * FROM {table} WHERE {query_str} {SORT}') - - try: - return row.singledict() if one else row.dictresult() - - except pg.NoResultError: - pass - - -class HashContext: - def __init__(self, schemes=['argon2'], default='argon2', rounds=25): - self.saltfile = f'{stor_path}/salt.txt' - self.salt = None - self.hasher = CryptContext(schemes=schemes, default=default, argon2__default_rounds=rounds) - - def hash(self, string): - return self.hasher.encrypt(string+self.salt) - - def verify(self, string, hashed): - return self.hasher.verify(string+self.salt, hashed) - - def setsalt(self): - if not isfile(self.saltfile): - self.salt = randomgen() - - with open(self.saltfile, 'w') as newfile: - newfile.write(self.salt) - - else: - self.salt = open(self.saltfile).read() - - -def randomgen(chars=20): - if type(chars) != int: - logging.warn(f'Invalid character length. Must be an int: {chars}') - chars = 20 - - return ''.join(random.choices(string.ascii_letters + string.digits, k=chars)) - - -def bool_check(value): - if type(value) != str: - return value - - if value.lower() in ['on', 'y', 'yes', 'true', 'enable']: - return True - - elif value.lower() in ['off', 'n', 'no', 'false', 'disable', '']: - return False - - else: - return value - - -__all__ = ['connection', 'HashContext', 'randomgen', 'query', 'query_all', 'query_or', 'query_and'] + #for domain in ['barkshark.xyz', 'chomp.life']: + #s.put.instance(f'https://{domain}/inbox', f'https://{domain}/actor') diff --git a/uncia/database/database.sql b/uncia/database/database.sql deleted file mode 100644 index 85c78ac..0000000 --- a/uncia/database/database.sql +++ /dev/null @@ -1,80 +0,0 @@ -CREATE TABLE IF NOT EXISTS config ( - id SERIAL PRIMARY KEY, - key TEXT NOT NULL, - value TEXT -); - - -CREATE TABLE IF NOT EXISTS inboxes ( - id SERIAL PRIMARY KEY, - domain TEXT NOT NULL, - inbox TEXT NOT NULL, - actor TEXT NOT NULL, - timestamp float8 NOT NULL -); - - -CREATE TABLE IF NOT EXISTS retries ( - id SERIAL PRIMARY KEY, - msgid TEXT NOT NULL, - inbox TEXT NOT NULL, - data TEXT NOT NULL, - headers TEXT NOT NULL -); - - -CREATE TABLE IF NOT EXISTS requests ( - id SERIAL PRIMARY KEY, - followid TEXT NOT NULL, - domain TEXT NOT NULL, - inbox TEXT NOT NULL, - actor TEXT NOT NULL -); - - -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - handle TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - timestamp float8 NOT NULL -); - - -CREATE TABLE IF NOT EXISTS tokens ( - id SERIAL PRIMARY KEY, - userid int NOT NULL, - token TEXT NOT NULL, - timestamp float8 NOT NULL -); - - -CREATE TABLE IF NOT EXISTS whitelist ( - id SERIAL PRIMARY KEY, - domain TEXT NOT NULL, - timestamp float8 NOT NULL -); - - -CREATE TABLE IF NOT EXISTS domainbans ( - id SERIAL PRIMARY KEY, - domain TEXT NOT NULL, - reason TEXT, - timestamp float8 NOT NULL -); - - -CREATE TABLE IF NOT EXISTS userbans ( - id SERIAL PRIMARY KEY, - username TEXT NOT NULL, - domain TEXT NOT NULL, - reason TEXT, - timestamp float8 NOT NULL -); - - -CREATE TABLE IF NOT EXISTS keys ( - actor TEXT NOT NULL PRIMARY KEY, - privkey TEXT NOT NULL, - pubkey TEXT NOT NULL -); diff --git a/uncia/database/delete.py b/uncia/database/delete.py new file mode 100644 index 0000000..df76b68 --- /dev/null +++ b/uncia/database/delete.py @@ -0,0 +1,16 @@ +from izzylib import logging + + +def cmd_inbox(self, data): + instance = self.get.inbox(data) + + if not instance: + logging.debug(f'db.get.inbox: instance does not exist: {data}') + return False + + self.remove(row=instance) + + for row in self.search('retry', inboxid=instance.id): + self.remove(row=row) + + return True diff --git a/uncia/database/get.py b/uncia/database/get.py index 32456d9..7d53cbc 100644 --- a/uncia/database/get.py +++ b/uncia/database/get.py @@ -1,246 +1,96 @@ -import pg - -from Crypto.PublicKey import RSA - -from . import * -from . import bool_check as bcheck, dbcache -from ..Lib.IzzyLib import logging -from ..functions import DotDict - -Hash = HashContext() -Hash.setsalt() -auth_code = None - -@connection -def rsa_key(actor, db=None, cached=True): - if cached == True: - cachedkey = dbcache.key.fetch(actor) - - if cachedkey: - return cachedkey - - actor_key = query('keys', {'actor': actor}) - - if not actor_key: - logging.info('No RSA key. Generating one...') - PRIVKEY = RSA.generate(4096) - PUBKEY = PRIVKEY.publickey() - - new_key = { - 'actor': actor, - 'pubkey': PUBKEY.exportKey('PEM').decode('utf-8'), - 'privkey': PRIVKEY.exportKey('PEM').decode('utf-8') - } - - db.begin() - actor_key = db.insert('keys', new_key) - db.end() - - actor_key = DotDict(actor_key) - actor_key.update({ - 'PRIVKEY': RSA.importKey(actor_key['privkey']), - 'PUBKEY': RSA.importKey(actor_key['pubkey']) - }) - - actor_key.size = actor_key.PRIVKEY.size_in_bytes()/2 - - dbcache.key.store(actor, actor_key) - return actor_key +from izzylib import DotDict -@connection -def config(data, default=None, cache=True, db=None): - if len(dbcache.config.keys()) < 1 and cache: - update_config() +def cmd_ban_list(self, types='domain'): + if types == 'domain': + return self.search('ban', handle=None) - if type(data) == list or data == 'all': - settings = {} + bans = [] - if data == 'all': - if dbcache.config and cache: - logging.debug('Returning cached config') - return dbcache.config + for row in self.search('ban'): + if row.handle: + bans.append(row) - rows = query_all('config') + return bans - for row in rows: - settings.update({row['key']: bcheck(row['value'])}) - else: - for k,v in data.items(): - if cache: - logging.debug('Returning cached config') - row = [{'key': key, 'value': value} for key, value in dbcache.config.items()] +def cmd_ban(self, handle=None, domain=None): + cache_key = f'{handle}{domain}' + cache = self.cache.ban.fetch(cache_key) - else: - query_data = {'key': k, 'value': v} - row = query_and('config', query_data) + if cache: + return cache - if row: - settings.update({key, bcheck(row['value'])}) + if handle and not domain: + return self.fetch('ban', handle=handle) - else: - settings.update({key: None}) + elif not handle and domain: + return self.fetch('ban', domain=domain) - return settings + elif handle and domain: + return self.fetch('ban', handle=handle, domain=domain) - elif type(data) == str: - cached = dbcache.config.get(data) + raise ValueError('handle or domain not specified') - if cached and cache: - return cached - row = query('config', {'key': data}) +def cmd_config(self, key): + if key not in self.config_defaults: + raise KeyError(f'Invalid config option: {key}') + + cache = self.cache.config.fetch(key) + + if cache: + return cache + + row = self.fetch('config', key=key) + + if not row: + return self.config_defaults[key][0] + + value = self.config_defaults[key][1](row.value) + self.cache.config.store(key, value) + return value + + +def cmd_config_all(self): + data = DotDict() + + for key in self.config_defaults: + data[key] = self.get.config(key) + + return data + + +def cmd_inbox(self, data): + for line in ['domain', 'inbox', 'actor']: + row = self.fetch('inbox', **{line: data}) if row: - value = bcheck(row['value']) - dbcache.config[data] = value - return value if value != None else default + return row -@connection -def update_config(db=None): - rows = query_all('config') +def cmd_inbox_list(self, value=None): + data = [] - for row in rows: - key = row['key'] - value = bcheck(row['value']) + if value not in [None, 'domain', 'inbox', 'actor']: + raise ValueError('Invalid row data') - dbcache.config[key] = value + for row in self.search('inbox'): + if not row.followid: + if value: + data.append(row[value]) + + else: + data.append(row) + + return data -@connection -def inbox(url, db=None): - return query_all('inboxes', sort='domain') if url == 'all' else query_or('inboxes', url, ['inbox', 'domain', 'actor'], sort='domain') +def cmd_instance(self, data): + for field in ['domain', 'inbox', 'actor']: + row = self.fetch('inbox', **{field: data}) + if row: + break -@connection -def domainban(domain, db=None): - return query_all('domainbans', sort='domain') if domain == 'all' else query('domainbans', {'domain': domain}, sort='domain') - - -@connection -def userban(user, domain, db=None): - return query_all('userbans', sort='username') if domain == 'all' else query_and('userbans', {'username': user, 'domain': domain}, sort='username') - - -@connection -def whitelist(domain, db=None): - return query_all('whitelist', sort='domain') if domain == 'all' else query_and('whitelist', {'domain': domain}, sort='domain') - - -@connection -def request(domain, db=None): - return query_all('requests', sort='domain') if domain == 'all' else query_or('requests', domain, ['inbox', 'domain', 'actor'], sort='domain') - - -@connection -def retries(data, db=None): - if data == 'all': - return query_all('retries') - - if type(data) == int: - return query('retries', {'id': data}) - - if type(data) == dict: - inbox = data.get('inbox') - msgid = data.get('msgid') - - if inbox and msgid: - query_data = {'inbox': inbox, 'msgid': msgid} - return query_and('retries', query_data, one=False) - - elif inbox or msgid: - query_data = {'msgid': msgid} if msgid else {'inbox': inbox} - return query('retries', query_data, one=False) - - else: - logging.error('Failed to provide inbox or message id') - - -@connection -def user(data, db=None): - if not data: - return - - if data == 'all': - return query_all('users') - - if type(data) == int: - return query('users', {'id': data}) - - else: - return query_or('users', data, ['username', 'handle']) - - -@connection -def token(data, *args, db=None, **kwargs): - if type(data) == str: - query_string = {'token': data} - - elif type(data) == int: - query_string = {'id': data} - - elif type(data) == dict: - if 'token' in data.keys(): - query_string = {'token': data['token']} - - elif 'userid' in data.keys(): - query_string = {'userid': data['userid']} - return query('tokens', query_string, one=False, sort='timestamp') - - else: - logging.error(f'Invalid data for get.token: {data}') - return - - else: - logging.verbose(f'Unhandled data type for get.token: {type(data)}') - return - - return query('tokens', query_string) - - -@connection -def verify_password(username, password, db=None): - user_data = user(username) - - if not user_data: - logging.verbose(f'Invalid user when trying to verify password: {username}') - return - - return Hash.verify(password, user_data['password']) - - -def code(action=None): - global auth_code - - if action in ['regen', 'delete']: - auth_code = randomgen() if action == 'regen' else None - - return auth_code - - -# generate an auth code if there are no admin users -users = user('all') -if not users or len(users) < 1: - code('regen') - host = config('host') - port = config('port') - address = config('address') - - address = '127.0.0.1' if address == '0.0.0.0' else address - - if host: - if config('setup'): - logging.warning(f'There are no admin users in the database. Please register an account at https://{host}/register?code={auth_code}') - - else: - logging.warning(f'The relay is not configured. Please set it up at http://{address}:{port}/setup?code={auth_code}') - -else: - auth_code = None - - -# Set log level from config -log_level = config('log_level') -logging.setLevel(log_level if log_level else 'INFO') + return row diff --git a/uncia/database/put.py b/uncia/database/put.py index 930f332..ec5d0f5 100644 --- a/uncia/database/put.py +++ b/uncia/database/put.py @@ -1,297 +1,32 @@ -import pg, json - from datetime import datetime - -from . import * -from . import get, bool_check, dbcache -from ..log import logging -from ..functions import format_urls +from izzylib import logging +from urllib.parse import urlparse -Hash = HashContext() -Hash.setsalt() - - -@connection -def config(data, db=None): - db.begin() - - for k,v in data.items(): - value = bool_check(v) - row = query('config', {'key': k}) - - data = { - 'key': k, - 'value': value - } - - if row: - data['id'] = row['id'] - - db.upsert('config', data) - - db.end() - get.update_config() - - -@connection -def rsa_key(name, keys, db=None): - key = { - 'actor': name, - 'pubkey': keys['pubkey'], - 'privkey': keys['privkey'] - } - - if db.upsert('keys', key, actor=name): - dbcache.key.store(name, get.rsa_key(name, cached=False)) - return True - - -@connection -def inbox(action, urls, timestamp=None, db=None): - actor, inbox, domain = format_urls(urls) - row = get.inbox(actor) - - if (row and action == 'add') or (not row and action == 'remove'): - return True - - if action == 'add': - data = { - 'domain': domain, - 'inbox': inbox, - 'actor': actor, - 'timestamp': datetime.now().timestamp() if not timestamp else timestamp - } - - db.insert('inboxes', data) - - elif action == 'remove': - db.delete('inboxes', id=row['id']) - - else: - return - - return True - - -@connection -def request(action, urls, followid=None, db=None): - actor, inbox, domain = format_urls(urls) - row = get.request(domain) - - if (row and action == 'add') or (not row and action == 'remove'): - return True - - if action == 'add' and followid: - data = { - 'domain': domain, - 'inbox': inbox, - 'actor': actor, - 'followid': followid, - 'timestamp': datetime.now().timestamp() - } - db.insert('requests', data) - - return True - - if action == 'remove': - db.delete('requests', id=row['id']) - - return True - - -@connection -def add_retry(msgid, inbox, data, headers, db=None): - row = get.retries({'msgid': msgid, 'inbox': inbox}) +def cmd_config(self, key, value): + row = self.fetch('config', key=key) if row: - return True - - domain_retries = get.retries({'inbox': inbox}) - - if len(domain_retries) >= 500: - return - - data = { - 'inbox': inbox, - 'data': json.dumps(data), - 'headers': json.dumps(headers), - 'msgid': msgid, - 'timestamp': datetime.now().timestamp() - } - - if db.insert('retries', data): - return True - - -@connection -def del_retries(data, db=None): - if type(data) == int: - rows = [get.retries(data)] + self.update(row=row, value=value) else: - rows = get.retries(data) + self.insert('config', key=key, value=value) - if not rows: + self.cache.config.store(key, value) + + +def cmd_instance(self, inbox, actor, followid=None): + if self.get.instance(inbox): + logging.verbose(f'Inbox already in database: {inbox}') return - for row in rows: - db.delete('retries', id=row['id']) + row = self.insert('inbox', + domain = urlparse(inbox).netloc, + inbox = inbox, + actor = actor, + followid = followid, + timestamp = datetime.now(), + return_row = True + ) - return True - - -@connection -def ban(action, data, reason=None, db=None): - if '@' in data: - if data.startswith('@'): - data = data.replace('@', '', 1) - - username, domain = data.split('@') - - else: - username = None - domain = urlparse(data).netloc if data.startswith('https://') else data - - row = get.userban(username, domain) if username else get.domainban(domain) - bantype = 'userbans' if username else 'domainbans' - - if action == 'remove' and not row: - return True - - if action == 'add': - data = { - 'domain': domain, - 'reason': reason, - 'timestamp': datetime.now().timestamp() - } - - if username: - data.update({'username': username}) - - if row: - return True if db.update(bantype, data, id=row['id']) else False - - else: - return True if db.insert(bantype, data) else False - - if action == 'remove': - return True if db.delete(bantype, id=row['id']) else False - - -@connection -def whitelist(action, data, db=None): - domain = urlparse(data).netloc if data.startswith('https://') else data - - row = get.whitelist(domain) - - if action == 'remove' and not row: - return True - - if action == 'add': - data = { - 'domain': domain, - 'timestamp': datetime.now().timestamp() - } - - if row: - db.update('whitelist', data, id=row['id']) - - else: - db.insert('whitelist', data) - - if action == 'remove': - db.delete('whitelist', id=row['id']) - - -@connection -def user(username, password, db=None): - handle = username.lower() - timestamp = datetime.now().timestamp() - - if query('users', {'username': username}): - return - - data = { - 'handle': handle, - 'username': username, - 'password': Hash.hash(password), - 'timestamp': timestamp - } - - return db.insert('users', data) - - -@connection -def del_user(token=None, username=None, db=None): - if not username and not token: - return - - if not username and token: - token_data = get.token(token) - userid = token_data['userid'] if token_data else None - - else: - user = get.user(username) - userid = user['id'] if user else None - - if not userid: - return - - tokens = query('tokens', {'userid': userid}, one=False) - - for token in tokens: - db.delete('tokens', id=token['id']) - - db.delete('users', id=userid) - - -@connection -def token(username, db=None): - userdata = get.user(username) - - if not userdata: - return - - tokendata = { - 'userid': userdata['id'], - 'token': randomgen(chars=40), - 'timestamp': datetime.now().timestamp() - } - - return db.insert('tokens', tokendata) - - -@connection -def del_token(token, db=None): - row = get.token(token) - - if not row: - return - - if db.delete('tokens', id=row['id']): - return True - - -@connection -def acct_name(handle, username, db=None): - data = {'username': username} - user = get.user(handle) - - if not user: - logging.warning(f'Invalid user: {handle}') - return - - if db.update('users', data, id=user['id']): - return True - - -@connection -def password(handle, password, db=None): - user = get.user(handle) - - if not user: - logging.warning(f'Invalid user: {handle}') - - if db.update('users', {'password': Hash.hash(password)}, id=user['id']): - return True + return row diff --git a/uncia/errors.py b/uncia/errors.py deleted file mode 100644 index 044efd9..0000000 --- a/uncia/errors.py +++ /dev/null @@ -1,33 +0,0 @@ -import traceback - -from sanic import response - -from .log import logging -from .templates import error - - -def logstr(request, status, e=False): - if e: - logging.error(e) - uagent = request.headers.get('user-agent') - logging.info(f'{request.remote_addr} "{request.method} {request.path}" {status} "{uagent}"') - - -def not_found(request, exception): - return error(request, f'Not found: {request.path}', 404) - - -def method_not_supported(request, exception): - return error(request, f'Invalid method: {request.method}', 405) - - -def server_error(request, exception): - logstr(request, 500, e=exception) - msg = 'OOPSIE WOOPSIE!! Uwu We made a fucky wucky!! A wittle fucko boingo! The code monkeys at our headquarters are working VEWY HAWD to fix this!' - return error(request, msg, 500) - - -def no_template(request, exception): - logstr(request, 500, e=exception) - msg = 'I\'m a dumbass and forgot to create a template for this page' - return error(request, msg, 500) diff --git a/uncia/frontend/color.css b/uncia/frontend/color.css deleted file mode 100644 index 3b50949..0000000 --- a/uncia/frontend/color.css +++ /dev/null @@ -1,173 +0,0 @@ -{% set primary = '#C6C' %} -{% set secondary = '#68C' %} -{% set error = '#D44' %} -{% set background = '#202020' %} -{% set text = '#DDD' %} - -/* variables */ -:root { - --text: {{text}}; - --bg-color: {{background}}; - --bg-color-dark: {{desaturate(darken(primary, 0.85), 0.8)}}; - --bg-color-lighter: {{lighten(background, 0.075)}}; - --bg-dark: {{desaturate(darken(primary, 0.90), 0.5)}}; - --primary: {{primary}}; - --valid: {{desaturate(darken('green', 0.5), 0.5)}}; - --shadow-color: {{rgba('black', 0.5)}}; - - --shadow: 0 4px 4px 0 var(--shadow-color), 0 6px 10px 0 var(--shadow-color); - --border-radius: 10px; -} - - -/* general */ -*:not(#content), *:not(.section) { - transition-property: color, background-color, border, width, height; - transition-timing-function: ease-in-out; - transition-duration: 0.35s; -} - -body { - background-color: var(--bg-dark); - color: var(--text); -} - -a { - color: {{saturate(lighten(primary, 0.4), 0.2)}}; - text-decoration: none; -} - -select { - -webkit-appearance: none; - -moz-appearance: none; -} - -input, textarea, select { - color: var(--text); - border: 1px solid transparent; - background-color: var(--bg-color); - box-shadow: var(--shadow); -} - -input:hover, textarea:hover, select:hover { - color: {{desaturate(primary, 0.6)}}; - border-color: {{desaturate(primary, 0.6)}}; -} - -input:focus, textarea:focus, select:focus { - color: {{primary}}; - background-color: var(--bg-dark); - border-color: {{primary}}; -} - -input:disabled, textarea:disabled, select:disabled { - color: {{desaturate(darken(primary, 0.2), 0.6)}} -} - -a:hover { - color: {{saturate(primary, 0.8)}}; -} - -#content { - background-color: var(--bg-color); - box-shadow: var(--shadow); -} - -.section { - background-color: var(--bg-color-lighter); - box-shadow: var(--shadow); -} - -tr { - border: 1px solid black; - border-radius: var(--border-radius); -} - -tr:nth-child(odd):not(.header) td { - background-color: {{desaturate(darken(primary, 0.80), 0.9)}}; -} - -tr:nth-child(even) td { - background-color: {{desaturate(darken(primary, 0.75), 1)}}; -} - -tr:not(.header):hover td { - color: var(--bg-color-dark); - background-color: {{desaturate(primary, 0.2)}}; -} - -tr:not(.header):hover td a { - color: var(--bg-color-dark); -} - -.new td { - background-color: {{desaturate(darken(primary, 0.70), 0.5)}} !important; -} - -.new:hover td { - background-color: {{desaturate(primary, 0.1)}} !important; -} - -.fail td { - background-color: {{darken(error, 0.75)}} !important; -} - -.fail:hover td { - background-color: {{darken(saturate(error, 0.3), 0.2)}} !important; -} - -/* Dropdown menus */ -.menu { - background-color: var(--bg-color-dark); - box-shadow: var(--shadow); -} - -/*.menu summary { - color: {{desaturate(primary, 0.6)}}; -}*/ - -.submenu details[open] { - background-color: {{desaturate(darken(primary, 0.87), 0.8)}}; -} - - -/* admin area */ -#setmenu .selected{ - background-color: {{lighten(background, 0.20)}}; -} - -.setmenu-item { - color: {{text}} -} - -.setmenu-item:hover { - color: {{primary}}; -} - -.admin summary[open] { - border-bottom: 1px solid var(--primary); -} - -.stats .grid-item, .info .grid-item { - background-color: {{desaturate(darken(primary, 0.75), 0.9)}}; - box-shadow: var(--shadow); -} - -.stats .grid-item:hover, .info .grid-item:hover { - background-color: {{desaturate(darken(primary, 0.70), 0.2)}}; -} - - -/* setup page */ -.error { - color: {{error}}; - background-color: {{desaturate(darken(error, 0.85), 0.50)}}; -} - - -/* account page */ -.tokens .active td { - background-color: var(--valid); -} - -{% include 'layout.css' %} diff --git a/uncia/frontend/favicon.png b/uncia/frontend/favicon.png deleted file mode 100644 index 1f5b602..0000000 Binary files a/uncia/frontend/favicon.png and /dev/null differ diff --git a/uncia/frontend/info.md b/uncia/frontend/info.md deleted file mode 100644 index c47fa28..0000000 --- a/uncia/frontend/info.md +++ /dev/null @@ -1 +0,0 @@ -Just a fediverse relay. Check the [FAQ](/faq) for more info. diff --git a/uncia/frontend/layout.css b/uncia/frontend/layout.css deleted file mode 100644 index 3f705fe..0000000 --- a/uncia/frontend/layout.css +++ /dev/null @@ -1,426 +0,0 @@ -/* general */ -body { - font-family: "Noto Sans", Sans-Serif; - font-size: 12pt; - margin: 0px; -} - -input, textarea, select, div.placeholder { - height: 22px; - padding: 2px 7px; - -moz-border-radius: var(--border-radius); - border-radius: var(--border-radius); - margin: 5px; -} - -select { - height: 28px !important; -} - -summary:hover { - cursor: pointer; -} - -#content { - width: 1000px; - margin: 0 auto; - padding-bottom: 1px; - border-radius: var(--border-radius); -} - -#header h1 { - margin: 0px; - padding-top: 20px; - padding-bottom: 15px; - text-align: center; -} - -#footer { - grid-template-columns: auto auto auto; - font-size: 10px; -} - -#footer .col2 { - text-align: right; -} - -#footer p { - margin: 0; -} - -.section { - margin: 15px auto; - padding: 10px; - width: calc(100% - 45px); - border-radius: var(--border-radius); -} - -.section .title { - margin-top: 0; - margin-bottom: 10px; - text-align: center; -} - -input.placeholder { - height: 21px; -} - -label.placeholder { - height: 19px; -} - -table { - width: 100%; - border-spacing: 0; -} - -td { - padding: 5px; -} - -.instance { - text-align: left; -} - -.timestamp { - text-align: right; - width: 135px; -} - -tr:nth-child(2) .col1 { - border-top-left-radius: var(--border-radius); -} - -tr:nth-child(2) .col2 { - border-top-right-radius: var(--border-radius); -} - -tr:last-child .col1 { - border-bottom-left-radius: var(--border-radius); -} - -tr:last-child .col2 { - border-bottom-right-radius: var(--border-radius); -} - -.indent1 { - padding-left: 20px; -} - -.indent2 { - padding-left: 40px; -} - -.indent3 { - padding-left: 60px; -} - - -/* Grids */ -.grid-container { - display: grid; - grid-gap: 0; - grid-template-columns: 50% auto; -} - -.grid-item { - display: inline-grid; -} - -/* Dropdown menu */ -.menu { - position: fixed; - display: block; - right: 0; - padding: 5px; - text-align: center; - z-index: 10; - top: 0; - border-radius: 0 0 0 5px; - } - -.menu a { - padding: 5px; -} - -.menu .title { - font-weight: bold; - font-size: 14pt; - text-transform: uppercase; -} - -.menu .item { - padding: 5px 0; - text-transform: uppercase; - font-size: 14pt; -} - -.menu-right details[open] summary ~ * { - animation: sweep-left .5s ease-in-out; -} - - -/* home */ -#home_instances table .header { - font-size: 18pt; - font-weight: bold; -} - - -/* auth stuff */ -#auth { - text-align: center; -} - - -/* admin */ -.sec-header { - font-size: 36px; - margin: 0px; - font-weight: bold; - text-align: center; - width: 100%; -} - -.admin summary { - font-size: 18pt; - text-align: center; -} - -.admin td:not(.header) { - padding: 0 5px; -} - -.admin table .header td { - padding: 5px; -} - -.admin table .header { - font-size: 16pt; - font-weight: bold; -} - -.admin table .col1 a { - line-height: 100%; -} - -.admin table .col2, .admin table .action { - width: 75px; - text-align: center; -} - -.admin table .col2 input { - width: 90%; -} - -.admin .domain { - width: 35%; -} - -.admin .domain:hover { - width: 45%; -} - -.admin .domain:focus { - width: calc(100% - 30px); -} - -.admin .mainban input { - height: calc(100px - 30px); -} - -.settings textarea { - width: calc(100% - 20px); - height: 4em; - resize: none; -} - -.settings textarea:focus { - height: 16em; -} - -#network input { - width: calc(100% - 20px); -} - -#submit { - text-align: center; -} - -.settings .submit { - width: 50%; -} - -#code .col2 { - text-align: right; -} - - -/* Admin menu */ -#setmenu { - padding: 0px; - text-align: center; - display: block; -} - -.setmenu-item { - padding: 10px; - font-weight: bold; - display: inline-block; -} - -.retries textarea { - min-width: 200px; -} - -.retries textarea:focus { - height: 200px; -} - -#code input[type="submit"] { - display: inline-block; - width: 100px; -} - - -/* info page */ -.stats .title, .info .title { - font-size: 16pt; - font-weight: bold; - text-align: center; -} - -.stats .sub-title, .info .sub-title { - font-weight: bold; - text-align: center; -} - -.info .grid-container { - grid-template-columns: 50% auto; -} - -.stats .grid-container { - grid-template-columns: 25% 25% 25% auto; -} - -.stats .grid-item, .info .grid-item { - min-height: 150px; - margin: 10px; - border-radius: var(--border-radius); - padding: 10px; -} - -.relay-info .indent { - display: inline; -} - -.relay-info .whitelist { - text-align: center; -} - -#footer .acct { - text-align: center; -} - -#footer .col1, #footer .col2 { - min-width: 200px; -} - - -/* cache page */ -.cache .cache-item textarea { - width: calc(100% - 20px); - height: 200px; - resize: vertical; -} - - -/* setup page */ -.error { - text-align: center; - font-weight: bold; -} - - -/* account page */ -.account { - text-align: center; -} - -.account input:not([type="submit"]) { - width: calc(50% - 20px); -} - -.account input[type="submit"] { - min-width: 200px; -} - -.account .tokens .col1 { - text-align: left; - min-width: 100px; -} - -.account .tokens .col2 { - width: 50px; -} - -.account .tokens .col2 input[type="submit"] { - min-width: 50px; -} - - -/* mobile/small screen */ -@media (max-width : 1000px) { - #content { - width: 100%; - border-radius: 0; - } - - .grid-container:not(#footer) { - grid-template-columns: auto; - } -} - -@media (max-width : 1000px) { - .stats .grid-container { - grid-template-columns: 33% 33% auto; - } - - .account input[type="submit"] { - min-width: auto; - } -} - -@media (max-width : 800px) { - .stats .grid-container { - grid-template-columns: 50% auto; - } - - .relay-info .indent { - padding-left: 20px; - display: block; - } -} - -@media (max-width : 600px) { - .stats .grid-container { - grid-template-columns: auto; - } -} - -@media (max-width : 600px) { - .info .grid-container { - grid-template-columns: auto; - } -} - -/* Horizontal swipe animations */ -@keyframes sweep-right { - 0% {opacity: 0; margin-left: -10px} - 100% {opacity: 1; margin-left: 0px} -} - -@keyframes sweep-left { - 0% {opacity: 0; margin-right: -10px} - 100% {opacity: 1; margin-right: 0px} -} diff --git a/uncia/frontend/menu.haml b/uncia/frontend/menu.haml new file mode 100644 index 0000000..ce961be --- /dev/null +++ b/uncia/frontend/menu.haml @@ -0,0 +1,15 @@ +.item -> %a(href='/') << Home +.item -> %a(href='/rules') << Rules + +-if not request.user + .item -> %a(href='/login') << Login + .item -> %a(href='/register') << Register + +-else + .item -> %a(href='/logout') << Logout + + if request.user.level >= 10 + .item -> %a(href='/user') << User + + if request.user.level >= 20 + .item -> %a(href='/admin') << Admin diff --git a/uncia/frontend/page/home.haml b/uncia/frontend/page/home.haml new file mode 100644 index 0000000..079ad08 --- /dev/null +++ b/uncia/frontend/page/home.haml @@ -0,0 +1,19 @@ +-extends 'base.haml' +-set page = 'Home' +-block head + %link(rel='stylesheet' type='text/css' href='/style/home.css') + +-block content + #description -> =config.description + + %h2 << Connected Instances: + + -if not len(instances): + No instances :/ + + -else + %table#instances + -for instance in instances: + %tr + %td.domain -> %a(href='https://{{instance.domain}}/about', target='_new') -> =instance.domain + %td.timestamp -> =instance.date diff --git a/uncia/frontend/page/login.haml b/uncia/frontend/page/login.haml new file mode 100644 index 0000000..1d3f8ce --- /dev/null +++ b/uncia/frontend/page/login.haml @@ -0,0 +1,9 @@ +-extends 'base.haml' +-set page = 'Login' +-block content + .title -> Login + + %form(id='logreg', action='/login', method='post') + %input(type='text', name='username', placeholder='Username', value='{{form.get("username", "")}}') + %input(type='password', name='password', placeholder='Password', value='{{form.get("password", "")}}') + %input(type='submit', value='Login') diff --git a/uncia/frontend/page/register.haml b/uncia/frontend/page/register.haml new file mode 100644 index 0000000..405df76 --- /dev/null +++ b/uncia/frontend/page/register.haml @@ -0,0 +1,10 @@ +-extends 'base.haml' +-set page = 'Register' +-block content + .title -> Register + + %form(id='logreg', action='/register', method='post') + %input(type='text', name='username', placeholder='Username', value='{{form.get("username", "")}}') + %input(type='password', name='password', placeholder='Password', value='{{form.get("password", "")}}') + %input(type='password', name='password2', placeholder='Password', value='{{form.get("password2", "")}}') + %input(type='submit', value='Login') diff --git a/uncia/frontend/robots.txt b/uncia/frontend/robots.txt deleted file mode 100644 index 1f53798..0000000 --- a/uncia/frontend/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / diff --git a/uncia/frontend/rules.md b/uncia/frontend/rules.md deleted file mode 100644 index 4c0bec0..0000000 --- a/uncia/frontend/rules.md +++ /dev/null @@ -1 +0,0 @@ -This is the default rules list. Ask the admin to fill it out. diff --git a/uncia/frontend/style/home.css b/uncia/frontend/style/home.css new file mode 100644 index 0000000..2667da9 --- /dev/null +++ b/uncia/frontend/style/home.css @@ -0,0 +1,23 @@ +#instances { + width: 100%; +} + +#instances .timestamp { + text-align: right; +} + +#instance .new { + background-color: var(--positive-dark); +} + +#instance .new:hover { + background-color: var(--positive); +} + +#instances .fail { + background-color: var(--negative-dark); +} + +#instances .fail:hover { + background-color: var(--negative-dark); +} diff --git a/uncia/frontend/templates/account.haml b/uncia/frontend/templates/account.haml deleted file mode 100644 index f33dad4..0000000 --- a/uncia/frontend/templates/account.haml +++ /dev/null @@ -1,61 +0,0 @@ -- extends "base.html" -- set title = 'Login' - -- block content - %div{'class': 'section account token'} - %h2{'class': 'title'} Tokens - %table{'class': 'tokens'} - %tr{'class': 'header'} - %td{'class': 'col1'} Token ID - %td Timestamp - %td{'class': 'col2'} Action - - -for token in tokens - -if token.token == cookie.token - -set current = 'active' - -else - -set current = '' - - %tr{'class': 'token_row {{current}}'} - %td{'class': 'col1'} - -if current == 'active' - {{token.token}} ({{current}}) - -else - {{token.token}} - - %td - {{token.timestamp}} - - %td{'class': 'col2'} - -if current != 'active' - %form{'action': 'https://{{config.host}}/account/token', 'method': 'post'} - %input{'type': 'hidden', 'name': 'token', 'value': '{{token.token}}'} - %input{'type': 'submit', 'value': 'Delete'} - - -else - n/a - - %div{'class': 'section account profile'} - %h2{'class': 'title'} Display Name - %form{'action': 'https://{{config.host}}/account/name', 'method': 'post'} - %input{'type': 'text', 'name': 'displayname', 'placeholder': 'displayname', 'value': '{{user.username}}'} - %br - %input{'type': 'submit', 'value': 'Submit'} - - %div{'class': 'section account password'} - %h2{'class': 'title'} Password - %form{'action': 'https://{{config.host}}/account/password', 'method': 'post'} - %input{'type': 'password', 'name': 'password', 'placeholder': 'old password'} - %br - %input{'type': 'password', 'name': 'newpass1', 'placeholder': 'new password'} - %br - %input{'type': 'password', 'name': 'newpass2', 'placeholder': 'new password again'} - %br - %input{'type': 'submit', 'value': 'Submit'} - - %div{'class': 'section account delete'} - %h2{'class': 'title'} Delete Account - %form{'action': 'https://{{config.host}}/account/delete', 'method': 'post'} - %input{'type': 'password', 'name': 'password', 'placeholder': 'password'} - %br - %input{'type': 'submit', 'value': 'Delete'} diff --git a/uncia/frontend/templates/admin.haml b/uncia/frontend/templates/admin.haml deleted file mode 100644 index 10697d6..0000000 --- a/uncia/frontend/templates/admin.haml +++ /dev/null @@ -1,394 +0,0 @@ --extends "base.html" --set title = 'Admin' --set retries = get.retries('all') --set page = data.page - -- block content - %div{'id': 'setmenu', 'class': 'section admin'} - -if page == 'instances' - %a{'href': '/admin?page=instances', 'class': 'setmenu-item selected'}< Instances - -else - %a{'href': '/admin?page=instances', 'class': 'setmenu-item'}< Instances - - -if config.require_approval and len(data.requests) > 0 - -if page == 'requests' - %a{'href': '/admin?page=requests', 'class': 'setmenu-item selected'}< Requests - -else - %a{'href': '/admin?page=requests', 'class': 'setmenu-item'}< Requests - - -if config.whitelist or config.require_approval - -if page == 'whitelist' - %a{'href': '/admin?page=whitelist', 'class': 'setmenu-item selected'}< Whitelist - -else - %a{'href': '/admin?page=whitelist', 'class': 'setmenu-item'}< Whitelist - - -if len(data.domainban) > 0 - -if page == 'domainbans' - %a{'href': '/admin?page=domainbans', 'class': 'setmenu-item selected'}< Domain Bans - -else - %a{'href': '/admin?page=domainbans', 'class': 'setmenu-item'}< Domain Bans - - -if len(data.userban) > 0 - -if page == 'userbans' - %a{'href': '/admin?page=userbans', 'class': 'setmenu-item selected'}< User Bans - -else - %a{'href': '/admin?page=userbans', 'class': 'setmenu-item'}< User Bans - - -if retries - -if page == 'retries' - %a{'href': '/admin?page=retries', 'class': 'setmenu-item selected'}< Retries - -else - %a{'href': '/admin?page=retries', 'class': 'setmenu-item'}< Retries - - -if page == 'settings' - %a{'href': '/admin?page=settings', 'class': 'setmenu-item selected'}< Settings - -else - %a{'href': '/admin?page=settings', 'class': 'setmenu-item'}< Settings - - -if page == 'instances' - %div{'class': 'section admin group instances'} - %p{'class': 'sec-header'} Instances - - %table - %tr{'class': 'header'} - %td{'class': 'col1'} Instance - %td{'class': 'timestamp'} Date - %td{'class': 'col2', 'colspan': 3} Action - - - for instance in data.instances - %tr{'class': 'instance_row {{instance.tag}}' } - %td{'class': 'col1 instance'} - %a{'href': 'https://{{instance.domain}}/about', 'target': '_new'} - {{instance.domain}} {% if instance.retries and len(instance.retries) > 0 %}({{len(instance.retries)}}){% endif %} - - %td{'class': 'timestamp'} - {{instance.date}} - - %td{'class': 'action'} - -if config.whitelist or config.require_approval: - -if instance.domain not in data.wldomains: - %form{'action': 'https://{{config.host}}/admin/add', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'WL Add'} - - -else - %form{'action': 'https://{{config.host}}/admin/remove', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'WL Remove'} - - %td{'class': 'action'} - %form{'action': 'https://{{config.host}}/admin/eject', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Remove'} - - %td{'class': 'col2 action'} - %form{'action': 'https://{{config.host}}/admin/ban', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Ban'} - - %tr - %form{'action': 'https://{{config.host}}/admin/ban', 'method': 'post'} - %td{'class': 'col1', 'colspan': 4} - %input{'class': 'domain', 'name': 'name', 'placeholder': 'domain or user@domain'} - %br - %input{'class': 'domain', 'name': 'reason', 'placeholder': 'Ban reason'} - - %td{'class': 'col2 mainban'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Ban'} - - -if page == 'requests' - %div{'class': 'section admin group requests'} - %p{'class': 'sec-header'} Follow Requests - %table - %tr{'class': 'header'} - %td{'class': 'col1'} Request - %td{'class': 'col2'} Action - - - for domain in data.requests - %tr - %td{'class': 'col1 instance'} - %a{'href': 'https://{{domain.domain}}/about', 'target': '_new'} - {{domain.domain}} - - %td{'class': 'col2'} - %form{'action': 'https://{{config.host}}/admin/accept', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{domain.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Accept'} - - %form{'action': 'https://{{config.host}}/admin/deny', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{domain.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Deny'} - - -if page == 'whitelist' - %div{'class': 'section admin group whitelist'} - %p{'class': 'sec-header'} Whitelist - - %table - %tr{'class': 'header'} - %td{'class': 'col1'} Instance - %td{'class': 'col2'} Action - - - for instance in data.whitelist - %tr{'class': 'instance_row'} - %td{'class': 'col1 instance'} - %a{'href': 'https://{{instance.domain}}/about', 'target': '_new'} - {{instance.domain}} - - %td{'class': 'col2'} - %form{'action': 'https://{{config.host}}/admin/remove', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Remove'} - - %tr - %form{'action': 'https://{{config.host}}/admin/add', 'method': 'post'} - %td{'class': 'col1'} - %input{'class': 'domain', 'name': 'name', 'placeholder': 'domain'} - - %td{'class': 'col2'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Add'} - - -if page == 'domainbans' - %div{'class': 'section admin group domainbans'} - %p{'class': 'sec-header'} Domain Bans - %table - %tr{'class': 'header'} - %td{'class': 'col1'} Instance - %td{'class': 'reason'} Reason - %td{'class': 'col2'} Action - - - for domain in data.domainban - %tr - %td{'class': 'col1 instance'} - %a{'href': 'https://{{domain.domain}}/about', 'target': '_new'} - {{domain.domain}} - - %td{'class': 'reason'} - {{domain.reason}} - - %td{'class': 'col2'} - %form{'action': 'https://{{config.host}}/admin/unban', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{domain.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Unban'} - - -if page == 'userbans' - %div{'class': 'section admin group userbans'} - %p{'class': 'sec-header'} User Bans - - %table - %tr{'class': 'header'} - %td{'class': 'col1'} User - %td{'class': 'reason'} Reason - %td{'class': 'col2'} Action - - - for user in data.userban - %tr - %td{'class': 'col1 instance'} - -if user.domain != 'any' - %a{'href': 'https://{{user.domain}}/users/{{user.user}}', 'target': '_new'} - {{user.user}}@{{user.domain}} - - -else - {{user.user}}@{{user.domain}} - - %td{'class': 'reason'} - {{user.reason}} - - %td{'class': 'col2'} - %form{'action': 'https://{{config.host}}/admin/unban', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{user.user}}@{{user.domain}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Unban'} - - -if page == 'retries' - %div{'class': 'section admin group retries'} - %p{'class': 'sec-header'} Retries - - %table - %tr{'class': 'header'} - %td{'class': 'col1'} ID - %td Inbox - %td Data - %td Headers - %td{'class': 'col2', 'colspan': 2} Action - - -for retry in retries - %tr{'class': 'instance_row'} - %td{'class': 'col1 id'} - {{retry.id}} - - %td - {{retry.inbox}} - - %td - %textarea{'class': 'data'}< - {{retry.data}} - - %td - %textarea{'class': 'headers'}< - {{retry.headers}} - - %td{'class': 'action'} - %form{'action': 'https://{{config.host}}/admin/retry', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{retry.id}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Retry'} - - %td{'class': 'col2 action'} - %form{'action': 'https://{{config.host}}/admin/remret', 'method': 'post'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'name': 'name', 'value': '{{retry.id}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Remove'} - - -if page == 'settings' - %div{'class': 'group settings-div'} - %form{'action': 'https://{{config.host}}/admin/settings', 'method': 'post'} - %div{'class': 'section settings', 'id': 'info'} - %p{'class': 'sec-header'} Server Info - - %label General Info - %textarea{'name': 'info', 'placeholder': 'Relay Info'}< - {% if config.info %}{{config.info}}{% endif %} - - %label Relay Rules - %textarea{'name': 'rules', 'placeholder': 'Relay Rules'}< - {% if config.rules %}{{config.rules}}{% endif %} - - %div{'class': 'section settings', 'id': 'settings'} - %p{'class': 'sec-header'} Relay Settings - %div{'class': 'grid-container'} - -if config.admin - -set admin = config.admin - -else - -set admin = 'None' - - -if config.email - -set email = config.email - -else - -set email = 'None' - - %div{'class': 'grid-item col1'} - %label Name - %input{'type': 'text', 'name': 'name', 'placeholder': 'Relay Name', 'value': '{{config.name}}'} - - %label Contact E-Mail - %input{'type': 'text', 'name': 'email', 'placeholder': 'name@example.com', 'value': '{{email}}'} - - %label Admin Fedi Account - %input{'type': 'text', 'name': 'admin', 'placeholder': 'username@fedi.example.com', 'value': '{{admin}}'} - - %label New Instance Notification - %select{'name': 'notification'} - -if config.notification - %option{'value': 'yes', 'selected': None} Yes - %option{'value': 'no'} No (default) - - -else - %option{'value': 'yes'} Yes - %option{'value': 'no', 'selected': None} No (default) - - %label Block Other Relays - %select{'name': 'block_relays'} - -if config.block_relays - %option{'value': 'yes', 'selected': None} Yes (default) - %option{'value': 'no'} No - - -else - %option{'value': 'yes'} Yes (default) - %option{'value': 'no', 'selected': None} No - - %div{'class': 'grid-item col2'} - %label Show Instance Blocks - %select{'name': 'show_domainbans'} - -if config.show_domainbans - %option{'value': 'yes', 'selected': None} Yes - %option{'value': 'no'} No (default) - - -else - %option{'value': 'yes'} Yes - %option{'value': 'no', 'selected': None} No (default) - - %label Show User Blocks - %select{'name': 'show_userbans'} - -if config.show_userbans - %option{'value': 'yes', 'selected': None} Yes - %option{'value': 'no'} No (default) - - -else - %option{'value': 'yes'} Yes - %option{'value': 'no', 'selected': None} No (default) - - %label Require Approval - %select{'name': 'require_approval'} - -if config.require_approval - %option{'value': 'yes', selected: None} Yes (default) - %option{'value': 'no'} No - - -else - %option{'value': 'yes'} Yes (default) - %option{'value': 'no', selected: None} No - - %label Whitelist Mode - %select{'name': 'whitelist'} - -if config.whitelist - %option{'value': 'yes', selected: None} Yes - %option{'value': 'no'} No (default) - - -else - %option{'value': 'yes'} Yes - %option{'value': 'no', selected: None} No (default) - - %label Log Level - %select{'name': 'log_level'} - - for level in ['MERP', 'DEBUG', 'VERB', 'INFO', 'WARN', 'ERROR', 'CRIT'] - -if config.log_level == level - %option{'value': '{{level}}', selected: None} - {{level}} - - -else - %option{'value': '{{level}}'} - {{level}} - - %div{'class': 'section settings', 'id': 'network'} - %p{'class': 'sec-header'} Network - %div{'class': 'grid-container'} - %div{'class': 'grid-item col1'} - - %label Listen Address - %input{'type': 'text', 'name': 'address', 'placeholder': '127.0.0.1', 'value': '{{config.address}}'} - - %label Listen Port - %input{'type': 'numeric', 'name': 'port', 'placeholder': '3621', 'value': '{{config.port}}'} - - %label Hostname - %input{'type': 'text', 'name': 'host', 'placeholder': 'relay.example.com', 'value': '{{config.host}}'} - - %div{'class': 'section settings', 'id': 'submit'} - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'type': 'submit', 'value': 'Save Settings', 'class': 'submit'} - - %div{'class': 'section settings', 'id': 'code'} - %p{'class': 'sec-header'}< Authentication Code - %div{'class': 'grid-container'} - %div{'class': 'grid-item col1'} - -if data.auth_code - %a{'href': 'https://{{config.host}}/register?code=={data.auth_code}', 'target': '_new'}< - {{data.auth_code}} - - -else - No Code - - %div{'class': 'grid-item col2'} - %form{'action': 'https://{{config.host}}/admin/auth_code', 'method': 'post'}< - %input{'name': 'page', 'value': '{{page}}', 'hidden': None} - %input{'type': 'submit', 'name': 'action', 'value': 'Delete'} - %input{'type': 'submit', 'name': 'action', 'value': 'Regen'} diff --git a/uncia/frontend/templates/base.haml b/uncia/frontend/templates/base.haml deleted file mode 100644 index 18438da..0000000 --- a/uncia/frontend/templates/base.haml +++ /dev/null @@ -1,82 +0,0 @@ --set default_open = 'open' --set cookie = get.token(request.cookies.token) --set user = get.user(cookie.userid) --set config = get.config('all') -!!! -%html - %head - %title - {{config.name}}: {{title}} - - %link{'rel': 'stylesheet', 'media': 'screen', 'href': '/style-{{cssts()}}.css'} - %link{'rel': 'manifest', 'href': 'manifest.json'} - %meta{'name': 'mobile-web-app-capable', 'content': 'yes'} - %meta{'name': 'apple-mobile-web-app-capable', 'content': 'yes'} - %meta{'name': 'application-name', 'content': '{{config.name}}'} - %meta{'name': 'apple-mobile-web-app-title', 'content': '{{config.name}}'} - %meta{'name': 'theme-color', 'content': '#AA44AA'} - %meta{'name': 'msapplication-navbutton-color', 'content': '#AA44AA'} - %meta{'name': 'apple-mobile-web-app-status-bar-style', 'content': 'black-translucent'} - %meta{'name': 'msapplication-starturl', 'content': '/'} - %meta{'name': 'viewport', 'content': 'width=device-width, initial-scale=1, shrink-to-fit=no'} - %link{'rel': 'icon', 'type': 'png', 'sizes': '64x64', 'href': '/favicon.ico'} - %link{'rel': 'apple-touch-icon', 'type': 'png', 'sizes': '64x64', 'href': '/favicon.ico'} - - %body - %div{'class': 'menu menu-right'} - %details - %summary{'class': "title"} - %a - Menu - - .item - %a{'href': 'https://{{config.host}}/', 'target': '_self'} Home - .item - %a{'href': 'https://{{config.host}}/faq', 'target': '_self'} Faq - -if user - .item - %a{'href': 'https://{{config.host}}/admin', 'target': '_self'} Admin - .item - %a{'href': 'https://{{config.host}}/account', 'target': '_self'} Account - .item - %a{'href': 'https://{{config.host}}/logout', 'target': '_self'} Logout - -else - .item - %a{'href': 'https://{{config.host}}/login', 'target': '_self'} Login - - %div{'id': 'content'} - %div{'id': 'header'} - %h1{'id': 'name'} - {{config.name}} - - -if msg - %div{'id': 'message', 'class': 'section error'} - {{msg}} - - -block content - - %div{'class': 'section grid-container', 'id': 'footer'} - %div{'class': 'grid-item col1'} - -if config.admin - -set admin = config.admin.split('@') - - %p - Run by - %a{'href': 'https://{{admin[1]}}/@{{admin[0]}}', 'target': '_new'} - @{{config.admin}} - - %div{'class': 'grid-item acct', 'style': 'display: inline'} - -if config.setup - -if user != None - {{user.username}} [logout] - - -else - Guest [login] - - -else - %p UvU - - %div{'class': 'grid-item col2'} - %p - %a{'href': 'https://git.barkshark.xyz/izaliamae/uncia', 'target': '_new'} - Uncia Relay/{{version}} diff --git a/uncia/frontend/templates/cache.haml b/uncia/frontend/templates/cache.haml deleted file mode 100644 index 0344bd2..0000000 --- a/uncia/frontend/templates/cache.haml +++ /dev/null @@ -1,24 +0,0 @@ -- extends "base.html" - -- set title = 'Home' - -- block content - %div{'class': 'section cache url'} - %p{'class': 'sec-header'} URLs - - for k, v in cache.url.items() - %details{'class': 'cache-item'} - %summary - {{k}} - - %textarea{'readonly': None}< - {{v}} - - %div{'class': 'section cache sig'} - %p{'class': 'sec-header'} Signatures - - for k, v in cache.sig.items() - %details{'class': 'cache-item'} - %summary - {{k}} - - %textarea{'readonly': None}< - {{v}} diff --git a/uncia/frontend/templates/error.haml b/uncia/frontend/templates/error.haml deleted file mode 100644 index c9d4950..0000000 --- a/uncia/frontend/templates/error.haml +++ /dev/null @@ -1,10 +0,0 @@ -- extends 'base.html' - -- set title = 'Error '+code - -- block content - %div{'class': 'section', 'id': 'error'} - %h2{'class': 'title'} - Error {{code}} - - %center {{msg}} diff --git a/uncia/frontend/templates/faq.haml b/uncia/frontend/templates/faq.haml deleted file mode 100644 index cd3521c..0000000 --- a/uncia/frontend/templates/faq.haml +++ /dev/null @@ -1,44 +0,0 @@ -- extends "base.html" -- set title = 'Faq' - -- block content - %div{'class': 'section'} - %h2{'class': 'title'} What is a relay? - It is an optional component to the fediverse that forwards all public posts to every instance connected to it. This allows smaller and single-user instances to populate an otherwise slow fedi timeline - - %div{'class': 'section'} - %h2{'class': 'title'} How do I join one? - If it isn't obvious, you must be an admin of the instance you wanna add the relay to.

- %b Mastodon: - %a{'href': 'https://{{config.host}}/inbox'} - https://{{config.host}}/inbox - %br - %br - Copy the above url, enter your domain in the box below, click "Go to Relay Settings", and paste the relay url into the box on the New Relay page. If you wanna be sure it was enabled, wait 5 sec and reload the page.

- - %form{'id': 'form', 'action': '/faq', 'method': 'post'} - Domain: - %input{'type': 'url', 'name': 'domain', 'placeholder': 'ex. barkshark.xyz'} - %input{'type': 'submit', 'value': 'Go to Relay Settings'} - %br - %br - - %b Pleroma: - %a{'href': 'https://{{config.host}}/actor'} - https://{{config.host}}/actor - %br - %br - - In a terminal window, cd to your pleroma dir and run the following command - %br - %ul - %li - MIX_ENV=prod mix pleroma.relay follow https://{{config.host}}/actor - - - if config.require_approval - Note: This relay requires approval from the admin, so it will show as "Waiting for relay's approvel" in Mastodon until accepted. - - - if config.rules - %div{'class': 'section'} - %h2{'class': 'title'} What are the rules? - {{markdown(config.rules)}} diff --git a/uncia/frontend/templates/home.haml b/uncia/frontend/templates/home.haml deleted file mode 100644 index ba43258..0000000 --- a/uncia/frontend/templates/home.haml +++ /dev/null @@ -1,34 +0,0 @@ -- extends "base.html" - -- set title = 'Home' - -- block content - %div{'class': 'section'} - %h2{'class': 'title'} Info - - if config.info - {{markdown(config.info)}} - - - else - empty :/ - - %div{'class': 'section', 'id': 'home_instances'} - %h2{'class': 'title'} Registered Instances - %table - %tr{'class': 'header'} - %td{'class': 'col1 instance'} Instance - %td{'class': 'col2 timestamp'} Join Date - - - if len(instances) < 1 - %tr{'class': 'instance_row'} - %td{'class': 'col1 instance'} none - %td{'class': 'col2 timestamp'} n/a - - - else - - for instance in instances - %tr{'class': 'instance_row {{instance.tag}}'} - %td{'class': 'col1 instance'} - %a{'href': 'https://{{instance.domain}}/about', 'target': '_new'} - {{instance.domain}} - - %td{'class': 'col2 timestamp'} - {{instance.date}} diff --git a/uncia/frontend/templates/login.haml b/uncia/frontend/templates/login.haml deleted file mode 100644 index 5ea080b..0000000 --- a/uncia/frontend/templates/login.haml +++ /dev/null @@ -1,12 +0,0 @@ -- extends "base.html" -- set title = 'Login' - -- block content - %div{'class': 'section', 'id': 'auth'} - %h2{'class': 'title'} Login - %form{'action': 'https://{{config.host}}/login', 'method': 'post'} - %input{'type': 'text', 'name': 'username', 'placeholder': 'username'} - %br - %input{'type': 'password', 'name': 'password', 'placeholder': 'password'} - %br - %input{'type': 'submit', 'value': 'submit'} diff --git a/uncia/frontend/templates/register.haml b/uncia/frontend/templates/register.haml deleted file mode 100644 index 0fcfc56..0000000 --- a/uncia/frontend/templates/register.haml +++ /dev/null @@ -1,19 +0,0 @@ -- extends "base.html" -- set title = 'Register' - -- block content - %div{'class': 'section', 'id': 'auth'} - %h2{'class': 'title'} Register - %form{'action': 'https://{{config.host}}/register', 'method': 'post', 'autocomplete': 'new-password'} - %input{'type': 'text', 'name': 'username', 'placeholder': 'username'} - %br - %input{'type': 'password', 'name': 'password', 'placeholder': 'password'} - %br - %input{'type': 'password', 'name': 'password2', 'placeholder': 'password again'} - %br - - if code - %input{'type': 'text', 'name': 'code', 'value': '{{code}}', 'readonly': None} - - else - %input{'type': 'text', 'name': 'code', 'placeholder': 'authentication code'} - %br - %input{'type': 'submit', 'value': 'submit'} diff --git a/uncia/frontend/templates/setup.haml b/uncia/frontend/templates/setup.haml deleted file mode 100644 index de6f90b..0000000 --- a/uncia/frontend/templates/setup.haml +++ /dev/null @@ -1,99 +0,0 @@ -- extends "base.html" -- set title = 'Setup' - -- block content - -if config.setup - %div{'class': 'section setup'} - %p{'class': 'sec-header'} Setup - %p< - The relay has been successfully setup. Please start the relay again and setup an admin account via the register url that shows up in the console log - - -else - %div{'class': 'section setup'} - %p{'class': 'sec-header'} Setup - %p< - Welcome to the Uncia Relay! Before the relay will function properly, some settings have to be adjusted. Any values left empty will be set to their default - - %form{'action': '/setup', 'method': 'post'} - %div{'class': 'section settings', 'id': 'info'} - %p{'class': 'sec-header'} Server Info - - %label General Info (This will show up on the home page) - %textarea{'name': 'info', 'placeholder': 'Relay Info (default: none)'} - %label Relay Rules (This will be displayed on the FAQ page) - %textarea{'name': 'rules', 'placeholder': 'Relay Rules (default: none)'} - - %div{'class': 'section settings', 'id': 'settings'} - %p{'class': 'sec-header'} Relay Settings - %div{'class': 'grid-container'} - %div{'class': 'grid-item col1'} - %label Name (Display name of the relay) - %input{'type': 'text', 'name': 'name', 'placeholder': 'Relay Name', 'value': 'Uncia Relay'} - - %label Contact E-Mail (E-mail account to display in nodeinfo) - %input{'type': 'text', 'name': 'email', 'placeholder': 'name@example.com (default: none)'} - - %label Admin Fedi Account (Fedi account to display in the bottom left. Also used for notifications) - %input{'type': 'text', 'name': 'admin', 'placeholder': 'username@fedi.example.com (default: none)'} - - %label New Instance Notification (Receive a DM from the relay when an instance tries to join) - %select{'name': 'notification'} - %option{'value': 'yes'} Yes - %option{'value': 'no', selected: None} No (default) - - %label Block Other Relays (Prevent other relays from following) - %select{'name': 'block_relays'} - %option{'value': 'yes', selected: None} Yes (default) - %option{'value': 'no'} No - - %div{'class': 'grid-item col2'} - %label Show Instance Blocks (Display instance blocks in nodeinfo) - %select{'name': 'show_domainbans'} - %option{'value': 'yes'} Yes - %option{'value': 'no', 'selected': None} No (default) - - %label Show User Blocks (Display user blocks in nodeinfo) - %select{'name': 'show_userbans'} - %option{'value': 'yes'} Yes - %option{'value': 'no', selected: None} No (default) - - %label Require Approval (Require an admin to approve a relay when it tries to join) - %select{'name': 'require_approval'} - %option{'value': 'yes', selected: None} Yes (default) - %option{'value': 'no'} No - - %label Whitelist Mode (Only instances on the whitelist can join) - %select{'name': 'whitelist'} - %option{'value': 'yes'} Yes - %option{'value': 'no', selected: None} No (default) - - %label Log Level (Minimmum message level to display) - %select{'name': 'log_level'} - - for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] - - if level == 'INFO' - %option{'value': '{{level}}', selected: None} - {{level}} (default) - - - else - %option{'value': '{{level}}'} - {{level}} - - %div{'class': 'section settings network', 'id': 'network'} - %p{'class': 'sec-header'} Network - - %label Listen Address (IP address the relay will listen on) - %input{'type': 'text', 'name': 'address', 'placeholder': '127.0.0.1'} - - %label Listen Port (Port the relay will listen on) - %input{'type': 'numeric', 'name': 'port', 'placeholder': '3621'} - - %label Hostname (Domain the relay is served from) - %input{'type': 'text', 'name': 'host', 'placeholder': 'relay.example.com'} - - %div{'class': 'section setup', 'id': 'submit'} - - if code - %input{'type': 'text', 'name': 'code', 'value': '{{code}}', 'readonly': None} - - else - %input{'type': 'text', 'name': 'code', 'placeholder': 'authentication code'} - %br - %input{'type': 'submit', 'value': 'Save Config', 'class': 'submit'} diff --git a/uncia/functions.py b/uncia/functions.py index 56e2f61..8a5cd96 100644 --- a/uncia/functions.py +++ b/uncia/functions.py @@ -1,329 +1,77 @@ -import re, sys, os, traceback, logging, ujson as json +from functools import wraps +from izzylib import HttpRequestsClient, LruCache, logging -from urllib.request import Request, urlopen -from urllib.parse import urlparse -from os.path import abspath, isfile, isdir, getmtime -from collections import OrderedDict -from datetime import datetime - -import urllib3 - -from sanic import response -from colour import Color -from Crypto.PublicKey import RSA - -import urllib3 - -from .config import script_path, stor_path, version, pyv +from . import __version__ +from .config import config +from .database import db -httpclient = urllib3.PoolManager(num_pools=100, timeout=urllib3.Timeout(connect=15, read=15)) +client = HttpRequestsClient(appagent=f'UnciaRelay/{__version__}; https://{config.host}') +client.set_global() +cache = LruCache() -def defhead(): - from .database import get - - host = get.config('host') - data = { - 'User-Agent': f'python/{pyv[0]}.{pyv[1]}.{pyv[2]} (UnciaRelay/{version}; +https://{host})', - } - - return data - - -def fetch(url, cached=True, signed=False, new_headers={}): - from .signatures import SignHeaders - from .database import get - cached_data = cache.url.fetch(url) - host = get.config('host') - - if cached and cached_data: - logging.debug(f'Returning cached data for {url}') - return cached_data - - headers = defhead() - headers.update(new_headers) - headers.update({'Accept': 'application/json'}) - - if signed: - headers = SignHeaders(headers, 'default', f'https://{host}/actor#main-key', url, 'get') - - try: - logging.debug(f'Fetching new data for {url}') - response = httpclient.request('GET', url, headers=headers) - - except Exception as e: - logging.debug(f'Failed to fetch {url}') - logging.debug(e) - return - - if response.data == b'': - logging.debug(f'Received blank data while fetching url: {url}') - return - - try: - data = json.loads(response.data) +def cache_fetch(func): + @wraps(func) + def inner_func(url, *args, **kwargs): + cached = cache.fetch(url) if cached: - logging.debug(f'Caching {url}') - cache.url.store(url, data) + return cached - if data.get('error'): + response = func(url, *args, **kwargs) + + if not response: return - return data + cache.store(url, response) + return response - except Exception as e: - logging.debug(f'Failed to load data: {response.data}') - logging.debug(e) - return + return inner_func +@cache_fetch +def fetch_actor(url): + return client.json(url, activity=True).json -def format_urls(urls): - actor = urls.get('actor') - inbox = urls.get('inbox') - domain = urls.get('domain') - if not actor: - logging.warning('Missing actor') +@cache_fetch +def fetch_auth(url): + with db.session as s: + response = client.signed_request( + s.get.config('privkey'), + f'https://{config.host}/actor#main-key', + url, + headers = {'accept': 'application/activity+json'} + ) - if not inbox: - actor_data = fetch(actor) - - if actor_data: - actor = get_inbox(actor_data) - - if not domain: - domain = urlparse(actor).netloc - - if None in [actor, inbox, domain]: - return (None, None, None) - - return (actor, inbox, domain) + return response.json() def get_inbox(actor): - if actor == None: - return - - if not actor.get('endpoints'): - return actor.get('inbox') - - else: - return actor['endpoints'].get('sharedInbox') - - -def get_id(data): try: - object_id = data['object'].get('id') - + return actor.endpoints.sharedInbox except: - object_id = data['object'] + return actor.inbox - return object_id +def push_message(inbox, message): + with db.session as s: + response = client.signed_request( + s.get.config('privkey'), + f'https://{config.host}/actor#main-key', + inbox, + data = message.to_json(), + method = 'post', + headers = {'accept': 'application/json'} + ) -def get_user(user): - username, domain = user.split('@') - webfinger = fetch(f'https://{domain}/.well-known/webfinger?resource=acct:{user}') + if response.status not in [200, 202]: + try: + body = response.json + except: + body = response.text - if not webfinger or not webfinger.get('links'): - return + logging.debug(f'Error from {inbox}: {body}') - actor = None - - for line in webfinger['links']: - if line.get('type') in ['application/activity+json', 'application/json']: - actor = line['href'] - - if not actor: - return - - return fetch(actor) - - -def get_post_user(data): - if data['type'] in ['Follow', 'Undo']: - return - - if type(data['object']) == str: - post_url = data['object'] - post = fetch(post_url, signed=True) - - if not post: - return - - actor_url = post.get('attributedTo') - - elif type(data['object']) == dict: - actor_url = data['object'].get('attributedTo') - - else: - return - - actor = fetch(actor_url) - - if not actor: - return - - domain = urlparse(actor_url).netloc - user = actor.get('preferredUsername') - - if None in [user, domain]: - return - - return (user, domain) - - - -def format_date(timestamp=None): - if timestamp: - date = datetime.fromtimestamp(timestamp) - - else: - date = datetime.utcnow() - - return date.strftime('%a, %d %b %Y %H:%M:%S GMT') - - -def timestamp(): - return datetime.timestamp(datetime.now()) - - -def cssts(): - from .config import script_path - - css_check = lambda css_file : int(os.path.getmtime(f'{script_path}/frontend/{css_file}.css')) - - color = css_check('color') - layout = css_check('layout') - - return color + layout - - -class color: - def __init__(self): - self.check = lambda color: Color(f'#{str(color)}' if re.search(r'^(?:[0-9a-fA-F]{3}){1,2}$', color) else color) - - def multi(self, multiplier): - if multiplier >= 1: - return 1 - - elif multiplier <= 0: - return 0 - - return multiplier - - def lighten(self, color, multiplier): - col = self.check(color) - col.luminance += ((1 - col.luminance) * self.multi(multiplier)) - - return col.hex_l - - def darken(self, color, multiplier): - col = self.check(color) - col.luminance -= (col.luminance * self.multi(multiplier)) - - return col.hex_l - - - def saturate(self, color, multiplier): - col = self.check(color) - col.saturation += ((1 - col.saturation) * self.multi(multiplier)) - - return col.hex_l - - - def desaturate(self, color, multiplier): - col = self.check(color) - col.saturation -= (col.saturation * self.multi(multiplier)) - - return col.hex_l - - - def rgba(self, color, transparency): - col = self.check(color) - - red = col.red*255 - green = col.green*255 - blue = col.blue*255 - trans = self.multi(transparency) - - return f'rgba({red:0.2f}, {green:0.2f}, {blue:0.2f}, {trans:0.2f})' - - -class LRUCache(OrderedDict): - def __init__(self, maxsize=1024): - self.maxsize = maxsize - - def invalidate(self, key): - if self.get(key): - del self[key] - return True - - return False - - def store(self, key, value): - while len(self) >= self.maxsize and self.maxsize != 0: - self.popitem(last=False) - - self[key] = value - self.move_to_end(key) - - def fetch(self, key): - if key in self: - return self[key] - - return None - - -class DotDict(dict): - __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ - - def __init__(self, value=None, **kwargs): - super().__init__() - - if value.__class__ == str: - self.FromJson(value) - - elif value.__class__ in [dict, DotDict]: - self.update(value) - - elif value: - raise TypeError('The value must be a JSON string, dict, or another DotDict object, not', value.__class__) - - if kwargs: - self.update(kwargs) - - - def __getattr__(self, value, default=None): - val = self.get(value, default) if default else self[value] - - return DotDict(val) if type(val) == dict else val - - - def ToJson(self, **kwargs): - return self.__str__(**kwargs) - - - def FromJson(self, string): - data = json.loads(string) - self.update(data) - - - def AsDict(self): - return {k: v for k, v in self.items() if not k.startswith('__')} - - - def __parse_item(self, data): - return DotDict(data) if type(data) == dict else data - - -cache_size = 2048 - -class cache: - url = LRUCache(maxsize=cache_size) - sig = LRUCache(maxsize=cache_size) - obj = LRUCache(maxsize=cache_size) + return response diff --git a/uncia/log.py b/uncia/log.py deleted file mode 100644 index 2ee9249..0000000 --- a/uncia/log.py +++ /dev/null @@ -1,136 +0,0 @@ -import sys - -from os import environ as env -from datetime import datetime - -from .Lib.IzzyLib import logging - - -# Custom logger -class Log(): - def __init__(self, minimum='INFO', datefmt='%Y-%m-%d %H:%M:%S', date=True): - self.levels = { - 'CRIT': 60, - 'ERROR': 50, - 'WARN': 40, - 'INFO': 30, - 'VERB': 20, - 'DEBUG': 10, - 'MERP': 0 - } - - self.datefmt = datefmt - self.minimum = self._lvlCheck(minimum) - - # make sure the minimum logging level is an int - def _lvlCheck(self, level): - try: - value = int(level) - - except ValueError: - value = self.levels.get(level) - - if value not in self.levels.values(): - raise InvalidLevel(f'Invalid logging level: {level}') - - return value - - def setLevel(self, level): - self.minimum = self._lvlCheck(level) - - def log(self, level, msg): - levelNum = self._lvlCheck(level) - - if type(level) == int: - for k,v in self.levels.items(): - if v == levelNum: - level = k - - if levelNum < self.minimum: - return - - output = f'{level}: {msg}\n' - - # Only show date when not running in systemd - if not env.get('INVOCATION_ID'): - date = datetime.now().strftime(self.datefmt) - output = f'{date} {output}' - - stdout = sys.stdout - stdout.write(output) - stdout.flush() - - - def critical(self, msg): - self.log('CRIT', msg) - - def error(self, msg): - self.log('ERROR', msg) - - def warning(self, msg): - self.log('WARN', msg) - - def info(self, msg): - self.log('INFO', msg) - - def verbose(self, msg): - self.log('VERB', msg) - - def debug(self, msg): - self.log('DEBUG', msg) - - def merp(self, msg): - self.log('MERP', msg) - - -class InvalidType(Exception): - '''Raise when the log level isn't a str or an int''' - -class InvalidLevel(Exception): - '''Raise when an invalid logging level was specified''' - - -# Set logger for sanic -LOG = dict( - version=1, - disable_existing_loggers=False, - loggers={ - "sanic.root": { - "level": 'CRITICAL', - "handlers": ["console"], - "propagate": False, - }, - - "sanic.error": { - "level": "CRITICAL", - "handlers": ["error_console"], - "propagate": False, - "qualname": "sanic.error", - }, - - }, - handlers={ - "console": { - "class": "logging.StreamHandler", - 'level': 'CRITICAL', - "formatter": "generic", - "stream": sys.stdout, - }, - "error_console": { - "class": "logging.StreamHandler", - "formatter": "generic", - "stream": sys.stderr, - }, - }, - formatters={ - "generic": { - "format": f"%(asctime)s %(process)d %(levelname)s %(message)s", - "datefmt": "%Y-%m-%d %H:%M:%S" if not env.get('INVOCATION_ID') else '', - "class": "logging.Formatter", - }, - }, -) - -#logconf.dictConfig(LOG) - -#logging = Log() diff --git a/uncia/manage.py b/uncia/manage.py new file mode 100644 index 0000000..3eaa984 --- /dev/null +++ b/uncia/manage.py @@ -0,0 +1,84 @@ +import sys + +from izzylib import logging + +from . import __version__ +from .database import db + + +exe = f'{sys.executable} -m uncia.manage' +forbidden_keys = ['pubkey', 'privkey', 'version'] + + +class Command: + def __init__(self, arguments): + try: + cmd = arguments[0] + + except IndexError: + self.result = self.cmd_help() + return + + args = arguments[1:] if len(arguments) > 1 else [] + + self.result = self[cmd](*args) + + + def __getitem__(self, key): + try: + return getattr(self, f'cmd_{key}') + except AttributeError: + raise InvalidCommandError(f'Not a valid command: {key}') + + + def cmd_help(self): + return f'''Uncia Relay Management v{__version__} + +python3 -m uncia.manage config [key] [value]: + Gets or sets the config. Specify a key and value to set a config option. + Only specify a key to get the value of a specific option. Leave out the + key and value to get all the options and their values +''' + + + def cmd_config(self, key=None, value=None): + with db.session as s: + if key and value: + if key in forbidden_keys: + return f'Refusing to set "{key}"' + + s.put.config(key, value) + return self.cmd_config(key) + + elif key and not value: + value = s.get.config(key) + return f'Value for "{key}": {value}' + + output = 'Current config:\n' + + for key, value in s.get.config_all().items(): + if key not in forbidden_keys: + output += f' {key}: {value}\n' + + return output + + + def cmd_set(self, key, *value): + return self.cmd_config(key, ' '.join(value)) + + + def cmd_remove(self, data): + with db.session as s: + if s.delete.inbox(data): + return f'Instance removed: {data}' + + else: + return f'Instance does not exist: {data}' + + +if __name__ == '__main__': + args = sys.argv[1:] if len(sys.argv) > 1 else [] + cmd = Command(args) + + if cmd.result: + print(cmd.result) diff --git a/uncia/messages.py b/uncia/messages.py index 3391faf..8630400 100644 --- a/uncia/messages.py +++ b/uncia/messages.py @@ -1,231 +1,64 @@ -import asyncio, threading, uuid, traceback, json, base64 - +from izzylib import DotDict, ap_date from urllib.parse import urlparse +from uuid import uuid4 -import urllib3 - -from .Lib.IzzyLib import logging -from .functions import format_date, get_id, cache, httpclient, get_user, get_inbox, fetch, defhead -from .signatures import SignHeaders, SignHeaders, SignBody -from .database import get, put -from .config import version, pyv +from .config import config -host = get.config('host') - - -def accept(followid, urls): - actor_url = urls['actor'] - inbox = urls['inbox'] - domain = urls['domain'] - UUID = str(uuid.uuid4()) - - body = { +def accept(followid, instance): + message = DotDict({ '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Accept', - 'to': [actor_url], - 'actor': f'https://{host}/actor', + 'to': [instance.actor], + 'actor': f'https://{config.host}/actor', 'object': { 'type': 'Follow', 'id': followid, - 'object': f'https://{host}/actor', - 'actor': actor_url + 'object': f'https://{config.host}/actor', + 'actor': instance.actor }, - 'id': f'https://{host}/activities/{UUID}', - } + 'id': f'https://{config.host}/activities/{str(uuid4())}', + }) - if push(inbox, body): - put.inbox('add', urls) - - if not get.config('require_approval') and get.config('notification'): - thread = threading.Thread(target=notification, args=[domain]) - thread.start() - - return True + return message -def announce(obj_id, inbox): - activity_id = f'https://{host}/activities/{str(uuid.uuid4())}' - - message = { +def announce(object_id, inbox): + data = DotDict({ '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Announce', - 'to': [f'https://{host}/followers'], - 'actor': f'https://{host}/actor', - 'object': obj_id, - 'id': activity_id - } + 'to': [f'https://{config.host}/followers'], + 'actor': f'https://{config.host}/actor', + 'object': object_id, + 'id': f'https://{config.host}/activities/{str(uuid4())}' + }) - push_inboxes(message, origin=inbox) + return data -def forward(data, inbox): - push_inboxes(data, origin=inbox) - - -def paws(url): - data = { +def note(user_handle, user_inbox, user_actor, actor, message): + actor_domain = urlparse(actor).netloc + user_domain = urlparse(user_inbox).netloc + data = DotDict({ "@context": "https://www.w3.org/ns/activitystreams", - "type": "Paws", - "id": f"https://{host}/paws/lorge", - 'to': [f'https://{host}/followers'], - "actor": f"https://{host}/actor", - "object": f"https://{host}/paws/lorge" - } - - push(url, data) - - -def notification(domain): - acct = get.config('admin') - admin_user, admin_domain = acct.split('@') - admin_uuid = uuid.uuid4() - - instance = get.inbox(admin_domain) - - if not instance: - actor = get_user(acct) - inbox = get_inbox(actor) - - else: - inbox = instance['inbox'] - - if not inbox: - logging.error(f'Failed to get inbox for {acct}') - return - - admin_message = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": f"https://{host}/activities/{admin_uuid}", + "id": f"https://{config.host}/activities/{str(uuid4())}", "type": "Create", - "actor": f"https://{host}/actor", + "actor": f"https://{config.host}/actor", "object": { - "id": f"https://{host}/activities/{admin_uuid}", + "id": f"https://{config.host}/activities/{str(uuid4())}", "type": "Note", - "published": format_date(None), - "attributedTo": f"https://{host}/actor", - "content": f"

{domain} is requesting to join the relay

", - 'to': [ - f'https://{admin_domain}/users/{admin_user}' - ], + "published": ap_date(), + "attributedTo": f"https://{config.host}/actor", + "content": message, + 'to': [user_inbox], 'tag': [{ 'type': 'Mention', - 'href': f'https://{admin_domain}/users/{admin_user}', - 'name': f'@{admin_user}@{admin_domain}' + 'href': user_actor, + 'name': f'@{user_handle}@{user_domain}' }], } - } + }) - if not get.config('require_approval'): - admin_message['object']['content'] = f'

{domain} has joined the relay

' - - return True if push(inbox, admin_message) else False - - -### End of messages - - -def run_retries(inbox=None, msgid=None): - if inbox: - messages = get.retries(inbox) - - elif msgid: - try: - msgid = int(msgid) - except: - return - - rows = get.retries(msgid) - messages = [rows] if rows else None - - else: - messages = get.retries('all') - - if not messages: - return - - logging.info('Retrying posts...') - threads = [] - failing = [] - - for msg in messages: - inbox = msg['inbox'] - - if inbox in failing: - continue - - if not fetch(f'https://{urlparse(inbox).netloc}/nodeinfo/2.0.json'): - failing.append(inbox) - continue - - try: - data = json.loads(msg['data']) - headers = json.loads(msg['headers']) - - except: - # This will get removed in a future update - data = eval(msg['data']) - headers = eval(msg['headers']) - - threads.append(threading.Thread(target=push, args=(inbox, data, headers))) - - for thread in threads: - thread.start() - - -def push_inboxes(data, headers={}, origin=None): - object_id = get_id(data) - object_domain = urlparse(object_id).netloc - threads = [] - - for inbox in get.inbox('all'): - inbox_url = inbox['inbox'] - domain = inbox['domain'] - - if domain != object_domain and inbox_url != origin: - threads.append(threading.Thread(target=push, args=(inbox_url, data, headers, True))) - - for thread in threads: - thread.start() - - -def push(inbox, data, headers={}, retry=False): - logging.debug(f'Sending message to {inbox}') - body = json.dumps(data) - url = get_id(data) - posthost = urlparse(inbox).netloc - orig_head = headers.copy() - - headers.update(defhead()) - - headers['content-type'] = 'application/activity+json' - headers['digest'] = f'SHA-256={SignBody(body)}' - - if headers.get('signature'): - del headers['signature'] - - headers = SignHeaders(headers, 'default', f'https://{host}/actor#main-key', inbox, 'post') - - try: - response = httpclient.request('POST', inbox, body=body, headers=headers) - respdata = response.data.decode() - - if response.status not in [200, 202]: - logging.verbose(f'Failed to push to {inbox}: Error {response.status}') - - if len(respdata) < 200: - logging.debug(f'Response from {posthost}: {respdata}') - - if response.status not in [403]: - put.add_retry(url, inbox, data, orig_head) - - else: - logging.verbose(f'Successfully sent message to {inbox}') - put.del_retries({'inbox': inbox, 'msgid': url}) - return True - - except Exception as e: - logging.verbose(f'Connection error when pushing to {inbox}: {e}') - put.add_retry(url, inbox, data, orig_head) + return data diff --git a/uncia/middleware.py b/uncia/middleware.py index e3f5572..32125cf 100644 --- a/uncia/middleware.py +++ b/uncia/middleware.py @@ -1,61 +1,54 @@ -import ujson as json +from izzylib import logging +from izzylib.http_requests_client import parse_signature, verify_request +from izzylib.http_server import MiddlewareBase -from sanic import response - -from .log import logging -from .templates import error -from .signatures import ValidateRequest -from .views import Login -from .database import get, put -from .config import version +from .database import db -async def access_log(request, response): - response.headers['Server'] = f'Uncia/{version}' - response.headers['Trans'] = 'Rights' - - addr = request.headers.get('x-forwarded-for', request.remote_addr) - uagent = request.headers.get('user-agent') - logging.info(f'{addr} {request.method} {request.path} {response.status} "{uagent}"') +auth_paths = [ + '/logout', + '/user', + '/admin' +] -async def query_post_dict(request): - request.ctx.query = {} - request.ctx.form = {} - - for k, v in request.query_args: - request.ctx.query.update({k: v}) - - for k, v in request.form.items(): - request.ctx.form.update({k: v[0]}) +class AuthCheck(MiddlewareBase): + attach = 'request' -async def authentication(request): - if request.path == '/inbox': - valid = ValidateRequest(request) - data = request.json + async def handler(self, request, response): + validated = False + token = request.headers.get('token') - if valid == False and data.get('type') == 'Delete': - return response.text('Stop sending account deletes', status=202) + with db.session as s: + request.ctx.token = s.fetch('token', code=token) + request.ctx.user = s.fetch('user', id=request.token.id) if request.token else None + request.ctx.signature = parse_signature(request.headers.get('signature')) + request.ctx.instance = None - if not valid: - return error(request, 'Invalid signature', 401) + if request.ctx.signature: + if any([s.get.ban(domain=request.ctx.signature.domain), s.get.ban(domain=request.ctx.signature.top_domain)]): + return response.text(f'BEGONE!', status=403) - else: - accept = True if 'json' in request.headers.get('accept', '') or request.path.startswith('/api') else None + request.ctx.instance = s.get.inbox(request.ctx.signature.domain) - if not get.config('setup') and not request.path.startswith(('/setup', '/style')): - return response.redirect('/setup') if not accept else response.json({'error': 'relay not setup yet'}, status=401) + if request.path == '/inbox' and request.method.lower() == 'post': + try: + data = request.data.json - apitoken = request.headers.get('token') - token = request.cookies.get('token') + except: + logging.verbose('Failed to parse post data') + return response.text(f'Invalid data', status=400) - if not get.user('all') and not accept and request.path.startswith(('/admin', '/login')): - return response.redirect('/register') + try: + validated = await verify_request(request) - if request.path.startswith(('/api', '/admin', '/account')) and (not token or not get.token(token)): - if accept: - return error(request, 'Missing or invalid token', 401) if accept else await Login().get(request) + except AssertionError as e: + logging.debug(f'Failed sig check: {e}') + return response.text(f'Failed signature check: {e}', status=401) - else: - return response.redirect('/login') + if not request.ctx.instance and data and data.type.lower() != 'follow': + return response.text(f'Follow the relay first', status=401) + + if any(map(request.path.startswith, auth_paths)) and not request.ctx.user: + return response.redir('/login') diff --git a/uncia/migrate.py b/uncia/migrate.py deleted file mode 100755 index 3f03fc9..0000000 --- a/uncia/migrate.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -import time, sys, ujson as json - -from os.path import isfile, abspath -from datetime import datetime -from urllib.parse import urlparse - -from .config import stor_path -from .database import get, put - - -if 'pleroma' in sys.argv: - import yaml - - try: - with open('relay.yaml') as f: - config = yaml.load(f, Loader=yaml.SafeLoader) - - except Exception as e: - print(f'Failed to open "relay.yaml": {e}') - sys.exit() - - dbfile = config['db'] - domainbans = config['ap']['blocked_instances'] - whitelist = config['ap']['whitelist'] - settings = { - 'address': config['listen'], - 'port': config['port'], - 'host': config['ap']['host'], - 'whitelist': config['ap']['whitelist_enabled'] - } - - try: - jsondb = json.load(open(dbfile)) - - except Exception as e: - print(f'Failed to open "{dbfile}": {e}') - sys.exit() - - inboxes = jsondb['relay-list'] - key = { - 'privkey': jsondb['actorKeys']['privateKey'], - 'pubkey': jsondb['actorKeys']['publicKey'] - } - -else: - print('heck') - sys.exit() - - -# migrate inboxes -for row in inboxes: - if type(row) == str: - domain = urlparse(row).netloc - row = { - 'actor': f'https://{domain}/actor', - 'inbox': f'https://{domain}/inbox', - 'domain': domain - } - - if not get.inbox(row['domain']): - urls = { - 'actor': row['actor'], - 'inbox': row['inbox'], - 'domain': row['domain'] - } - - timestamp = row.get('timestamp') - - put.inbox('add', urls, timestamp=timestamp) - - -# migrate actor key -put.rsa_key('default', key) - - -# migrate config -put.config(settings) - - -# migrate domain bans -for domain in domainbans: - put.ban('add', domain) - - -# migrate whitelist -for domain in whitelist: - put.whitelist('add', domain) diff --git a/uncia/processing.py b/uncia/processing.py index 15a270c..f0c6dc5 100644 --- a/uncia/processing.py +++ b/uncia/processing.py @@ -1,115 +1,60 @@ -import uuid, threading -import ujson as json +from izzylib import logging -from urllib.parse import urlparse -from datetime import datetime - -from .log import logging -from .messages import fetch, accept, announce, forward, notification -from .database import get, put -from .functions import get_inbox, cache, get_id, get_post_user, format_urls +from . import messages +from .database import db +from .functions import fetch_actor, get_inbox, push_message -def relay_announce(data, actor, urls): - object_id = get_id(data) - inbox = urls['inbox'] - instance = urls['domain'] +class ProcessData: + def __init__(self, request, response, data): + self.request = request + self.response = response + self.signature = request.ctx.signature + self.instance = request.ctx.instance + self.type = data.type.lower() + self.data = data + self.actor = fetch_actor(data.actor) - if not object_id: - logging.debug(f'Can\'t find object id') - - if cache.obj.fetch(object_id): - logging.debug(f'Already relayed {object_id}') - return - - username = get_post_user(data) - - if username: - user, domain = username - - if get.domainban(domain): - logging.info(f'Rejected post from banned instance: {domain} from {instance}') - - if get.userban(user, domain): - logging.info(f'Rejected post from banned user: {user}@{domain} from {instance}') + if not self.actor: + logging.verbose(f'Failed to fetch actor on instance follow: {actor.data}') + self.new_response = response.json('Failed to fetch actor.', status=400) return - announce(object_id, inbox) - - cache.obj.store(object_id, {'data': data, 'actor': actor}) + self.new_response = getattr(self, f'cmd_{self.type}')() -def relay_forward(data, actor, urls): - object_id = get_id(data) - inbox = urls['inbox'] - - if not object_id: - logging.debug(f'Can\'t find object id') - - if cache.obj.fetch(object_id): - logging.debug(f'Already relayed {object_id}') - return - - forward(data, inbox) - - cache.obj.store(object_id, {'data': data, 'actor': actor}) - - -def relay_follow(data, actor, urls): - followid = data.get('id') - domain = urls['domain'] - - if not followid: - return - - if not get.whitelist(urls['domain']): - if get.config('require_approval'): - put.request('add', urls, followid=followid) - - if get.config('notification'): - thread = threading.Thread(target=notification, args=[domain]) - thread.start() - + def cmd_follow(self): + if self.instance and not self.instance.followid: return - elif get.config('whitelist'): - return + data = [ + get_inbox(self.actor), + self.actor.id + ] - accept(followid, urls) + with db.session as s: + req_app = s.get.config('require_approval') + + if req_app: + data.append(self.data.id) + + instance = s.put.instance(*data) + + if not instance: + logging.error(f'Something messed up when inserting "{self.signature.domain}" into the database') + return self.response.json('Internal error', status=500) + + if not req_app: + accept_msg = messages.accept(self.data.id, instance) + resp = push_message(instance.inbox, accept_msg) + + if resp.status not in [200, 202]: + raise ValueError(f'Error when pushing to "{instance.inbox}"') -def relay_undo(data, actor, urls): - actor_url, inbox, domain = format_urls(urls) - - if type(data.get('object')) != dict: - logging.warning(json.dumps(data, indent=4)) - return - - action = data['object'].get('type', '').lower() - - if action in ['announce', 'create']: - relay_forward(data, actor, urls) - - elif action == 'follow': - put.inbox('remove', urls) - - else: - logging.warning(json.dumps(data, indent=4)) + def cmd_undo(self): + pass -def process(request, data, actor, urls): - objtype = { - 'Announce': relay_announce, - 'Create': relay_announce, - 'Delete': relay_forward, - 'Follow': relay_follow, - 'Undo': relay_undo, - 'Update': relay_forward - } - - action = data.get('type') - - if action not in objtype: - return - - objtype[action](data, actor, urls) + def cmd_announce(self): + pass diff --git a/uncia/server.py b/uncia/server.py index 98d7500..974dfc1 100644 --- a/uncia/server.py +++ b/uncia/server.py @@ -1,107 +1,44 @@ -import sys, os, asyncio -import ujson as json +from izzylib import logging +from izzylib.http_server import Application, Request -from sanic import Sanic -from sanic.exceptions import NotFound, MethodNotSupported, ServerError -from jinja2.exceptions import TemplateNotFound -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler - -from .log import logging, LOG -from .config import script_path, fwsecret, development -from .database import get, setup -from .messages import run_retries -from .admin import bool_check -from .templates import build_templates -from . import errors, views, middleware as mw +from . import __version__, views +from .config import config, path +from .database import db +from .middleware import AuthCheck -app = Sanic() -app.config.FORWARDED_SECRET = fwsecret +def template_context(context): + with db.session as s: + config = s.get.config_all() -# Register middlewares -app.register_middleware(mw.authentication) -app.register_middleware(mw.query_post_dict) -app.register_middleware(mw.access_log, attach_to='response') + try: + context['config'] = config + except: + context['config'] = {} -# Register error handlers -app.error_handler.add(NotFound, errors.not_found) -app.error_handler.add(MethodNotSupported, errors.method_not_supported) -app.error_handler.add(ServerError, errors.server_error) -app.error_handler.add(TemplateNotFound, errors.no_template) - -# Register AP endpoints -app.add_route(views.Inbox.as_view(), '/inbox') -app.add_route(views.Actor.as_view(), '/actor') -app.add_route(views.WellknownNodeinfo.as_view(), '/.well-known/nodeinfo') -app.add_route(views.WellknowWebfinger.as_view(), '/.well-known/webfinger') -app.add_route(views.Nodeinfo.as_view(), '/nodeinfo/2.0.json') - -# Register web frontend routes -app.add_route(views.Home.as_view(), '/') -app.add_route(views.Faq.as_view(), '/faq') -app.add_route(views.Account.as_view(), '/account') -app.add_route(views.Account.as_view(), '/account/') -app.add_route(views.Admin.as_view(), '/admin') -app.add_route(views.Admin.as_view(), '/admin/') -app.add_route(views.Login.as_view(), '/login') -app.add_route(views.Logout.as_view(), '/logout') -app.add_route(views.Register.as_view(), '/register') -#app.add_route(views.Cache.as_view(), '/admin/cache') # I probably don't need this anymore - -# Register resources for web frontend -app.add_route(views.Style.as_view(), '/style-') -app.static('/favicon.ico', f'{script_path}/frontend/favicon.png') -app.add_route(views.Robots.as_view(), '/robots.txt') - -# heck -app.add_route(views.BeGay.as_view(), '/begay') + return context -# Enable setup page if this is the first run -if not bool_check(get.config('setup')): - app.add_route(views.Setup.as_view(), '/setup') +def HttpRequest(Request): + pass -class WatchHandler(FileSystemEventHandler): - def on_any_event(self, event): - filename, ext = os.path.splitext(os.path.relpath(event.src_path)) +with db.session as s: + app = Application( + name = s.get.config('name'), + version = __version__, + listen = config.listen, + port = config.port, + host = config.host, + workers = config.workers, + git_repo = 'https://git.barkshark.xyz/izaliamae/uncia', + proto = 'https', + #request_class = HttpRequest, + tpl_search = [path.frontend], + tpl_context = template_context, + class_views = [getattr(views, view) for view in dir(views) if view.startswith('Uncia')] + ) - if event.event_type in ['modified', 'created'] and ext[1:] == 'haml': - logging.info('Rebuilding templates') - build_templates() +app.add_middleware(AuthCheck) +app.static('/style', path.frontend.join('style')) - -def setup_template_watcher(): - tplpath = f'{script_path}/frontend/templates' - observer = Observer() - observer.schedule(WatchHandler(), tplpath, recursive=False) - - return observer - - -@app.listener('after_server_start') -async def retries_timer(app, loop): - while True: - run_retries() - await asyncio.sleep(60*30) - - -def main(): - setup() - - dblisten = get.config('address') - dbport = int(get.config('port')) - - build_templates() - observer = setup_template_watcher() - - if bool_check(development): - logging.info('Starting template watcher') - observer.start() - - logging.info(f'Starting Uncia at {dblisten}:{dbport}') - app.run(host=dblisten, port=dbport, workers=1, debug=False, access_log=False) - - logging.info('Stopping template watcher') - observer.stop() diff --git a/uncia/signatures.py b/uncia/signatures.py deleted file mode 100644 index aa249e0..0000000 --- a/uncia/signatures.py +++ /dev/null @@ -1,149 +0,0 @@ -import json - -from base64 import b64decode, b64encode -from urllib.parse import urlparse -from datetime import datetime - -import httpsig - -from Crypto.PublicKey import RSA -from Crypto.Hash import SHA, SHA256, SHA512 -from Crypto.Signature import PKCS1_v1_5 - -from .log import logging -from .functions import cache, format_date, fetch -from .database import get - - -HASHES = { - 'sha1': SHA, - 'sha256': SHA256, - 'sha512': SHA512 -} - - -def ParseSig(headers): - sig_header = headers.get('signature') - - if not sig_header: - logging.verbose('Missing signature header') - return - - split_sig = sig_header.split(',') - signature = {} - - for part in split_sig: - key, value = part.split('=', 1) - signature[key.lower()] = value.replace('"', '') - - if not signature.get('headers'): - logging.verbose('Missing headers section in signature') - return - - signature['headers'] = signature['headers'].split() - - return signature - - -def build_sigstring(request, used_headers, target=None): - string = '' - - if not target: - if type(request) == dict: - headers = request - - else: - headers = request.headers.copy() - headers['(request-target)'] = f'{request.method.lower()} {request.path}' - - else: - headers = request.copy() - headers['(request-target)'] = target - - for header in used_headers: - string += f'{header.lower()}: {headers[header]}' - - if header != list(used_headers)[-1]: - string += '\n' - - return string - - -def SignBody(body): - bodyhash = cache.sig.fetch(body) - - if not bodyhash: - h = SHA256.new(body.encode('utf-8')) - bodyhash = b64encode(h.digest()).decode('utf-8') - cache.sig[body] = bodyhash - - return bodyhash - - - -def ValidateSignature(headers, method, path): - headers = {k.lower(): v for k,v in headers.items()} - signature = ParseSig(headers) - - actor_data = fetch(signature['keyid']) - logging.debug(actor_data) - - try: - pubkey = actor_data['publicKey']['publicKeyPem'] - - except Exception as e: - logging.verbose(f'Failed to get public key for actor {signature["keyid"]}') - return - - valid = httpsig.HeaderVerifier(headers, pubkey, signature['headers'], method, path, sign_header='signature').verify() - - if not valid: - if not isinstance(valid, tuple): - logging.verbose('Signature validation failed for unknown actor') - logging.verbose(valid) - - else: - logging.verbose(f'Signature validation failed for actor: {valid[1]}') - - return - - else: - return True - - -def ValidateRequest(request): - ''' - Validates the headers in a Sanic or Aiohttp request (other frameworks may be supported) - See ValidateSignature for 'client' and 'agent' usage - ''' - return ValidateSignature(request.headers, request.method, request.path) - - -def SignHeaders(headers, key, keyid, url, method='get'): - if headers.get('date'): - del headers['date'] - - actor_key = get.rsa_key(key) - - if not actor_key: - logging.error('Could not find signing key:', key) - return - - privkey = actor_key['privkey'] - RSAkey = RSA.import_key(privkey) - key_size = int(RSAkey.size_in_bytes()/2) - logging.debug('Signing key size:', key_size) - - parsed_url = urlparse(url) - - raw_headers = {'date': format_date(), 'host': parsed_url.netloc, '(request-target)': ' '.join([method.lower(), parsed_url.path])} - raw_headers.update(dict(headers)) - header_keys = raw_headers.keys() - - signer = httpsig.HeaderSigner(keyid, privkey, f'rsa-sha{key_size}', headers=header_keys, sign_header='signature') - new_headers = signer.sign(raw_headers, parsed_url.netloc, method, parsed_url.path) - logging.debug('Signed headers:', new_headers) - - del new_headers['(request-target)'] - - return new_headers diff --git a/uncia/templates.py b/uncia/templates.py deleted file mode 100644 index 3ea53e3..0000000 --- a/uncia/templates.py +++ /dev/null @@ -1,118 +0,0 @@ -import codecs, traceback, os -import ujson as json - -from os import listdir -from os.path import isfile, isdir, getmtime - -from jinja2 import Environment, FileSystemLoader, ChoiceLoader -from hamlpy.hamlpy import Compiler -from sanic import response -from markdown import markdown - -from .log import logging -from .functions import cssts, color -from .config import version, stor_path, script_path -from .database import get - - -global_variables = { - 'get': get, - 'version': version, - 'markdown': markdown, - 'lighten': color().lighten, - 'darken': color().darken, - 'saturate': color().saturate, - 'desaturate': color().desaturate, - 'rgba': color().rgba, - 'cssts': cssts, - 'len': len, - 'type': type -} - - -env = Environment( - loader=ChoiceLoader([ - FileSystemLoader(f'{stor_path}/build'), - FileSystemLoader(f'{script_path}/frontend') - ]) -) - - -def render(tplfile, request, context, headers=None, status=200): - data = global_variables.copy() - data['request'] = request - data.update(context) - - if type(context) != dict: - logging.error(f'Context for {template} not a dict') - - resp = response.html(env.get_template(tplfile).render(data)) - - if headers: - resp.headers.update(headers) - - return resp - - -def error(request, msg, status): - if 'json' in request.headers.get('accept', '') or (request.path == '/inbox' and 'mozilla' not in request.headers.get('user-agent', '').lower()): - return response.json({'err': msg}, status=status) - - data = {'msg': msg, 'code': str(status), 'config': get.config('all')} - - return render('error.html', request, data, status=status) - - -def build_templates(): - timefile = f'{stor_path}/build/times.json' - updated = False - - if not isdir(f'{stor_path}/build'): - os.makedirs(f'{stor_path}/build') - - if isfile(timefile): - try: - times = json.load(open(timefile)) - - except: - times = {} - - else: - times = {} - - for filename in listdir(f'{script_path}/frontend/templates'): - modtime = getmtime(f'{script_path}/frontend/templates/{filename}') - base, ext = filename.split('.') - - if ext != 'haml': - pass - - elif base not in times or times.get(base) != modtime: - updated = True - logging.verbose(f"Template '{filename}' was changed. Building...") - - try: - template = f'{script_path}/frontend/templates/{filename}' - destination = f'{stor_path}/build/{base}.html' - haml_lines = codecs.open(template, 'r', encoding='utf-8').read().splitlines() - - if not isfile(template): - return False - - compiler = Compiler() - output = compiler.process_lines(haml_lines) - - outfile = codecs.open(destination, 'w', encoding='utf-8') - outfile.write(output) - - logging.info(f"Template '{filename}' has been built") - - except: - traceback.print_exc() - logging.error(f'Failed to build {filename}') - - times[base] = modtime - - if updated: - with open(timefile, 'w') as filename: - filename.write(json.dumps(times)) diff --git a/uncia/views.py b/uncia/views.py index e6d1e0e..c1c3fee 100644 --- a/uncia/views.py +++ b/uncia/views.py @@ -1,141 +1,150 @@ -import threading, re -import ujson as json +from izzylib import DotDict, logging +from izzylib.http_server import View -from os.path import dirname, abspath -from urllib.parse import urlparse -from datetime import datetime - -from sanic import response -from sanic.views import HTTPMethodView -from sanic.exceptions import ServerError - -from .log import logging -from .config import script_path, version -from .functions import cache, get_inbox, format_date -from .processing import process -from .messages import fetch -from .database import get, put -from .templates import render, error -from . import admin +from . import __version__ +from .config import config +from .database import db +from .processing import ProcessData -host = get.config('host') -keys = get.rsa_key('default') +### Frontend + +class UnciaHome(View): + paths = ['/'] + + async def get(self, request, response): + instances = [] + + with db.session as s: + for row in s.get.inbox_list(): + if not row.followid: + instances.append({ + 'domain': row.domain, + 'date': row.timestamp.strftime('%Y-%m-%d') + }) + + return response.template('page/home.haml', {'instances': instances}) -async def reterror(view, request, error): - return await view.get(view, request, msg=error) +class UnciaRules(View): + paths = ['/rules'] + + async def get(self, request, response): + pass -# ActivityPub-related Endpoints -class Inbox(HTTPMethodView): - async def post(self, request): - if request.body == b'': - logging.debug('received empty message') - return error(request, 'Empty message', 400) +class UnciaAbout(View): + paths = ['/about'] - try: - data = json.loads(request.body) + async def get(self, request, response): + return response.template('page/about.haml') - except Exception as e: - logging.error(e) - return error(request, 'Message not valid json', 400) - if None in [data.get('actor'), data.get('object')]: - logging.info('Missing actor or object') - return error(request, 'Missing actor or object', 401) +class UnciaRegister(View): + paths = ['/register'] - actor_url = data.get('actor') - action = data.get('type', '').lower() - actor = fetch(actor_url) - inbox = get_inbox(actor) - - if None in [actor, inbox]: - return response.text('Failed to fetch actor', status=400) - - domain = urlparse(actor_url).netloc - urls = { - 'inbox': inbox, - 'actor': actor_url, - 'domain': domain + async def get(self, request, response, error=None, message=None, form={}): + data = { + 'form': form, + 'error': error, + 'message': message } - if get.config('block_relays') and action == 'follow': - nodeinfo = fetch(f'https://{domain}/nodeinfo/2.0.json') - - if nodeinfo: - try: - software = nodeinfo['software']['name'] - - except KeyError: - software = '' - - if software.lower() in ['activityrelay', 'unciarelay']: - logging.debug(f'Ignored relay: {domain}') - return error(request, 'Relays have been blocked from following', 403) - - if get.domainban(domain): - logging.debug(f'') - return error(request, 'Unauthorized!', 403) - - dbinbox = get.inbox(inbox) - - if action == 'undo' and not dbinbox: - logging.debug(f'Non-registered instance tried to send a delete: {domain}') - return response.text('Stop sending deletes!', status=202) - - if action != 'follow' and not dbinbox: - logging.info(f'Inbox not in database: {inbox}') - return error(request, 'Not following', 401) - - thread = threading.Thread(target=process, args=(request, data, actor, urls)) - thread.start() - - return response.text('OwO', status=202) + return response.template('page/register.haml', data) -class Actor(HTTPMethodView): - async def get(self, request): + async def post(self, request, response): + return await self.get(request, response, form=request.data.form) + + +class UnciaLogin(View): + paths = ['/login'] + + async def get(self, request, response, error=None, message=None, form={}): + data = { + 'form': form, + 'error': error, + 'message': message + } + + return response.template('page/login.haml', data) + + + async def post(self, request, response): + return await self.get(request, response, form=request.data.form) + + +class UnciaLogout(View): + paths = ['/logout'] + + async def get(self, request, response): + return response.redir('/') + + +class UnciaAdmin(View): + paths = ['/admin'] + + async def get(self, request, response): + return response.template('page/home.haml') + + + async def post(self, request, response): + return await self.get(request, response) + + +### ActivityPub and AP-related endpoints + +class UnciaActor(View): + paths = ['/actor', '/inbox'] + + async def get(self, request, response): + with db.session as s: + cfg = s.get.config_all() + data = { '@context': [ 'https://www.w3.org/ns/activitystreams', {'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers'}, ], 'endpoints': { - 'sharedInbox': f"https://{host}/inbox" + 'sharedInbox': f"https://{config.host}/inbox" }, #'followers': f'https://{host}/followers', #'following': f'https://{host}/following', - 'inbox': f'https://{host}/inbox', - 'name': get.config('name'), + 'inbox': f'https://{config.host}/inbox', + 'name': cfg.name, 'type': 'Application', - 'id': f'https://{host}/actor', - 'manuallyApprovesFollowers': get.config('require_approval'), + 'id': f'https://{config.host}/actor', + 'manuallyApprovesFollowers': cfg.require_approval, 'publicKey': { - 'id': f'https://{host}/actor#main-key', - 'owner': f'https://{host}/actor', - 'publicKeyPem': keys['pubkey'] + 'id': f'https://{config.host}/actor#main-key', + 'owner': f'https://{config.host}/actor', + 'publicKeyPem': cfg.pubkey }, 'summary': 'Relay Actor', 'preferredUsername': 'relay', - 'url': f'https://{host}/actor' + 'url': f'https://{config.host}/actor' } - return response.json(data, status=200) + return response.json(data) -class Nodeinfo(HTTPMethodView): - async def get(self, request): - admin_acct = get.config('admin') - email = get.config('email') - admin = admin_acct.split('@') if admin_acct else None + async def post(self, request, response): + data = ProcessData(request, response, request.data.json) - inboxes = [inbox['domain'] for inbox in get.inbox('all')] - domainbans = [row['domain'] for row in get.domainban('all')] - userbans = [f"{row['username']}@{row['domain']}" for row in get.userban(None, 'all')] + # return error to let mastodon retry instead of having to re-add relay + return data.new_response or response.text('UvU', status=202) + #return response.text('Merp! uvu') - if '@' not in admin_acct: - admin = None + +class UnciaNodeinfo(View): + paths = ['/nodeinfo/2.0.json', '/nodeinfo/2.0'] + + async def get(self, request, response): + with db.session as s: + instances = s.get.inbox_list('domain') + domainbans = [row.domain for row in s.get.ban_list('domain')] + userbans = [f"{row.handle}@{row.domain}" for row in s.get.ban_list('user')] data = { 'openRegistrations': True, @@ -146,24 +155,22 @@ class Nodeinfo(HTTPMethodView): }, 'software': { 'name': 'unciarelay', - 'version': f'{version}' + 'version': f'{__version__}' }, 'usage': { 'localPosts': 0, 'users': { - 'total': len(inboxes) + 'total': len(instances) } }, 'version': '2.0', 'metadata': { - 'require_approval': get.config('require_approval'), - 'peers': inboxes, - 'email': email if email else 'NotSet', - 'admin': {'username': admin_acct, 'url': f'https://{admin[1]}/users/{admin[0]}'} if admin_acct else 'NotSet', + 'require_approval': s.get.config('require_approval'), + 'peers': instances, + 'email': s.get.config('email'), 'federation': { - 'whitelist': True if get.config('whitelist') else False, - 'instance_blocks': False if not get.config('show_domainbans') else domainbans, - 'user_blocks': False if not get.config('show_userbans') else userbans + 'instance_blocks': False if not s.get.config('show_domain_bans') else domainbans, + 'user_blocks': False if not s.get.config('show_user_bans') else userbans } } } @@ -171,319 +178,39 @@ class Nodeinfo(HTTPMethodView): return response.json(data) -class WellknownNodeinfo(HTTPMethodView): - async def get(self, request): - data = { +class UnciaWebfinger(View): + paths = ['/.well-known/webfinger'] + + async def get(self, request, response): + resource = request.data.query.get('resource') + + if resource != f'acct:relay@{config.host}': + return response.text('', status=404) + + return response.json({ + 'subject': f'acct:relay@{config.host}', + 'aliases': [ + f'https://{config.host}/actor' + ], + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': f'https://{config.host}/actor' + } + ] + }) + + +class UnciaWellknownNodeinfo(View): + paths = ['/.well-known/nodeinfo'] + + async def get(self, request, response): + return response.json({ 'links': [ { 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', - 'href': f'https://{host}/nodeinfo/2.0.json' + 'href': f'https://{config.host}/nodeinfo/2.0.json' } ] - } - return response.json(data) - - -class WellknowWebfinger(HTTPMethodView): - async def get(self, request): - res = request.ctx.query.get('resource') - - if not res or res != f'acct:relay@{host}': - data = {} - - else: - data = { - 'subject': f'acct:relay@{host}', - 'aliases': [ - f'https://{host}/actor' - ], - 'links': [ - { - 'href': f'https://{host}/actor', - 'rel': 'self', - 'type': 'application/activity+json' - }, - #{ - #'href': f'https://{host}/actor', - #'rel': 'self', - #'type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' - #} - ] - } - - return response.json(data) - - -# Frontend -class Home(HTTPMethodView): - async def get(self, request): - data = {'instances': admin.get_instance_data()} - return render('home.html', request, data) - - -class Faq(HTTPMethodView): - async def get(self, request): - return render('faq.html', request, {}) - - -class Admin(HTTPMethodView): - async def get(self, request, *args, action=None, msg=None, **kwargs): - page = kwargs.get('page', request.ctx.query.get('page', 'instances')) - - if action: - return error(request, f'Not found: {request.path}', 404) - - whitelist = admin.get_whitelist_data() - - data = { - 'page': page, - 'instances': admin.get_instance_data(), - 'whitelist': whitelist, - 'wldomains': [row['domain'] for row in whitelist], - 'requests': get.request('all'), - 'domainban': admin.get_domainbans(), - 'userban': admin.get_userbans(), - 'auth_code': get.auth_code - } - - context = {'msg': msg, 'data': data} - return render('admin.html', request, context) - - async def post(self, request, action=''): - action = re.sub(r'[^a-z]+', '', action.lower()) - data = request.ctx.form - page = data.get('page', 'instances') - - msg = admin.run(action, data) - - return await self.get(request, msg=msg, page=page) - - -class Account(HTTPMethodView): - async def get(self, request, msg=None): - token = request.cookies.get('token') - token_data = get.token(token) - - if not token_data: - return await Login().get(request, msg='Invalid token') - - user = get.user(token_data['userid']) - tokens = get.token({'userid': token_data['userid']}) - context = { - 'tokens': [{'id': token['id'], 'token': token['token'], 'timestamp': format_date(token['timestamp'])} for token in tokens], - 'user': user, - 'msg': msg - } - - return render('account.html', request, context) - - async def post(self, request, action=''): - action = re.sub(r'[^a-z]+', '', action.lower()) - password = request.ctx.form.get('password') - token = request.cookies.get('token') - token_data = get.token(token) - user = get.user(token_data['userid']) - handle = user['handle'] - - if action in ['delete', 'password']: - if not get.verify_password(handle, password): - return await self.get(request, msg='Invalid password') - - if action == 'delete': - if None in [password, token, user]: - return self.get(request, msg='Missing password, token, or username') - - put.del_user(token) - resp = response.redirect('/') - del resp.cookies['token'] - return resp - - if action == 'password': - pass1 = request.ctx.form.get('newpass1') - pass2 = request.ctx.form.get('newpass2') - - if pass1 != pass2: - return await self.get(request, msg='New passwords do not match') - - new_pass = pass1 - - if not put.password(handle, new_pass): - return await self.get(request, msg='Failed to update password') - - else: - return await self.get(request, msg='Updated password') - - if action == 'name': - dispname = request.ctx.form.get('displayname') - - if not dispname: - return await self.get(request, msg='Missing new display name') - - if put.acct_name(handle, dispname): - return await self.get(request, msg='Updated display name') - - else: - return await self.get(request, msg='Failed to update display name') - - if action == 'token': - form_token = request.ctx.form.get('token') - - if not form_token: - return await self.get(request, msg='Failed to provide token to delete') - - if put.del_token(form_token): - return await self.get(request, msg='Deleted token') - - else: - return await self.get(request, msg='Failed to delete token') - - return response.redirect('/account') - - -class Cache(HTTPMethodView): - async def get(self, request): - urls = {k: v for k,v in cache.url.items()} - sigs = {k: v for k,v in cache.sig.items()} - - data = { - 'cache': { - 'url': {k: json.dumps(v, indent=4) for k,v in urls.items()}, - 'sig': {k: json.dumps(v, indent=4) for k,v in sigs.items()} - } - } - return render('cache.html', request, data) - - -class Login(HTTPMethodView): - async def get(self, request, msg=None): - data = { - 'msg': msg, - 'code': request.ctx.query.get('code') - } - - return render('login.html', request, data) - - async def post(self, request): - username = request.ctx.form.get('username') - password = request.ctx.form.get('password') - - if None in [username, password]: - return await self.get(request, msg='Missing username or password') - - if not get.user(username): - return await self.get(request, msg='Invalid username') - - if not get.verify_password(username, password): - return await self.get(request, msg='Invalid password') - - tokendata = put.token(username) - - if not tokendata: - return await self.get(request, msg='Failed to create token') - - resp = response.redirect('/admin') - resp.cookies['token'] = tokendata['token'] - resp.cookies['token']['domain'] = host - - return resp - - -class Logout(HTTPMethodView): - async def get(self, request): - token = request.cookies.get('token') - resp = response.redirect('/') - - if token: - put.del_token(token) - del resp.cookies['token'] - - return resp - - -class Register(HTTPMethodView): - async def get(self, request, msg=None): - data = { - 'msg': msg, - 'code': request.ctx.query.get('code') - } - - return render('register.html', request, data) - - async def post(self, request): - data = request.ctx.form - keys = ['username', 'password', 'password2', 'code'] - - for key in keys: - if not data.get(key): - return await reterror(Register, request, 'One or more fields are empty') - - else: - data[key] = re.sub(r'[^a-zA-Z0-9@_.\-\!\'d,%{}]+', '', data[key]).strip() - - if data['code'] != get.auth_code: - return await reterror(Register, request, 'Invalid authentication code') - - if get.user(data['username'].lower()): - return await reterror(Register, request, 'User already exists') - - if data['password'] != data['password2']: - return await reterror(Register, request, 'Passwords don\'t match') - - userdata = put.user(data['username'], data['password']) - - if not userdata: - return await reterror(Register, request, 'Failed to create user') - - tokendata = put.token(userdata['id']) - - if not tokendata: - return await reterror(Register, request, 'Failed to create user') - - resp = response.redirect('/admin') - resp.cookies['token'] = tokendata['token'] - resp.cookies['token']['domain'] = host - - get.code('delete') - - return resp - - -class Style(HTTPMethodView): - async def get(self, request, **kwargs): - maxage = 60*60*24*7 #ONE WEEK - data = {'msg': 'uvu'} - headers = {'Content-Type': 'text/css', 'Cache-Control': f'public,max-age={maxage}, immutable'} - return render('color.css', request, data, headers=headers) - - -class Robots(HTTPMethodView): - async def get(self, request): - data = 'User-agent: *\nDisallow: /' - return response.text(data) - - -class Setup(HTTPMethodView): - async def get(self, request, *args, msg=None, **kwargs): - data = {'code': request.ctx.query.get('code'), 'msg': msg} - - return render('setup.html', request, data) - - async def post(self, request, action=''): - data = request.ctx.form - - if data.get('code') != get.auth_code: - return await self.get(request, msg='Invalid auth code') - - msg = admin.run('settings', data) - - if msg == str: - return await self.get(request, msg=msg) - - put.config({'setup': True}) - - return await self.get(request) - -class BeGay(HTTPMethodView): - async def get(self, request, *args): - data = {'Be gay': 'Do crimes'} - return response.json(data, status=200) + })