Compare commits
17 commits
3bf362556c
...
7852ddc95b
Author | SHA1 | Date | |
---|---|---|---|
Izalia Mae | 7852ddc95b | ||
Izalia Mae | abfce89435 | ||
Izalia Mae | 3cbee693a2 | ||
Izalia Mae | e5bdb40964 | ||
Izalia Mae | 057f737cf8 | ||
Izalia Mae | 310f1aee17 | ||
Izalia Mae | 51cddac201 | ||
Izalia Mae | 7f76724806 | ||
Izalia Mae | 6b952cf0d0 | ||
Izalia Mae | 14af3349a7 | ||
Izalia Mae | ee900b5282 | ||
Izalia Mae | 7bf5faebe6 | ||
Izalia Mae | 15d978b18c | ||
Izalia Mae | 11d9bd9890 | ||
Izalia Mae | 0620b67bd3 | ||
Izalia Mae | 8bb5d7c2cf | ||
Izalia Mae | 6ea8a4bcd9 |
6
LICENSE
6
LICENSE
|
@ -1,7 +1,7 @@
|
|||
relay
|
||||
Copyright Zoey Mae 2020
|
||||
Uncia Relay
|
||||
Copyright Zoey Mae 2021
|
||||
|
||||
NON-VIOLENT PUBLIC LICENSE v4
|
||||
NON-VIOLENT PUBLIC LICENSE v4+
|
||||
|
||||
Preamble
|
||||
|
||||
|
|
38
Makefile
Normal file
38
Makefile
Normal file
|
@ -0,0 +1,38 @@
|
|||
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.git"
|
||||
|
||||
update-deps:
|
||||
git reset HEAD --hard
|
||||
git pull
|
||||
$(PYTHON) -m pip install -U -r requirements.txt
|
||||
|
||||
run:
|
||||
$(PYTHON) -m uncia
|
||||
|
||||
dev:
|
||||
env LOG_LEVEL='debug' $(PYTHON) -m reload
|
49
README.md
49
README.md
|
@ -1,44 +1,35 @@
|
|||
# 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`
|
||||
|
||||
## Manage Commands
|
||||
|
||||
There are a number of commands that can be ran to manage various parts of the relay. Some require the relay to be running in order to work. Run the `help` command to list all of the available commands.
|
||||
|
||||
~/.local/share/venv/uncia/bin/python -m uncia.manage <command> [*args]
|
||||
|
|
|
@ -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
|
21
reload.json
Normal file
21
reload.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"bin": "~/.local/share/venv/uncia/bin/python",
|
||||
"args": ["-m", "uncia"],
|
||||
"env": {},
|
||||
"path": "./uncia",
|
||||
"watch_ext": [
|
||||
"py",
|
||||
"env"
|
||||
],
|
||||
"ignore_dirs": [
|
||||
"build",
|
||||
"config",
|
||||
"data"
|
||||
],
|
||||
"ignore_files": [
|
||||
"reload.py",
|
||||
"test.py",
|
||||
"manage.py"
|
||||
],
|
||||
"log_level": "INFO"
|
||||
}
|
|
@ -1,14 +1,4 @@
|
|||
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
|
||||
envbash==1.2.0
|
||||
izzylib[hasher,http_server,http_urllib_client,sql,template] @ https://git.barkshark.xyz/izaliamae/izzylib/archive/0.7.0.tar.gz
|
||||
|
||||
pyyaml==5.4.1
|
||||
pg8000==1.21.2
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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']
|
|
@ -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)
|
|
@ -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
|
|
@ -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')
|
|
@ -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']
|
|
@ -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'
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from .server import main
|
||||
from izzylib import logging
|
||||
|
||||
try:
|
||||
main()
|
||||
from .config import first_start
|
||||
from .server import app
|
||||
|
||||
except KeyboardInterrupt:
|
||||
sys.exit()
|
||||
|
||||
if first_start:
|
||||
logging.error(f'Uncia has not been configured yet. Please edit {config.envfile} first.')
|
||||
|
||||
app.start()
|
||||
|
|
261
uncia/admin.py
261
uncia/admin.py
|
@ -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}')
|
129
uncia/config.py
129
uncia/config.py
|
@ -1,83 +1,74 @@
|
|||
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'
|
||||
logging.set_config('level', env.get('LOG_LEVEL', 'INFO'))
|
||||
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
full_path = abspath(sys.executable)
|
||||
exe_path = None
|
||||
data_path = getattr(sys, '_MEIPASS', dirname(abspath(__file__)))
|
||||
scriptpath = Path(__file__).resolve.parent
|
||||
configpath = scriptpath.parent.join('data')
|
||||
configpath.mkdir()
|
||||
|
||||
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)
|
||||
first_start = False
|
||||
|
||||
|
||||
envfile = f'{stor_path}/production.env'
|
||||
|
||||
if isfile(envfile):
|
||||
load_envbash(envfile)
|
||||
|
||||
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
|
||||
|
||||
# Web forward config
|
||||
SECRET={ranchars}
|
||||
|
||||
# Development mode
|
||||
#UNCIA_DEV=False
|
||||
'''
|
||||
|
||||
with open(envfile, 'w') as newenvfile:
|
||||
newenvfile.write(newenv)
|
||||
path = DotDict(
|
||||
script = scriptpath,
|
||||
frontend = scriptpath.join('frontend'),
|
||||
config = configpath,
|
||||
envfile = configpath.join('env.production')
|
||||
)
|
||||
|
||||
try:
|
||||
db_connection_count = int(env.get('DBCONNUM', 25))
|
||||
load_envbash(path.envfile)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
except:
|
||||
db_connection_count = 25
|
||||
|
||||
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
|
||||
}
|
||||
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()))
|
||||
)
|
||||
|
||||
fwsecret = env.get('SECRET')
|
||||
development = env.get('UNCIA_DEV')
|
||||
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))
|
||||
)
|
||||
|
||||
|
||||
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}
|
||||
|
||||
# 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}
|
||||
''')
|
||||
|
||||
|
||||
if not path.envfile.exists:
|
||||
first_start = True
|
||||
write_config()
|
||||
|
|
|
@ -1,234 +1,34 @@
|
|||
import pg, uuid, sys, random, string, traceback
|
||||
from izzylib import logging
|
||||
from izzylib.http_urllib_client.signatures import generate_rsa_key
|
||||
from izzylib.sql import Database
|
||||
|
||||
from os.path import isfile
|
||||
from .base import Session, tables
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def dbconn(database, pooled=True):
|
||||
options = {
|
||||
'dbname': database,
|
||||
'user': dbconfig['user'],
|
||||
'passwd': dbconfig['pass']
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def db_check():
|
||||
database = dbconfig['name']
|
||||
pre_db = dbconn('postgres', pooled=False)
|
||||
|
||||
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};')
|
||||
|
||||
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()
|
||||
|
||||
|
||||
if 'skipdbcheck' not in sys.argv:
|
||||
db_check()
|
||||
|
||||
dbpool = dbconn(dbconfig['name'])
|
||||
|
||||
|
||||
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 = Database(
|
||||
config.dbtype,
|
||||
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 KeyError:
|
||||
version = 0
|
||||
|
||||
if version == 0:
|
||||
keys = generate_rsa_key()
|
||||
db.create_database(tables)
|
||||
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')
|
||||
|
|
110
uncia/database/base.py
Normal file
110
uncia/database/base.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
import json
|
||||
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from izzylib import DotDict, boolean, logging
|
||||
from izzylib.sql import SqlDatabase, SqlSession
|
||||
from izzylib.sql import SqlColumn as Column
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import get, put, delete
|
||||
|
||||
from ..config import config, dbconfig
|
||||
|
||||
|
||||
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'),
|
||||
Column('timestamp')
|
||||
],
|
||||
'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('domain', 'text', nullable=False, unique=True),
|
||||
Column('timestamp')
|
||||
],
|
||||
'ban': [
|
||||
Column('id'),
|
||||
Column('handle', 'text'),
|
||||
Column('domain', 'text'),
|
||||
Column('reason', 'text'),
|
||||
Column('timestamp')
|
||||
],
|
||||
'actor_cache': [
|
||||
Column('id'),
|
||||
Column('url', 'text', nullable=False, unique=True),
|
||||
Column('data', 'json', nullable=False),
|
||||
Column('timestamp')
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class Session(SqlSession):
|
||||
#get = get
|
||||
#put = put
|
||||
#delete = delete
|
||||
|
||||
config_defaults = dict(
|
||||
version = (0, int),
|
||||
pubkey = (None, str),
|
||||
privkey = (None, str),
|
||||
name = ('Uncia Relay', str),
|
||||
description = ('Small and fast ActivityPub relay', str),
|
||||
rules = ([], json.loads),
|
||||
email = (None, str),
|
||||
blocked_software = (['unciarelay', 'activityrelay', 'aoderelay', 'seatlerelay'], json.loads),
|
||||
block_relays = (True, boolean),
|
||||
require_approval = (True, boolean),
|
||||
show_domain_bans = (False, boolean),
|
||||
show_user_bans = (False, boolean),
|
||||
show_whitelist = (False, boolean),
|
||||
whitelist = (False, boolean)
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.get = DotDict()
|
||||
self.put = DotDict()
|
||||
self.delete = DotDict()
|
||||
|
||||
self._set_commands('get', get)
|
||||
self._set_commands('put', put)
|
||||
self._set_commands('delete', delete)
|
||||
|
||||
|
||||
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)
|
|
@ -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
16
uncia/database/delete.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from izzylib import logging
|
||||
|
||||
|
||||
def cmd_instance(self, data):
|
||||
instance = self.get.instance(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
|
|
@ -1,246 +1,114 @@
|
|||
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, logging
|
||||
|
||||
|
||||
@connection
|
||||
def config(data, default=None, cache=True, db=None):
|
||||
if len(dbcache.config.keys()) < 1 and cache:
|
||||
update_config()
|
||||
def cmd_actor(self, url):
|
||||
#cache = self.cache.actor_cache.fetch(url)
|
||||
|
||||
if type(data) == list or data == 'all':
|
||||
settings = {}
|
||||
#if cache:
|
||||
#return cache
|
||||
|
||||
if data == 'all':
|
||||
if dbcache.config and cache:
|
||||
logging.debug('Returning cached config')
|
||||
return dbcache.config
|
||||
row = self.fetch('actor_cache', url=url)
|
||||
|
||||
rows = query_all('config')
|
||||
if not row:
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
settings.update({row['key']: bcheck(row['value'])})
|
||||
data = DotDict(row.data)
|
||||
#self.cache.actor_cache.store(url, data)
|
||||
return data
|
||||
|
||||
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()]
|
||||
|
||||
else:
|
||||
query_data = {'key': k, 'value': v}
|
||||
row = query_and('config', query_data)
|
||||
def cmd_ban_list(self, types='domain'):
|
||||
if types == 'domain':
|
||||
return self.search('ban', handle=None)
|
||||
|
||||
if row:
|
||||
settings.update({key, bcheck(row['value'])})
|
||||
bans = []
|
||||
|
||||
else:
|
||||
settings.update({key: None})
|
||||
for row in self.search('ban'):
|
||||
if row.handle:
|
||||
bans.append(row)
|
||||
|
||||
return settings
|
||||
return bans
|
||||
|
||||
elif type(data) == str:
|
||||
cached = dbcache.config.get(data)
|
||||
|
||||
if cached and cache:
|
||||
return cached
|
||||
def cmd_ban(self, handle=None, domain=None):
|
||||
cache_key = f'{handle}{domain}'
|
||||
cache = self.cache.ban.fetch(cache_key)
|
||||
|
||||
row = query('config', {'key': data})
|
||||
if cache:
|
||||
return cache
|
||||
|
||||
if handle and not domain:
|
||||
return self.fetch('ban', handle=handle)
|
||||
|
||||
elif not handle and domain:
|
||||
return self.fetch('ban', domain=domain)
|
||||
|
||||
elif handle and domain:
|
||||
return self.fetch('ban', handle=handle, domain=domain)
|
||||
|
||||
raise ValueError('handle or domain not specified')
|
||||
|
||||
|
||||
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_instance(self, data):
|
||||
for field in ['domain', 'inbox', 'actor']:
|
||||
row = self.fetch('inbox', **{field: data})
|
||||
|
||||
if row:
|
||||
value = bcheck(row['value'])
|
||||
dbcache.config[data] = value
|
||||
return value if value != None else default
|
||||
break
|
||||
|
||||
return row
|
||||
|
||||
|
||||
@connection
|
||||
def update_config(db=None):
|
||||
rows = query_all('config')
|
||||
def cmd_instance_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_inbox(*args, **kwargs):
|
||||
logging.warning('DeprecationWarning: session.get.inbox')
|
||||
return cmd_instance(*args, **kwargs)
|
||||
|
||||
|
||||
@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')
|
||||
def cmd_inbox_list(*args, **kwargs):
|
||||
logging.warning('DeprecationWarning: session.get.inbox_list')
|
||||
return cmd_instance_list(*args, **kwargs)
|
||||
|
|
|
@ -1,297 +1,100 @@
|
|||
import pg, json
|
||||
import 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 DotDict, 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_ban(self, handle=None, domain=None, reason=None):
|
||||
row = self.get.ban(handle, domain)
|
||||
|
||||
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)]
|
||||
|
||||
else:
|
||||
rows = get.retries(data)
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
db.delete('retries', id=row['id'])
|
||||
|
||||
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
|
||||
if reason:
|
||||
self.update(row=row, reason=reason)
|
||||
|
||||
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
|
||||
logging.verbose('Banned user or instance already exists')
|
||||
return
|
||||
|
||||
else:
|
||||
user = get.user(username)
|
||||
userid = user['id'] if user else None
|
||||
self.insert('ban',
|
||||
handle = handle,
|
||||
domain = domain,
|
||||
reason = reason,
|
||||
timestamp = datetime.now()
|
||||
)
|
||||
|
||||
if not userid:
|
||||
|
||||
def cmd_actor(self, url, actor):
|
||||
row = self.get.actor(url)
|
||||
|
||||
if row:
|
||||
row = self.update(
|
||||
row = row,
|
||||
data = actor,
|
||||
timestamp = datetime.now(),
|
||||
return_row = True
|
||||
)
|
||||
|
||||
else:
|
||||
row = self.insert('actor_cache',
|
||||
url = url,
|
||||
data = actor,
|
||||
timestamp = datetime.now(),
|
||||
return_row = True
|
||||
)
|
||||
|
||||
self.cache.actor_cache.store(url, row)
|
||||
return row
|
||||
|
||||
|
||||
def cmd_config(self, key, value=None):
|
||||
row = self.fetch('config', key=key)
|
||||
|
||||
if not value:
|
||||
value = self.config_defaults[key][0]
|
||||
|
||||
if type(value) == DotDict:
|
||||
value = value.to_json()
|
||||
|
||||
elif type(value) in [dict, list, set, tuple]:
|
||||
value = json.dumps(value)
|
||||
|
||||
if row:
|
||||
self.update(row=row, value=value)
|
||||
|
||||
else:
|
||||
self.insert('config', key=key, value=value)
|
||||
|
||||
self.cache.config.store(key, value)
|
||||
return value
|
||||
|
||||
|
||||
def cmd_instance(self, inbox, actor, followid=None):
|
||||
if self.get.instance(inbox):
|
||||
logging.verbose(f'Inbox already in database: {inbox}')
|
||||
return
|
||||
|
||||
tokens = query('tokens', {'userid': userid}, one=False)
|
||||
row = self.insert('inbox',
|
||||
domain = urlparse(inbox).netloc,
|
||||
inbox = inbox,
|
||||
actor = actor,
|
||||
followid = followid,
|
||||
timestamp = datetime.now(),
|
||||
return_row = True
|
||||
)
|
||||
|
||||
for token in tokens:
|
||||
db.delete('tokens', id=token['id'])
|
||||
|
||||
db.delete('users', id=userid)
|
||||
return row
|
||||
|
||||
|
||||
@connection
|
||||
def token(username, db=None):
|
||||
userdata = get.user(username)
|
||||
def cmd_whitelist(self, domain):
|
||||
row = self.fetch('whitelist', domain=domain)
|
||||
|
||||
if not userdata:
|
||||
if row:
|
||||
logging.verbose(f'Domain already in the whitelist: {domain}')
|
||||
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
|
||||
self.insert('whitelist',
|
||||
domain = domain,
|
||||
timestamp = datetime.now()
|
||||
)
|
||||
|
|
|
@ -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)
|
|
@ -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 |
|
@ -1 +0,0 @@
|
|||
Just a fediverse relay. Check the [FAQ](/faq) for more info.
|
|
@ -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
15
uncia/frontend/menu.haml
Normal file
|
@ -0,0 +1,15 @@
|
|||
.item -> %a(href='/') << Home
|
||||
.item -> %a(href='/about') << About
|
||||
|
||||
;-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
|
12
uncia/frontend/page/about.haml
Normal file
12
uncia/frontend/page/about.haml
Normal file
|
@ -0,0 +1,12 @@
|
|||
-extends 'base.haml'
|
||||
-set page = 'About'
|
||||
-block content
|
||||
.title -> About
|
||||
|
||||
|
||||
|
||||
%h3 << Rules:
|
||||
|
||||
%ul#rules
|
||||
-for rule in config.rules
|
||||
%li -> =rule
|
19
uncia/frontend/page/home.haml
Normal file
19
uncia/frontend/page/home.haml
Normal 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.timestamp.strftime('%Y-%m-%d')
|
9
uncia/frontend/page/login.haml
Normal file
9
uncia/frontend/page/login.haml
Normal 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')
|
10
uncia/frontend/page/register.haml
Normal file
10
uncia/frontend/page/register.haml
Normal 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')
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
|
@ -1 +0,0 @@
|
|||
This is the default rules list. Ask the admin to fill it out.
|
23
uncia/frontend/style/home.css
Normal file
23
uncia/frontend/style/home.css
Normal 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);
|
||||
}
|
|
@ -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'}
|
|
@ -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'}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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)}}
|
|
@ -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}}
|
|
@ -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'}
|
|
@ -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'}
|
|
@ -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'}
|
|
@ -1,329 +1,119 @@
|
|||
import re, sys, os, traceback, logging, ujson as json
|
||||
import json
|
||||
|
||||
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
|
||||
from functools import wraps
|
||||
from izzylib import LruCache, logging
|
||||
from izzylib.http_urllib_client import HttpUrllibClient
|
||||
from izzylib.http_urllib_client.error import MaxRetryError
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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})',
|
||||
client = HttpUrllibClient(
|
||||
appagent = f'UnciaRelay/{__version__}; https://{config.host}',
|
||||
headers = {
|
||||
'accept': 'application/activity+json,application/json'
|
||||
}
|
||||
)
|
||||
|
||||
client.set_global()
|
||||
fetch_cache = LruCache()
|
||||
|
||||
|
||||
def fetch_actor(url):
|
||||
with db.session as s:
|
||||
cached = s.get.actor(url)
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = fetch(url, cache=False)
|
||||
|
||||
except:
|
||||
data = None
|
||||
|
||||
if not data:
|
||||
data = fetch(url, sign=True, cache=False)
|
||||
|
||||
if not data or data.get('error'):
|
||||
return
|
||||
|
||||
s.put.actor(url, data)
|
||||
return data
|
||||
|
||||
|
||||
def fetch(url, sign=False, headers={}, cache=True):
|
||||
cached_data = fetch_cache.fetch(url)
|
||||
|
||||
if cached_data and cache:
|
||||
return cached_data
|
||||
|
||||
if sign:
|
||||
with db.session as s:
|
||||
response = client.request(url,
|
||||
privkey = s.get.config('privkey'),
|
||||
keyid = f'https://{config.host}/actor#main-key',
|
||||
headers = {}
|
||||
)
|
||||
|
||||
else:
|
||||
response = client.request(url)
|
||||
|
||||
try:
|
||||
data = response.dict
|
||||
|
||||
except json.decoder.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
if cache:
|
||||
fetch_cache.store(url, data)
|
||||
|
||||
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)
|
||||
|
||||
if cached:
|
||||
logging.debug(f'Caching {url}')
|
||||
cache.url.store(url, data)
|
||||
|
||||
if data.get('error'):
|
||||
return
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f'Failed to load data: {response.data}')
|
||||
logging.debug(e)
|
||||
return
|
||||
|
||||
|
||||
|
||||
def format_urls(urls):
|
||||
actor = urls.get('actor')
|
||||
inbox = urls.get('inbox')
|
||||
domain = urls.get('domain')
|
||||
|
||||
if not actor:
|
||||
logging.warning('Missing actor')
|
||||
|
||||
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)
|
||||
def fetch_auth(url):
|
||||
return fetch(url, sign=True)
|
||||
|
||||
|
||||
def get_inbox(actor):
|
||||
if actor == None:
|
||||
if not actor:
|
||||
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, headers={}):
|
||||
with db.session as s:
|
||||
try:
|
||||
response = client.request(
|
||||
inbox,
|
||||
body = message.to_json(),
|
||||
method = 'post',
|
||||
privkey = s.get.config('privkey'),
|
||||
keyid = f'https://{config.host}/actor#main-key'
|
||||
)
|
||||
|
||||
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.dict
|
||||
except:
|
||||
body = response.text
|
||||
|
||||
if not webfinger or not webfinger.get('links'):
|
||||
return
|
||||
logging.debug(f'Error from {inbox}: {body}')
|
||||
|
||||
actor = None
|
||||
return response
|
||||
|
||||
for line in webfinger['links']:
|
||||
if line.get('type') in ['application/activity+json', 'application/json']:
|
||||
actor = line['href']
|
||||
## this exception catching will be used later
|
||||
except Exception as s:
|
||||
pass
|
||||
|
||||
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)
|
||||
except MaxRetryError:
|
||||
pass
|
||||
|
|
136
uncia/log.py
136
uncia/log.py
|
@ -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()
|
485
uncia/manage.py
Normal file
485
uncia/manage.py
Normal file
|
@ -0,0 +1,485 @@
|
|||
import json, sys
|
||||
|
||||
from datetime import datetime
|
||||
from envbash import load_envbash
|
||||
from getpass import getuser
|
||||
from izzylib import DotDict, Path, boolean, logging, prompt
|
||||
from izzylib.sql import Database
|
||||
from os import environ as env
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import __version__
|
||||
from .config import config, dbconfig, path, write_config
|
||||
from .database import db
|
||||
from .functions import fetch_actor, get_inbox
|
||||
from .messages import Message
|
||||
|
||||
|
||||
exe = f'{sys.executable} -m uncia.manage'
|
||||
forbidden_keys = ['pubkey', 'privkey', 'version', 'rules']
|
||||
|
||||
|
||||
## todo: use argparse to simplify argument parsing
|
||||
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 []
|
||||
|
||||
try:
|
||||
self.result = self[cmd](*args)
|
||||
except InvalidCommandError:
|
||||
self.result = f'Not a valid command: {cmd}'
|
||||
|
||||
|
||||
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 help:
|
||||
Show this message.
|
||||
|
||||
python3 -m uncia.manage list:
|
||||
List currently subscribed instances.
|
||||
|
||||
python3 -m add <actor or domain>: *
|
||||
Add an instance to the database.
|
||||
|
||||
python3 -m remove <actor, inbox, or domain>:
|
||||
Remove an instance and any retries associated with it from the database.
|
||||
|
||||
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.
|
||||
|
||||
python3 -m uncia.manage set <key> [value]:
|
||||
Set a config option to a value. If no value is specified, the default is used.
|
||||
|
||||
python3 -m uncia.manage request:
|
||||
List the instances currently requesting to join.
|
||||
|
||||
python3 -m uncia.manage accept <actor, inbox, or domain>: *
|
||||
Accept a request.
|
||||
|
||||
python3 -m uncia.manage deny <actor, inbox, or domain>: *
|
||||
Reject a request.
|
||||
|
||||
python3 -m uncia.manage rules [list]:
|
||||
List the current rules.
|
||||
|
||||
python3 -m uncia.manage rules add <rule>:
|
||||
Add a rule to the list.
|
||||
|
||||
python3 -m uncia.manage rules remove <rule number>:
|
||||
Remove a rule from the list.
|
||||
|
||||
python3 -m uncia.manage convert [pleroma or uncia]:
|
||||
Convert the database and config of another relay. Tries to convert a
|
||||
Pleroma Relay by default.
|
||||
|
||||
* = The relay needs to be running for the command to work
|
||||
'''
|
||||
|
||||
|
||||
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}"'
|
||||
|
||||
if value == DefaultValue:
|
||||
value = None
|
||||
|
||||
value = s.put.config(key, value)
|
||||
return f'Set {key} = "{value}"'
|
||||
|
||||
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):
|
||||
if not value:
|
||||
return self.cmd_config(key, DefaultValue)
|
||||
|
||||
return self.cmd_config(key, ' '.join(value))
|
||||
|
||||
|
||||
def cmd_request(self, action=None, url=None):
|
||||
with db.session as s:
|
||||
if not action:
|
||||
instances = []
|
||||
|
||||
for row in s.search('inbox'):
|
||||
if row.followid:
|
||||
instances.append(row.domain)
|
||||
|
||||
if not instances:
|
||||
return 'No requests'
|
||||
|
||||
text = 'Awaiting Requests:\n'
|
||||
text += '\n'.join([f'- {domain}' for domain in instances])
|
||||
return text
|
||||
|
||||
else:
|
||||
instance = s.get.instance(url)
|
||||
|
||||
if instance.followid:
|
||||
if action == 'accept':
|
||||
s.update(row=instance, followid=None)
|
||||
text = f'Accepted {url}'
|
||||
|
||||
elif action == 'reject':
|
||||
s.remove(row=instance)
|
||||
text = f'Rejected {url}'
|
||||
|
||||
else:
|
||||
return f'Not a valid request action: {action}'
|
||||
|
||||
Message('accept', instance.followid, instance.actor).send(instance.inbox)
|
||||
return text
|
||||
|
||||
|
||||
def cmd_accept(self, url):
|
||||
return self.cmd_request('accept', url)
|
||||
|
||||
|
||||
def cmd_reject(self, url):
|
||||
return self.cmd_request('reject', url)
|
||||
|
||||
|
||||
def cmd_list(self):
|
||||
instance_list = 'Connected Instances:\n'
|
||||
|
||||
with db.session as s:
|
||||
instance_list += '\n'.join([f'- {domain}' for domain in s.get.instance_list('domain')])
|
||||
|
||||
return instance_list
|
||||
|
||||
|
||||
def cmd_add(self, actor_url):
|
||||
if not actor_url.startswith('https://'):
|
||||
actor_url = f'https://{actor_url}/actor'
|
||||
|
||||
actor = fetch_actor(actor_url)
|
||||
inbox = get_inbox(actor)
|
||||
|
||||
if not actor:
|
||||
return f'Failed to fetch actor at {actor_url}'
|
||||
|
||||
with db.session as s:
|
||||
if s.get.instance(actor_url):
|
||||
return f'Instance already added to the relay'
|
||||
|
||||
instance = s.put.instance(inbox, actor_url)
|
||||
|
||||
if instance:
|
||||
return f'Added {instance.domain} to the relay'
|
||||
|
||||
else:
|
||||
return f'Failed to add {actor_url} to the relay'
|
||||
|
||||
|
||||
def cmd_remove(self, data):
|
||||
with db.session as s:
|
||||
if s.delete.instance(data):
|
||||
return f'Instance removed: {data}'
|
||||
|
||||
else:
|
||||
return f'Instance does not exist: {data}'
|
||||
|
||||
|
||||
def cmd_rules(self, *args):
|
||||
try:
|
||||
action = args[0]
|
||||
except IndexError:
|
||||
action = 'list'
|
||||
|
||||
try:
|
||||
rule = ' '.join(args[1:])
|
||||
except IndexError:
|
||||
rule = None
|
||||
|
||||
if action in ['add', 'remove'] and rule == None:
|
||||
return 'You forgot to specify a rule'
|
||||
|
||||
with db.session as s:
|
||||
rules = s.get.config('rules')
|
||||
|
||||
if action == 'list':
|
||||
if not rules:
|
||||
return 'No rules specified'
|
||||
|
||||
text = 'Relay Rules:\n'
|
||||
text += '\n'.join([f'{idx} {rule}' for idx, rule in enumerate(rules)])
|
||||
|
||||
return text
|
||||
|
||||
elif action == 'add':
|
||||
rules.append(rule)
|
||||
s.put.config('rules', rules)
|
||||
return f'Added rule: {rule}'
|
||||
|
||||
elif action == 'remove':
|
||||
rule = int(rule)
|
||||
rule_text = rules[rule]
|
||||
|
||||
rules.remove(rule_text)
|
||||
s.put.config('rules', rules)
|
||||
return f'Removed rule: {rule_text}'
|
||||
|
||||
|
||||
def cmd_convert(self, relay='uncia'):
|
||||
if relay.lower() == 'uncia':
|
||||
return self.convert_uncia()
|
||||
|
||||
if relay.lower() == 'pleroma':
|
||||
return self.convert_pleroma()
|
||||
|
||||
|
||||
def convert_uncia(self):
|
||||
cfg = load_old_uncia_config()
|
||||
pgdb = Database('postgresql', **cfg)
|
||||
|
||||
new_cfg = cfg.copy()
|
||||
new_cfg.pop('name', None)
|
||||
dbconfig.update(new_cfg)
|
||||
|
||||
with pgdb.session as s:
|
||||
oldcfg = DotDict({row.key: row.value for row in s.search('config')})
|
||||
inboxes = s.search('inboxes')
|
||||
requests = s.search('requests')
|
||||
retries = s.search('retries')
|
||||
users = s.search('users')
|
||||
whitelist = s.search('whitelist')
|
||||
domainbans = s.search('domainbans')
|
||||
userbans = s.search('userbans')
|
||||
actorkey = s.fetch('keys', actor='default')
|
||||
|
||||
config.host = oldcfg.host
|
||||
config.listen = oldcfg.address
|
||||
config.port = oldcfg.port
|
||||
|
||||
write_config()
|
||||
|
||||
with db.session as s:
|
||||
s.put.config('name', oldcfg.name)
|
||||
s.put.config('admin', oldcfg.admin)
|
||||
s.put.config('show_domain_bans', boolean(oldcfg.show_domainbans))
|
||||
s.put.config('show_user_bans', boolean(oldcfg.show_userbans))
|
||||
s.put.config('whitelist', boolean(oldcfg.whitelist))
|
||||
s.put.config('block_relays', boolean(oldcfg.block_relays))
|
||||
s.put.config('require_approval', boolean(oldcfg.require_approval))
|
||||
s.put.config('privkey', actorkey.privkey)
|
||||
s.put.config('pubkey', actorkey.pubkey)
|
||||
|
||||
for inbox in [*inboxes, *requests]:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(inbox.timestamp)
|
||||
except:
|
||||
timestamp = datetime.now()
|
||||
|
||||
s.insert('inbox',
|
||||
actor = inbox.actor,
|
||||
inbox = inbox.inbox,
|
||||
domain = inbox.domain,
|
||||
followid = inbox.get('followid'),
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
for retry in retries:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(retry.timestamp)
|
||||
except:
|
||||
timestamp = datetime.now()
|
||||
|
||||
instance = s.fetch('inbox', inbox=retry.inbox)
|
||||
|
||||
if not instance:
|
||||
continue
|
||||
|
||||
s.insert('retry',
|
||||
inboxid = instance.id,
|
||||
msgid = retry.msgid,
|
||||
headers = json.loads(retry.headers),
|
||||
data = json.loads(retry.data),
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(user.timestamp)
|
||||
except:
|
||||
timestamp = datetime.now()
|
||||
|
||||
s.insert('user',
|
||||
handle = user.handle,
|
||||
username = user.username,
|
||||
hash = user.password,
|
||||
level = 30,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
for instance in whitelist:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(instance.timestamp)
|
||||
except:
|
||||
timestamp = datetime.now()
|
||||
|
||||
s.insert('whitelist',
|
||||
domain = instance.domain,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
for row in [*domainbans, *userbans]:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(row.timestamp)
|
||||
except:
|
||||
timestamp = datetime.now()
|
||||
|
||||
s.insert('ban',
|
||||
handle = row.get('username'),
|
||||
domain = None if row.domain.lower() == 'any' else row.domain,
|
||||
reason = row.reason,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
pgdb.close()
|
||||
|
||||
delete_old = prompt('Config and database successfully converted. Delete old files and drop the old database?', default='no', valtype=boolean, options=['yes', 'no'])
|
||||
|
||||
if delete_old:
|
||||
path.data.join('production.env').delete()
|
||||
|
||||
pgdb._connect_args[0]['name'] = 'postgres'
|
||||
pgdb.open()
|
||||
|
||||
with pgdb.session as s:
|
||||
s.execute(f'DROP DATABASE {cfg.name}')
|
||||
|
||||
pgdb.close()
|
||||
|
||||
return f'Successfully dropped database: {cfg.name}'
|
||||
|
||||
|
||||
def convert_pleroma(self):
|
||||
try:
|
||||
pl_cfg = load_pleroma_relay_config()
|
||||
pl_db = DotDict()
|
||||
pl_db.load_json(pl_cfg.db)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return f'Error when loading config or database: {e}'
|
||||
|
||||
config.listen = pl_cfg.listen
|
||||
config.port = pl_cfg.port
|
||||
config.host = pl_cfg.host
|
||||
|
||||
write_config()
|
||||
|
||||
for instance in pl_db['relay-list']:
|
||||
self.cmd_add(urlparse(instance).netloc)
|
||||
|
||||
with db.session as s:
|
||||
s.put.config('privkey', pl_db.actorKeys.privateKey)
|
||||
s.put.config('pubkey', pl_db.actorKeys.publicKey)
|
||||
s.put.config('whitelist', pl_cfg.whitelist_enabled)
|
||||
s.put.config('description', pl_cfg.note)
|
||||
s.put.config('blocked_software', pl_cfg.blocked_software)
|
||||
|
||||
for domain in pl_cfg.blocked_instances:
|
||||
s.put.ban(domain=domain)
|
||||
|
||||
for domain in pl_cfg.whitelist:
|
||||
s.put.whitelist(domain)
|
||||
|
||||
delete_old = prompt('Config and database successfully converted. Delete old files?', default='no', valtype=boolean, options=['yes', 'no'])
|
||||
|
||||
if delete_old:
|
||||
Path('relay.yaml').delete()
|
||||
pl_cfg.db.delete()
|
||||
|
||||
|
||||
def load_old_uncia_config():
|
||||
try:
|
||||
load_envbash(path.config.join('production.env'))
|
||||
|
||||
except FileNotFoundError as e:
|
||||
pass
|
||||
|
||||
cfg = DotDict(
|
||||
host = env.get('DBHOST'),
|
||||
port = int(env.get('DBPORT', 5432)),
|
||||
user = env.get('DBUSER', getuser()),
|
||||
password = env.get('DBPASS'),
|
||||
name = env.get('DBNAME', 'uncia'),
|
||||
maxconnections = int(env.get('DBCONNUM', 15))
|
||||
)
|
||||
|
||||
#for k,v in cfg.items():
|
||||
#if v == None:
|
||||
#del cfg[k]
|
||||
|
||||
#if k == 'host' and Path(v).exists():
|
||||
#cfg['unix_socket'] = v
|
||||
#del cfg[k]
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def load_pleroma_relay_config():
|
||||
with open('relay.yaml') as f:
|
||||
yaml_file = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
if not yaml_file.get('ap'):
|
||||
raise ValueError('Missing AP section in Pleroma Relay config')
|
||||
|
||||
config = DotDict({
|
||||
'db': Path(yaml_file.get('db', 'relay.jsonld')).resolve,
|
||||
'listen': yaml_file.get('listen', '127.0.0.1'),
|
||||
'port': int(yaml_file.get('port', 3621)),
|
||||
'note': yaml_file.get('note'),
|
||||
'blocked_software': [v.lower() for v in yaml_file['ap'].get('blocked_software', [])],
|
||||
'blocked_instances': yaml_file['ap'].get('blocked_instances', []),
|
||||
'host': yaml_file['ap'].get('host', 'localhost'),
|
||||
'whitelist': yaml_file['ap'].get('whitelist', []),
|
||||
'whitelist_enabled': yaml_file['ap'].get('whitelist_enabled', False)
|
||||
})
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class InvalidCommandError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DefaultValue(object):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = sys.argv[1:] if len(sys.argv) > 1 else []
|
||||
cmd = Command(args)
|
||||
|
||||
if cmd.result:
|
||||
print(cmd.result)
|
|
@ -1,231 +1,94 @@
|
|||
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
|
||||
from .functions import push_message
|
||||
|
||||
|
||||
host = get.config('host')
|
||||
class Message:
|
||||
def __init__(self, name, *args):
|
||||
self.message = getattr(self, name)(*args)
|
||||
|
||||
|
||||
def accept(followid, urls):
|
||||
actor_url = urls['actor']
|
||||
inbox = urls['inbox']
|
||||
domain = urls['domain']
|
||||
UUID = str(uuid.uuid4())
|
||||
|
||||
body = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Accept',
|
||||
'to': [actor_url],
|
||||
'actor': f'https://{host}/actor',
|
||||
|
||||
'object': {
|
||||
'type': 'Follow',
|
||||
'id': followid,
|
||||
'object': f'https://{host}/actor',
|
||||
'actor': actor_url
|
||||
},
|
||||
|
||||
'id': f'https://{host}/activities/{UUID}',
|
||||
}
|
||||
|
||||
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
|
||||
def send(self, inbox):
|
||||
return push_message(inbox, self.message)
|
||||
|
||||
|
||||
def announce(obj_id, inbox):
|
||||
activity_id = f'https://{host}/activities/{str(uuid.uuid4())}'
|
||||
def accept(self, followid, actor):
|
||||
message = DotDict({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Accept',
|
||||
'to': [actor],
|
||||
'actor': f'https://{config.host}/actor',
|
||||
|
||||
message = {
|
||||
'@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
|
||||
}
|
||||
'object': {
|
||||
'type': 'Follow',
|
||||
'id': followid,
|
||||
'object': f'https://{config.host}/actor',
|
||||
'actor': actor
|
||||
},
|
||||
|
||||
push_inboxes(message, origin=inbox)
|
||||
'id': f'https://{config.host}/activities/{str(uuid4())}',
|
||||
})
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def forward(data, inbox):
|
||||
push_inboxes(data, origin=inbox)
|
||||
def reject(self, followid, actor):
|
||||
message = DotDict({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Reject',
|
||||
'to': [actor],
|
||||
'actor': f'https://{config.host}/actor',
|
||||
|
||||
'object': {
|
||||
'type': 'Follow',
|
||||
'id': followid,
|
||||
'object': f'https://{config.host}/actor',
|
||||
'actor': actor
|
||||
},
|
||||
|
||||
'id': f'https://{config.host}/activities/{str(uuid4())}',
|
||||
})
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def paws(url):
|
||||
data = {
|
||||
"@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"
|
||||
}
|
||||
def announce(self, object_id):
|
||||
data = DotDict({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Announce',
|
||||
'to': [f'https://{config.host}/followers'],
|
||||
'actor': f'https://{config.host}/actor',
|
||||
'object': object_id,
|
||||
'id': f'https://{config.host}/activities/{str(uuid4())}'
|
||||
})
|
||||
|
||||
push(url, data)
|
||||
return data
|
||||
|
||||
|
||||
def notification(domain):
|
||||
acct = get.config('admin')
|
||||
admin_user, admin_domain = acct.split('@')
|
||||
admin_uuid = uuid.uuid4()
|
||||
def note(self, 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",
|
||||
"id": f"https://{config.host}/activities/{str(uuid4())}",
|
||||
"type": "Create",
|
||||
"actor": f"https://{config.host}/actor",
|
||||
"object": {
|
||||
"id": f"https://{config.host}/activities/{str(uuid4())}",
|
||||
"type": "Note",
|
||||
"published": ap_date(),
|
||||
"attributedTo": f"https://{config.host}/actor",
|
||||
"content": message,
|
||||
'to': [user_inbox],
|
||||
'tag': [{
|
||||
'type': 'Mention',
|
||||
'href': user_actor,
|
||||
'name': f'@{user_handle}@{user_domain}'
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
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}",
|
||||
"type": "Create",
|
||||
"actor": f"https://{host}/actor",
|
||||
"object": {
|
||||
"id": f"https://{host}/activities/{admin_uuid}",
|
||||
"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}'
|
||||
],
|
||||
'tag': [{
|
||||
'type': 'Mention',
|
||||
'href': f'https://{admin_domain}/users/{admin_user}',
|
||||
'name': f'@{admin_user}@{admin_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
|
||||
|
|
|
@ -1,61 +1,103 @@
|
|||
import ujson as json
|
||||
from izzylib import logging
|
||||
from izzylib.http_urllib_client import parse_signature, verify_request, verify_headers
|
||||
from izzylib.http_server import MiddlewareBase
|
||||
|
||||
from sanic import response
|
||||
from izzylib.sql.rows import Row
|
||||
|
||||
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
|
||||
from .functions import fetch_actor
|
||||
|
||||
|
||||
async def access_log(request, response):
|
||||
response.headers['Server'] = f'Uncia/{version}'
|
||||
response.headers['Trans'] = 'Rights'
|
||||
auth_paths = [
|
||||
'/logout',
|
||||
'/user',
|
||||
'/admin'
|
||||
]
|
||||
|
||||
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}"')
|
||||
# Instances that just shouldn't exist and/or are known to harass others
|
||||
blocked_instances = [
|
||||
'kiwifarms.cc',
|
||||
'neckbeard.xyz',
|
||||
'gameliberty.club',
|
||||
'shitposter.club',
|
||||
'freespeechextremist.com',
|
||||
'smuglo.li',
|
||||
'yggdrasil.social',
|
||||
'gleasonator.com',
|
||||
'ligma.pro',
|
||||
'fedi.absturztau.be',
|
||||
'blob.cat',
|
||||
'social.i2p.rocks',
|
||||
'lolicon.rocks',
|
||||
'pawoo.net',
|
||||
'baraag.net'
|
||||
]
|
||||
|
||||
|
||||
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.ctx.token.id) if request.token else None
|
||||
request.ctx.signature = parse_signature(request.headers.get('signature'))
|
||||
request.ctx.instance = None
|
||||
request.ctx.actor = None
|
||||
|
||||
if not valid:
|
||||
return error(request, 'Invalid signature', 401)
|
||||
if request.ctx.signature:
|
||||
domain = request.ctx.signature.domain
|
||||
top_domain = request.ctx.signature.top_domain
|
||||
actor = request.ctx.signature.actor
|
||||
|
||||
else:
|
||||
accept = True if 'json' in request.headers.get('accept', '') or request.path.startswith('/api') else None
|
||||
if top_domain in blocked_instances:
|
||||
return response.text(f'This teapot kills fascists', status=418)
|
||||
|
||||
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 any(map(s.get.ban, [None], [domain, top_domain])):
|
||||
return response.text('no', status=403)
|
||||
|
||||
apitoken = request.headers.get('token')
|
||||
token = request.cookies.get('token')
|
||||
request.ctx.instance = s.get.instance(domain)
|
||||
request.ctx.actor = fetch_actor(actor)
|
||||
|
||||
if not get.user('all') and not accept and request.path.startswith(('/admin', '/login')):
|
||||
return response.redirect('/register')
|
||||
if request.path in ['/inbox', '/actor'] and request.method.lower() == 'post':
|
||||
if not request.ctx.actor:
|
||||
return response.text('Could not get actor', status=400)
|
||||
|
||||
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)
|
||||
try:
|
||||
data = request.data.json
|
||||
|
||||
else:
|
||||
return response.redirect('/login')
|
||||
except:
|
||||
logging.verbose('Failed to parse post data')
|
||||
return response.text(f'Invalid data', status=400)
|
||||
|
||||
try:
|
||||
if type(request.ctx.actor).__name__ == 'Row':
|
||||
logging.warning('Actor data is a db row:', actor)
|
||||
logging.debug(request.ctx.actor.keys())
|
||||
return response.text(f'An unknown error happened', status=500)
|
||||
|
||||
validated = verify_headers(
|
||||
request.Headers.to_dict(),
|
||||
request.method,
|
||||
request.path,
|
||||
actor = request.ctx.actor,
|
||||
body = request.body
|
||||
)
|
||||
|
||||
except AssertionError as e:
|
||||
logging.debug(f'Failed sig check: {e}')
|
||||
return response.text(f'Failed signature check: {e}', status=401)
|
||||
|
||||
if not validated:
|
||||
logging.debug(f'Not validated: {actor}')
|
||||
return response.text(f'Failed signature check: {e}', status=401)
|
||||
|
||||
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')
|
||||
|
|
|
@ -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)
|
|
@ -1,115 +1,135 @@
|
|||
import uuid, threading
|
||||
import ujson as json
|
||||
import json
|
||||
|
||||
from izzylib import logging
|
||||
from tldextract import extract
|
||||
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 .database import db
|
||||
from .functions import fetch, fetch_actor, get_inbox, push_message
|
||||
from .messages import Message
|
||||
|
||||
|
||||
def relay_announce(data, actor, urls):
|
||||
object_id = get_id(data)
|
||||
inbox = urls['inbox']
|
||||
instance = urls['domain']
|
||||
relayed_objects = []
|
||||
|
||||
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
|
||||
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.actor = request.ctx.actor
|
||||
self.type = data.type.lower()
|
||||
self.data = data
|
||||
|
||||
username = get_post_user(data)
|
||||
|
||||
if username:
|
||||
user, domain = username
|
||||
@property
|
||||
def func(self):
|
||||
return getattr(self, f'cmd_{self.type}')
|
||||
|
||||
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}')
|
||||
def error(self, message, status=500):
|
||||
return self.response.json({'error': message}, status=status)
|
||||
|
||||
|
||||
def cmd_follow(self):
|
||||
if self.actor.type.lower() != 'application':
|
||||
#return self.response.json('No', status=403)
|
||||
|
||||
Message('reject', self.data.id, self.actor.id).send(self.actor.inbox)
|
||||
logging.debug(f'Rejected non-application actor: {self.actor.id}')
|
||||
return
|
||||
|
||||
announce(object_id, inbox)
|
||||
with db.session as s:
|
||||
req_app = s.get.config('require_approval')
|
||||
|
||||
cache.obj.store(object_id, {'data': data, 'actor': actor})
|
||||
if not (self.instance and not self.instance.followid):
|
||||
data = [
|
||||
get_inbox(self.actor),
|
||||
self.actor.id
|
||||
]
|
||||
|
||||
if req_app:
|
||||
data.append(self.data.id)
|
||||
|
||||
self.instance = s.put.instance(*data)
|
||||
|
||||
if not self.instance:
|
||||
logging.error(f'Something messed up when inserting "{self.signature.domain}" into the database')
|
||||
return self.error('Internal error', 500)
|
||||
|
||||
message = Message('accept', self.data.id, self.instance.actor)
|
||||
resp = message.send(self.instance.inbox)
|
||||
|
||||
if resp.status not in [200, 202]:
|
||||
raise ValueError(f'Error when pushing to "{self.instance.inbox}"')
|
||||
|
||||
logging.verbose(f'Instance joined the relay: {self.instance.domain}')
|
||||
|
||||
|
||||
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_undo(self):
|
||||
if self.actor.type.lower() != 'application':
|
||||
return
|
||||
|
||||
elif get.config('whitelist'):
|
||||
object = fetch(self.data.object, sign=True) if isinstance(self.data.object, str) else self.data.object
|
||||
|
||||
if object.type != 'Follow':
|
||||
return
|
||||
|
||||
accept(followid, urls)
|
||||
with db.session as s:
|
||||
s.delete.instance(self.signature.actor)
|
||||
logging.debug(f'Removed instance from relay: {self.instance.domain}')
|
||||
|
||||
|
||||
def relay_undo(data, actor, urls):
|
||||
actor_url, inbox, domain = format_urls(urls)
|
||||
def cmd_announce(self):
|
||||
if isinstance(self.data.object, dict):
|
||||
object = self.data.object
|
||||
object_id = self.data.object.id
|
||||
|
||||
if type(data.get('object')) != dict:
|
||||
logging.warning(json.dumps(data, indent=4))
|
||||
return
|
||||
else:
|
||||
object = fetch(self.data.object, sign=True)
|
||||
object_id = self.data.object
|
||||
|
||||
action = data['object'].get('type', '').lower()
|
||||
if not object:
|
||||
obj_actor = None
|
||||
logging.verbose('Failed to fetch object:', object_id)
|
||||
|
||||
if action in ['announce', 'create']:
|
||||
relay_forward(data, actor, urls)
|
||||
else:
|
||||
obj_actor = fetch(object.attributedTo, sign=True)
|
||||
|
||||
elif action == 'follow':
|
||||
put.inbox('remove', urls)
|
||||
# I'm pretty sure object.attributedTo is a string, but leaving this here just in case
|
||||
#obj_actor = fetch(object.attributedTo, sign=True) if isinstance(object.attributedTo, str) else object.attributedTo
|
||||
|
||||
else:
|
||||
logging.warning(json.dumps(data, indent=4))
|
||||
if object.id in relayed_objects:
|
||||
logging.verbose('Already relayed object:', object.id)
|
||||
return
|
||||
|
||||
if obj_actor:
|
||||
obj_handle = obj_actor.preferredUsername
|
||||
obj_domain = urlparse(object.id).netloc
|
||||
obj_domain_top = extract(obj_domain).registered_domain
|
||||
|
||||
with db.session as s:
|
||||
if any(map(s.get.ban, [None], [obj_domain, obj_domain_top])) or s.get.ban(obj_handle, obj_domain):
|
||||
logging.verbose('Refusing to relay object from banned domain or user:', object.id)
|
||||
return
|
||||
|
||||
msg = Message('announce', object.id)
|
||||
|
||||
for instance in [row for row in s.get.instance_list() if row.domain not in [obj_domain, self.instance.domain]]:
|
||||
response = msg.send(instance.inbox)
|
||||
|
||||
if response.status not in [200, 202]:
|
||||
logging.verbose(f'Failed to send object announce to {instance.domain}: {object.id}')
|
||||
logging.debug(f'Server error {response.status}: {response.text}')
|
||||
|
||||
else:
|
||||
logging.verbose(f'Sent "{object.id}" to {instance.domain}')
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
def cmd_create(self):
|
||||
return self.cmd_announce()
|
||||
|
||||
action = data.get('type')
|
||||
|
||||
if action not in objtype:
|
||||
return
|
||||
|
||||
objtype[action](data, actor, urls)
|
||||
#def cmd_delete(self):
|
||||
#pass
|
||||
|
|
126
uncia/server.py
126
uncia/server.py
|
@ -1,107 +1,37 @@
|
|||
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')
|
||||
context['config'] = 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')
|
||||
with db.session as s:
|
||||
app = Application(
|
||||
title = s.get.config('name'),
|
||||
name = 'UnciaRelay',
|
||||
version = __version__,
|
||||
listen = config.listen,
|
||||
port = config.port,
|
||||
host = config.host,
|
||||
workers = config.workers,
|
||||
git_repo = 'https://git.barkshark.xyz/izaliamae/uncia',
|
||||
proto = 'https',
|
||||
tpl_search = [path.frontend],
|
||||
tpl_context = template_context,
|
||||
class_views = [getattr(views, view) for view in dir(views) if view.startswith('Uncia')]
|
||||
)
|
||||
|
||||
app.add_middleware(AuthCheck)
|
||||
app.static('/style', path.frontend.join('style'))
|
||||
|
||||
class WatchHandler(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')
|
||||
build_templates()
|
||||
|
||||
|
||||
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()
|
||||
|
|
|
@ -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
|
|
@ -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))
|
598
uncia/views.py
598
uncia/views.py
|
@ -1,141 +1,156 @@
|
|||
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):
|
||||
with db.session as s:
|
||||
instances = s.get.instance_list()
|
||||
|
||||
return response.template('page/home.haml', {'instances': instances})
|
||||
|
||||
|
||||
async def reterror(view, request, error):
|
||||
return await view.get(view, request, msg=error)
|
||||
class UnciaAbout(View):
|
||||
paths = ['/about']
|
||||
|
||||
async def get(self, request, response):
|
||||
return response.template('page/about.haml')
|
||||
|
||||
|
||||
# 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 UnciaRegister(View):
|
||||
paths = ['/register']
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
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)
|
||||
|
||||
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 UnciaUser(View):
|
||||
paths = ['/user']
|
||||
|
||||
async def get(self, request, response):
|
||||
return response.template('page/user.haml')
|
||||
|
||||
|
||||
async def post(self, request, response):
|
||||
return await self.get(request, response)
|
||||
|
||||
|
||||
class UnciaAdmin(View):
|
||||
paths = ['/admin']
|
||||
|
||||
async def get(self, request, response):
|
||||
return response.template('page/admin.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"
|
||||
},
|
||||
'id': f'https://{config.host}/actor',
|
||||
#'followers': f'https://{host}/followers',
|
||||
#'following': f'https://{host}/following',
|
||||
'inbox': f'https://{host}/inbox',
|
||||
'name': get.config('name'),
|
||||
'type': 'Application',
|
||||
'id': f'https://{host}/actor',
|
||||
'manuallyApprovesFollowers': get.config('require_approval'),
|
||||
'publicKey': {
|
||||
'id': f'https://{host}/actor#main-key',
|
||||
'owner': f'https://{host}/actor',
|
||||
'publicKeyPem': keys['pubkey']
|
||||
},
|
||||
'summary': 'Relay Actor',
|
||||
'name': cfg.name,
|
||||
'summary': cfg.description,
|
||||
'preferredUsername': 'relay',
|
||||
'url': f'https://{host}/actor'
|
||||
'type': 'Application',
|
||||
'inbox': f'https://{config.host}/inbox',
|
||||
'url': f'https://{config.host}/',
|
||||
'manuallyApprovesFollowers': cfg.require_approval,
|
||||
'endpoints': {
|
||||
'sharedInbox': f"https://{config.host}/inbox"
|
||||
},
|
||||
'publicKey': {
|
||||
'id': f'https://{config.host}/actor#main-key',
|
||||
'owner': f'https://{config.host}/actor',
|
||||
'publicKeyPem': cfg.pubkey
|
||||
}
|
||||
}
|
||||
|
||||
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):
|
||||
if not request.ctx.actor:
|
||||
logging.verbose(f'Failed to fetch actor')
|
||||
return response.json({'error': 'Failed to fetch actor'})
|
||||
|
||||
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')]
|
||||
processor = ProcessData(request, response, request.data.json)
|
||||
|
||||
if '@' not in admin_acct:
|
||||
admin = None
|
||||
try:
|
||||
data = processor.func()
|
||||
except AttributeError:
|
||||
return processor.error(f'Message type unhandled: {processor.type}', 401)
|
||||
|
||||
return data or response.text('UvU', status=202)
|
||||
|
||||
|
||||
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.instance_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')]
|
||||
whitelist = [row.domain for row in s.search('whitelist')]
|
||||
wl_enabled = s.get.config('whitelist')
|
||||
|
||||
data = {
|
||||
'openRegistrations': True,
|
||||
|
@ -146,24 +161,23 @@ class Nodeinfo(HTTPMethodView):
|
|||
},
|
||||
'software': {
|
||||
'name': 'unciarelay',
|
||||
'version': f'{version}'
|
||||
'version': f'{__version__}'
|
||||
},
|
||||
'usage': {
|
||||
'localPosts': 0,
|
||||
'users': {
|
||||
'total': len(inboxes)
|
||||
'total': 1
|
||||
}
|
||||
},
|
||||
'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,
|
||||
'whitelist': whitelist if s.get.config('show_whitelist') and wl_enabled else wl_enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -171,319 +185,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)
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue