From 39187f0074b66798f8e6f7f293924458b6db932e Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 11:47:22 +0600 Subject: [PATCH 1/8] :lock: Added TLS support --- internal/cmd/home/home.go | 8 ++------ internal/domain/config.go | 2 ++ main.go | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/internal/cmd/home/home.go b/internal/cmd/home/home.go index bde6112..e4b5019 100644 --- a/internal/cmd/home/home.go +++ b/internal/cmd/home/home.go @@ -198,12 +198,8 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) { }) chain := middleware.Chain{ middleware.LogFmt(), - middleware.Redirect(middleware.RedirectConfig{ - Serverer: serverer, - }), - middleware.Header(middleware.HeaderConfig{ - Serverer: serverer, - }), + middleware.Redirect(middleware.RedirectConfig{Serverer: serverer}), + middleware.Header(middleware.HeaderConfig{Serverer: serverer}), } return &App{server: &http.Server{ diff --git a/internal/domain/config.go b/internal/domain/config.go index a44c8ec..478a529 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -13,6 +13,8 @@ type Config struct { Host string `env:"HOST" envDefault:"0.0.0.0"` ThemeDir string `env:"THEME_DIR" envDefault:"theme"` StaticDir string `env:"STATIC_DIR" envDefault:"static"` + CertKey string `env:"CERT_KEY"` + CertFile string `env:"CERT_FILE"` Port uint16 `env:"PORT" envDefault:"3000"` } diff --git a/main.go b/main.go index 315d861..54f26eb 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,11 @@ package main import ( "context" + "crypto/tls" "errors" "flag" "log" + "net" "os" "os/signal" "path/filepath" @@ -40,6 +42,20 @@ func main() { logger.Fatalln("cannot unmarshal configuration into domain:", err) } + ln, err := net.Listen("tcp", config.AddrPort().String()) + if err != nil { + logger.Fatalln("cannot listen requested address:", err) + } + + if config.CertFile != "" && config.CertKey != "" { + cert, err := tls.LoadX509KeyPair(config.CertFile, config.CertKey) + if err != nil { + logger.Fatalln("cannot load certificate files from config:", err) + } + + ln = tls.NewListener(ln, &tls.Config{Certificates: []tls.Certificate{cert}}) + } + for _, dir := range []*string{ &config.ContentDir, &config.ThemeDir, @@ -90,7 +106,7 @@ func main() { go func() { logger.Printf("starting server on %d...", config.Port) - if err = app.Run(nil); err != nil { + if err = app.Run(ln); err != nil { logger.Fatalln("cannot run app:", err) } }() From 0a93366d9a28da785ee8f7e0f5acccfa41351a1c Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:07:03 +0600 Subject: [PATCH 2/8] :necktie: Created webfinger module with use case contracts --- internal/webfinger/usecase.go | 11 ++++++ internal/webfinger/usecase/webfinger_ucase.go | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 internal/webfinger/usecase.go create mode 100644 internal/webfinger/usecase/webfinger_ucase.go diff --git a/internal/webfinger/usecase.go b/internal/webfinger/usecase.go new file mode 100644 index 0000000..67949b8 --- /dev/null +++ b/internal/webfinger/usecase.go @@ -0,0 +1,11 @@ +package webfinger + +import ( + "context" + + "source.toby3d.me/toby3d/home/internal/domain" +) + +type UseCase interface { + Do(ctx context.Context, acct string) (*domain.Site, error) +} diff --git a/internal/webfinger/usecase/webfinger_ucase.go b/internal/webfinger/usecase/webfinger_ucase.go new file mode 100644 index 0000000..6c76910 --- /dev/null +++ b/internal/webfinger/usecase/webfinger_ucase.go @@ -0,0 +1,37 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "source.toby3d.me/toby3d/home/internal/domain" + "source.toby3d.me/toby3d/home/internal/site" + "source.toby3d.me/toby3d/home/internal/webfinger" +) + +type webFingerUseCase struct { + sites site.Repository +} + +func NewWebFingerUseCase(sites site.Repository) webfinger.UseCase { + return &webFingerUseCase{sites: sites} +} + +func (ucase *webFingerUseCase) Do(ctx context.Context, acct string) (*domain.Site, error) { + parts := strings.Split(acct, "@") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid 'acct' value format, got %s, want [user, host]", parts) + } + + site, err := ucase.sites.Get(ctx, domain.LanguageUnd) + if err != nil { + return nil, fmt.Errorf("cannot read global site config: %w", err) + } + + if !strings.EqualFold(parts[1], site.BaseURL.Hostname()) { + return nil, fmt.Errorf("requested %s user outside %s resource", parts[1], site.BaseURL.Hostname()) + } + + return site, nil +} From f0c33ae5999164edd79a774e76fcb8c307831735 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:07:39 +0600 Subject: [PATCH 3/8] :card_file_box: Created site stub repository implementation --- internal/site/repository/stub/stub_site.go | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 internal/site/repository/stub/stub_site.go diff --git a/internal/site/repository/stub/stub_site.go b/internal/site/repository/stub/stub_site.go new file mode 100644 index 0000000..5657bd1 --- /dev/null +++ b/internal/site/repository/stub/stub_site.go @@ -0,0 +1,24 @@ +package stub + +import ( + "context" + + "source.toby3d.me/toby3d/home/internal/domain" + "source.toby3d.me/toby3d/home/internal/site" +) + +type stubSiteRepository struct { + err error + site *domain.Site +} + +func NewStubSiteRepository(site *domain.Site, err error) site.Repository { + return &stubSiteRepository{ + site: site, + err: err, + } +} + +func (repo *stubSiteRepository) Get(_ context.Context, _ domain.Language) (*domain.Site, error) { + return repo.site, repo.err +} From a013c3a3fa4e5901bef0a571abccbba5a391b1ab Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:08:48 +0600 Subject: [PATCH 4/8] :necktie: Created stub site use case implementation --- internal/site/usecase/site_ucase.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/site/usecase/site_ucase.go b/internal/site/usecase/site_ucase.go index bf98b87..f42164c 100644 --- a/internal/site/usecase/site_ucase.go +++ b/internal/site/usecase/site_ucase.go @@ -9,10 +9,17 @@ import ( "source.toby3d.me/toby3d/home/internal/site" ) -type siteUseCase struct { - sites site.Repository - resources resource.Repository -} +type ( + siteUseCase struct { + sites site.Repository + resources resource.Repository + } + + stubSiteUseCase struct { + err error + site *domain.Site + } +) func NewSiteUseCase(sites site.Repository, resources resource.Repository) site.UseCase { return &siteUseCase{ @@ -50,3 +57,14 @@ func (ucase *siteUseCase) Do(ctx context.Context, lang domain.Language) (*domain return out, nil } + +func NewStubSiteUseCase(site *domain.Site, err error) site.UseCase { + return &stubSiteUseCase{ + site: site, + err: err, + } +} + +func (ucase *stubSiteUseCase) Do(_ context.Context, _ domain.Language) (*domain.Site, error) { + return ucase.site, ucase.err +} From d84563a515a05c0fc6cd93ebba8c9a193fcf08b0 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:09:31 +0600 Subject: [PATCH 5/8] :technologist: Updated test site constructor, added logo/photo resources --- internal/domain/site.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/domain/site.go b/internal/domain/site.go index aba53fc..aa19880 100644 --- a/internal/domain/site.go +++ b/internal/domain/site.go @@ -1,6 +1,7 @@ package domain import ( + "image" "net/url" "path" "path/filepath" @@ -42,11 +43,34 @@ func TestSite(tb testing.TB) *Site { DefaultLanguage: en, Language: ru, Languages: []Language{en, ru}, - BaseURL: &url.URL{Scheme: "http", Host: "127.0.0.1:3000", Path: "/"}, + BaseURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/"}, TimeZone: time.UTC, File: NewPath(filepath.Join("content", "index.en.md")), Title: "Testing", - Resources: make([]*Resource, 0), + Resources: []*Resource{ + { + modTime: time.Now().UTC().Add(-1 * time.Hour), + params: make(map[string]any), + File: NewPath("photo.png"), + mediaType: NewMediaType("image/png"), + key: "photo", + name: "photo", + resourceType: ResourceTypeImage, + title: "", + image: image.Config{}, + }, + { + modTime: time.Now().UTC().Add(-2 * time.Hour), + params: make(map[string]any), + File: NewPath("logo.jpg"), + mediaType: NewMediaType("image/jpeg"), + key: "logo", + name: "logo", + resourceType: ResourceTypeImage, + title: "", + image: image.Config{}, + }, + }, Params: map[string]any{ "server": map[string]any{ "headers": []any{map[string]any{ From 90eff9a81214b4f8bbbd9dbe8114f456e6b9cddf Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:09:56 +0600 Subject: [PATCH 6/8] :art: Added more common headers and content-type strings --- internal/common/common.go | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/internal/common/common.go b/internal/common/common.go index 1b008f7..c6e0cb3 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -1,20 +1,33 @@ package common const ( - HeaderAcceptLanguage string = "Accept-Language" - HeaderAuthorization string = "Authorization" - HeaderContentLanguage string = "Content-Language" - HeaderContentType string = "Content-Type" - HeaderCookie string = "Cookie" + HeaderAccept string = "Accept" + HeaderAcceptLanguage string = "Accept-Language" + HeaderAuthorization string = "Authorization" + HeaderContentLanguage string = "Content-Language" + HeaderContentType string = "Content-Type" + HeaderCookie string = "Cookie" + HeaderAccessControlAllowOrigin string = "Access-Control-Allow-Origin" ) const ( - MIMETextHTML string = "text/html" - MIMETextHTMLCharsetUTF8 string = MIMETextHTML + "; " + charsetUTF8 - MIMETextPlain string = "text/plain" - MIMETextPlainCharsetUTF8 string = MIMETextPlain + "; " + charsetUTF8 + MIMEApplicationActivityJSON string = "application/activity+json" + MIMEApplicationActivityJSONCharsetUTF8 string = MIMEApplicationActivityJSON + "; " + charsetUTF8 + MIMEApplicationJRDKJSON string = "application/jrd+json" + MIMEApplicationJRDKJSONCharsetUTF8 string = MIMEApplicationJRDKJSON + "; " + charsetUTF8 + MIMEApplicationJSON string = "application/json" + MIMEApplicationJSONCharsetUTF8 string = MIMEApplicationJSON + "; " + charsetUTF8 + MIMEApplicationLdJSON string = "application/ld+json" + MIMEApplicationLdJSONProfile string = MIMEApplicationLdJSON + "; " + profile + MIMETextHTML string = "text/html" + MIMETextHTMLCharsetUTF8 string = MIMETextHTML + "; " + charsetUTF8 + MIMETextPlain string = "text/plain" + MIMETextPlainCharsetUTF8 string = MIMETextPlain + "; " + charsetUTF8 ) const Und string = "und" -const charsetUTF8 string = "charset=UTF-8" +const ( + charsetUTF8 string = "charset=UTF-8" + profile string = `profile="https://www.w3.org/ns/activitystreams"` +) From d4a7d8061d9f0b99a1d94e7f32d86cf5d45fc235 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:14:33 +0600 Subject: [PATCH 7/8] :mag: Created WebFinger HTTP delivery --- .../testdata/TestHandler_ServeHTTP.golden | 1 + .../webfinger/delivery/http/webfinger_http.go | 167 ++++++++++++++++++ .../delivery/http/webfinger_http_test.go | 59 +++++++ 3 files changed, 227 insertions(+) create mode 100755 internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden create mode 100644 internal/webfinger/delivery/http/webfinger_http.go create mode 100644 internal/webfinger/delivery/http/webfinger_http_test.go diff --git a/internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden b/internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden new file mode 100755 index 0000000..368f2aa --- /dev/null +++ b/internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden @@ -0,0 +1 @@ +{"subject":"acct:user@example.com","links":[{"rel":"self","type":"application/activity+json","href":"https://example.com/"},{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://example.com/"},{"rel":"http://webfinger.net/rel/avatar","type":"image/jpeg","href":"https://example.com/logo.jpg"}]} diff --git a/internal/webfinger/delivery/http/webfinger_http.go b/internal/webfinger/delivery/http/webfinger_http.go new file mode 100644 index 0000000..7a82e2c --- /dev/null +++ b/internal/webfinger/delivery/http/webfinger_http.go @@ -0,0 +1,167 @@ +package http + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + "source.toby3d.me/toby3d/home/internal/webfinger" +) + +type ( + Handler struct { + webFingers webfinger.UseCase + } + + Request struct { + Resource *Resource + Rel string // optional + } + + Response struct { + Properties map[string]string `json:"properties,omitempty"` + Subject string `json:"subject"` + Aliases []string `json:"aliases,omitempty"` + Links []Link `json:"links,omitempty"` + } + + Link struct { + Titles map[string]string `json:"titles,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + Rel string `json:"rel"` + Type string `json:"type,omitempty"` + Href string `json:"href,omitempty"` + } + + Resource struct { + User string + Host string + IsEmail bool + } +) + +var ErrResource error = errors.New("'resource' URL must be absolute, or an email address") + +func NewHandler(webFingers webfinger.UseCase) *Handler { + return &Handler{webFingers: webFingers} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set(common.HeaderAccessControlAllowOrigin, "*") + + if r.TLS == nil { + http.Error(w, "webfinger usage is https only", http.StatusBadRequest) + + return + } + + if r.Method != "" && r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + + req := new(Request) + if err := req.bind(r.URL.Query()); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + site, err := h.webFingers.Do(r.Context(), strings.TrimPrefix(req.Resource.String(), "acct:")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + resp := NewResponse(req.Resource.String()) + // TODO(toby3d): fill resp.Aliases with profile syndications links. + // NOTE(toby3d): rel="self" link is a Mastodon requirement for + // ActivityPub support. + resp.Links = append(resp.Links, Link{ + Titles: make(map[string]string), + Properties: make(map[string]string), + Rel: "self", + Type: common.MIMEApplicationActivityJSON, + Href: site.BaseURL.String(), + }, Link{ + Titles: make(map[string]string), + Properties: make(map[string]string), + Rel: "http://webfinger.net/rel/profile-page", + Type: "text/html", + Href: site.BaseURL.String(), + }) + + // TODO(toby3d): explicitly control which resource is to be used. + if photo := site.Resources.GetType(domain.ResourceTypeImage).GetMatch("logo"); photo != nil { + resp.Links = append(resp.Links, Link{ + Titles: make(map[string]string), + Properties: make(map[string]string), + Rel: "http://webfinger.net/rel/avatar", + Type: photo.MediaType().String(), + Href: site.BaseURL.JoinPath(photo.File.Path()).String(), + }) + } + + w.Header().Set(common.HeaderContentType, common.MIMEApplicationJRDKJSONCharsetUTF8) + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (r *Request) bind(req url.Values) error { + if !req.Has("resource") { + return ErrResource + } + + var err error + if r.Resource, err = ParseResource(req.Get("resource")); err != nil { + return fmt.Errorf("cannot parse 'resource' query as URI: %w", err) + } + + return nil +} + +func NewResponse(subject string) *Response { + return &Response{ + Properties: make(map[string]string), + Subject: subject, + Aliases: make([]string, 0), + Links: make([]Link, 0), + } +} + +func ParseResource(raw string) (*Resource, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("cannot parse 'resource' query value as URL or email address: %w", err) + } + + var parts []string + switch { + case u.Path != "": + parts = strings.Split(u.Path, "@") + case u.Opaque != "": + parts = strings.Split(u.Opaque, "@") + } + + return &Resource{ + IsEmail: u.Scheme != "acct", + User: parts[0], + Host: parts[len(parts)-1], + }, nil +} + +func (r Resource) String() string { + if !r.IsEmail { + return "acct:" + r.User + "@" + r.Host + } + + return r.User + "@" + r.Host +} diff --git a/internal/webfinger/delivery/http/webfinger_http_test.go b/internal/webfinger/delivery/http/webfinger_http_test.go new file mode 100644 index 0000000..899d202 --- /dev/null +++ b/internal/webfinger/delivery/http/webfinger_http_test.go @@ -0,0 +1,59 @@ +package http_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + sitestubrepo "source.toby3d.me/toby3d/home/internal/site/repository/stub" + "source.toby3d.me/toby3d/home/internal/testutil" + delivery "source.toby3d.me/toby3d/home/internal/webfinger/delivery/http" + webfingerucase "source.toby3d.me/toby3d/home/internal/webfinger/usecase" +) + +func TestHandler_ServeHTTP(t *testing.T) { + t.Parallel() + + testSite := domain.TestSite(t) + u := testSite.BaseURL.JoinPath(".well-known", "webfinger") + u.RawQuery = "resource=acct:user@" + testSite.BaseURL.Hostname() + + req := httptest.NewRequest(http.MethodGet, u.String(), nil) + req.Header.Set(common.HeaderAccept, common.MIMEApplicationJRDKJSON+", "+common.MIMEApplicationJSON) + w := httptest.NewRecorder() + + delivery.NewHandler(webfingerucase.NewWebFingerUseCase(sitestubrepo.NewStubSiteRepository(testSite, nil))). + ServeHTTP(w, req) + testutil.GoldenEqual(t, w.Result().Body) +} + +func TestParseResource(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + input string + expect delivery.Resource + }{ + "profile": {"acct:user@example.com", delivery.Resource{"user", "example.com", false}}, + "email": {"user@example.com", delivery.Resource{"user", "example.com", true}}, + } { + name, tc := name, tc + + t.Run(name, func(t *testing.T) { + t.Parallel() + + result, err := delivery.ParseResource(tc.input) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expect, *result); diff != "" { + t.Error(diff) + } + }) + } +} From c359d057619b7605fa1a2c5ac6ed0b7b660ca238 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:16:45 +0600 Subject: [PATCH 8/8] :building_construction: Connect and use webfinger module --- internal/cmd/home/home.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/cmd/home/home.go b/internal/cmd/home/home.go index e4b5019..1c2eb13 100644 --- a/internal/cmd/home/home.go +++ b/internal/cmd/home/home.go @@ -32,6 +32,8 @@ import ( themefsrepo "source.toby3d.me/toby3d/home/internal/theme/repository/fs" themeucase "source.toby3d.me/toby3d/home/internal/theme/usecase" "source.toby3d.me/toby3d/home/internal/urlutil" + webfingerhttpdelivery "source.toby3d.me/toby3d/home/internal/webfinger/delivery/http" + webfingerucase "source.toby3d.me/toby3d/home/internal/webfinger/usecase" ) type App struct { @@ -58,7 +60,21 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) { entries := pagefsrepo.NewFileSystemPageRepository(contentDir) entrier := pageucase.NewEntryUseCase(entries, resources) serverer := servercase.NewServerUseCase(sites) + webfingerer := webfingerucase.NewWebFingerUseCase(sites) + webfingerHandler := webfingerhttpdelivery.NewHandler(webfingerer) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + head, tail := urlutil.ShiftPath(r.URL.Path) + + switch head { + case ".well-known": + switch { + case strings.HasPrefix(tail, "/webfinger"): + webfingerHandler.ServeHTTP(w, r) + + return + } + } + // NOTE(toby3d): any static file is public and unprotected by // design, so it's safe to search it first before deep down to // any page or it's resource which might be protected by @@ -83,8 +99,7 @@ func NewApp(logger *log.Logger, config *domain.Config) (*App, error) { if s.IsMultiLingual() { // NOTE(toby3d): $HOME_CONTENT_DIR contains at least two // index.md with different language codes. - head, tail := urlutil.ShiftPath(r.URL.Path) - if head == "" { + if head, tail = urlutil.ShiftPath(r.URL.Path); head == "" { // NOTE(toby3d): client request just '/', try to // understand which language subdirectory is // need to redirect.