Activity Pub Implementation #58
|
@ -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 = []
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
e2e_tests/
|
||||
tmp/
|
||||
*.db
|
|
@ -27,4 +27,7 @@ users/
|
|||
|
||||
|
||||
*.db
|
||||
tmp/
|
||||
tmp/
|
||||
|
||||
venv/
|
||||
*.pyc
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
BIN
avatar.jpg
BIN
avatar.jpg
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
|
@ -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,
|
||||
)
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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"]
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
FROM python:3.11
|
||||
|
||||
COPY . .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
CMD [ "python", "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": '<p>Teaching computers to do things with arguable efficiency.</p><p>he/him</p><p><a href="http://mock_masto:8000/tags/vegan" class="mention hashtag" rel="tag">#<span>vegan</span></a> <a href="http://mock_masto:8000/tags/cooking" class="mention hashtag" rel="tag">#<span>cooking</span></a> <a href="http://mock_masto:8000/tags/programming" class="mention hashtag" rel="tag">#<span>programming</span></a> <a href="http://mock_masto:8000/tags/politics" class="mention hashtag" rel="tag">#<span>politics</span></a> <a href="http://mock_masto:8000/tags/climate" class="mention hashtag" rel="tag">#<span>climate</span></a></p>',
|
||||
"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": '<a href="http://rerere.org" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">rerere.org</span><span class="invisible"></span></a>',
|
||||
},
|
||||
{
|
||||
"type": "PropertyValue",
|
||||
"name": "Blog",
|
||||
"value": '<a href="http://blog.libove.org" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">blog.libove.org</span><span class="invisible"></span></a>',
|
||||
},
|
||||
{"type": "PropertyValue", "name": "Location", "value": "Münster"},
|
||||
{
|
||||
"type": "PropertyValue",
|
||||
"name": "Current Project",
|
||||
"value": '<a href="http://git.libove.org/h4kor/owl-blogs" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">git.libove.org/h4kor/owl-blogs</span><span class="invisible"></span></a>',
|
||||
},
|
||||
],
|
||||
"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")
|
|
@ -0,0 +1 @@
|
|||
Flask==3.0.3
|
|
@ -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
|
|
@ -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}"
|
|
@ -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
|
|
@ -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
|
1
go.mod
1
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
|
||||
|
|
8
go.sum
8
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=
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -56,6 +56,11 @@
|
|||
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range $me := .SiteConfig.Me }}
|
||||
<li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
||||
<li><a href="/admin/">Editor</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
Liked by <a href="{{.MetaData.SenderUrl}}">
|
||||
{{.MetaData.SenderName}}
|
||||
</a>
|
|
@ -0,0 +1,3 @@
|
|||
Reposted by <a href="{{.MetaData.SenderUrl}}">
|
||||
{{.MetaData.SenderName}}
|
||||
</a>
|
451
testbed.html
451
testbed.html
|
@ -1,451 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stylesheet Test</title>
|
||||
<link rel="stylesheet" href="/web/static/style2.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<img src="avatar.jpg" alt="Page Logo" />
|
||||
<hgroup>
|
||||
<h1>Page Title</h1>
|
||||
<p>Short description of waht the blog is about. Catchy phrase to remember the blog by!</p>
|
||||
</hgroup>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>Navigation 1</li>
|
||||
<li>Navigation 2</li>
|
||||
<li>Navigation 3</li>
|
||||
<li>Navigation 4</li>
|
||||
<li>Navigation 5</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<h1>Structure</h1>
|
||||
|
||||
<footer>Footer</footer>
|
||||
|
||||
<h1>Text</h1>
|
||||
|
||||
<h2>Headings</h2>
|
||||
|
||||
<h1>Heading 1</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Heading 2</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Heading 3</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h4>Heading 4</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h5>Heading 5</h5>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h6>Heading 6</h6>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Inline text elements</h2>
|
||||
<p>
|
||||
<a href="#">Link</a><code><a></code>
|
||||
</p>
|
||||
<p>
|
||||
<abbr>Abbr.</abbr><code><abbr></code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Bold</strong>
|
||||
<code><strong></code>
|
||||
<code><b></code>
|
||||
</p>
|
||||
<p>
|
||||
<em>Italic</em>
|
||||
<code><i></code>
|
||||
<code><em></code>
|
||||
<code><cite></code>
|
||||
</p>
|
||||
<p>
|
||||
<del>Deleted</del>
|
||||
<code><del></code>
|
||||
</p>
|
||||
<p>
|
||||
<ins>Inserted</ins>
|
||||
<code><ins></code>
|
||||
</p>
|
||||
<p>
|
||||
<kbd>Ctrl + S</kbd>
|
||||
<code><kbd></code>
|
||||
</p>
|
||||
<p>
|
||||
<mark>Highlighted</mark>
|
||||
<code><mark></code>
|
||||
</p>
|
||||
<p>
|
||||
<s>Strikethrough</s>
|
||||
<code><s></code>
|
||||
</p>
|
||||
<p>
|
||||
<small>Small</small>
|
||||
<code><small></code>
|
||||
</p>
|
||||
<p>
|
||||
Text <sub>Sub</sub>
|
||||
<code><sub></code>
|
||||
</p>
|
||||
<p>
|
||||
Text <sup>Sup</sup>
|
||||
<code><sup></code>
|
||||
</p>
|
||||
<p>
|
||||
<u>Underline</u>
|
||||
<code><u></code>
|
||||
</p>
|
||||
<p>
|
||||
<code>Code</code>
|
||||
<code><code></code>
|
||||
</p>
|
||||
|
||||
<h1>List</h1>
|
||||
|
||||
<h2>Unordered List</h2>
|
||||
|
||||
<ul>
|
||||
<li>Unordered List 1</li>
|
||||
<ul>
|
||||
<li>Sub List 1</li>
|
||||
<li>Sub List 2</li>
|
||||
<li>Sub List 3</li>
|
||||
<li>Sub List 4</li>
|
||||
<ul>
|
||||
<li>Sub Sub List 1</li>
|
||||
<li>Sub Sub List 2</li>
|
||||
<li>Sub Sub List 3</li>
|
||||
<li>Sub Sub List 4</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<li>Unordered List 2</li>
|
||||
<li>Unordered List 3</li>
|
||||
<li>Unordered List 4</li>
|
||||
</ul>
|
||||
|
||||
<h2>Ordered List</h2>
|
||||
|
||||
<ol>
|
||||
<li>Ordered List 1</li>
|
||||
<ol>
|
||||
<li>Sub List 1</li>
|
||||
<li>Sub List 2</li>
|
||||
<li>Sub List 3</li>
|
||||
<li>Sub List 4</li>
|
||||
<ol>
|
||||
<li>Sub Sub List 1</li>
|
||||
<li>Sub Sub List 2</li>
|
||||
<li>Sub Sub List 3</li>
|
||||
<li>Sub Sub List 4</li>
|
||||
</ol>
|
||||
</ol>
|
||||
<li>Ordered List 2</li>
|
||||
<li>Ordered List 3</li>
|
||||
<li>Ordered List 4</li>
|
||||
</ol>
|
||||
|
||||
<h1>Quote</h1>
|
||||
|
||||
<blockquote>
|
||||
“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.”
|
||||
<footer>
|
||||
<cite>— Steve Jobs</cite>
|
||||
</footer>
|
||||
</blockquote>
|
||||
|
||||
<h1>Horizontal rule</h1>
|
||||
|
||||
<hr />
|
||||
|
||||
<h1>Table</h1>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column 1</th>
|
||||
<th>Column 2</th>
|
||||
<th>Column 3</th>
|
||||
<th>Column 4</th>
|
||||
<th>Column 5</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
<td>Cell 3</td>
|
||||
<td>Cell 4</td>
|
||||
<td>Cell 5</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1>Buttons</h1>
|
||||
<button>Button</button>
|
||||
<button disabled="disabled">Button disabled</button>
|
||||
|
||||
<h2>Classes for buttons</h2>
|
||||
<button type="button" class="primary">Primary</button>
|
||||
<button type="button" class="secondary">Secondary</button>
|
||||
<button type="button" class="success">Success</button>
|
||||
<button type="button" class="danger">Danger</button>
|
||||
<button type="button" class="warning">Warning</button>
|
||||
<br><br>
|
||||
<button type="button" disabled="disabled" class="primary">Primary</button>
|
||||
<button type="button" disabled="disabled" class="secondary">Secondary</button>
|
||||
<button type="button" disabled="disabled" class="success">Success</button>
|
||||
<button type="button" disabled="disabled" class="danger">Danger</button>
|
||||
<button type="button" disabled="disabled" class="warning">Warning</button>
|
||||
|
||||
<h1>Forms</h1>
|
||||
|
||||
<h2>Inputs</h2>
|
||||
|
||||
<h3>Inputs</h3>
|
||||
<div>
|
||||
<label>Button</label>
|
||||
<input type="button" value="Button" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Submit</label>
|
||||
<input type="submit" value="Submit" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Checkbox</label>
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Color</label>
|
||||
<input type="color" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Password</label>
|
||||
<input type="password" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Date</label>
|
||||
<input type="date" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Datetime</label>
|
||||
<input type="datetime-local" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Radio</label>
|
||||
<input type="radio" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Text</label>
|
||||
<input type="text" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Password</label>
|
||||
<input type="password" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Time</label>
|
||||
<input type="time" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Range</label>
|
||||
<input type="range" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Textarea</label>
|
||||
<textarea placeholder="Write a professional short bio...">
|
||||
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.
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<h3>Inline Inputs</h3>
|
||||
<div>
|
||||
<label>Inline Button<input type="button" value="Button" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Submit<input type="submit" value="Submit" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Checkbox<input type="checkbox" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Color<input type="color" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Password<input type="password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Date<input type="date" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Datetime<input type="datetime-local" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Radio<input type="radio" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Text<input type="text" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Password<input type="password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Time<input type="time" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Inline Range<input type="range" /></label>
|
||||
</div>
|
||||
|
||||
<h1>Navigation</h1>
|
||||
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#">Nav 1</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Nav 2</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Nav 3</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Nav 4</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<h1>Detail</h1>
|
||||
|
||||
<details>
|
||||
<summary>Summary</summary>
|
||||
<p>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.</p>
|
||||
</details>
|
||||
|
||||
<h1>Pre</h1>
|
||||
<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.
|
||||
</pre>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -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)
|
||||
}
|
||||
|
|
45
web/app.go
45
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,
|
||||
|
|
Loading…
Reference in New Issue