├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── Readme.rst ├── requirements.txt ├── setup.py ├── static_website_activitypub ├── __init__.py ├── __main__.py ├── actors.py ├── cmd.py ├── root.py └── well_known.py └── tests.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto !eol svneol=native#text/plain 2 | *.gitattributes text svneol=native#text/plain 3 | 4 | # Scriptish formats 5 | *.bat text svneol=native#text/plain 6 | *.bsh text svneol=native#text/x-beanshell 7 | *.cgi text svneol=native#text/plain 8 | *.cmd text svneol=native#text/plain 9 | *.js text svneol=native#text/javascript 10 | *.php text svneol=native#text/x-php 11 | *.pl text svneol=native#text/x-perl 12 | *.pm text svneol=native#text/x-perl 13 | *.py text svneol=native#text/x-python 14 | *.sh eol=lf svneol=LF#text/x-sh 15 | configure eol=lf svneol=LF#text/x-sh 16 | 17 | # Image formats 18 | *.bmp binary svneol=unset#image/bmp 19 | *.gif binary svneol=unset#image/gif 20 | *.ico binary svneol=unset#image/ico 21 | *.jpeg binary svneol=unset#image/jpeg 22 | *.jpg binary svneol=unset#image/jpeg 23 | *.png binary svneol=unset#image/png 24 | *.tif binary svneol=unset#image/tiff 25 | *.tiff binary svneol=unset#image/tiff 26 | *.svg text svneol=native#image/svg%2Bxml 27 | 28 | # Data formats 29 | *.pdf binary svneol=unset#application/pdf 30 | *.avi binary svneol=unset#video/avi 31 | *.doc binary svneol=unset#application/msword 32 | *.dsp text svneol=crlf#text/plain 33 | *.dsw text svneol=crlf#text/plain 34 | *.eps binary svneol=unset#application/postscript 35 | *.json text svneol=native#application/json 36 | *.gz binary svneol=unset#application/gzip 37 | *.mov binary svneol=unset#video/quicktime 38 | *.mp3 binary svneol=unset#audio/mpeg 39 | *.ppt binary svneol=unset#application/vnd.ms-powerpoint 40 | *.ps binary svneol=unset#application/postscript 41 | *.psd binary svneol=unset#application/photoshop 42 | *.rdf binary svneol=unset#text/rdf 43 | *.rss text svneol=unset#text/xml 44 | *.rtf binary svneol=unset#text/rtf 45 | *.sln text svneol=native#text/plain 46 | *.swf binary svneol=unset#application/x-shockwave-flash 47 | *.tgz binary svneol=unset#application/gzip 48 | *.vcproj text svneol=native#text/xml 49 | *.vcxproj text svneol=native#text/xml 50 | *.vsprops text svneol=native#text/xml 51 | *.wav binary svneol=unset#audio/wav 52 | *.xls binary svneol=unset#application/vnd.ms-excel 53 | *.zip binary svneol=unset#application/zip 54 | 55 | # Text formats 56 | .htaccess text svneol=native#text/plain 57 | *.bbk text svneol=native#text/xml 58 | *.cmake text svneol=native#text/plain 59 | *.css text svneol=native#text/css 60 | *.dtd text svneol=native#text/xml 61 | *.htm text svneol=native#text/html 62 | *.html text svneol=native#text/html 63 | *.ini text svneol=native#text/plain 64 | *.log text svneol=native#text/plain 65 | *.mak text svneol=native#text/plain 66 | *.qbk text svneol=native#text/plain 67 | *.rst text svneol=native#text/plain 68 | *.sql text svneol=native#text/x-sql 69 | *.txt text svneol=native#text/plain 70 | *.xhtml text svneol=native#text/xhtml%2Bxml 71 | *.xml text svneol=native#text/xml 72 | *.xsd text svneol=native#text/xml 73 | *.xsl text svneol=native#text/xml 74 | *.xslt text svneol=native#text/xml 75 | *.xul text svneol=native#text/xul 76 | *.yml text svneol=native#text/plain 77 | boost-no-inspect text svneol=native#text/plain 78 | CHANGES text svneol=native#text/plain 79 | COPYING text svneol=native#text/plain 80 | INSTALL text svneol=native#text/plain 81 | Jamfile text svneol=native#text/plain 82 | Jamroot text svneol=native#text/plain 83 | Jamfile.v2 text svneol=native#text/plain 84 | Jamrules text svneol=native#text/plain 85 | Makefile* text svneol=native#text/plain 86 | README text svneol=native#text/plain 87 | TODO text svneol=native#text/plain 88 | 89 | # Code formats 90 | *.c text svneol=native#text/plain 91 | *.cpp text svneol=native#text/plain 92 | *.h text svneol=native#text/plain 93 | *.hpp text svneol=native#text/plain 94 | *.ipp text svneol=native#text/plain 95 | *.tpp text svneol=native#text/plain 96 | *.jam text svneol=native#text/plain 97 | *.java text svneol=native#text/plain 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.yaml 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: python 3 | python: 4 | - "3.6" 5 | notifications: 6 | email: 7 | recipients: 8 | - nialldouglas14@gmail.com 9 | 10 | env: 11 | - __="Build" 12 | - __="Test" 13 | - __="Install" 14 | 15 | install: "pip install -r requirements.txt" 16 | 17 | script: 18 | - 19 | if [ "$__" = "Build" ]; then 20 | python setup.py build; 21 | fi 22 | - 23 | if [ "$__" = "Test" ]; then 24 | python setup.py test; 25 | fi 26 | - 27 | if [ "$__" = "Install" ]; then 28 | python setup.py install; 29 | static-website-activitypub --version; 30 | fi 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Readme.rst: -------------------------------------------------------------------------------- 1 | Static Website ActivityPub 2 | ========================== 3 | 4 | .. |travis| image:: https://travis-ci.org/ned14/static-website-activitypub.svg?branch=master 5 | :align: middle 6 | :target: https://travis-ci.org/ned14/static-website-activitypub 7 | 8 | \(C) 2019 Niall Douglas http://www.nedprod.com/ 9 | 10 | PyPI: https://pypi.python.org/pypi/static-website-activitypub Github: https://github.com/ned14/static-website-activitypub 11 | 12 | Travis master branch all tests passing for Python v3: |travis| 13 | 14 | **Note that this project is currently in alpha, and should not be used by other people.** 15 | 16 | Implemented: 17 | 18 | - [X] Static website HTTP server 19 | - [X] ``/.well-known/webfinger`` 20 | - [X] ``/actors/preferredUsername`` 21 | - [ ] GET ``/actors/preferredUsername/inbox`` (returns no posts, we do not implement subscriptions) 22 | - [ ] POST ``/actors/preferredUsername/inbox`` (returns failure, we do not implement subscriptions) 23 | - [ ] GET ``/actors/preferredUsername/outbox`` (list all messages you have ever posted, paginated) 24 | - [ ] POST ``/actors/preferredUsername/outbox`` (add a new message) 25 | - [ ] AJAX based HTML page implementing POST to outbox 26 | - [ ] Publish to PyPI 27 | 28 | Herein is a CherryPy-based Python web server which implements the ActivityPub 29 | (https://www.w3.org/TR/activitypub/) client-to-server REST API for static 30 | website generators such as https://gohugo.io/ and https://jekyllrb.com/. 31 | It permits applications which can speak the client-to-server ActivityPub 32 | API, such as some mobile phone apps, to add posts to the static website 33 | by writing the post into a post directory, and then to call the static 34 | website generator to regenerate the static website. 35 | 36 | An AJAX based HTML page is optionally also provided which can enable mobile 37 | devices to add posts without requiring the installation of an app. 38 | 39 | Requirements 40 | ------------ 41 | 1. A working static website whose generator is supported by this service. 42 | Currently the only post format supported is ``yaml-front-matter`` where 43 | posts have a YAML front matter enclosed by ``---```, followed by the post 44 | content which will be in either Markdown or HTML. This suits https://gohugo.io/ 45 | and https://jekyllrb.com/ just fine. If this doesn't suit your static 46 | website generator, pull requests adding support for others are welcome. 47 | 48 | 2. Your static website does not have a ``.well-known`` directory or file, 49 | as this is used is by the ActivityPub implementation. 50 | 51 | Instructions 52 | ------------ 53 | 1. Generate a default ``static-website-activitypub.yaml`` file to somewhere using: 54 | 55 | static-website-activitypub -w static-website-activitypub.yaml \ 56 | --serve-directory path/to/your/public/html/directory \ 57 | --posts-directory path/to/your/blog/posts/source/directory \ 58 | --regenerate-command "shell script for regenerating the website" \ 59 | --user-account username@domain.com \ 60 | --website https://www.domain.com 61 | 62 | Customise the yaml script if you need to. 63 | 64 | 2. Run ``static-website-activitypub -c path/to/your/static-website-activitypub.yaml``, 65 | or you can specify all the configuation options on the command line, or by 66 | environment variables (see output from ``--help``). 67 | 68 | 3. Deploy to production using any of the mechanisms listed at 69 | http://docs.cherrypy.org/en/latest/deploy.html, which includes the 70 | forking model, systemd socket activation, supervisord, wsgi amongst others. 71 | Whilst you *can* serve your static website to HTTP exclusively using 72 | this service, it is more scalable to serve the website using nginx etc 73 | directly, and only proxy the ActivityPub services to this service. 74 | 75 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ConfigArgParse >= 0.14 2 | CherryPy >= 18.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | import os, static_website_activitypub 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | # Get the long description from the README file 9 | with open(os.path.join(here, 'Readme.rst')) as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name='static-website-activitypub', 14 | version=static_website_activitypub.version, 15 | description='Wraps a static website generator with an ActivityPub client-to-server implementation', 16 | long_description=long_description, 17 | author='Niall Douglas', 18 | url='http://pypi.python.org/pypi/static-website-activitypub', 19 | packages=['static_website_activitypub'], 20 | package_data={'static_website_activitypub' : ['../LICENSE']}, 21 | test_suite='tests', 22 | entry_points={ 23 | 'console_scripts': [ 'static-website-activitypub=static_website_activitypub:invoke_main' ] 24 | }, 25 | install_requires=['ConfigArgParse'], 26 | license='Apache', 27 | classifiers=[ 28 | 'Development Status :: 4 - Beta', 29 | 'Framework :: CherryPy', 30 | 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', 31 | 'License :: OSI Approved :: Apache Software License', 32 | 'Programming Language :: Python :: 2', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | ], 36 | ) -------------------------------------------------------------------------------- /static_website_activitypub/__init__.py: -------------------------------------------------------------------------------- 1 | from .cmd import main, version 2 | __version__ = version 3 | def invoke_main(*args): 4 | return next(main(*args), None) 5 | -------------------------------------------------------------------------------- /static_website_activitypub/__main__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .cmd import main 3 | except: 4 | from cmd import main 5 | main().next() 6 | -------------------------------------------------------------------------------- /static_website_activitypub/actors.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | import cherrypy 3 | 4 | # From https://www.w3.org/TR/activitypub/: 5 | # 6 | # - You can POST to someone's inbox to send them a message (server-to-server / federation only... this is federation!) 7 | # - You can GET from your inbox to read your latest messages (client-to-server; this is like reading your social network stream) 8 | # - You can POST to your outbox to send messages to the world (client-to-server) 9 | # - You can GET from someone's outbox to see what messages they've posted (or at least the ones you're authorized to see). (client-to-server and/or server-to-server) 10 | # 11 | # So we only care about implementing: 12 | # - POST outbox, so you can add new posts to your website 13 | # - Create https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create 14 | # - Delete https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete 15 | # - Update https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update 16 | # - GET outbox, which returns all posts on your website 17 | # 18 | # We implement GET inbox only to return no posts without error 19 | # 20 | # We implement POST inbox to return failure, as we don't support 21 | # others posting comments to our posts. 22 | 23 | class Actors(object): 24 | """Implements the /actors REST endpoint used to query user specifics""" 25 | def __init__(self, args, conf, actors_endpoint): 26 | self.user_account = args.user_account 27 | self.actors_endpoint = actors_endpoint 28 | # We only have one user, so pregenerate it 29 | self.cached_response = { 30 | '@context' : [ 31 | "https://www.w3.org/ns/activitystreams", 32 | "https://w3id.org/security/v1" 33 | ], 34 | 'id' : args.user_actor_href, 35 | 'type' : 'Person', 36 | 'preferredUsername' : args.user_account, 37 | 'inbox' : args.user_actor_href + '/inbox', 38 | 'outbox' : args.user_actor_href + '/outbox', 39 | 40 | # Mastodon seems to need this bit, but it's not required in W3C ActivityPub spec 41 | #'publicKey' : { 42 | # 'id' : args.user_actor_href + '#main-key', 43 | # 'owner' : args.user_actor_href, 44 | # 'publicKeyPem' : 45 | #} 46 | } 47 | # Update the conf to be given to cherrypy 48 | conf[args.actors_endpoint] = { 49 | 'tools.staticdir.on' : False, 50 | 'tools.encode.on' : True, 51 | 'tools.encode.encoding' : 'utf-8', 52 | 'tools.response_headers.on': True, 53 | 'tools.response_headers.headers': [('Content-Type', 'application/ld+json')], 54 | } 55 | 56 | def _cp_dispatch(self, vpath): 57 | if len(vpath) == 1: 58 | cherrypy.request.params['actor'] = vpath.pop() 59 | return self 60 | return vpath 61 | 62 | @cherrypy.expose 63 | @cherrypy.tools.json_out() 64 | def index(self, actor): 65 | if actor == self.user_account: 66 | return self.cached_response 67 | raise cherrypy.HTTPError(404, 'Actor not found') -------------------------------------------------------------------------------- /static_website_activitypub/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | import sys, os, cherrypy, json 3 | from configargparse import ArgParser, YAMLConfigFileParser 4 | from .root import Root 5 | 6 | version='0.10' 7 | 8 | def abspath(path): 9 | if not path: 10 | return path 11 | if not os.path.isabs(path): 12 | return os.path.abspath(path) 13 | return path 14 | 15 | def main(argv = sys.argv, testing = False): 16 | argp = ArgParser(prog = 'static-website-activitypub', 17 | description = 18 | r'''A CherryPy-based Python web server which implements the ActivityPub 19 | client-to-server REST API for static website generators.''', 20 | config_file_parser_class = YAMLConfigFileParser, ## Their default corrupts Windows filesystem paths 21 | default_config_files = ['./static-website-activitypub.yaml'], 22 | auto_env_var_prefix = 'SWA_', 23 | args_for_setting_config_path = ['-c', '--config-file'], 24 | args_for_writing_out_config_file = ['-w', '--write-out-config-file']) 25 | argp.add_argument('-q', '--quiet', action = "store_true", default = False, help = 'print nothing to stdout, and enable production mode.') 26 | argp.add_argument('-b', '--bind', dest = "bind_address", metavar = 'ADDRESS', default = '127.0.0.1', help = 'address to run server upon. Defaults to 127.0.0.1.') 27 | argp.add_argument('-p', '--port', dest = 'bind_port', metavar = "PORTNO", type = int, default = 8080, help = 'port to run server upon. Defaults to 8080.') 28 | argp.add_argument('--post-format', dest = 'post_format', metavar = "FORMAT", default = 'yaml-front-matter', help = 'how to write the post. Options include: yaml-front-matter', choices = ['yaml-front-matter']) 29 | argp.add_argument('-d', '--serve-directory', dest = 'serve_directory', metavar = '', type = abspath, default = '', help = 'Path to directory to serve') 30 | argp.add_argument('-o', '--posts-directory', dest = 'posts_directory', metavar = 'DIR', type = abspath, default = '', help = 'Path to directory containing posts') 31 | argp.add_argument('-r', '--regenerate-command', dest = 'regenerate_command', metavar = '', default = '', help = 'shell command to execute to cause the regeneration of the static website e.g. "cd path/to/your/hugo/sources && hugo"') 32 | argp.add_argument('-u', '--user-account', dest = 'user_account', metavar = 'EMAIL', default = '', help = 'Email account for the user whose posts are being published.') 33 | argp.add_argument('-s', '--website', metavar = 'URL', default = '', help = 'Website for the user whose posts are being published.') 34 | 35 | argp.add_argument('--actors-endpoint', dest = 'actors_endpoint', metavar = 'PATH', default = '/actors', help = 'REST endpoint for querying the actors. Defaults to "/actors" (obviously change this if you have an /actors directory or file).') 36 | argp.add_argument('--version', action='version', version='static-website-activitypub ' + version) 37 | 38 | args = argp.parse_args(argv[1:]) 39 | if not args.quiet: 40 | print('\nstatic-website-activitypub v' + version + ' (C) 2019 Niall Douglas http://www.nedprod.com/') 41 | print('Running with config:\n ' + argp.format_values().replace('\n', '\n ')) 42 | if not args.serve_directory: 43 | print('FATAL: serve-directory is not set, cannot continue!', file = sys.stderr) 44 | sys.exit(1) 45 | elif not os.path.isabs(args.serve_directory) or not os.path.exists(args.serve_directory): 46 | print('FATAL: serve-directory is set to "' + args.serve_directory + '" which is not absolute, or doesn\'t exist', file = sys.stderr) 47 | sys.exit(1) 48 | if '@' not in args.user_account: 49 | print('FATAL: user-account is not in email address format, cannot continue!', file = sys.stderr) 50 | sys.exit(1) 51 | args.user_domain = args.user_account.split('@')[1] 52 | args.user_account = args.user_account.split('@')[0] 53 | if 'http' not in args.website: 54 | print('FATAL: website is not in URL format, cannot continue!', file = sys.stderr) 55 | sys.exit(1) 56 | if not args.posts_directory: 57 | if not args.quiet: 58 | print('WARNING: posts-directory is not set, ActivityPub service will not be able to write posts!', file = sys.stderr) 59 | args.posts_directory = None 60 | elif not os.path.isabs(args.posts_directory) or not os.path.exists(args.posts_directory): 61 | print('FATAL: posts-directory is set to "' + args.posts_directory + '" which is not absolute, or doesn\'t exist', file = sys.stderr) 62 | sys.exit(1) 63 | if not args.regenerate_command: 64 | if not args.quiet: 65 | print('WARNING: regenerate-command is not set, ActivityPub service will not be able to regenerate the static website after writing a new post!', file = sys.stderr) 66 | args.regenerate_command = None 67 | 68 | cherrypy.config.update({ 69 | 'server.socket_host' : args.bind_address, 70 | 'server.socket_port' : args.bind_port, 71 | 'log.screen' : not args.quiet, 72 | }) 73 | if testing: 74 | cherrypy.config.update({ 75 | 'environment' : 'test_suite' 76 | }) 77 | elif args.quiet: 78 | cherrypy.config.update({ 79 | 'environment' : 'production' 80 | }) 81 | conf = { 82 | '/' : { 83 | 'tools.staticdir.on' : True, 84 | 'tools.staticdir.debug' : not args.quiet, 85 | 'tools.staticdir.root': os.path.abspath(args.serve_directory), 86 | 'tools.staticdir.dir': '', 87 | 'tools.staticdir.index': 'index.html', 88 | } 89 | } 90 | if args.website[-1] == '/': 91 | args.user_actor_href = args.website[:-1] + args.actors_endpoint + '/' + args.user_account 92 | else: 93 | args.user_actor_href = args.website + args.actors_endpoint + '/' + args.user_account 94 | cherrypy.tree.mount(Root(args, conf), '/', conf) 95 | cherrypy.engine.signals.subscribe() 96 | cherrypy.engine.start() 97 | if not testing: 98 | cherrypy.engine.block() 99 | else: 100 | yield 0 101 | cherrypy.engine.exit() 102 | return 0 103 | 104 | if __name__ == "__main__": 105 | sys.exit(main().next()) 106 | -------------------------------------------------------------------------------- /static_website_activitypub/root.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | import cherrypy 3 | from .well_known import WellKnown 4 | from .actors import Actors 5 | 6 | class Root(object): 7 | """The root website implementation""" 8 | def __init__(self, args, conf): 9 | # Preinitialise handlers for our endpoints 10 | self.well_known = WellKnown(args, conf) 11 | self.actors_endpoint = args.actors_endpoint[1:] 12 | self.actors = Actors(args, conf, self.actors_endpoint) 13 | 14 | def _cp_dispatch(self, vpath): 15 | if len(vpath) >= 1: 16 | if vpath[0] == '.well-known': 17 | return self.well_known 18 | elif vpath[0] == self.actors_endpoint: 19 | return self.actors 20 | return vpath 21 | -------------------------------------------------------------------------------- /static_website_activitypub/well_known.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | import cherrypy 3 | 4 | class WellKnown(object): 5 | """Implements the /.well-known REST endpoint used to query user account info""" 6 | def __init__(self, args, conf): 7 | # We only have one user, so pregenerate it 8 | self.cached_user = 'acct:' + args.user_account + '@' + args.user_domain 9 | self.cached_response = { 10 | 'subject' : self.cached_user, 11 | 'links' : [ 12 | { 13 | 'rel': 'self', 14 | 'type': 'application/activity+json', 15 | 'href': args.user_actor_href 16 | } 17 | ] 18 | } 19 | # Update the conf to be given to cherrypy 20 | conf['/.well-known'] = { 21 | 'tools.staticdir.on' : False, 22 | 'tools.encode.on' : True, 23 | 'tools.encode.encoding' : 'utf-8', 24 | 'tools.response_headers.on': True, 25 | 'tools.response_headers.headers': [('Content-Type', 'application/jrd+json')], 26 | } 27 | 28 | @cherrypy.expose 29 | @cherrypy.tools.json_out() 30 | def webfinger(self, resource): 31 | if resource == self.cached_user: 32 | return self.cached_response 33 | raise cherrypy.HTTPError(404, 'Resource not found') -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | import unittest, cherrypy, sys, os, inspect, json, static_website_activitypub 3 | 4 | try: 5 | from urllib import urlencode 6 | except: 7 | from urllib.parse import urlencode 8 | try: 9 | from StringIO import StringIO 10 | except: 11 | from io import StringIO 12 | 13 | class TestCase(unittest.TestCase): 14 | @classmethod 15 | def setUpClass(cls): 16 | cls._local = cherrypy.lib.httputil.Host('127.0.0.1', 50000, "") 17 | cls._remote = cherrypy.lib.httputil.Host('127.0.0.1', 50001, "") 18 | cherrypy.server.unsubscribe() 19 | cls._instance = static_website_activitypub.main(['static-website-activitypub', 20 | '--quiet', 21 | '--serve-directory', '.', 22 | '--user-account', 'test@nowhere', 23 | '--website', 'http://nowhere' 24 | ], testing = True) 25 | next(cls._instance, None) 26 | 27 | @classmethod 28 | def tearDownClass(cls): 29 | next(cls._instance, None) 30 | del cls._instance 31 | 32 | # Borrowed from https://bitbucket.org/Lawouach/cherrypy-recipes/src/50aff88dc4e24206518ec32e1c32af043f2729da/testing/unit/serverless?at=default 33 | @classmethod 34 | def request(cls, path='/', method='GET', app_path='', scheme='http', 35 | proto='HTTP/1.1', data=None, headers=None, **kwargs): 36 | """ 37 | CherryPy does not have a facility for serverless unit testing. 38 | However this recipe demonstrates a way of doing it by 39 | calling its internal API to simulate an incoming request. 40 | This will exercise the whole stack from there. 41 | 42 | Remember a couple of things: 43 | 44 | * CherryPy is multithreaded. The response you will get 45 | from this method is a thread-data object attached to 46 | the current thread. Unless you use many threads from 47 | within a unit test, you can mostly forget 48 | about the thread data aspect of the response. 49 | 50 | * Responses are dispatched to a mounted application's 51 | page handler, if found. This is the reason why you 52 | must indicate which app you are targetting with 53 | this request by specifying its mount point. 54 | 55 | You can simulate various request settings by setting 56 | the `headers` parameter to a dictionary of headers, 57 | the request's `scheme` or `protocol`. 58 | 59 | .. seealso: http://docs.cherrypy.org/stable/refman/_cprequest.html#cherrypy._cprequest.Response 60 | """ 61 | # This is a required header when running HTTP/1.1 62 | h = {'Host': '127.0.0.1'} 63 | 64 | if headers is not None: 65 | h.update(headers) 66 | 67 | # If we have a POST/PUT request but no data 68 | # we urlencode the named arguments in **kwargs 69 | # and set the content-type header 70 | if method in ('POST', 'PUT') and not data: 71 | data = urlencode(kwargs) 72 | kwargs = None 73 | h['content-type'] = 'application/x-www-form-urlencoded' 74 | 75 | # If we did have named arguments, let's 76 | # urlencode them and use them as a querystring 77 | qs = None 78 | if kwargs: 79 | qs = urlencode(kwargs) 80 | 81 | # if we had some data passed as the request entity 82 | # let's make sure we have the content-length set 83 | fd = None 84 | if data is not None: 85 | h['content-length'] = '%d' % len(data) 86 | fd = StringIO(data) 87 | 88 | # Get our application and run the request against it 89 | app = cherrypy.tree.apps.get(app_path) 90 | if not app: 91 | # XXX: perhaps not the best exception to raise? 92 | raise AssertionError("No application mounted at '%s'" % app_path) 93 | 94 | # Cleanup any previous returned response 95 | # between calls to this method 96 | app.release_serving() 97 | 98 | # Let's fake the local and remote addresses 99 | request, response = app.get_serving(cls._local, cls._remote, scheme, proto) 100 | try: 101 | h = [(k, v) for k, v in h.items()] 102 | response = request.run(method, path, qs, proto, h, fd) 103 | finally: 104 | if fd: 105 | fd.close() 106 | fd = None 107 | 108 | if response.output_status.startswith(b'500'): 109 | print(response.body) 110 | raise AssertionError("Unexpected error") 111 | 112 | # collapse the response into a bytestring 113 | response.collapse_body() 114 | return response 115 | 116 | class webfinger(TestCase): 117 | def runTest(self): 118 | resp = self.request('/.well-known/webfinger', resource = 'acct:test@nowhere') 119 | self.assertEqual(resp.output_status, b'200 OK') 120 | resp = json.loads(resp.body[0]) 121 | matching = (link['href'] for link in resp['links'] if link['type'] == 'application/activity+json') 122 | user_url = next(matching, None) 123 | self.assertEqual(user_url, 'http://nowhere/actors/test') 124 | 125 | class webfinger404(TestCase): 126 | def runTest(self): 127 | resp = self.request('/.well-known/webfinger', resource = 'acct:different@nowhere') 128 | self.assertEqual(resp.output_status, b'404 Not Found') 129 | resp = self.request('/.well-known/webfiner', resource = 'acct:different@nowhere') 130 | self.assertEqual(resp.output_status, b'404 Not Found') 131 | resp = self.request('/.well-known/') 132 | self.assertEqual(resp.output_status, b'404 Not Found') 133 | 134 | class actor(TestCase): 135 | def runTest(self): 136 | resp = self.request('/actors/test/') 137 | self.assertEqual(resp.output_status, b'200 OK') 138 | resp = json.loads(resp.body[0]) 139 | self.assertEqual(resp['inbox'], 'http://nowhere/actors/test/inbox') 140 | 141 | class actor404(TestCase): 142 | def runTest(self): 143 | resp = self.request('/actors/different/') 144 | self.assertEqual(resp.output_status, b'404 Not Found') 145 | resp = self.request('/actors/') 146 | self.assertEqual(resp.output_status, b'404 Not Found') 147 | 148 | if __name__ == '__main__': 149 | unittest.main() 150 | --------------------------------------------------------------------------------