kopia lustrzana https://github.com/dsblank/activitypub
Refactor apps
rodzic
851af921fa
commit
b327292394
|
@ -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()
|
|
@ -0,0 +1,3 @@
|
|||
from .app import main
|
||||
|
||||
main()
|
Plik diff jest za duży
Load Diff
|
@ -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.")
|
|
@ -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
|
|
@ -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)
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -1,2 +1 @@
|
|||
tornado
|
||||
passlib
|
||||
pymongo
|
||||
|
|
Ładowanie…
Reference in New Issue