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

', + "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": 'rerere.org', + }, + { + "type": "PropertyValue", + "name": "Blog", + "value": 'blog.libove.org', + }, + {"type": "PropertyValue", "name": "Location", "value": "Münster"}, + { + "type": "PropertyValue", + "name": "Current Project", + "value": '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 - - - - - -
    - Page Logo -
    -

    Page Title

    -

    Short description of waht the blog is about. Catchy phrase to remember the blog by!

    -
    - -
    - -
    - -

    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

    - - - -

    Ordered List

    - -
      -
    1. Ordered List 1
    2. -
        -
      1. Sub List 1
      2. -
      3. Sub List 2
      4. -
      5. Sub List 3
      6. -
      7. Sub List 4
      8. -
          -
        1. Sub Sub List 1
        2. -
        3. Sub Sub List 2
        4. -
        5. Sub Sub List 3
        6. -
        7. Sub Sub List 4
        8. -
        -
      -
    3. Ordered List 2
    4. -
    5. Ordered List 3
    6. -
    7. Ordered List 4
    8. -
    - -

    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.” -
    - — Steve Jobs -
    -
    - -

    Horizontal rule

    - -
    - -

    Table

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Column 1Column 2Column 3Column 4Column 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    Cell 1Cell 2Cell 3Cell 4Cell 5
    - -

    Buttons

    - - - -

    Classes for buttons

    - - - - - -

    - - - - - - -

    Forms

    - -

    Inputs

    - -

    Inputs

    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -

    Inline Inputs

    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    - -

    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,