Fediverse: First draft.

This commit is contained in:
Valentin Lorentz 2020-05-09 19:14:56 +02:00
parent 43a8724d3a
commit e87a14826e
9 changed files with 683 additions and 3 deletions

View File

@ -0,0 +1 @@
Fetches information from ActivityPub servers.

View File

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

View File

@ -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
<https://docs.python.org/3/library/xml.html#xml-vulnerabilities>
"""
@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)

View File

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

View File

@ -0,0 +1 @@
# Stub so local is a module, used for third-party modules

204
plugins/Fediverse/plugin.py Normal file
View File

@ -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<localuser>[^@]+)@(?P<hostname>[^@]+)")
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": "<p>Hello world</p>",
"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:

90
plugins/Fediverse/test.py Normal file
View File

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

View File

@ -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://<hostname>:<port>/, but you will want to change
this if there is a reverse proxy (nginx, apache, ...) in front of
the bot.""")))
###

View File

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