ability to accept follows

This commit is contained in:
Izalia Mae 2021-09-14 07:33:32 -04:00
parent 3bf362556c
commit 6ea8a4bcd9
55 changed files with 864 additions and 5069 deletions

View file

@ -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

37
Makefile Normal file
View file

@ -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

View file

@ -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`

View file

@ -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

20
reload.json Normal file
View file

@ -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"
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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']

View file

@ -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)

View file

@ -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

View file

@ -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')

View file

@ -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']

View file

@ -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'

View file

@ -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()

View file

@ -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}')

View file

@ -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}
''')

View file

@ -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')

View file

@ -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
);

16
uncia/database/delete.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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' %}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -1 +0,0 @@
Just a fediverse relay. Check the [FAQ](/faq) for more info.

View file

@ -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}
}

15
uncia/frontend/menu.haml Normal file
View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View file

@ -1 +0,0 @@
This is the default rules list. Ask the admin to fill it out.

View file

@ -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);
}

View file

@ -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'}

View file

@ -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'}

View file

@ -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}} [<a href='/logout'>logout</a>]
-else
Guest [<a href='/login'>login</a>]
-else
%p UvU
%div{'class': 'grid-item col2'}
%p
%a{'href': 'https://git.barkshark.xyz/izaliamae/uncia', 'target': '_new'}
Uncia Relay/{{version}}

View file

@ -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}}

View file

@ -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}}

View file

@ -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.<br><br>
%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.<br><br>
%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)}}

View file

@ -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}}

View file

@ -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'}

View file

@ -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'}

View file

@ -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'}

View file

@ -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

View file

@ -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()

84
uncia/manage.py Normal file
View file

@ -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)

View file

@ -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"<p><a href=\"https://{domain}/about\">{domain}</a> is requesting to join the relay</p>",
'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'<p><a href=\"https://{domain}/about\">{domain}</a> has joined the relay</p>'
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

View file

@ -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')

View file

@ -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)

View file

@ -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

View file

@ -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/<action>')
app.add_route(views.Admin.as_view(), '/admin')
app.add_route(views.Admin.as_view(), '/admin/<action>')
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-<timestamp>')
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()

View file

@ -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

View file

@ -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))

View file

@ -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)
})