├── .gitignore ├── LICENSE ├── README.md ├── config.example.sh ├── context.json ├── ctl.sh ├── daemon.sh ├── events.sh ├── index.html ├── instanceicon.png ├── lib ├── act.sh ├── db.sh ├── http.sh ├── httpd.sh ├── inbox.sh ├── json.sh ├── main.sh ├── meta.sh ├── users.sh └── util.sh └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | db/ 2 | config.sh 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # Non-White-Heterosexual-Male License 2 | 3 | If you are not a white heterosexual male you are permitted to copy, sell and use this work in any manner you choose without need to include any attribution you do not see fit. You are asked as a courtesy to retain this license in any derivatives but you are not required. If you are a white heterosexual male you are provided the same permissions (reuse, modification, resale) but are required to include this license in any documentation and any public facing derivative. You are also required to include attribution to the original author or to an author responsible for redistribution of a derivative. 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KiKi 2 | 3 | a super-tiny multithreaded activitypub compliant microblogging platform, written entirely in bash. 4 | 5 | ## Why? 6 | 7 | why not! also i wanted to learn how activitypub works 8 | 9 | ## You can write servers in bash? 10 | Yes! although there's no support for LISTEN in bash, you can hook up the script to netcat to respond to http requests. see `./daemon.sh` and `./lib/httpd.sh` 11 | 12 | ## Features 13 | 14 | | Feature | Supported? | 15 | | -------------------- | -------------------- | 16 | | Notes (Pub/Sub) | :white_check_mark: | 17 | | Follows | :white_check_mark: | 18 | | mia:Bite | :white_check_mark: | 19 | | Likes | :white_check_mark: | 20 | | Mastodon API | :x: | 21 | | Frontend | :x: | 22 | | Announce | :x: | 23 | | Emoji Reactions | :x: | 24 | 25 | # Installation 26 | you need `openssl`, `jq`, `curl`, and `ncat` installed for this 27 | ``` 28 | git clone https://github.com/velzie/kiki 29 | cd kiki 30 | # edit config.sh to your liking 31 | ./ctl.sh db_init 32 | 33 | # now you need to create the instance actor, used to send signed AP requests. You can fill in anything, just make sure the actor id is the same as what you put in config.sh as INSTANCEACTOR 34 | ./ctl.sh useradd 35 | ``` 36 | 37 | # Usage 38 | 39 | This server has no frontend, nor does it implement any client-to-server APIs. All interactions must happen through editing the "database" or with ctl.sh 40 | 41 | - `./daemon.sh` to start the server 42 | - `./ctl.sh useradd` creates a user, make sure to add "pfp.png" and "banner.png" to `db/users//` 43 | - `./ctl.sh act post "content"` will post a note and send it to followers. It will not currently post Announce activities to foreign instances. 44 | - `./ctl.sh act follow ` 45 | 46 | 47 | Since it's so light, you might want to simply use it to run bots. If you do expose this to the web, keep in mind that bash is the most insecure out of all languages and run it as an isolated user. 48 | 49 | 50 | thank you to shittykopper and harper/blueb for helping me understand the AP protocol 51 | -------------------------------------------------------------------------------- /config.example.sh: -------------------------------------------------------------------------------- 1 | DOMAIN=kiki.velzie.rip 2 | VERSION=2024.0.1 3 | NODENAME=kiki.sh 4 | NODEDESCRIPTION="fedi server written in bash. why did i do this" 5 | 6 | INSTANCEACTOR=shuid 7 | 8 | MAINTAINERNAME=velzie 9 | MAINTAINEREMAIL=velzie@velzie.rip 10 | 11 | # will help with federation if on but is a bit weird 12 | FOLLOWBACK=0 13 | -------------------------------------------------------------------------------- /context.json: -------------------------------------------------------------------------------- 1 | [ 2 | "https://www.w3.org/ns/activitystreams", 3 | "https://w3id.org/security/v1", 4 | { 5 | "Key": "sec:Key", 6 | "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", 7 | "sensitive": "as:sensitive", 8 | "Hashtag": "as:Hashtag", 9 | "quoteUrl": "as:quoteUrl", 10 | "fedibird": "http://fedibird.com/ns#", 11 | "quoteUri": "fedibird:quoteUri", 12 | "toot": "http://joinmastodon.org/ns#", 13 | "Emoji": "toot:Emoji", 14 | "featured": "toot:featured", 15 | "discoverable": "toot:discoverable", 16 | "schema": "http://schema.org#", 17 | "PropertyValue": "schema:PropertyValue", 18 | "value": "schema:value", 19 | "misskey": "https://misskey-hub.net/ns#", 20 | "_misskey_content": "misskey:_misskey_content", 21 | "_misskey_quote": "misskey:_misskey_quote", 22 | "_misskey_reaction": "misskey:_misskey_reaction", 23 | "_misskey_votes": "misskey:_misskey_votes", 24 | "_misskey_summary": "misskey:_misskey_summary", 25 | "isCat": "misskey:isCat", 26 | "firefish": "https://joinfirefish.org/ns#", 27 | "speakAsCat": "firefish:speakAsCat", 28 | "sharkey": "https://joinsharkey.org/ns#", 29 | "backgroundUrl": "sharkey:backgroundUrl", 30 | "listenbrainz": "sharkey:listenbrainz", 31 | "vcard": "http://www.w3.org/2006/vcard/ns#", 32 | "Bite": "https://ns.mia.jetzt/as#Bite" 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /ctl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . ./lib/main.sh 4 | 5 | 6 | db_init() { 7 | mkdir -p "$DB_USERS" 8 | mkdir -p "$DB_OBJECTS" 9 | } 10 | 11 | useradd() { 12 | read -r -p "Username: " username 13 | read -r -p "UID: " uid 14 | read -r -p "Name: " name 15 | read -r -p "Summary: " summary 16 | 17 | 18 | dir="$DB_USERS/$uid" 19 | mkdir -p "$dir" 20 | 21 | openssl genpkey -algorithm RSA -out "$dir/privkey.pem" -pkeyopt rsa_keygen_bits:2048 22 | openssl rsa -pubout -in "$dir/privkey.pem" -out "$dir/pubkey.pem" 23 | 24 | 25 | cat >"$dir/info" <"$dir/followers" 33 | :>"$dir/following" 34 | } 35 | 36 | act() { 37 | cmd=$1 38 | shift 39 | 40 | "act_$cmd" "$@" 41 | } 42 | 43 | "$@" 44 | -------------------------------------------------------------------------------- /daemon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . ./lib/main.sh 4 | PORT=${PORT:-4206} 5 | 6 | 7 | # bash does not have any mechanism to LISTEN on a port. nmap's netcat is the best non-cheaty solution 8 | 9 | # ncat -k with -c will run the command for every request. this means every time main runs it's in a different process 10 | # inside the shell, stdin will be the request, and stdout will be the response 11 | # we want to keep stdout for debugging, so we redirect it to fd 4, and use fd 3 for the response 12 | # fd 4 of ncat then goes back to stdout so everything works 13 | 14 | ncat -k -l -p "$PORT" -c "bash -c '. ./lib/main.sh; ( do_routes>&4 ) 3>&1'" 4>&1 15 | -------------------------------------------------------------------------------- /events.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # this file will get run when users interact with posts from this instance. you might want to customize this file 4 | 5 | 6 | . ./lib/main.sh 7 | 8 | 9 | if [[ $1 == "tag" ]]; then 10 | uid=$2 11 | noteid=$3 12 | tagger=$4 13 | 14 | 15 | actorlookup "$tagger" 16 | 17 | 18 | ./ctl.sh act post "$uid" "@$setAcct :3" "$noteid" 19 | fi 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | kiki 6 | 7 | 8 | 9 | 10 | 11 |

kiki.sh - a terrible, horrible experiment

12 | 13 |

i have built a fedi server in bash. this request is being served by 14 | bash. you can federate with this server and it 15 | will work (sometimes)

16 |
17 | 18 |

Accounts

19 |
    20 |
  • 21 | @sh2@kiki.velzie.rip - testing accounts 22 |
  • 23 |
  • 24 | @mastodon@kiki.velzie.rip - shitposts 25 |
  • 26 |
  • 27 | @colonthree@kiki.velzie.rip - posts :3 every hour 28 |
  • 29 |
30 |
31 |

this instance has no frontend - you have to look at the accounts on another instance

32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /instanceicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velzie/kiki/b00251351b468b7b9d4a8d97c95382c68a1db542/instanceicon.png -------------------------------------------------------------------------------- /lib/act.sh: -------------------------------------------------------------------------------- 1 | 2 | 3 | act_follow() { 4 | uid=$1 5 | actor=$2 6 | 7 | followid=$(uuid) 8 | 9 | if ! actorlookup "$actor"; then 10 | dbg "act_follow: no such actor $actor!" 11 | return 12 | fi 13 | 14 | 15 | http_post_json_signed "$setInbox" "$uid"\ 16 | %@context "$CONTEXT"\ 17 | .id "$DOMAINURL/follows/wtf"\ 18 | .type Follow\ 19 | .actor "$DOMAINURL/users/$uid"\ 20 | .object "$actor" 21 | } 22 | 23 | act_bite() { 24 | uid=$1 25 | actor=$2 26 | note=${3-$2} 27 | 28 | if ! actorlookup "$actor"; then 29 | dbg "act_bite: no such actor $actor!" 30 | return 31 | fi 32 | 33 | biteid=$(uuid) 34 | http_post_json_signed "$setInbox" "$uid"\ 35 | %@context "$CONTEXT"\ 36 | .id "$DOMAINURL/bites/$biteid"\ 37 | .type Bite\ 38 | .actor "$DOMAINURL/users/$uid"\ 39 | .target "$note" 40 | } 41 | 42 | act_accept() { 43 | uid=$1 44 | actor=$2 45 | followid=$3 46 | 47 | if ! actorlookup "$actor"; then 48 | dbg "act_accept: no such actor $actor!" 49 | return 50 | fi 51 | 52 | http_post_json_signed "$setInbox" "$uid"\ 53 | %@context "$CONTEXT"\ 54 | .type Accept\ 55 | .id "$DOMAINURL/accepts/$(uuid)"\ 56 | .actor "$DOMAINURL/users/$uid"\ 57 | !object 4\ 58 | .id "$followid"\ 59 | .actor "$actor"\ 60 | .object "$actor"\ 61 | .type Follow 62 | } 63 | 64 | 65 | act_post() { 66 | uid=$1 67 | content=$2 68 | inreplyto=$3 69 | to=$4 70 | 71 | # first create the note 72 | noteid=$(uuid) 73 | 74 | mkdir -p "$DB_OBJECTS/$noteid" 75 | echo "Note" > "$DB_OBJECTS/$noteid/type" 76 | 77 | json=$(json\ 78 | ._misskey_content "$content"\ 79 | .content "

$content

"\ 80 | .sensitive false\ 81 | .published "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"\ 82 | !source 2\ 83 | .content "$content"\ 84 | .mediaType "text/x.misskeymarkdown"\ 85 | @to 2\ 86 | . "$DOMAINURL/followers/$uid"\ 87 | . "$to"\ 88 | @cc 1\ 89 | . "https://www.w3.org/ns/activitystreams#Public"\ 90 | .attributedTo "$DOMAINURL/users/$uid"\ 91 | @tag 0\ 92 | .id "$DOMAINURL/notes/$noteid"\ 93 | .type Note) 94 | 95 | 96 | if [ -n "$inreplyto" ]; then 97 | json=$(jq --arg inreplyto "$inreplyto" '.inReplyTo = $inreplyto' <<< "$json") 98 | fi 99 | 100 | echosafe "$json" > "$DB_OBJECTS/$noteid/object.json" 101 | 102 | 103 | # now add it to the user's outbox 104 | echo "$noteid" >> "$DB_USERS/$uid/outbox" 105 | 106 | # then send it to our loyal followers 107 | 108 | while read -r follower; do 109 | actorlookup "$follower" 110 | http_post_json_signed "$setInbox" "$uid"\ 111 | %@context "$CONTEXT"\ 112 | .id "$DOMAINURL/notes/$noteid"\ 113 | .type Create\ 114 | .actor "$DOMAINURL/users/$uid"\ 115 | %object "$(< "$DB_OBJECTS/$noteid/object.json")" 116 | done < "$DB_USERS/$uid/followers" 117 | 118 | } 119 | -------------------------------------------------------------------------------- /lib/db.sh: -------------------------------------------------------------------------------- 1 | 2 | DB=./db 3 | DB_USERS=$DB/users 4 | 5 | # remote objects and actors 6 | DB_OBJECTS=$DB/objects 7 | DB_ACTORS=$DB/actors 8 | 9 | 10 | finduser() { 11 | username=$1 12 | for actor in $DB_USERS/*; do 13 | source "$actor/info" 14 | if [ "$setUsername" = "$username" ]; then 15 | echo "$setUid" 16 | return 17 | fi 18 | done 19 | } 20 | 21 | userlookup() { 22 | uid=$1 23 | if [ -f "$DB_USERS/$uid/info" ]; then 24 | source "$DB_USERS/$uid/info" 25 | return 0 26 | else 27 | return 1 28 | fi 29 | } 30 | 31 | actorlookup(){ 32 | local actorurl=$1 33 | local actor 34 | local json 35 | local ourid 36 | 37 | 38 | local found=0 39 | 40 | files=$(shopt -s nullglob dotglob; echo $DB_ACTORS/*) 41 | if (( ${#files} > 0 )); then 42 | for actor in $DB_ACTORS/*; do 43 | if [ "$(<"$actor/url" )" = "$actorurl" ]; then 44 | ourid=$(basename "$actor") 45 | found=1 46 | json=$(<"$actor/actor.json") 47 | fi 48 | done 49 | fi 50 | 51 | if [ "$found" = 0 ]; then 52 | json=$(http_get_signed "$actorurl") 53 | if ! jsonvalid <<<"$json"; then 54 | dbg "$json" 55 | return 1 56 | fi 57 | 58 | ourid=$(uuid) 59 | 60 | mkdir -p "$DB_ACTORS/$ourid" 61 | 62 | echosafe "$json" > "$DB_ACTORS/$ourid/actor.json" 63 | echosafe "$actorurl" > "$DB_ACTORS/$ourid/url" 64 | fi 65 | 66 | 67 | 68 | setOurId="$ourid" 69 | setInbox=$(jq -r '.inbox' <<< "$json") 70 | setOutbox=$(jq -r '.outbox' <<< "$json") 71 | setUrl=$(jq -r '.id' <<< "$json") 72 | 73 | local url=$setUrl 74 | url=${url#*//} 75 | setDomain=${url%%/*} 76 | 77 | setAcct=$(jq -r '.preferredUsername' <<< "$json")@$setDomain 78 | 79 | } 80 | -------------------------------------------------------------------------------- /lib/http.sh: -------------------------------------------------------------------------------- 1 | # inboxUrl: /inbox or /users/uid/inbox 2 | # uid: the uid of the sending user 3 | http_post_signed() { 4 | url=$1 5 | uid=$2 6 | toSign=$3 7 | 8 | protocol=${url%%:*} 9 | url=${url#*://} 10 | host=${url%%/*} 11 | url=${url#*/} 12 | pathname=/${url%%\?*} 13 | 14 | if [ "$protocol" != "https" ]; then 15 | dbg "attempting to send to non-https url" 16 | fi 17 | 18 | 19 | if ! userlookup "$uid"; then 20 | dbg "http_post_signed: no such user $uid!" 21 | return 22 | fi 23 | 24 | 25 | # create b64 digest 26 | digest=$(openssl dgst -sha256 -binary <(echosafe "$toSign") | openssl enc -base64) 27 | 28 | 29 | # sendDate needs to be rfc2616 30 | sendDate=$(date -u +"%a, %d %b %Y %T GMT") 31 | 32 | 33 | 34 | signed=$(openssl dgst -sha256 -sign "$DB_USERS/$uid/privkey.pem" <( 35 | echo -en "(request-target): post ${pathname}\nhost: ${host}\ndate: ${sendDate}\ndigest: SHA-256=${digest}" 36 | ) | openssl base64 -A) 37 | 38 | header="keyId=\"$DOMAINURL/users/$uid#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"$signed\"" 39 | 40 | 41 | echo "Sending to $protocol://$host$pathname" 42 | 43 | 44 | curl --fail -X POST\ 45 | -H "Content-Type: application/activity+json"\ 46 | -H "User-Agent: kiki.sh/$VERSION $DOMAINURL"\ 47 | -H "Accept: application/activity+json"\ 48 | -H "Algorithm: rsa-sha256"\ 49 | -H "Host: $host"\ 50 | -H "Date: $sendDate"\ 51 | -H "Digest: SHA-256=$digest"\ 52 | -H "Signature: $header"\ 53 | -d "$toSign"\ 54 | "$protocol://$host$pathname" 55 | } 56 | 57 | http_get_signed() { 58 | url=$1 59 | uid=${2:-$INSTANCEACTOR} 60 | 61 | protocol=${url%%:*} 62 | url=${url#*://} 63 | host=${url%%/*} 64 | url=${url#*/} 65 | pathname=/${url%%\?*} 66 | 67 | if [ "$protocol" != "https" ]; then 68 | dbg "attempting to send to non-https url" 69 | fi 70 | 71 | 72 | if ! userlookup "$uid"; then 73 | dbg "http_get_signed: no such user $uid!" 74 | return 75 | fi 76 | 77 | sendDate=$(date -u +"%a, %d %b %Y %T GMT") 78 | 79 | 80 | signed=$(openssl dgst -sha256 -sign "$DB_USERS/$uid/privkey.pem" <( 81 | echo -en "(request-target): get ${pathname}\nhost: ${host}\ndate: ${sendDate}" 82 | ) | openssl base64 -A) 83 | 84 | header="keyId=\"$DOMAINURL/users/$uid#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"$signed\"" 85 | 86 | curl --fail -sL\ 87 | -H "Content-Type: application/activity+json"\ 88 | -H "User-Agent: kiki.sh/$VERSION $DOMAINURL"\ 89 | -H "accept: application/activity+json"\ 90 | -H "Algorithm: rsa-sha256"\ 91 | -H "host: $host"\ 92 | -H "date: $sendDate"\ 93 | -H "signature: $header"\ 94 | "$protocol://$host$pathname" 95 | 96 | return 1 97 | } 98 | 99 | verify_signature(){ 100 | pubpath=$1 101 | signature=$2 102 | 103 | openssl dgst -sha256 -verify "$pubpath" -signature <(openssl enc -base64 -d <<<"$signature") body 104 | } 105 | 106 | http_post_json_signed(){ 107 | url=$1 108 | shift 109 | uid=$1 110 | shift 111 | 112 | json=$(json "$@") 113 | 114 | http_post_signed "$url" "$uid" "$json" 115 | } 116 | -------------------------------------------------------------------------------- /lib/httpd.sh: -------------------------------------------------------------------------------- 1 | declare -A G_headers 2 | declare -A G_search 3 | 4 | httpd_init(){ 5 | HTTPD_routes_method=() 6 | HTTPD_routes_path=() 7 | HTTPD_routes_callback=() 8 | } 9 | 10 | httpd_route(){ 11 | method=$1 12 | path=$2 13 | callback=$3 14 | 15 | HTTPD_routes_method+=("$method") 16 | HTTPD_routes_path+=("$path") 17 | HTTPD_routes_callback+=("$callback") 18 | } 19 | 20 | httpd_handle() { 21 | read -r line 22 | 23 | local request=($line) 24 | 25 | 26 | httpd_clear 27 | 28 | while read -r line; do 29 | if [ "$line" = "$(echo -en "0d0a" | xxd -r -p)" ]; then 30 | break 31 | fi 32 | local line=${line//$'\r'/} 33 | local key=${line%: *} 34 | local value=${line#*: } 35 | G_headers[${key,,}]=$value 36 | done 37 | 38 | 39 | 40 | # this one is global 41 | path=${request[1]} 42 | 43 | 44 | if [[ "$path" == *"?"* ]]; then 45 | path=${path%%\?*} 46 | local query=${request[1]#*\?} 47 | 48 | while read -r -d "&" fragment; do 49 | key=${fragment%=*} 50 | value=${fragment#*=} 51 | G_search[$key]=$(urldecode "$value") 52 | done <<< "$query&" 53 | fi 54 | 55 | if [[ "${G_headers[user-agent]}" == *"Friendica"* ]]; then 56 | # ermmm.. don't want to turn my timeline into an old folks home.. sorry! 57 | exit 58 | fi 59 | 60 | echo "${request[@]}" 61 | echo "${G_headers[user-agent]}" 62 | echo "--------" 63 | for key in "${!G_search[@]}"; do 64 | echo "Search: $key ${G_search[$key]}" 65 | done 66 | 67 | 68 | 69 | for i in "${!HTTPD_routes_method[@]}"; do 70 | 71 | path="$(urldecode "$path")" 72 | #shellcheck disable=SC2053 # the path is an expression 73 | if [ "${HTTPD_routes_method[$i]}" = "${request[0]}" ] && [[ "$path" = ${HTTPD_routes_path[$i]} ]]; then 74 | eval "${HTTPD_routes_callback[$i]}" 75 | return 76 | fi 77 | done 78 | 79 | httpd_clear 80 | httpd_send 404 "Not Found" 81 | } 82 | 83 | 84 | httpd_clear() { 85 | G_headers=() 86 | G_search=() 87 | } 88 | 89 | httpd_header() { 90 | key=$1 91 | value=$2 92 | G_headers[$key]=$value 93 | } 94 | 95 | httpd_send() { 96 | status=$1 97 | body=$2 98 | length=$(echosafe "$body" | wc -c) 99 | 100 | { 101 | echo -en "HTTP/1.1 $status OK\r\n" 102 | echo -en "Content-Length: $length\r\n" 103 | echo -en "Content-Type: text/plain\r\n" 104 | for key in "${!G_headers[@]}"; do 105 | echo -en "$key: ${G_headers[$key]}\r\n" 106 | done 107 | echo -en "\r\n" 108 | echosafe "$body" 109 | } >&3 110 | } 111 | 112 | httpd_json() { 113 | status=$1 114 | shift 115 | 116 | httpd_send "$status" "$(json "$@")" 117 | } 118 | 119 | 120 | 121 | httpd_sendfile() { 122 | status=$1 123 | file=$2 124 | 125 | if [ ! -f "$file" ]; then 126 | dbg "warn: sendfile: file not found: $file" 127 | httpd_clear 128 | httpd_send 404 "Not Found" 129 | return 130 | fi 131 | 132 | { 133 | echo -en "HTTP/1.1 $status OK\r\n" 134 | echo -en "Content-Length: $(stat -c %s "$file")\r\n" 135 | echo -en "Content-Type: $(file -b --mime-type "$file")\r\n" 136 | for key in "${!G_headers[@]}"; do 137 | echo -en "$key: ${G_headers[$key]}\r\n" 138 | done 139 | echo -en "\r\n" 140 | cat "$file" 141 | } >&3 142 | } 143 | 144 | 145 | httpd_read() { 146 | length=${G_headers[content-length]} 147 | length=${length:-0} 148 | head -c "$length" 149 | } 150 | -------------------------------------------------------------------------------- /lib/inbox.sh: -------------------------------------------------------------------------------- 1 | 2 | add_object() { 3 | object=$1 4 | ourid=$(uuid) 5 | mkdir -p "$DB_OBJECTS/$ourid" 6 | 7 | type=$(jq -r '.type' <<< "$object") 8 | 9 | echosafe "$type" > "$DB_OBJECTS/$ourid/type" 10 | echosafe "$object" > "$DB_OBJECTS/$ourid/object.json" 11 | 12 | echo "$ourid" 13 | } 14 | 15 | 16 | notelookup(){ 17 | local noteid=$1 18 | 19 | if ! [ -d "$DB_OBJECTS/$noteid/" ]; then 20 | dbg "notelookup: no such note $noteid!" 21 | return 1 22 | fi 23 | 24 | setType=$(<"$DB_OBJECTS/$noteid/type") 25 | setJson=$(<"$DB_OBJECTS/$noteid/object.json") 26 | setOwner=$(jq -r '.attributedTo' <<< "$setJson") 27 | setContent=$(jq -r '.content' <<< "$setJson") 28 | } 29 | 30 | req_note(){ 31 | id=${path#*notes/} 32 | 33 | 34 | if ! notelookup "$id"; then 35 | httpd_clear 36 | httpd_send 404 "no such note!" 37 | return 38 | fi 39 | 40 | 41 | httpd_clear 42 | httpd_header "Content-Type" "application/activity+json" 43 | 44 | # add context to the note json 45 | setJson=$(echosafe "$setJson" | jq '.["@context"] = $context' --argjson context "$CONTEXT") 46 | 47 | httpd_send 200 "$setJson" 48 | } 49 | 50 | 51 | 52 | in_act_follow() { 53 | local json=$1 54 | local followid 55 | local actor 56 | local object 57 | local uid 58 | 59 | 60 | echo "INBOX FOLLOW $json" 61 | 62 | followid=$(jq -r '.id' <<< "$json") 63 | actor=$(jq -r '.actor' <<< "$json") 64 | object=$(jq -r '.object' <<< "$json") 65 | # actor is the remote actor, object is our actor 66 | 67 | uid=${object#*users/} 68 | 69 | if ! userlookup "$uid"; then 70 | httpd_clear 71 | httpd_send 404 "no such user!" 72 | return 73 | fi 74 | 75 | echo "FOLLOW REQUEST: $actor -> $object" 76 | echo "FOLLOW ID: $followid" 77 | 78 | actorlookup "$actor" 79 | 80 | act_accept "$uid" "$actor" "$followid" 81 | 82 | echo "$actor" >> "$DB_USERS/$uid/followers" 83 | 84 | 85 | if [ -n "$FOLLOWBACK" ]; then 86 | act_follow "$uid" "$actor" 87 | fi 88 | } 89 | 90 | req_user_inbox() { 91 | uid=${path#*inbox/} 92 | 93 | json=$(httpd_read) 94 | type=$(jq -r '.type' <<< "$json") 95 | 96 | inbox 97 | } 98 | 99 | req_user_outbox() { 100 | uid=$1 101 | echo "Outbox: $uid" 102 | 103 | 104 | if ! userlookup "$uid"; then 105 | httpd_clear 106 | httpd_send 404 "no such user!" 107 | return 108 | fi 109 | 110 | numactivities=$(find "$DB_USERS/$uid/activities" -type f | wc -l) 111 | 112 | httpd_clear 113 | httpd_header "Content-Type" "application/activity+json" 114 | httpd_json 200\ 115 | %@context "$CONTEXT"\ 116 | .id "$DOMAINURL/outbox/$uid"\ 117 | .type OrderedCollection\ 118 | %totalItems $numactivities\ 119 | .first "$DOMAINURL/outbox/$uid?page=true"\ 120 | .last "$DOMAINURL/outbox/$uid?page=true?since_id=0"\ 121 | 122 | 123 | } 124 | 125 | 126 | req_ap_inbox() { 127 | json=$(httpd_read) 128 | type=$(jq -r '.type' <<< "$json") 129 | 130 | inbox 131 | } 132 | 133 | inbox() { 134 | 135 | if [[ "$type" = "Create" ]]; then 136 | add_object "$(jq -r '.object' <<< "$json")" 137 | noteurl=$(jq -r '.object.id' <<< "$json") 138 | 139 | attributedTo=$(jq -r '.object.attributedTo' <<< "$json") 140 | 141 | tags=$(jq -r '.object.tag | map (.href) | join (" ")' <<< "$json") 142 | for tag in $tags; do 143 | if [[ "$tag" = "$DOMAINURL/users"* ]]; then 144 | uid=${tag#*users/} 145 | echo "$uid was tagged!!" 146 | ./events.sh tag "$uid" "$noteurl" "$attributedTo" 147 | fi 148 | done 149 | 150 | elif [[ "$type" = "Follow" ]]; then 151 | in_act_follow "$json" 152 | elif [[ "$type" = "Like" ]]; then 153 | object=$(jq -r '.object' <<< "$json") 154 | actor=$(jq -r '.actor' <<< "$json") 155 | 156 | noteid=${object#*notes/} 157 | echo "LIKE $noteid" 158 | echosafe "$actor" >> "$DB_OBJECTS/$noteid/likes" 159 | elif [[ "$type" = "Accept" ]]; then 160 | : 161 | elif [[ "$type" = "Delete" ]]; then 162 | # don't care. spams the logs 163 | : 164 | else 165 | echo "UNKNOWN ACTIVITY: $type" 166 | echo "$json" 167 | fi 168 | 169 | 170 | httpd_clear 171 | httpd_send 200 172 | } 173 | -------------------------------------------------------------------------------- /lib/json.sh: -------------------------------------------------------------------------------- 1 | #shellcheck shell=bash 2 | 3 | 4 | dbg() { 5 | echo "$@" >&2 6 | } 7 | 8 | # .: string 9 | # %: json 10 | # @: array 11 | 12 | 13 | json() { 14 | _json 99999 "$@" 15 | } 16 | 17 | _json() { 18 | local count=$1 19 | shift 20 | 21 | local command=(-r -n) 22 | local shiftby=0 23 | 24 | local j=0 25 | while [ "$j" -lt "$count" ]; do 26 | j=$((j+1)) 27 | 28 | 29 | 30 | 31 | type=${1:0:1} 32 | name=${1:1} 33 | shift 34 | shiftby=$((shiftby+1)) 35 | 36 | if [ "$type" = "." ]; then 37 | value=$1 38 | shift 39 | shiftby=$((shiftby+1)) 40 | 41 | command+=("--arg" "$name" "$value") 42 | elif [ "$type" = "%" ]; then 43 | value=$1 44 | shift 45 | shiftby=$((shiftby+1)) 46 | 47 | command+=("--argjson" "$name" "$value") 48 | elif [ "$type" = "@" ]; then 49 | arrlen=$1 50 | shift 51 | shiftby=$((shiftby+1)) 52 | 53 | _jsonr "$arrlen" "$@" 54 | _shiftby=$? 55 | 56 | for ((i=0; i<_shiftby; i++)); do 57 | shift 58 | shiftby=$((shiftby+1)) 59 | done 60 | 61 | command+=("--argjson" "$name" "$out") 62 | 63 | elif [ "$type" = "!" ]; then 64 | local numfields=$1 65 | shift 66 | 67 | shiftby=$((shiftby+1)) 68 | 69 | 70 | out=$(_json "$numfields" "$@") 71 | 72 | _shiftby=$? 73 | 74 | local k 75 | for ((k=0; k<_shiftby; k++)); do 76 | shift 77 | shiftby=$((shiftby+1)) 78 | done 79 | 80 | command+=("--argjson" "$name" "$out") 81 | else 82 | dbg "Invalid type: $type" 83 | return 1 84 | fi 85 | 86 | if [ "$#" -eq 0 ]; then 87 | break 88 | fi 89 | done 90 | 91 | command+=('$ARGS.named') 92 | 93 | # dbg "${command[@]}" 94 | 95 | jq "${command[@]}" 96 | 97 | return $shiftby 98 | } 99 | 100 | 101 | _jsonr() { 102 | local count=$1 103 | shift 104 | 105 | local shiftby=0 106 | out="[" 107 | 108 | 109 | for ((i=0; i 33 | 34 | 35 | 36 | EOF 37 | )" 38 | } 39 | 40 | req_hostmeta_json() { 41 | httpd_clear 42 | httpd_header "Content-Type" "application/json" 43 | httpd_json 200\ 44 | @links 1\ 45 | ! 3\ 46 | .rel "lrdd"\ 47 | .type "application/json"\ 48 | .template "$DOMAINURL/.well-known/webfinger?resource={uri}" 49 | } 50 | 51 | req_webfinger() { 52 | resource=${G_search[resource]} 53 | echo "Webfinger: $resource" 54 | 55 | 56 | if [[ "$resource" = "acct:"* ]]; then 57 | account=${resource#*acct:} 58 | 59 | username=${account%@*} 60 | 61 | uid=$(finduser "$username") 62 | else 63 | # iceshrimp.net resource 64 | uid=${resource#*users/} 65 | fi 66 | 67 | if ! userlookup "$uid"; then 68 | httpd_clear 69 | httpd_send 404 "no such user!" 70 | return 71 | fi 72 | 73 | 74 | 75 | 76 | httpd_clear 77 | httpd_header "Content-Type" "application/jrd+json" 78 | httpd_json 200\ 79 | .subject "acct:$setUsername@$DOMAIN"\ 80 | @links 3\ 81 | ! 3\ 82 | .rel self\ 83 | .type "application/activity+json"\ 84 | .href "$DOMAINURL/users/$uid"\ 85 | ! 3\ 86 | .rel "http://webfinger.net/rel/profile-page"\ 87 | .type "text/html"\ 88 | .href "$DOMAINURL/@$setUsername"\ 89 | ! 2\ 90 | .rel "http://ostatus.org/schema/1.0/subscribe"\ 91 | .template "$DOMAINURL/authorize-follow?acct={uri}" 92 | 93 | } 94 | 95 | req_nodeinfo() { 96 | httpd_clear 97 | httpd_header "Content-Type" "application/json" 98 | httpd_json 200\ 99 | @links 2\ 100 | ! 2\ 101 | .rel "http://nodeinfo.diaspora.software/ns/schema/2.1"\ 102 | .href "$DOMAINURL/nodeinfo/2.1"\ 103 | ! 2\ 104 | .rel "http://nodeinfo.diaspora.software/ns/schema/2.0"\ 105 | .href "$DOMAINURL/nodeinfo/2.0"\ 106 | 107 | } 108 | 109 | req_disaspora_nodeinfo() { 110 | httpd_header "Content-Type" "application/json" 111 | httpd_json 200\ 112 | .version 2.1\ 113 | !software 3\ 114 | .name kiki\ 115 | .version "$VERSION"\ 116 | .homepage "$HOMEPAGE"\ 117 | @protocols 1\ 118 | . activitypub\ 119 | !services 1\ 120 | @inbound 0\ 121 | @outbound 0\ 122 | %openRegistrations false\ 123 | !usage 3\ 124 | !users 3\ 125 | %total 1\ 126 | %activeHalfyear null\ 127 | %activeMonth null\ 128 | %localPosts 10\ 129 | %localComments 0\ 130 | !metadata 28\ 131 | .nodeName "$NODENAME"\ 132 | .nodeDescription "$NODEDESCRIPTION"\ 133 | @nodeAdmins 0\ 134 | !maintainer 2\ 135 | .name "$MAINTAINERNAME"\ 136 | .email "$MAINTAINEREMAIL"\ 137 | @langs 0\ 138 | %tosUrl null\ 139 | %privacyPolicyUrl null\ 140 | .inquiryUrl "https://www.youtube.com/watch?v=K8GQcE-XwK0"\ 141 | %impressumUrl null\ 142 | %donationUrl null\ 143 | .repositoryUrl "https://github.com/velzie/kiki"\ 144 | %feedbackUrl null\ 145 | %disableRegistration true\ 146 | %disableLocalTimeline false\ 147 | %disableGlobalTimeline false\ 148 | %disableBubbleTimeline false\ 149 | %emailRequiredForSignup true\ 150 | %enableHcaptcha false\ 151 | %enableRecaptcha false\ 152 | %enableMcaptcha false\ 153 | %enableTurnstile false\ 154 | %maxNoteTextLength 8192\ 155 | %enableEmail false\ 156 | %enableServiceWorker false\ 157 | %proxyAccountName null\ 158 | .themeColor "#00ae00" 159 | 160 | 161 | } 162 | 163 | -------------------------------------------------------------------------------- /lib/users.sh: -------------------------------------------------------------------------------- 1 | req_user_banner() { 2 | uid=${path#*banner/} 3 | echo "Banner: $uid" 4 | 5 | httpd_clear 6 | httpd_header "Content-Type" "image/png" 7 | httpd_sendfile 200 "$DB_USERS/$uid/banner.png" 8 | } 9 | 10 | req_user_pfp() { 11 | uid=${path#*pfp/} 12 | echo "PFP: $uid" 13 | 14 | httpd_clear 15 | httpd_header "Content-Type" "image/png" 16 | httpd_sendfile 200 "$DB_USERS/$uid/pfp.png" 17 | } 18 | 19 | 20 | 21 | actorjson() { 22 | uid=$1 23 | 24 | if ! userlookup "$uid"; then 25 | httpd_clear 26 | httpd_send 404 "no such user!" 27 | return 28 | fi 29 | 30 | json\ 31 | .type Person\ 32 | .id "$DOMAINURL/users/$uid"\ 33 | .inbox "$DOMAINURL/inbox/$uid"\ 34 | .outbox "$DOMAINURL/outbox/$uid"\ 35 | .followers "$DOMAINURL/follwers/$uid"\ 36 | .following "$DOMAINURL/following/$uid"\ 37 | .featured "$DOMAINURL/collectionsfeatured/$uid"\ 38 | .sharedInbox "$DOMAINURL/sharedinbox"\ 39 | !endpoints 1\ 40 | .sharedInbox "$DOMAINURL/sharedinbox"\ 41 | .url "$DOMAINURL/@$setUsername"\ 42 | .preferredUsername "$setUsername"\ 43 | .name "$setName"\ 44 | .summary "$setSummary"\ 45 | ._misskey_summary "$setSummary"\ 46 | !icon 4\ 47 | .type Image\ 48 | .url "$DOMAINURL/pfp/$uid"\ 49 | %sensitive false\ 50 | %name null\ 51 | !image 4\ 52 | .type Image\ 53 | .url "$DOMAINURL/banner/$uid"\ 54 | %sensitive false\ 55 | %name null\ 56 | !backgroundUrl 4\ 57 | .type Image\ 58 | .url "$DOMAINURL/banner/$uid"\ 59 | %sensitive false\ 60 | %name null\ 61 | @tag 0\ 62 | %manuallyApprovesFollowers false\ 63 | %discoverable true\ 64 | !publicKey 3\ 65 | .id "$DOMAINURL/users/$uid#main-key"\ 66 | .owner "$DOMAINURL/users/$uid"\ 67 | .publicKeyPem "$(< "$DB_USERS/$uid/pubkey.pem")"$'\n'\ 68 | %isCat true\ 69 | %noindex true\ 70 | %speakAsCat false\ 71 | @attachment 0\ 72 | @alsoKnownAs 0 73 | } 74 | 75 | senduserinfo() { 76 | uid=$1 77 | echo "User: $uid" 78 | 79 | if ! userlookup "$uid"; then 80 | httpd_clear 81 | httpd_send 404 "no such user!" 82 | return 83 | fi 84 | 85 | 86 | httpd_clear 87 | httpd_header "Content-Type" "application/activity+json" 88 | 89 | actor=$(actorjson "$uid") 90 | 91 | # add json-ld context to actor object 92 | actor=$(echosafe "$actor" | jq '.["@context"] = $context' --argjson context "$CONTEXT") 93 | 94 | httpd_send 200 "$actor" 95 | } 96 | -------------------------------------------------------------------------------- /lib/util.sh: -------------------------------------------------------------------------------- 1 | #shellcheck shell=bash 2 | 3 | 4 | jsonvalid(){ 5 | jq -e . >/dev/null 2>&1 6 | } 7 | 8 | uuid(){ 9 | tr -dc 'a-f0-9' < /dev/urandom | head -c 8 10 | } 11 | 12 | echosafe() { 13 | printf "%s" "$1" 14 | } 15 | 16 | fromhex() { 17 | xxd -p -r -c999999 18 | } 19 | 20 | tohex() { 21 | xxd -p 22 | } 23 | 24 | 25 | urldecode() { 26 | : "${*//+/ }" 27 | echo -e "${_//%/\\x}" 28 | } 29 | 30 | urlencode() { 31 | local length="${#1}" 32 | for ((i = 0; i < length; i++)); do 33 | local c="${1:i:1}" 34 | case $c in 35 | [a-zA-Z0-9.~_-]) printf "%s" "$c" ;; 36 | *) printf "%%%02X" "'$c" ;; 37 | esac 38 | done 39 | } 40 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | . ./json.sh 2 | 3 | json\ 4 | .a 1\ 5 | .b 2\ 6 | !object 2\ 7 | .a 1\ 8 | @b 2\ 9 | . 1\ 10 | ! 2\ 11 | .b 1\ 12 | .a 2\ 13 | .c 1 14 | 15 | 16 | json\ 17 | .type "i loadasd" 18 | --------------------------------------------------------------------------------