{"@context":"https://www.w3.org/ns/activitystreams","type":"Article","id":"https://buron.coffee/entity/ox_activity_streams","published":"2023-09-28T18:22:16+0200","url":"https://buron.coffee/entity/ox_activity_streams","attributedTo":"https://buron.coffee/becasse","to":"https://www.w3.org/ns/activitystreams#Public","name":"ox-activity-streams","summary":"ox-activity-streams is a publish backend for Org-mode, which allows to publish org files as fediverse posts by serving only static files. It assumes that the org files are already published on the Web as html files. ox-activity-streams creates additional JSON-LD files, which can be alternatively served by your server as activity streams objects.","content":"
\nox-activity-streams is a publish backend for Org-mode, which allows to publish org files as fediverse posts by serving only static files. It assumes that the org files are already published on the Web as html files. ox-activity-streams creates additional JSON-LD files, which can be alternatively served by your server as activity streams objects.\n
\n\n\nBelow, the video shows this article display in Mastodon, when we look for its URL: https://buron.coffee/entity/ox_activity_streams.\n
\n\n\n\n
\n\n\nI want to acknowledge David Larlet for our discussion about this, which gives me the motivation to continue.\n
\n\n\nFirst, we have to create a file describing the activitypub actor (or the user in common language) to which all the publication will be attributed. Second, we set up a webfinger route such that the other fediverse applications are able to resolve the actor. \nFinally, in order to correctly serve activity-streams objects, we can configure a content negotiation which allows to gracefully serve a HTML or JSON file using the same IRI (or URL) following the requested content type.\n
\n\n\nIn the following, we assume that the web site files are stored in a directory accessible at the URL https://buron.coffee/
(in the following the https
is required by activitypub).\n
\nWe create a JSON file becasse.jsonac
for the actor becasse@buron.coffee
:\n
{\n \"@context\": [\n \"https://www.w3.org/ns/activitystreams\",\n \"https://w3id.org/security/v1\"\n ],\n \"type\": \"Person\",\n \"id\": \"https://buron.coffee/becasse\",\n \"inbox\": \"https://buron.coffee/becasse-inbox\",\n \"outbox\": \"https://buron.coffee/becasse-outbox\",\n \"preferredUsername\": \"becasse\",\n \"url\": \"https://buron.coffee\",\n \"name\": \"La Bécasse (website)\",\n \"icon\": {\n \"type\": \"Image\",\n \"mediaType\": \"image/png\",\n \"url\": \"https://buron.coffee/img/logo.png\"\n },\n \"following\": \"https://buron.coffee/becasse-following\",\n \"followers\": \"https://buron.coffee/becasse-followers\",\n \"summary\": \"<p>Welcome to my website</p>\",\n \"publicKey\": {\n \"id\": \"https://buron.coffee/becasse#main-key\",\n \"owner\": \"https://buron.coffee/becasse\",\n \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt0YbiewxvvDjoV5yiwya\\nBFZmOOBLPZJAkp3ounbvkYlinslpBXCA8qQWER6XrMemaEBZxfXPvuKNCJEnGZZJ\\nI17nbcgcMoqfw7l8qJBEx06MSOxKkcdVAcHLkteHMw4K3xw4M2t+PoA3XZt2AWts\\nHPBgaosGCRVAR89KUr0jehOBY2KrQWlWIWGnomKsfUNZ3WcJ7NsocWxeycKU/U3+\\nH4g6SzjkjhJgHX2D+oO7APiWSp5F107Imo5aV5elfie7wAKsDiQYVv/URGpYQyjl\\nkkW/6dd78ZskV4Yof6LS2Ie4v/JI9EVSf4hgiWGrGSUbmHUEij5ZV9Xz2xKJVMXf\\nZwIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n },\n \"endpoints\": {}\n}\n\n
\nI will explain why we use the extension .jsonac
below. The reason why the actor's IRI (the id
value above) does not contain this extension is explained with content negiation, if you choose to skip it you have to add to extension to your links.\nUp to now, I don't know if the public key is necessary for the rest.\n
\nSimilarly, we create four files for the inbox
, outbox
, following
, follower
, for example named respectively becasse-inbox.jsonac
, becasse-outbox.jsonac
, becasse-following.jsonac
, becasse-follower.jsonac
, which contains the same JSON document, which the id
's value is adapted:\n
{\n \"@context\": [\n \"https://www.w3.org/ns/activitystreams\"\n ],\n \"type\": \"OrderedCollection\",\n \"id\": \"https://buron.coffee/becasse-outbox\",\n \"totalItems\": 0,\n \"orderedItems\": []\n}\n\n
\nFinally, we have to ensure that the previous files about actor and the file about the publication are served with content type application/activity+json
. To implement it using Nginx, I add a mime type to /etc/nginx/mime.types
. The following line tells Nginx that the mime type of files with the extension .jsonac
is application/activity+json
:\n
\napplication/activity+json jsonac;\n\n
\nIt also is required that the actor file can be discovered using webfinger. So, we create another JSON file (e.g. becasse-webfinger.json
) containing information related to the actor becasse@buron.coffee
:\n
{\n \"subject\": \"acct:becasse@buron.coffee\",\n \"aliases\": [\n \"https://buron.coffee/becasse\"\n ],\n \"links\": [\n {\n \"rel\": \"http://webfinger.net/rel/profile-page\",\n \"type\": \"text/html\",\n \"href\": \"https://buron.coffee\"\n },\n {\n \"rel\": \"self\",\n \"type\": \"application/activity+json\",\n \"href\": \"https://buron.coffee/becasse\"\n }\n ]\n}\n\n\n
\nThis file should be accessible with an URL of the form http://buron.coffee/.well-known/webfinger?resource=acct:becasse@buron.coffee. For example with Nginx we can use the following an route of the server for buron.coffee
:\n
\nlocation = /.well-known/webfinger {\n add_header 'Access-Control-Allow-Origin' '*'; \n root /var/www/buron.coffee/notes/;\n try_files /becasse-webfinger.json =404;\n}\n\n\n
\nThis instruction can be extended if you use several actors, see: https://willnorris.com/2014/07/webfinger-with-static-files-nginx\n
\n\nThe content negotiation is an optional configuration, which allows to mimic the behaviour of several fediverse application concerning their links: they are both used for serving HTML page and content of type application/activity+json
. For example, the link https://mastodon.social/@Gargron corresponds to a HTML page and an actor JSON document, which is accessible using the following command line:\n
curl -H \"Accept: application/activity+json\" https://mastodon.social/@Gargron\n
\n\nSo, I aim to have an IRI for each post (or actor) that will be the URL of the HTML file without its extension and requesting this IRI should respond the JSON activity streams object (or JSON actor) if the Accept
HTTP header is application/activity+json
and otherwise the HTML page.\n
\nIn Nginx, we first need to define a map from the accept value to the request file's extension (it can be added in /etc/nginx/nginx.conf
):\n
\n ## activitypub extention mapping\n\n map $http_accept $ac_ext{\n \"~application/activity\\+json\" \".jsonac\";\n default \".html\";\n} \n\n\n
\nFinally, we use the $ac_ext
value in an route of the server for buron.coffee
:\n
\nlocation / {\n root /var/www/buron.coffee/notes/;\n try_files $uri $uri$ac_ext $uri.html $uri/index.html =404;\n}\n\n
\nYou can find below the code of ox-activity-streams.el
:\n
(require 'ox-html)\n\n(defgroup org-export-activity-streams nil\n \"Options specific to activity streams export back-end.\"\n :tag \"Org activity streams\"\n :group 'org-export\n :version \"24.4\"\n :package-version '(Org . \"8.0\"))\n\n(defcustom org-activity-streams-actor-url \"https://mastodon.social/@Mastodon\"\n \"The URL of the actor.\"\n :group 'org-export-activity-streams\n :type 'string)\n\n(defcustom org-activity-streams-tag-name \"@Mastodon@mastodon.social\"\n \"The name of the actor.\"\n :group 'org-export-activity-streams\n :type 'string)\n\n(defcustom org-activity-streams-tag-url \"https://mastodon.social/@Mastodon\"\n \"The url of the actor.\"\n :group 'org-export-activity-streams\n :type 'string)\n\n(defcustom org-activity-streams-ext \"as.json\"\n \"The extension of the exported file.\"\n :group 'org-export-activity-streams\n :type 'string)\n\n;;; Define backend\n\n\n\n(org-export-define-derived-backend 'activity 'html\n :menu-entry\n '(?a \"Export to Activity Streams\"\n ((?A \"As JSON buffer\"\n (lambda (a s v b) (org-activity-streams-export-in-buffer a s v)))\n (?r \"As JSON file\" (lambda (a s v b) (org-activity-streams-export-to-json a s v)))\n (?o \"As JSON file and open\"\n (lambda (a s v b)\n (if a (org-activity-streams-export-to-json t s v)\n (org-open-file (org-activity-streams-export-to-json nil s v)))))))\n :options-alist\n '((:activity-streams-tag-name nil nil org-activity-streams-tag-name)\n (:activity-streams-tag-name nil nil org-activity-streams-tag-url)\n (:activity-streams-actor-url nil nil org-activity-streams-actor-url)\n (:activity-streams-extension nil nil org-activity-streams-ext))\n :translate-alist '((comment . (lambda (&rest args) \"\"))\n (template . org-activity-streams-template)))\n\n(defun org-activity-streams-template (contents info)\n \"Return complete document string after RSS conversion.\nCONTENTS is the transcoded contents string. INFO is a plist used\nas a communication channel.\"\n (let* ((title (org-html-plain-text\n (org-element-interpret-data (plist-get info :title)) info))\n (hl-pdir (plist-get info :publishing-directory))\n (actor-url (url-encode-url (plist-get info :activity-streams-actor-url)))\n (tag-url (url-encode-url (plist-get info :activity-streams-tag-url)))\n (tag-name (plist-get info :activity-streams-tag-name))\n (hl-home (plist-get info :html-link-home))\n (as-ext (plist-get info :activity-streams-extension))\n (input-file (plist-get info :input-file))\n (publink\n (concat\n (or hl-home hl-pdir)\n (file-name-sans-extension\n (file-relative-name input-file (plist-get info :root-directory)))))\n (pubdate (format-time-string \"%Y-%m-%dT%T%z\" \n (and input-file (file-attribute-modification-time\n (file-attributes input-file)))))\n ;; in the following, we could introduce a function for computing a description from the html content following this example:\n (description (or (plist-get info :description)\n (and (string-match \"<p>\\n*\\\\(.*?\\\\)\\n*</p>\" contents)\n (replace-regexp-in-string \"</?[^>]*>\" \"\"\n (match-string 1 contents))))))\n\n\n (s-replace \"[\" \"[\"\n (json-encode\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put\n (plist-put nil :@context \"https://www.w3.org/ns/activitystreams\")\n :type \"Article\")\n :id publink)\n :published pubdate)\n :url publink)\n :attributedTo actor-url)\n :to \"https://www.w3.org/ns/activitystreams#Public\")\n :name title)\n :summary description)\n :content contents)\n ;; insert null in the array because json-encode have a bug ? \n :tag (list nil\n (plist-put\n (plist-put\n (plist-put nil :type \"Mention\")\n :href tag-url)\n :name tag-name)))))))\n\n;;;###autoload\n(defun org-activity-streams-export-in-buffer (&optional async subtreep visible-only)\n \"Export current buffer to a json buffer.\n\nIf narrowing is active in the current buffer, only export its\nnarrowed part.\n\nIf a region is active, export that region.\n\nA non-nil optional argument ASYNC means the process should happen\nasynchronously. The resulting buffer should be accessible\nthrough the `org-export-stack' interface.\n\nWhen optional argument SUBTREEP is non-nil, export the sub-tree\nat point, extracting information from the headline properties\nfirst.\n\nWhen optional argument VISIBLE-ONLY is non-nil, don't export\ncontents of hidden elements.\n\nExport is done in a buffer named \\\"*Org Activity Streams Export*\\\", which will\nbe displayed when `org-export-show-temporary-export-buffer' is\nnon-nil.\"\n (interactive)\n (org-export-to-buffer 'activity \"*Org Activity Streams Export*\"\n async subtreep visible-only nil nil (lambda () (json-mode))))\n\n;;;###autoload\n(defun org-activity-streams-export-to-json (&optional async subtreep visible-only)\n \"Export current buffer to a JSON file.\n\nIf narrowing is active in the current buffer, only export its\nnarrowed part.\n\nIf a region is active, export that region.\n\nA non-nil optional argument ASYNC means the process should happen\nasynchronously. The resulting file should be accessible through\nthe `org-export-stack' interface.\n\nWhen optional argument SUBTREEP is non-nil, export the sub-tree\nat point, extracting information from the headline properties\nfirst.\n\nWhen optional argument VISIBLE-ONLY is non-nil, don't export\ncontents of hidden elements.\n\nReturn output file's name.\"\n (interactive)\n (let ((outfile (org-export-output-file-name\n (concat \".\" org-activity-streams-ext) subtreep)))\n (org-export-to-file 'activity outfile async subtreep visible-only)))\n\n;;;###autoload\n(defun org-activity-streams-publish-to-json (plist filename pub-dir)\n \"Publish an org file to a JSON file.\n\nFILENAME is the filename of the Org file to be published. PLIST\nis the property list for the given project. PUB-DIR is the\npublishing directory.\n\nReturn output file name.\"\n (let ((bf (get-file-buffer filename)))\n (if bf\n (with-current-buffer bf\n (write-file filename))\n (find-file filename)\n (write-file filename) (kill-buffer)))\n (let ((as-ext (plist-get plist :activity-streams-extension)))\n (org-publish-org-to\n 'activity filename (concat \".\" as-ext) plist pub-dir)))\n\n\n(provide 'ox-activity)\n\n
\nHere an example of configuration:\n
\n(require 'ox-activity-streams)\n\n(setq org-publish-project-alist\n '((\"notes-activity-streams\"\n :base-directory \"~/documents/notes/\"\n :root-directory \"~/documents/notes/\"\n :html-link-home \"https://buron.coffee/\"\n :activity-streams-extension \"jsonac\"\n :activity-streams-tag-name \"@LaBecasse@mastodon.social\"\n :activity-streams-tag-url \"https://mastodon.social/users/LaBecasse\"\n :activity-streams-actor-url \"https://buron.coffee/becasse\"\n :publishing-directory \"/var/www/buron.coffee/book/\"\n :publishing-function org-activity-streams-publish-to-json)))\n\n
\nox-activity-streams
is based on the HTML publish backend of org-mode, so it supports some of the HTML properties. Its specific properties are:\n
activity-streams-extension
the extension used for the outputted files,activity-streams-tag-name
and activity-streams-tag-url
specifies the name and the URL of the actor to add in copy when some reply to the publication,\nactivity-streams-actor-url
is the URL of the actor as configured above.\n
\nThe result for the current page have the following form and is accessible at https://buron.coffee/entity/ox_activity_streams.jsonac:\n
\n{\n \"@context\": \"https://www.w3.org/ns/activitystreams\",\n \"type\": \"Article\",\n \"id\": \"https://buron.coffee/entity/ox_activity_streams\",\n \"published\": \"2021-08-25T00:13:39+0100\",\n \"url\": \"https://buron.coffee/entity/ox_activity_streams\",\n \"attributedTo\": \"https://buron.coffee/becasse\",\n \"to\": \"https://www.w3.org/ns/activitystreams#Public\",\n \"name\": \"ox-activity-streams\",\n \"content\": \"--- published HTML ---\",\n \"tag\": [\n {\n \"type\": \"Mention\",\n \"href\": \"https://mastodon.social/users/LaBecasse\",\n \"name\": \"@LaBecasse@mastodon.social\"\n }\n ]\n}\n\n
\nAs you can see it defines an \"Article\", which is not the object type used by default by Mastodon being \"Note\". For this reason, only the title and the URL will be visible from Mastodon, the other fediverse applications may display it differently. An additional \"summary\" properties could have defined to summarize the content of the Article, for example by exporting in plain text the first line of the org file, but I don't know how to do this.\n
\nHere, I propose to add a form to the exported HTML pages, in order that people can interact (reply, boost, favorite) with the post from the fediverse. You can test it by using the button \"answer from fediverse\" at the end of this page.\nThe needed HTML code is:\n
\n<p>Interact from the fediverse with your username:</p>\n<form onsubmit=\"event.preventDefault();subcribeComponent(event)\">\n <input name=\"text\" type=\"email\" placeholder=\"user@instance\">\n <input value=\"answer from fediverse\" type=\"submit\">\n</form>\n\n
\nIt requires the following JS function to get the link of interaction from the username using webfinger:\n
\nfunction subcribeComponent(e) {\n // this is my way to IRI from page content, you have to adapt it to your case\n const uri = document.querySelector('.h-entry .u-url').href;\n\n const address = e.target.querySelector('input[name=text]').value;\n const [ username, hostname ] = address.split('@')\n\n const protocol = window.location.protocol\n\n // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5\n fetch(`${protocol}//${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`)\n .then(response => response.json())\n .then(data => {\n if (!data || Array.isArray(data.links) === false) {\n throw new Error('Not links in webfinger response')\n }\n\n const link = data.links.find(link => {\n return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe'\n })\n\n if (link !== null && link.template.includes('{uri}')) {\n return link.template.replace('{uri}', encodeURIComponent(uri))\n }\n\n throw new Error('No subscribe template in webfinger response')\n })\n .then(window.open)\n .catch(err => {\n alert('Cannot fetch information of this remote account:\\n ' +err)\n })\n}\n\n
\nWe can also use Nginx to store the activities received on actor inbox:\n
\n\n\nlocation = /becasse-inbox {\n access_log /var/log/nginx/buron.coffee/becasse-activities.log postdata;\n echo_read_request_body;\n\n root /var/www/buron.coffee/notes/;\n try_files /becasse-inbox.jsonac =404;\n}\n\n
\nBopwiki seems to be a promising alternative to org-roam and ox-activity-streams as a simpler way to create knowledge base following the slip box principle with links and backlinks. I discussed with its creator about the differences in this thread.\n
\n