Compare commits

...

2 Commits

Author SHA1 Message Date
Niko Abeler 723a6000bf first retrieved webmention 2023-08-09 20:36:44 +02:00
Niko Abeler df5215d943 tests for incoming webmention 2023-08-09 20:10:51 +02:00
11 changed files with 398 additions and 39 deletions

View File

@ -109,7 +109,7 @@ func (s *WebmentionService) GetExistingWebmention(entryId string, source string,
} }
for _, interaction := range inters { for _, interaction := range inters {
if webm, ok := interaction.(*interactions.Webmention); ok { if webm, ok := interaction.(*interactions.Webmention); ok {
m := webm.MetaData().(interactions.WebmentionInteractionMetaData) m := webm.MetaData().(*interactions.WebmentionMetaData)
if m.Source == source && m.Target == target { if m.Source == source && m.Target == target {
return webm, nil return webm, nil
} }
@ -130,6 +130,10 @@ func (s *WebmentionService) ProcessWebmention(source string, target string) erro
} }
entryId := UrlToEntryId(target) entryId := UrlToEntryId(target)
println(entryId)
println(entryId)
println(entryId)
println(entryId)
_, err = s.EntryRepository.FindById(entryId) _, err = s.EntryRepository.FindById(entryId)
if err != nil { if err != nil {
return err return err
@ -140,24 +144,24 @@ func (s *WebmentionService) ProcessWebmention(source string, target string) erro
return err return err
} }
if webmention != nil { if webmention != nil {
data := interactions.WebmentionInteractionMetaData{ data := interactions.WebmentionMetaData{
Source: source, Source: source,
Target: target, Target: target,
Title: hEntry.Title, Title: hEntry.Title,
} }
webmention.SetMetaData(data) webmention.SetMetaData(&data)
webmention.SetEntryID(entryId) webmention.SetEntryID(entryId)
webmention.SetCreatedAt(time.Now()) webmention.SetCreatedAt(time.Now())
err = s.InteractionRepository.Update(webmention) err = s.InteractionRepository.Update(webmention)
return err return err
} else { } else {
webmention = &interactions.Webmention{} webmention = &interactions.Webmention{}
data := interactions.WebmentionInteractionMetaData{ data := interactions.WebmentionMetaData{
Source: source, Source: source,
Target: target, Target: target,
Title: hEntry.Title, Title: hEntry.Title,
} }
webmention.SetMetaData(data) webmention.SetMetaData(&data)
webmention.SetEntryID(entryId) webmention.SetEntryID(entryId)
webmention.SetCreatedAt(time.Now()) webmention.SetCreatedAt(time.Now())
err = s.InteractionRepository.Create(webmention) err = s.InteractionRepository.Create(webmention)

222
app/webmention_test.go Normal file
View File

@ -0,0 +1,222 @@
package app_test
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"owl-blogs/app"
"owl-blogs/infra"
"owl-blogs/interactions"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
// func constructResponse(html []byte) *http.Response {
// url, _ := url.Parse("http://example.com/foo/bar")
// return &http.Response{
// Request: &http.Request{
// URL: url,
// },
// Body: io.NopCloser(bytes.NewReader([]byte(html))),
// }
// }
type MockHttpClient struct {
PageContent string
}
// Post implements owlhttp.HttpClient.
func (MockHttpClient) Post(url string, contentType string, body io.Reader) (resp *http.Response, err error) {
panic("unimplemented")
}
// PostForm implements owlhttp.HttpClient.
func (MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
panic("unimplemented")
}
func (c *MockHttpClient) Get(url string) (*http.Response, error) {
return &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte(c.PageContent))),
}, nil
}
func getWebmentionService() *app.WebmentionService {
db := test.NewMockDb()
entryRegister := app.NewEntryTypeRegistry()
entryRegister.Register(&test.MockEntry{})
entryRepo := infra.NewEntryRepository(db, entryRegister)
interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{})
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
http := infra.OwlHttpClient{}
return app.NewWebmentionService(
interactionRepo, entryRepo, &http,
)
}
//
// https://www.w3.org/TR/webmention/#h-webmention-verification
//
func TestParseValidHEntry(t *testing.T) {
service := getWebmentionService()
html := []byte("<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>")
entry, err := service.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
require.NoError(t, err)
require.Equal(t, entry.Title, "Foo")
}
func TestParseValidHEntryWithoutTitle(t *testing.T) {
service := getWebmentionService()
html := []byte("<div class=\"h-entry\"></div><div class=\"p-name\">Foo</div>")
entry, err := service.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
require.NoError(t, err)
require.Equal(t, entry.Title, "")
}
func TestCreateNewWebmention(t *testing.T) {
service := getWebmentionService()
service.Http = &MockHttpClient{
PageContent: "<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>",
}
entry := test.MockEntry{}
service.EntryRepository.Create(&entry)
err := service.ProcessWebmention(
"http://example.com/foo",
fmt.Sprintf("https.//example.com/posts/%s/", entry.ID()),
)
require.NoError(t, err)
inters, err := service.InteractionRepository.FindAll(entry.ID())
require.NoError(t, err)
require.Equal(t, len(inters), 1)
webm := inters[0].(*interactions.Webmention)
meta := webm.MetaData().(*interactions.WebmentionMetaData)
require.Equal(t, meta.Source, "http://example.com/foo")
require.Equal(t, meta.Target, fmt.Sprintf("https.//example.com/posts/%s/", entry.ID()))
require.Equal(t, meta.Title, "Foo")
}
// func TestGetWebmentionEndpointLink(t *testing.T) {
// service := getWebmentionService()
// html := []byte("<link rel=\"webmention\" href=\"http://example.com/webmention\" />")
// endpoint, err := service.GetWebmentionEndpoint(constructResponse(html))
// require.NoError(t, err)
// require.Equal(t, endpoint, "http://example.com/webmention")
// }
// func TestGetWebmentionEndpointLinkA(t *testing.T) {
// service := getWebmentionService()
// html := []byte("<a rel=\"webmention\" href=\"http://example.com/webmention\" />")
// endpoint, err := service.GetWebmentionEndpoint(constructResponse(html))
// require.NoError(t, err)
// require.Equal(t, endpoint, "http://example.com/webmention")
// }
// func TestGetWebmentionEndpointLinkAFakeWebmention(t *testing.T) {
// service := getWebmentionService()
// html := []byte("<a rel=\"not-webmention\" href=\"http://example.com/foo\" /><a rel=\"webmention\" href=\"http://example.com/webmention\" />")
// endpoint, err := service.GetWebmentionEndpoint(constructResponse(html))
// require.NoError(t, err)
// require.Equal(t, endpoint, "http://example.com/webmention")
// }
// func TestGetWebmentionEndpointLinkHeader(t *testing.T) {
// service := getWebmentionService()
// html := []byte("")
// resp := constructResponse(html)
// resp.Header = http.Header{"Link": []string{"<http://example.com/webmention>; rel=\"webmention\""}}
// endpoint, err := service.GetWebmentionEndpoint(resp)
// require.NoError(t, err)
// require.Equal(t, endpoint, "http://example.com/webmention")
// }
// func TestGetWebmentionEndpointLinkHeaderCommas(t *testing.T) {
// service := getWebmentionService()
// html := []byte("")
// resp := constructResponse(html)
// resp.Header = http.Header{
// "Link": []string{"<https://webmention.rocks/test/19/webmention/error>; rel=\"other\", <https://webmention.rocks/test/19/webmention>; rel=\"webmention\""},
// }
// endpoint, err := service.GetWebmentionEndpoint(resp)
// require.NoError(t, err)
// require.Equal(t, endpoint, "https://webmention.rocks/test/19/webmention")
// }
// func TestGetWebmentionEndpointRelativeLink(t *testing.T) {
// service := getWebmentionService()
// html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
// endpoint, err := service.GetWebmentionEndpoint(constructResponse(html))
// require.NoError(t, err)
// require.Equal(t, endpoint, "http://example.com/webmention")
// }
// func TestGetWebmentionEndpointRelativeLinkInHeader(t *testing.T) {
// service := getWebmentionService()
// html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
// resp := constructResponse(html)
// resp.Header = http.Header{"Link": []string{"</webmention>; rel=\"webmention\""}}
// endpoint, err := service.GetWebmentionEndpoint(resp)
// require.NoError(t, err)
// require.Equal(t, endpoint, "http://example.com/webmention")
// }
// func TestRealWorldWebmention(t *testing.T) {
// service := getWebmentionService()
// links := []string{
// "https://webmention.rocks/test/1",
// "https://webmention.rocks/test/2",
// "https://webmention.rocks/test/3",
// "https://webmention.rocks/test/4",
// "https://webmention.rocks/test/5",
// "https://webmention.rocks/test/6",
// "https://webmention.rocks/test/7",
// "https://webmention.rocks/test/8",
// "https://webmention.rocks/test/9",
// // "https://webmention.rocks/test/10", // not supported
// "https://webmention.rocks/test/11",
// "https://webmention.rocks/test/12",
// "https://webmention.rocks/test/13",
// "https://webmention.rocks/test/14",
// "https://webmention.rocks/test/15",
// "https://webmention.rocks/test/16",
// "https://webmention.rocks/test/17",
// "https://webmention.rocks/test/18",
// "https://webmention.rocks/test/19",
// "https://webmention.rocks/test/20",
// "https://webmention.rocks/test/21",
// "https://webmention.rocks/test/22",
// "https://webmention.rocks/test/23/page",
// }
// for _, link := range links {
//
// client := &owl.OwlHttpClient{}
// html, _ := client.Get(link)
// _, err := service.GetWebmentionEndpoint(html)
// if err != nil {
// t.Errorf("Unable to find webmention: %v for link %v", err, link)
// }
// }
// }

View File

@ -64,7 +64,7 @@ func App(db infra.Database) *web.WebApp {
return web.NewWebApp( return web.NewWebApp(
entryService, entryRegister, binaryService, entryService, entryRegister, binaryService,
authorService, siteConfigRepo, configRegister, authorService, siteConfigRepo, configRegister,
webmentionService, webmentionService, interactionRepo,
) )
} }

View File

@ -1,19 +1,24 @@
package infra package infra
import ( import (
"encoding/json"
"errors"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"reflect"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type sqlInteraction struct { type sqlInteraction struct {
Id string `db:"id"` Id string `db:"id"`
Type string `db:"type"` Type string `db:"type"`
EntryId string `db:"entry_id"` EntryId string `db:"entry_id"`
CreatedAt string `db:"created_at"` CreatedAt time.Time `db:"created_at"`
MetaData string `db:"meta_data"` MetaData *string `db:"meta_data"`
} }
type DefaultInteractionRepo struct { type DefaultInteractionRepo struct {
@ -42,8 +47,34 @@ func NewInteractionRepo(db Database, register *app.InteractionTypeRegistry) repo
} }
// Create implements repository.InteractionRepository. // Create implements repository.InteractionRepository.
func (*DefaultInteractionRepo) Create(interaction model.Interaction) error { func (repo *DefaultInteractionRepo) Create(interaction model.Interaction) error {
panic("unimplemented") t, err := repo.typeRegistry.TypeName(interaction)
if err != nil {
return errors.New("interaction type not registered")
}
if interaction.ID() == "" {
interaction.SetID(uuid.New().String())
}
var metaDataJson []byte
if interaction.MetaData() != nil {
metaDataJson, _ = json.Marshal(interaction.MetaData())
}
metaDataStr := string(metaDataJson)
_, err = repo.db.NamedExec(`
INSERT INTO interactions (id, type, entry_id, created_at, meta_data)
VALUES (:id, :type, :entry_id, :created_at, :meta_data)
`, sqlInteraction{
Id: interaction.ID(),
Type: t,
EntryId: interaction.EntryID(),
CreatedAt: interaction.CreatedAt(),
MetaData: &metaDataStr,
})
return err
} }
// Delete implements repository.InteractionRepository. // Delete implements repository.InteractionRepository.
@ -52,16 +83,72 @@ func (*DefaultInteractionRepo) Delete(interaction model.Interaction) error {
} }
// FindAll implements repository.InteractionRepository. // FindAll implements repository.InteractionRepository.
func (*DefaultInteractionRepo) FindAll(entryId string) ([]model.Interaction, error) { func (repo *DefaultInteractionRepo) FindAll(entryId string) ([]model.Interaction, error) {
panic("unimplemented") data := []sqlInteraction{}
err := repo.db.Select(&data, "SELECT * FROM interactions WHERE entry_id = ?", entryId)
if err != nil {
return nil, err
}
interactions := []model.Interaction{}
for _, d := range data {
i, err := repo.sqlInteractionToInteraction(d)
if err != nil {
return nil, err
}
interactions = append(interactions, i)
}
return interactions, nil
} }
// FindById implements repository.InteractionRepository. // FindById implements repository.InteractionRepository.
func (*DefaultInteractionRepo) FindById(id string) (model.Interaction, error) { func (repo *DefaultInteractionRepo) FindById(id string) (model.Interaction, error) {
panic("unimplemented") data := sqlInteraction{}
err := repo.db.Get(&data, "SELECT * FROM interactions WHERE id = ?", id)
if err != nil {
return nil, err
}
if data.Id == "" {
return nil, errors.New("interaction not found")
}
return repo.sqlInteractionToInteraction(data)
} }
// Update implements repository.InteractionRepository. // Update implements repository.InteractionRepository.
func (*DefaultInteractionRepo) Update(interaction model.Interaction) error { func (repo *DefaultInteractionRepo) Update(interaction model.Interaction) error {
panic("unimplemented") exInter, _ := repo.FindById(interaction.ID())
if exInter == nil {
return errors.New("interaction not found")
}
_, err := repo.typeRegistry.TypeName(interaction)
if err != nil {
return errors.New("interaction type not registered")
}
var metaDataJson []byte
if interaction.MetaData() != nil {
metaDataJson, _ = json.Marshal(interaction.MetaData())
}
_, err = repo.db.Exec("UPDATE interactions SET entry_id = ?, meta_data = ? WHERE id = ?", interaction.EntryID(), metaDataJson, interaction.ID())
return err
}
func (repo *DefaultInteractionRepo) sqlInteractionToInteraction(interaction sqlInteraction) (model.Interaction, error) {
i, err := repo.typeRegistry.Type(interaction.Type)
if err != nil {
return nil, errors.New("interaction type not registered")
}
metaData := reflect.New(reflect.TypeOf(i.MetaData()).Elem()).Interface()
json.Unmarshal([]byte(*interaction.MetaData), metaData)
i.SetID(interaction.Id)
i.SetEntryID(interaction.EntryId)
i.SetCreatedAt(interaction.CreatedAt)
i.SetMetaData(metaData)
return i, nil
} }

View File

@ -1,20 +1,28 @@
package interactions package interactions
import "owl-blogs/domain/model" import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Webmention struct { type Webmention struct {
model.InteractionBase model.InteractionBase
meta WebmentionInteractionMetaData meta WebmentionMetaData
} }
type WebmentionInteractionMetaData struct { type WebmentionMetaData struct {
Source string Source string
Target string Target string
Title string Title string
} }
func (i *Webmention) Content() model.InteractionContent { func (i *Webmention) Content() model.InteractionContent {
return model.InteractionContent(i.meta.Source) str, err := render.RenderTemplateToString("interaction/Webmention", i)
if err != nil {
fmt.Println(err)
}
return model.InteractionContent(str)
} }
func (i *Webmention) MetaData() interface{} { func (i *Webmention) MetaData() interface{} {
@ -22,5 +30,5 @@ func (i *Webmention) MetaData() interface{} {
} }
func (i *Webmention) SetMetaData(metaData interface{}) { func (i *Webmention) SetMetaData(metaData interface{}) {
i.meta = *metaData.(*WebmentionInteractionMetaData) i.meta = *metaData.(*WebmentionMetaData)
} }

View File

@ -6,6 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{template "title" .Data}} - {{ .SiteConfig.Title }}</title> <title>{{template "title" .Data}} - {{ .SiteConfig.Title }}</title>
<meta property="og:title" content="{{template "title" .Data}}" /> <meta property="og:title" content="{{template "title" .Data}}" />
<link rel="webmention" href="/webmention/" />
<link rel='stylesheet' href='/static/pico.min.css'> <link rel='stylesheet' href='/static/pico.min.css'>
<style> <style>

View File

@ -0,0 +1,7 @@
<a href="{{.MetaData.Source}}">
{{if .MetaData.Title}}
{{.MetaData.Title}}
{{else}}
{{.MetaData.Source}}
{{end}}
</a>

View File

@ -39,6 +39,25 @@
</div> </div>
{{if .Interactions}}
<br>
<br>
<br>
<hr>
<h4>
Interactions
</h4>
<ul>
{{range .Interactions}}
<li>
{{ .Content }}
</li>
{{end}}
</ul>
{{end}}
{{ if .LoggedIn }} {{ if .LoggedIn }}
<br> <br>
<br> <br>

View File

@ -35,13 +35,14 @@ func NewWebApp(
configRepo repository.ConfigRepository, configRepo repository.ConfigRepository,
configRegister *app.ConfigRegister, configRegister *app.ConfigRegister,
webmentionService *app.WebmentionService, webmentionService *app.WebmentionService,
interactionRepo repository.InteractionRepository,
) *WebApp { ) *WebApp {
app := fiber.New() app := fiber.New()
app.Use(middleware.NewUserMiddleware(authorService).Handle) app.Use(middleware.NewUserMiddleware(authorService).Handle)
indexHandler := NewIndexHandler(entryService, configRepo) indexHandler := NewIndexHandler(entryService, configRepo)
listHandler := NewListHandler(entryService, configRepo) listHandler := NewListHandler(entryService, configRepo)
entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, configRepo) entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, configRepo, interactionRepo)
mediaHandler := NewMediaHandler(binService) mediaHandler := NewMediaHandler(binService)
rssHandler := NewRSSHandler(entryService, configRepo) rssHandler := NewRSSHandler(entryService, configRepo)
loginHandler := NewLoginHandler(authorService, configRepo) loginHandler := NewLoginHandler(authorService, configRepo)

View File

@ -10,16 +10,18 @@ import (
) )
type EntryHandler struct { type EntryHandler struct {
configRepo repository.ConfigRepository configRepo repository.ConfigRepository
entrySvc *app.EntryService entrySvc *app.EntryService
authorSvc *app.AuthorService authorSvc *app.AuthorService
registry *app.EntryTypeRegistry registry *app.EntryTypeRegistry
interactionRepo repository.InteractionRepository
} }
type entryData struct { type entryData struct {
Entry model.Entry Entry model.Entry
Author *model.Author Author *model.Author
LoggedIn bool LoggedIn bool
Interactions []model.Interaction
} }
func NewEntryHandler( func NewEntryHandler(
@ -27,12 +29,14 @@ func NewEntryHandler(
registry *app.EntryTypeRegistry, registry *app.EntryTypeRegistry,
authorService *app.AuthorService, authorService *app.AuthorService,
configRepo repository.ConfigRepository, configRepo repository.ConfigRepository,
interactionRepo repository.InteractionRepository,
) *EntryHandler { ) *EntryHandler {
return &EntryHandler{ return &EntryHandler{
entrySvc: entryService, entrySvc: entryService,
authorSvc: authorService, authorSvc: authorService,
registry: registry, registry: registry,
configRepo: configRepo, configRepo: configRepo,
interactionRepo: interactionRepo,
} }
} }
@ -58,14 +62,17 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error {
author = &model.Author{} author = &model.Author{}
} }
inters, _ := h.interactionRepo.FindAll(entry.ID())
return render.RenderTemplateWithBase( return render.RenderTemplateWithBase(
c, c,
getSiteConfig(h.configRepo), getSiteConfig(h.configRepo),
"views/entry", "views/entry",
entryData{ entryData{
Entry: entry, Entry: entry,
Author: author, Author: author,
LoggedIn: loggedIn, LoggedIn: loggedIn,
Interactions: inters,
}, },
) )
} }

View File

@ -26,6 +26,9 @@ func (h *WebmentionHandler) Handle(c *fiber.Ctx) error {
target := c.FormValue("target") target := c.FormValue("target")
source := c.FormValue("source") source := c.FormValue("source")
println("target", target)
println("source", source)
if target == "" { if target == "" {
return c.Status(400).SendString("target is required") return c.Status(400).SendString("target is required")
} }