To run this project, you will need openssl-devel
and rust
on your system.
Then, a working configuration for your http server and for the project will be necessary.
This is an example of configuration for NGINX (with certbot
for https-certificates)
server {
server_name example.tld; # managed by Certbot
root /var/www/example/public/;
location /inbox {
proxy_pass http://localhost:8080;
}
location /users {
proxy_pass http://localhost:8080;
}
location /.well-known/webfinger {
proxy_pass http://localhost:8080;
}
location / {
}
error_page 404 /404.html;
location = /40x.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/example-tld/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/example-tld/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
config.json
can be used as a starting point to configure the project.
.keys/privkey.pem
and .keys/publickey.pem
are used to sign post requests between instances.
I use the private key generated by certbot
(and do a static link). To get a public key:
openssl rsa -in /etc/letsencrypt/live/example/privkey.pem -outform PEM -pubout -out /var/www/example/chactivityserver/.keys/pubkey.pem
auto_follow_back
will automatically follow people following the instance.
This project mostly uses 3 standards:
There is mostly 3 different aspects. Profile discovery, announcing articles, retrieving articles. The first step is profile discovery.
Any chactivity server uses by default a single account (chef
) and the discovery is done via the WebFinger protocol.
To discover a profile, servers MUST do a request to example.tld/.well-known/webfinger?resource=acct:[email protected]
. This will return a JSON
response containing the link to the profile (example.tld/users/chef
)
example.tld/users/chef
is accepting GET requests with the header: Accept: application/activity+json
.
And is returning a JSON response containing the profile, links to the outbox, followers, inbox and linked inbox
(which is the same as the inbox, as there is only one user).
example.tld/users/chef/outbox
is accepting GET requests with the header: Accept: application/activity+json
.
The outbox contains all articles already written. So that any server can get all past interactions.
The outbox is divided in pages of 12 articles. A page can be accessed by passing page=1
to the request (or any other number).
Page=1 is the first page. The last page is announced in the outbox description (page=0
or no page parameter).
Finally, the outbox content is cached in the cache directory. This cache is automatically invalidated when a new
recipe in /content/recettes
is detected (we store the current number of articles and the last modification date in .cache/date_file.txt
).
However, this is not enough to have incoming articles on remote instances. The server MUST announce new articles by posting to followers inbox.
Sending events to other instances MUST include a HTTP-Signature. This signature is generated by:
- Using the keys provided in the configuration (a classic RSA key)
- It uses the
rsa-sha256
algorithm to sign requests. This is done via a fork ofhttp-sig
- This adds a new header to the signed requests like:
Signature: keyId="rsa-key-1",algorithm="hs2019",
created=1402170695, expires=1402170995,
headers="(request-target) (created) (expires)
host date digest content-length",
signature="Base64(RSA-SHA256(signing string))"
When a new article is detected, the cache is refreshed and articles are transformed into a JSON:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount"
}
],
"id": format!("https://{}/recettes/{}", self.config.domain, filename_without_extension),
"type": "Create",
"actor": format!("https://{}/users/{}", self.config.domain, self.config.user),
"published": published,
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
format!("https://{}/users/{}/followers", self.config.domain, self.config.user),
],
"object": {
"id": format!("https://{}/recettes/{}", self.config.domain, filename_without_extension),
"type": "Article",
"summary": null,
"inReplyTo": null,
"published": published,
"url": format!("https://{}/recettes/{}", self.config.domain, filename_without_extension),
"attributedTo": format!("https://{}/users/{}", self.config.domain, self.config.user),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
format!("https://{}/users/{}/followers", self.config.domain, self.config.user),
],
"sensitive": false,
"atomUri": format!("https://{}/recettes/{}", self.config.domain, filename_without_extension),
"content": markdown,
"name": match_title.get(1).map_or("Chalut!", |m| m.as_str()).to_owned(),
"mediaType": String::from("text/markdown"),
"attachment": attachments,
"tag": tags_value,
"license": self.config.license
}
}
attachments
: are the list of images in the article (checked in config.img_dir/recipe-name/
)
tags
are retrieved from the article if the list in the config is empty.
This JSON is sent to each followers inbox via signed requests. If two followers got a shared inbox, only one POST will be done.
Followers are cached in .cache/followers
and available via example.tld/users/chef/followers
.
A new follower is added to the list by receiving a Follow activity in the inbox. Then, an Accept activity is sent to the follower inbox.
Mechanism is described there.
Likes and boosts are cached in .cache/likes.json
and available via example.tld/users/chef/likes
with the following parameters:
- object the current pathname (e.g. /recettes/profiteroles-chocolat)
- wanted_type like or boost
This is shown in the website via a fetch()
request done in layouts/_default/single.html
It will be incoming articles automatically received when they will update their cache.
The content will appear when hugo
will be executed. For the format, cf Posting on the Fediverse
.
It's possible to convert toots to articles in cha-cuit. Toots are Note
objects in the ActivityPub protocol.
The following format will be used:
---
title: Note["summary"]
date: SystemTime.now
tags: [Note["tags"]]
author: Note["actor"]
thumbnail: (Optional)Note["attachment"][0]
---
Note["content"]
(Optional) Gallery of Notes["attachment"]
Note["id"]
So, a CW will be used as a title, the gallery will be up to 4 images and #chacuit must be specified.
e.g.:
Every incoming POST request in the inbox MUST include a signature.
The mechanism is fully documented there and the code uses a fork of PassFort/http-signatures#18. Some other steps are also documented here.
Basically, 3 steps are done:
- The
Digest
header is verified: base64(sha256(body)). - The
Date
should be from a request less than 12 hours old. - The
Signature
header is verified:
e.g. Signature: keyId="https://my-example.com/actor#main-key",headers="(request-target) host date digest",signature="Y2FiYW...IxNGRiZDk4ZA=="
keyId
will be fetched and we will retrieve result['publicKey']['publicKeyPem']
.
Then we will build the string to verify (e.g. signedString):
(request-target): post /users/chef/inbox
host: cha-cu.it
date: Fri, 13 Jan 2023 00:24:25 GMT
digest: SHA-256=0ipv+6eNelzhIkYlUeJkSVz+mAuFPOlABHgWP4UdY1Y=
content-type: application/activity+json
that will be something like: PublicKey.verify(signedString, base64.decode(Signature["signature"]))
.
There is 2 ways to get incoming articles from the Fediverse:
- Following users. This is configurable via
config.json
(auto_follow_back
andmanual_follow_list
which can contains actors that will be automatically followed during cache invalidation) - Following other instances via
instances.txt
.
New comments on article are not shown (at least for now), but if somebody answer to a post (e.g. via
commenting it on mastodon), the comment will be saved in .cache/mentions
. So that you can do
whatever you want with the comments.
To block a user or an instance, simply add it to the file configured in block_list
in the config.
At the next update, they will be added to the block list, all previous content hidden, new content
will be discarded and the blocked user will be un-followed.