Compare commits
6 Commits
3f7b1bae50
...
55fb101ab5
Author | SHA1 | Date |
---|---|---|
Niko Abeler | 55fb101ab5 | |
Niko Abeler | 653efcc487 | |
Niko Abeler | 723a6000bf | |
Niko Abeler | df5215d943 | |
Niko Abeler | 6ab9af2d53 | |
Niko Abeler | b1c46a86aa |
|
@ -1,58 +1,11 @@
|
||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"owl-blogs/domain/model"
|
"owl-blogs/domain/model"
|
||||||
"reflect"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type EntryTypeRegistry struct {
|
type EntryTypeRegistry = TypeRegistry[model.Entry]
|
||||||
types map[string]model.Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEntryTypeRegistry() *EntryTypeRegistry {
|
func NewEntryTypeRegistry() *EntryTypeRegistry {
|
||||||
return &EntryTypeRegistry{types: map[string]model.Entry{}}
|
return NewTypeRegistry[model.Entry]()
|
||||||
}
|
|
||||||
|
|
||||||
func (r *EntryTypeRegistry) entryType(entry model.Entry) string {
|
|
||||||
return reflect.TypeOf(entry).Elem().Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *EntryTypeRegistry) Register(entry model.Entry) error {
|
|
||||||
t := r.entryType(entry)
|
|
||||||
if _, ok := r.types[t]; ok {
|
|
||||||
return errors.New("entry type already registered")
|
|
||||||
}
|
|
||||||
r.types[t] = entry
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *EntryTypeRegistry) Types() []model.Entry {
|
|
||||||
types := []model.Entry{}
|
|
||||||
for _, t := range r.types {
|
|
||||||
types = append(types, t)
|
|
||||||
}
|
|
||||||
return types
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *EntryTypeRegistry) TypeName(entry model.Entry) (string, error) {
|
|
||||||
t := r.entryType(entry)
|
|
||||||
if _, ok := r.types[t]; !ok {
|
|
||||||
return "", errors.New("entry type not registered")
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *EntryTypeRegistry) Type(name string) (model.Entry, error) {
|
|
||||||
if _, ok := r.types[name]; !ok {
|
|
||||||
return nil, errors.New("entry type not registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
val := reflect.ValueOf(r.types[name])
|
|
||||||
if val.Kind() == reflect.Ptr {
|
|
||||||
val = reflect.Indirect(val)
|
|
||||||
}
|
|
||||||
newEntry := reflect.New(val.Type()).Interface().(model.Entry)
|
|
||||||
|
|
||||||
return newEntry, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TypeRegistry[T any] struct {
|
||||||
|
types map[string]T
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTypeRegistry[T any]() *TypeRegistry[T] {
|
||||||
|
return &TypeRegistry[T]{types: map[string]T{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) entryType(entry T) string {
|
||||||
|
return reflect.TypeOf(entry).Elem().Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) Register(entry T) error {
|
||||||
|
t := r.entryType(entry)
|
||||||
|
if _, ok := r.types[t]; ok {
|
||||||
|
return errors.New("entry type already registered")
|
||||||
|
}
|
||||||
|
r.types[t] = entry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) Types() []T {
|
||||||
|
types := []T{}
|
||||||
|
for _, t := range r.types {
|
||||||
|
types = append(types, t)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) TypeName(entry T) (string, error) {
|
||||||
|
t := r.entryType(entry)
|
||||||
|
if _, ok := r.types[t]; !ok {
|
||||||
|
return "", errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TypeRegistry[T]) Type(name string) (T, error) {
|
||||||
|
if _, ok := r.types[name]; !ok {
|
||||||
|
return *new(T), errors.New("entry type not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
val := reflect.ValueOf(r.types[name])
|
||||||
|
if val.Kind() == reflect.Ptr {
|
||||||
|
val = reflect.Indirect(val)
|
||||||
|
}
|
||||||
|
newEntry := reflect.New(val.Type()).Interface().(T)
|
||||||
|
|
||||||
|
return newEntry, nil
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InteractionTypeRegistry = TypeRegistry[model.Interaction]
|
||||||
|
|
||||||
|
func NewInteractionTypeRegistry() *InteractionTypeRegistry {
|
||||||
|
return NewTypeRegistry[model.Interaction]()
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package owlhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HttpClient interface {
|
||||||
|
Get(url string) (resp *http.Response, err error)
|
||||||
|
Post(url, contentType string, body io.Reader) (resp *http.Response, err error)
|
||||||
|
PostForm(url string, data url.Values) (resp *http.Response, err error)
|
||||||
|
}
|
|
@ -36,3 +36,11 @@ type ConfigRepository interface {
|
||||||
Get(name string, config interface{}) error
|
Get(name string, config interface{}) error
|
||||||
Update(name string, siteConfig interface{}) error
|
Update(name string, siteConfig interface{}) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InteractionRepository interface {
|
||||||
|
Create(interaction model.Interaction) error
|
||||||
|
Update(interaction model.Interaction) error
|
||||||
|
Delete(interaction model.Interaction) error
|
||||||
|
FindById(id string) (model.Interaction, error)
|
||||||
|
FindAll(entryId string) ([]model.Interaction, error)
|
||||||
|
}
|
||||||
|
|
10
app/utils.go
10
app/utils.go
|
@ -2,6 +2,7 @@ package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
@ -13,3 +14,12 @@ func RandStringRunes(n int) string {
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UrlToEntryId(url string) string {
|
||||||
|
parts := strings.Split(url, "/")
|
||||||
|
if parts[len(parts)-1] == "" {
|
||||||
|
return parts[len(parts)-2]
|
||||||
|
} else {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,166 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"owl-blogs/app/owlhttp"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/interactions"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebmentionService struct {
|
||||||
|
InteractionRepository repository.InteractionRepository
|
||||||
|
EntryRepository repository.EntryRepository
|
||||||
|
Http owlhttp.HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedHEntry struct {
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebmentionService(
|
||||||
|
interactionRepository repository.InteractionRepository,
|
||||||
|
entryRepository repository.EntryRepository,
|
||||||
|
http owlhttp.HttpClient,
|
||||||
|
) *WebmentionService {
|
||||||
|
return &WebmentionService{
|
||||||
|
InteractionRepository: interactionRepository,
|
||||||
|
EntryRepository: entryRepository,
|
||||||
|
Http: http,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readResponseBody(resp *http.Response) (string, error) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bodyBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectText(n *html.Node, buf *bytes.Buffer) {
|
||||||
|
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
buf.WriteString(n.Data)
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
collectText(c, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (WebmentionService) ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
|
||||||
|
htmlStr, err := readResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return ParsedHEntry{}, err
|
||||||
|
}
|
||||||
|
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||||
|
if err != nil {
|
||||||
|
return ParsedHEntry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var interpretHFeed func(*html.Node, *ParsedHEntry, bool) (ParsedHEntry, error)
|
||||||
|
interpretHFeed = func(n *html.Node, curr *ParsedHEntry, parent bool) (ParsedHEntry, error) {
|
||||||
|
attrs := n.Attr
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if attr.Key == "class" && strings.Contains(attr.Val, "p-name") {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
collectText(n, buf)
|
||||||
|
curr.Title = buf.String()
|
||||||
|
return *curr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
interpretHFeed(c, curr, false)
|
||||||
|
}
|
||||||
|
return *curr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var findHFeed func(*html.Node) (ParsedHEntry, error)
|
||||||
|
findHFeed = func(n *html.Node) (ParsedHEntry, error) {
|
||||||
|
attrs := n.Attr
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if attr.Key == "class" && strings.Contains(attr.Val, "h-entry") {
|
||||||
|
return interpretHFeed(n, &ParsedHEntry{}, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
entry, err := findHFeed(c)
|
||||||
|
if err == nil {
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ParsedHEntry{}, errors.New("no h-entry found")
|
||||||
|
}
|
||||||
|
return findHFeed(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) GetExistingWebmention(entryId string, source string, target string) (*interactions.Webmention, error) {
|
||||||
|
inters, err := s.InteractionRepository.FindAll(entryId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, interaction := range inters {
|
||||||
|
if webm, ok := interaction.(*interactions.Webmention); ok {
|
||||||
|
m := webm.MetaData().(*interactions.WebmentionMetaData)
|
||||||
|
if m.Source == source && m.Target == target {
|
||||||
|
return webm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebmentionService) ProcessWebmention(source string, target string) error {
|
||||||
|
resp, err := s.Http.Get(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hEntry, err := s.ParseHEntry(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entryId := UrlToEntryId(target)
|
||||||
|
_, err = s.EntryRepository.FindById(entryId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
webmention, err := s.GetExistingWebmention(entryId, source, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if webmention != nil {
|
||||||
|
data := interactions.WebmentionMetaData{
|
||||||
|
Source: source,
|
||||||
|
Target: target,
|
||||||
|
Title: hEntry.Title,
|
||||||
|
}
|
||||||
|
webmention.SetMetaData(&data)
|
||||||
|
webmention.SetEntryID(entryId)
|
||||||
|
webmention.SetCreatedAt(time.Now())
|
||||||
|
err = s.InteractionRepository.Update(webmention)
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
webmention = &interactions.Webmention{}
|
||||||
|
data := interactions.WebmentionMetaData{
|
||||||
|
Source: source,
|
||||||
|
Target: target,
|
||||||
|
Title: hEntry.Title,
|
||||||
|
}
|
||||||
|
webmention.SetMetaData(&data)
|
||||||
|
webmention.SetEntryID(entryId)
|
||||||
|
webmention.SetCreatedAt(time.Now())
|
||||||
|
err = s.InteractionRepository.Create(webmention)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"owl-blogs/app"
|
"owl-blogs/app"
|
||||||
entrytypes "owl-blogs/entry_types"
|
entrytypes "owl-blogs/entry_types"
|
||||||
"owl-blogs/infra"
|
"owl-blogs/infra"
|
||||||
|
"owl-blogs/interactions"
|
||||||
"owl-blogs/web"
|
"owl-blogs/web"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
@ -26,29 +27,44 @@ func Execute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func App(db infra.Database) *web.WebApp {
|
func App(db infra.Database) *web.WebApp {
|
||||||
registry := app.NewEntryTypeRegistry()
|
// Register Types
|
||||||
registry.Register(&entrytypes.Image{})
|
entryRegister := app.NewEntryTypeRegistry()
|
||||||
registry.Register(&entrytypes.Article{})
|
entryRegister.Register(&entrytypes.Image{})
|
||||||
registry.Register(&entrytypes.Page{})
|
entryRegister.Register(&entrytypes.Article{})
|
||||||
registry.Register(&entrytypes.Recipe{})
|
entryRegister.Register(&entrytypes.Page{})
|
||||||
registry.Register(&entrytypes.Note{})
|
entryRegister.Register(&entrytypes.Recipe{})
|
||||||
registry.Register(&entrytypes.Bookmark{})
|
entryRegister.Register(&entrytypes.Note{})
|
||||||
registry.Register(&entrytypes.Reply{})
|
entryRegister.Register(&entrytypes.Bookmark{})
|
||||||
|
entryRegister.Register(&entrytypes.Reply{})
|
||||||
|
|
||||||
entryRepo := infra.NewEntryRepository(db, registry)
|
interactionRegister := app.NewInteractionTypeRegistry()
|
||||||
binRepo := infra.NewBinaryFileRepo(db)
|
interactionRegister.Register(&interactions.Webmention{})
|
||||||
authorRepo := infra.NewDefaultAuthorRepo(db)
|
|
||||||
siteConfigRepo := infra.NewConfigRepo(db)
|
|
||||||
|
|
||||||
entryService := app.NewEntryService(entryRepo)
|
|
||||||
binaryService := app.NewBinaryFileService(binRepo)
|
|
||||||
authorService := app.NewAuthorService(authorRepo, siteConfigRepo)
|
|
||||||
|
|
||||||
configRegister := app.NewConfigRegister()
|
configRegister := app.NewConfigRegister()
|
||||||
|
|
||||||
|
// Create Repositories
|
||||||
|
entryRepo := infra.NewEntryRepository(db, entryRegister)
|
||||||
|
binRepo := infra.NewBinaryFileRepo(db)
|
||||||
|
authorRepo := infra.NewDefaultAuthorRepo(db)
|
||||||
|
siteConfigRepo := infra.NewConfigRepo(db)
|
||||||
|
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
|
||||||
|
|
||||||
|
// Create External Services
|
||||||
|
httpClient := &infra.OwlHttpClient{}
|
||||||
|
|
||||||
|
// Create Services
|
||||||
|
entryService := app.NewEntryService(entryRepo)
|
||||||
|
binaryService := app.NewBinaryFileService(binRepo)
|
||||||
|
authorService := app.NewAuthorService(authorRepo, siteConfigRepo)
|
||||||
|
webmentionService := app.NewWebmentionService(
|
||||||
|
interactionRepo, entryRepo, httpClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create WebApp
|
||||||
return web.NewWebApp(
|
return web.NewWebApp(
|
||||||
entryService, registry, binaryService,
|
entryService, entryRegister, binaryService,
|
||||||
authorService, siteConfigRepo, configRegister,
|
authorService, siteConfigRepo, configRegister,
|
||||||
|
webmentionService, interactionRepo,
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type InteractionContent string
|
||||||
|
|
||||||
|
// Interaction is a generic interface for all interactions with entries
|
||||||
|
// These interactions can be:
|
||||||
|
// - Webmention, Pingback, Trackback
|
||||||
|
// - Likes, Comments on third party sites
|
||||||
|
// - Comments on the site itself
|
||||||
|
type Interaction interface {
|
||||||
|
ID() string
|
||||||
|
EntryID() string
|
||||||
|
Content() InteractionContent
|
||||||
|
CreatedAt() time.Time
|
||||||
|
MetaData() interface{}
|
||||||
|
|
||||||
|
SetID(id string)
|
||||||
|
SetEntryID(entryID string)
|
||||||
|
SetCreatedAt(createdAt time.Time)
|
||||||
|
SetMetaData(metaData interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractionBase struct {
|
||||||
|
id string
|
||||||
|
entryID string
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) ID() string {
|
||||||
|
return i.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) EntryID() string {
|
||||||
|
return i.entryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) CreatedAt() time.Time {
|
||||||
|
return i.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) SetID(id string) {
|
||||||
|
i.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) SetEntryID(entryID string) {
|
||||||
|
i.entryID = entryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InteractionBase) SetCreatedAt(createdAt time.Time) {
|
||||||
|
i.createdAt = createdAt
|
||||||
|
}
|
5
go.mod
5
go.mod
|
@ -10,7 +10,7 @@ require (
|
||||||
github.com/spf13/cobra v1.7.0
|
github.com/spf13/cobra v1.7.0
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.8.4
|
||||||
github.com/yuin/goldmark v1.5.4
|
github.com/yuin/goldmark v1.5.4
|
||||||
golang.org/x/crypto v0.11.0
|
golang.org/x/crypto v0.12.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ require (
|
||||||
github.com/valyala/fasthttp v1.47.0 // indirect
|
github.com/valyala/fasthttp v1.47.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.4 // indirect
|
github.com/valyala/fastjson v1.6.4 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.10.0 // indirect
|
golang.org/x/net v0.14.0 // indirect
|
||||||
|
golang.org/x/sys v0.11.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
6
go.sum
6
go.sum
|
@ -76,6 +76,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
|
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
|
||||||
|
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
@ -85,6 +87,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
|
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||||
|
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
@ -101,6 +105,8 @@ golang.org/x/sys v0.3.0/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.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
|
|
|
@ -28,6 +28,26 @@ type DefaultEntryRepo struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository.EntryRepository {
|
||||||
|
sqlxdb := db.Get()
|
||||||
|
|
||||||
|
// Create tables if not exists
|
||||||
|
sqlxdb.MustExec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
published_at DATETIME,
|
||||||
|
author_id TEXT NOT NULL,
|
||||||
|
meta_data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return &DefaultEntryRepo{
|
||||||
|
db: sqlxdb,
|
||||||
|
typeRegistry: register,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create implements repository.EntryRepository.
|
// Create implements repository.EntryRepository.
|
||||||
func (r *DefaultEntryRepo) Create(entry model.Entry) error {
|
func (r *DefaultEntryRepo) Create(entry model.Entry) error {
|
||||||
t, err := r.typeRegistry.TypeName(entry)
|
t, err := r.typeRegistry.TypeName(entry)
|
||||||
|
@ -50,6 +70,9 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error {
|
||||||
|
|
||||||
// Delete implements repository.EntryRepository.
|
// Delete implements repository.EntryRepository.
|
||||||
func (r *DefaultEntryRepo) Delete(entry model.Entry) error {
|
func (r *DefaultEntryRepo) Delete(entry model.Entry) error {
|
||||||
|
if entry.ID() == "" {
|
||||||
|
return errors.New("entry not found")
|
||||||
|
}
|
||||||
_, err := r.db.Exec("DELETE FROM entries WHERE id = ?", entry.ID())
|
_, err := r.db.Exec("DELETE FROM entries WHERE id = ?", entry.ID())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -123,26 +146,6 @@ func (r *DefaultEntryRepo) Update(entry model.Entry) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository.EntryRepository {
|
|
||||||
sqlxdb := db.Get()
|
|
||||||
|
|
||||||
// Create tables if not exists
|
|
||||||
sqlxdb.MustExec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
published_at DATETIME,
|
|
||||||
author_id TEXT NOT NULL,
|
|
||||||
meta_data TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
|
|
||||||
return &DefaultEntryRepo{
|
|
||||||
db: sqlxdb,
|
|
||||||
typeRegistry: register,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) {
|
func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) {
|
||||||
e, err := r.typeRegistry.Type(entry.Type)
|
e, err := r.typeRegistry.Type(entry.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
type OwlHttpClient = http.Client
|
|
@ -0,0 +1,158 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlInteraction struct {
|
||||||
|
Id string `db:"id"`
|
||||||
|
Type string `db:"type"`
|
||||||
|
EntryId string `db:"entry_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
MetaData *string `db:"meta_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultInteractionRepo struct {
|
||||||
|
typeRegistry *app.InteractionTypeRegistry
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInteractionRepo(db Database, register *app.InteractionTypeRegistry) repository.InteractionRepository {
|
||||||
|
sqlxdb := db.Get()
|
||||||
|
|
||||||
|
// Create tables if not exists
|
||||||
|
sqlxdb.MustExec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS interactions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
entry_id TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
meta_data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return &DefaultInteractionRepo{
|
||||||
|
db: sqlxdb,
|
||||||
|
typeRegistry: register,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create implements repository.InteractionRepository.
|
||||||
|
func (repo *DefaultInteractionRepo) Create(interaction model.Interaction) error {
|
||||||
|
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.
|
||||||
|
func (repo *DefaultInteractionRepo) Delete(interaction model.Interaction) error {
|
||||||
|
if interaction.ID() == "" {
|
||||||
|
return errors.New("interaction not found")
|
||||||
|
}
|
||||||
|
_, err := repo.db.Exec("DELETE FROM interactions WHERE id = ?", interaction.ID())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll implements repository.InteractionRepository.
|
||||||
|
func (repo *DefaultInteractionRepo) FindAll(entryId string) ([]model.Interaction, error) {
|
||||||
|
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.
|
||||||
|
func (repo *DefaultInteractionRepo) FindById(id string) (model.Interaction, error) {
|
||||||
|
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.
|
||||||
|
func (repo *DefaultInteractionRepo) Update(interaction model.Interaction) error {
|
||||||
|
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
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package interactions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"owl-blogs/domain/model"
|
||||||
|
"owl-blogs/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Webmention struct {
|
||||||
|
model.InteractionBase
|
||||||
|
meta WebmentionMetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebmentionMetaData struct {
|
||||||
|
Source string
|
||||||
|
Target string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Webmention) Content() model.InteractionContent {
|
||||||
|
str, err := render.RenderTemplateToString("interaction/Webmention", i)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
return model.InteractionContent(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Webmention) MetaData() interface{} {
|
||||||
|
return &i.meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Webmention) SetMetaData(metaData interface{}) {
|
||||||
|
i.meta = *metaData.(*WebmentionMetaData)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
<a href="{{.MetaData.Source}}">
|
||||||
|
{{if .MetaData.Title}}
|
||||||
|
{{.MetaData.Title}}
|
||||||
|
{{else}}
|
||||||
|
{{.MetaData.Source}}
|
||||||
|
{{end}}
|
||||||
|
</a>
|
|
@ -39,6 +39,34 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .Interactions}}
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<hr>
|
||||||
|
<h4>
|
||||||
|
Interactions
|
||||||
|
</h4>
|
||||||
|
<ul>
|
||||||
|
{{range .Interactions}}
|
||||||
|
<li>
|
||||||
|
{{ .Content }}
|
||||||
|
{{ if $.LoggedIn }}
|
||||||
|
<form method="post" action="/admin/interactions/{{.ID}}/delete/" class="grid">
|
||||||
|
<label for="confirm">
|
||||||
|
Confirm deletion
|
||||||
|
<input type="checkbox" name="confirm" id="confirm" required />
|
||||||
|
</label>
|
||||||
|
<input type="submit" class="secondary outline" value="Delete" />
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{{ if .LoggedIn }}
|
{{ if .LoggedIn }}
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminInteractionHandler struct {
|
||||||
|
interactionRepo repository.InteractionRepository
|
||||||
|
configRepo repository.ConfigRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminInteractionHandler(configRepo repository.ConfigRepository, interactionRepo repository.InteractionRepository) *AdminInteractionHandler {
|
||||||
|
return &AdminInteractionHandler{
|
||||||
|
interactionRepo: interactionRepo,
|
||||||
|
configRepo: configRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminInteractionHandler) HandleDelete(c *fiber.Ctx) error {
|
||||||
|
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
|
||||||
|
|
||||||
|
id := c.Params("id")
|
||||||
|
inter, err := h.interactionRepo.FindById(id)
|
||||||
|
entryId := inter.EntryID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm := c.FormValue("confirm")
|
||||||
|
if confirm != "on" {
|
||||||
|
return c.Redirect("/posts/" + inter.ID() + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.interactionRepo.Delete(inter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Redirect("/posts/" + entryId + "/")
|
||||||
|
}
|
|
@ -34,17 +34,20 @@ func NewWebApp(
|
||||||
authorService *app.AuthorService,
|
authorService *app.AuthorService,
|
||||||
configRepo repository.ConfigRepository,
|
configRepo repository.ConfigRepository,
|
||||||
configRegister *app.ConfigRegister,
|
configRegister *app.ConfigRegister,
|
||||||
|
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)
|
||||||
editorHandler := NewEditorHandler(entryService, typeRegistry, binService, configRepo)
|
editorHandler := NewEditorHandler(entryService, typeRegistry, binService, configRepo)
|
||||||
|
webmentionHandler := NewWebmentionHandler(webmentionService, configRepo)
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
app.Get("/auth/login", loginHandler.HandleGet)
|
app.Get("/auth/login", loginHandler.HandleGet)
|
||||||
|
@ -54,6 +57,7 @@ func NewWebApp(
|
||||||
adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry)
|
adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry)
|
||||||
draftHandler := NewDraftHandler(entryService, configRepo)
|
draftHandler := NewDraftHandler(entryService, configRepo)
|
||||||
binaryManageHandler := NewBinaryManageHandler(configRepo, binService)
|
binaryManageHandler := NewBinaryManageHandler(configRepo, binService)
|
||||||
|
adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo)
|
||||||
admin := app.Group("/admin")
|
admin := app.Group("/admin")
|
||||||
admin.Use(middleware.NewAuthMiddleware(authorService).Handle)
|
admin.Use(middleware.NewAuthMiddleware(authorService).Handle)
|
||||||
admin.Get("/", adminHandler.Handle)
|
admin.Get("/", adminHandler.Handle)
|
||||||
|
@ -63,6 +67,7 @@ func NewWebApp(
|
||||||
admin.Get("/binaries/", binaryManageHandler.Handle)
|
admin.Get("/binaries/", binaryManageHandler.Handle)
|
||||||
admin.Post("/binaries/new/", binaryManageHandler.HandleUpload)
|
admin.Post("/binaries/new/", binaryManageHandler.HandleUpload)
|
||||||
admin.Post("/binaries/delete", binaryManageHandler.HandleDelete)
|
admin.Post("/binaries/delete", binaryManageHandler.HandleDelete)
|
||||||
|
admin.Post("/interactions/:id/delete/", adminInteractionHandler.HandleDelete)
|
||||||
|
|
||||||
// Editor
|
// Editor
|
||||||
editor := app.Group("/editor")
|
editor := app.Group("/editor")
|
||||||
|
@ -111,6 +116,8 @@ func NewWebApp(
|
||||||
app.Get("/index.xml", rssHandler.Handle)
|
app.Get("/index.xml", rssHandler.Handle)
|
||||||
// Posts
|
// Posts
|
||||||
app.Get("/posts/:post/", entryHandler.Handle)
|
app.Get("/posts/:post/", entryHandler.Handle)
|
||||||
|
// Webmention
|
||||||
|
app.Post("/webmention/", webmentionHandler.Handle)
|
||||||
// robots.txt
|
// robots.txt
|
||||||
app.Get("/robots.txt", func(c *fiber.Ctx) error {
|
app.Get("/robots.txt", func(c *fiber.Ctx) error {
|
||||||
siteConfig := model.SiteConfig{}
|
siteConfig := model.SiteConfig{}
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"owl-blogs/app"
|
||||||
|
"owl-blogs/app/repository"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebmentionHandler struct {
|
||||||
|
configRepo repository.ConfigRepository
|
||||||
|
webmentionService *app.WebmentionService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebmentionHandler(
|
||||||
|
webmentionService *app.WebmentionService,
|
||||||
|
configRepo repository.ConfigRepository,
|
||||||
|
) *WebmentionHandler {
|
||||||
|
return &WebmentionHandler{
|
||||||
|
webmentionService: webmentionService,
|
||||||
|
configRepo: configRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebmentionHandler) Handle(c *fiber.Ctx) error {
|
||||||
|
target := c.FormValue("target")
|
||||||
|
source := c.FormValue("source")
|
||||||
|
|
||||||
|
println("Incoming webmention")
|
||||||
|
println("target", target)
|
||||||
|
println("source", source)
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
return c.Status(400).SendString("target is required")
|
||||||
|
}
|
||||||
|
if source == "" {
|
||||||
|
return c.Status(400).SendString("source is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(target) < 7 || (target[:7] != "http://" && target[:8] != "https://") {
|
||||||
|
return c.Status(400).SendString("target must be a valid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(source) < 7 || (source[:7] != "http://" && source[:8] != "https://") {
|
||||||
|
return c.Status(400).SendString("source must be a valid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if source == target {
|
||||||
|
return c.Status(400).SendString("source and target must be different")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.webmentionService.ProcessWebmention(source, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendString("ok")
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue