diff --git a/plugins/Fediverse/README.md b/plugins/Fediverse/README.md new file mode 100644 index 000000000..ad1683afb --- /dev/null +++ b/plugins/Fediverse/README.md @@ -0,0 +1 @@ +Fetches information from ActivityPub servers. diff --git a/plugins/Fediverse/__init__.py b/plugins/Fediverse/__init__.py new file mode 100644 index 000000000..61e737803 --- /dev/null +++ b/plugins/Fediverse/__init__.py @@ -0,0 +1,72 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +""" +Fediverse: Fetches information from ActivityPub servers. +""" + +import sys +import supybot +from supybot import world + +# Use this for the version of this plugin. +__version__ = "" + +# XXX Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.authors.unknown + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = "" + +from . import config +from . import plugin + +if sys.version_info >= (3, 4): + from importlib import reload +else: + from imp import reload +# In case we're being reloaded. +reload(config) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Fediverse/activitypub.py b/plugins/Fediverse/activitypub.py new file mode 100644 index 000000000..cc4b13aa4 --- /dev/null +++ b/plugins/Fediverse/activitypub.py @@ -0,0 +1,221 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import json +import base64 +import hashlib +import functools +import contextlib +import urllib.parse +import xml.etree.ElementTree as ET + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding + + +from supybot import commands, conf +from supybot.utils import web + + +XRD_URI = "{http://docs.oasis-open.org/ns/xri/xrd-1.0}" +ACTIVITY_MIMETYPE = "application/activity+json" + + +class ActivityPubError(Exception): + pass + + +class ProtocolError(ActivityPubError): + pass + + +class HostmetaError(ProtocolError): + pass + + +class ActivityPubProtocolError(ActivityPubError): + pass + + +class WebfingerError(ProtocolError): + pass + + +class ActorNotFound(ActivityPubError): + pass + + +def sandbox(f): + """Runs a function in a process with limited memory to prevent + XML memory bombs + + """ + + @functools.wraps(f) + def newf(*args, **kwargs): + try: + return commands.process( + f, + *args, + **kwargs, + timeout=10, + heap_size=100 * 1024 * 1024, + pn="Fediverse", + cn=f.__name__ + ) + except (commands.ProcessTimeoutError, MemoryError): + raise utils.web.Error( + _( + "Page is too big or the server took " + "too much time to answer the request." + ) + ) + + return newf + + +@contextlib.contextmanager +def convert_exceptions(to_class, msg="", from_none=False): + try: + yield + except Exception as e: + arg = msg + str(e) + if from_none: + raise to_class(arg) from None + else: + raise to_class(arg) from e + + +@sandbox +def _get_webfinger_url(hostname): + with convert_exceptions(HostmetaError): + doc = ET.fromstring( + web.getUrlContent("https://%s/.well-known/host-meta" % hostname) + ) + + for link in doc.iter(XRD_URI + "Link"): + if link.attrib["rel"] == "lrdd": + return link.attrib["template"] + + return "https://%s/.well-known/webfinger?resource={uri}" + + +def webfinger(hostname, uri): + template = _get_webfinger_url(hostname) + assert template + + with convert_exceptions(WebfingerError): + content = web.getUrlContent( + template.replace("{uri}", uri), + headers={"Accept": "application/json"}, + ) + + with convert_exceptions(WebfingerError, "Invalid JSON: ", True): + return json.loads(content) + + +def get_instance_actor_url(): + root_url = conf.supybot.servers.http.publicUrl() + if not root_url: + return None + + return urllib.parse.urljoin(root_url, "/fediverse/instance_actor") + + +def _get_private_key(): + path = conf.supybot.directories.data.dirize("Fediverse/instance_key.pem") + with open(path, "rb") as fd: + return serialization.load_pem_private_key( + fd.read(), password=None, backend=default_backend() + ) + + +def get_public_key(): + return _get_private_key().public_key() + + +def get_public_key_pem(): + return get_public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + +def _signed_request(url, headers, data=None): + method = "get" if data is None else "post" + instance_actor_url = get_instance_actor_url() + + assert instance_actor_url + if instance_actor_url: + signed_headers = [ + ( + "(request-target)", + method + " " + urllib.parse.urlparse(url).path, + ) + ] + for (header_name, header_value) in headers.items(): + signed_headers.append((header_name.lower(), header_value)) + signed_text = "\n".join("%s: %s" % header for header in signed_headers) + + private_key = _get_private_key() + signature = private_key.sign( + signed_text.encode(), padding.PKCS1v15(), hashes.SHA256() + ) + + headers["Signature"] = ( + 'keyId="%s#main-key",' % instance_actor_url + + 'headers="%s",' % " ".join(k for (k, v) in signed_headers) + + 'signature="%s"' % base64.b64encode(signature).decode() + ) + + with convert_exceptions(ActivityPubProtocolError): + return web.getUrlContent(url, headers=headers, data=data) + + +def actor_url(localuser, hostname): + uri = "acct:%s@%s" % (localuser, hostname) + for link in webfinger(hostname, uri)["links"]: + if link["rel"] == "self" and link["type"] == ACTIVITY_MIMETYPE: + return link["href"] + + raise ActorNotFound(localuser, hostname) + + +def get_actor(localuser, hostname): + url = actor_url(localuser, hostname) + + content = _signed_request(url, headers={"Accept": ACTIVITY_MIMETYPE}) + + assert content is not None + + with convert_exceptions(ActivityPubProtocolError, "Invalid JSON: ", True): + return json.loads(content) diff --git a/plugins/Fediverse/config.py b/plugins/Fediverse/config.py new file mode 100644 index 000000000..89a7e8a56 --- /dev/null +++ b/plugins/Fediverse/config.py @@ -0,0 +1,59 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot import conf, registry + +try: + from supybot.i18n import PluginInternationalization + + _ = PluginInternationalization("Fediverse") +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified themself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + + conf.registerPlugin("Fediverse", True) + + +Fediverse = conf.registerPlugin("Fediverse") +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Fediverse, 'someConfigVariableName', +# registry.Boolean(False, _("""Help for someConfigVariableName."""))) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Fediverse/local/__init__.py b/plugins/Fediverse/local/__init__.py new file mode 100644 index 000000000..e86e97b86 --- /dev/null +++ b/plugins/Fediverse/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/plugins/Fediverse/plugin.py b/plugins/Fediverse/plugin.py new file mode 100644 index 000000000..abfa40173 --- /dev/null +++ b/plugins/Fediverse/plugin.py @@ -0,0 +1,204 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import re +import json +import importlib +import urllib.parse + +from supybot import conf, utils, plugins, ircutils, callbacks, httpserver +from supybot.commands import * +from supybot.i18n import PluginInternationalization + +_ = PluginInternationalization("Fediverse") + +from . import activitypub as ap + +importlib.reload(ap) + + +_username_re = re.compile("@(?P[^@]+)@(?P[^@]+)") + + +class FediverseHttp(httpserver.SupyHTTPServerCallback): + name = "minimal ActivityPub server" + defaultResponse = _( + """ + You shouldn't be here, this subfolder is not for you. Go back to the + index and try out other plugins (if any).""" + ) + + def doGetOrHead(self, handler, path, write_content): + if path == "/instance_actor": + self.instance_actor(write_content) + else: + assert False, repr(path) + + def doWellKnown(self, handler, path): + actor_url = ap.get_instance_actor_url() + instance_hostname = urllib.parse.urlsplit(actor_url).hostname + instance_account = "acct:%s@%s" % ( + instance_hostname, + instance_hostname, + ) + if path == "/webfinger?resource=%s" % instance_account: + headers = {"Content-Type": "application/jrd+json"} + content = { + "subject": instance_account, + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": actor_url, + } + ], + } + return (200, headers, json.dumps(content).encode()) + else: + return None + + def instance_actor(self, write_content): + self.send_response(200) + self.send_header("Content-type", ap.ACTIVITY_MIMETYPE) + self.end_headers() + if not write_content: + return + pem = ap.get_public_key_pem() + actor_url = ap.get_instance_actor_url() + hostname = urllib.parse.urlparse(hostname).hostname + actor = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + "id": actor_url, + "preferredUsername": hostname, + "type": "Person", + "publicKey": { + "id": actor_url + "#main-key", + "owner": actor_url, + "publicKeyPem": pem.decode(), + }, + "inbox": actor_url + "/inbox", + } + self.wfile.write(json.dumps(actor).encode()) + + +class Fediverse(callbacks.Plugin): + """Fetches information from ActivityPub servers.""" + + threaded = True + + def __init__(self, irc): + super().__init__(irc) + self._startHttp() + + def _startHttp(self): + callback = FediverseHttp() + callback._plugin = self + httpserver.hook("fediverse", callback) + + def die(self): + self._stopHttp() + super().die() + + def _stopHttp(self): + httpserver.unhook("fediverse") + + @wrap(["somethingWithoutSpaces"]) + def profile(self, irc, msg, args, username): + """<@user@instance> + + Returns generic information on the account @user@instance.""" + match = _username_re.match(username) + if not match: + irc.errorInvalid("fediverse username", username) + localuser = match.group("localuser") + hostname = match.group("hostname") + + try: + actor = ap.get_actor(localuser, hostname) + except ap.WebfingerError as e: + # Usually a 404 + irc.error(e.args[0], Raise=True) + + irc.reply( + _("\x02%s\x02 (@%s@%s): %s") + % ( + actor["name"], + actor["preferredUsername"], + hostname, + utils.web.htmlToText(actor["summary"], tagReplace=""), + ) + ) + + ''' + @wrap(['somethingWithoutSpaces']) + def post(self, irc, msg, args, username): + """<@user@instance> + + Returns generic information on the account @user@instance.""" + match = _username_re.match(username) + if not match: + irc.errorInvalid('fediverse username', username) + localuser = match.group('localuser') + hostname = match.group('hostname') + + instance_actor = ap.get_instance_actor_url() + instance_hostname = urllib.parse.urlparse( + conf.supybot.servers.http.publicUrl()).hostname + doc = { + "@context": "https://www.w3.org/ns/activitystreams", + + "id": "https://%s/create-hello-world" % instance_hostname, + "type": "Create", + "actor": instance_actor, + + "object": { + "id": "https://%s/hello-world" % instance_hostname, + "type": "Note", + "published": "2018-06-23T17:17:11Z", + "attributedTo": instance_actor, + "content": "

Hello world

", + "to": "https://www.w3.org/ns/activitystreams#Public" + } + } + + ap._signed_request( + url='https://%s/inbox' % hostname, + headers={}, + data=json.dumps(doc), + )''' + + +Class = Fediverse + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Fediverse/test.py b/plugins/Fediverse/test.py new file mode 100644 index 000000000..31aa6e285 --- /dev/null +++ b/plugins/Fediverse/test.py @@ -0,0 +1,90 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot import conf +from supybot.test import * + + +PRIVATE_KEY = b""" +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA6jtjTlaTh1aR+q3gpZvb4dj8s81zKmwy7cwn44LtLV+ivNf/ +SkWPr1zkm/gWFItC3058Faqk9p4fdJaxVJJTW0KL7LlJs+LTcMsLi2nTgvBZg7oE +KRXZxuJJcc5QNkgY8vHt1PxdD17mZBGwfg2loZfnjZOOz4F8wdQ18Da1ZFUFyc+R +qj1THdXbpBjF7zcNyJOWzzRwhpiqdJomnTAYDscAkkF2/gI8tYP+Is31GOE1phPC +DH20uvJNUtDnXSdUm2Ol21LmePV4pWS75mcIHz5YAKwAGo9XoUQa8lC6IHw6LX+y +CVKkoSc0Ouzr3acQCLZ8EDUIh2nMhw/VtYV7JwIDAQABAoIBAFSARkwtqZ1qmtFf +xyqXttScblYDaWfFjv4A5+cJBb2XweL03ZGS1MpD7elir7yLnP1omBVM8aRS2TA7 +aRAElfPXZxloovE1hGgtqCWMcRTM1s5R3kxgKKe6XRqkfoWGrxF+O/nZbU0tRFqX +kx92lulcHtoRgLTVlwdqImddpUTjQrWmrt3nEjTZj5tHcPGdC2ovH/bFrganbCR1 +If6xG2r6RWSfMEpj7yFTKRvnLCr2VpviDOwFh/zZdwyqBRKW6LNZP04TtlFfKh5C +1R2tZVRHQ7Ed99yruirW0rmgOjA6dJTpN6oX6x3DpTi48oK2jktEIk07P7jy1mZY +NeCQcqkCgYEA+M0DQ+0fBm/RJyDIxsupMAf8De1kG6Bj8gVSRnvtD0Fb3LTswT3I +TDnIVttjOzBsbpZVdjdCE9Wcfj9pIwu3YTO54OTS8kiwYRshzEm3UpdPOSCnIZUx +jwbbwEHq0zEeIWVjDBDXN2fqEcu7gFqBzYivAh8hYq78BJkUeBWU3N0CgYEA8QJ0 +6xS551VEGLbn9h5pPxze7l8a9WJc1uIxRexeBtd4UwJ5e1yLN68FVNjGr3JtreJ3 +KP/VyynFubNRvwGEnifKe9QyiATFCbVeAFBQFuA0w89LOmBiHc+uHz1uA5oXnD99 +Y0pEu8g+QsBKeQowMhkYnw4h5cq3AVCKRIdNpdMCgYEAwy5p8l7SKQWNagnBGJtr +BeAtr2tdToL8BUCBdAQCTCZ0/2b8GPjz6kCmVuVTKnrphbPwJYZiExdP5oauXyzw +1pNyreg1SJcXr4ZOdGocI/HJ18Iy+xiEwXSa7m+H3dg5j+9uzWdkvvWJXh6a4K2g +CPLCgIKVeUpXMPA6a55aow0CgYAMpoRckonvipo4ceFbGd2MYoeRG4zetHsLDHRp +py6ITWcTdF3MC9+C3Lz65yYGr4ryRaDblhIyx86JINB5piq/4nbOaST93sI48Dwu +6AhMKxiZ7peUSNrdlbkeCqtrpPr4SJzcSVmyQaCDAHToRZCiEI8qSiOdXDae6wtW +7YM14QKBgQDnbseQK0yzrsZoOmQ9PBULr4vNLiL5+OllOG1+GNNztk/Q+Xfx6Hvw +h6cgTcpZsvaa2CW6A2yqenmGfKBgiRoN39vFqjVDkjL1HaL3rPeK1H7RWrz1Sto7 +rut+UhYHat9fo6950Wvxa4Iee9q0NOF0HUkD6WupcPyC0nSEex8Z6A== +-----END RSA PRIVATE KEY----- +""" + + +class FediverseTestCase(PluginTestCase): + plugins = ("Fediverse",) + config = { + "supybot.servers.http.port": 18080, + "supybot.servers.http.publicUrl": "https://particle18080.test.progval.net/", + } + + def setUp(self): + super().setUp() + path = conf.supybot.directories.data.dirize( + "Fediverse/instance_key.pem" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fd: + fd.write(PRIVATE_KEY) + + if network: + + def testProfile(self): + self.assertRegexp("profile @val@oc.todon.fr", "0E082B40E4376B1E") + # TODO: add a test with an instance which only allows fetches + # with valid signatures. + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/src/conf.py b/src/conf.py index e8ddbf3f1..61cb52840 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1388,6 +1388,11 @@ registerGlobalValue(supybot.servers.http, 'keepAlive', registerGlobalValue(supybot.servers.http, 'favicon', registry.String('', _("""Determines the path of the file served as favicon to browsers."""))) +registerGlobalValue(supybot.servers.http, 'publicUrl', + registry.String('', _("""Determines the public URL of the server. + By default it is http://:/, but you will want to change + this if there is a reverse proxy (nginx, apache, ...) in front of + the bot."""))) ### diff --git a/src/httpserver.py b/src/httpserver.py index 715c1c5d2..ce3af27fd 100644 --- a/src/httpserver.py +++ b/src/httpserver.py @@ -178,7 +178,7 @@ class RealSupyHTTPServer(HTTPServer): else: raise AssertionError(protocol) HTTPServer.__init__(self, address, callback) - self.callbacks = {} + self.callbacks = DEFAULT_CALLBACKS def server_bind(self): if self.protocol == 6: @@ -322,6 +322,10 @@ class SupyHTTPServerCallback(log.Firewalled): doPost = doGet + def doWellKnown(self, handler, path): + """Handles GET request to /.well-known/""" + return None + def doHook(self, handler, subdir): """Method called when hooking this callback.""" pass @@ -352,7 +356,6 @@ class Supy404(SupyHTTPServerCallback): class SupyIndex(SupyHTTPServerCallback): """Displays the index of available plugins.""" name = "index" - fullpath = True defaultResponse = _("Request not handled.") def doGetOrHead(self, handler, path, write_content): plugins = [x for x in handler.server.callbacks.items()] @@ -428,6 +431,27 @@ class Favicon(SupyHTTPServerCallback): if write_content: self.wfile.write(response) +class SupyWellKnown(SupyHTTPServerCallback): + """Serves /.well-known/ resources.""" + name = 'well-known' + defaultResponse = _('Request not handled') + + def doGetOrHead(self, handler, path, write_content): + for callback in handler.server.callbacks.values(): + resp = callback.doWellKnown(handler, path) + if resp: + (status, headers, content) = resp + handler.send_response(status) + for header in headers.items(): + self.send_header(*header) + self.end_headers() + if write_content: + self.wfile.write(content) + return + + handler.send_response(404) + self.end_headers() + http_servers = [] def startServer(): @@ -471,6 +495,9 @@ def unhook(subdir): assert isinstance(http_servers, list) for server in list(http_servers): server.unhook(subdir) - if len(server.callbacks) <= 0 and not configGroup.keepAlive(): + if len(set(server.callbacks) - set(DEFAULT_CALLBACKS)) <= 0 \ + and not configGroup.keepAlive(): server.shutdown() http_servers.remove(server) + +DEFAULT_CALLBACKS = {'.well-known': SupyWellKnown()}