Skip to content

Commit

Permalink
Lets Federate (#5)
Browse files Browse the repository at this point in the history
* auth header

* deliver refactoring

* add logging

* add instance endpoint

* fix headers sig

* make prefix optional

* minor fixes
  • Loading branch information
autogestion committed Aug 20, 2018
1 parent ed8c2ae commit 32570c5
Show file tree
Hide file tree
Showing 17 changed files with 237 additions and 85 deletions.
6 changes: 3 additions & 3 deletions README.md
Expand Up @@ -3,21 +3,21 @@
Based on [little-boxes](https://github.com/tsileo/little-boxes).
Implements both the client-to-server API and the federated server-to-server API.

Compatible with [Mastodon](https://github.com/tootsuite/mastodon), but will drop OStatus messages.
Compatible (tested) with Mastodon, Pleroma and microblog.pub

(will) Support extensions

## Endpoints

####Federated
#### Federated

- /.well-known/ (webfinger)
- /user/ (profile, following)
- /inbox/ (create, list)
- /outbox/ (create, list, item, activity, remote post)


####Additional
#### Additional
- /auth (create user, get token)
- /swagger (api docs)

Expand Down
1 change: 1 addition & 0 deletions config/sample_conf.cfg
@@ -1,6 +1,7 @@
DOMAIN = "example.com:8000"
METHOD = "http"
OPEN_REGISTRATION = True
API_V1_PREFIX = '/api/v1'

DEBUG = True

Expand Down
18 changes: 16 additions & 2 deletions pubgate.postman_collection.json
Expand Up @@ -109,6 +109,20 @@
},
"response": []
},
{
"name": "/api/v1/instance",
"request": {
"url": "{{host}}/api/v1/instance",
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"username\": \"dev1\", \"password\":\"dev1\", \"email\": \"bu\"}"
},
"description": ""
},
"response": []
},
{
"name": "/api/v1/outbox/user_id Create Note",
"request": {
Expand All @@ -123,7 +137,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"Create\",\n \"actor\": \"{{method}}://{{host}}/api/v1/user/{{user}}\",\n \"to\": [\n \"https://www.w3.org/ns/activitystreams#Public\"\n ],\n \"object\": {\n \"type\": \"Note\",\n \"summary\": null,\n \"inReplyTo\": \"\",\n \"attributedTo\": \"{{method}}://{{host}}/api/v1/user/{{user}}\",\n \"to\": [\n \"https://www.w3.org/ns/activitystreams#Public\"\n ],\n \"sensitive\": false,\n \"content\": \"Звитяга! Слава Ісу! Карамба\",\n \"contentMap\": {\n \"uk\": \"Звитягаll!\"\n },\n \"attachment\": [],\n \"tag\": []\n }\n} "
"raw": "{\n \"type\": \"Create\",\n \"actor\": \"{{host}}/api/v1/user/{{user}}\",\n \"object\": {\n \"type\": \"Note\",\n \"summary\": null,\n \"inReplyTo\": \"\",\n \"attributedTo\": \"{{host}}/api/v1/user/{{user}}\",\n \"to\": [\n \"https://www.w3.org/ns/activitystreams#Public\"\n ],\n \"sensitive\": false,\n \"content\": \"Ще Звитяга! Слава Ісу! Ще Карамба хуямба\",\n \"contentMap\": {\n \"uk\": \"Звитягаll!\"\n },\n \"attachment\": [],\n \"tag\": []\n }\n} "
},
"description": ""
},
Expand All @@ -143,7 +157,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"Follow\",\n \"actor\": \"{{method}}://{{host}}/api/v1/user/{{user}}\",\n \"object\": \"https://pixelfed.social/SneekyPeet\",\n}"
"raw": "{\n \"type\": \"Follow\",\n \"actor\": \"{{host}}/api/v1/user/{{user}}\",\n \"object\": \"https://acts-sh.pp.ua\",\n}"
},
"description": ""
},
Expand Down
4 changes: 3 additions & 1 deletion pubgate/__init__.py
Expand Up @@ -2,4 +2,6 @@

__version__ = "0.1.0"

KEY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")
KEY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")
LOGO = "http://d1nhio0ox7pgb.cloudfront.net/_img/g_collection_png/standard/512x512/torii.png"

2 changes: 1 addition & 1 deletion pubgate/api/v1/__init__.py
@@ -1,5 +1,5 @@
from pubgate.api.v1.views.user import user_v1
from pubgate.api.v1.views.inbox import inbox_v1
from pubgate.api.v1.views.outbox import outbox_v1
from pubgate.api.v1.views.well_known import well_known
from pubgate.api.v1.views.well_known import well_known, instance
from pubgate.api.v1.views.auth import auth_v1
107 changes: 107 additions & 0 deletions pubgate/api/v1/deliver.py
@@ -0,0 +1,107 @@
import aiohttp
import base64
import hashlib
from urllib.parse import urlparse
import json

from Crypto.Hash import SHA256
from Crypto.Signature import PKCS1_v1_5

from sanic.log import logger
from sanic import exceptions
from little_boxes.linked_data_sig import generate_signature

from pubgate import __version__
from pubgate.api.v1.utils import make_label
from pubgate.api.v1.renders import context
from pubgate.api.v1.key import get_key


async def deliver_task(recipient, http_sig, activity):
async with aiohttp.ClientSession() as session:
async with session.get(recipient,
headers={'Accept': 'application/activity+json',
"User-Agent": f"PubGate v:{__version__}",}
) as resp:
logger.info(f"Delivering {make_label(activity)} ===>> {recipient},"
f" status: {resp.status}, {resp.reason}")
profile = await resp.json()

body = json.dumps(activity)
url = profile["inbox"]
headers = http_sig.sign(url, body)

async with aiohttp.ClientSession() as session:
async with session.post(url,
data=body,
headers=headers) as resp:
logger.info(f"Post to inbox {resp.real_url}, status: {resp.status}, {resp.reason}")
print(resp.request_info.headers)
print("\n")


async def deliver(activity, recipients):
# TODO deliver
# TODO retry over day if fails
key = get_key(activity["actor"])
activity['@context'] = context
generate_signature(activity, key)

headers = {"content-type": 'application/activity+json',
"user-agent": f"PubGate v:{__version__}"}

http_sig = HTTPSigAuth(key, headers)
# print(activity)

for recipient in recipients:
# try:
await deliver_task(recipient, http_sig, activity)
# except Exception as e:
# logger.error(e)


class HTTPSigAuth:
"""Requests auth plugin for signing requests on the fly."""

def __init__(self, key, headers) -> None:
self.key = key
self.headers = headers

def sign(self, url, r_body):
logger.info(f"keyid={self.key.key_id()}")
host = urlparse(url).netloc
headers = self.headers.copy()

bh = hashlib.new("sha256")
body = r_body
try:
body = r_body.encode("utf-8")
except AttributeError:
pass
bh.update(body)
bodydigest = "SHA-256=" + base64.b64encode(bh.digest()).decode("utf-8")

headers.update({"digest": bodydigest, "host": host})

sigheaders = "host digest"
out = []
for signed_header in sigheaders.split(" "):
if signed_header == "digest":
out.append("digest: " + bodydigest)
else:
out.append(signed_header + ": " + headers[signed_header])
to_be_signed = "\n".join(out)

signer = PKCS1_v1_5.new(self.key.privkey)
digest = SHA256.new()
digest.update(to_be_signed.encode("utf-8"))
sig = base64.b64encode(signer.sign(digest))
sig = sig.decode("utf-8")

key_id = self.key.key_id()
headers.update({
"Signature": f'keyId="{key_id}",algorithm="rsa-sha256",headers="{sigheaders}",signature="{sig}"'
})
logger.debug(f"signed request headers={headers}")

return headers
8 changes: 4 additions & 4 deletions pubgate/api/v1/key.py
@@ -1,6 +1,7 @@
import binascii
import os
from typing import Callable
import re

from little_boxes.key import Key

Expand All @@ -24,12 +25,11 @@ def get_secret_key(name: str, new_key: Callable[[], str] = _new_key) -> str:
return f.read()


def get_key(owner: str, user: str, domain: str) -> Key:
def get_key(owner: str) -> Key:
""""Loads or generates an RSA key."""
k = Key(owner)
user = user.replace(".", "_")
domain = domain.replace(".", "_")
key_path = os.path.join(KEY_DIR, f"key_{user}_{domain}.pem")
user = re.sub('[^\w\d]', "_", owner)
key_path = os.path.join(KEY_DIR, f"key_{user}.pem")
if os.path.isfile(key_path):
with open(key_path) as f:
privkey_pem = f.read()
Expand Down
23 changes: 10 additions & 13 deletions pubgate/api/v1/renders.py
@@ -1,6 +1,6 @@

from little_boxes.key import Key

from pubgate.api.v1.key import get_key
from pubgate import LOGO

context = [
"https://www.w3.org/ns/activitystreams",
Expand All @@ -12,34 +12,31 @@
]


def user_profile(base_url, user_id):
id = f"{base_url}/api/v1/user/{user_id}"
key = Key(id)
key.new()
def user_profile(v1_path, user_id):
id = f"{v1_path}/user/{user_id}"

return {
"@context": context,
"id": id,
"type": "Person",
"following": f"{id}/following",
"followers": f"{id}/followers",
"inbox": f"{base_url}/api/v1/inbox/{user_id}",
"outbox": f"{base_url}/api/v1/outbox/{user_id}",
"inbox": f"{v1_path}/inbox/{user_id}",
"outbox": f"{v1_path}/outbox/{user_id}",
"preferredUsername": f"{user_id}",
"name": "",
"summary": "<p></p>",
"url": f"{base_url}/@{user_id}",
# "url": f"{base_url}/@{user_id}",
"manuallyApprovesFollowers": False,
"publicKey": key.to_dict(),
"tag": [],
"attachment": [],
"publicKey": get_key(id).to_dict(),
"endpoints": {
# "sharedInbox": f"{base_url}/inbox"
"oauthTokenEndpoint": f"{v1_path}/auth/token"
},
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "http://d1nhio0ox7pgb.cloudfront.net/_img/g_collection_png/standard/512x512/torii.png"
"url": LOGO
}
}

Expand Down
35 changes: 0 additions & 35 deletions pubgate/api/v1/utils.py
@@ -1,47 +1,12 @@
import binascii
import os
import aiohttp
from functools import wraps

from sanic.log import logger
from sanic import exceptions

from pubgate import __version__
from pubgate.api.v1.db.models import User


async def deliver_task(recipient, activity):
logger.info(f" Delivering {make_label(activity)} ===> {recipient}")
async with aiohttp.ClientSession() as session:

async with session.get(recipient,
headers={'Accept': 'application/activity+json'}
) as resp:
logger.info(resp)
profile = await resp.json()

async with session.post(profile["inbox"],
json=activity,
headers={
'Accept': 'application/activity+json',
"Content-Type": 'application/activity+json',
"User-Agent": f"Pubgate v:{__version__}",
}
) as resp:
logger.info(resp)


async def deliver(activity, recipients):
# TODO deliver
# TODO retry over day if fails

for recipient in recipients:
try:
await deliver_task(recipient, activity)
except Exception as e:
logger.error(e)


def make_label(activity):
label = activity["type"]
if isinstance(activity["object"], dict):
Expand Down
2 changes: 1 addition & 1 deletion pubgate/api/v1/views/auth.py
Expand Up @@ -6,7 +6,7 @@
from pubgate.api.v1.db.models import User
from pubgate.api.v1.utils import random_object_id

auth_v1 = Blueprint('auth_v1', url_prefix='/api/v1/auth')
auth_v1 = Blueprint('auth_v1')


@auth_v1.route('/', methods=['POST'])
Expand Down
13 changes: 5 additions & 8 deletions pubgate/api/v1/views/inbox.py
Expand Up @@ -3,15 +3,14 @@
from sanic import response, Blueprint
from sanic_openapi import doc
from little_boxes.httpsig import verify_request
from little_boxes.linked_data_sig import generate_signature

from pubgate.api.v1.db.models import User, Inbox, Outbox
from pubgate.api.v1.renders import ordered_collection, context
from pubgate.api.v1.utils import deliver, make_label, random_object_id, auth_required
from pubgate.api.v1.key import get_key
from pubgate.api.v1.utils import make_label, random_object_id, auth_required
from pubgate.api.v1.deliver import deliver


inbox_v1 = Blueprint('inbox_v1', url_prefix='/api/v1/inbox')
inbox_v1 = Blueprint('inbox_v1')


@inbox_v1.route('/<user_id>', methods=['POST'])
Expand Down Expand Up @@ -73,7 +72,7 @@ async def inbox_post(request, user_id):

if activity["type"] == "Follow":
obj_id = random_object_id()
outbox_url = f"{request.app.base_url}/outbox/{user_id}"
outbox_url = f"{request.app.v1_path}/outbox/{user_id}"
deliverance = {
"id": f"{outbox_url}/{obj_id}",
"type": "Accept",
Expand All @@ -95,8 +94,6 @@ async def inbox_post(request, user_id):
})

# post_to_remote_inbox
generate_signature(deliverance, get_key(request.app.base_url, user_id, request.app.config.DOMAIN))
deliverance['@context'] = context
asyncio.ensure_future(deliver(deliverance, [activity["actor"]]))

return response.json({'peremoga': 'yep'})
Expand All @@ -117,7 +114,7 @@ async def inbox_list(request, user_id):
sort="activity.published desc"
)

inbox_url = f"{request.app.base_url}/inbox/{user_id}"
inbox_url = f"{request.app.v1_path}/inbox/{user_id}"
cleaned = [item["activity"] for item in data.objects]
resp = ordered_collection(inbox_url, cleaned)

Expand Down

0 comments on commit 32570c5

Please sign in to comment.