diff --git a/app/entry_register.go b/app/entry_register.go index 90c4e7e..125ae96 100644 --- a/app/entry_register.go +++ b/app/entry_register.go @@ -1,58 +1,11 @@ package app import ( - "errors" "owl-blogs/domain/model" - "reflect" ) -type EntryTypeRegistry struct { - types map[string]model.Entry -} +type EntryTypeRegistry = TypeRegistry[model.Entry] func NewEntryTypeRegistry() *EntryTypeRegistry { - return &EntryTypeRegistry{types: map[string]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 + return NewTypeRegistry[model.Entry]() } diff --git a/app/generic_register.go b/app/generic_register.go new file mode 100644 index 0000000..8766c70 --- /dev/null +++ b/app/generic_register.go @@ -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 +} diff --git a/app/interaction_register.go b/app/interaction_register.go new file mode 100644 index 0000000..c1c386b --- /dev/null +++ b/app/interaction_register.go @@ -0,0 +1,11 @@ +package app + +import ( + "owl-blogs/domain/model" +) + +type InteractionTypeRegistry = TypeRegistry[model.Interaction] + +func NewInteractionTypeRegistry() *InteractionTypeRegistry { + return NewTypeRegistry[model.Interaction]() +} diff --git a/app/owlhttp/interface.go b/app/owlhttp/interface.go new file mode 100644 index 0000000..5997b2e --- /dev/null +++ b/app/owlhttp/interface.go @@ -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) +} diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 7e671a4..262ed0e 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -36,3 +36,11 @@ type ConfigRepository interface { Get(name string, config 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) +} diff --git a/app/utils.go b/app/utils.go index 2797526..b8c3d4c 100644 --- a/app/utils.go +++ b/app/utils.go @@ -2,6 +2,7 @@ package app import ( "math/rand" + "strings" ) var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -13,3 +14,12 @@ func RandStringRunes(n int) string { } 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] + } +} diff --git a/app/webmention_service.go b/app/webmention_service.go new file mode 100644 index 0000000..5c712ed --- /dev/null +++ b/app/webmention_service.go @@ -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 + } +} diff --git a/app/webmention_test.go b/app/webmention_test.go new file mode 100644 index 0000000..29d85bb --- /dev/null +++ b/app/webmention_test.go @@ -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("
Foo
") + 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("
Foo
") + 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: "
Foo
", + } + 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("") +// 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("") +// 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("") +// 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{"; 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{"; rel=\"other\", ; 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("") +// 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("") +// resp := constructResponse(html) +// resp.Header = http.Header{"Link": []string{"; 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) +// } +// } + +// } diff --git a/cmd/owl/main.go b/cmd/owl/main.go index ae74c6e..724c332 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -6,6 +6,7 @@ import ( "owl-blogs/app" entrytypes "owl-blogs/entry_types" "owl-blogs/infra" + "owl-blogs/interactions" "owl-blogs/web" "github.com/spf13/cobra" @@ -26,29 +27,44 @@ func Execute() { } func App(db infra.Database) *web.WebApp { - registry := app.NewEntryTypeRegistry() - registry.Register(&entrytypes.Image{}) - registry.Register(&entrytypes.Article{}) - registry.Register(&entrytypes.Page{}) - registry.Register(&entrytypes.Recipe{}) - registry.Register(&entrytypes.Note{}) - registry.Register(&entrytypes.Bookmark{}) - registry.Register(&entrytypes.Reply{}) + // Register Types + entryRegister := app.NewEntryTypeRegistry() + entryRegister.Register(&entrytypes.Image{}) + entryRegister.Register(&entrytypes.Article{}) + entryRegister.Register(&entrytypes.Page{}) + entryRegister.Register(&entrytypes.Recipe{}) + entryRegister.Register(&entrytypes.Note{}) + entryRegister.Register(&entrytypes.Bookmark{}) + entryRegister.Register(&entrytypes.Reply{}) - entryRepo := infra.NewEntryRepository(db, registry) - binRepo := infra.NewBinaryFileRepo(db) - authorRepo := infra.NewDefaultAuthorRepo(db) - siteConfigRepo := infra.NewConfigRepo(db) - - entryService := app.NewEntryService(entryRepo) - binaryService := app.NewBinaryFileService(binRepo) - authorService := app.NewAuthorService(authorRepo, siteConfigRepo) + interactionRegister := app.NewInteractionTypeRegistry() + interactionRegister.Register(&interactions.Webmention{}) 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( - entryService, registry, binaryService, + entryService, entryRegister, binaryService, authorService, siteConfigRepo, configRegister, + webmentionService, interactionRepo, ) } diff --git a/domain/model/interaction.go b/domain/model/interaction.go new file mode 100644 index 0000000..5f88215 --- /dev/null +++ b/domain/model/interaction.go @@ -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 +} diff --git a/go.mod b/go.mod index 1af7299..0c45be0 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.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 ) @@ -37,6 +37,7 @@ require ( github.com/valyala/fasthttp v1.47.0 // indirect github.com/valyala/fastjson v1.6.4 // 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 ) diff --git a/go.sum b/go.sum index a5a4c5e..01abbac 100644 --- a/go.sum +++ b/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.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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-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.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-20201020160332-67f06af15bc9/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.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= diff --git a/infra/entry_repository.go b/infra/entry_repository.go index 1316757..e4fc563 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -28,6 +28,26 @@ type DefaultEntryRepo struct { 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. func (r *DefaultEntryRepo) Create(entry model.Entry) error { t, err := r.typeRegistry.TypeName(entry) @@ -50,6 +70,9 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error { // Delete implements repository.EntryRepository. 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()) return err } @@ -123,26 +146,6 @@ func (r *DefaultEntryRepo) Update(entry model.Entry) error { 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) { e, err := r.typeRegistry.Type(entry.Type) if err != nil { diff --git a/infra/http.go b/infra/http.go new file mode 100644 index 0000000..080cf6d --- /dev/null +++ b/infra/http.go @@ -0,0 +1,5 @@ +package infra + +import "net/http" + +type OwlHttpClient = http.Client diff --git a/infra/interaction_repository.go b/infra/interaction_repository.go new file mode 100644 index 0000000..7ef340b --- /dev/null +++ b/infra/interaction_repository.go @@ -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 + +} diff --git a/interactions/webmention.go b/interactions/webmention.go new file mode 100644 index 0000000..d60b45f --- /dev/null +++ b/interactions/webmention.go @@ -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) +} diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl index d0b0815..2fac1dd 100644 --- a/render/templates/base.tmpl +++ b/render/templates/base.tmpl @@ -6,6 +6,7 @@ {{template "title" .Data}} - {{ .SiteConfig.Title }} +