From 0c38b587b4c7870386ea4f988d919ef749f4f99f Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 21:23:06 +0600 Subject: [PATCH] :technologist: Created entry handler with basic ActivityPub support --- go.mod | 8 + go.sum | 10 ++ internal/domain/entry.go | 20 +++ internal/domain/path.go | 17 +- internal/entry/delivery/http/entry_http.go | 153 ++++++++++++++++++ .../entry/delivery/http/entry_http_test.go | 44 +++++ .../TestHandler_ServeHTTP/note.golden | 1 + .../TestHandler_ServeHTTP/person.golden | 1 + internal/entry/usecase/page_ucase.go | 26 ++- internal/theme/usecase/theme_ucase.go | 20 ++- 10 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 internal/entry/delivery/http/entry_http.go create mode 100644 internal/entry/delivery/http/entry_http_test.go create mode 100755 internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/note.golden create mode 100755 internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/person.golden diff --git a/go.mod b/go.mod index 9f5ae20..5a7d5ed 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,16 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect + github.com/go-ap/errors v0.0.0-20231003111023-183eef4b31b7 // indirect + github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 // indirect + github.com/valyala/fastjson v1.6.4 // indirect +) + require ( github.com/BurntSushi/toml v1.3.2 // indirect + github.com/go-ap/activitypub v0.0.0-20240211124657-820024a66b78 github.com/valyala/bytebufferpool v1.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 45c6743..3c04fc9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg= +git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -7,6 +9,12 @@ github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA= github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18= +github.com/go-ap/activitypub v0.0.0-20240211124657-820024a66b78 h1:M3QPsOk2J/AyyIODQQf2Jm9vsp6Jor0NQWyIBzI3oSM= +github.com/go-ap/activitypub v0.0.0-20240211124657-820024a66b78/go.mod h1:cJ9Ye0ZNSMN7RzZDBRY3E+8M3Bpf/R1JX22Ir9yX6WI= +github.com/go-ap/errors v0.0.0-20231003111023-183eef4b31b7 h1:I2nuhyVI/48VXoRCCZR2hYBgnSXa+EuDJf/VyX06TC0= +github.com/go-ap/errors v0.0.0-20231003111023-183eef4b31b7/go.mod h1:5x8a6P/dhmMGFxWLcyYlyOuJ2lRNaHGhRv+yu8BaTSI= +github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= +github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -15,6 +23,8 @@ github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM= github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= diff --git a/internal/domain/entry.go b/internal/domain/entry.go index c7b6015..9ce4073 100644 --- a/internal/domain/entry.go +++ b/internal/domain/entry.go @@ -1,5 +1,10 @@ package domain +import ( + "path" + "testing" +) + type Entry struct { Language Language Params map[string]any @@ -18,3 +23,18 @@ func (e Entry) IsHome() bool { func (e Entry) IsTranslated() bool { return 1 < len(e.Translations) } + +func TestEntry(tb testing.TB) *Entry { + tb.Helper() + + return &Entry{ + Language: NewLanguage("en"), + Params: make(map[string]any), + File: NewPath(path.Join("sample-page.en.md")), + Description: "do not use in production", + Title: "Sample Page", + Content: []byte("Hello, world!"), + Resources: make([]*Resource, 0), + Translations: make([]*Entry, 0), + } +} diff --git a/internal/domain/path.go b/internal/domain/path.go index b5af346..0fd0d68 100644 --- a/internal/domain/path.go +++ b/internal/domain/path.go @@ -2,6 +2,7 @@ package domain import ( "crypto/md5" + "path" "path/filepath" "strings" ) @@ -19,16 +20,16 @@ type Path struct { uniqueId string } -func NewPath(path string) Path { +func NewPath(p string) Path { out := Path{ Language: LanguageUnd, baseFileName: "", contentBaseName: "", - dir: filepath.Dir(path), - ext: strings.TrimPrefix(filepath.Ext(path), "."), - filename: path, - logicalName: filepath.Base(path), - path: path, + dir: filepath.Dir(p), + ext: strings.TrimPrefix(filepath.Ext(p), "."), + filename: p, + logicalName: filepath.Base(p), + path: "", translationBaseName: "", uniqueId: "", } @@ -54,6 +55,8 @@ func NewPath(path string) Path { out.contentBaseName = filepath.Base(out.dir) } + out.path = path.Join(out.Language.code, out.dir, out.contentBaseName) + hash := md5.New() _, _ = hash.Write([]byte(out.path)) out.uniqueId = string(hash.Sum(nil)) @@ -99,6 +102,7 @@ func (p Path) Ext() string { return p.ext } +// Filename returns the file path relative to $HOME_CONTENT_DIR. func (p Path) Filename() string { return p.filename } @@ -113,6 +117,7 @@ func (p Path) LogicalName() string { return p.logicalName } +// Path returns the relative URL path to the file. func (p Path) Path() string { return p.path } diff --git a/internal/entry/delivery/http/entry_http.go b/internal/entry/delivery/http/entry_http.go new file mode 100644 index 0000000..4a7b6d4 --- /dev/null +++ b/internal/entry/delivery/http/entry_http.go @@ -0,0 +1,153 @@ +package http + +import ( + "encoding/json" + "errors" + "mime" + "net/http" + "strings" + "time" + + "github.com/go-ap/activitypub" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + "source.toby3d.me/toby3d/home/internal/entry" + "source.toby3d.me/toby3d/home/internal/site" + "source.toby3d.me/toby3d/home/internal/theme" + "source.toby3d.me/toby3d/home/internal/urlutil" +) + +type ( + Handler struct { + entries entry.UseCase + sites site.UseCase + themes theme.UseCase + } + + Profile struct { + *activitypub.Person + + // Mastodon related + + // Pinned posts. See [Featured collection]. + // + // [Featured collection]: https://docs.joinmastodon.org/spec/activitypub/#featured + Featured []interface{} `json:"featured"` + + // Required for Move activity. + AlsoKnownAs []string `json:"alsoKnownAs"` + + // Will be shown as a locked account. + ManuallyApprovesFollowers bool `json:"manuallyApprovesFollowers"` + + // Will be shown in the profile directory. + // See [Discoverability flag]. + // + // [Discoverability flag]: https://docs.joinmastodon.org/spec/activitypub/#discoverable + Discoverable bool `json:"discoverable"` + } +) + +func NewHandler(sites site.UseCase, entries entry.UseCase, themes theme.UseCase) *Handler { + return &Handler{ + sites: sites, + entries: entries, + themes: themes, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + head, tail := urlutil.ShiftPath(r.URL.Path) + + lang := domain.NewLanguage(head) + + s, err := h.sites.Do(r.Context(), lang) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + contentType, params, _ := mime.ParseMediaType(r.Header.Get(common.HeaderAccept)) + + switch contentType { + case common.MIMEApplicationLdJSON: // NOTE(toby3d): show entry as ActivityPub object. + if profile, ok := params["profile"]; ok && profile != "https://www.w3.org/ns/activitystreams" { + http.Error(w, "got '"+profile+"' profile value, want 'https://www.w3.org/ns/activitystreams'", + http.StatusBadRequest) + + return + } + + w.Header().Set(common.HeaderContentType, common.MIMEApplicationActivityJSONCharsetUTF8) + encoder := json.NewEncoder(w) + + if head == "" { // NOTE(toby3d): base URL point to owner Profile. + langRef := activitypub.LangRef(lang.Lang()) + person := activitypub.PersonNew(activitypub.IRI(s.BaseURL.String())) + person.URL = person.ID + person.Name.Add(activitypub.LangRefValueNew(langRef, "Maxim Lebedev")) + person.Summary.Add(activitypub.LangRefValueNew(langRef, "Creative dude from russia")) + person.PreferredUsername.Add(activitypub.LangRefValueNew(langRef, "toby3d")) + person.Published = time.Date(2009, time.February, 0, 0, 0, 0, 0, time.UTC) + + _ = encoder.Encode(person) + + return + } + + e, err := h.entries.Do(r.Context(), lang, tail) + if err != nil { + if errors.Is(err, entry.ErrNotExist) { + http.NotFound(w, r) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + resp := activitypub.ObjectNew(activitypub.NoteType) + resp.ID = activitypub.ID(s.BaseURL.JoinPath(e.File.Path()).String()) + resp.Content.Add(activitypub.LangRefValueNew(activitypub.NilLangRef, string(e.Content))) + + _ = encoder.Encode(resp) + + return + } + + // NOTE(toby3d): search entry for requested URL and language + // code in subdir. + e, err := h.entries.Do(r.Context(), lang, tail) + if err != nil { + if errors.Is(err, entry.ErrNotExist) { + http.NotFound(w, r) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + // NOTE(toby3d): wrap founded entry into theme template and + // answer to client. + contentLanguage := make([]string, len(e.Translations)) + for i := range e.Translations { + contentLanguage[i] = e.Translations[i].Language.Code() + } + + w.Header().Set(common.HeaderContentLanguage, strings.Join(contentLanguage, ", ")) + + template, err := h.themes.Do(r.Context(), s, e) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8) + if err = template(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/entry/delivery/http/entry_http_test.go b/internal/entry/delivery/http/entry_http_test.go new file mode 100644 index 0000000..8c06c80 --- /dev/null +++ b/internal/entry/delivery/http/entry_http_test.go @@ -0,0 +1,44 @@ +package http_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + delivery "source.toby3d.me/toby3d/home/internal/entry/delivery/http" + entryucase "source.toby3d.me/toby3d/home/internal/entry/usecase" + siteucase "source.toby3d.me/toby3d/home/internal/site/usecase" + "source.toby3d.me/toby3d/home/internal/testutil" + themeucase "source.toby3d.me/toby3d/home/internal/theme/usecase" +) + +func TestHandler_ServeHTTP(t *testing.T) { + t.Parallel() + + testEntry := domain.TestEntry(t) + + for name, path := range map[string]string{ + "person": "/", + "note": testEntry.File.Path(), + } { + name, path := name, path + + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "https://example.com/"+path, nil) + req.Header.Set(common.HeaderAccept, common.MIMEApplicationLdJSONProfile) + + w := httptest.NewRecorder() + + delivery.NewHandler( + siteucase.NewStubSiteUseCase(domain.TestSite(t), nil), + entryucase.NewStubEntryUseCase(domain.TestEntry(t), nil), + themeucase.NewDummyThemeUseCase(), + ).ServeHTTP(w, req) + testutil.GoldenEqual(t, w.Result().Body) + }) + } +} diff --git a/internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/note.golden b/internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/note.golden new file mode 100755 index 0000000..7bea216 --- /dev/null +++ b/internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/note.golden @@ -0,0 +1 @@ +{"id":"https://example.com/en/sample-page","type":"Note","content":"Hello, world!"} diff --git a/internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/person.golden b/internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/person.golden new file mode 100755 index 0000000..90f87fb --- /dev/null +++ b/internal/entry/delivery/http/testdata/TestHandler_ServeHTTP/person.golden @@ -0,0 +1 @@ +{"id":"https://example.com/","type":"Person","name":"Maxim Lebedev","summary":"Creative dude from russia","url":"https://example.com/","published":"2009-01-31T00:00:00Z","preferredUsername":"toby3d"} diff --git a/internal/entry/usecase/page_ucase.go b/internal/entry/usecase/page_ucase.go index ca768e7..a8d351c 100644 --- a/internal/entry/usecase/page_ucase.go +++ b/internal/entry/usecase/page_ucase.go @@ -12,10 +12,17 @@ import ( "source.toby3d.me/toby3d/home/internal/urlutil" ) -type entryUseCase struct { - entries entry.Repository - resources resource.Repository -} +type ( + entryUseCase struct { + entries entry.Repository + resources resource.Repository + } + + stubEntryUseCase struct { + entry *domain.Entry + err error + } +) func NewEntryUseCase(entries entry.Repository, resources resource.Repository) entry.UseCase { return &entryUseCase{ @@ -78,3 +85,14 @@ func (ucase *entryUseCase) Do(ctx context.Context, lang domain.Language, p strin return nil, fmt.Errorf("cannot find page on path '%s': %w", p, entry.ErrNotExist) } + +func NewStubEntryUseCase(entry *domain.Entry, err error) entry.UseCase { + return &stubEntryUseCase{ + entry: entry, + err: err, + } +} + +func (ucase *stubEntryUseCase) Do(_ context.Context, _ domain.Language, _ string) (*domain.Entry, error) { + return ucase.entry, ucase.err +} diff --git a/internal/theme/usecase/theme_ucase.go b/internal/theme/usecase/theme_ucase.go index 2022e4c..12f9373 100644 --- a/internal/theme/usecase/theme_ucase.go +++ b/internal/theme/usecase/theme_ucase.go @@ -11,10 +11,14 @@ import ( "source.toby3d.me/toby3d/home/internal/theme" ) -type themeUseCase struct { - partials fs.FS - themes theme.Repository -} +type ( + themeUseCase struct { + partials fs.FS + themes theme.Repository + } + + dummyThemeUseCase struct{} +) func NewThemeUseCase(partials fs.FS, themes theme.Repository) theme.UseCase { return &themeUseCase{ @@ -36,3 +40,11 @@ func (ucase *themeUseCase) Do(ctx context.Context, s *domain.Site, e *domain.Ent }) }, nil } + +func NewDummyThemeUseCase() theme.UseCase { + return dummyThemeUseCase{} +} + +func (dummyThemeUseCase) Do(_ context.Context, _ *domain.Site, _ *domain.Entry) (theme.Writer, error) { + return nil, nil +}