Compare commits

...

33 commits

Author SHA1 Message Date
Izalia Mae 21368f9886 add filters to instances page 2020-05-02 02:57:23 -04:00
Izalia Mae 4f0b99b71a fix links in base template 2020-04-28 17:14:25 -04:00
Izalia Mae 383cbf1423 greylist mostly done 2020-04-28 14:42:52 -04:00
Izalia Mae 2c621d266e use git master for IzzyLib in requirements 2020-04-28 01:00:25 -04:00
Izalia Mae ada71fb7d7 basic greylist functionality 2020-04-28 00:57:46 -04:00
Izalia Mae 67efd787c7 start on instance approval mode 2020-03-16 21:29:56 -04:00
Izalia Mae 9d1445c080 convert templates 2020-03-11 20:50:31 -04:00
Izalia Mae fb0d51c872 various small changes 2020-03-11 19:21:03 -04:00
Izalia Mae 6cf1c4a446 tweak logging and minor fixes 2020-03-08 13:56:19 -04:00
Izalia Mae 34833acae5 a lot 2020-03-07 15:42:15 -05:00
Izalia Mae bd7ef6c31c started token cleanup 2020-03-06 14:30:20 -05:00
Izalia Mae a1721321ec started token cleanup 2020-03-06 14:18:19 -05:00
Izalia Mae 1e884e366e fix db compatability with ujson 2020-03-05 16:37:36 -05:00
Izalia Mae b0acb14b92 disable rss feeds 2020-03-05 16:10:51 -05:00
Izalia Mae 705f35f7a6 fix logins 2020-02-06 12:33:37 -05:00
Izalia Mae bd6833f2f1 forgot to hit save 2020-01-21 04:07:10 -05:00
Izalia Mae bcb75b0542 make logo smaller 2020-01-21 04:06:43 -05:00
Izalia Mae 2a81a3c68f use small logo for readme 2020-01-21 04:05:43 -05:00
Izalia Mae 124c004856 update readme and add badge 2020-01-21 04:05:12 -05:00
Izalia Mae a1110841f3 remove extra logging 2020-01-21 00:59:38 -05:00
Izalia Mae 85d0ae7268 redirect requests to right host 2020-01-21 00:58:24 -05:00
Izalia Mae 22fe37d38a honor user domain blocks and many changes 2020-01-20 23:54:30 -05:00
Izalia Mae 545a68eeb1 add whitelist management 2020-01-20 03:44:17 -05:00
Izalia Mae c3efd9adc1 fix webfinger, move functions, and handle other methods 2020-01-19 23:01:08 -05:00
Izalia Mae e04dd99dc1 update readme and create default config 2020-01-18 11:55:07 -05:00
Izalia Mae 4aa385f8af version bump 2020-01-18 11:18:51 -05:00
Izalia Mae 1da142a994 rework filter and add whitelist 2020-01-18 11:13:54 -05:00
Izalia Mae a28aca218a fix domain ban checking and remove app data 2020-01-17 08:47:37 -05:00
Izalia Mae 18f3803856 actually check if user is banned 2020-01-17 08:25:16 -05:00
Izalia Mae 35673d4cc5 block suspended users 2020-01-17 08:22:59 -05:00
Izalia Mae 3c3f49c110 check personal user bans 2020-01-17 07:32:40 -05:00
Izalia Mae 69cb899a93 oops 2020-01-15 08:57:07 -05:00
Izalia Mae 46489746c5 basic oauth support 2020-01-15 08:56:27 -05:00
31 changed files with 2210 additions and 439 deletions

122
README.md
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),
'domains': db.table('domains')
}
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,6 +54,44 @@ def pgdb():
sys.exit()
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 = {} if details else []
@ -60,8 +106,8 @@ def get_bans(suspend=True, details=False):
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']
@ -73,6 +119,21 @@ def get_bans(suspend=True, details=False):
return banlist
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()
if not users:
return
allbans = [(user['username'].lower(), user['domain'].lower()) for user in users]
if access_user in allbans:
return True
def ban_check(url):
instance = urlparse(url).netloc if url.startswith('http') else url
domain = extract(url)
@ -80,13 +141,198 @@ def ban_check(url):
banlist = get_bans()
for ban in banlist:
if ban in [instance, parsed]:
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()

View file

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

View file

@ -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',
@ -31,7 +31,9 @@ blocked_agents = [
'baraag',
'gameliberty',
'neckbeard',
'soapbox'
'soapbox',
'qoto',
'archive'
]
auth_paths = [
@ -39,179 +41,226 @@ auth_paths = [
'/users'
]
admin_paths = [
'/paws/action',
'/paws/list'
]
def parse_sig(signature, short=False):
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
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 'unknown'
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 or 'microblog.pub' in agent:
ua2 = ua1[1]
else:
logging.warning(f'Unhandled user-agent: {agent}')
return 'unknown'
if len(ua2) > 1:
logging.debug(f'domain: {ua2}')
return ua2[0]
logging.warning(f'Invalid user-agent: {ua2}')
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
if any(map(request.path.startswith, auth_paths)) and request.method != 'POST':
if 'json' in request.headers.get('Accept', '') or request.path.endswith('.json'):
request['reqtype'] = 'json'
if not user_check(request.path) and not MASTOCONFIG['auth_fetch']:
signature = request.headers.get('signature', '')
actor = parse_sig(signature)
if not signature:
logging.warning('missing signature')
raise json_error(401, 'Missing 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:
request['reqtype'] = 'html'
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):
if request.get('reqtype') == 'json':
signature = request.headers.get('signature').lower()
ua = request.headers.get('user-agent').lower()
signature = request.headers.get('signature')
sig_domain = parse_sig(signature, short=True)
ua = request.headers.get('user-agent').lower()
ua_domain = parse_ua(ua)
domain = ua_domain if not sig_domain else sig_domain
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)
real_ip = request.headers.get('X-Real-Ip', request.remote)
ua_ip = dig(ua_domain)
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
# 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
# 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):
sig_domain = parse_sig(signature, short=True)
ua_domain = parse_ua(ua)
domain = ua_domain if not sig_domain else sig_domain
if PAWSCONFIG['require_approval'] and not allow:
if allow != False:
status, message = (401, 'Instance awaiting approval or rejection')
instances('add', instance)
if not domain:
raise json_error(401, 'Can\'t find instance domain')
else:
status, message = (403, 'Rejected')
if [agent for agent in blocked_agents if agent in ua]:
logging.info(f'Blocked garbage: {domain}')
raise HTTPTeapot(body='418 This teapot kills fascists', content_type='text/plain')
return error(request, status, message)
if db.ban_check(domain):
logging.info(f'Blocked instance: {domain}')
raise json_error(403, 'Forbidden')
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
@ -227,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
View 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)

View file

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

View file

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

View 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

View 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
View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

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

View file

@ -0,0 +1,9 @@
-extends 'base.html'
-set page = 'Error ' + code
-block content
#error{'class': 'section'}
%h2{'class': 'title'}
Error {{code}}
.msg= msg

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

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

View file

@ -0,0 +1 @@
Missing template file

View 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

View file

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

View file

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

@ -0,0 +1,6 @@
PAWS_HOST=127.0.0.1
PAWS_PORT=3001
PAWS_DOMAIN=bappypaws.example.com
MASTOPATH=/home/mastodon/glitch-soc

View file

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

View file

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

View file

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