Compare commits

...

13 Commits

34 changed files with 1686 additions and 536 deletions

View File

@ -1,11 +1,11 @@
root = "." root = "."
testdata_dir = "testdata" testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "/tmp"
[build] [build]
args_bin = ["web"] args_bin = ["web"]
bin = "./tmp/main" bin = "/tmp/main"
cmd = "go build -o ./tmp/main owl-blogs/cmd/owl" cmd = "go build -buildvcs=false -o /tmp/main owl-blogs/cmd/owl"
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = [] exclude_file = []

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
e2e_tests/
tmp/
*.db

5
.gitignore vendored
View File

@ -27,4 +27,7 @@ users/
*.db *.db
tmp/ tmp/
venv/
*.pyc

620
app/activity_pub_service.go Normal file
View File

@ -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
}

View File

@ -1,6 +1,7 @@
package app package app
import ( import (
"errors"
"fmt" "fmt"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
@ -9,17 +10,20 @@ import (
) )
type EntryService struct { type EntryService struct {
EntryRepository repository.EntryRepository EntryRepository repository.EntryRepository
Bus *EventBus siteConfigServcie *SiteConfigService
Bus *EventBus
} }
func NewEntryService( func NewEntryService(
entryRepository repository.EntryRepository, entryRepository repository.EntryRepository,
siteConfigServcie *SiteConfigService,
bus *EventBus, bus *EventBus,
) *EntryService { ) *EntryService {
return &EntryService{ return &EntryService{
EntryRepository: entryRepository, EntryRepository: entryRepository,
Bus: bus, siteConfigServcie: siteConfigServcie,
Bus: bus,
} }
} }
@ -70,6 +74,19 @@ func (s *EntryService) FindById(id string) (model.Entry, error) {
return s.EntryRepository.FindById(id) 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 { func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry {
filteredEntries := make([]model.Entry, 0) filteredEntries := make([]model.Entry, 0)
for _, entry := range entries { for _, entry := range entries {

View File

@ -14,7 +14,7 @@ func setupService() *app.EntryService {
register := app.NewEntryTypeRegistry() register := app.NewEntryTypeRegistry()
register.Register(&test.MockEntry{}) register.Register(&test.MockEntry{})
repo := infra.NewEntryRepository(db, register) repo := infra.NewEntryRepository(db, register)
service := app.NewEntryService(repo, app.NewEventBus()) service := app.NewEntryService(repo, nil, app.NewEventBus())
return service return service
} }

View File

@ -51,3 +51,9 @@ type InteractionRepository interface {
// ListAllInteractions lists all interactions, sorted by creation date (descending) // ListAllInteractions lists all interactions, sorted by creation date (descending)
ListAllInteractions() ([]model.Interaction, error) ListAllInteractions() ([]model.Interaction, error)
} }
type FollowerRepository interface {
Add(follower string) error
Remove(follower string) error
All() ([]string, error)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -41,6 +41,8 @@ func App(db infra.Database) *web.WebApp {
interactionRegister := app.NewInteractionTypeRegistry() interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{}) interactionRegister.Register(&interactions.Webmention{})
interactionRegister.Register(&interactions.Like{})
interactionRegister.Register(&interactions.Repost{})
configRegister := app.NewConfigRegister() configRegister := app.NewConfigRegister()
@ -50,6 +52,7 @@ func App(db infra.Database) *web.WebApp {
authorRepo := infra.NewDefaultAuthorRepo(db) authorRepo := infra.NewDefaultAuthorRepo(db)
configRepo := infra.NewConfigRepo(db) configRepo := infra.NewConfigRepo(db)
interactionRepo := infra.NewInteractionRepo(db, interactionRegister) interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
followersRepo := infra.NewFollowerRepository(db)
// Create External Services // Create External Services
httpClient := &infra.OwlHttpClient{} httpClient := &infra.OwlHttpClient{}
@ -59,12 +62,17 @@ func App(db infra.Database) *web.WebApp {
// Create Services // Create Services
siteConfigService := app.NewSiteConfigService(configRepo) siteConfigService := app.NewSiteConfigService(configRepo)
entryService := app.NewEntryService(entryRepo, eventBus) entryService := app.NewEntryService(entryRepo, siteConfigService, eventBus)
binaryService := app.NewBinaryFileService(binRepo) binaryService := app.NewBinaryFileService(binRepo)
authorService := app.NewAuthorService(authorRepo, siteConfigService) authorService := app.NewAuthorService(authorRepo, siteConfigService)
webmentionService := app.NewWebmentionService( webmentionService := app.NewWebmentionService(
siteConfigService, interactionRepo, entryRepo, httpClient, eventBus, siteConfigService, interactionRepo, entryRepo, httpClient, eventBus,
) )
apService := app.NewActivityPubService(
followersRepo, configRepo, interactionRepo,
entryService, siteConfigService, binaryService,
eventBus,
)
// setup render functions // setup render functions
render.SiteConfigService = siteConfigService render.SiteConfigService = siteConfigService
@ -80,6 +88,7 @@ func App(db infra.Database) *web.WebApp {
entryService, entryRegister, binaryService, entryService, entryRegister, binaryService,
authorService, configRepo, configRegister, authorService, configRepo, configRegister,
siteConfigService, webmentionService, interactionRepo, siteConfigService, webmentionService, interactionRepo,
apService,
) )
} }

View File

@ -3,7 +3,8 @@ package config
import "os" import "os"
const ( const (
SITE_CONFIG = "site_config" SITE_CONFIG = "site_config"
ACT_PUB_CONF_NAME = "activity_pub"
) )
type Config interface { type Config interface {

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"net/url"
"time" "time"
) )
@ -21,6 +22,8 @@ type Entry interface {
SetPublishedAt(publishedAt *time.Time) SetPublishedAt(publishedAt *time.Time)
SetMetaData(metaData EntryMetaData) SetMetaData(metaData EntryMetaData)
SetAuthorId(authorId string) SetAuthorId(authorId string)
FullUrl(cfg SiteConfig) string
} }
type EntryMetaData interface { type EntryMetaData interface {
@ -60,3 +63,8 @@ func (e *EntryBase) AuthorId() string {
func (e *EntryBase) SetAuthorId(authorId string) { func (e *EntryBase) SetAuthorId(authorId string) {
e.authorId = authorId e.authorId = authorId
} }
func (e *EntryBase) FullUrl(cfg SiteConfig) string {
u, _ := url.JoinPath(cfg.FullUrl, "/posts/", e.ID(), "/")
return u
}

View File

@ -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)
}
}

49
e2e_tests/conftest.py Normal file
View File

@ -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"]

View File

@ -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

View File

@ -0,0 +1,6 @@
FROM python:3.11
COPY . .
RUN pip install -r requirements.txt
CMD [ "python", "main.py" ]

View File

@ -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")

View File

@ -0,0 +1 @@
Flask==3.0.3

View File

@ -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

View File

102
e2e_tests/tests/fixtures.py Normal file
View File

@ -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}"

View File

@ -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

View File

@ -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
View File

@ -26,6 +26,7 @@ require (
github.com/chromedp/sysutil v1.0.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 // 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/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect

8
go.sum
View File

@ -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/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 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= 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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 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/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 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 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 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 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 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.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.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.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 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@ -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
}

View File

@ -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)
}

33
interactions/like.go Normal file
View File

@ -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)
}

33
interactions/repost.go Normal file
View File

@ -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)
}

View File

@ -56,6 +56,11 @@
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li> <li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }} {{ end }}
{{ 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> <li><a href="/admin/">Editor</a></li>
</ul> </ul>
</nav> </nav>

View File

@ -0,0 +1,3 @@
Liked by <a href="{{.MetaData.SenderUrl}}">
{{.MetaData.SenderName}}
</a>

View File

@ -0,0 +1,3 @@
Reposted by <a href="{{.MetaData.SenderUrl}}">
{{.MetaData.SenderName}}
</a>

View File

@ -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>&lt;a&gt;</code>
</p>
<p>
<abbr>Abbr.</abbr><code>&lt;abbr&gt;</code>
</p>
<p>
<strong>Bold</strong>
<code>&lt;strong&gt;</code>
<code>&lt;b&gt;</code>
</p>
<p>
<em>Italic</em>
<code>&lt;i&gt;</code>
<code>&lt;em&gt;</code>
<code>&lt;cite&gt;</code>
</p>
<p>
<del>Deleted</del>
<code>&lt;del&gt;</code>
</p>
<p>
<ins>Inserted</ins>
<code>&lt;ins&gt;</code>
</p>
<p>
<kbd>Ctrl + S</kbd>
<code>&lt;kbd&gt;</code>
</p>
<p>
<mark>Highlighted</mark>
<code>&lt;mark&gt;</code>
</p>
<p>
<s>Strikethrough</s>
<code>&lt;s&gt;</code>
</p>
<p>
<small>Small</small>
<code>&lt;small&gt;</code>
</p>
<p>
Text <sub>Sub</sub>
<code>&lt;sub&gt;</code>
</p>
<p>
Text <sup>Sup</sup>
<code>&lt;sup&gt;</code>
</p>
<p>
<u>Underline</u>
<code>&lt;u&gt;</code>
</p>
<p>
<code>Code</code>
<code>&lt;code&gt;</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>

View File

@ -1,44 +1,24 @@
package web package web
import ( import (
"errors"
"log/slog"
"net/http"
"net/url" "net/url"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository" "strings"
"owl-blogs/config"
"owl-blogs/domain/model"
"owl-blogs/render"
vocab "github.com/go-ap/activitypub" vocab "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld" "github.com/go-ap/jsonld"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
) )
const ACT_PUB_CONF_NAME = "activity_pub"
type ActivityPubServer struct { type ActivityPubServer struct {
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
entryService *app.EntryService apService *app.ActivityPubService
} 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
} }
type WebfingerResponse struct { type WebfingerResponse struct {
@ -53,18 +33,17 @@ type WebfingerLink struct {
Href string `json:"href"` 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{ return &ActivityPubServer{
configRepo: configRepo, siteConfigService: siteConfigService,
entryService: entryService, entryService: entryService,
apService: apService,
} }
} }
func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error { func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
siteConfig := model.SiteConfig{} siteConfig, _ := s.siteConfigService.GetSiteConfig()
apConfig := ActivityPubConfig{} apConfig, _ := s.apService.GetApConfig()
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
domain, err := url.Parse(siteConfig.FullUrl) domain, err := url.Parse(siteConfig.FullUrl)
if err != nil { if err != nil {
@ -72,7 +51,9 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
} }
subject := ctx.Query("resource", "") 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) return ctx.Status(404).JSON(nil)
} }
@ -83,7 +64,7 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
{ {
Rel: "self", Rel: "self",
Type: "application/activity+json", 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) { func (s *ActivityPubServer) Router(router fiber.Router) {
router.Get("/actor", s.HandleActor)
router.Get("/outbox", s.HandleOutbox) router.Get("/outbox", s.HandleOutbox)
router.Post("/inbox", s.HandleInbox)
router.Get("/followers", s.HandleFollowers)
} }
func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error { func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
siteConfig := model.SiteConfig{} accepts := (strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/activity+json") ||
apConfig := ActivityPubConfig{} strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/ld+json"))
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig) req_content := (strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/activity+json") ||
s.configRepo.Get(config.SITE_CONFIG, &siteConfig) 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.PreferredUsername = vocab.NaturalLanguageValues{{Value: vocab.Content(apConfig.PreferredUsername)}}
actor.Inbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/inbox") actor.Inbox = vocab.IRI(s.apService.InboxUrl())
actor.Outbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox") actor.Outbox = vocab.IRI(s.apService.OutboxUrl())
actor.Followers = vocab.IRI(siteConfig.FullUrl + "/activitypub/followers") actor.Followers = vocab.IRI(s.apService.FollowersUrl())
actor.PublicKey = vocab.PublicKey{ actor.PublicKey = vocab.PublicKey{
ID: vocab.ID(siteConfig.FullUrl + "/activitypub/actor#main-key"), ID: vocab.IRI(s.apService.MainKeyUri()),
Owner: vocab.IRI(siteConfig.FullUrl + "/activitypub/actor"), Owner: vocab.IRI(s.apService.ActorUrl()),
PublicKeyPem: apConfig.PublicKeyPem, 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( data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI), jsonld.IRI(vocab.ActivityBaseURI),
jsonld.IRI(vocab.SecurityContextURI), jsonld.IRI(vocab.SecurityContextURI),
@ -125,10 +116,8 @@ func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
} }
func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error { func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
siteConfig := model.SiteConfig{} siteConfig, _ := s.siteConfigService.GetSiteConfig()
apConfig := ActivityPubConfig{} // apConfig, _ := s.apService.GetApConfig()
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
entries, err := s.entryService.FindAllByType(nil, true, false) entries, err := s.entryService.FindAllByType(nil, true, false)
if err != nil { 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.TotalItems = uint(len(items))
outbox.OrderedItems = items outbox.OrderedItems = items
@ -157,5 +146,158 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
} }
ctx.Set("Content-Type", "application/activity+json") ctx.Set("Content-Type", "application/activity+json")
return ctx.Send(data) 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)
}

View File

@ -7,6 +7,7 @@ import (
"net/url" "net/url"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/web/middleware" "owl-blogs/web/middleware"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -35,11 +36,12 @@ func NewWebApp(
siteConfigService *app.SiteConfigService, siteConfigService *app.SiteConfigService,
webmentionService *app.WebmentionService, webmentionService *app.WebmentionService,
interactionRepo repository.InteractionRepository, interactionRepo repository.InteractionRepository,
apService *app.ActivityPubService,
) *WebApp { ) *WebApp {
app := fiber.New(fiber.Config{ fiberApp := fiber.New(fiber.Config{
BodyLimit: 50 * 1024 * 1024, // 50MB in bytes BodyLimit: 50 * 1024 * 1024, // 50MB in bytes
}) })
app.Use(middleware.NewUserMiddleware(authorService).Handle) fiberApp.Use(middleware.NewUserMiddleware(authorService).Handle)
indexHandler := NewIndexHandler(entryService, siteConfigService) indexHandler := NewIndexHandler(entryService, siteConfigService)
listHandler := NewListHandler(entryService, siteConfigService) listHandler := NewListHandler(entryService, siteConfigService)
@ -51,15 +53,15 @@ func NewWebApp(
webmentionHandler := NewWebmentionHandler(webmentionService, configRepo) webmentionHandler := NewWebmentionHandler(webmentionService, configRepo)
// Login // Login
app.Get("/auth/login", loginHandler.HandleGet) fiberApp.Get("/auth/login", loginHandler.HandleGet)
app.Post("/auth/login", loginHandler.HandlePost) fiberApp.Post("/auth/login", loginHandler.HandlePost)
// admin // admin
adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry) adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry)
draftHandler := NewDraftHandler(entryService, siteConfigService) draftHandler := NewDraftHandler(entryService, siteConfigService)
binaryManageHandler := NewBinaryManageHandler(configRepo, binService) binaryManageHandler := NewBinaryManageHandler(configRepo, binService)
adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo) adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo)
admin := app.Group("/admin") admin := fiberApp.Group("/admin")
admin.Use(middleware.NewAuthMiddleware(authorService).Handle) admin.Use(middleware.NewAuthMiddleware(authorService).Handle)
admin.Get("/", adminHandler.Handle) admin.Get("/", adminHandler.Handle)
admin.Get("/drafts/", draftHandler.Handle) admin.Get("/drafts/", draftHandler.Handle)
@ -75,7 +77,7 @@ func NewWebApp(
adminApi.Post("/binaries", binaryManageHandler.HandleUploadApi) adminApi.Post("/binaries", binaryManageHandler.HandleUploadApi)
// Editor // Editor
editor := app.Group("/editor") editor := fiberApp.Group("/editor")
editor.Use(middleware.NewAuthMiddleware(authorService).Handle) editor.Use(middleware.NewAuthMiddleware(authorService).Handle)
editor.Get("/new/:editor/", editorHandler.HandleGetNew) editor.Get("/new/:editor/", editorHandler.HandleGetNew)
editor.Post("/new/:editor/", editorHandler.HandlePostNew) editor.Post("/new/:editor/", editorHandler.HandlePostNew)
@ -85,7 +87,7 @@ func NewWebApp(
editor.Post("/unpublish/:id/", editorHandler.HandlePostUnpublish) editor.Post("/unpublish/:id/", editorHandler.HandlePostUnpublish)
// SiteConfig // SiteConfig
siteConfig := app.Group("/site-config") siteConfig := fiberApp.Group("/site-config")
siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle) siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle)
siteConfigHandler := NewSiteConfigHandler(siteConfigService) siteConfigHandler := NewSiteConfigHandler(siteConfigService)
@ -107,39 +109,40 @@ func NewWebApp(
siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate) siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate)
siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete) 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), Root: http.FS(embedDirStatic),
PathPrefix: "static", PathPrefix: "static",
Browse: false, Browse: false,
})) }))
app.Get("/", indexHandler.Handle) fiberApp.Get("/", activityPubServer.HandleActor, indexHandler.Handle)
app.Get("/lists/:list/", listHandler.Handle) fiberApp.Get("/lists/:list/", listHandler.Handle)
// Media // Media
app.Get("/media/+", mediaHandler.Handle) fiberApp.Get("/media/+", mediaHandler.Handle)
// RSS // RSS
app.Get("/index.xml", rssHandler.Handle) fiberApp.Get("/index.xml", rssHandler.Handle)
// Posts // Posts
app.Get("/posts/:post/", entryHandler.Handle) fiberApp.Get("/posts/:post/", entryHandler.Handle)
// Webmention // Webmention
app.Post("/webmention/", webmentionHandler.Handle) fiberApp.Post("/webmention/", webmentionHandler.Handle)
// robots.txt // robots.txt
app.Get("/robots.txt", func(c *fiber.Ctx) error { fiberApp.Get("/robots.txt", func(c *fiber.Ctx) error {
siteConfig, _ := siteConfigService.GetSiteConfig() siteConfig, _ := siteConfigService.GetSiteConfig()
sitemapUrl, _ := url.JoinPath(siteConfig.FullUrl, "/sitemap.xml") sitemapUrl, _ := url.JoinPath(siteConfig.FullUrl, "/sitemap.xml")
c.Set("Content-Type", "text/plain") c.Set("Content-Type", "text/plain")
return c.SendString(fmt.Sprintf("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n\nSitemap: %s\n", sitemapUrl)) return c.SendString(fmt.Sprintf("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n\nSitemap: %s\n", sitemapUrl))
}) })
// sitemap.xml // sitemap.xml
app.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle) fiberApp.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle)
// ActivityPub // ActivityPub
activityPubServer := NewActivityPubServer(configRepo, entryService) fiberApp.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
configRegister.Register(ACT_PUB_CONF_NAME, &ActivityPubConfig{}) fiberApp.Route("/activitypub", activityPubServer.Router)
app.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
app.Route("/activitypub", activityPubServer.Router)
return &WebApp{ return &WebApp{
FiberApp: app, FiberApp: fiberApp,
EntryService: entryService, EntryService: entryService,
Registry: typeRegistry, Registry: typeRegistry,
BinaryService: binService, BinaryService: binService,