diff --git a/.air.toml b/.air.toml
index 37bc493..891a1c4 100644
--- a/.air.toml
+++ b/.air.toml
@@ -1,11 +1,11 @@
root = "."
testdata_dir = "testdata"
-tmp_dir = "tmp"
+tmp_dir = "/tmp"
[build]
args_bin = ["web"]
- bin = "./tmp/main"
- cmd = "go build -o ./tmp/main owl-blogs/cmd/owl"
+ bin = "/tmp/main"
+ cmd = "go build -buildvcs=false -o /tmp/main owl-blogs/cmd/owl"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8313a0c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+e2e_tests/
+tmp/
+*.db
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index fd7e798..13804c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,7 @@ users/
*.db
-tmp/
\ No newline at end of file
+tmp/
+
+venv/
+*.pyc
\ No newline at end of file
diff --git a/app/activity_pub_service.go b/app/activity_pub_service.go
new file mode 100644
index 0000000..1f06ec9
--- /dev/null
+++ b/app/activity_pub_service.go
@@ -0,0 +1,620 @@
+package app
+
+import (
+ "bytes"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "owl-blogs/app/repository"
+ "owl-blogs/config"
+ "owl-blogs/domain/model"
+ entrytypes "owl-blogs/entry_types"
+ "owl-blogs/interactions"
+ "owl-blogs/render"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ vocab "github.com/go-ap/activitypub"
+ "github.com/go-ap/jsonld"
+ "github.com/go-fed/httpsig"
+)
+
+type ActivityPubConfig struct {
+ PreferredUsername string
+ PublicKeyPem string
+ PrivateKeyPem string
+}
+
+// Form implements app.AppConfig.
+func (cfg *ActivityPubConfig) Form(binSvc model.BinaryStorageInterface) string {
+ f, _ := render.RenderTemplateToString("forms/ActivityPubConfig", cfg)
+ return f
+}
+
+// ParseFormData implements app.AppConfig.
+func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
+ cfg.PreferredUsername = data.FormValue("PreferredUsername")
+ cfg.PublicKeyPem = data.FormValue("PublicKeyPem")
+ cfg.PrivateKeyPem = data.FormValue("PrivateKeyPem")
+ return nil
+}
+
+func (cfg *ActivityPubConfig) PrivateKey() *rsa.PrivateKey {
+ block, _ := pem.Decode([]byte(cfg.PrivateKeyPem))
+ privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ if err != nil {
+ slog.Error("error x509.ParsePKCS1PrivateKey", "err", err)
+ }
+ return privKey
+}
+
+type ActivityPubService struct {
+ followersRepo repository.FollowerRepository
+ configRepo repository.ConfigRepository
+ interactionRepository repository.InteractionRepository
+ entryService *EntryService
+ siteConfigServcie *SiteConfigService
+ binService *BinaryService
+}
+
+func NewActivityPubService(
+ followersRepo repository.FollowerRepository,
+ configRepo repository.ConfigRepository,
+ interactionRepository repository.InteractionRepository,
+ entryService *EntryService,
+ siteConfigServcie *SiteConfigService,
+ binService *BinaryService,
+ bus *EventBus,
+) *ActivityPubService {
+ service := &ActivityPubService{
+ followersRepo: followersRepo,
+ configRepo: configRepo,
+ interactionRepository: interactionRepository,
+ entryService: entryService,
+ binService: binService,
+ siteConfigServcie: siteConfigServcie,
+ }
+
+ bus.Subscribe(service)
+
+ return service
+}
+
+func (svc *ActivityPubService) defaultConfig() ActivityPubConfig {
+ privKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ pubKey := privKey.Public().(*rsa.PublicKey)
+
+ pubKeyPem := pem.EncodeToMemory(
+ &pem.Block{
+ Type: "RSA PUBLIC KEY",
+ Bytes: x509.MarshalPKCS1PublicKey(pubKey),
+ },
+ )
+
+ privKeyPrm := pem.EncodeToMemory(
+ &pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: x509.MarshalPKCS1PrivateKey(privKey),
+ },
+ )
+
+ return ActivityPubConfig{
+ PreferredUsername: "blog",
+ PublicKeyPem: string(pubKeyPem),
+ PrivateKeyPem: string(privKeyPrm),
+ }
+}
+
+func (svc *ActivityPubService) GetApConfig() (ActivityPubConfig, error) {
+ apConfig := ActivityPubConfig{}
+ err := svc.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
+ if err != nil {
+ println("ERROR IN ACTIVITY PUB CONFIG")
+ return ActivityPubConfig{}, err
+ }
+ if reflect.ValueOf(apConfig).IsZero() {
+ cfg := svc.defaultConfig()
+ svc.configRepo.Update(config.ACT_PUB_CONF_NAME, cfg)
+ return cfg, nil
+ }
+ return apConfig, nil
+}
+
+func (svc *ActivityPubService) ActorUrl() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl
+}
+func (svc *ActivityPubService) MainKeyUri() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl + "#main-key"
+}
+func (svc *ActivityPubService) InboxUrl() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl + "/activitypub/inbox"
+}
+func (svc *ActivityPubService) OutboxUrl() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl + "/activitypub/outbox"
+}
+func (svc *ActivityPubService) FollowersUrl() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl + "/activitypub/followers"
+}
+func (svc *ActivityPubService) AcccepId() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl + "#accept-" + strconv.FormatInt(time.Now().UnixNano(), 16)
+}
+
+func (svc *ActivityPubService) HashtagId(hashtag string) string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.FullUrl + "/tags/" + strings.ReplaceAll(hashtag, "#", "")
+}
+
+func (svc *ActivityPubService) ActorName() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.Title
+}
+
+func (svc *ActivityPubService) ActorIcon() vocab.Image {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ u := cfg.AvatarUrl
+ pUrl, _ := url.Parse(u)
+ parts := strings.Split(pUrl.Path, ".")
+ fullUrl, _ := url.JoinPath(cfg.FullUrl, u)
+ return vocab.Image{
+ Type: vocab.ImageType,
+ MediaType: vocab.MimeType("image/" + parts[len(parts)-1]),
+ URL: vocab.IRI(fullUrl),
+ }
+}
+
+func (svc *ActivityPubService) ActorSummary() string {
+ cfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ return cfg.SubTitle
+}
+
+func (s *ActivityPubService) AddFollower(follower string) error {
+ return s.followersRepo.Add(follower)
+}
+
+func (s *ActivityPubService) RemoveFollower(follower string) error {
+ return s.followersRepo.Remove(follower)
+}
+
+func (s *ActivityPubService) AllFollowers() ([]string, error) {
+ return s.followersRepo.All()
+}
+
+func (s *ActivityPubService) sign(privateKey *rsa.PrivateKey, pubKeyId string, body []byte, 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, "host", "date"}
+ if body != nil {
+ headersToSign = append(headersToSign, "digest")
+ }
+ signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
+ if err != nil {
+ return err
+ }
+ // To sign the digest, we need to give the signer a copy of the body...
+ // ...but it is optional, no digest will be signed if given "nil"
+ // If r were a http.ResponseWriter, call SignResponse instead.
+ err = signer.SignRequest(privateKey, pubKeyId, r, body)
+
+ slog.Info("Signed Request", "req", r.Header)
+ return err
+}
+
+func (s *ActivityPubService) GetActor(reqUrl string) (vocab.Actor, error) {
+
+ siteConfig := model.SiteConfig{}
+ apConfig := ActivityPubConfig{}
+ s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
+ s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
+
+ c := http.Client{}
+
+ parsedUrl, err := url.Parse(reqUrl)
+ if err != nil {
+ slog.Error("parse error", "err", err)
+ return vocab.Actor{}, err
+ }
+
+ req, _ := http.NewRequest("GET", reqUrl, nil)
+ req.Header.Set("Accept", "application/ld+json")
+ req.Header.Set("Date", time.Now().Format(http.TimeFormat))
+ req.Header.Set("Host", parsedUrl.Host)
+
+ err = s.sign(apConfig.PrivateKey(), s.MainKeyUri(), nil, req)
+ if err != nil {
+ slog.Error("Signing error", "err", err)
+ return vocab.Actor{}, err
+ }
+
+ resp, err := c.Do(req)
+ if err != nil {
+ slog.Error("failed to retrieve sender actor", "err", err, "url", reqUrl)
+ return vocab.Actor{}, err
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return vocab.Actor{}, err
+ }
+
+ item, err := vocab.UnmarshalJSON(data)
+ if err != nil {
+ return vocab.Actor{}, err
+ }
+
+ var actor vocab.Actor
+
+ err = vocab.OnActor(item, func(o *vocab.Actor) error {
+ actor = *o
+ return nil
+ })
+
+ return actor, err
+}
+
+func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) error {
+ siteConfig := model.SiteConfig{}
+ apConfig := ActivityPubConfig{}
+ s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
+ s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
+
+ slog.Info("verifying for", "sender", sender, "retriever", s.ActorUrl())
+
+ actor, err := s.GetActor(sender)
+ // actor does not have a pub key -> don't verify
+ if actor.PublicKey.PublicKeyPem == "" {
+ return nil
+ }
+
+ if err != nil {
+ slog.Error("unable to retrieve actor for sig verification", "sender", sender)
+ return err
+ }
+ block, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem))
+ pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ slog.Error("unable to decode pub key pem", "pubKeyPem", actor.PublicKey.PublicKeyPem)
+ return err
+ }
+ slog.Info("retrieved pub key of sender", "actor", actor, "pubKey", pubKey)
+
+ verifier, err := httpsig.NewVerifier(r)
+ if err != nil {
+ slog.Error("invalid signature", "err", err)
+ return err
+ }
+ return verifier.Verify(pubKey, httpsig.RSA_SHA256)
+}
+
+func (s *ActivityPubService) Accept(act *vocab.Activity) error {
+ actor, err := s.GetActor(act.Actor.GetID().String())
+ if err != nil {
+ return err
+ }
+
+ accept := vocab.AcceptNew(vocab.IRI(s.AcccepId()), act)
+ data, err := jsonld.WithContext(
+ jsonld.IRI(vocab.ActivityBaseURI),
+ ).Marshal(accept)
+
+ if err != nil {
+ slog.Error("marshalling error", "err", err)
+ return err
+ }
+
+ return s.sendObject(actor, data)
+}
+
+func (s *ActivityPubService) AddLike(sender string, liked string, likeId string) error {
+ entry, err := s.entryService.FindByUrl(liked)
+ if err != nil {
+ return err
+ }
+
+ actor, err := s.GetActor(sender)
+ if err != nil {
+ return err
+ }
+
+ var like *interactions.Like
+ interaction, err := s.interactionRepository.FindById(likeId)
+ if err != nil {
+ interaction = &interactions.Like{}
+ }
+ like, ok := interaction.(*interactions.Like)
+ if !ok {
+ return errors.New("existing interaction with same id is not a like")
+ }
+ existing := like.ID() != ""
+
+ likeMeta := interactions.LikeMetaData{
+ SenderUrl: sender,
+ SenderName: actor.Name.String(),
+ }
+ like.SetID(likeId)
+ like.SetMetaData(&likeMeta)
+ like.SetEntryID(entry.ID())
+ like.SetCreatedAt(time.Now())
+ if !existing {
+ return s.interactionRepository.Create(like)
+ } else {
+ return s.interactionRepository.Update(like)
+ }
+}
+
+func (s *ActivityPubService) RemoveLike(id string) error {
+ interaction, err := s.interactionRepository.FindById(id)
+ if err != nil {
+ interaction = &interactions.Like{}
+ }
+ return s.interactionRepository.Delete(interaction)
+}
+
+func (s *ActivityPubService) AddRepost(sender string, reposted string, respostId string) error {
+ entry, err := s.entryService.FindByUrl(reposted)
+ if err != nil {
+ return err
+ }
+
+ actor, err := s.GetActor(sender)
+ if err != nil {
+ return err
+ }
+
+ var repost *interactions.Repost
+ interaction, err := s.interactionRepository.FindById(respostId)
+ if err != nil {
+ interaction = &interactions.Repost{}
+ }
+ repost, ok := interaction.(*interactions.Repost)
+ if !ok {
+ return errors.New("existing interaction with same id is not a like")
+ }
+ existing := repost.ID() != ""
+
+ repostMeta := interactions.RepostMetaData{
+ SenderUrl: sender,
+ SenderName: actor.Name.String(),
+ }
+ repost.SetID(respostId)
+ repost.SetMetaData(&repostMeta)
+ repost.SetEntryID(entry.ID())
+ repost.SetCreatedAt(time.Now())
+ if !existing {
+ return s.interactionRepository.Create(repost)
+ } else {
+ return s.interactionRepository.Update(repost)
+ }
+}
+
+func (s *ActivityPubService) RemoveRepost(id string) error {
+ interaction, err := s.interactionRepository.FindById(id)
+ if err != nil {
+ interaction = &interactions.Repost{}
+ }
+ return s.interactionRepository.Delete(interaction)
+}
+
+func (s *ActivityPubService) sendObject(to vocab.Actor, data []byte) error {
+ siteConfig := model.SiteConfig{}
+ apConfig := ActivityPubConfig{}
+ s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
+ s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
+
+ if to.Inbox == nil {
+ slog.Error("actor has no inbox", "actor", to)
+ return errors.New("actor has no inbox")
+ }
+
+ actorUrl, err := url.Parse(to.Inbox.GetID().String())
+ if err != nil {
+ slog.Error("parse error", "err", err)
+ return err
+ }
+
+ c := http.Client{}
+ req, _ := http.NewRequest("POST", to.Inbox.GetID().String(), bytes.NewReader(data))
+ req.Header.Set("Accept", "application/ld+json")
+ req.Header.Set("Date", time.Now().Format(http.TimeFormat))
+ req.Header.Set("Host", actorUrl.Host)
+ err = s.sign(apConfig.PrivateKey(), s.MainKeyUri(), data, req)
+ if err != nil {
+ slog.Error("Signing error", "err", err)
+ return err
+ }
+ resp, err := c.Do(req)
+ if err != nil {
+ slog.Error("Sending error", "url", req.URL, "err", err)
+ return err
+ }
+ slog.Info("Request", "host", resp.Request.Header)
+
+ if resp.StatusCode > 299 {
+ body, _ := io.ReadAll(resp.Body)
+ slog.Error("Error sending Note", "method", resp.Request.Method, "url", resp.Request.URL, "status", resp.Status, "body", string(body))
+ return err
+ }
+ body, _ := io.ReadAll(resp.Body)
+ slog.Info("Sent Body", "body", string(data))
+ slog.Info("Retrieved", "status", resp.Status, "body", string(body))
+ return nil
+}
+
+/*
+ * Notifiers
+ */
+
+func (svc *ActivityPubService) NotifyEntryCreated(entry model.Entry) {
+ slog.Info("Processing Entry Create for ActivityPub")
+ followers, err := svc.AllFollowers()
+ if err != nil {
+ slog.Error("Cannot retrieve followers")
+ }
+
+ object, err := svc.entryToObject(entry)
+ if err != nil {
+ slog.Error("Cannot convert object", "err", err)
+ }
+
+ create := vocab.CreateNew(object.ID, object)
+ create.Actor = object.AttributedTo
+ create.To = object.To
+ create.Published = object.Published
+ data, err := jsonld.WithContext(
+ jsonld.IRI(vocab.ActivityBaseURI),
+ jsonld.Context{
+ jsonld.ContextElement{
+ Term: "toot",
+ IRI: jsonld.IRI("http://joinmastodon.org/ns#"),
+ },
+ },
+ ).Marshal(create)
+ if err != nil {
+ slog.Error("marshalling error", "err", err)
+ }
+
+ for _, follower := range followers {
+ actor, err := svc.GetActor(follower)
+ if err != nil {
+ slog.Error("Unable to retrieve follower actor", "err", err)
+ }
+ svc.sendObject(actor, data)
+ }
+}
+
+func (svc *ActivityPubService) NotifyEntryUpdated(entry model.Entry) {
+
+}
+
+func (svc *ActivityPubService) NotifyEntryDeleted(entry model.Entry) {
+ obj, err := svc.entryToObject(entry)
+ if err != nil {
+ slog.Error("error converting to object", "err", err)
+ return
+ }
+
+ followers, err := svc.AllFollowers()
+ if err != nil {
+ slog.Error("Cannot retrieve followers")
+ }
+
+ delete := vocab.DeleteNew(obj.ID, obj)
+ delete.Actor = obj.AttributedTo
+ delete.To = obj.To
+ delete.Published = time.Now()
+ data, err := jsonld.WithContext(
+ jsonld.IRI(vocab.ActivityBaseURI),
+ ).Marshal(delete)
+ if err != nil {
+ slog.Error("marshalling error", "err", err)
+ }
+
+ for _, follower := range followers {
+ actor, err := svc.GetActor(follower)
+ if err != nil {
+ slog.Error("Unable to retrieve follower actor", "err", err)
+ }
+ svc.sendObject(actor, data)
+ }
+
+}
+
+func (svc *ActivityPubService) entryToObject(entry model.Entry) (vocab.Object, error) {
+ // limit to notes for now
+
+ if noteEntry, ok := entry.(*entrytypes.Note); ok {
+ return svc.noteToObject(noteEntry), nil
+ }
+ if imageEntry, ok := entry.(*entrytypes.Image); ok {
+ return svc.imageToObject(imageEntry), nil
+ }
+ slog.Warn("entry type not yet supported for activity pub")
+ return vocab.Object{}, errors.New("entry type not supported")
+}
+
+func (svc *ActivityPubService) noteToObject(noteEntry *entrytypes.Note) vocab.Object {
+
+ siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ content := noteEntry.Content()
+ r := regexp.MustCompile("#[a-z0-9_]+")
+ matches := r.FindAllString(string(content), -1)
+ tags := vocab.ItemCollection{}
+ for _, hashtag := range matches {
+ tags.Append(vocab.Object{
+ ID: vocab.ID(svc.HashtagId(hashtag)),
+ Name: vocab.NaturalLanguageValues{{Value: vocab.Content(hashtag)}},
+ })
+ }
+
+ note := vocab.Note{
+ ID: vocab.ID(noteEntry.FullUrl(siteCfg)),
+ Type: "Note",
+ To: vocab.ItemCollection{
+ vocab.PublicNS,
+ vocab.IRI(svc.FollowersUrl()),
+ },
+ Published: *noteEntry.PublishedAt(),
+ AttributedTo: vocab.ID(svc.ActorUrl()),
+ Content: vocab.NaturalLanguageValues{
+ {Value: vocab.Content(content)},
+ },
+ Tag: tags,
+ }
+ return note
+
+}
+
+func (svc *ActivityPubService) imageToObject(imageEntry *entrytypes.Image) vocab.Object {
+ siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
+ content := imageEntry.Content()
+
+ imgPath := imageEntry.ImageUrl()
+ fullImageUrl, _ := url.JoinPath(siteCfg.FullUrl, imgPath)
+ binaryFile, err := svc.binService.FindById(imageEntry.MetaData().(*entrytypes.ImageMetaData).ImageId)
+ if err != nil {
+ slog.Error("cannot get image file")
+ }
+
+ attachments := vocab.ItemCollection{}
+ attachments = append(attachments, vocab.Document{
+ Type: vocab.DocumentType,
+ MediaType: vocab.MimeType(binaryFile.Mime()),
+ URL: vocab.ID(fullImageUrl),
+ })
+
+ image := vocab.Note{
+ ID: vocab.ID(imageEntry.FullUrl(siteCfg)),
+ Type: "Note",
+ To: vocab.ItemCollection{
+ vocab.PublicNS,
+ vocab.IRI(svc.FollowersUrl()),
+ },
+ Published: *imageEntry.PublishedAt(),
+ AttributedTo: vocab.ID(svc.ActorUrl()),
+ Name: vocab.NaturalLanguageValues{
+ {Value: vocab.Content(imageEntry.Title())},
+ },
+ Content: vocab.NaturalLanguageValues{
+ {Value: vocab.Content(content)},
+ },
+ Attachment: attachments,
+ // Tag: tags,
+ }
+ return image
+
+}
diff --git a/app/entry_service.go b/app/entry_service.go
index 491e6c9..26a2053 100644
--- a/app/entry_service.go
+++ b/app/entry_service.go
@@ -1,6 +1,7 @@
package app
import (
+ "errors"
"fmt"
"owl-blogs/app/repository"
"owl-blogs/domain/model"
@@ -9,17 +10,20 @@ import (
)
type EntryService struct {
- EntryRepository repository.EntryRepository
- Bus *EventBus
+ EntryRepository repository.EntryRepository
+ siteConfigServcie *SiteConfigService
+ Bus *EventBus
}
func NewEntryService(
entryRepository repository.EntryRepository,
+ siteConfigServcie *SiteConfigService,
bus *EventBus,
) *EntryService {
return &EntryService{
- EntryRepository: entryRepository,
- Bus: bus,
+ EntryRepository: entryRepository,
+ siteConfigServcie: siteConfigServcie,
+ Bus: bus,
}
}
@@ -70,6 +74,19 @@ func (s *EntryService) FindById(id string) (model.Entry, error) {
return s.EntryRepository.FindById(id)
}
+func (s *EntryService) FindByUrl(url string) (model.Entry, error) {
+ cfg, _ := s.siteConfigServcie.GetSiteConfig()
+ if !strings.HasPrefix(url, cfg.FullUrl) {
+ return nil, errors.New("url does not belong to blog")
+ }
+ if strings.HasSuffix(url, "/") {
+ url = url[:len(url)-1]
+ }
+ parts := strings.Split(url, "/")
+ id := parts[len(parts)-1]
+ return s.FindById(id)
+}
+
func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry {
filteredEntries := make([]model.Entry, 0)
for _, entry := range entries {
diff --git a/app/entry_service_test.go b/app/entry_service_test.go
index 88a4e4b..ec7cd78 100644
--- a/app/entry_service_test.go
+++ b/app/entry_service_test.go
@@ -14,7 +14,7 @@ func setupService() *app.EntryService {
register := app.NewEntryTypeRegistry()
register.Register(&test.MockEntry{})
repo := infra.NewEntryRepository(db, register)
- service := app.NewEntryService(repo, app.NewEventBus())
+ service := app.NewEntryService(repo, nil, app.NewEventBus())
return service
}
diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go
index b0aeed2..0c3c046 100644
--- a/app/repository/interfaces.go
+++ b/app/repository/interfaces.go
@@ -51,3 +51,9 @@ type InteractionRepository interface {
// ListAllInteractions lists all interactions, sorted by creation date (descending)
ListAllInteractions() ([]model.Interaction, error)
}
+
+type FollowerRepository interface {
+ Add(follower string) error
+ Remove(follower string) error
+ All() ([]string, error)
+}
diff --git a/avatar.jpg b/avatar.jpg
deleted file mode 100644
index 30c33c4..0000000
Binary files a/avatar.jpg and /dev/null differ
diff --git a/cmd/owl/main.go b/cmd/owl/main.go
index 8b0304b..e864ca1 100644
--- a/cmd/owl/main.go
+++ b/cmd/owl/main.go
@@ -41,6 +41,8 @@ func App(db infra.Database) *web.WebApp {
interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{})
+ interactionRegister.Register(&interactions.Like{})
+ interactionRegister.Register(&interactions.Repost{})
configRegister := app.NewConfigRegister()
@@ -50,6 +52,7 @@ func App(db infra.Database) *web.WebApp {
authorRepo := infra.NewDefaultAuthorRepo(db)
configRepo := infra.NewConfigRepo(db)
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
+ followersRepo := infra.NewFollowerRepository(db)
// Create External Services
httpClient := &infra.OwlHttpClient{}
@@ -59,12 +62,17 @@ func App(db infra.Database) *web.WebApp {
// Create Services
siteConfigService := app.NewSiteConfigService(configRepo)
- entryService := app.NewEntryService(entryRepo, eventBus)
+ entryService := app.NewEntryService(entryRepo, siteConfigService, eventBus)
binaryService := app.NewBinaryFileService(binRepo)
authorService := app.NewAuthorService(authorRepo, siteConfigService)
webmentionService := app.NewWebmentionService(
siteConfigService, interactionRepo, entryRepo, httpClient, eventBus,
)
+ apService := app.NewActivityPubService(
+ followersRepo, configRepo, interactionRepo,
+ entryService, siteConfigService, binaryService,
+ eventBus,
+ )
// setup render functions
render.SiteConfigService = siteConfigService
@@ -80,6 +88,7 @@ func App(db infra.Database) *web.WebApp {
entryService, entryRegister, binaryService,
authorService, configRepo, configRegister,
siteConfigService, webmentionService, interactionRepo,
+ apService,
)
}
diff --git a/config/config.go b/config/config.go
index b7ce95d..0f406a7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -3,7 +3,8 @@ package config
import "os"
const (
- SITE_CONFIG = "site_config"
+ SITE_CONFIG = "site_config"
+ ACT_PUB_CONF_NAME = "activity_pub"
)
type Config interface {
diff --git a/domain/model/entry.go b/domain/model/entry.go
index e59562f..15fb2c3 100644
--- a/domain/model/entry.go
+++ b/domain/model/entry.go
@@ -1,6 +1,7 @@
package model
import (
+ "net/url"
"time"
)
@@ -21,6 +22,8 @@ type Entry interface {
SetPublishedAt(publishedAt *time.Time)
SetMetaData(metaData EntryMetaData)
SetAuthorId(authorId string)
+
+ FullUrl(cfg SiteConfig) string
}
type EntryMetaData interface {
@@ -60,3 +63,8 @@ func (e *EntryBase) AuthorId() string {
func (e *EntryBase) SetAuthorId(authorId string) {
e.authorId = authorId
}
+
+func (e *EntryBase) FullUrl(cfg SiteConfig) string {
+ u, _ := url.JoinPath(cfg.FullUrl, "/posts/", e.ID(), "/")
+ return u
+}
diff --git a/domain/model/entry_test.go b/domain/model/entry_test.go
new file mode 100644
index 0000000..19f157f
--- /dev/null
+++ b/domain/model/entry_test.go
@@ -0,0 +1,34 @@
+package model_test
+
+import (
+ "owl-blogs/domain/model"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestEntryFullUrl(t *testing.T) {
+
+ type testCase struct {
+ Id string
+ Url string
+ Want string
+ }
+
+ testCases := []testCase{
+ {Id: "foobar", Url: "https://example.com", Want: "https://example.com/posts/foobar/"},
+ {Id: "foobar", Url: "https://example.com/", Want: "https://example.com/posts/foobar/"},
+ {Id: "foobar", Url: "http://example.com", Want: "http://example.com/posts/foobar/"},
+ {Id: "foobar", Url: "http://example.com/", Want: "http://example.com/posts/foobar/"},
+ {Id: "bi-bar-buz", Url: "https://example.com", Want: "https://example.com/posts/bi-bar-buz/"},
+ {Id: "foobar", Url: "https://example.com/lol/", Want: "https://example.com/lol/posts/foobar/"},
+ }
+
+ for _, test := range testCases {
+ e := model.EntryBase{}
+ e.SetID(test.Id)
+ cfg := model.SiteConfig{FullUrl: test.Url}
+ require.Equal(t, e.FullUrl(cfg), test.Want)
+ }
+
+}
diff --git a/e2e_tests/conftest.py b/e2e_tests/conftest.py
new file mode 100644
index 0000000..e3ae9ac
--- /dev/null
+++ b/e2e_tests/conftest.py
@@ -0,0 +1,49 @@
+import pytest
+from requests import Session
+from urllib.parse import urljoin
+from tests.fixtures import ACCT_NAME
+
+
+class LiveServerSession(Session):
+ def __init__(self, base_url=None):
+ super().__init__()
+ self.base_url = base_url
+
+ def request(self, method, url, *args, **kwargs):
+ joined_url = urljoin(self.base_url, url)
+ return super().request(method, joined_url, *args, **kwargs)
+
+
+@pytest.fixture
+def client():
+ return LiveServerSession("http://localhost:3000")
+
+
+@pytest.fixture
+def actor_url(client):
+ resp = client.get(f"/.well-known/webfinger?resource={ACCT_NAME}")
+ data = resp.json()
+ self_link = [x for x in data["links"] if x["rel"] == "self"][0]
+ return self_link["href"]
+
+
+@pytest.fixture
+def actor(client, actor_url):
+ resp = client.get(actor_url, headers={"Content-Type": "application/activity+json"})
+ assert resp.status_code == 200
+ return resp.json()
+
+
+@pytest.fixture
+def inbox_url(actor):
+ return actor["inbox"]
+
+
+@pytest.fixture
+def outbox_url(actor):
+ return actor["outbox"]
+
+
+@pytest.fixture
+def followers_url(actor):
+ return actor["followers"]
diff --git a/e2e_tests/docker-compose.yml b/e2e_tests/docker-compose.yml
new file mode 100644
index 0000000..991de34
--- /dev/null
+++ b/e2e_tests/docker-compose.yml
@@ -0,0 +1,24 @@
+services:
+ web:
+ build:
+ context: ../
+ dockerfile: Dockerfile.test
+ volumes:
+ - ../app:/go/owl/app
+ - ../assets:/go/owl/assets
+ - ../cmd:/go/owl/cmd
+ - ../config:/go/owl/config
+ - ../domain:/go/owl/domain
+ - ../entry_types:/go/owl/entry_types
+ - ../importer:/go/owl/importer
+ - ../infra:/go/owl/infra
+ - ../interactions:/go/owl/interactions
+ - ../plugings:/go/owl/plugings
+ - ../render:/go/owl/render
+ - ../web:/go/owl/web
+ ports:
+ - "3000:3000"
+ mock_masto:
+ build: mock_masto
+ ports:
+ - 8000:8000
diff --git a/e2e_tests/mock_masto/Dockerfile b/e2e_tests/mock_masto/Dockerfile
new file mode 100644
index 0000000..900aa26
--- /dev/null
+++ b/e2e_tests/mock_masto/Dockerfile
@@ -0,0 +1,6 @@
+FROM python:3.11
+
+COPY . .
+RUN pip install -r requirements.txt
+
+CMD [ "python", "main.py" ]
\ No newline at end of file
diff --git a/e2e_tests/mock_masto/main.py b/e2e_tests/mock_masto/main.py
new file mode 100644
index 0000000..692e75c
--- /dev/null
+++ b/e2e_tests/mock_masto/main.py
@@ -0,0 +1,214 @@
+import json
+from flask import Flask, request
+
+app = Flask(__name__)
+
+
+PRIV_KEY_PEM = """-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCni8P4bvkC/3Sx
+NTrDw1qw0vWtJKZMsyJ3Mcs4+1apoVqOQhujUqGqFSiRT7Vmc7OEhB0vikdiTkCk
+1PcoTd/rOa/0WbG8385JcLzaJfTIG+rrRtHwZ1TwxwBju43jpGEZjpbA0dqoXMkr
+J1MyD7aPLoAiVe0ikw2czSZumv4ncemOtk0VG3b2TnIxo3CMKtUOWu8xT08MMIuo
+3cZRnpI6Xr/ULzvjv8e3EjIpwRJqMPECtGsfdcjFmR0yFIrjrlmkZTiW31z/Dk7i
+xRGD0ADy3WEQ3lA4l3mNZeyG4S0Wi4iYe9/wegESMZcakLoME7ks+KNS388Mdbcd
+DKy9NmWvAgMBAAECggEABLQAA0hHhdWv6+Lc9xkpFuTvxTV4fuyvCf4u1eGlnstg
+ZF/nW1/6w8XQ8WCgbJ4mKuZz1J14FYKxfoRaj8S9MA2Ff+wd+M77gRpAuDWajRzO
+LQk8OW2yd7POXKkAzvln9F9eofkCFKR4zSpPGTenCJaQkuYrQEOKfUf7oofdRzQi
+w9kmp3wAxM/EseHZpknYDCgDQV7MDQAaMD7kbynL2WfXPxebktwpRlKUwgtGrevj
+gagQL8J/GX6wO3ymw9sln4BhlI2+3LuiMXQdQc1tamkXFCguCuOZCu/2VRdCHmiS
+nnpu+FMspBHbvxO+RXo3Cu/S6jjJgoQxD2WZTE0gqQKBgQDM6AQdqBYjISdkI9Gl
+6ZLLjwZRJSYpopujtX7pun61l9kUwQevaR2Z39rMWxX62DD6arazi/ygIUBw6Kgp
+s/qBEb29ec+0cESdC8aJYb3dGvDzh/8C05p7ozxj8JZQcxq5W5jql/BELlSsUONO
+jfqQv8RGZNSkD9uy6TxOr4eWIwKBgQDRUuO/XRDLt8Mp10mTshxTznSQ3gAJYKeG
+0WfEC3kPEukHBQb8huqFcQDiQ71oBWuEdOQWgT3aBS6L+nIMyZMT5u+BejQm7/E5
+pMM+z0VRpfFSsIrCvU8yKam0aemQGlKQAfhTct1gCg+wKnYsSQMlNHKWEfDbw9I/
+cns/IN+dBQKBgQC6/Of0oFVDTZgC3GUPAO3C8QwUtM/0or1hUdk1Nck3shCZzeVT
+f5tRtmSWpHCUbwGTJBsCEjdBcda6srXzCJkLe8Moy6ZtxR34KqzM5fM7eMB1nJ9s
+Vunc9gPAN+cUF1ZF3H7ZZjoOHjGK5m3oW8xSl41np9Acv5P/2rP8Ilaa/QKBgQDJ
+YwISfitGk8mEW8hB/L4cMykapztJyl/i6Vz31EHoKr1fL4sFMZg4QfwjtCBqD6zd
+hshajoU/WHTr30wS2WxTXX9YBoZeX8KpPsdJioiagRioAYm+yfuDu2m2VZ+MMIb2
+Xa7YOk6Zs5RcXL3M5YHNLaSAlUoxZTjGKhJBLhN1MQKBgQCbo3ngBl7Qjjx4WJ93
+2WEEKvSDCv69eecNQDuKWKEiFqBN23LheNrN8DXMWFTtE4miY106dzQ0dUMh418x
+K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
+0PuW3IUym9KvO0WX9E+1Qw8mbw==
+-----END PRIVATE KEY-----"""
+
+PUB_KEY_PEM = """-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp4vD+G75Av90sTU6w8Na
+sNL1rSSmTLMidzHLOPtWqaFajkIbo1KhqhUokU+1ZnOzhIQdL4pHYk5ApNT3KE3f
+6zmv9FmxvN/OSXC82iX0yBvq60bR8GdU8McAY7uN46RhGY6WwNHaqFzJKydTMg+2
+jy6AIlXtIpMNnM0mbpr+J3HpjrZNFRt29k5yMaNwjCrVDlrvMU9PDDCLqN3GUZ6S
+Ol6/1C8747/HtxIyKcESajDxArRrH3XIxZkdMhSK465ZpGU4lt9c/w5O4sURg9AA
+8t1hEN5QOJd5jWXshuEtFouImHvf8HoBEjGXGpC6DBO5LPijUt/PDHW3HQysvTZl
+rwIDAQAB
+-----END PUBLIC KEY-----"""
+
+INBOX = []
+
+
+@app.route("/.well-known/webfinger")
+def webfinger():
+ return json.dumps(
+ {
+ "subject": "acct:h4kor@mock_masto",
+ "aliases": [
+ "http://mock_masto:8000/@h4kor",
+ "http://mock_masto:8000/users/h4kor",
+ ],
+ "links": [
+ {
+ "rel": "http://webfinger.net/rel/profile-page",
+ "type": "text/html",
+ "href": "http://mock_masto:8000/@h4kor",
+ },
+ {
+ "rel": "self",
+ "type": "application/activity+json",
+ "href": "http://mock_masto:8000/users/h4kor",
+ },
+ {
+ "rel": "http://ostatus.org/schema/1.0/subscribe",
+ "template": "http://mock_masto:8000/authorize_interaction?uri={uri}",
+ },
+ {
+ "rel": "http://webfinger.net/rel/avatar",
+ "type": "image/png",
+ "href": "http://assets.mock_masto/accounts/avatars/000/082/056/original/a4be9944e3b03229.png",
+ },
+ ],
+ }
+ )
+
+
+@app.route("/users/h4kor")
+def actor():
+ print("request to actor")
+ return json.dumps(
+ {
+ "@context": [
+ "http://www.w3.org/ns/activitystreams",
+ "http://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "toot": "http://joinmastodon.org/ns#",
+ "featured": {"@id": "toot:featured", "@type": "@id"},
+ "featuredTags": {"@id": "toot:featuredTags", "@type": "@id"},
+ "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
+ "movedTo": {"@id": "as:movedTo", "@type": "@id"},
+ "schema": "http://schema.org#",
+ "PropertyValue": "schema:PropertyValue",
+ "value": "schema:value",
+ "discoverable": "toot:discoverable",
+ "Device": "toot:Device",
+ "Ed25519Signature": "toot:Ed25519Signature",
+ "Ed25519Key": "toot:Ed25519Key",
+ "Curve25519Key": "toot:Curve25519Key",
+ "EncryptedMessage": "toot:EncryptedMessage",
+ "publicKeyBase64": "toot:publicKeyBase64",
+ "deviceId": "toot:deviceId",
+ "claim": {"@type": "@id", "@id": "toot:claim"},
+ "fingerprintKey": {"@type": "@id", "@id": "toot:fingerprintKey"},
+ "identityKey": {"@type": "@id", "@id": "toot:identityKey"},
+ "devices": {"@type": "@id", "@id": "toot:devices"},
+ "messageFranking": "toot:messageFranking",
+ "messageType": "toot:messageType",
+ "cipherText": "toot:cipherText",
+ "suspended": "toot:suspended",
+ "memorial": "toot:memorial",
+ "indexable": "toot:indexable",
+ "Hashtag": "as:Hashtag",
+ "focalPoint": {"@container": "@list", "@id": "toot:focalPoint"},
+ },
+ ],
+ "id": "http://mock_masto:8000/users/h4kor",
+ "type": "Person",
+ "following": "http://mock_masto:8000/users/h4kor/following",
+ "followers": "http://mock_masto:8000/users/h4kor/followers",
+ "inbox": "http://mock_masto:8000/users/h4kor/inbox",
+ "outbox": "http://mock_masto:8000/users/h4kor/outbox",
+ "featured": "http://mock_masto:8000/users/h4kor/collections/featured",
+ "featuredTags": "http://mock_masto:8000/users/h4kor/collections/tags",
+ "preferredUsername": "h4kor",
+ "name": "Niko",
+ "summary": '
Teaching computers to do things with arguable efficiency.
he/him
#vegan #cooking #programming #politics #climate
',
+ "url": "http://mock_masto:8000/@h4kor",
+ "manuallyApprovesFollowers": False,
+ "discoverable": True,
+ "indexable": False,
+ "published": "2018-08-16T00:00:00Z",
+ "memorial": False,
+ "devices": "http://mock_masto:8000/users/h4kor/collections/devices",
+ "publicKey": {
+ "id": "http://mock_masto:8000/users/h4kor#main-key",
+ "owner": "http://mock_masto:8000/users/h4kor",
+ "publicKeyPem": PUB_KEY_PEM,
+ },
+ "tag": [
+ {
+ "type": "Hashtag",
+ "href": "http://mock_masto:8000/tags/politics",
+ "name": "#politics",
+ },
+ {
+ "type": "Hashtag",
+ "href": "http://mock_masto:8000/tags/climate",
+ "name": "#climate",
+ },
+ {
+ "type": "Hashtag",
+ "href": "http://mock_masto:8000/tags/vegan",
+ "name": "#vegan",
+ },
+ {
+ "type": "Hashtag",
+ "href": "http://mock_masto:8000/tags/programming",
+ "name": "#programming",
+ },
+ {
+ "type": "Hashtag",
+ "href": "http://mock_masto:8000/tags/cooking",
+ "name": "#cooking",
+ },
+ ],
+ "attachment": [
+ {
+ "type": "PropertyValue",
+ "name": "Me",
+ "value": 'http:// rerere.org ',
+ },
+ {
+ "type": "PropertyValue",
+ "name": "Blog",
+ "value": 'http:// blog.libove.org ',
+ },
+ {"type": "PropertyValue", "name": "Location", "value": "Münster"},
+ {
+ "type": "PropertyValue",
+ "name": "Current Project",
+ "value": 'http:// git.libove.org/h4kor/owl-blogs ',
+ },
+ ],
+ "endpoints": {"sharedInbox": "http://mock_masto:8000/inbox"},
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": "http://assets.mock_masto/accounts/avatars/000/082/056/original/a4be9944e3b03229.png",
+ },
+ }
+ )
+
+
+@app.route("/users/h4kor/inbox", methods=["POST"])
+def inbox():
+ if request.method == "POST":
+ INBOX.append(json.loads(request.get_data()))
+ return ""
+
+
+@app.route("/msgs")
+def msgs():
+ return json.dumps(INBOX)
+
+
+if __name__ == "__main__":
+ app.run(debug=True, host="0.0.0.0", port="8000")
diff --git a/e2e_tests/mock_masto/requirements.txt b/e2e_tests/mock_masto/requirements.txt
new file mode 100644
index 0000000..a993b8d
--- /dev/null
+++ b/e2e_tests/mock_masto/requirements.txt
@@ -0,0 +1 @@
+Flask==3.0.3
\ No newline at end of file
diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt
new file mode 100644
index 0000000..df3c493
--- /dev/null
+++ b/e2e_tests/requirements.txt
@@ -0,0 +1,11 @@
+certifi==2024.2.2
+charset-normalizer==3.3.2
+exceptiongroup==1.2.1
+idna==3.7
+iniconfig==2.0.0
+packaging==24.0
+pluggy==1.5.0
+pytest==8.2.0
+requests==2.31.0
+tomli==2.0.1
+urllib3==2.2.1
diff --git a/e2e_tests/tests/__init__.py b/e2e_tests/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/e2e_tests/tests/fixtures.py b/e2e_tests/tests/fixtures.py
new file mode 100644
index 0000000..c4481a8
--- /dev/null
+++ b/e2e_tests/tests/fixtures.py
@@ -0,0 +1,102 @@
+from contextlib import contextmanager
+from datetime import datetime, timezone
+import json
+from time import sleep
+from urllib.parse import urlparse
+import requests, base64, hashlib
+from urllib.parse import urlparse
+from cryptography.hazmat.primitives.serialization import load_pem_private_key
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import padding
+
+ACCT_NAME = "acct:blog@localhost:3000"
+
+PRIV_KEY_PEM = """-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCni8P4bvkC/3Sx
+NTrDw1qw0vWtJKZMsyJ3Mcs4+1apoVqOQhujUqGqFSiRT7Vmc7OEhB0vikdiTkCk
+1PcoTd/rOa/0WbG8385JcLzaJfTIG+rrRtHwZ1TwxwBju43jpGEZjpbA0dqoXMkr
+J1MyD7aPLoAiVe0ikw2czSZumv4ncemOtk0VG3b2TnIxo3CMKtUOWu8xT08MMIuo
+3cZRnpI6Xr/ULzvjv8e3EjIpwRJqMPECtGsfdcjFmR0yFIrjrlmkZTiW31z/Dk7i
+xRGD0ADy3WEQ3lA4l3mNZeyG4S0Wi4iYe9/wegESMZcakLoME7ks+KNS388Mdbcd
+DKy9NmWvAgMBAAECggEABLQAA0hHhdWv6+Lc9xkpFuTvxTV4fuyvCf4u1eGlnstg
+ZF/nW1/6w8XQ8WCgbJ4mKuZz1J14FYKxfoRaj8S9MA2Ff+wd+M77gRpAuDWajRzO
+LQk8OW2yd7POXKkAzvln9F9eofkCFKR4zSpPGTenCJaQkuYrQEOKfUf7oofdRzQi
+w9kmp3wAxM/EseHZpknYDCgDQV7MDQAaMD7kbynL2WfXPxebktwpRlKUwgtGrevj
+gagQL8J/GX6wO3ymw9sln4BhlI2+3LuiMXQdQc1tamkXFCguCuOZCu/2VRdCHmiS
+nnpu+FMspBHbvxO+RXo3Cu/S6jjJgoQxD2WZTE0gqQKBgQDM6AQdqBYjISdkI9Gl
+6ZLLjwZRJSYpopujtX7pun61l9kUwQevaR2Z39rMWxX62DD6arazi/ygIUBw6Kgp
+s/qBEb29ec+0cESdC8aJYb3dGvDzh/8C05p7ozxj8JZQcxq5W5jql/BELlSsUONO
+jfqQv8RGZNSkD9uy6TxOr4eWIwKBgQDRUuO/XRDLt8Mp10mTshxTznSQ3gAJYKeG
+0WfEC3kPEukHBQb8huqFcQDiQ71oBWuEdOQWgT3aBS6L+nIMyZMT5u+BejQm7/E5
+pMM+z0VRpfFSsIrCvU8yKam0aemQGlKQAfhTct1gCg+wKnYsSQMlNHKWEfDbw9I/
+cns/IN+dBQKBgQC6/Of0oFVDTZgC3GUPAO3C8QwUtM/0or1hUdk1Nck3shCZzeVT
+f5tRtmSWpHCUbwGTJBsCEjdBcda6srXzCJkLe8Moy6ZtxR34KqzM5fM7eMB1nJ9s
+Vunc9gPAN+cUF1ZF3H7ZZjoOHjGK5m3oW8xSl41np9Acv5P/2rP8Ilaa/QKBgQDJ
+YwISfitGk8mEW8hB/L4cMykapztJyl/i6Vz31EHoKr1fL4sFMZg4QfwjtCBqD6zd
+hshajoU/WHTr30wS2WxTXX9YBoZeX8KpPsdJioiagRioAYm+yfuDu2m2VZ+MMIb2
+Xa7YOk6Zs5RcXL3M5YHNLaSAlUoxZTjGKhJBLhN1MQKBgQCbo3ngBl7Qjjx4WJ93
+2WEEKvSDCv69eecNQDuKWKEiFqBN23LheNrN8DXMWFTtE4miY106dzQ0dUMh418x
+K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
+0PuW3IUym9KvO0WX9E+1Qw8mbw==
+-----END PRIVATE KEY-----"""
+
+
+def ensure_follow(client, inbox_url, actor_url):
+ req = sign(
+ "POST",
+ inbox_url,
+ {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
+ "type": "Follow",
+ "actor": "http://mock_masto:8000/users/h4kor",
+ "object": actor_url,
+ },
+ )
+ resp = requests.Session().send(req)
+
+ assert resp.status_code == 200
+
+
+def sign(method, url, data):
+
+ priv_key = load_pem_private_key(PRIV_KEY_PEM.encode(), None)
+ body = json.dumps(data).encode()
+ body_hash = hashlib.sha256(body).digest()
+ digest = "SHA-256=" + base64.b64encode(body_hash).decode()
+ date = datetime.now(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
+ host = "localhost:3000"
+ target = urlparse(url).path
+ to_sign = f"""(request-target): {method.lower()} {target}
+host: {host}
+date: {date}""".encode()
+ sig = priv_key.sign(
+ to_sign,
+ padding.PKCS1v15(),
+ hashes.SHA256(),
+ )
+ sig_str = base64.b64encode(sig).decode()
+
+ request = requests.Request(method, url, data=body)
+ request = request.prepare()
+ request.headers["Content-Digest"] = digest
+ request.headers["Host"] = host
+ request.headers["Date"] = date
+ request.headers["Signature"] = (
+ f'keyId="http://mock_masto:8000/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"'
+ )
+ return request
+
+
+@contextmanager
+def msg_inc(n):
+ resp = requests.get("http://localhost:8000/msgs")
+ data = resp.json()
+ msgs = len(data)
+ yield
+ sleep(0.2)
+ resp = requests.get("http://localhost:8000/msgs")
+ data = resp.json()
+ assert msgs + n == len(
+ data
+ ), f"prev: {msgs}, now: {len(data)}, expected: {msgs + n}"
diff --git a/e2e_tests/tests/test_activity_pub.py b/e2e_tests/tests/test_activity_pub.py
new file mode 100644
index 0000000..1b543e0
--- /dev/null
+++ b/e2e_tests/tests/test_activity_pub.py
@@ -0,0 +1,87 @@
+from pprint import pprint
+from time import sleep
+import requests
+from .fixtures import ensure_follow, msg_inc, sign
+import pytest
+
+
+def test_actor(client, actor_url):
+ resp = client.get(actor_url, headers={"Content-Type": "application/activity+json"})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "id" in data
+ assert "type" in data
+ assert "inbox" in data
+ assert "outbox" in data
+ assert "followers" in data
+ assert "preferredUsername" in data
+ assert "publicKey" in data
+ assert len(data["publicKey"])
+
+ pubKey = data["publicKey"]
+ assert "id" in pubKey
+ assert "owner" in pubKey
+ assert "publicKeyPem" in pubKey
+
+ assert pubKey["owner"] == data["id"]
+ assert pubKey["id"] != data["id"]
+ assert "-----BEGIN RSA PUBLIC KEY-----" in pubKey["publicKeyPem"]
+
+
+def test_following(client, inbox_url, followers_url, actor_url):
+ with msg_inc(1):
+ req = sign(
+ "POST",
+ inbox_url,
+ {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
+ "type": "Follow",
+ "actor": "http://mock_masto:8000/users/h4kor",
+ "object": actor_url,
+ },
+ )
+ resp = requests.Session().send(req)
+
+ assert resp.status_code == 200
+
+ resp = client.get(
+ followers_url, headers={"Content-Type": "application/activity+json"}
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ pprint(data)
+ assert "items" in data
+ assert len(data["items"]) == 1
+
+
+def test_unfollow(client, inbox_url, followers_url, actor_url):
+ ensure_follow(client, inbox_url, actor_url)
+ with msg_inc(1):
+ req = sign(
+ "POST",
+ inbox_url,
+ {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo",
+ "type": "Undo",
+ "actor": "http://mock_masto:8000/users/h4kor",
+ "object": {
+ "id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
+ "type": "Follow",
+ "actor": "http://mock_masto:8000/users/h4kor",
+ "object": actor_url,
+ },
+ },
+ )
+ resp = requests.Session().send(req)
+ assert resp.status_code == 200
+
+ resp = client.get(
+ followers_url, headers={"Content-Type": "application/activity+json"}
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ pprint(data)
+ assert "totalItems" in data
+ assert data["totalItems"] == 0
diff --git a/e2e_tests/tests/test_webfinger.py b/e2e_tests/tests/test_webfinger.py
new file mode 100644
index 0000000..1119507
--- /dev/null
+++ b/e2e_tests/tests/test_webfinger.py
@@ -0,0 +1,27 @@
+import pytest
+from .fixtures import ACCT_NAME
+
+
+@pytest.mark.parametrize(
+ ["query", "status"],
+ [
+ ["", 404],
+ ["?foo=bar", 404],
+ ["?resource=lol@bar.com", 404],
+ [f"?resource={ACCT_NAME}", 200],
+ ],
+)
+def test_webfinger_status(client, query, status):
+ resp = client.get("/.well-known/webfinger" + query)
+ assert resp.status_code == status
+
+
+def test_webfinger(client):
+ resp = client.get(f"/.well-known/webfinger?resource={ACCT_NAME}")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["subject"] == ACCT_NAME
+ assert len(data["links"]) > 0
+ self_link = [x for x in data["links"] if x["rel"] == "self"][0]
+ assert self_link["type"] == "application/activity+json"
+ assert "href" in self_link
diff --git a/go.mod b/go.mod
index 7696ce1..c0bb528 100644
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@ require (
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 // indirect
+ github.com/go-fed/httpsig v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
diff --git a/go.sum b/go.sum
index 2c26529..3c49271 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 h1:H9MGShwybHLSln6K8R
github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0/go.mod h1:5x8a6P/dhmMGFxWLcyYlyOuJ2lRNaHGhRv+yu8BaTSI=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
+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-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
@@ -84,15 +86,21 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
diff --git a/infra/follower_repository.go b/infra/follower_repository.go
new file mode 100644
index 0000000..386ed15
--- /dev/null
+++ b/infra/follower_repository.go
@@ -0,0 +1,57 @@
+package infra
+
+import (
+ "owl-blogs/app/repository"
+
+ "github.com/jmoiron/sqlx"
+)
+
+type sqlFollower struct {
+ Follwer string `db:"follower"`
+}
+
+type DefaultFollowerRepo struct {
+ db *sqlx.DB
+}
+
+func NewFollowerRepository(db Database) repository.FollowerRepository {
+ sqlxdb := db.Get()
+
+ // Create tables if not exists
+ sqlxdb.MustExec(`
+ CREATE TABLE IF NOT EXISTS followers (
+ follower TEXT PRIMARY KEY
+ );
+ `)
+
+ return &DefaultFollowerRepo{
+ db: sqlxdb,
+ }
+}
+
+// Add implements repository.FollowerRepository.
+func (d *DefaultFollowerRepo) Add(follower string) error {
+ _, err := d.db.Exec("INSERT INTO followers (follower) VALUES (?) ON CONFLICT DO NOTHING", follower)
+ return err
+}
+
+// Remove implements repository.FollowerRepository.
+func (d *DefaultFollowerRepo) Remove(follower string) error {
+ _, err := d.db.Exec("DELETE FROM followers WHERE follower = ?", follower)
+ return err
+}
+
+// All implements repository.FollowerRepository.
+func (d *DefaultFollowerRepo) All() ([]string, error) {
+ var followers []sqlFollower
+ err := d.db.Select(&followers, "SELECT * FROM followers")
+ if err != nil {
+ return nil, err
+ }
+
+ result := []string{}
+ for _, follower := range followers {
+ result = append(result, follower.Follwer)
+ }
+ return result, nil
+}
diff --git a/infra/follower_repository_test.go b/infra/follower_repository_test.go
new file mode 100644
index 0000000..7a26360
--- /dev/null
+++ b/infra/follower_repository_test.go
@@ -0,0 +1,91 @@
+package infra_test
+
+import (
+ "owl-blogs/app/repository"
+ "owl-blogs/infra"
+ "owl-blogs/test"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func setupFollowerRepo() repository.FollowerRepository {
+ db := test.NewMockDb()
+ repo := infra.NewFollowerRepository(db)
+ return repo
+}
+
+func TestAddFollower(t *testing.T) {
+ repo := setupFollowerRepo()
+
+ err := repo.Add("foo@example.com")
+ require.NoError(t, err)
+
+ followers, err := repo.All()
+ require.NoError(t, err)
+ require.Len(t, followers, 1)
+ require.Equal(t, followers[0], "foo@example.com")
+}
+
+func TestDoubleAddFollower(t *testing.T) {
+ repo := setupFollowerRepo()
+
+ err := repo.Add("foo@example.com")
+ require.NoError(t, err)
+
+ err = repo.Add("foo@example.com")
+ require.NoError(t, err)
+
+ followers, err := repo.All()
+ require.NoError(t, err)
+ require.Len(t, followers, 1)
+ require.Equal(t, followers[0], "foo@example.com")
+}
+
+func TestMultipleAddFollower(t *testing.T) {
+ repo := setupFollowerRepo()
+
+ err := repo.Add("foo@example.com")
+ require.NoError(t, err)
+
+ err = repo.Add("bar@example.com")
+ require.NoError(t, err)
+
+ err = repo.Add("baz@example.com")
+ require.NoError(t, err)
+
+ followers, err := repo.All()
+ require.NoError(t, err)
+ require.Len(t, followers, 3)
+}
+
+func TestRemoveFollower(t *testing.T) {
+ repo := setupFollowerRepo()
+
+ err := repo.Add("foo@example.com")
+ require.NoError(t, err)
+
+ followers, err := repo.All()
+ require.NoError(t, err)
+ require.Len(t, followers, 1)
+
+ err = repo.Remove("foo@example.com")
+ require.NoError(t, err)
+
+ followers, err = repo.All()
+ require.NoError(t, err)
+ require.Len(t, followers, 0)
+
+}
+
+func TestRemoveNonExistingFollower(t *testing.T) {
+ repo := setupFollowerRepo()
+
+ err := repo.Remove("foo@example.com")
+ require.NoError(t, err)
+
+ followers, err := repo.All()
+ require.NoError(t, err)
+ require.Len(t, followers, 0)
+
+}
diff --git a/interactions/like.go b/interactions/like.go
new file mode 100644
index 0000000..59c95fa
--- /dev/null
+++ b/interactions/like.go
@@ -0,0 +1,33 @@
+package interactions
+
+import (
+ "fmt"
+ "owl-blogs/domain/model"
+ "owl-blogs/render"
+)
+
+type Like struct {
+ model.InteractionBase
+ meta LikeMetaData
+}
+
+type LikeMetaData struct {
+ SenderUrl string
+ SenderName string
+}
+
+func (i *Like) Content() model.InteractionContent {
+ str, err := render.RenderTemplateToString("interaction/Like", i)
+ if err != nil {
+ fmt.Println(err)
+ }
+ return model.InteractionContent(str)
+}
+
+func (i *Like) MetaData() interface{} {
+ return &i.meta
+}
+
+func (i *Like) SetMetaData(metaData interface{}) {
+ i.meta = *metaData.(*LikeMetaData)
+}
diff --git a/interactions/repost.go b/interactions/repost.go
new file mode 100644
index 0000000..434c694
--- /dev/null
+++ b/interactions/repost.go
@@ -0,0 +1,33 @@
+package interactions
+
+import (
+ "fmt"
+ "owl-blogs/domain/model"
+ "owl-blogs/render"
+)
+
+type Repost struct {
+ model.InteractionBase
+ meta RepostMetaData
+}
+
+type RepostMetaData struct {
+ SenderUrl string
+ SenderName string
+}
+
+func (i *Repost) Content() model.InteractionContent {
+ str, err := render.RenderTemplateToString("interaction/Repost", i)
+ if err != nil {
+ fmt.Println(err)
+ }
+ return model.InteractionContent(str)
+}
+
+func (i *Repost) MetaData() interface{} {
+ return &i.meta
+}
+
+func (i *Repost) SetMetaData(metaData interface{}) {
+ i.meta = *metaData.(*RepostMetaData)
+}
diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl
index 51089a9..c17528b 100644
--- a/render/templates/base.tmpl
+++ b/render/templates/base.tmpl
@@ -56,6 +56,11 @@
{{ $link.Title }}
{{ end }}
{{ end }}
+ {{ range $me := .SiteConfig.Me }}
+ {{$me.Name}}
+
+ {{ end }}
+
Editor
diff --git a/render/templates/interaction/Like.tmpl b/render/templates/interaction/Like.tmpl
new file mode 100644
index 0000000..b0ec3f1
--- /dev/null
+++ b/render/templates/interaction/Like.tmpl
@@ -0,0 +1,3 @@
+Liked by
+ {{.MetaData.SenderName}}
+
\ No newline at end of file
diff --git a/render/templates/interaction/Repost.tmpl b/render/templates/interaction/Repost.tmpl
new file mode 100644
index 0000000..e527808
--- /dev/null
+++ b/render/templates/interaction/Repost.tmpl
@@ -0,0 +1,3 @@
+Reposted by
+ {{.MetaData.SenderName}}
+
\ No newline at end of file
diff --git a/testbed.html b/testbed.html
deleted file mode 100644
index bb97e73..0000000
--- a/testbed.html
+++ /dev/null
@@ -1,451 +0,0 @@
-
-
-
-
-
-
- Stylesheet Test
-
-
-
-
-
-
-
-
-
- Structure
-
-
-
- Text
-
- Headings
-
- Heading 1
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Heading 2
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Heading 3
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Heading 4
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Heading 5
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Heading 6
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Inline text elements
-
- Link <a>
-
-
- Abbr. <abbr>
-
-
- Bold
- <strong>
- <b>
-
-
- Italic
- <i>
- <em>
- <cite>
-
-
- Deleted
- <del>
-
-
- Inserted
- <ins>
-
-
- Ctrl + S
- <kbd>
-
-
- Highlighted
- <mark>
-
-
- Strikethrough
- <s>
-
-
- Small
- <small>
-
-
- Text Sub
- <sub>
-
-
- Text Sup
- <sup>
-
-
- Underline
- <u>
-
-
- Code
- <code>
-
-
- List
-
- Unordered List
-
-
- Unordered List 1
-
- Sub List 1
- Sub List 2
- Sub List 3
- Sub List 4
-
- Sub Sub List 1
- Sub Sub List 2
- Sub Sub List 3
- Sub Sub List 4
-
-
- Unordered List 2
- Unordered List 3
- Unordered List 4
-
-
- Ordered List
-
-
- Ordered List 1
-
- Sub List 1
- Sub List 2
- Sub List 3
- Sub List 4
-
- Sub Sub List 1
- Sub Sub List 2
- Sub Sub List 3
- Sub Sub List 4
-
-
- Ordered List 2
- Ordered List 3
- Ordered List 4
-
-
- Quote
-
-
- “Design is a funny word. Some people think
- design means how it looks. But of course, if
- you dig deeper, it's really how it works.”
-
-
-
- Horizontal rule
-
-
-
- Table
-
-
-
-
- Column 1
- Column 2
- Column 3
- Column 4
- Column 5
-
-
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
- Cell 1
- Cell 2
- Cell 3
- Cell 4
- Cell 5
-
-
-
-
- Buttons
- Button
- Button disabled
-
- Classes for buttons
- Primary
- Secondary
- Success
- Danger
- Warning
-
- Primary
- Secondary
- Success
- Danger
- Warning
-
- Forms
-
- Inputs
-
- Inputs
-
- Button
-
-
-
- Submit
-
-
-
- Checkbox
-
-
-
- Color
-
-
-
- Password
-
-
-
- Date
-
-
-
- Datetime
-
-
-
- Radio
-
-
-
- Text
-
-
-
- Password
-
-
-
- Time
-
-
-
- Range
-
-
-
- Textarea
-
-
-
- Inline Inputs
-
- Inline Button
-
-
- Inline Submit
-
-
- Inline Checkbox
-
-
- Inline Color
-
-
- Inline Password
-
-
- Inline Date
-
-
- Inline Datetime
-
-
- Inline Radio
-
-
- Inline Text
-
-
- Inline Password
-
-
- Inline Time
-
-
- Inline Range
-
-
- Navigation
-
-
-
-
-
- Detail
-
-
- Summary
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor
- convallis.
- Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arcu
- lobortis.
- Nam et turpis a leo commodo imperdiet.
-
-
- Pre
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sodales felis pulvinar auctor convallis.
-Maecenas luctus nec magna quis convallis. Donec vestibulum risus nec metus consectetur, at rhoncus arculobortis.
-Nam et turpis a leo commodo imperdiet.
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/web/activity_pub_handler.go b/web/activity_pub_handler.go
index 4bebe1f..3164e2b 100644
--- a/web/activity_pub_handler.go
+++ b/web/activity_pub_handler.go
@@ -1,44 +1,24 @@
package web
import (
+ "errors"
+ "log/slog"
+ "net/http"
"net/url"
"owl-blogs/app"
- "owl-blogs/app/repository"
- "owl-blogs/config"
- "owl-blogs/domain/model"
- "owl-blogs/render"
+ "strings"
vocab "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/adaptor"
)
-const ACT_PUB_CONF_NAME = "activity_pub"
-
type ActivityPubServer struct {
- configRepo repository.ConfigRepository
- entryService *app.EntryService
-}
-
-type ActivityPubConfig struct {
- PreferredUsername string
- PublicKeyPem string
- PrivateKeyPem string
-}
-
-// Form implements app.AppConfig.
-func (cfg *ActivityPubConfig) Form(binSvc model.BinaryStorageInterface) string {
- f, _ := render.RenderTemplateToString("forms/ActivityPubConfig", cfg)
- return f
-}
-
-// ParseFormData implements app.AppConfig.
-func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
- cfg.PreferredUsername = data.FormValue("PreferredUsername")
- cfg.PublicKeyPem = data.FormValue("PublicKeyPem")
- cfg.PrivateKeyPem = data.FormValue("PrivateKeyPem")
- return nil
+ siteConfigService *app.SiteConfigService
+ apService *app.ActivityPubService
+ entryService *app.EntryService
}
type WebfingerResponse struct {
@@ -53,18 +33,17 @@ type WebfingerLink struct {
Href string `json:"href"`
}
-func NewActivityPubServer(configRepo repository.ConfigRepository, entryService *app.EntryService) *ActivityPubServer {
+func NewActivityPubServer(siteConfigService *app.SiteConfigService, entryService *app.EntryService, apService *app.ActivityPubService) *ActivityPubServer {
return &ActivityPubServer{
- configRepo: configRepo,
- entryService: entryService,
+ siteConfigService: siteConfigService,
+ entryService: entryService,
+ apService: apService,
}
}
func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
- siteConfig := model.SiteConfig{}
- apConfig := ActivityPubConfig{}
- s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
- s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
+ siteConfig, _ := s.siteConfigService.GetSiteConfig()
+ apConfig, _ := s.apService.GetApConfig()
domain, err := url.Parse(siteConfig.FullUrl)
if err != nil {
@@ -72,7 +51,9 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
}
subject := ctx.Query("resource", "")
- if subject != "acct:"+apConfig.PreferredUsername+"@"+domain.Host {
+ blogSubject := "acct:" + apConfig.PreferredUsername + "@" + domain.Host
+ slog.Info("webfinger request", "for", subject, "required", blogSubject)
+ if subject != blogSubject {
return ctx.Status(404).JSON(nil)
}
@@ -83,7 +64,7 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
{
Rel: "self",
Type: "application/activity+json",
- Href: siteConfig.FullUrl + "/activitypub/actor",
+ Href: s.apService.ActorUrl(),
},
},
}
@@ -93,26 +74,36 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
}
func (s *ActivityPubServer) Router(router fiber.Router) {
- router.Get("/actor", s.HandleActor)
router.Get("/outbox", s.HandleOutbox)
+ router.Post("/inbox", s.HandleInbox)
+ router.Get("/followers", s.HandleFollowers)
}
func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
- siteConfig := model.SiteConfig{}
- apConfig := ActivityPubConfig{}
- s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
- s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
+ accepts := (strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/activity+json") ||
+ strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/ld+json"))
+ req_content := (strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/activity+json") ||
+ strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/ld+json"))
+ if !accepts && !req_content {
+ return ctx.Next()
+ }
+ apConfig, _ := s.apService.GetApConfig()
- actor := vocab.PersonNew(vocab.IRI(siteConfig.FullUrl + "/activitypub/actor"))
+ actor := vocab.PersonNew(vocab.IRI(s.apService.ActorUrl()))
actor.PreferredUsername = vocab.NaturalLanguageValues{{Value: vocab.Content(apConfig.PreferredUsername)}}
- actor.Inbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/inbox")
- actor.Outbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox")
- actor.Followers = vocab.IRI(siteConfig.FullUrl + "/activitypub/followers")
+ actor.Inbox = vocab.IRI(s.apService.InboxUrl())
+ actor.Outbox = vocab.IRI(s.apService.OutboxUrl())
+ actor.Followers = vocab.IRI(s.apService.FollowersUrl())
actor.PublicKey = vocab.PublicKey{
- ID: vocab.ID(siteConfig.FullUrl + "/activitypub/actor#main-key"),
- Owner: vocab.IRI(siteConfig.FullUrl + "/activitypub/actor"),
+ ID: vocab.IRI(s.apService.MainKeyUri()),
+ Owner: vocab.IRI(s.apService.ActorUrl()),
PublicKeyPem: apConfig.PublicKeyPem,
}
+
+ actor.Name = vocab.NaturalLanguageValues{{Value: vocab.Content(s.apService.ActorName())}}
+ actor.Icon = s.apService.ActorIcon()
+ actor.Summary = vocab.NaturalLanguageValues{{Value: vocab.Content(s.apService.ActorSummary())}}
+
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
jsonld.IRI(vocab.SecurityContextURI),
@@ -125,10 +116,8 @@ func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
}
func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
- siteConfig := model.SiteConfig{}
- apConfig := ActivityPubConfig{}
- s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
- s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
+ siteConfig, _ := s.siteConfigService.GetSiteConfig()
+ // apConfig, _ := s.apService.GetApConfig()
entries, err := s.entryService.FindAllByType(nil, true, false)
if err != nil {
@@ -147,7 +136,7 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
})
}
- outbox := vocab.OrderedCollectionNew(vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox"))
+ outbox := vocab.OrderedCollectionNew(vocab.IRI(s.apService.OutboxUrl()))
outbox.TotalItems = uint(len(items))
outbox.OrderedItems = items
@@ -157,5 +146,158 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
}
ctx.Set("Content-Type", "application/activity+json")
return ctx.Send(data)
+}
+
+func (s *ActivityPubServer) processFollow(r *http.Request, act *vocab.Activity) error {
+ follower := act.Actor.GetID().String()
+ err := s.apService.VerifySignature(r, follower)
+ if err != nil {
+ slog.Error("wrong signature", "err", err)
+ return err
+ }
+ err = s.apService.AddFollower(follower)
+ if err != nil {
+ return err
+ }
+
+ go s.apService.Accept(act)
+
+ return nil
+}
+
+func (s *ActivityPubServer) processUndo(r *http.Request, act *vocab.Activity) error {
+ sender := act.Actor.GetID().String()
+ err := s.apService.VerifySignature(r, sender)
+
+ return vocab.OnObject(act.Object, func(o *vocab.Object) error {
+ if o.Type == vocab.FollowType {
+ if err != nil {
+ slog.Error("wrong signature", "err", err)
+ return err
+ }
+ err = s.apService.RemoveFollower(sender)
+ if err != nil {
+ return err
+ }
+ go s.apService.Accept(act)
+ return nil
+ }
+ if o.Type == vocab.LikeType {
+ return s.apService.RemoveLike(o.ID.String())
+ }
+ if o.Type == vocab.AnnounceType {
+ return s.apService.RemoveRepost(o.ID.String())
+ }
+ slog.Warn("unsupporeted object type for undo", "object", o)
+ return errors.New("unsupporeted object type")
+ })
}
+
+func (s *ActivityPubServer) processLike(r *http.Request, act *vocab.Activity) error {
+ sender := act.Actor.GetID().String()
+ liked := act.Object.GetID().String()
+ err := s.apService.VerifySignature(r, sender)
+ if err != nil {
+ slog.Error("wrong signature", "err", err)
+ return err
+ }
+
+ err = s.apService.AddLike(sender, liked, act.ID.String())
+ if err != nil {
+ slog.Error("error saving like", "err", err)
+ return err
+ }
+
+ go s.apService.Accept(act)
+ return nil
+}
+
+func (s *ActivityPubServer) processAnnounce(r *http.Request, act *vocab.Activity) error {
+ sender := act.Actor.GetID().String()
+ liked := act.Object.GetID().String()
+ err := s.apService.VerifySignature(r, sender)
+ if err != nil {
+ slog.Error("wrong signature", "err", err)
+ return err
+ }
+
+ err = s.apService.AddRepost(sender, liked, act.ID.String())
+ if err != nil {
+ slog.Error("error saving like", "err", err)
+ return err
+ }
+
+ go s.apService.Accept(act)
+ return nil
+}
+
+func (s *ActivityPubServer) processDelete(r *http.Request, act *vocab.Activity) error {
+ return vocab.OnObject(act.Object, func(o *vocab.Object) error {
+ slog.Warn("Not processing delete", "action", act, "object", o)
+ return nil
+ })
+}
+
+func (s *ActivityPubServer) HandleInbox(ctx *fiber.Ctx) error {
+ body := ctx.Request().Body()
+ data, err := vocab.UnmarshalJSON(body)
+ if err != nil {
+ slog.Error("failed to parse request body", "body", body, "err", err)
+ return err
+ }
+
+ err = vocab.OnActivity(data, func(act *vocab.Activity) error {
+ slog.Info("activity retrieved", "activity", act, "type", act.Type)
+
+ r, err := adaptor.ConvertRequest(ctx, true)
+ if err != nil {
+ return err
+ }
+
+ if act.Type == vocab.FollowType {
+ return s.processFollow(r, act)
+ }
+ if act.Type == vocab.UndoType {
+ return s.processUndo(r, act)
+ }
+ if act.Type == vocab.DeleteType {
+ return s.processDelete(r, act)
+ }
+ if act.Type == vocab.LikeType {
+ return s.processLike(r, act)
+ }
+ if act.Type == vocab.AnnounceType {
+ return s.processAnnounce(r, act)
+ }
+
+ slog.Warn("Unsupported action", "body", body)
+
+ return errors.New("only follow and undo actions supported")
+ })
+ return err
+
+}
+
+func (s *ActivityPubServer) HandleFollowers(ctx *fiber.Ctx) error {
+ fs, err := s.apService.AllFollowers()
+ if err != nil {
+ return err
+ }
+
+ followers := vocab.Collection{}
+ for _, f := range fs {
+ followers.Append(vocab.IRI(f))
+ }
+ followers.TotalItems = uint(len(fs))
+ followers.ID = vocab.IRI(s.apService.FollowersUrl())
+ data, err := jsonld.WithContext(
+ jsonld.IRI(vocab.ActivityBaseURI),
+ ).Marshal(followers)
+
+ if err != nil {
+ return err
+ }
+ ctx.Set("Content-Type", "application/activity+json")
+ return ctx.Send(data)
+}
diff --git a/web/app.go b/web/app.go
index 80830cc..0deed3e 100644
--- a/web/app.go
+++ b/web/app.go
@@ -7,6 +7,7 @@ import (
"net/url"
"owl-blogs/app"
"owl-blogs/app/repository"
+ "owl-blogs/config"
"owl-blogs/web/middleware"
"github.com/gofiber/fiber/v2"
@@ -35,11 +36,12 @@ func NewWebApp(
siteConfigService *app.SiteConfigService,
webmentionService *app.WebmentionService,
interactionRepo repository.InteractionRepository,
+ apService *app.ActivityPubService,
) *WebApp {
- app := fiber.New(fiber.Config{
+ fiberApp := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024, // 50MB in bytes
})
- app.Use(middleware.NewUserMiddleware(authorService).Handle)
+ fiberApp.Use(middleware.NewUserMiddleware(authorService).Handle)
indexHandler := NewIndexHandler(entryService, siteConfigService)
listHandler := NewListHandler(entryService, siteConfigService)
@@ -51,15 +53,15 @@ func NewWebApp(
webmentionHandler := NewWebmentionHandler(webmentionService, configRepo)
// Login
- app.Get("/auth/login", loginHandler.HandleGet)
- app.Post("/auth/login", loginHandler.HandlePost)
+ fiberApp.Get("/auth/login", loginHandler.HandleGet)
+ fiberApp.Post("/auth/login", loginHandler.HandlePost)
// admin
adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry)
draftHandler := NewDraftHandler(entryService, siteConfigService)
binaryManageHandler := NewBinaryManageHandler(configRepo, binService)
adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo)
- admin := app.Group("/admin")
+ admin := fiberApp.Group("/admin")
admin.Use(middleware.NewAuthMiddleware(authorService).Handle)
admin.Get("/", adminHandler.Handle)
admin.Get("/drafts/", draftHandler.Handle)
@@ -75,7 +77,7 @@ func NewWebApp(
adminApi.Post("/binaries", binaryManageHandler.HandleUploadApi)
// Editor
- editor := app.Group("/editor")
+ editor := fiberApp.Group("/editor")
editor.Use(middleware.NewAuthMiddleware(authorService).Handle)
editor.Get("/new/:editor/", editorHandler.HandleGetNew)
editor.Post("/new/:editor/", editorHandler.HandlePostNew)
@@ -85,7 +87,7 @@ func NewWebApp(
editor.Post("/unpublish/:id/", editorHandler.HandlePostUnpublish)
// SiteConfig
- siteConfig := app.Group("/site-config")
+ siteConfig := fiberApp.Group("/site-config")
siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle)
siteConfigHandler := NewSiteConfigHandler(siteConfigService)
@@ -107,39 +109,40 @@ func NewWebApp(
siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate)
siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete)
- app.Use("/static", filesystem.New(filesystem.Config{
+ activityPubServer := NewActivityPubServer(siteConfigService, entryService, apService)
+ configRegister.Register(config.ACT_PUB_CONF_NAME, &app.ActivityPubConfig{})
+
+ fiberApp.Use("/static", filesystem.New(filesystem.Config{
Root: http.FS(embedDirStatic),
PathPrefix: "static",
Browse: false,
}))
- app.Get("/", indexHandler.Handle)
- app.Get("/lists/:list/", listHandler.Handle)
+ fiberApp.Get("/", activityPubServer.HandleActor, indexHandler.Handle)
+ fiberApp.Get("/lists/:list/", listHandler.Handle)
// Media
- app.Get("/media/+", mediaHandler.Handle)
+ fiberApp.Get("/media/+", mediaHandler.Handle)
// RSS
- app.Get("/index.xml", rssHandler.Handle)
+ fiberApp.Get("/index.xml", rssHandler.Handle)
// Posts
- app.Get("/posts/:post/", entryHandler.Handle)
+ fiberApp.Get("/posts/:post/", entryHandler.Handle)
// Webmention
- app.Post("/webmention/", webmentionHandler.Handle)
+ fiberApp.Post("/webmention/", webmentionHandler.Handle)
// robots.txt
- app.Get("/robots.txt", func(c *fiber.Ctx) error {
+ fiberApp.Get("/robots.txt", func(c *fiber.Ctx) error {
siteConfig, _ := siteConfigService.GetSiteConfig()
sitemapUrl, _ := url.JoinPath(siteConfig.FullUrl, "/sitemap.xml")
c.Set("Content-Type", "text/plain")
return c.SendString(fmt.Sprintf("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n\nSitemap: %s\n", sitemapUrl))
})
// sitemap.xml
- app.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle)
+ fiberApp.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle)
// ActivityPub
- activityPubServer := NewActivityPubServer(configRepo, entryService)
- configRegister.Register(ACT_PUB_CONF_NAME, &ActivityPubConfig{})
- app.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
- app.Route("/activitypub", activityPubServer.Router)
+ fiberApp.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
+ fiberApp.Route("/activitypub", activityPubServer.Router)
return &WebApp{
- FiberApp: app,
+ FiberApp: fiberApp,
EntryService: entryService,
Registry: typeRegistry,
BinaryService: binService,