master
Douglas Blank 2018-07-19 10:00:33 -04:00
rodzic 851af921fa
commit b327292394
14 zmienionych plików z 1640 dodań i 2 usunięć

Wyświetl plik

@ -0,0 +1,10 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .models import Base
class Application():
def __init__(self, engine_string):
self.engine = create_engine(engine_string)
Base.metadata.create_all(self.engine)
DBSession = sessionmaker(bind=self.engine)
self.session = DBSession()

Wyświetl plik

@ -0,0 +1,3 @@
from .app import main
main()

Plik diff jest za duży Load Diff

Wyświetl plik

@ -0,0 +1,84 @@
import logging
import os
import tornado.web
import tornado.log
from tornado.options import define, options, parse_command_line
from passlib.hash import sha256_crypt as crypt
from .handlers import (MessageNewHandler, MessageUpdatesHandler,
MainHandler, MessageBuffer, LoginHandler,
LogoutHandler)
define("port", default=8888, help="run on the given port", type=int)
define("debug", default=False, help="run in debug mode", type=bool)
define("directory", default=".", help="location of templates and static", type=str)
APPLICATION_NAME = "ActivityPub"
### To change the URLs each handler serves, you'll need to edit:
### 1. This code
### 2. The static/chat.js code
### 3. The template/*.html code
class ActivityPubApplication(tornado.web.Application):
"""
"""
def __init__(self, *args, **kwargs):
self.message_buffer = MessageBuffer()
super().__init__(*args, **kwargs)
def get_user_data(self, getusername):
return {
"password": crypt.hash(getusername),
}
def handle_messages(self, messages):
for message in messages:
tornado.log.logging.info("handle_message: %s", message)
self.message_buffer.new_messages(messages)
def make_url(path, handler, kwargs=None, name=None):
#kwargs["options"] = options
return tornado.web.url(path, handler, kwargs, name)
def make_app():
parse_command_line()
if options.debug:
import tornado.autoreload
log = logging.getLogger()
log.setLevel(logging.DEBUG)
tornado.log.logging.info("Debug mode...")
template_directory = os.path.join(options.directory, 'templates')
tornado.log.logging.info(template_directory)
for dirpath, dirnames, filenames in os.walk(template_directory):
for filename in filenames:
template_filename = os.path.join(dirpath, filename)
tornado.log.logging.info(" watching: " + os.path.relpath(template_filename))
tornado.autoreload.watch(template_filename)
app = ActivityPubApplication(
[
make_url(r"/", MainHandler, name="home"),
make_url(r'/login', LoginHandler, name="login"),
make_url(r'/logout', LogoutHandler, name="logout"),
make_url(r"/message/new", MessageNewHandler),
make_url(r"/message/updates", MessageUpdatesHandler),
],
cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__",
login_url = "/login",
template_path=os.path.join(os.path.dirname(options.directory), "templates"),
static_path=os.path.join(os.path.dirname(options.directory), "static"),
xsrf_cookies=True,
debug=options.debug,
)
return app
def main():
app = make_app()
app.listen(options.port)
tornado.log.logging.info("Starting...")
try:
tornado.ioloop.IOLoop.current().start()
except KeyboardInterrupt:
tornado.log.logging.info("Shutting down...")
tornado.log.logging.info("Stopped.")

Wyświetl plik

@ -0,0 +1,179 @@
import abc
import binascii
import os
import typing
import requests
from .__version__ import __version__
from .errors import ActivityNotFoundError
from .errors import RemoteActivityGoneError
from .urlutils import check_url as check_url
if typing.TYPE_CHECKING:
from little_boxes import activitypub as ap # noqa: type checking
class Backend(abc.ABC):
def debug_mode(self) -> bool:
"""Should be overidded to return `True` in order to enable the debug mode."""
return False
def check_url(self, url: str) -> None:
check_url(url, debug=self.debug_mode())
def user_agent(self) -> str:
return (
f"{requests.utils.default_user_agent()} (Little Boxes/{__version__};"
" +http://github.com/tsileo/little-boxes)"
)
def random_object_id(self) -> str:
"""Generates a random object ID."""
return binascii.hexlify(os.urandom(8)).decode("utf-8")
def fetch_json(self, url: str, **kwargs):
self.check_url(url)
resp = requests.get(
url,
headers={"User-Agent": self.user_agent(), "Accept": "application/json"},
**kwargs,
)
resp.raise_for_status()
return resp
def is_from_outbox(
self, as_actor: "ap.Person", activity: "ap.BaseActivity"
) -> bool:
return activity.get_actor().id == as_actor.id
@abc.abstractmethod
def post_to_remote_inbox(
self, as_actor: "ap.Person", payload_encoded: str, recp: str
) -> None:
pass # pragma: no cover
@abc.abstractmethod
def base_url(self) -> str:
pass # pragma: no cover
def fetch_iri(self, iri: str, **kwargs) -> "ap.ObjectType": # pragma: no cover
self.check_url(iri)
resp = requests.get(
iri,
headers={
"User-Agent": self.user_agent(),
"Accept": "application/activity+json",
},
**kwargs,
)
if resp.status_code == 404:
raise ActivityNotFoundError(f"{iri} is not found")
elif resp.status_code == 410:
raise RemoteActivityGoneError(f"{iri} is gone")
resp.raise_for_status()
return resp.json()
@abc.abstractmethod
def inbox_check_duplicate(self, as_actor: "ap.Person", iri: str) -> bool:
pass # pragma: no cover
@abc.abstractmethod
def activity_url(self, obj_id: str) -> str:
pass # pragma: no cover
@abc.abstractmethod
def note_url(self, obj_id: str) -> str:
pass # pragma: no cover
@abc.abstractmethod
def outbox_create(self, as_actor: "ap.Person", activity: "ap.Create") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_delete(self, as_actor: "ap.Person", activity: "ap.Delete") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_create(self, as_actor: "ap.Person", activity: "ap.Create") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_delete(self, as_actor: "ap.Person", activity: "ap.Delete") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_is_blocked(self, as_actor: "ap.Person", actor_id: str) -> bool:
pass # pragma: no cover
@abc.abstractmethod
def inbox_new(self, as_actor: "ap.Person", activity: "ap.BaseActivity") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_new(self, as_actor: "ap.Person", activity: "ap.BaseActivity") -> None:
pass # pragma: no cover
@abc.abstractmethod
def new_follower(self, as_actor: "ap.Person", follow: "ap.Follow") -> None:
pass # pragma: no cover
@abc.abstractmethod
def new_following(self, as_actor: "ap.Person", follow: "ap.Follow") -> None:
pass # pragma: no cover
@abc.abstractmethod
def undo_new_follower(self, as_actor: "ap.Person", follow: "ap.Follow") -> None:
pass # pragma: no cover
@abc.abstractmethod
def undo_new_following(self, as_actor: "ap.Person", follow: "ap.Follow") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_update(self, as_actor: "ap.Person", activity: "ap.Update") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_update(self, as_actor: "ap.Person", activity: "ap.Update") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_like(self, as_actor: "ap.Person", activity: "ap.Like") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_undo_like(self, as_actor: "ap.Person", activity: "ap.Like") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_like(self, as_actor: "ap.Person", activity: "ap.Like") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_undo_like(self, as_actor: "ap.Person", activity: "ap.Like") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_announce(self, as_actor: "ap.Person", activity: "ap.Announce") -> None:
pass # pragma: no cover
@abc.abstractmethod
def inbox_undo_announce(
self, as_actor: "ap.Person", activity: "ap.Announce"
) -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_announce(self, as_actor: "ap.Person", activity: "ap.Announce") -> None:
pass # pragma: no cover
@abc.abstractmethod
def outbox_undo_announce(
self, as_actor: "ap.Person", activity: "ap.Announce"
) -> None:
pass # pragma: no cover

Wyświetl plik

@ -0,0 +1,134 @@
import uuid
import tornado.escape
import tornado.web
import tornado.log
from tornado.concurrent import Future
from tornado import gen
from passlib.hash import sha256_crypt as crypt
class BaseHandler(tornado.web.RequestHandler):
def prepare(self):
super().prepare()
self.json_data = None
if self.request.headers.get("Content-Type") == "application/json":
if self.request.body:
try:
self.json_data = tornado.escape.json_decode(self.request.body)
except ValueError:
tornado.log.logging.info("unable to decode JSON data (%s)", self.request.body)
def get_current_user(self):
user = self.get_secure_cookie("user")
if isinstance(user, bytes):
user = user.decode()
return user
class LoginHandler(BaseHandler):
def get(self):
self.render('login.html')
def post(self):
getusername = self.get_argument("username")
getpassword = self.get_argument("password")
user_data = self.application.get_user_data(getusername)
tornado.log.logging.info("user_data[password]=%s", user_data["password"])
tornado.log.logging.info("getpassword=%s", getpassword)
if user_data and user_data["password"] and crypt.verify(getpassword, user_data["password"]):
self.set_secure_cookie("user", self.get_argument("username"))
self.redirect(self.get_argument("next", self.reverse_url("home")))
else:
self.redirect(self.reverse_url("login"))
class LogoutHandler(BaseHandler):
def get(self):
self.clear_cookie("user")
self.redirect(self.get_argument("next", self.reverse_url("home")))
class MessageBuffer(object):
def __init__(self):
self.waiters = set()
self.cache = []
self.cache_size = 200
def wait_for_messages(self, cursor=None):
# Construct a Future to return to our caller. This allows
# wait_for_messages to be yielded from a coroutine even though
# it is not a coroutine itself. We will set the result of the
# Future when results are available.
result_future = Future()
if cursor:
new_count = 0
for msg in reversed(self.cache):
if msg["id"] == cursor:
break
new_count += 1
if new_count:
result_future.set_result(self.cache[-new_count:])
return result_future
self.waiters.add(result_future)
return result_future
def cancel_wait(self, future):
self.waiters.remove(future)
# Set an empty result to unblock any coroutines waiting.
future.set_result([])
def new_messages(self, messages):
tornado.log.logging.info("Sending new message to %r listeners", len(self.waiters))
for future in self.waiters:
future.set_result(messages)
self.waiters = set()
self.cache.extend(messages)
if len(self.cache) > self.cache_size:
self.cache = self.cache[-self.cache_size:]
class MainHandler(BaseHandler):
@tornado.web.authenticated
def get(self):
user = self.get_current_user()
#messages = [message for message in self.application.message_buffer.cache
# if message.get("to_address", "") == user]
messages = [message for message in self.application.message_buffer.cache]
tornado.log.logging.info("messages: %s", messages)
self.render("index.html", messages=messages, user=user)
class MessageNewHandler(BaseHandler):
@tornado.web.authenticated
def post(self):
user = self.get_current_user()
message = {
"id": str(uuid.uuid4()),
"to_address": self.get_argument("to_address"),
"from_address": self.get_argument("from_address"),
"message_type": self.get_argument("message_type"),
"body": self.get_argument("body"),
"html": "",
}
# message["html"] contains itself:
message["html"] = tornado.escape.to_basestring(
self.render_string("message.html", message=message, user=user))
## Message goes to database:
self.application.handle_messages([message])
if self.get_argument("next", None):
self.redirect(self.get_argument("next"))
else:
## Sender gets the message back to handle:
self.write(message)
class MessageUpdatesHandler(BaseHandler):
@tornado.web.authenticated
@gen.coroutine
def post(self):
## send info to javascript:
cursor = self.get_argument("cursor", None)
# Save the future returned by wait_for_messages so we can cancel
# it in wait_for_messages
self.future = self.application.message_buffer.wait_for_messages(cursor=cursor)
messages = yield self.future
if self.request.connection.stream.closed():
return
self.write(dict(messages=messages))
def on_connection_close(self):
self.application.message_buffer.cancel_wait(self.future)

Wyświetl plik

@ -0,0 +1,41 @@
from typing import Any
from typing import Dict
from typing import Optional
from Crypto.PublicKey import RSA
class Key(object):
DEFAULT_KEY_SIZE = 2048
def __init__(self, owner: str) -> None:
self.owner = owner
self.privkey_pem: Optional[str] = None
self.pubkey_pem: Optional[str] = None
self.privkey: Optional[Any] = None
self.pubkey: Optional[Any] = None
def load_pub(self, pubkey_pem: str) -> None:
self.pubkey_pem = pubkey_pem
self.pubkey = RSA.importKey(pubkey_pem)
def load(self, privkey_pem: str) -> None:
self.privkey_pem = privkey_pem
self.privkey = RSA.importKey(self.privkey_pem)
self.pubkey_pem = self.privkey.publickey().exportKey("PEM").decode("utf-8")
def new(self) -> None:
k = RSA.generate(self.DEFAULT_KEY_SIZE)
self.privkey_pem = k.exportKey("PEM").decode("utf-8")
self.pubkey_pem = k.publickey().exportKey("PEM").decode("utf-8")
self.privkey = k
def key_id(self) -> str:
return f"{self.owner}#main-key"
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.key_id(),
"owner": self.owner,
"publicKeyPem": self.pubkey_pem,
}

Wyświetl plik

@ -0,0 +1,77 @@
import logging
from typing import Any
from typing import Dict
from typing import Optional
from urllib.parse import urlparse
import requests
from .activitypub import get_backend
from .urlutils import check_url
logger = logging.getLogger(__name__)
def webfinger(resource: str) -> Optional[Dict[str, Any]]:
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL.
"""
logger.info(f"performing webfinger resolution for {resource}")
protos = ["https", "http"]
if resource.startswith("http://"):
protos.reverse()
host = urlparse(resource).netloc
elif resource.startswith("https://"):
host = urlparse(resource).netloc
else:
if resource.startswith("acct:"):
resource = resource[5:]
if resource.startswith("@"):
resource = resource[1:]
_, host = resource.split("@", 1)
resource = "acct:" + resource
# Security check on the url (like not calling localhost)
check_url(f"https://{host}")
for i, proto in enumerate(protos):
try:
url = f"{proto}://{host}/.well-known/webfinger"
# FIXME(tsileo): BACKEND.fetch_json so we can set a UserAgent
resp = get_backend().fetch_json(url, params={"resource": resource})
except requests.ConnectionError:
# If we tried https first and the domain is "http only"
if i == 0:
continue
break
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
def get_remote_follow_template(resource: str) -> Optional[str]:
data = webfinger(resource)
if data is None:
return None
for link in data["links"]:
if link.get("rel") == "http://ostatus.org/schema/1.0/subscribe":
return link.get("template")
return None
def get_actor_url(resource: str) -> Optional[str]:
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL.
Returns:
the Actor URL or None if the resolution failed.
"""
data = webfinger(resource)
if data is None:
return None
for link in data["links"]:
if (
link.get("rel") == "self"
and link.get("type") == "application/activity+json"
):
return link.get("href")
return None

Wyświetl plik

@ -1,2 +1 @@
tornado
passlib
pymongo