Compare commits
34 commits
Author | SHA1 | Date | |
---|---|---|---|
Izalia Mae | 21368f9886 | ||
Izalia Mae | 4f0b99b71a | ||
Izalia Mae | 383cbf1423 | ||
Izalia Mae | 2c621d266e | ||
Izalia Mae | ada71fb7d7 | ||
Izalia Mae | 67efd787c7 | ||
Izalia Mae | 9d1445c080 | ||
Izalia Mae | fb0d51c872 | ||
Izalia Mae | 6cf1c4a446 | ||
Izalia Mae | 34833acae5 | ||
Izalia Mae | bd7ef6c31c | ||
Izalia Mae | a1721321ec | ||
Izalia Mae | 1e884e366e | ||
Izalia Mae | b0acb14b92 | ||
705f35f7a6 | |||
bd6833f2f1 | |||
bcb75b0542 | |||
2a81a3c68f | |||
124c004856 | |||
a1110841f3 | |||
85d0ae7268 | |||
22fe37d38a | |||
545a68eeb1 | |||
c3efd9adc1 | |||
e04dd99dc1 | |||
4aa385f8af | |||
1da142a994 | |||
a28aca218a | |||
18f3803856 | |||
35673d4cc5 | |||
3c3f49c110 | |||
69cb899a93 | |||
46489746c5 | |||
257fd8900e |
122
README.md
122
README.md
|
@ -1,4 +1,4 @@
|
|||
# Protection Against Web Scrapers (PAWS)
|
||||
# ![PAWS logo](https://git.barkshark.xyz/izaliamae/paws/raw/branch/master/paws/templates/media/logo-small.png "PAWS logo") Protection Against Web Scrapers (PAWS)
|
||||
|
||||
Web proxy for Mastodon that puts public profiles behind an auth layer.
|
||||
|
||||
|
@ -6,8 +6,6 @@ Web proxy for Mastodon that puts public profiles behind an auth layer.
|
|||
|
||||
PAWS sits between Mastodon and your front-facing web proxy to intercept incoming requests. If a profile, toot, or any related json is requested, it will be blocked unless authenticated. If authenticated fetches on mastodon are disabled, PAWS will check signatures instead
|
||||
|
||||
Note: Still very much a WIP. Currently it's just simple http auth, but I plan on adding the ability to login via oauth
|
||||
|
||||
## Installation
|
||||
|
||||
Python 3.6.0+ (3.8.0 recommended)
|
||||
|
@ -22,15 +20,22 @@ data/production.env:
|
|||
# Path to mastodon instance. Defaults to current working dir
|
||||
MASTOPATH=/home/mastodon/glitch-soc
|
||||
|
||||
# The address to mastodon
|
||||
MASTOHOST=localhost:3000
|
||||
|
||||
# Listen address and port for PAWS. Can safely be ignored if running on same host as web server
|
||||
PAWS_HOST=127.0.0.1
|
||||
PAWS_PORT=3001
|
||||
|
||||
# These will be phased out
|
||||
PAWS_USER=admin
|
||||
PAWS_PASS=password
|
||||
# Domain to list in the actor. Only needed if you plan on using the whitelist
|
||||
PAWS_DOMAIN=bappypaws.example.com
|
||||
```
|
||||
|
||||
Extra environment variables (cannot be put in production.env):
|
||||
- LOGDATE: boolean (default: yes). Set to no to remove access times from the console log
|
||||
|
||||
If Mastodon is on a different host, you'll have to copy Mastodon's .env.production to the working directory or mount the Mastodon directory to make it accessible to PAWS
|
||||
|
||||
### Caddy
|
||||
|
||||
Append this to caddy's mastodon config:
|
||||
|
@ -46,7 +51,7 @@ rewrite {
|
|||
rewrite {
|
||||
if_op or
|
||||
if {path} starts_with /@
|
||||
if {path} starts_with /authorize
|
||||
if {path} starts_with /paws
|
||||
to {path} /auth/{path}
|
||||
}
|
||||
|
||||
|
@ -56,9 +61,103 @@ proxy /auth localhost:3001 {
|
|||
}
|
||||
```
|
||||
|
||||
Config for PAWS domain:
|
||||
|
||||
```
|
||||
paws.bappypaws.example.com {
|
||||
proxy / localhost:3001 {
|
||||
transparent
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
Coming soon. Convert caddy's config to nginx format if you know how for now
|
||||
Append this to the mastodon config (Thanks to [@finkeldoodle@transfur.online](https://transfur.online/@frinkeldoodle) for the conversion)
|
||||
|
||||
```
|
||||
## Note: Order of location blocks is IMPORTANT.
|
||||
## Nginx matches longest non-regex location block, then **FIRST** matching regex block takes preference.
|
||||
## (regex blocks are ones with ~ or ~* before the path)
|
||||
|
||||
## This file is designed to be dropped into the Mastodon server block, probably before all the location blocks.
|
||||
|
||||
## If starts with /users, ends with inbox, bypass filter
|
||||
location ~* ^/users[\s\S]*inbox {
|
||||
try_files $uri @proxy;
|
||||
}
|
||||
|
||||
## If starts with /users, /@, or /paws, enter filter
|
||||
location ~* ^/(users|@|paws) {
|
||||
try_files '' @paws;
|
||||
}
|
||||
|
||||
|
||||
location @paws {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Proxy "";
|
||||
proxy_pass_header Server;
|
||||
|
||||
proxy_pass http://localhost:3001;
|
||||
}
|
||||
```
|
||||
|
||||
Config for PAWS domain:
|
||||
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name paws.bappypaws.example.com;
|
||||
root /home/mastodon/live/public;
|
||||
location /.well-known/acme-challenge/ { allow all; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name paws.bappypaws.example.com;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
|
||||
# Uncomment these lines once you acquire a certificate:
|
||||
# ssl_certificate /etc/letsencrypt/live/paws.bappypaws.example.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/paws.bappypaws.example.com/privkey.pem;
|
||||
|
||||
keepalive_timeout 70;
|
||||
sendfile on;
|
||||
client_max_body_size 80m;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Proxy "";
|
||||
proxy_pass_header Server;
|
||||
|
||||
proxy_pass http://localhost:3001;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mastodon
|
||||
|
||||
|
@ -70,3 +169,10 @@ While it isn't necessary, I highly recommend turning on authorized fetches (v3.0
|
|||
AUTHORIZED_FETCH=true
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server with `./server.py`. If you want to edit the config, just run `./server.py edit`.
|
||||
|
||||
## WebUI usage
|
||||
|
||||
If your account is an admin account according to Mastodon, you can manage the PAWS whitelist at {domain}/paws. Instances in this whitelist will have their fetches signed if they don't sign them themselves.
|
||||
|
|
13
paws.example.service
Normal file
13
paws.example.service
Normal file
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description = PAWS
|
||||
After = network.target
|
||||
[Service]
|
||||
User = [user]
|
||||
Group = [user]
|
||||
WorkingDirectory = /home/[user]/paws
|
||||
Environment = LOGDATE=no
|
||||
ExecStart = /usr/bin/python3 ./server.py
|
||||
Restart = always
|
||||
RestartSec = 3
|
||||
[Install]
|
||||
WantedBy = multi-user.target
|
|
@ -1 +1,14 @@
|
|||
'''heck'''
|
||||
from os.path import dirname, abspath
|
||||
from IzzyLib import logging, template
|
||||
from IzzyLib.misc import config_dir
|
||||
|
||||
|
||||
stor_path = config_dir(__file__)
|
||||
|
||||
logging.setConfig({'level': 'info'})
|
||||
logging.debug(f'Config: {logging.getConfig()}')
|
||||
|
||||
templates = abspath(dirname(__file__))+'/templates'
|
||||
template.addSearchPath(templates)
|
||||
template.addBuildPath('default', templates+'/pages', stor_path+'/templates')
|
||||
template.setup()
|
||||
|
|
|
@ -1,29 +1,4 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import stat
|
||||
|
||||
from os import environ as env
|
||||
from .routes import main
|
||||
|
||||
if 'install' in sys.argv:
|
||||
from .config import mastodir, logging
|
||||
|
||||
script = f'{mastodir}/paws.sh'
|
||||
start_script = f'''#!/bin/sh
|
||||
export MASTODIR={mastodir}
|
||||
|
||||
(cd MASTODIR && python -m paws)'''
|
||||
|
||||
with open(script, 'w') as sh:
|
||||
sh.write(start_script)
|
||||
|
||||
if os.path.isfile(script):
|
||||
os.chmod(script, 492)
|
||||
logging.info(f'Startup script saved as {script}')
|
||||
|
||||
else:
|
||||
logging.info(f'Failed to write script as {script}')
|
||||
|
||||
else:
|
||||
main()
|
||||
main()
|
||||
|
|
111
paws/cache.py
111
paws/cache.py
|
@ -1,111 +0,0 @@
|
|||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
def parse_ttl(ttl):
|
||||
m = re.match(r'^(\d+)([smhdw]?)$', ttl)
|
||||
|
||||
if not m:
|
||||
logging.warning(f'Invalid TTL: {ttl}. Setting to default: 1h')
|
||||
amount = 1
|
||||
unit = 'h'
|
||||
|
||||
else:
|
||||
amount = m.group(1)
|
||||
unit = m.group(2)
|
||||
|
||||
units = {
|
||||
's': 1,
|
||||
'm': 60,
|
||||
'h': 60 * 60,
|
||||
'd': 24 * 60 * 60,
|
||||
'w': 7 * 24 * 60 * 60,
|
||||
}
|
||||
|
||||
if unit:
|
||||
multiplier = units[unit]
|
||||
|
||||
else:
|
||||
multiplier = 1
|
||||
|
||||
return multiplier * int(amount)
|
||||
|
||||
|
||||
class TTLCache:
|
||||
def __init__(self, ttl='1h', maxsize=1024):
|
||||
self.items = OrderedDict()
|
||||
self.ttl = parse_ttl(ttl)
|
||||
self.maxsize = maxsize
|
||||
|
||||
|
||||
def invalidate(self, key):
|
||||
if key in self.items:
|
||||
del self.items[key]
|
||||
|
||||
|
||||
def store(self, key, value):
|
||||
timestamp = int(datetime.timestamp(datetime.now()))
|
||||
item = self.items.get(key)
|
||||
|
||||
while len(self.items) >= self.maxsize and self.maxsize != 0:
|
||||
self.items.popitem(last=False)
|
||||
|
||||
if item == None:
|
||||
data = {'data': value}
|
||||
self.items[key] = data
|
||||
|
||||
elif self.items[key]['timestamp'] + self.ttl < timestamp:
|
||||
del self.items[key]
|
||||
|
||||
self.items[key]['timestamp'] = timestamp + self.ttl
|
||||
self.items.move_to_end(key)
|
||||
|
||||
|
||||
def fetch(self, key):
|
||||
item = self.items.get(key)
|
||||
|
||||
if item != None:
|
||||
timestamp = int(datetime.timestamp(datetime.now()))
|
||||
|
||||
if timestamp >= self.items[key]['timestamp']:
|
||||
del self.items[key]
|
||||
|
||||
else:
|
||||
self.items[key]['timestamp'] = timestamp + self.ttl
|
||||
self.items.move_to_end(key)
|
||||
return self.items[key]['data']
|
||||
|
||||
|
||||
class LRUCache:
|
||||
def __init__(self, maxsize=1024):
|
||||
self.items = OrderedDict()
|
||||
self.maxsize = maxsize
|
||||
|
||||
|
||||
def invalidate(self, key):
|
||||
if key in self.items:
|
||||
del self.items[key]
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def store(self, key, value):
|
||||
while len(self.items) >= self.maxsize and self.maxsize != 0:
|
||||
self.items.popitem(last=False)
|
||||
|
||||
if (key in self.items) == False:
|
||||
self.items[key] = value
|
||||
|
||||
self.items.move_to_end(key)
|
||||
|
||||
|
||||
def fetch(self, key):
|
||||
if key in self.items:
|
||||
return self.items[key]
|
||||
|
||||
return None
|
||||
|
|
@ -1,61 +1,75 @@
|
|||
import sys
|
||||
import os
|
||||
|
||||
import logging as logger
|
||||
|
||||
from os import environ as env
|
||||
from os.path import isdir, isfile, abspath, dirname, basename
|
||||
|
||||
from envbash import load_envbash
|
||||
from IzzyLib import logging
|
||||
from IzzyLib.misc import boolean
|
||||
|
||||
from .functions import bool_check
|
||||
|
||||
VERSION = '0.1'
|
||||
VERSION = '0.3.0b'
|
||||
|
||||
full_path = abspath(sys.executable) if getattr(sys, 'frozen', False) else abspath(__file__)
|
||||
script_path = getattr(sys, '_MEIPASS', dirname(abspath(__file__)))
|
||||
script_name = basename(full_path)
|
||||
stor_path = abspath(f'{script_path}/../data')
|
||||
stor_path = abspath(f'{os.getcwd()}/data')
|
||||
|
||||
|
||||
if not isdir(stor_path):
|
||||
os.makedirs(stor_path, exist_ok=True)
|
||||
|
||||
|
||||
if not bool_check(env.get('LOGDATE', 'yes').lower()):
|
||||
log_date = ''
|
||||
|
||||
else:
|
||||
log_date = '[%(asctime)s] '
|
||||
|
||||
|
||||
logging = logger.getLogger()
|
||||
logging.setLevel(logger.DEBUG)
|
||||
log_format = f'{log_date} %(levelname)s: %(message)s'
|
||||
|
||||
logger.addLevelName(5, 'VERBOSE')
|
||||
logger.addLevelName(30, 'WARN')
|
||||
logger.addLevelName(50, 'CRIT')
|
||||
|
||||
console = logger.StreamHandler()
|
||||
console.name = 'Console Log'
|
||||
console.level = logger.INFO
|
||||
console.formatter = logger.Formatter(log_format)
|
||||
|
||||
logging.addHandler(console)
|
||||
if not boolean(env.get('LOGDATE', 'yes').lower()):
|
||||
logging.setConfig({'date': False})
|
||||
|
||||
|
||||
if not isfile(f'{stor_path}/production.env'):
|
||||
logging.error(f'PAWS environment file doesn\'t exist: {stor_path}/production.env')
|
||||
logging.info('Creating a new config file. Be sure to edit it and restart PAWS')
|
||||
|
||||
new_config = '''###
|
||||
# Uncomment and adjust any values as necessary
|
||||
###
|
||||
|
||||
### Port and host PAWS will listen on
|
||||
#PAWS_HOST=127.0.0.1
|
||||
#PAWS_PORT=3001
|
||||
|
||||
#PAWS_DOMAIN=bappypaws.example.com
|
||||
#PAWS_NORSS=true
|
||||
|
||||
### Require approval for unknown instances. They can be accepted or denied at {{MASTODOMAIN}}/paws/list/requests
|
||||
#PAWS_REQ_APPROVAL=false
|
||||
|
||||
### Override domain in mastodon's config
|
||||
#MASTODOMAIN=mastodon.example.com
|
||||
|
||||
###
|
||||
#MASTOPATH=/home/mastodon/glitch-soc
|
||||
#MASTOHOST=localhost:3000
|
||||
'''
|
||||
|
||||
with open(f'{stor_path}/production.env', 'w') as pawsconf:
|
||||
pawsconf.write(new_config)
|
||||
|
||||
logging.info(f'Created new config at {stor_path}/production.env')
|
||||
|
||||
else:
|
||||
load_envbash(f'{stor_path}/production.env')
|
||||
|
||||
|
||||
req_approval = boolean(env.get('PAWS_REQ_APPROVAL'), False)
|
||||
|
||||
|
||||
PAWSCONFIG = {
|
||||
'host': env.get('PAWS_HOST', '127.0.0.1'),
|
||||
'port': int(env.get('PAWS_PORT', 3001)),
|
||||
'user': env.get('PAWS_USER', 'admin'),
|
||||
'pass': env.get('PAWS_PASS', 'password'),
|
||||
'mastopath': env.get('MASTOPATH', os.getcwd())
|
||||
'domain': env.get('PAWS_DOMAIN', 'bappypaws.example.com'),
|
||||
'disable_rss': boolean(env.get('PAWS_DISABLE_RSS', True)),
|
||||
'require_approval': req_approval,
|
||||
'mastopath': env.get('MASTOPATH', os.getcwd()),
|
||||
'mastohost': env.get('MASTOHOST', 'localhost:3000'),
|
||||
}
|
||||
|
||||
|
||||
|
@ -68,12 +82,11 @@ else:
|
|||
load_envbash(f'{masto_path}/.env.production')
|
||||
|
||||
MASTOCONFIG={
|
||||
'domain': env.get('WEB_DOMAIN', env.get('LOCAL_DOMAIN', 'localhost:3000')),
|
||||
'auth_fetch': bool_check(env.get('AUTHENTICATED_FETCH')),
|
||||
'domain': env.get('MASTODOMAIN', env.get('WEB_DOMAIN', env.get('LOCAL_DOMAIN', 'localhost:3000'))),
|
||||
'auth_fetch': boolean(env.get('AUTHORIZED_FETCH')),
|
||||
'dbhost': env.get('DB_HOST', '/var/run/postgresql'),
|
||||
'dbport': int(env.get('DB_PORT', 5432)),
|
||||
'dbname': env.get('DB_NAME', 'mastodon_production'),
|
||||
'dbuser': env.get('DB_USER', env.get('USER')),
|
||||
'dbpass': env.get('DB_PASS')
|
||||
}
|
||||
|
||||
|
|
305
paws/database.py
305
paws/database.py
|
@ -1,21 +1,29 @@
|
|||
import sys
|
||||
import urllib3
|
||||
|
||||
from DBUtils.PooledPg import PooledPg as DB
|
||||
from datetime import datetime
|
||||
from tinydb import TinyDB, Query
|
||||
from tinydb_smartcache import SmartCacheTable
|
||||
from tinyrecord import transaction as trans
|
||||
from tldextract import extract
|
||||
from urllib.parse import urlparse
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
from .config import stor_path, logging, MASTOCONFIG as mdb
|
||||
from .functions import bool_check
|
||||
from IzzyLib import logging
|
||||
from IzzyLib.cache import LRUCache
|
||||
from IzzyLib.misc import boolean
|
||||
from DBUtils.PooledPg import PooledPg as DB
|
||||
from tinydb import TinyDB, Query, where
|
||||
from tinydb_smartcache import SmartCacheTable
|
||||
from tinyrecord import transaction as trans
|
||||
from tldextract import extract
|
||||
from Crypto.PublicKey import RSA
|
||||
from mastodon import Mastodon
|
||||
from mastodon.Mastodon import MastodonUnauthorizedError, MastodonBadGatewayError, MastodonNetworkError
|
||||
|
||||
from .config import stor_path, PAWSCONFIG, MASTOCONFIG as mdb
|
||||
from .functions import fetch, get_nodeinfo
|
||||
|
||||
|
||||
def jsondb():
|
||||
try:
|
||||
db = TinyDB(f'{stor_path}/db.json', indent='\t')
|
||||
db = TinyDB(f'{stor_path}/db.json', indent=4)
|
||||
|
||||
except JSONDecodeError as e:
|
||||
logging.critical(f'Failed to load DB: {e}. Exiting...')
|
||||
|
@ -23,14 +31,14 @@ def jsondb():
|
|||
|
||||
db.table_class = SmartCacheTable
|
||||
|
||||
tables = {
|
||||
'bans': db.table('bans'),
|
||||
'follows': db.table('follows'),
|
||||
'users': db.table('users'),
|
||||
'tokens': db.table('tokens')
|
||||
}
|
||||
class table:
|
||||
keys = db.table('keys')
|
||||
instances = db.table('instances')
|
||||
users = db.table('users')
|
||||
whitelist = db.table('whitelist')
|
||||
request = db.table('request')
|
||||
|
||||
return tables
|
||||
return table
|
||||
|
||||
|
||||
def pgdb():
|
||||
|
@ -46,56 +54,285 @@ def pgdb():
|
|||
sys.exit()
|
||||
|
||||
|
||||
def get_bans():
|
||||
def keys(actor):
|
||||
if pawsdb.keys.get(query.actor == actor) == None:
|
||||
logging.info(f'No RSA key. Generating one for {actor}...')
|
||||
PRIV = RSA.generate(4096)
|
||||
PUB = PRIV.publickey()
|
||||
|
||||
keydata = {
|
||||
'actor': actor,
|
||||
'pubkey': PUB.exportKey('PEM').decode('utf-8'),
|
||||
'privkey': PRIV.exportKey('PEM').decode('utf-8')
|
||||
}
|
||||
|
||||
with trans(pawsdb.keys) as tr:
|
||||
tr.insert(keydata)
|
||||
|
||||
|
||||
return pawsdb.keys.get(query.actor == actor)
|
||||
|
||||
|
||||
def get_handle(userid):
|
||||
user_data = mastodb.query(f'SELECT username,domain FROM public.accounts WHERE id = \'{userid}\'').dictresult()
|
||||
|
||||
if len(user_data) < 1:
|
||||
return
|
||||
|
||||
return (user_data[0]['username'].lower(), user_data[0]['domain'].lower())
|
||||
|
||||
|
||||
def get_user(handle):
|
||||
user_data = mastodb.query(f'SELECT * FROM public.accounts WHERE username = \'{handle}\' and domain is NULL').dictresult()
|
||||
return user_data[0] if len(user_data) > 0 else None
|
||||
|
||||
|
||||
def get_toot(tootid):
|
||||
toot = mastodb.query(f'SELECT * FROM public.statuses WHERE id = \'{tootid}\'').dictresult()
|
||||
return toot[0] if len(toot) > 0 else None
|
||||
|
||||
|
||||
def get_bans(suspend=True, details=False):
|
||||
domains = mastodb.query('SELECT * FROM public.domain_blocks;').dictresult()
|
||||
banlist = {}
|
||||
banlist = {} if details else []
|
||||
|
||||
for domain in domains:
|
||||
instance = domain['domain']
|
||||
severity = domain['severity']
|
||||
|
||||
if severity == 1:
|
||||
if suspend and severity != 1:
|
||||
continue
|
||||
|
||||
if details:
|
||||
banlist[instance] = {
|
||||
'severity': domain['severity'],
|
||||
'media': bool_check(domain['reject_media']),
|
||||
'reports': bool_check(domain['reject_reports']),
|
||||
'media': boolean(domain['reject_media']),
|
||||
'reports': boolean(domain['reject_reports']),
|
||||
'private': domain['private_comment'],
|
||||
'public': domain['public_comment'],
|
||||
'updated': domain['updated_at']
|
||||
}
|
||||
|
||||
else:
|
||||
banlist.append(instance)
|
||||
|
||||
return banlist
|
||||
|
||||
|
||||
def update_bans():
|
||||
'''I'll implement this later'''
|
||||
pass
|
||||
def banned_user_check(access_user):
|
||||
if not access_user:
|
||||
return
|
||||
|
||||
users = mastodb.query('SELECT username, domain FROM accounts WHERE suspended_at is not NULL').dictresult()
|
||||
|
||||
def update_bancache():
|
||||
bans = get_bans()
|
||||
banlist = cache.get('bans')
|
||||
if not users:
|
||||
return
|
||||
|
||||
for domain in bans:
|
||||
if domain not in banlist or bans[domain]['updated'] > banlist[domain]['updated']:
|
||||
banlist[domain] = bans[domain]
|
||||
allbans = [(user['username'].lower(), user['domain'].lower()) for user in users]
|
||||
|
||||
cache['bans'] = banlist
|
||||
logging.debug('Updated ban cache')
|
||||
if access_user in allbans:
|
||||
return True
|
||||
|
||||
|
||||
def ban_check(url):
|
||||
instance = urlparse(url).netloc if url.startswith('http') else url
|
||||
domain = extract(url)
|
||||
parsed = f'{domain.domain}.{domain.suffix}'
|
||||
banlist = get_bans()
|
||||
|
||||
for ban in get_bans():
|
||||
if ban in [instance, parsed]:
|
||||
for ban in banlist:
|
||||
if parsed in ban or parsed == ban:
|
||||
return True
|
||||
|
||||
logging.debug(f'{parsed} not in blocklist')
|
||||
|
||||
|
||||
def user_ban_check(user, access_user):
|
||||
user_data = mastodb.query(f'SELECT id FROM public.accounts WHERE LOWER(username) = \'{user}\' and domain is NULL').dictresult()
|
||||
|
||||
if len(user_data) < 1:
|
||||
return
|
||||
|
||||
userid = user_data[0]['id']
|
||||
userban_query = mastodb.query(f'SELECT * FROM public.blocks WHERE account_id = \'{userid}\'').dictresult()
|
||||
userbans = []
|
||||
|
||||
for ban in userban_query:
|
||||
acct_id = ban['target_account_id']
|
||||
user = get_handle(acct_id)
|
||||
|
||||
if user:
|
||||
userbans.append(user)
|
||||
|
||||
else:
|
||||
logging.warning(f'Invalid userid: {acct_id}')
|
||||
|
||||
if access_user in userbans:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def user_domain_ban_check(handle, access_domain):
|
||||
user_data = get_user(handle)
|
||||
|
||||
if not user_data:
|
||||
return
|
||||
|
||||
userid = user_data['id']
|
||||
ban_data = mastodb.query(f'SELECT * FROM public.account_domain_blocks WHERE account_id = \'{userid}\' and domain = \'{access_domain}\'').dictresult()
|
||||
|
||||
return ban_data
|
||||
|
||||
|
||||
def wl_check(domain):
|
||||
data = pawsdb.whitelist.get(query.domain == domain)
|
||||
return data
|
||||
|
||||
|
||||
def admin_check(handle):
|
||||
user = get_user(handle)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
userid = user['id']
|
||||
user_data = mastodb.query(f'SELECT * FROM public.users WHERE account_id = \'{userid}\'').dictresult()
|
||||
|
||||
if not user_data or not user_data[0]['admin']:
|
||||
return
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def whitelist(action, instance):
|
||||
domain = pawsdb.whitelist.get(query.domain == instance)
|
||||
|
||||
if action == 'add':
|
||||
if not domain:
|
||||
with trans(pawsdb.whitelist) as tr:
|
||||
tr.insert({'domain': instance})
|
||||
|
||||
elif action == 'remove':
|
||||
if domain:
|
||||
with trans(pawsdb.whitelist) as tr:
|
||||
tr.remove(query.domain == instance)
|
||||
|
||||
else:
|
||||
return 'InvalidAction'
|
||||
|
||||
|
||||
def get_instances(domain=None, state=None):
|
||||
if domain:
|
||||
rows = pawsdb.instances.get(query.domain == domain)
|
||||
|
||||
elif state:
|
||||
rows = pawsdb.instances.search(query.state == state)
|
||||
|
||||
else:
|
||||
rows = pawsdb.instances.all()
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def instances(action, instance, state='request'):
|
||||
logging.debug(', '.join([action, instance, state]))
|
||||
|
||||
if None in [action, instance, state]:
|
||||
return False
|
||||
|
||||
domain = urlparse(instance).netloc if instance.startswith('http') else instance
|
||||
instance_data = pawsdb.instances.get(query.domain == domain)
|
||||
state = state.lower()
|
||||
|
||||
if action == 'add':
|
||||
if instance_data:
|
||||
logging.debug(f'Updating instance state: {domain}, {state}')
|
||||
with trans(pawsdb.instances) as tr:
|
||||
tr.update({'state': state}, doc_ids=[instance_data.doc_id])
|
||||
|
||||
return
|
||||
|
||||
ni_software = get_nodeinfo(domain)
|
||||
|
||||
if not ni_software:
|
||||
logging.debug(f'Failed to get nodeinfo data from instance: {domain}')
|
||||
return
|
||||
|
||||
data = {
|
||||
'domain': domain,
|
||||
'software': ni_software['name'],
|
||||
'state': state,
|
||||
'timestamp': datetime.timestamp(datetime.now())
|
||||
}
|
||||
|
||||
with trans(pawsdb.instances) as tr:
|
||||
tr.insert(data)
|
||||
|
||||
elif action == 'remove':
|
||||
if not instance_data:
|
||||
logging.debug(f'Domain not in request list: {domain}')
|
||||
|
||||
with trans(pawsdb.instances) as tr:
|
||||
tr.remove(doc_ids=[instance_data.doc_id])
|
||||
|
||||
|
||||
def instance_check(domain):
|
||||
timestamp = datetime.timestamp(datetime.now())
|
||||
instance = pawsdb.instances.get(query.domain == domain)
|
||||
state = instance.get('state') if instance else None
|
||||
|
||||
if not domain:
|
||||
return False
|
||||
|
||||
if state == 'accept' or domain == mdb['domain']:
|
||||
return True
|
||||
|
||||
elif state == 'deny':
|
||||
return False
|
||||
|
||||
elif not state:
|
||||
result = instances('add', domain)
|
||||
|
||||
return
|
||||
|
||||
|
||||
def cleanup_users(invalid_check=None):
|
||||
timestamp = datetime.timestamp(datetime.now())
|
||||
invalid_offset = 60 * 15
|
||||
|
||||
with trans(pawsdb.users) as tr:
|
||||
for user in pawsdb.users.all():
|
||||
if not user.get('timestamp'):
|
||||
logging.debug(f'adding timestamp for user: {user["handle"]}@{user["domain"]}')
|
||||
tr.update({'timestamp': timestamp}, doc_ids=[user.doc_id])
|
||||
continue
|
||||
|
||||
if invalid_check:
|
||||
if user['timestamp'] < timestamp - invalid_offset and not user['token']:
|
||||
logging.debug(f'old and incomplete access token for {user["handle"]}@{user["domain"]}')
|
||||
tr.remove(doc_ids=[user.doc_id])
|
||||
continue
|
||||
|
||||
else:
|
||||
if not user['token']:
|
||||
logging.debug(f'no access token for {user["handle"]}@{user["domain"]}')
|
||||
continue
|
||||
|
||||
client = Mastodon(api_base_url=user['domain'], access_token=user['token'])
|
||||
|
||||
try:
|
||||
client.me()
|
||||
logging.debug(f'valid token for {user["handle"]}@{user["domain"]}')
|
||||
|
||||
except MastodonUnauthorizedError:
|
||||
logging.debug(f'invalid token for {user["handle"]}@{user["domain"]}')
|
||||
tr.remove(doc_ids=[user.doc_id])
|
||||
|
||||
except (urllib3.exceptions.NewConnectionError, MastodonBadGatewayError, MastodonNetworkError):
|
||||
logging.debug(f'failed to connect to domain for {user["handle"]}@{user["domain"]}')
|
||||
|
||||
|
||||
pawsdb = jsondb()
|
||||
query = Query()
|
||||
mastodb = pgdb()
|
||||
|
||||
|
|
|
@ -1,46 +1,90 @@
|
|||
import aiohttp
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import json, socket, os, re
|
||||
import aiohttp, validators, urllib3
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from IzzyLib import logging
|
||||
from IzzyLib.template import aiohttpTemplate
|
||||
from IzzyLib.cache import LRUCache
|
||||
|
||||
from .config import VERSION
|
||||
|
||||
|
||||
error_codes = {
|
||||
400: 'BadRequest',
|
||||
404: 'NotFound',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'NotFound',
|
||||
500: 'InternalServerError',
|
||||
504: 'GatewayTimeout'
|
||||
}
|
||||
httpclient = urllib3.PoolManager(num_pools=100, timeout=urllib3.Timeout(connect=15, read=15))
|
||||
|
||||
|
||||
def bool_check(value):
|
||||
if value == True or str(value).lower() in ['yes', 'true', 'enable']:
|
||||
return True
|
||||
class cache:
|
||||
url = LRUCache()
|
||||
|
||||
elif value in [None, False] or str(value).lower() in ['no', 'false', 'disable', '']:
|
||||
return False
|
||||
|
||||
def error(request, code, msg, isjson=False):
|
||||
if isjson or request.get('jsonreq'):
|
||||
return json_error(code, msg)
|
||||
|
||||
else:
|
||||
return value
|
||||
return http_error(request, code, msg)
|
||||
|
||||
|
||||
def json_error(code, error):
|
||||
error_body = json.dumps({'error': error})
|
||||
cont_type = 'application/json'
|
||||
return aiohttp.web.Response(body=error_body, content_type=cont_type, status=code)
|
||||
|
||||
if code == 418:
|
||||
raise HTTPTeapot(body=error_body, content_type=cont_type)
|
||||
|
||||
elif code not in error_codes.keys():
|
||||
logging.error(f'Hey! You specified a wrong error code: {code} {error}')
|
||||
def http_error(request, code, msg):
|
||||
data = {'msg': msg, 'code': str(code)}
|
||||
return aiohttpTemplate('error.html', data, request, status=code)
|
||||
|
||||
error_body = json.dumps({'error': 'DevError'})
|
||||
|
||||
raise aiohttp.web.HTTPInternalServerError(body=error_body, content_type=cont_type)
|
||||
def fed_domain(user, domain):
|
||||
data = fetch(f'https://{domain}/.well-known/webfinger?resource=acct:{user}@{domain}')
|
||||
|
||||
raise eval('aiohttp.web.HTTP'+error_codes[code]+'(body=error_body, content_type=cont_type)')
|
||||
if not data:
|
||||
logging.error(f'User doesn\'t exist: {user}@{domain}')
|
||||
return
|
||||
|
||||
wf_data = data.get('subject')
|
||||
user_data = wf_data.replace('acct:', '').split('@')
|
||||
|
||||
return user_data[1]
|
||||
|
||||
|
||||
def get_nodeinfo(instance):
|
||||
domain = urlparse(instance).netloc if instance.startswith('http') else instance
|
||||
|
||||
nodeinfo = fetch(f'https://{domain}/nodeinfo/2.0.json')
|
||||
|
||||
if not nodeinfo or (isinstance(nodeinfo, dict) and nodeinfo.get('error')):
|
||||
logging.debug('Wrong nodeinfo url. Finding correct one...')
|
||||
wk_url = f'https://{domain}/.well-known/nodeinfo'
|
||||
well_known = fetch(wk_url)
|
||||
|
||||
if not well_known:
|
||||
logging.debug(f'failed to fetch {wk_url}')
|
||||
return
|
||||
|
||||
ni_url = None
|
||||
|
||||
try:
|
||||
for link in well_known['links']:
|
||||
if link['rel'].startswith('http://nodeinfo.diaspora.software/ns/schema/2'):
|
||||
ni_url = link['href']
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f'Invalid nodeinfo data, Error: {e}')
|
||||
return
|
||||
|
||||
nodeinfo = fetch(ni_url)
|
||||
|
||||
if not nodeinfo:
|
||||
logging.debug(f'Failed to fetch nodeinfo for {domain}')
|
||||
|
||||
return nodeinfo['software'] if nodeinfo else None
|
||||
|
||||
|
||||
def domain_check(domain):
|
||||
stripped = domain.replace(' ', '')
|
||||
return stripped if validators.domain(stripped) else None
|
||||
|
||||
|
||||
def user_check(path):
|
||||
|
@ -56,3 +100,110 @@ def user_check(path):
|
|||
|
||||
return False
|
||||
|
||||
|
||||
def parse_sig(signature, short=False):
|
||||
if not signature:
|
||||
return
|
||||
|
||||
for line in signature.split(','):
|
||||
if 'keyid' in line.lower():
|
||||
actor = line.split('=')[1].split('#')[0].replace('"', '')
|
||||
return urlparse(actor).netloc if short else actor
|
||||
|
||||
|
||||
def parse_ua(agent):
|
||||
if not agent:
|
||||
return
|
||||
|
||||
url = re.findall('https?://(?:[-\w.]|(?:%[\da-fA-F]))+', agent)
|
||||
|
||||
if not url:
|
||||
if 'mozilla' not in agent.lower():
|
||||
logging.debug('Failed to find url in user agent')
|
||||
|
||||
return 'unknown'
|
||||
|
||||
return urlparse(url[0]).netloc
|
||||
|
||||
|
||||
def dig(domain):
|
||||
if not domain or domain == 'unknown':
|
||||
return
|
||||
|
||||
try:
|
||||
return socket.gethostbyname(domain)
|
||||
|
||||
except socket.gaierror as e:
|
||||
logging.info(f'Failed to resolve IP: {e}')
|
||||
|
||||
|
||||
def distill_query(querydata):
|
||||
rawquery = ''
|
||||
|
||||
if len(querydata) > 0:
|
||||
for var in querydata:
|
||||
if rawquery == '':
|
||||
rawquery += f'{var}={querydata[var]}'
|
||||
|
||||
else:
|
||||
rawquery += f'&{var}={querydata[var]}'
|
||||
|
||||
return rawquery if rawquery != '?' else ''
|
||||
|
||||
|
||||
def css_ts():
|
||||
from .config import script_path
|
||||
|
||||
css_check = lambda css_file : int(os.path.getmtime(f'{script_path}/templates/{css_file}.css'))
|
||||
|
||||
color = css_check('color')
|
||||
layout = css_check('layout')
|
||||
|
||||
return color + layout
|
||||
|
||||
|
||||
def fetch(url, cached=True):
|
||||
if not url:
|
||||
logging.debug(f'Fetch was not fed a url')
|
||||
return
|
||||
|
||||
cached_data = cache.url.fetch(url)
|
||||
|
||||
if cached and cached_data:
|
||||
logging.debug(f'Returning cached data for {url}')
|
||||
return cached_data
|
||||
|
||||
headers = {
|
||||
'User-Agent': f'PAWS/{VERSION}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
import logging
|
||||
import binascii
|
||||
import base64
|
||||
import traceback
|
||||
import asyncio, json, binascii, base64, traceback, aiohttp
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, quote_plus, unquote_plus
|
||||
from random import choice
|
||||
from os.path import isfile
|
||||
from http import client as HTTP
|
||||
|
||||
from IzzyLib import logging
|
||||
from IzzyLib.misc import boolean
|
||||
from aiohttp.http_exceptions import *
|
||||
from aiohttp.client_exceptions import *
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from .signature import validate, pass_hash
|
||||
from .functions import json_error, user_check
|
||||
from .config import MASTOCONFIG, PAWSCONFIG, script_path
|
||||
from . import database as db
|
||||
from .signature import validate, pass_hash, sign_headers
|
||||
from .functions import error, user_check, domain_check, parse_sig, parse_ua, dig, distill_query, get_nodeinfo, httpclient
|
||||
from .config import MASTOCONFIG, PAWSCONFIG, VERSION, script_path
|
||||
from .database import pawsdb, query, trans, ban_check, user_ban_check, banned_user_check, wl_check, keys, user_domain_ban_check, admin_check, instance_check, instances
|
||||
|
||||
|
||||
# I'm a little teapot :3
|
||||
class HTTPTeapot(aiohttp.web.HTTPError):
|
||||
status_code = 418
|
||||
|
||||
paws_host = PAWSCONFIG['domain']
|
||||
masto_host = MASTOCONFIG['domain']
|
||||
masto_path = PAWSCONFIG['mastopath']
|
||||
|
||||
blocked_agents = [
|
||||
'gabsocial',
|
||||
|
@ -30,7 +30,10 @@ blocked_agents = [
|
|||
'shitposter.club',
|
||||
'baraag',
|
||||
'gameliberty',
|
||||
'neckbeard'
|
||||
'neckbeard',
|
||||
'soapbox',
|
||||
'qoto',
|
||||
'archive'
|
||||
]
|
||||
|
||||
auth_paths = [
|
||||
|
@ -38,201 +41,226 @@ auth_paths = [
|
|||
'/users'
|
||||
]
|
||||
|
||||
admin_paths = [
|
||||
'/paws/action',
|
||||
'/paws/list'
|
||||
]
|
||||
|
||||
def parse_sig(signature):
|
||||
for line in signature.split(','):
|
||||
if 'keyId' in line:
|
||||
actor = line.split('=')[1].split('#')[0].replace('"', '')
|
||||
return actor
|
||||
error_msgs = {
|
||||
404: 'Not found',
|
||||
500: 'Server did an oopsie'
|
||||
}
|
||||
|
||||
|
||||
def parse_ua(agent):
|
||||
if not agent:
|
||||
return
|
||||
def parse_headers(headers, enc=True):
|
||||
new_headers = {}
|
||||
|
||||
ua1 = agent.split('https://')
|
||||
for header in headers:
|
||||
key, value = [item.decode() for item in header]
|
||||
|
||||
if len(ua1) < 2:
|
||||
logging.warning(f'No url in user-agent: {agent}')
|
||||
return
|
||||
if key.lower() in ['content-encoding', 'transfer-encoding'] and not enc:
|
||||
continue
|
||||
|
||||
if 'Mastodon' in agent:
|
||||
ua2 = ua1[1].split('/')
|
||||
if key in new_headers:
|
||||
new_headers[key] + '; ' + value
|
||||
|
||||
elif 'Pleroma' in agent:
|
||||
ua2 = ua1[1].split(' <')
|
||||
else:
|
||||
new_headers[key] = value
|
||||
|
||||
elif 'Misskey' in agent or 'BarksharkRelay' in agent:
|
||||
ua2 = ua1[1].split(')')
|
||||
|
||||
elif 'Friendica' in agent:
|
||||
logging.debug(ua1[1])
|
||||
return ua1[1]
|
||||
|
||||
elif 'activityrelay' in agent.lower():
|
||||
return ''
|
||||
|
||||
else:
|
||||
logging.warning(f'Unhandled user-agent: {agent}')
|
||||
|
||||
if len(ua2) > 1:
|
||||
logging.debug(ua2[0])
|
||||
return ua2[0]
|
||||
|
||||
logging.warning(f'Invalid user-agent: {agent}')
|
||||
return new_headers
|
||||
|
||||
|
||||
async def raise_auth_error(request, auth_realm):
|
||||
raise aiohttp.web.HTTPUnauthorized(
|
||||
headers={aiohttp.hdrs.WWW_AUTHENTICATE: f'Basic realm={auth_realm}'},
|
||||
body=open(f'{script_path}/templates/unauthorized.html').read(),
|
||||
content_type='text/html'
|
||||
)
|
||||
|
||||
|
||||
async def passthrough(path, headers, post=None, query=None):
|
||||
reqtype = 'POST' if post else 'GET'
|
||||
url = urlparse(path).path
|
||||
querydata = query if query else ''
|
||||
async def passthrough2(request, headers):
|
||||
mastohost = PAWSCONFIG['mastohost']
|
||||
raw_data = await request.read()
|
||||
is_json = request.get('jsonreq', False)
|
||||
Headers = parse_headers(request.raw_headers)
|
||||
Headers['Proxy'] = ''
|
||||
|
||||
try:
|
||||
async with aiohttp.request(reqtype, f'https://{MASTOCONFIG["domain"]}/{path}{query}', headers=headers, data=post) as resp:
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.request(request.method, f'http://{mastohost}{request.path}?{request.query_string}', headers=Headers, data=raw_data, timeout=timeout, allow_redirects=False) as resp:
|
||||
data = await resp.read()
|
||||
|
||||
if resp.status not in [200, 202]:
|
||||
print(data)
|
||||
logging.warning(f'Recieved error {resp.status} from Mastodon')
|
||||
json_error(resp.status, f'Failed to forward request. Recieved error {resp.status} from Mastodon')
|
||||
if resp.status not in [200, 202, 301, 302]:
|
||||
if data in [b'Request not signed', 'Request not signed']:
|
||||
err_msg = 'Missing signature'
|
||||
logging.debug(err_msg)
|
||||
|
||||
raise aiohttp.web.HTTPOk(body=data, content_type=resp.content_type)
|
||||
else:
|
||||
err_msg = f'Recieved error {resp.status} from Mastodon'
|
||||
logging.debug(err_msg)
|
||||
|
||||
return error(request, resp.status, f'Failed to forward request')
|
||||
|
||||
resp_headers = parse_headers(resp.raw_headers, False)
|
||||
|
||||
return aiohttp.web.Response(body=data, headers=resp_headers, status=resp.status)
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return error(request, 504, f'Failed to connect to Mastodon')
|
||||
|
||||
|
||||
async def passthrough(request, headers):
|
||||
mastohost = PAWSCONFIG['mastohost']
|
||||
req_data = await request.read() if request.body_exists else None
|
||||
is_json = request.get('jsonreq', False)
|
||||
|
||||
try:
|
||||
resp = httpclient.request(request.method, f'http://{mastohost}{request.path}?{request.query_string}', body=req_data, headers=headers, redirect=False)
|
||||
data = resp.data
|
||||
|
||||
if resp.status not in [200, 202, 301, 302]:
|
||||
if data in [b'Request not signed', 'Request not signed']:
|
||||
err_msg = 'Missing signature'
|
||||
logging.debug(err_msg)
|
||||
|
||||
else:
|
||||
err_msg = f'Recieved error {resp.status} from Mastodon'
|
||||
logging.debug(err_msg)
|
||||
|
||||
return error(request, resp.status, f'Failed to forward request')
|
||||
|
||||
resp_headers = dict(resp.headers)
|
||||
|
||||
if resp_headers.get('Content-Encoding'):
|
||||
del resp_headers['Content-Encoding']
|
||||
|
||||
if resp_headers.get('Transfer-Encoding'):
|
||||
del resp_headers['Transfer-Encoding']
|
||||
|
||||
return aiohttp.web.Response(body=data, headers=resp_headers, status=resp.status)
|
||||
|
||||
except ClientConnectorError:
|
||||
traceback.print_exc()
|
||||
return json_error(504, f'Failed to connect to Mastodon')
|
||||
|
||||
|
||||
async def http_redirect(app, handler):
|
||||
async def redirect_handler(request):
|
||||
querydata = request.query
|
||||
|
||||
rawquery = '?'
|
||||
|
||||
if len(querydata) > 0:
|
||||
for var in querydata:
|
||||
if rawquery == '?':
|
||||
rawquery += f'{var}={querydata[var]}'
|
||||
|
||||
else:
|
||||
rawquery += f'&{var}={querydata[var]}'
|
||||
|
||||
query = rawquery if rawquery != '' else None
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
|
||||
except Exception as e:
|
||||
#logging.warning(f'failed to grab data: {e}')
|
||||
data = None
|
||||
|
||||
await passthrough(request.path, request.headers, post=data, query=query)
|
||||
|
||||
return (await handler(request))
|
||||
return redirect_handler
|
||||
|
||||
|
||||
async def http_signatures(app, handler):
|
||||
async def http_signatures_handler(request):
|
||||
request['validated'] = False
|
||||
json_req = True if 'json' in request.headers.get('Accept', '') else False
|
||||
|
||||
if request.method == 'POST':
|
||||
if 'signature' in request.headers:
|
||||
data = await request.json()
|
||||
|
||||
if 'actor' not in data:
|
||||
logging.info('signature check failed, no actor in message')
|
||||
raise json_error(401, 'signature check failed, no actor in message')
|
||||
|
||||
actor = data["actor"]
|
||||
if not (await validate(actor, request)):
|
||||
logging.info(f'Signature validation failed for: {actor}')
|
||||
raise json_error(401, 'signature check failed, signature did not match key')
|
||||
|
||||
else:
|
||||
logging.info('missing signature')
|
||||
raise json_error(401, 'Missing signature')
|
||||
|
||||
if any(map(request.path.startswith, auth_paths)) and request.method != 'POST':
|
||||
if json_req or request.path.endswith('.json'):
|
||||
if not user_check(request.path) and MASTOCONFIG['auth_fetch']:
|
||||
signature = request.headers.get('signature', '')
|
||||
|
||||
if not signature:
|
||||
logging.info('missing signature')
|
||||
raise json_error(401, 'Missing signature')
|
||||
|
||||
actor = parse_sig(signature)
|
||||
|
||||
if not (await validate(actor, request)):
|
||||
logging.info(f'Signature validation failed for: {actor}')
|
||||
raise json_error(401, 'signature check failed, signature did not match key')
|
||||
|
||||
else:
|
||||
auth_username = PAWSCONFIG['user']
|
||||
auth_password = PAWSCONFIG['pass']
|
||||
auth_realm = 'Nope'
|
||||
|
||||
auth_header = request.headers.get(aiohttp.hdrs.AUTHORIZATION)
|
||||
|
||||
if auth_header == None or not auth_header.startswith('Basic '):
|
||||
return await raise_auth_error(request, auth_realm)
|
||||
|
||||
try:
|
||||
secret = auth_header[6:].encode('utf-8')
|
||||
auth_decoded = base64.decodebytes(secret).decode('utf-8')
|
||||
|
||||
except (UnicodeDecodeError, UnicodeEncodeError, binascii.Error):
|
||||
await raise_auth_error(request)
|
||||
|
||||
credentials = auth_decoded.split(':')
|
||||
|
||||
if len(credentials) != 2:
|
||||
await raise_auth_error(request, auth_realm)
|
||||
|
||||
username, password = credentials
|
||||
|
||||
if username != auth_username or password != auth_password:
|
||||
await raise_auth_error(request, auth_realm)
|
||||
|
||||
return (await handler(request))
|
||||
return http_signatures_handler
|
||||
return error(request, 504, f'Failed to connect to Mastodon')
|
||||
|
||||
|
||||
async def http_filter(app, handler):
|
||||
async def http_filter_handler(request):
|
||||
ua = request.headers.get('user-agent')
|
||||
signature = request.headers.get('signature')
|
||||
sig_domain = parse_sig(signature, short=True)
|
||||
|
||||
if not user_check(request.path):
|
||||
try:
|
||||
data = await request.json()
|
||||
actor = data.get('actor')
|
||||
ua = request.headers.get('user-agent').lower()
|
||||
ua_domain = parse_ua(ua)
|
||||
domain = ua_domain if not sig_domain else sig_domain
|
||||
|
||||
if actor:
|
||||
domain = urlparse(actor).netloc
|
||||
token = request.cookies.get('paws_token')
|
||||
user_data = None if not token else pawsdb.users.get(query.token == token)
|
||||
user = (user_data['handle'], user_data['instance']) if user_data and user_data.get('instance') else (None, None)
|
||||
|
||||
except:
|
||||
domain = parse_ua(ua)
|
||||
real_ip = request.headers.get('X-Real-Ip', request.remote)
|
||||
ua_ip = dig(ua_domain)
|
||||
|
||||
if not domain:
|
||||
raise json_error(401, 'Can\'t parse user-agent')
|
||||
instance = domain if domain != 'unknown' else user[1]
|
||||
allow = instance_check(instance)
|
||||
request['jsonreq'] = True if 'json' in request.headers.get('Accept', '') or request.path.endswith('.json') else False
|
||||
|
||||
if [agent for agent in blocked_agents if agent in request.headers.get('User-Agent', '').lower()]:
|
||||
logging.info(f'Blocked garbage: {domain}')
|
||||
raise HTTPTeapot(body='418 This teapot kills fascists', content_type='text/plain')
|
||||
# add logged in user data to the request for the frontend
|
||||
request['user'] = user_data
|
||||
request['admin'] = admin_check(user_data['handle']) if user_data else None
|
||||
|
||||
if db.ban_check(domain):
|
||||
logging.info(f'Blocked instance: {domain}')
|
||||
raise json_error(403, 'Forbidden')
|
||||
# block nazis and general garbage
|
||||
for agent in blocked_agents:
|
||||
if agent in ua:
|
||||
logging.debug(f'Blocked garbage: {domain}')
|
||||
return error(request, 418, 'This teapot kills fascists')
|
||||
|
||||
# Disable rss feeds
|
||||
if PAWSCONFIG['disable_rss'] and request.path.endswith('.rss'):
|
||||
return error(request, 403, 'RSS feeds disabled')
|
||||
|
||||
if request.path in ['/paws/actor', '/paws/inbox', '/.well-known/webfinger'] and request.host != paws_host:
|
||||
return aiohttp.web.HTTPFound('/paws')
|
||||
|
||||
# give up if a domain can't be found'
|
||||
if not domain:
|
||||
return error(request, 401, 'Can\'t find instance domain')
|
||||
|
||||
# block any suspended instances
|
||||
if ban_check(domain):
|
||||
logging.debug(f'Blocked instance: {domain}')
|
||||
return error(request, 403, 'Forbidden')
|
||||
|
||||
# block any suspended users
|
||||
if banned_user_check(user):
|
||||
logging.debug(f'Blocked user: {domain}')
|
||||
return error(request, 403, 'Access Denied')
|
||||
|
||||
# prevent unauthed users from accessing the instance lists
|
||||
if any(map(request.path.startswith, admin_paths)) and not request['admin']:
|
||||
return aiohttp.web.HTTPFound('/paws/login')
|
||||
|
||||
if any(map(request.path.startswith, auth_paths)) and request.method == 'GET':
|
||||
# Check signatures if auth fetches are off
|
||||
if not user_check(request.path):
|
||||
if PAWSCONFIG['require_approval'] and not allow:
|
||||
if allow != False:
|
||||
status, message = (401, 'Instance awaiting approval or rejection')
|
||||
instances('add', instance)
|
||||
|
||||
else:
|
||||
status, message = (403, 'Rejected')
|
||||
|
||||
return error(request, status, message)
|
||||
|
||||
if not MASTOCONFIG['auth_fetch']:
|
||||
if signature:
|
||||
actor = parse_sig(signature)
|
||||
|
||||
if not (await validate(actor, request)):
|
||||
logging.warning(f'Signature validation failed for: {actor}')
|
||||
return error(request, 401, 'signature check failed, signature did not match key')
|
||||
|
||||
elif real_ip == ua_ip and wl_check(domain):
|
||||
logging.info(f'Letting {domain} through')
|
||||
|
||||
else:
|
||||
msg = 'missing signature'
|
||||
logging.warning(msg)
|
||||
return error(request, 401, msg)
|
||||
|
||||
if not request['jsonreq']:
|
||||
if not token or not user_data:
|
||||
return aiohttp.web.HTTPFound(f'/paws/login?redir={quote_plus(request.path)}')
|
||||
|
||||
split_path = request.path.split('/')
|
||||
user = split_path[1].replace('@', '') if request.path.startswith('/@') else split_path[2]
|
||||
|
||||
if user_ban_check(user.lower(), (user_data['handle'].lower(), user_data['instance'])) or user_domain_ban_check(user.lower(), user_data['instance']):
|
||||
return error(request, 403, 'Access Denied')
|
||||
|
||||
if signature and wl_check(domain) and request.method == 'GET':
|
||||
logging.warning(f'{domain} has started signing requests and can be removed from the whitelist')
|
||||
|
||||
if not signature and real_ip == ua_ip and wl_check(domain) and request.method == 'GET':
|
||||
logging.debug(f'Signing fetch for whitelisted instance: {domain}')
|
||||
|
||||
HEADERS = {
|
||||
'(request-target)': f'get {request.path}',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': f'PAWS/{VERSION}; https://{masto_host}',
|
||||
'Host': masto_host,
|
||||
'X-Forwarded-For': request.headers.get('X-Forwarded-For', request.remote),
|
||||
'X-Forwarded-Port': '443',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Real-Ip': request.headers.get('X-Real-Ip', request.remote)
|
||||
}
|
||||
HEADERS['signature'] = sign_headers(HEADERS, 'default', f'https://{paws_host}/paws/actor#main-key')
|
||||
HEADERS.pop('(request-target)')
|
||||
|
||||
else:
|
||||
HEADERS = request.headers
|
||||
|
||||
if not request.path.startswith('/paws'):
|
||||
masto_file = f'{masto_path}/public{request.path}'
|
||||
|
||||
if isfile(masto_file):
|
||||
return aiohttp.web.FileResponse(masto_file, headers=HEADERS)
|
||||
|
||||
return_data = await passthrough(request, HEADERS)
|
||||
return return_data
|
||||
|
||||
return (await handler(request))
|
||||
return http_filter_handler
|
||||
|
@ -248,4 +276,50 @@ async def http_trailing_slash(app, handler):
|
|||
return http_trailing_slash_handler
|
||||
|
||||
|
||||
__all__ = ['http_signatures_middleware', 'http_auth_middleware', 'http_filter_middleware', 'http_trailing_slash']
|
||||
# Custom error handler
|
||||
async def http_error(app, handler):
|
||||
async def http_error_handler(request):
|
||||
try:
|
||||
response = await handler(request)
|
||||
|
||||
#except aiohttp.web.HTTPException as ex:
|
||||
# message = error_msgs.get(ex.status, 'Server did an oopsie')
|
||||
# response = error(request, ex.status, message)
|
||||
|
||||
except Exception as e:
|
||||
if getattr(e, 'status', None):
|
||||
message = error_msgs.get(e.status, 'Server did an oopsie')
|
||||
response = error(request, e.status, message)
|
||||
|
||||
else:
|
||||
traceback.print_exc()
|
||||
response = error(request, 500, e)
|
||||
|
||||
return response
|
||||
return http_error_handler
|
||||
|
||||
|
||||
async def http_server_header(request, response):
|
||||
response.headers['Server'] = f'PAWS/{VERSION}'
|
||||
|
||||
phrases = [
|
||||
'trans rights are human rights',
|
||||
'trans rights',
|
||||
'be gay, do crimes',
|
||||
'punch nazis',
|
||||
'eat the rich',
|
||||
'im gay',
|
||||
'heck'
|
||||
]
|
||||
|
||||
value = choice(phrases)
|
||||
|
||||
response.headers['msg'] = value
|
||||
|
||||
|
||||
async def http_access_log(request, response):
|
||||
if not request.path.endswith(('js', 'css', 'png', 'jpg', 'gif')):
|
||||
uagent = request.headers.get('user-agent')
|
||||
client_ip = request.headers.get('X-Real-IP', request.remote)
|
||||
|
||||
logging.info(f'{client_ip} {request.method} {request.path_qs} {response.status} "{uagent}"')
|
||||
|
|
105
paws/oauth.py
Normal file
105
paws/oauth.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import http.client as http
|
||||
|
||||
from IzzyLib import logging
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from mastodon import Mastodon
|
||||
|
||||
from .config import PAWSCONFIG, MASTOCONFIG, VERSION, stor_path
|
||||
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': f'PAWS/{VERSION}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
SCOPES = ['read:accounts']
|
||||
REDIR_URI = f'https://{MASTOCONFIG["domain"]}/paws/auth'
|
||||
WEBSITE = 'https://git.barkshark.xyz/izaliamae/paws'
|
||||
|
||||
|
||||
# some instances use a different domain for federation
|
||||
def get_webhost(domain):
|
||||
try:
|
||||
conn = http.HTTPSConnection(domain)
|
||||
conn.request('GET', '/.well-known/host-meta', headers=HEADERS)
|
||||
response = conn.getresponse()
|
||||
|
||||
if response.status == 301:
|
||||
url = response.msg.get('Location')
|
||||
return urlparse(url).netloc
|
||||
|
||||
elif response.status != 200:
|
||||
return ('error', f'Failed to fetch host-meta: received error {response.status}', 'heck')
|
||||
|
||||
except:
|
||||
msg = f'Failed to connect to {domain}'
|
||||
logging.error(msg)
|
||||
return ('error', msg, 'heck')
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
def create_app(domain):
|
||||
instance = get_webhost(domain)
|
||||
|
||||
if 'error' in instance:
|
||||
return instance
|
||||
|
||||
try:
|
||||
app_id, app_secret = Mastodon.create_app(
|
||||
f'PAWS @ {MASTOCONFIG["domain"]}',
|
||||
api_base_url=instance,
|
||||
scopes=SCOPES,
|
||||
redirect_uris=REDIR_URI,
|
||||
website=WEBSITE
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logging.error(f'Failed to create app: {e}')
|
||||
return ('error', 'Failed to create app', 'heck')
|
||||
|
||||
try:
|
||||
client = Mastodon(
|
||||
api_base_url=instance,
|
||||
client_id=app_id,
|
||||
client_secret=app_secret
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logging.error(f'Failed to create app: {e}')
|
||||
return ('error', 'Failed to create app', 'heck')
|
||||
|
||||
return (app_id, app_secret, client.auth_request_url(redirect_uris=REDIR_URI, scopes=SCOPES))
|
||||
|
||||
|
||||
def login(user, code):
|
||||
fetch = Mastodon(
|
||||
api_base_url=user['domain'],
|
||||
client_id=user['appid'],
|
||||
client_secret=user['appsecret']
|
||||
)
|
||||
|
||||
try:
|
||||
token = fetch.log_in(redirect_uri=REDIR_URI, scopes=SCOPES, code=code)
|
||||
|
||||
except:
|
||||
msg = 'Failed to fetch token'
|
||||
logging.error(msg)
|
||||
return ('error', msg)
|
||||
|
||||
try:
|
||||
client = Mastodon(api_base_url=user['domain'], access_token=token)
|
||||
fetch_user = client.me()
|
||||
|
||||
except:
|
||||
msg = 'Failed to get user info'
|
||||
logging.error(msg)
|
||||
return ('error', msg)
|
||||
|
||||
return (token, fetch_user)
|
|
@ -1,34 +1,60 @@
|
|||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import os, sys, asyncio, aiohttp, aiohttp_jinja2, jinja2
|
||||
|
||||
from IzzyLib import logging, template, color
|
||||
from IzzyLib.template import buildTemplates, templateWatcher
|
||||
from ipaddress import ip_address as address
|
||||
from urllib.parse import urlparse
|
||||
from mastodon import Mastodon
|
||||
|
||||
from .config import PAWSCONFIG, VERSION, script_path, logging
|
||||
from . import middleware
|
||||
from .config import PAWSCONFIG, MASTOCONFIG, VERSION, script_path
|
||||
from .functions import css_ts
|
||||
from .database import cleanup_users, admin_check
|
||||
from . import middleware, views, stor_path
|
||||
|
||||
|
||||
def webserver():
|
||||
from . import views
|
||||
|
||||
web = aiohttp.web.Application(middlewares=[
|
||||
middleware.http_filter,
|
||||
middleware.http_signatures,
|
||||
middleware.http_redirect
|
||||
middleware.http_trailing_slash,
|
||||
middleware.http_error
|
||||
])
|
||||
|
||||
web.on_response_prepare.append(middleware.http_server_header)
|
||||
web.on_response_prepare.append(middleware.http_access_log)
|
||||
|
||||
web.add_routes([
|
||||
aiohttp.web.route('GET', '/authorize', views.authorize),
|
||||
aiohttp.web.route('GET', '/paws', views.get_paws),
|
||||
aiohttp.web.route('GET', '/paws/imgay', views.get_gay),
|
||||
aiohttp.web.route('POST', '/paws/action/{action}', views.post_paws),
|
||||
aiohttp.web.view('/paws/list/{list}', views.lists),
|
||||
aiohttp.web.view('/paws/login', views.login),
|
||||
aiohttp.web.route('GET', '/paws/logout', views.get_logout),
|
||||
aiohttp.web.route('GET', '/paws/auth', views.get_auth),
|
||||
aiohttp.web.route('GET', '/paws/style-{timestamp}.css', views.get_style),
|
||||
|
||||
aiohttp.web.route('GET', '/.well-known/webfinger', views.get_webfinger),
|
||||
aiohttp.web.route('POST', '/paws/inbox', views.post_inbox),
|
||||
aiohttp.web.route('GET', '/paws/actor', views.get_actor),
|
||||
|
||||
#aiohttp.web.route('GET', '/paws/outbox', views.get_outbox),
|
||||
#aiohttp.web.route('GET', '/paws/imgay', views.get_gay)
|
||||
])
|
||||
|
||||
template.addEnv({
|
||||
'css_ts': css_ts,
|
||||
'domain': MASTOCONFIG['domain'],
|
||||
'VERSION': VERSION,
|
||||
'len': len,
|
||||
'urlparse': urlparse,
|
||||
'admin_check': admin_check
|
||||
})
|
||||
|
||||
return web
|
||||
|
||||
|
||||
async def start_webserver():
|
||||
app = webserver()
|
||||
runner = aiohttp.web.AppRunner(app, access_log_format='%{X-Real-Ip}i "%r" %s %b "%{User-Agent}i"')
|
||||
runner = aiohttp.web.AppRunner(app, access_log_format='%{X-Real-Ip}i %s %b "%r" "%{User-Agent}i"')
|
||||
await runner.setup()
|
||||
|
||||
listen = PAWSCONFIG['host']
|
||||
|
@ -40,6 +66,7 @@ async def start_webserver():
|
|||
|
||||
logging.info(f'Starting webserver at socket: {sock_listen}')
|
||||
site = aiohttp.web.UnixSite(runner, sock_listen)
|
||||
os.chmod(sock_listen, 0o664)
|
||||
|
||||
else:
|
||||
logging.error('Windows cannot use unix sockets. Use an IP address instead. Exiting...')
|
||||
|
@ -50,15 +77,15 @@ async def start_webserver():
|
|||
address(listen)
|
||||
|
||||
except ValueError:
|
||||
logging.warning('Invalid IP address. Listening on "0.0.0.0" instead.')
|
||||
logging.warning('Invalid IP address. Listening on "127.0.0.1" instead.')
|
||||
listen = '127.0.0.1'
|
||||
|
||||
try:
|
||||
int(port)
|
||||
|
||||
except ValueError:
|
||||
logging.warning('Invalid port. Using 3621 instead.')
|
||||
port = 3621
|
||||
logging.warning('Invalid port. Using 3001 instead.')
|
||||
port = 3001
|
||||
|
||||
logging.info(f'Starting webserver at address: {listen}:{port}')
|
||||
site = aiohttp.web.TCPSite(runner, listen, port)
|
||||
|
@ -66,11 +93,33 @@ async def start_webserver():
|
|||
await site.start()
|
||||
|
||||
|
||||
async def cleanup_tokens():
|
||||
while True:
|
||||
await asyncio.sleep(60*60)
|
||||
logging.debug('Cleaning up tokens')
|
||||
cleanup_users()
|
||||
|
||||
|
||||
async def cleanup_invalid_users():
|
||||
while True:
|
||||
logging.debug('Cleaning up invalid users')
|
||||
cleanup_users(True)
|
||||
await asyncio.sleep(60*15)
|
||||
|
||||
|
||||
def main():
|
||||
buildTemplates()
|
||||
observer = templateWatcher()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
asyncio.ensure_future(start_webserver())
|
||||
asyncio.ensure_future(cleanup_tokens())
|
||||
asyncio.ensure_future(cleanup_invalid_users())
|
||||
loop.run_forever()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logging.info('Bye!')
|
||||
|
||||
observer.stop()
|
||||
|
||||
|
|
|
@ -3,18 +3,19 @@ import aiohttp.web
|
|||
import binascii
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from IzzyLib import logging
|
||||
from IzzyLib.cache import LRUCache, TTLCache
|
||||
from aiohttp.http_exceptions import *
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Hash import SHA, SHA256, SHA512
|
||||
from Crypto.Signature import PKCS1_v1_5
|
||||
|
||||
from .config import MASTOCONFIG, VERSION
|
||||
from .database import keys
|
||||
|
||||
|
||||
class cache:
|
||||
from .cache import LRUCache, TTLCache
|
||||
messages = LRUCache()
|
||||
actors = TTLCache()
|
||||
keys = LRUCache()
|
||||
|
@ -52,7 +53,7 @@ def build_signing_string(headers, used_headers):
|
|||
|
||||
|
||||
def sign_signing_string(sigstring, key):
|
||||
if sigstring not in cache.sigstrings.items:
|
||||
if sigstring not in cache.sigstrings:
|
||||
pkcs = PKCS1_v1_5.new(key)
|
||||
h = SHA256.new()
|
||||
h.update(sigstring.encode('ascii'))
|
||||
|
@ -64,7 +65,9 @@ def sign_signing_string(sigstring, key):
|
|||
return cache.sigstrings.fetch(sigstring)
|
||||
|
||||
|
||||
def sign_headers(headers, key, key_id):
|
||||
def sign_headers(headers, keyname, key_id):
|
||||
keydata = keys(keyname)
|
||||
key = RSA.importKey(keydata['privkey'])
|
||||
headers = {x.lower(): y for x, y in headers.items()}
|
||||
used_headers = headers.keys()
|
||||
sig = {
|
||||
|
@ -85,7 +88,7 @@ async def fetch_actor(uri, force=False):
|
|||
try:
|
||||
headers = {
|
||||
'Accept': 'application/activity+json',
|
||||
'User-Agent': f'MAW/{VERSION}; https://{domain}'
|
||||
'User-Agent': f'PAWS/{VERSION}; https://{domain}'
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
|
@ -104,7 +107,7 @@ async def fetch_actor(uri, force=False):
|
|||
|
||||
|
||||
async def fetch_actor_key(actor):
|
||||
if actor not in cache.keys.items:
|
||||
if actor not in cache.keys:
|
||||
actor_data = await fetch_actor(actor)
|
||||
|
||||
if not actor_data:
|
||||
|
|
117
paws/templates/color.css
Normal file
117
paws/templates/color.css
Normal file
|
@ -0,0 +1,117 @@
|
|||
{% set primary = '#C6C' %}
|
||||
{% set secondary = '#68C' %}
|
||||
{% set error = '#C64' %}
|
||||
{% 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}};
|
||||
--error: {{desaturate(darken('red', 0.2), 0.25)}};
|
||||
--valid: {{desaturate(darken('green', 0.2), 0.25)}};
|
||||
--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 */
|
||||
input, a {
|
||||
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}};
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: {{saturate(primary, 0.8)}};
|
||||
}
|
||||
|
||||
#paw_logo circle {
|
||||
fill: var(--text)
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown menus */
|
||||
.menu {
|
||||
background-color: var(--bg-color-dark);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
||||
{% include 'layout.css' %}
|
162
paws/templates/components/badge.svg
Normal file
162
paws/templates/components/badge.svg
Normal file
|
@ -0,0 +1,162 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="300"
|
||||
height="400"
|
||||
viewBox="0 0 79.374999 105.83333"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:export-filename="/home/zoey/Desktop/logo.svg.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<title
|
||||
id="title817">Gently Protected by PAWS</title>
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4142136"
|
||||
inkscape:cx="-9.008941"
|
||||
inkscape:cy="193.715"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="svg8"
|
||||
showgrid="true"
|
||||
guidetolerance="10000"
|
||||
units="px"
|
||||
inkscape:window-width="1280"
|
||||
inkscape:window-height="972"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:snap-text-baseline="true">
|
||||
<inkscape:grid
|
||||
type="xygrid"
|
||||
id="grid815" />
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title>Gently Protected by PAWS</dc:title>
|
||||
<cc:license
|
||||
rdf:resource="http://creativecommons.org/licenses/by-nc-sa/4.0/" />
|
||||
</cc:Work>
|
||||
<cc:License
|
||||
rdf:about="http://creativecommons.org/licenses/by-nc-sa/4.0/">
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction" />
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution" />
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice" />
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution" />
|
||||
<cc:prohibits
|
||||
rdf:resource="http://creativecommons.org/ns#CommercialUse" />
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#ShareAlike" />
|
||||
</cc:License>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
id="layer1"
|
||||
transform="translate(0,-191.16667)"
|
||||
inkscape:groupmode="layer">
|
||||
<rect
|
||||
style="fill:#2b0b1a;fill-opacity:1;stroke-width:0.26458332;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;paint-order:markers fill stroke"
|
||||
id="rect823"
|
||||
width="79.375"
|
||||
height="105.83333"
|
||||
x="0"
|
||||
y="191.16667"
|
||||
ry="15.874998" />
|
||||
<path
|
||||
style="fill:#5f0029;fill-opacity:0.99719891;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 0,204.39584 c 3.1481224,75.36347 79.375466,35.7162 79.375,35.71875 l 0.02339,41.00745 C 79.407325,296.79004 63.5,297 63.5,297 H 15.875 C 0,297 0,281.125 0,281.125 v -76.72916"
|
||||
id="path825"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccscscc" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="logo"
|
||||
id="paw_logo"
|
||||
transform="translate(0,-244.08334)"
|
||||
inkscape:groupmode="layer"
|
||||
style="display:inline">
|
||||
<circle
|
||||
id="pawpad"
|
||||
cx="39.026043"
|
||||
cy="280.46356"
|
||||
r="13.229166"
|
||||
style="fill:#ffebeb;fill-opacity:1;stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="toebean1"
|
||||
cx="31.088533"
|
||||
cy="259.29691"
|
||||
r="5.2916665"
|
||||
style="fill:#ffebeb;fill-opacity:1;stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="toebean2"
|
||||
cx="46.963547"
|
||||
cy="259.29691"
|
||||
r="5.2916665"
|
||||
style="fill:#ffebeb;fill-opacity:1;stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="tobean3"
|
||||
cx="59.134399"
|
||||
cy="269.88037"
|
||||
r="5.2916665"
|
||||
style="display:inline;fill:#ffebeb;fill-opacity:1;stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="toebean4"
|
||||
cx="18.917707"
|
||||
cy="269.88037"
|
||||
r="5.2916665"
|
||||
style="display:inline;fill:#ffebeb;fill-opacity:1;stroke-width:0.26458332" />
|
||||
</g>
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="textContainer"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:40.47038651px;line-height:1.25;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;display:inline;fill:#f5f5f5;fill-opacity:1;stroke:none"
|
||||
transform="matrix(0.2966959,0,0,0.28422717,-4.0751446,-2.6908806)"
|
||||
inkscape:label="text"><flowRegion
|
||||
id="text"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:40.47038651px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f5f5f5;fill-opacity:1"><rect
|
||||
id="rect4533"
|
||||
width="235"
|
||||
height="150"
|
||||
x="30"
|
||||
y="225"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:40.47038651px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f5f5f5;fill-opacity:1" /></flowRegion><flowPara
|
||||
id="gently"
|
||||
style="text-align:center">Gently</flowPara><flowPara
|
||||
id="secured"
|
||||
style="text-align:center">Secured</flowPara><flowPara
|
||||
id="byPAWS"
|
||||
style="text-align:center">by PAWS</flowPara></flowRoot> <g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer4"
|
||||
inkscape:label="text" />
|
||||
</svg>
|
After Width: | Height: | Size: 6.4 KiB |
62
paws/templates/components/logo.svg
Normal file
62
paws/templates/components/logo.svg
Normal file
|
@ -0,0 +1,62 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 52.916666 52.916666"
|
||||
version="1.1"
|
||||
id="paw_logo_container">
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="logo"
|
||||
inkscape:groupmode="layer"
|
||||
id="paw_logo"
|
||||
transform="translate(0,-244.08334)">
|
||||
<circle
|
||||
id="pawpad"
|
||||
cx="26.458332"
|
||||
cy="277.15625"
|
||||
r="13.229166"
|
||||
style="stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="toebean1"
|
||||
cx="18.520826"
|
||||
cy="255.98961"
|
||||
r="5.2916665"
|
||||
style="stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="toebean2"
|
||||
cx="34.395836"
|
||||
cy="255.98961"
|
||||
r="5.2916665"
|
||||
style="stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="tobean3"
|
||||
cx="46.566689"
|
||||
cy="266.57306"
|
||||
r="5.2916665"
|
||||
style="stroke-width:0.26458332" />
|
||||
<circle
|
||||
id="toebean4"
|
||||
cx="6.3499994"
|
||||
cy="266.57306"
|
||||
r="5.2916665"
|
||||
style="stroke-width:0.26458332" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
224
paws/templates/layout.css
Normal file
224
paws/templates/layout.css
Normal file
|
@ -0,0 +1,224 @@
|
|||
/* 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;
|
||||
}
|
||||
|
||||
#paw_logo_container {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
#content {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 1px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
margin: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#header div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#footer {
|
||||
grid-template-columns: 100px auto 100px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#footer .col2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#footer .col3 {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.msg, .error {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* Login */
|
||||
#auth {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input[type=text] {
|
||||
width: 25%;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
input[type=text]:hover {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
input[type=text]:focus {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
|
||||
/* lists */
|
||||
.list .col1 {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.list .button {
|
||||
text-align: center;
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.list .button input {
|
||||
width: calc(100% - 10px);
|
||||
}
|
||||
|
||||
/* Errors */
|
||||
#error .msg {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#error .title {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
|
||||
/* mobile/small screen */
|
||||
@media (max-width : 1000px) {
|
||||
#content {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
grid-template-columns: auto;
|
||||
}
|
||||
}
|
BIN
paws/templates/media/badge.png
Normal file
BIN
paws/templates/media/badge.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
paws/templates/media/logo-small.png
Normal file
BIN
paws/templates/media/logo-small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 578 B |
BIN
paws/templates/media/logo.png
Normal file
BIN
paws/templates/media/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
71
paws/templates/pages/base.haml
Normal file
71
paws/templates/pages/base.haml
Normal file
|
@ -0,0 +1,71 @@
|
|||
-set default_open = 'open'
|
||||
{% set logo_svg %}
|
||||
{% include 'components/logo.svg' %}
|
||||
{% endset %}
|
||||
|
||||
!!! Strict
|
||||
%html
|
||||
%head
|
||||
%title<
|
||||
PAWS@{{domain}}: {{page}}
|
||||
|
||||
%link{'rel': 'stylesheet', 'href': '/paws/style-{{css_ts()}}.css', 'media': 'screen'}
|
||||
%meta{'name': 'viewport', 'content': 'width=device-width, initial-scale=1, shrink-to-fit=no'}
|
||||
|
||||
%body
|
||||
%div{'class': 'menu menu-right'}
|
||||
%details
|
||||
%summary{'class': "title"}
|
||||
%a
|
||||
-if request.user:
|
||||
{{request.user.handle}}
|
||||
-else
|
||||
Guest
|
||||
|
||||
.item
|
||||
%a{'href': '/about', 'target': '_self'} Masto Home
|
||||
.item
|
||||
%a{'href': '/paws', 'target': '_self'} Paws Home
|
||||
-if request.user
|
||||
-if request.admin
|
||||
.item
|
||||
%a{'href': '/paws/list/instances', 'target': '_self'} Instances
|
||||
.item
|
||||
%a{'href': '/paws/list/requests', 'target': '_self'} Requests
|
||||
.item
|
||||
%a{'href': '/paws/list/signlist', 'target': '_self'} Signlist
|
||||
.item
|
||||
%a{'href': '/paws/logout', 'target': '_self'} Logout
|
||||
-else
|
||||
.item
|
||||
%a{'href': '/paws/login', 'target': '_self'} Login
|
||||
|
||||
#content
|
||||
#header
|
||||
%h1{'id': 'name'}= logo_svg
|
||||
|
||||
#body
|
||||
-block content
|
||||
|
||||
#footer{'class': 'section grid-container'}
|
||||
%div{'class': 'grid-item col1'}
|
||||
%p
|
||||
%a{'href': '/about'}<
|
||||
{{domain}}
|
||||
|
||||
%div{'class': 'grid-item col2'}
|
||||
%p<
|
||||
-if request.user
|
||||
{{request.user.handle}}@{{request.user.instance}} [
|
||||
%a{'href': '/paws/logout'}> logout
|
||||
]
|
||||
|
||||
-else
|
||||
Guest [
|
||||
%a{'href': '/paws/login'}> login
|
||||
]
|
||||
|
||||
%div{'class': 'grid-item col3'}
|
||||
%p
|
||||
%a{'href': 'https://git.barkshark.xyz/izalia/paws', 'target': '_new'}
|
||||
PAWS/{{VERSION}}
|
9
paws/templates/pages/error.haml
Normal file
9
paws/templates/pages/error.haml
Normal file
|
@ -0,0 +1,9 @@
|
|||
-extends 'base.html'
|
||||
-set page = 'Error ' + code
|
||||
|
||||
-block content
|
||||
#error{'class': 'section'}
|
||||
%h2{'class': 'title'}
|
||||
Error {{code}}
|
||||
|
||||
.msg= msg
|
111
paws/templates/pages/lists.haml
Normal file
111
paws/templates/pages/lists.haml
Normal file
|
@ -0,0 +1,111 @@
|
|||
-extends 'base.html'
|
||||
-set page = 'Home'
|
||||
|
||||
-block content
|
||||
#panel{'class': 'section'}
|
||||
-if listtype == 'signlist'
|
||||
%center
|
||||
%h2{'class': 'title'} Signlist
|
||||
|
||||
%table{'id': 'signlist', 'class': 'list'}
|
||||
%tr{'class': 'header'}
|
||||
%td
|
||||
%td
|
||||
|
||||
-if len(signlist) > 0
|
||||
-for instance in signlist
|
||||
%tr{'class': 'instance'}
|
||||
%td{'class': 'col1'}
|
||||
%a{'href': 'https://{{instance}}/about', 'target': '_new'}= instance
|
||||
%td{'class': 'col2 button'}
|
||||
%form{'action': '/paws/action/remove', 'method': 'post'}
|
||||
%input{'name': 'name', 'value': '{{instance}}', 'hidden': None}
|
||||
%input{'type': 'submit', 'value': 'Remove'}
|
||||
|
||||
-else
|
||||
%tr{'class': 'instance'}
|
||||
%td none
|
||||
%td
|
||||
|
||||
%tr{'class': 'instance'}
|
||||
%form{'action': '/paws/action/add', 'method': 'post'}
|
||||
%td{'class': 'col1'}
|
||||
%input{'type': 'text', 'name': 'name', 'placeholder': 'mastodon.social'}
|
||||
%td{'class': 'col2'}
|
||||
%input{'type': 'submit', 'value': 'Add'}
|
||||
|
||||
-if listtype == 'requests'
|
||||
%center
|
||||
%h2{'class': 'title'} Requests
|
||||
|
||||
%table{'id': 'request', 'class': 'list'}
|
||||
-if len(requests) > 0
|
||||
-for instance in requests
|
||||
%tr{'class': 'instance'}
|
||||
%td{'class': 'col1'}
|
||||
%a{'href': 'https://{{instance.domain}}/about', 'target': '_new'}= instance.domain
|
||||
%td{'class': 'button'}= instance.software
|
||||
%form{'action': '/paws/action/add?list=={listtype}', 'method': 'post'}
|
||||
%input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None}
|
||||
%td{'class': 'button'}
|
||||
%input{'type': 'submit', 'name': 'action', 'value': 'Accept'}
|
||||
%td{'class': 'button'}
|
||||
%input{'type': 'submit', 'name': 'action', 'value': 'Deny'}
|
||||
|
||||
%form{'action': '/paws/action/remove?list=={listtype}', 'method': 'post'}
|
||||
%input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None}
|
||||
%td{'class': 'col2 button'}
|
||||
%input{'type': 'submit', 'value': 'Remove'}
|
||||
|
||||
-else
|
||||
%tr{'class': 'instance'}
|
||||
%td none
|
||||
%td
|
||||
|
||||
-if listtype == 'instances'
|
||||
%center
|
||||
%h2{'class': 'title'} Instances
|
||||
%a{'href': '?'} All
|
||||
|
|
||||
%a{'href': '?type=accept'} Accepted
|
||||
|
|
||||
%a{'href': '?type=deny'} Denied
|
||||
%br
|
||||
%br
|
||||
|
||||
%table{'id': 'accept', 'class': 'list'}
|
||||
-if len(instances) > 0
|
||||
-for instance in instances
|
||||
%tr{'class': 'instance'}
|
||||
%td{'class': 'col1'}
|
||||
%a{'href': 'https://{{instance.domain}}/about', 'target': '_new'}= instance.domain
|
||||
%td{'class': 'button'}= instance.software
|
||||
%form{'action': '/paws/action/add?list=={listtype}', 'method': 'post'}
|
||||
%input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None}
|
||||
%td{'class': 'button'}
|
||||
-if instance.state == 'accept'
|
||||
%input{'type': 'submit', 'name': 'action', 'value': 'Deny'}
|
||||
|
||||
-elif instance.state == 'deny'
|
||||
%input{'type': 'submit', 'name': 'action', 'value': 'Accept'}
|
||||
|
||||
%form{'action': '/paws/action/remove?list=={listtype}', 'method': 'post'}
|
||||
%input{'name': 'name', 'value': '{{instance.domain}}', 'hidden': None}
|
||||
%td{'class': 'col2 button'}
|
||||
%input{'type': 'submit', 'value': 'Remove'}
|
||||
|
||||
-else
|
||||
%tr{'class': 'instance'}
|
||||
%td none
|
||||
%td
|
||||
|
||||
%tr{'class': 'instance'}
|
||||
%form{'action': '/paws/action/add', 'method': 'post'}
|
||||
%td{'class': 'col1'}
|
||||
%input{'type': 'text', 'name': 'name', 'placeholder': 'mastodon.social'}
|
||||
%td{'class': 'button'}
|
||||
%td{'class': 'button'}
|
||||
%input{'type': 'submit', 'name': 'action', 'value': 'Accept'}
|
||||
%td{'class': 'col2 button'}
|
||||
%input{'type': 'submit', 'name': 'action', 'value': 'Deny'}
|
||||
|
20
paws/templates/pages/login.haml
Normal file
20
paws/templates/pages/login.haml
Normal file
|
@ -0,0 +1,20 @@
|
|||
-extends 'base.html'
|
||||
-set page = 'Login'
|
||||
|
||||
-block content
|
||||
#auth{'class': 'section'}
|
||||
%h2{'class': 'title'} Login
|
||||
|
||||
-if msg
|
||||
.error= msg
|
||||
|
||||
%form{'action': '/paws/login', 'method': 'post'}
|
||||
.msg This instance requires you to login via oauth to view public pages
|
||||
%br
|
||||
Please input the instance of the account you'd like to login with
|
||||
|
||||
%input{'type': 'hidden', 'name': 'redir', 'value': '{{redir}}'}
|
||||
%input{'type': 'hidden', 'name': 'numid', 'value': '{{numid}}'}
|
||||
%input{'type': 'text', 'name': 'domain', 'placeholder': 'mastodon.social'}
|
||||
%br
|
||||
%input{'type': 'submit', 'value': 'Login'}
|
1
paws/templates/pages/missing.haml
Normal file
1
paws/templates/pages/missing.haml
Normal file
|
@ -0,0 +1 @@
|
|||
Missing template file
|
8
paws/templates/pages/panel.haml
Normal file
8
paws/templates/pages/panel.haml
Normal file
|
@ -0,0 +1,8 @@
|
|||
-extends 'base.html'
|
||||
-set page = 'Home'
|
||||
|
||||
-block content
|
||||
#panel{'class': 'section'}
|
||||
%h2{'class': 'title'} PAWS
|
||||
%p
|
||||
PAWS is an extra auth layer for public profiles and posts. The main purpose is to prevent web scrapers from hoarding posts, but it also prevents banned users from viewing toots they shouldn't see
|
|
@ -1,24 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Nope.mov</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.center {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin-left: -150px;
|
||||
margin-top: -150px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="https://static.barkshark.xyz/main/img/YouDidntSayTheMagicWord.gif" class="center" />
|
||||
</body>
|
||||
</html>
|
338
paws/views.py
338
paws/views.py
|
@ -1,5 +1,339 @@
|
|||
import aiohttp
|
||||
import random
|
||||
import traceback
|
||||
|
||||
from operator import itemgetter
|
||||
|
||||
from IzzyLib import logging
|
||||
from IzzyLib.cache import TTLCache
|
||||
from IzzyLib.template import aiohttpTemplate
|
||||
from urllib.parse import quote_plus, unquote_plus, urlparse
|
||||
from datetime import datetime
|
||||
|
||||
from . import oauth
|
||||
from .database import pawsdb, trans, query, where, keys, ban_check, get_user, get_toot, admin_check, whitelist, instances
|
||||
from .functions import error, fed_domain, domain_check, css_ts
|
||||
from .config import MASTOCONFIG, PAWSCONFIG
|
||||
|
||||
|
||||
paws_host = PAWSCONFIG['domain']
|
||||
masto_host = MASTOCONFIG['domain']
|
||||
|
||||
|
||||
class login(aiohttp.web.View):
|
||||
async def get(self):
|
||||
parms = self.request.rel_url.query
|
||||
redir = parms.get('redir')
|
||||
numid = random.randint(1*1000000, 10*1000000-1)
|
||||
|
||||
if self.request['user']:
|
||||
return aiohttp.web.HTTPFound('/paws')
|
||||
|
||||
data = {'redir': redir, 'numid': numid}
|
||||
return aiohttpTemplate('login.html', data, self.request)
|
||||
|
||||
|
||||
async def post(self):
|
||||
data = await self.request.post()
|
||||
domain = data.get('domain')
|
||||
redir = data.get('redir')
|
||||
numid = data.get('numid')
|
||||
|
||||
if 'DROP ' in domain:
|
||||
return aiohttp.web.HTTPFound('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
|
||||
domain = domain_check(domain)
|
||||
|
||||
if not domain:
|
||||
return error(self.request, 400, 'Invalid domain')
|
||||
|
||||
if ban_check(domain):
|
||||
return error(self.request, 403, 'Instance banned')
|
||||
|
||||
appid, appsecret, redir_url = oauth.create_app(domain)
|
||||
|
||||
if appid == 'error':
|
||||
return error(self.request, 500, appsecret)
|
||||
|
||||
|
||||
with trans(pawsdb.users) as tr:
|
||||
tr.insert({
|
||||
'handle': data['numid'],
|
||||
'domain': data['domain'].lower(),
|
||||
'appid': appid,
|
||||
'appsecret': appsecret,
|
||||
'token': None,
|
||||
'timestamp': datetime.timestamp(datetime.now())
|
||||
})
|
||||
|
||||
response = aiohttp.web.HTTPFound(redir_url)
|
||||
response.set_cookie('paws_numid', numid, max_age=60*60, path='/paws')
|
||||
|
||||
if redir not in ['', None]:
|
||||
response.set_cookie('paws_redir', redir, max_age=60*60, path='/paws')
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class lists(aiohttp.web.View):
|
||||
async def get(self):
|
||||
request = self.request
|
||||
listtype = request.match_info['list']
|
||||
state = request.query.get('type', None)
|
||||
|
||||
if request['admin']:
|
||||
if state not in ['accept', 'deny']:
|
||||
instances = pawsdb.instances.search(query.state != 'request')
|
||||
|
||||
else:
|
||||
instances = pawsdb.instances.search(query.state == state)
|
||||
|
||||
requests = pawsdb.instances.search(query.state == 'request')
|
||||
signlist = [line['domain'] for line in pawsdb.whitelist.all()]
|
||||
signlist.sort()
|
||||
data = {
|
||||
'listtype': listtype,
|
||||
'signlist': signlist,
|
||||
'requests': sorted(requests, key=lambda k: k['domain']),
|
||||
'instances': sorted(instances, key=lambda k: k['domain'])
|
||||
}
|
||||
|
||||
else:
|
||||
data = {}
|
||||
|
||||
if listtype not in data.keys():
|
||||
return error(request, 404, 'Invalid list type')
|
||||
|
||||
return aiohttpTemplate('lists.html', data, request)
|
||||
|
||||
async def post(self):
|
||||
pass
|
||||
|
||||
|
||||
async def get_home(request):
|
||||
return aiohttpTemplate('home.html', {}, request)
|
||||
|
||||
|
||||
async def get_paws(request):
|
||||
data = {}
|
||||
return aiohttpTemplate('panel.html', data, request)
|
||||
|
||||
|
||||
async def post_paws(request):
|
||||
data = await request.post()
|
||||
user_data = request['user']
|
||||
domain = data.get('name')
|
||||
action = request.match_info['action'].lower()
|
||||
action = 'add' if action == 'update' else action
|
||||
admin = admin_check(user_data['handle']) if user_data else None
|
||||
page = request.query.get('list', 'instances')
|
||||
|
||||
if not admin:
|
||||
return error(request, 403, 'Not an admin')
|
||||
|
||||
if None in [action, domain]:
|
||||
return error(request, 400, 'Missing action or doamin')
|
||||
|
||||
domain = urlparse(domain.replace(' ', ''))
|
||||
parsed_domain = domain.netloc if domain.netloc != '' else domain.path
|
||||
|
||||
if action not in ['add', 'remove']:
|
||||
error(request, 400, 'Invalid action')
|
||||
|
||||
result = instances(action, parsed_domain, data.get('action', 'request'))
|
||||
print(result)
|
||||
|
||||
return aiohttp.web.HTTPFound(f'/paws/list/{page}')
|
||||
|
||||
|
||||
async def get_auth(request):
|
||||
parms = request.rel_url.query
|
||||
cookies = request.cookies
|
||||
redir = cookies.get('paws_redir')
|
||||
numid = cookies.get('paws_numid')
|
||||
code = parms.get('code')
|
||||
|
||||
if None in [numid, code]:
|
||||
return error(request, 500, 'Missing temporary userid or auth code')
|
||||
|
||||
if redir in ['', 'None', None]:
|
||||
redir = '/paws'
|
||||
|
||||
user = pawsdb.users.get(query.handle == str(numid))
|
||||
token, userinfo = oauth.login(user, code)
|
||||
|
||||
if token == 'error':
|
||||
return error(request, 500, userinfo)
|
||||
|
||||
instance = fed_domain(userinfo['username'], user['domain'])
|
||||
|
||||
with trans(pawsdb.users) as tr:
|
||||
tr.update(
|
||||
{
|
||||
'handle': userinfo['username'],
|
||||
'instance': instance,
|
||||
'appid': None,
|
||||
'appsecret': None,
|
||||
'token': token
|
||||
},
|
||||
where('handle') == numid
|
||||
)
|
||||
|
||||
response = aiohttp.web.HTTPFound(redir)
|
||||
response.set_cookie('paws_token', token, max_age=60*60*24*14)
|
||||
response.del_cookie('paws_redir', path='/paws')
|
||||
response.del_cookie('paws_numid', path='/paws')
|
||||
return response
|
||||
|
||||
|
||||
async def get_logout(request):
|
||||
if not request['user']:
|
||||
return aiohttp.web.HTTPFound('/paws/login')
|
||||
|
||||
token_id = request['user'].doc_id
|
||||
|
||||
with trans(pawsdb.users) as tr:
|
||||
tr.remove(doc_ids=[token_id])
|
||||
|
||||
response = aiohttp.web.HTTPFound('/paws/login')
|
||||
response.del_cookie('token')
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def get_style(request):
|
||||
maxage = 60*60*24*7
|
||||
|
||||
response = aiohttpTemplate('color.css', {}, request, content_type='text/css')
|
||||
response.headers['Cache-Control'] = 'public,max-age={maxage},immutable'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def get_webfinger(request):
|
||||
res = request.rel_url.query.get('resource')
|
||||
|
||||
if not res or res != f'acct:paws@{paws_host}':
|
||||
data = {}
|
||||
|
||||
else:
|
||||
data = {
|
||||
"aliases": [
|
||||
f"https://{paws_host}/paws/actor"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"href": f"https://{paws_host}/paws/actor",
|
||||
"rel": "self",
|
||||
"type": "application/activity+json"
|
||||
},
|
||||
{
|
||||
"href": f"https://{paws_host}/paws/actor",
|
||||
"rel": "self",
|
||||
"type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
|
||||
}
|
||||
],
|
||||
"subject": f"acct:paws@{paws_host}"
|
||||
}
|
||||
|
||||
async def authorize(request):
|
||||
data = {['heck']}
|
||||
return aiohttp.web.json_response(data)
|
||||
|
||||
|
||||
async def get_actor(request):
|
||||
rsakey = keys('default')
|
||||
|
||||
if not rsakey:
|
||||
return error(request, 500, 'Missing actor keys')
|
||||
|
||||
PUBKEY = rsakey['pubkey']
|
||||
|
||||
data = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams'
|
||||
],
|
||||
#'endpoints': {
|
||||
#'sharedInbox': f'https://{masto_host}/inbox'
|
||||
#},
|
||||
#'following': f'https://{host}/paws/following',
|
||||
#'followers': f'https://{host}/paws/followers',
|
||||
'inbox': f'https://{paws_host}/paws/inbox',
|
||||
#'outbox': f'https://{host}/paws/outbox',
|
||||
'name': f'PAWS @ {masto_host}',
|
||||
'type': 'Application',
|
||||
'id': f'https://{paws_host}/paws/actor',
|
||||
'publicKey': {
|
||||
'id': f'https://{paws_host}/paws/actor#main-key',
|
||||
'owner': f'https://{paws_host}/paws/actor',
|
||||
'publicKeyPem': PUBKEY
|
||||
},
|
||||
'summary': 'PAWS Actor',
|
||||
'preferredUsername': 'paws',
|
||||
'url': f'https://{paws_host}/paws/actor'
|
||||
}
|
||||
|
||||
return aiohttp.web.json_response(data)
|
||||
|
||||
|
||||
async def post_inbox(request):
|
||||
return aiohttp.web.json_response('UvU', status=202)
|
||||
|
||||
|
||||
# failed attempt at creating a status
|
||||
async def get_outbox(request):
|
||||
if not request.rel_url.query.get('page'):
|
||||
data = {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": f"https://{paws_host}/paws/outbox",
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": 1,
|
||||
"first": f"https://{paws_host}/paws/outbox?page=true",
|
||||
"last": f"https://{paws_host}/paws/outbox?page=true"
|
||||
}
|
||||
|
||||
else:
|
||||
data = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
{
|
||||
"ostatus": "http://ostatus.org#",
|
||||
"atomUri": "ostatus:atomUri",
|
||||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
|
||||
"conversation": "ostatus:conversation",
|
||||
"sensitive": "as:sensitive",
|
||||
"toot": "http://joinmastodon.org/ns#",
|
||||
"votersCount": "toot:votersCount",
|
||||
"blurhash": "toot:blurhash",
|
||||
"focalPoint": {
|
||||
"@container": "@list",
|
||||
"@id": "toot:focalPoint"
|
||||
},
|
||||
"Emoji": "toot:Emoji"
|
||||
}
|
||||
],
|
||||
"id": f"https://{paws_host}/paws/outbox?page=true",
|
||||
"type": "OrderedCollectionPage",
|
||||
"partOf": f"https://{paws_host}/paws/outbox",
|
||||
"orderedItems": [imgay]
|
||||
}
|
||||
|
||||
return aiohttp.web.json_response(data)
|
||||
|
||||
async def get_gay(request):
|
||||
data = imgay
|
||||
|
||||
return aiohttp.web.json_response(data)
|
||||
|
||||
|
||||
imgay = {
|
||||
"id": f"https://{paws_host}/paws/imgay",
|
||||
"type": "Note",
|
||||
"published": '2020-01-20T09:45:00Z',
|
||||
"attributedTo": f"https://{paws_host}/paws/actor",
|
||||
"content": '<p>im gay</p>',
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"cc": [
|
||||
f"https://{paws_host}/paws/actor/followers"
|
||||
]
|
||||
}
|
||||
|
|
6
production.example.env
Normal file
6
production.example.env
Normal file
|
@ -0,0 +1,6 @@
|
|||
PAWS_HOST=127.0.0.1
|
||||
PAWS_PORT=3001
|
||||
|
||||
PAWS_DOMAIN=bappypaws.example.com
|
||||
|
||||
MASTOPATH=/home/mastodon/glitch-soc
|
|
@ -1,5 +1,5 @@
|
|||
exec = python3 -m paws
|
||||
watch_ext = py, env
|
||||
ignore_dirs = build, data
|
||||
ignore_files = reload.py, test.py
|
||||
ignore_dirs = build
|
||||
ignore_files = reload.py, test.py, heck.py
|
||||
log_level = INFO
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# todo: module version numbers
|
||||
git+https://git.barkshark.xyz/izaliamae/izzylib.git
|
||||
dbutils
|
||||
pygresql
|
||||
tinydb
|
||||
|
@ -9,4 +9,5 @@ pycryptodome
|
|||
tldextract
|
||||
envbash
|
||||
ipaddress
|
||||
|
||||
mastodon.py
|
||||
validators>=0.14.1
|
||||
|
|
11
server.py
11
server.py
|
@ -1,5 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys, os
|
||||
|
||||
from paws.routes import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
if 'edit' in sys.argv:
|
||||
from paws.config import stor_path
|
||||
|
||||
print(f'Opening {stor_path}/production.env in a text editor')
|
||||
editor = os.popen(f'$EDITOR {stor_path}/production.env', 'r')
|
||||
editor.read()
|
||||
editor.close()
|
||||
sys.exit()
|
||||
|
||||
main()
|
||||
|
|
Loading…
Reference in a new issue