Add support basic for ActivityPub (#1139)
continuous-integration/drone/push Build is passing Details

Closes #1138 (supercedes it)

What works now:

- [x] You (Yarn.social) are discoverable in the Fediverse via Activity Pub
- [x] You (Yarn.social) can be followed in the Fediverse via Activity Pub
- [x] Posts you make (on Yarn.social) are delivered to your Fediverse followers via Activity Pub

Co-authored-by: James Mills <1290234+prologic@users.noreply.github.com>
Reviewed-on: #1139
pull/1143/head
James Mills 1 month ago
parent 323a688b3a
commit 78ba871111

@ -37,6 +37,7 @@ COPY ./internal/langs/* ./internal/langs/
# Copy sources
COPY *.go ./
COPY ./internal/*.go ./internal/
COPY ./internal/activitypub/*.go ./internal/activitypub/
COPY ./internal/auth/*.go ./internal/auth/
COPY ./internal/indieweb/*.go ./internal/indieweb/
COPY ./cmd/yarnd/*.go ./cmd/yarnd/

@ -10,6 +10,10 @@ GOCMD=go
DESTDIR=/usr/local/bin
ifeq ($(LOCAL), 1)
IMAGE := r.mills.io/prologic/yarnd
TAG := dev
else
ifeq ($(BRANCH), main)
IMAGE := prologic/yarnd
TAG := latest
@ -17,6 +21,7 @@ else
IMAGE := prologic/yarnd
TAG := dev
endif
endif
all: help

@ -0,0 +1,27 @@
-----BEGIN PRIVATE KEY-----
MIIEpQIBAAKCAQEA0FHKNv6CrWyArlUmlpWILDIVcfAxKDFvxUqcmDo436eSp354
NHsPd8TD2pj1sMbDBQRY/cvJrqXNPRo/fsCAqfPkff60gRCb09MS12u7AvzZH2Wl
kZvrjYC/xV1r9BfkWiOZJKQuQ2N8oJdq50Qm2z0XO07tKKX9HzU9Cg/TImlzVyH0
sWIEoe6TX7gbzONZl/7PFqWPMUbOGbWsDTrXh5mYvdS9Tb0XFYwKzmitVLRo7f+w
oUsRf/v3Brj8KiGsOqCiIORxt+/YiFLuOTpFE/ErSSPgMGI1zoOctEZg7fDm6YjQ
2BkENoJo5jVlmFi+lXNy+cH6v6Er5GyjJRDtEwIDAQABAoIBAQC3aItEx+d9kJ3q
3wVOZvCxGJdQ7UwaOwxRA1PDot8X6o1v8iUa7426wP4+o5UMwrJI5H8FVDqJDWkZ
dtaYXhvphdWSKIH7cAgCAz//cdYA12TCA9g1zrUgrE4rEglNqwtdYSIf5Hzmz9yV
9zojyxj6xVqC2QZsV1f39gN7rFsTW3QL4Kuqu//JVg9+ceX8gh0DRTFEqGjZ5MAy
QPo1yVQc/tyBBoYfH9FyuQtl45lmllWZB+PDAxYRFAIqtt6lKY/q5xa3p4G6IKds
GLpvPDWYprzAwYqUTZPqQo6O3Gcit4TLoCTsAh7HiGMOzfkBS/f2071DPT9Cuy/w
jQ2prGBBAoGBAPvJSwZ+91dCg9azEDA/1U1VYvWKw3l41+YTF6yLu7lVrUV/3wq5
VYmPnxiwo0asUsThyFmhLr0vQIufVwBG95ZmnYVG7jiHl/jZX3fzNcEBzowaN5cQ
Uw+LMbd+0QEE5wrNGTt97YLID8DG1JTgwC8SOqVK2uImyTdt/KVgFJgbAoGBANPO
Ro+XKu/LL6LMD9gtqhukAxlo19scF9SY6wAGp3LI1IUTdcf+NAxwLKpsW+8WM2i9
e+kdt7wZUR5aKBrkI7ogpeF1OFw2z3Ip3ahR8xo5X09ptWElfTKLajR++JY82H1q
xE4UaS6CoT+vbP2VseXNviLR6AaMaOsFvWTL2D5pAoGBAMWDCDmWS8zFvsojOWXA
DUFW5AQd0G1voF05SO7vxlkCnqPQRVUSQclhQrqJheugrmUHgLTevd1mPcnJOuRa
x0nSQWsVUzZBF7P8QOnFfbtkAXTh9A2qnp2o4V1CPA4CnINalJqYlEJtUf41evk2
vUuvjxWu/Lk/F8VFoFTSZBVVAoGAfaeCdO1Zq6j/ObWWMMnmgT9sF0b7yCGCgb22
rO1FqfM7ITfKSDum5TonRXPDlrO1DA5d1I6s0gqy9S7HXCy8hU+ZGYhRR2O87h2o
QpNbhdNDl/k+gcOb4sCS4VHyaC7wwHb2vtudCtq0jvOj1U1ZnNvSURX2cOwb0lI1
afcE8wECgYEAsH3SGDcqc3seesfVuZfmJTqwYzsMLjRZ0YGDouxvvJ+d5bKjVYWe
FCeiz2sK6mNFJwxhS2jXvwwDds08h0rc90bHi2MM8wq2F79yenZphJv8Tcdb0vVQ
789uEQNxNuIAD/s7YARBT3geyOQc/LgTaLm4Jz9Kr5QMp4gIFG9LbWg=
-----END PRIVATE KEY-----

@ -0,0 +1,27 @@
-----BEGIN PRIVATE KEY-----
MIIEpQIBAAKCAQEApVH9lrxxCYMIxpgswJX3zM/V0IFg7PecFp20OCgOMJjMfFz5
8k4ZpORHwULwCYR2OZIAIkxE6XQftsOc83qSK69nwd9oB92XSzXJZ0xF1jriijWl
gMnnnNdwTsmxAxUDY4S7R6G62xr8XWMrUzyNSOulWiaDkLW49dDT8SjgQATo/jt2
je/xp9kuHMCM7DXgkALa6SHXGmseckr07dJw2CsswaDew6wfn+wecUZpsaqgsz4z
P5lElADRfm+sREZl/2I8ZNbJaPqvgPS6vlEoum2Ce8Is57AHbRn6iZbxyLfix4i8
ps90uwXS62FJZuCI99fuc51RFO+i8KqNSf03NQIDAQABAoIBAQCYvYX0LKqrRRto
kNRYIrbNzgAYIlDw31yhMJd/gtKJZ8MV67kqe6oJxLffAP9Ra8bnLdNd3OWWY6mh
bF2oPsip/+d3Ife1vK+51zn7bGbhpYoEc8gzk1egexPSV1pqJJH68nktl2lSYj5j
8ennf3xxsPYWspq/qoX25zfKCiAWRHZyCkXSksaL34G1SDy2MyK48INykNfq8uN/
owZdT2SFqkvIbYjnY68VXfuD7Y71Cni7c/17gJPQ3/Z6Jl7Y5V/BzrqKYV9kCmKx
/+RjwTk46YlZnEr5AaL9cye2pWBBWEBeAp+ia/qup+HE9MWZVqZg+K0jfbpFptEn
pd3rw5CJAoGBAMNkwHp6RAakGvPHLlWeSxy+psewoesbIPBATAvP6xx8wDcsNYfW
qICBTKQTZMYlFXeBI4zpIurGjW8+tiCKsKnL5v9mMA3nAJzpgGOkX4bCwszlAg2h
UQN/corjPF12Kt5/RHmgTpMOgwT96DcyZ0H8weBTdlem+fPqq9/MasVLAoGBANiZ
RBcOSFznQVQlhFcN2zxywC6SGtSZ0QYHJdut2R2xtudD0KbN1+/RSjsrFS6LHR9/
SSlJaAEPnlM/cTGOHvy/ctTqcTduJh2qyC+/23DDL9HcO8TpFX3zH9VqngcU8rL+
0LBsIrZ8wAXTVW1vMshDclVFrO61zeDz/PIyFKV/AoGAYh8jZZ4msSsR+d/Jjedr
EulO+bLi7Rz3go7XYYstJ2YiZNKHo0qR3c6QvUib78FJsXShdK7TARFqjzXv4hGj
u/EQdKtNcH3T2fiMp/0wl40QpDJQwKWE+Hu0+rg4ZTrlNky1B1sQelrsQsJ7LdTa
89FJTyy6njPVC7+KRl3yNFcCgYEAx/6U2XkNpgK91pWhocQgl6sY+qdbcMzqLNey
xCm83Oc4DEgYw7wzU7N7CDEaqNQ4utmL0zx9dOVX+mQM/4XL5PJddG1YxqbFOQV1
PGm5lGAVqn/hDEtv1dEEpsmASuezxUT1qsDeOIPtxjNBoP9Y84MbcdMY/30NMVX4
TCWj3L8CgYEAg8qSqduZ0zQPJLlU6nUbYf2JpqVdq9nDCfq4k3amJrlm7nGKojLp
potcnRGiQVi1HQ13/4mF+Ka/6Qht3AH+BZsG8QypWuB16DjIFbZ1QOzf/j7Z1uxx
DT/0np4v5Al/bjqcIv17E1b8jibcFBkpK5rxXA4g76uXvQ5xuUKFuRI=
-----END PRIVATE KEY-----

@ -28,6 +28,7 @@ require (
github.com/dustin/go-humanize v1.0.0
github.com/gabstv/merger v1.0.1
github.com/gavv/httpexpect/v2 v2.3.1
github.com/go-fed/httpsig v1.1.0
github.com/go-mail/mail v2.3.1+incompatible
github.com/goccy/go-yaml v1.9.8
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c
@ -64,6 +65,7 @@ require (
github.com/steambap/captcha v1.4.1
github.com/stretchr/testify v1.8.1
github.com/theplant-retired/timezones v0.0.0-20150304063004-f9bd3c0ef9db
github.com/tj/assert v0.0.3
github.com/tj/go-editor v1.0.0
github.com/unrolled/logger v0.0.0-20201216141554-31a3694fe979
github.com/vcraescu/go-paginator v1.0.0
@ -71,7 +73,7 @@ require (
github.com/writeas/slug v1.2.0
go.mills.io/sessions v0.0.0-20230102023727-1d4fd809624f
go.mills.io/tasks v0.0.0-20221203225004-ed0b72b22ccc
go.mills.io/webfinger v0.0.0-20230105164239-760f94609371
go.mills.io/webfinger v0.0.0-20230218075238-e709ef684f28
go.mills.io/webmention v0.0.0-20230110091045-b61439c7853b
go.yarn.social/client v0.0.0-20221026065557-81f92f9fecbc
go.yarn.social/lextwt v0.0.0-20221221200320-31bca76a2587

@ -157,7 +157,6 @@ github.com/caio/go-tdigest v3.1.0+incompatible/go.mod h1:sHQM/ubZStBUmF1WbB8FAm8
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@ -231,6 +230,8 @@ github.com/gabstv/merger v1.0.1/go.mod h1:oQKCbAX4P6q0jk4s9Is144NojOE/HggFPb5qjP
github.com/gavv/httpexpect/v2 v2.3.1 h1:sGLlKMn8AuHS9ztK9Sb7AJ7OxIL8v2PcLdyxfKt1Fo4=
github.com/gavv/httpexpect/v2 v2.3.1/go.mod h1:yOE8m/aqFYQDNrgprMeXgq4YynfN9h1NgcE1+1suV64=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -441,8 +442,6 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.2/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0=
github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc=
github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -492,7 +491,6 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -594,8 +592,6 @@ github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NX
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
@ -627,8 +623,6 @@ github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71e
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/savsgio/gotils v0.0.0-20210617111740-97865ed5a873/go.mod h1:dmPawKuiAeG/aFYVs2i+Dyosoo7FNcm+Pi8iK6ZUrX8=
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 h1:ocK/D6lCgLji37Z2so4xhMl46se1ntReQQCUIU4BWI8=
github.com/schollz/progressbar/v3 v3.12.2 h1:yLqqqpQNMxGxHY8uEshRihaHWwa0rf0yb7/Zrpgq2C0=
github.com/schollz/progressbar/v3 v3.12.2/go.mod h1:HFJYIYQQJX32UJdyoigUl19xoV6aMwZt6iX/C30RWfg=
github.com/schollz/progressbar/v3 v3.13.0 h1:9TeeWRcjW2qd05I8Kf9knPkW4vLM/hYoa6z9ABvxje8=
github.com/schollz/progressbar/v3 v3.13.0/go.mod h1:ZBYnSuLAX2LU8P8UiKN/KgF2DY58AJC8yfVYLPC8Ly4=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@ -769,12 +763,8 @@ go.mills.io/sessions v0.0.0-20230102023727-1d4fd809624f h1:5idny2L+cU9cS9Mf2uJME
go.mills.io/sessions v0.0.0-20230102023727-1d4fd809624f/go.mod h1:EsqZxWAQ+bOPJMXoAQmMH1A4pfwWxEMwNsRQZwSK8zs=
go.mills.io/tasks v0.0.0-20221203225004-ed0b72b22ccc h1:wGYvsnvcKKyBgDuFj/gKV6cPPuPf2iJ3SxGDSq4aYJo=
go.mills.io/tasks v0.0.0-20221203225004-ed0b72b22ccc/go.mod h1:xFZNjTkSiYSqcXJpXjKK0gUKNUF55ChDrctAw75n5BI=
go.mills.io/webfinger v0.0.0-20230105141325-649c12083eed h1:mmUAcRU3u4WsdNwdJ2xNhqzdeZRcVJrxWZXPo4oVrKk=
go.mills.io/webfinger v0.0.0-20230105141325-649c12083eed/go.mod h1:ePsXiC8aRnx3QySCPRvuQRnqH7MIzc4Sa/PxzcWG8y0=
go.mills.io/webfinger v0.0.0-20230105164239-760f94609371 h1:6Aso675CFGHTVfsCv9JgpUgklEa4LuppItV+yXG8MVk=
go.mills.io/webfinger v0.0.0-20230105164239-760f94609371/go.mod h1:ePsXiC8aRnx3QySCPRvuQRnqH7MIzc4Sa/PxzcWG8y0=
go.mills.io/webmention v0.0.0-20230108014521-145ff1260f59 h1:WQYFhmsx8/Je5jTamm/HiVHNiSEbl0RZXhL57/DnBZ0=
go.mills.io/webmention v0.0.0-20230108014521-145ff1260f59/go.mod h1:UvdH1Ybk4UTRI+2eBBucElTasN8064nOC0IkjKBO/s8=
go.mills.io/webfinger v0.0.0-20230218075238-e709ef684f28 h1:J6QgD4mSaJtxMS+F3q4914fjDLRajkPF+8yB0cmw2vc=
go.mills.io/webfinger v0.0.0-20230218075238-e709ef684f28/go.mod h1:ePsXiC8aRnx3QySCPRvuQRnqH7MIzc4Sa/PxzcWG8y0=
go.mills.io/webmention v0.0.0-20230110091045-b61439c7853b h1:K1jvLZjZNHsfnu8cbBHMYlbKwG5SCV8t3nZBMH5j/YQ=
go.mills.io/webmention v0.0.0-20230110091045-b61439c7853b/go.mod h1:UvdH1Ybk4UTRI+2eBBucElTasN8064nOC0IkjKBO/s8=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@ -817,8 +807,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -835,16 +823,12 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 h1:5oN1Pz/eDhCpbMbLstvIPa0b/BEQo6g6nwV3pLjfM6w=
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a h1:tlXy25amD5A7gOfbXdqCGN5k8ESEed/Ee1E5RcrYnqU=
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -919,8 +903,6 @@ golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -1015,16 +997,12 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1037,8 +1015,6 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1255,8 +1231,6 @@ gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.6/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

@ -0,0 +1,382 @@
// Copyright 2020-present Yarn.social
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package activitypub implements ActivityPub types, handling and c2s (client-to-server) functionality
package activitypub
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os"
"time"
sync "github.com/sasha-s/go-deadlock"
log "github.com/sirupsen/logrus"
)
const (
defaultRedeliveryAttempts = 6
defaultQueueSize = 100
emptyDigest = "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE="
)
func fileExists(fn string) bool {
if _, err := os.Stat(fn); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
type activity struct {
actor *Actor
inbox string
object any
attempts int
}
type Follower struct {
Actor string `json:"actor"`
Inbox string `json:"inbox"`
CreatedAt time.Time `json:"created_at"`
}
func NewFollower(actor string) *Follower {
return &Follower{
Actor: actor,
CreatedAt: time.Now(),
}
}
type Followers []*Follower
func (f Followers) Len() int { return len(f) }
func (f Followers) Swap(i, j int) { f[i], f[j] = f[j], f[i] }
func (f Followers) Less(i, j int) bool { return f[i].CreatedAt.Before(f[j].CreatedAt) }
type Stats struct {
Actors int
Followers int
}
type ActivityPub struct {
sync.RWMutex
fn string
actor *Actor
// Mapping of Actor -> Followers
followers map[string]Followers
// inbox queue for processing received objects asynchronously
inbox chan any
inboxTicker *time.Ticker
// outbox queue for sending activities to followers
outbox chan *activity
outboxTicker *time.Ticker
stateTicker *time.Ticker
// ValidateActor is a function that takes an `actor` string as input and returns `true` if it is
// a valid actor or `false` otherwise. Consumers should override this field with a custom
// actor validation function that suits the application.
ValidateActor func(actor string) bool
}
// New returns a new ActivityPub endpoint and processor
func New(fn string, actor *Actor) *ActivityPub {
ap := &ActivityPub{
fn: fn,
actor: actor,
followers: make(map[string]Followers),
inbox: make(chan any, defaultQueueSize),
outbox: make(chan *activity, defaultQueueSize),
ValidateActor: func(id string) bool { return true },
}
ap.inboxTicker = time.NewTicker(1 * time.Second)
go func() {
for range ap.inboxTicker.C {
ap.processInbox()
}
}()
ap.outboxTicker = time.NewTicker(2 * time.Second)
go func() {
for range ap.outboxTicker.C {
ap.processOutbox()
}
}()
ap.stateTicker = time.NewTicker(1 * time.Minute)
go func() {
for range ap.stateTicker.C {
ap.Save()
}
}()
return ap
}
func (ap *ActivityPub) Load() error {
if !fileExists(ap.fn) {
return nil
}
data, err := os.ReadFile(ap.fn)
if err != nil {
os.Remove(ap.fn)
return fmt.Errorf("error loading state: %w", err)
}
state := struct {
Followers map[string]Followers
}{}
if err := json.Unmarshal(data, &state); err != nil {
os.Remove(ap.fn)
return fmt.Errorf("error parsing state: %w", err)
}
ap.Lock()
ap.followers = state.Followers
ap.Unlock()
return nil
}
func (ap *ActivityPub) Save() error {
ap.RLock()
defer ap.RUnlock()
state := struct {
Followers map[string]Followers
}{
Followers: ap.followers,
}
data, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("error serializing state: %s", err)
}
if err := os.WriteFile(ap.fn, data, 0644); err != nil {
return fmt.Errorf("error saving state: %w", err)
}
return nil
}
func (ap *ActivityPub) addFollower(actor string, follower *Follower) {
ap.followers[actor] = append(ap.followers[actor], follower)
}
func (ap *ActivityPub) AddFollower(actor string, follower *Follower) {
ap.Lock()
defer ap.Unlock()
ap.addFollower(actor, follower)
}
func (ap *ActivityPub) getFollowerFor(id, actor string) (*Follower, int) {
followers, ok := ap.followers[id]
if !ok {
return nil, -1
}
for idx, follower := range followers {
if follower.Actor == actor {
return follower, idx
}
}
return nil, -1
}
func (ap *ActivityPub) GetFollowerFor(id, actor string) (*Follower, int) {
ap.RLock()
defer ap.RUnlock()
return ap.getFollowerFor(id, actor)
}
func (ap *ActivityPub) IsFollowing(id, actor string) bool {
_, idx := ap.GetFollowerFor(id, actor)
return idx != -1
}
func (ap *ActivityPub) delFollower(id string, idx int) {
followers := ap.followers[id]
ap.followers[id] = append(followers[:idx], followers[idx+1:]...)
}
func (ap *ActivityPub) DelFollower(id string, idx int) {
ap.Lock()
defer ap.Unlock()
ap.delFollower(id, idx)
}
func (ap *ActivityPub) Broadcast(actor *Actor, obj any) {
ap.RLock()
defer ap.RUnlock()
followers, ok := ap.followers[actor.ID]
if !ok {
log.Debugf("no followers found for %s", actor.ID)
return
}
log.Debugf("%d followers found for %s", len(followers), actor.ID)
for _, follower := range followers {
ap.outbox <- &activity{actor: actor, inbox: follower.Inbox, object: obj}
}
}
func (ap *ActivityPub) Stats() (stats Stats) {
ap.RLock()
defer ap.RUnlock()
stats.Actors = len(ap.followers)
for _, followers := range ap.followers {
stats.Followers += len(followers)
}
return
}
func (ap *ActivityPub) DebugEndpoint(w http.ResponseWriter, r *http.Request) {
ap.RLock()
defer ap.RUnlock()
doc := struct {
Followers map[string]Followers
}{
Followers: ap.followers,
}
data, err := json.Marshal(doc)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func (ap *ActivityPub) Endpoint(w http.ResponseWriter, r *http.Request) {
log.Debug("ap.Endpoint(():")
dump, _ := httputil.DumpRequest(r, true)
fmt.Fprintf(os.Stderr, "Incoming Request:\n %q\n", dump)
data, err := io.ReadAll(r.Body)
if err != nil {
log.WithError(err).Error("error reading request body")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
obj, err := ParseObject(data)
if err != nil {
log.WithError(err).Error("error parsing object")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
switch obj.(type) {
case *Follow:
ap.inbox <- obj.(*Follow)
http.Error(w, "Accepted", http.StatusAccepted)
default:
log.Debugf("unsupported object type %T", obj)
http.Error(w, "Unsupported", http.StatusUnprocessableEntity)
}
}
func (ap *ActivityPub) handleFollow(follow *Follow) {
log.Debugf("Handling Follow: %#v", follow)
if !ap.IsFollowing(follow.Object, follow.Actor) {
follower := NewFollower(follow.Actor)
ap.AddFollower(follow.Object, follower)
actor, err := LookupActor(ap.actor, follow.Actor)
if err != nil {
log.WithError(err).Errorf("error looking up actor %s", follow.Actor)
return
}
follower.Inbox = actor.Inbox
accept := AcceptFollow(follow.ID, follow.To, follow)
ap.outbox <- &activity{actor: ap.actor, inbox: actor.Inbox, object: accept}
}
}
func (ap *ActivityPub) processInbox() {
obj := <-ap.inbox
switch obj.(type) {
case *Follow:
ap.handleFollow(obj.(*Follow))
default:
log.Warnf("unsupported object type %T", obj)
}
}
func (ap *ActivityPub) processOutbox() {
activity := <-ap.outbox
activity.attempts++
if activity.attempts > defaultRedeliveryAttempts {
log.Errorf(
"giving up processing activity from actor=%s to inbox=%s for object=%T after %d attempts",
activity.actor, activity.inbox, activity.object, activity.attempts,
)
return
}
data, err := json.Marshal(activity.object)
if err != nil {
log.WithError(err).Errorf("error serializing object=%T", activity.object)
return
}
buf := bytes.NewBuffer(data)
res, err := SignedRequest(activity.actor, http.MethodPost, activity.inbox, buf)
if err != nil {
log.WithError(err).Errorf(
"error sending activity actor=%s inbox=%s object=%T",
activity.actor, activity.inbox, activity.object,
)
// Attempt re-delivery
ap.outbox <- activity
return
}
defer res.Body.Close()
if res.StatusCode/100 != 2 {
log.Errorf(
"non-200 response ending activity actor=%s inbox=%s object=%T status=%s",
activity.actor, activity.inbox, activity.object, res.Status,
)
// Attempt re-delivery
ap.outbox <- activity
return
}
log.Debugf(
"successfully sent activity object=%T to inbox=%s from actor=%s",
activity.object, activity.inbox, activity.object,
)
}

@ -0,0 +1,82 @@
package activitypub
import (
"crypto"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os"
"time"
"github.com/go-fed/httpsig"
)
func signRequest(privateKey crypto.PrivateKey, pubKeyId string, r *http.Request) error {
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
digestAlgorithm := httpsig.DigestSha256
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
headersToSign := []string{httpsig.RequestTarget, "date", "digest"}
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 30)
if err != nil {
return err
}
return signer.SignRequest(privateKey, pubKeyId, r, nil)
}
// SignedRequest performs a signed request using the Actor's private key
func SignedRequest(actor *Actor, method, uri string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, uri, body)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Add("Digest", emptyDigest)
req.Header.Add("Date", time.Now().Format(http.TimeFormat))
req.Header.Add("Accept", ActivityContentType)
if method == http.MethodPost {
req.Header.Add("Content-Type", ActivityContentType)
}
if err := signRequest(actor.key, actor.PublicKey.ID, req); err != nil {
return nil, fmt.Errorf("error signing request: %w", err)
}
dump, _ := httputil.DumpRequestOut(req, true)
fmt.Fprintf(os.Stderr, "Outgoing Request:\n %q\n", dump)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
dump, _ = httputil.DumpResponse(res, true)
fmt.Fprintf(os.Stderr, "Received Response:\n %q\n", dump)
return res, nil
}
// LookupActor looks up an actor by its URI and returns an Actor object
func LookupActor(actor *Actor, uri string) (*Actor, error) {
res, err := SignedRequest(actor, http.MethodGet, uri, nil)
if err != nil {
return nil, fmt.Errorf("error looking up actor %s: %w", uri, err)
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var a Actor
if err := json.Unmarshal(data, &a); err != nil {
return nil, fmt.Errorf("error parsing response body: %w", err)
}
return &a, nil
}

@ -0,0 +1,117 @@
// Copyright 2020-present Yarn.social
// SPDX-License-Identifier: AGPL-3.0-or-later
package activitypub
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
)
const defaultKeySize = 2048
// SetKey sets private key of this actor and encodes the public key
func (a *Actor) SetKey(key *rsa.PrivateKey) error {
a.key = key
publicKey, err := EncodePublicKey(key)
if err != nil {
return fmt.Errorf("error encoding public key: %w", err)
}
a.PublicKey = PublicKey{
ID: a.ID,
Owner: a.ID,
PublicKey: publicKey,
}
return nil
}
// GenerateKeys generates a private rsa key
func GenerateKeys(bits int) (*rsa.PrivateKey, error) {
key, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, fmt.Errorf("error generating keys: %w", err)
}
return key, nil
}
// GetOrCreateKey reads the private rsa key from a file or creates a new one if it doesn't exist
func GetOrCreateKey(fn string) (key *rsa.PrivateKey, err error) {
key, err = ReadKey(fn)
if err != nil {
key, err = GenerateKeys(defaultKeySize)
if err != nil {
return nil, fmt.Errorf("error generating new key: %s", err)
}
if err := WriteKey(fn, key); err != nil {
return nil, fmt.Errorf("error writing new key: %w", err)
}
}
return
}
// ReadKey reads the private rsa key from a file
func ReadKey(fn string) (*rsa.PrivateKey, error) {
pemData, err := os.ReadFile(fn)
if err != nil {
return nil, fmt.Errorf("error reading private key %s: %w", fn, err)
}
pemBlock, _ := pem.Decode(pemData)
if pemBlock == nil {
return nil, fmt.Errorf("error decoding pem")
}
key, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("error parsing key: %w", err)
}
return key, nil
}
// WriteKey writes the private rsa key to the file
func WriteKey(fn string, key *rsa.PrivateKey) error {
p := filepath.Dir(fn)
if err := os.MkdirAll(p, os.FileMode(0755)); err != nil {
return fmt.Errorf("error creating path %s: %w", p, err)
}
f, err := os.OpenFile(fn, os.O_CREATE|os.O_EXCL|os.O_WRONLY, os.FileMode(0600))
if err != nil {
return fmt.Errorf("error opening %s for writing: %w", fn, err)
}
defer f.Close()
var privateKey = &pem.Block{
Type: "PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return pem.Encode(f, privateKey)
}
// EncodePublicKey encodes the public key of a private rsa key
func EncodePublicKey(key *rsa.PrivateKey) (string, error) {
pkixBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return "", fmt.Errorf("error encoding public key: %w", err)
}
var publicKey = &pem.Block{
Type: "PUBLIC KEY",
Bytes: pkixBytes,
}
return string(pem.EncodeToMemory(publicKey)), nil
}

@ -0,0 +1,211 @@
// Copyright 2020-present Yarn.social
// SPDX-License-Identifier: AGPL-3.0-or-later
package activitypub
import (
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"time"
)
const (
// ActivityContentType is the content type for all ActivityPub activities
ActivityContentType = "application/activity+json"
// LinkedDataContentType is the JSON Linked Data type
LinkedDataContentType = "application/ld+json"
// ActivityBaseURI the URI for the ActivityStreams namespace
ActivityBaseURI = "https://www.w3.org/ns/activitystreams"
// SecurityContextURI the URI for the security namespace (for an Actor's PublicKey)
SecurityContextURI = "https://w3id.org/security/v1"
// PublicNS is the reference to the Public entity in the ActivityStreams namespace.
PublicNS = ActivityBaseURI + "#Public"
defaultIconMediaType = "image/png"
personType = "Person"
serviceType = "Service"
createType = "Create"
acceptType = "Accept"
followType = "Follow"
noteType = "Note"
imageType = "Image"
)
var (
// ErrUnsupportedObject is the error returned when parsing objects that are not supported
ErrUnsupportedObject = errors.New("error: unsupported object")
)
// DefaultContext is a slice of URIs that form the default context for all objects
var DefaultContext = []string{
ActivityBaseURI,
SecurityContextURI,
}
// Follow is a Follow activity
type Follow struct {
Context any `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object string `json:"object"`
To string `json:"to"`
}
// Accept is an Accept activity
type Accept struct {
Context any `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
// AcceptFollow creates a new ActivityPub Accept Activity with an embedded Follow object
func AcceptFollow(id, actor string, follow *Follow) *Accept {
return &Accept{
Context: DefaultContext,
ID: id,
Type: acceptType,
Actor: actor,
Object: follow,
}
}
// Note is a Note object type embedded in an Create Activity
type Note struct {
ID string `json:"id"`
Type string `json:"type"`
To string `json:"to"`
Content string `json:"content"`
Published time.Time `json:"published"`
AttributeTo string `json:"attributedTo"`
InReplyTo string `json:"inReplyTo"`
}
// Create is a Create activity
type Create struct {
Context any `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
To string `json:"to"`
}
// CreateNote creates a new ActivityPub Create Activity with an embedded Note object
func CreateNote(id, actor string, created time.Time, content string) *Create {
return &Create{
Context: DefaultContext,
ID: id,
Type: createType,
Actor: actor,
Object: Note{
ID: id,
Type: noteType,
To: PublicNS,
Content: content,
Published: created,
AttributeTo: actor,
//TODO Add support for replies
},
To: PublicNS,
}
}
// PublicKey is a a Person's Public RSA Key
type PublicKey struct {
ID string `json:"id"`
Owner string `json:"owner"`
PublicKey string `json:"publicKeyPem"`
}
// Icon is an Icon
type Icon struct {
Type string `json:"type"`
MediaType string `json:"mediaType"`
URL string `json:"url"`
}
// Actor is a basic ActivityPub Actor
type Actor struct {
Context any `json:"@context"`
ID string `json:"id"`
Type string `json:"type"`
Icon Icon `json:"icon"`
Summary string `json:"summary"`
PreferredUsername string `json:"preferredUsername"`
Inbox string `json:"inbox"`
Outbox string `json:"outbox"`
Following string `json:"following"`
Followers string `json:"followers"`
PublicKey PublicKey `json:"publicKey"`
key *rsa.PrivateKey
}
// String implements the fmt.Stringer interface
func (a *Actor) String() string {
return fmt.Sprintf("Actor{%s}", a.ID)
}
// SetIcon sets the Actor's Icon (Avatar)
func (a *Actor) SetIcon(url string) {
a.Icon = Icon{Type: imageType, MediaType: defaultIconMediaType, URL: url}
}
// NewService creates a new ActivityPub Actor of Type Service
func NewService(id string) *Actor {
return &Actor{Context: DefaultContext, ID: id, Type: serviceType}
}
// NewPerson creates a new ActivityPub Actor of Type Person
func NewPerson(id string) *Actor {
return &Actor{Context: DefaultContext, ID: id, Type: personType}
}
// ParseObject parses the JSON object into an ActivityPub activity object
// by first inspecting its type before un-marshalling into the correct type.
func ParseObject(data []byte) (v any, err error) {
var obj map[string]any
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("error parsing object: %w", err)
}
switch objType := obj["type"].(string); objType {
case followType:
var follow Follow
if err := json.Unmarshal(data, &follow); err != nil {
return nil, fmt.Errorf("error parsing follow object: %w", err)
}
return &follow, nil
default:
return nil, fmt.Errorf("%w %q", ErrUnsupportedObject, objType)
}
}

@ -0,0 +1,26 @@
// Copyright 2020-present Yarn.social
// SPDX-License-Identifier: AGPL-3.0-or-later
package activitypub_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tj/assert"
"git.mills.io/yarnsocial/yarn/internal/activitypub"
)
func TestParseObject(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
payload := []byte(`{"@context":"https://www.w3.org/ns/activitystreams","actor":"https://gotosocial.mills.io/users/prologic","id":"https://gotosocial.mills.io/users/prologic/follow/01NQVA90B14DS9KY139N1XT4MT","object":"https://yarn.mills.io/user/james/","to":"https://yarn.mills.io/user/james/","type":"Follow"}`)
obj, err := activitypub.ParseObject(payload)
require.NoError(err)
follow, ok := obj.(*activitypub.Follow)
require.True(ok)
assert.Equal(follow.Type, "Follow")
}

@ -0,0 +1,59 @@
package internal
import (
"io"
"net/http"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
)
// InboxHandler ...
func (s *Server) InboxHandler() httprouter.Handle {
isAdminUser := IsAdminUserFactory(s.config)
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
defer r.Body.Close()
if r.Method == http.MethodPost {
ap.Endpoint(w, r)
return
}
if r.Method == http.MethodGet {
ctx := NewContext(s, r)
if !isAdminUser(ctx.User) {
ctx.Error = true
ctx.Message = "You are not a Pod Owner!"
s.render("403", w, ctx)
return
}
ap.DebugEndpoint(w, r)
return
}
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
// OutboxHandler ...
func (s *Server) OutboxHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
defer r.Body.Close()
data, err := io.ReadAll(r.Body)
if err != nil {
log.WithError(err).Error("error reading request body")
http.Error(w, "Internal Server Error", http.StatusInsufficientStorage)
return
}
log.Debugf("Outbox Request:\n %s\n", string(data))
http.Error(w, "Not Implemented", http.StatusNotImplemented)
}
}

@ -84,19 +84,19 @@ type Archiver interface {
Count() int64
}
// NullArchiver implements Archiver using dummy implementation stubs
type NullArchiver struct{}
// DummyArchiver implements Archiver using dummy implementation stubs
type DummyArchiver struct{}
func NewNullArchiver() (Archiver, error) {
return &NullArchiver{}, nil
func NewDummyArchiver() (Archiver, error) {
return &DummyArchiver{}, nil
}
func (a *NullArchiver) Del(hash string) error { return nil }
func (a *NullArchiver) Has(hash string) bool { return false }
func (a *NullArchiver) Get(hash string) (types.Twt, error) { return types.NilTwt, nil }
func (a *NullArchiver) Archive(twt types.Twt) error { return nil }
func (a *NullArchiver) Walk() <-chan types.Twt { return nil }
func (a *NullArchiver) Count() int64 { return 0 }
func (a *DummyArchiver) Del(hash string) error { return nil }
func (a *DummyArchiver) Has(hash string) bool { return false }
func (a *DummyArchiver) Get(hash string) (types.Twt, error) { return types.NilTwt, nil }
func (a *DummyArchiver) Archive(twt types.Twt) error { return nil }
func (a *DummyArchiver) Walk() <-chan types.Twt { return nil }
func (a *DummyArchiver) Count() int64 { return 0 }
// DiskArchiver implements Archiver using an on-disk hash layout directory
// structure with one directory per 2-letter hash sequence with a single

@ -21,6 +21,7 @@ const (
FeatureFoo
FeatureFilterAndLists
FeatureWebFinger
FeatureActivityPub
)
// Interface guards
@ -39,6 +40,8 @@ func (f FeatureType) String() string {
return "filter_and_lists"
case FeatureWebFinger:
return "webfinger"
case FeatureActivityPub:
return "activitypub"
default:
return "invalid_feature"
}
@ -73,6 +76,8 @@ func FeatureFromString(s string) (FeatureType, error) {
return FeatureFilterAndLists, nil
case "webfinger":
return FeatureWebFinger, nil
case "activitypub":
return FeatureActivityPub, nil
default:
fs := fmt.Sprintf("available features: %s", strings.Join(AvailableFeatures(), " "))
return FeatureInvalid, fmt.Errorf("Error unrecognised feature: %s (%s)", s, fs)

@ -16,6 +16,7 @@ import (
"time"
read_file_last_line "git.mills.io/prologic/read-file-last-line"
"git.mills.io/yarnsocial/yarn/internal/activitypub"
log "github.com/sirupsen/logrus"
"go.yarn.social/lextwt"
@ -80,12 +81,17 @@ func AppendTwtFactory(conf *Config, cache *Cache, db Store) AppendTwtFunc {
return types.NilTwt, fmt.Errorf("unauthorized attempt to post to feed %s from user %s", feed, user)
}
var fn string
var (
fn string
profile types.Profile
)
if feed == nil {
fn = filepath.Join(p, user.Username)
profile = user.Profile(conf.BaseURL, nil)
} else {
fn = filepath.Join(p, feed.Name)
profile = feed.Profile(conf.BaseURL, nil)
}
f, err := os.OpenFile(fn, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
@ -127,7 +133,46 @@ func AppendTwtFactory(conf *Config, cache *Cache, db Store) AppendTwtFunc {
return types.NilTwt, err
}
websub.SendNotification(conf.URLForUser(user.Username))
websub.SendNotification(conf.URLForUser(twter.Nick))
if conf.Features.IsEnabled(FeatureActivityPub) {
if err := func() error {
person := activitypub.NewPerson(UserURL(profile.URI))
person.SetIcon(profile.Avatar)
person.Summary = profile.Description
person.PreferredUsername = profile.Nick
person.Inbox = URLForInbox(conf.BaseURL, profile.Nick)
person.Outbox = URLForOutbox(conf.BaseURL, profile.Nick)
person.Following = URLForFollowing(conf.BaseURL, profile.Nick)
person.Followers = URLForFollowers(conf.BaseURL, profile.Nick)
// TODO: Add Following
key, err := activitypub.GetOrCreateKey(filepath.Join(conf.Data, keysDir, twter.Nick))
if err != nil {
return fmt.Errorf("error getting or creating key for %s", twter.Nick)
}
if err := person.SetKey(key); err != nil {
return fmt.Errorf("error setting public key")
}
note := activitypub.CreateNote(
URLForTwt(conf.BaseURL, twt.Hash()),
UserURL(twter.URI),
twt.Created(),
string(FormatTwtFactory(conf, cache, &DummyArchiver{})(twt, nil)),
)
ap.Broadcast(person, note)
return nil
}(); err != nil {
log.WithError(err).Warnf("error send activity for %s from %s", twt, twter)
}
}
return twt, nil
}

@ -10,6 +10,7 @@ import (
"strings"
"time"
"git.mills.io/yarnsocial/yarn/internal/activitypub"
"github.com/julienschmidt/httprouter"
"github.com/rickb777/accept"
"github.com/securisec/go-keywords"
@ -75,20 +76,6 @@ func (s *Server) PermalinkHandler() httprouter.Handle {
return
}
if accept.PreferredContentTypeLike(r.Header, "application/json") == "application/json" {
data, err := json.Marshal(twt)
if err != nil {
log.WithError(err).Error("error serializing twt response")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Last-Modified", twt.Created().Format(http.TimeFormat))
_, _ = w.Write(data)
return
}
who := twt.Twter().DomainNick()
var image string
@ -117,7 +104,6 @@ func (s *Server) PermalinkHandler() httprouter.Handle {
var tags types.TagList = twt.Tags()
ks = append(ks, tags.Tags()...)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Last-Modified", twt.Created().Format(http.TimeFormat))
w.Header().Set("Link", fmt.Sprintf(`<%s/webmention>; rel="webmention"`, s.config.BaseURL))
@ -161,6 +147,42 @@ func (s *Server) PermalinkHandler() httprouter.Handle {
ctx.Twts = s.FilterTwts(ctx.User, types.Twts{twt})
}
s.render("permalink", w, ctx)
if accept.PreferredContentTypeLike(r.Header, "text/html") == "text/html" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
s.render("permalink", w, ctx)
return
}
var obj any
preferred := accept.PreferredContentTypeLike(r.Header, "application/")
if preferred == "application/json" {
w.Header().Set("Content-Type", "application/json")
obj = twt
} else if s.config.Features.IsEnabled(FeatureActivityPub) && (preferred == activitypub.ActivityContentType || preferred == activitypub.LinkedDataContentType) {
w.Header().Set("Content-Type", activitypub.ActivityContentType)
note := activitypub.CreateNote(
URLForTwt(s.config.BaseURL, twt.Hash()),
UserURL(twt.Twter().URI),
twt.Created(),
string(FormatTwtFactory(s.config, s.cache, s.archive)(twt, nil)),
)
obj = note.Object
} else {
http.Error(w, "Not Acceptable", http.StatusNotAcceptable)
return
}
data, err := json.Marshal(obj)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
return
}
}

@ -4,16 +4,22 @@
package internal
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"git.mills.io/yarnsocial/yarn/internal/activitypub"
"github.com/julienschmidt/httprouter"
"github.com/rickb777/accept"
log "github.com/sirupsen/logrus"
"github.com/vcraescu/go-paginator"
"github.com/vcraescu/go-paginator/adapter"
"go.yarn.social/types"
)
const keysDir = "keys"
// ProfileHandler ...
func (s *Server) ProfileHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
@ -22,9 +28,13 @@ func (s *Server) ProfileHandler() httprouter.Handle {
nick := NormalizeUsername(p.ByName("nick"))
if nick == "" {
ctx.Error = true
ctx.Message = s.tr(ctx, "ErrorNoUser")
s.render("error", w, ctx)
if accept.PreferredContentTypeLike(r.Header, "text/html") == "text/html" {
ctx.Error = true
ctx.Message = s.tr(ctx, "ErrorNoUser")
s.render("error", w, ctx)
} else {
http.Error(w, "No User", http.StatusBadRequest)
}
return
}
@ -33,27 +43,39 @@ func (s *Server) ProfileHandler() httprouter.Handle {
if s.db.HasUser(nick) {
user, err := s.db.GetUser(nick)
if err != nil {
log.WithError(err).Errorf("error loading user object for %s", nick)
ctx.Error = true
ctx.Message = s.tr(ctx, "ErrorLoadingProfile")
s.render("error", w, ctx)
if accept.PreferredContentTypeLike(r.Header, "text/html") == "text/html" {
log.WithError(err).Errorf("error loading user object for %s", nick)
ctx.Error = true
ctx.Message = s.tr(ctx, "ErrorLoadingProfile")
s.render("error", w, ctx)
} else {
http.Error(w, "Error Loading Profile", http.StatusInternalServerError)
}
return
}
profile = user.Profile(s.config.BaseURL, ctx.User)
} else if s.db.HasFeed(nick) {
feed, err := s.db.GetFeed(nick)
if err != nil {
log.WithError(err).Errorf("error loading feed object for %s", nick)
ctx.Error = true
ctx.Message = s.tr(ctx, "ErrorLoadingProfile")
s.render("error", w, ctx)
if accept.PreferredContentTypeLike(r.Header, "text/html") == "text/html" {
log.WithError(err).Errorf("error loading feed object for %s", nick)
ctx.Error = true
ctx.Message = s.tr(ctx, "ErrorLoadingProfile")
s.render("error", w, ctx)