diff --git a/README.md b/README.md index 89041cf..ac95f6a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Finger -Webfinger server written in Go. +Webfinger handler / standalone server written in Go. ## Features - 🍰 Easy YAML configuration @@ -8,7 +8,58 @@ Webfinger server written in Go. - ⚡️ Sub millisecond responses at 10,000 request per second - 🐳 10MB Docker image -## Install +## In your existing server + +To use Finger in your existing server, download the package as a dependency: + +```bash +go get git.maronato.dev/maronato/finger@latest +``` + +Then, use it as a regular `http.Handler`: + +```go +package main + +import ( + "log" + "net/http" + + "git.maronato.dev/maronato/finger/handler" + "git.maronato.dev/maronato/finger/webfingers" +) + +func main() { + // Create the webfingers map that will be served by the handler + fingers, err := webfingers.NewWebFingers( + // Pass a map of your resources (Subject key followed by it's properties and links) + // the syntax is the same as the fingers.yml file (see below) + webfingers.Resources{ + "user@example.com": { + "name": "Example User", + }, + }, + // Optionally, pass a map of URN aliases (see urns.yml for more) + // If nil is provided, no aliases will be used + webfingers.URNAliases{ + "name": "http://schema.org/name", + }, + ) + if err != nil { + log.Fatal(err) + } + + mux := http.NewServeMux() + // Then use the handler as a regular http.Handler + mux.Handle("/.well-known/webfinger", handler.WebfingerHandler(fingers)) + + log.Fatal(http.ListenAndServe("localhost:8080", mux)) +} +``` + +## As a standalone server + +If you don't have a server, Finger can also serve itself. You can install it via `go install` or use the Docker image. Via `go install`: diff --git a/cmd/serve.go b/cmd/serve.go index 544376a..39ae56d 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -6,9 +6,9 @@ import ( "os" "git.maronato.dev/maronato/finger/internal/config" + "git.maronato.dev/maronato/finger/internal/fingerreader" "git.maronato.dev/maronato/finger/internal/log" "git.maronato.dev/maronato/finger/internal/server" - "git.maronato.dev/maronato/finger/internal/webfinger" "github.com/peterbourgon/ff/v4" ) @@ -25,21 +25,21 @@ func newServerCmd(cfg *config.Config) *ff.Command { ctx = log.WithLogger(ctx, l) // Read the webfinger files - r := webfinger.NewFingerReader() + r := fingerreader.NewFingerReader() err := r.ReadFiles(cfg) if err != nil { return fmt.Errorf("error reading finger files: %w", err) } - webfingers, err := r.ReadFingerFile(ctx) + fingers, err := r.ReadFingerFile(ctx) if err != nil { return fmt.Errorf("error parsing finger files: %w", err) } - l.Info(fmt.Sprintf("Loaded %d webfingers", len(webfingers))) + l.Info(fmt.Sprintf("Loaded %d webfingers", len(fingers))) // Start the server - if err := server.StartServer(ctx, cfg, webfingers); err != nil { + if err := server.StartServer(ctx, cfg, fingers); err != nil { return fmt.Errorf("error running server: %w", err) } diff --git a/internal/server/webfinger.go b/handler/handler.go similarity index 61% rename from internal/server/webfinger.go rename to handler/handler.go index 56e7cf8..90f048c 100644 --- a/internal/server/webfinger.go +++ b/handler/handler.go @@ -1,22 +1,16 @@ -package server +package handler import ( "encoding/json" "net/http" - "git.maronato.dev/maronato/finger/internal/config" - "git.maronato.dev/maronato/finger/internal/log" - "git.maronato.dev/maronato/finger/internal/webfinger" + "git.maronato.dev/maronato/finger/webfingers" ) -func WebfingerHandler(_ *config.Config, webfingers webfinger.WebFingers) http.Handler { +func WebfingerHandler(fingers webfingers.WebFingers) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - l := log.FromContext(ctx) - // Only handle GET requests if r.Method != http.MethodGet { - l.Debug("Method not allowed") http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -28,16 +22,14 @@ func WebfingerHandler(_ *config.Config, webfingers webfinger.WebFingers) http.Ha // Get the resource resource := q.Get("resource") if resource == "" { - l.Debug("No resource provided") http.Error(w, "No resource provided", http.StatusBadRequest) return } // Get and validate resource - finger, ok := webfingers[resource] + finger, ok := fingers[resource] if !ok { - l.Debug("Resource not found") http.Error(w, "Resource not found", http.StatusNotFound) return @@ -48,12 +40,9 @@ func WebfingerHandler(_ *config.Config, webfingers webfinger.WebFingers) http.Ha // Write the response if err := json.NewEncoder(w).Encode(finger); err != nil { - l.Debug("Error encoding json") http.Error(w, "Error encoding json", http.StatusInternalServerError) return } - - l.Debug("Webfinger request successful") }) } diff --git a/internal/server/webfinger_test.go b/handler/handler_test.go similarity index 79% rename from internal/server/webfinger_test.go rename to handler/handler_test.go index c887a18..1fafe4b 100644 --- a/internal/server/webfinger_test.go +++ b/handler/handler_test.go @@ -1,4 +1,4 @@ -package server_test +package handler_test import ( "context" @@ -10,19 +10,19 @@ import ( "strings" "testing" + "git.maronato.dev/maronato/finger/handler" "git.maronato.dev/maronato/finger/internal/config" "git.maronato.dev/maronato/finger/internal/log" - "git.maronato.dev/maronato/finger/internal/server" - "git.maronato.dev/maronato/finger/internal/webfinger" + "git.maronato.dev/maronato/finger/webfingers" ) func TestWebfingerHandler(t *testing.T) { t.Parallel() - webfingers := webfinger.WebFingers{ + fingers := webfingers.WebFingers{ "acct:user@example.com": { Subject: "acct:user@example.com", - Links: []webfinger.Link{ + Links: []webfingers.Link{ { Rel: "http://webfinger.net/rel/profile-page", Href: "https://example.com/user", @@ -104,7 +104,7 @@ func TestWebfingerHandler(t *testing.T) { w := httptest.NewRecorder() // Create a new handler - h := server.WebfingerHandler(cfg, webfingers) + h := handler.WebfingerHandler(fingers) // Serve the request h.ServeHTTP(w, r) @@ -121,8 +121,8 @@ func TestWebfingerHandler(t *testing.T) { t.Errorf("expected content type %s, got %s", "application/jrd+json", w.Header().Get("Content-Type")) } - fingerWant := webfingers[tc.resource] - fingerGot := &webfinger.WebFinger{} + fingerWant := fingers[tc.resource] + fingerGot := &webfingers.WebFinger{} // Decode the response body if err := json.NewDecoder(w.Body).Decode(fingerGot); err != nil { @@ -147,3 +147,30 @@ func TestWebfingerHandler(t *testing.T) { }) } } + +func BenchmarkWebfingerHandler(b *testing.B) { + fingers, err := webfingers.NewWebFingers( + webfingers.Resources{ + "user@example.com": { + "prop1": "value1", + }, + }, + nil, + ) + if err != nil { + b.Fatal(err) + } + + h := handler.WebfingerHandler(fingers) + r := httptest.NewRequest(http.MethodGet, "/.well-known/webfinger?resource=acct:user@example.com", http.NoBody) + + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + b.Errorf("expected status code %d, got %d", http.StatusOK, w.Code) + } + } +} diff --git a/internal/fingerreader/fingerreader.go b/internal/fingerreader/fingerreader.go new file mode 100644 index 0000000..bb242cf --- /dev/null +++ b/internal/fingerreader/fingerreader.go @@ -0,0 +1,89 @@ +package fingerreader + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "os" + + "git.maronato.dev/maronato/finger/internal/config" + "git.maronato.dev/maronato/finger/internal/log" + "git.maronato.dev/maronato/finger/webfingers" + "gopkg.in/yaml.v3" +) + +type FingerReader struct { + URNSFile []byte + FingersFile []byte +} + +func NewFingerReader() *FingerReader { + return &FingerReader{} +} + +func (f *FingerReader) ReadFiles(cfg *config.Config) error { + // Read URNs file + file, err := os.ReadFile(cfg.URNPath) + if err != nil { + // If the file does not exist and the path is the default, set the URNs to an empty map + if os.IsNotExist(err) && cfg.URNPath == config.DefaultURNPath { + f.URNSFile = []byte("") + } else { + return fmt.Errorf("error opening URNs file: %w", err) + } + } + + f.URNSFile = file + + // Read fingers file + file, err = os.ReadFile(cfg.FingerPath) + if err != nil { + // If the file does not exist and the path is the default, set the fingers to an empty map + if os.IsNotExist(err) && cfg.FingerPath == config.DefaultFingerPath { + f.FingersFile = []byte("") + } else { + return fmt.Errorf("error opening fingers file: %w", err) + } + } + + f.FingersFile = file + + return nil +} + +func (f *FingerReader) ReadFingerFile(ctx context.Context) (webfingers.WebFingers, error) { + l := log.FromContext(ctx) + + urnAliases := make(webfingers.URNAliases) + resources := make(webfingers.Resources) + + // Parse the URNs file + if err := yaml.Unmarshal(f.URNSFile, &urnAliases); err != nil { + return nil, fmt.Errorf("error unmarshalling URNs file: %w", err) + } + + // The URNs file must be a map of strings to valid URLs + for _, v := range urnAliases { + if _, err := url.ParseRequestURI(v); err != nil { + return nil, fmt.Errorf("error parsing URN URIs: %w", err) + } + } + + l.Debug("URNs file parsed successfully", slog.Int("number", len(urnAliases)), slog.Any("data", urnAliases)) + + // Parse the fingers file + if err := yaml.Unmarshal(f.FingersFile, &resources); err != nil { + return nil, fmt.Errorf("error unmarshalling fingers file: %w", err) + } + + l.Debug("Fingers file parsed successfully", slog.Int("number", len(resources)), slog.Any("data", resources)) + + // Parse raw data + fingers, err := webfingers.NewWebFingers(resources, urnAliases) + if err != nil { + return nil, fmt.Errorf("error parsing raw fingers: %w", err) + } + + return fingers, nil +} diff --git a/internal/fingerreader/fingerreader_test.go b/internal/fingerreader/fingerreader_test.go new file mode 100644 index 0000000..72efaad --- /dev/null +++ b/internal/fingerreader/fingerreader_test.go @@ -0,0 +1,242 @@ +package fingerreader_test + +import ( + "context" + "os" + "reflect" + "strings" + "testing" + + "git.maronato.dev/maronato/finger/internal/config" + "git.maronato.dev/maronato/finger/internal/fingerreader" + "git.maronato.dev/maronato/finger/internal/log" + "git.maronato.dev/maronato/finger/webfingers" +) + +func newTempFile(t *testing.T, content string) (name string, remove func()) { + t.Helper() + + f, err := os.CreateTemp("", "finger-test") + if err != nil { + t.Fatalf("error creating temp file: %v", err) + } + + _, err = f.WriteString(content) + if err != nil { + t.Fatalf("error writing to temp file: %v", err) + } + + return f.Name(), func() { + err = os.Remove(f.Name()) + if err != nil { + t.Fatalf("error removing temp file: %v", err) + } + } +} + +func TestNewFingerReader(t *testing.T) { + t.Parallel() + + f := fingerreader.NewFingerReader() + + if f == nil { + t.Errorf("NewFingerReader() = %v, want: %v", f, nil) + } +} + +func TestFingerReader_ReadFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + urnsContent string + fingersContent string + useURNFile bool + useFingerFile bool + wantErr bool + }{ + { + name: "reads files", + urnsContent: "name: https://schema/name\nprofile: https://schema/profile", + fingersContent: "user@example.com:\n name: John Doe", + useURNFile: true, + useFingerFile: true, + wantErr: false, + }, + { + name: "errors on missing URNs file", + urnsContent: "invalid", + fingersContent: "user@example.com:\n name: John Doe", + useURNFile: false, + useFingerFile: true, + wantErr: true, + }, + { + name: "errors on missing fingers file", + urnsContent: "name: https://schema/name\nprofile: https://schema/profile", + fingersContent: "invalid", + useFingerFile: false, + useURNFile: true, + wantErr: true, + }, + } + + for _, tt := range tests { + tc := tt + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := config.NewConfig() + + urnsFileName, urnsCleanup := newTempFile(t, tc.urnsContent) + defer urnsCleanup() + + fingersFileName, fingersCleanup := newTempFile(t, tc.fingersContent) + defer fingersCleanup() + + if !tc.useURNFile { + cfg.URNPath = "invalid" + } else { + cfg.URNPath = urnsFileName + } + + if !tc.useFingerFile { + cfg.FingerPath = "invalid" + } else { + cfg.FingerPath = fingersFileName + } + + f := fingerreader.NewFingerReader() + + err := f.ReadFiles(cfg) + if err != nil { + if !tc.wantErr { + t.Errorf("ReadFiles() error = %v", err) + } + + return + } else if tc.wantErr { + t.Errorf("ReadFiles() error = %v, wantErr %v", err, tc.wantErr) + } + + if !reflect.DeepEqual(f.URNSFile, []byte(tc.urnsContent)) { + t.Errorf("ReadFiles() URNsFile = %v, want: %v", f.URNSFile, tc.urnsContent) + } + + if !reflect.DeepEqual(f.FingersFile, []byte(tc.fingersContent)) { + t.Errorf("ReadFiles() FingersFile = %v, want: %v", f.FingersFile, tc.fingersContent) + } + }) + } +} + +func TestReadFingerFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + urnsContent string + fingersContent string + wantURN webfingers.URNAliases + wantFinger webfingers.Resources + returns webfingers.WebFingers + wantErr bool + }{ + { + name: "reads files", + urnsContent: "name: https://schema/name\nprofile: https://schema/profile", + fingersContent: "user@example.com:\n name: John Doe", + wantURN: webfingers.URNAliases{ + "name": "https://schema/name", + "profile": "https://schema/profile", + }, + wantFinger: webfingers.Resources{ + "user@example.com": { + "name": "John Doe", + }, + }, + returns: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Properties: map[string]string{ + "https://schema/name": "John Doe", + }, + }, + }, + wantErr: false, + }, + { + name: "uses custom URNs", + urnsContent: "favorite_food: https://schema/favorite_food", + fingersContent: "user@example.com:\n favorite_food: Apple", + wantURN: webfingers.URNAliases{ + "favorite_food": "https://schema/favorite_food", + }, + wantFinger: webfingers.Resources{ + "user@example.com": { + "https://schema/favorite_food": "Apple", + }, + }, + wantErr: false, + }, + { + name: "errors on invalid URNs file", + urnsContent: "invalid", + fingersContent: "user@example.com:\n name: John Doe", + wantErr: true, + }, + { + name: "errors on invalid fingers file", + urnsContent: "name: https://schema/name\nprofile: https://schema/profile", + fingersContent: "invalid", + wantErr: true, + }, + { + name: "errors on invalid URNs values", + urnsContent: "name: invalid", + fingersContent: "user@example.com:\n name: John Doe", + wantErr: true, + }, + { + name: "errors on invalid fingers values", + urnsContent: "name: https://schema/name\nprofile: https://schema/profile", + fingersContent: "invalid:\n name: John Doe", + wantErr: true, + }, + } + + for _, tt := range tests { + tc := tt + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cfg := config.NewConfig() + l := log.NewLogger(&strings.Builder{}, cfg) + + ctx = log.WithLogger(ctx, l) + + f := fingerreader.NewFingerReader() + + f.FingersFile = []byte(tc.fingersContent) + f.URNSFile = []byte(tc.urnsContent) + + got, err := f.ReadFingerFile(ctx) + if err != nil { + if !tc.wantErr { + t.Errorf("ReadFingerFile() error = %v", err) + } + + return + } else if tc.wantErr { + t.Errorf("ReadFingerFile() error = %v, wantErr %v", err, tc.wantErr) + } + + if tc.returns != nil && !reflect.DeepEqual(got, tc.returns) { + t.Errorf("ReadFingerFile() got = %v, want: %v", got, tc.returns) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5503632..99ebd20 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,10 +8,11 @@ import ( "net/http" "time" + "git.maronato.dev/maronato/finger/handler" "git.maronato.dev/maronato/finger/internal/config" "git.maronato.dev/maronato/finger/internal/log" "git.maronato.dev/maronato/finger/internal/middleware" - "git.maronato.dev/maronato/finger/internal/webfinger" + "git.maronato.dev/maronato/finger/webfingers" "golang.org/x/sync/errgroup" ) @@ -33,20 +34,17 @@ const ( RequestTimeout = 7 * 24 * time.Hour ) -func StartServer(ctx context.Context, cfg *config.Config, webfingers webfinger.WebFingers) error { +func StartServer(ctx context.Context, cfg *config.Config, fingers webfingers.WebFingers) error { l := log.FromContext(ctx) // Create the server mux mux := http.NewServeMux() - mux.Handle("/.well-known/webfinger", WebfingerHandler(cfg, webfingers)) + mux.Handle("/.well-known/webfinger", handler.WebfingerHandler(fingers)) mux.Handle("/healthz", HealthCheckHandler(cfg)) // Create a new server srv := &http.Server{ Addr: cfg.GetAddr(), - BaseContext: func(_ net.Listener) context.Context { - return ctx - }, Handler: middleware.RequestLogger( middleware.Recoverer( http.TimeoutHandler(mux, RequestTimeout, "request timed out"), diff --git a/internal/server/server_test.go b/internal/server/server_test.go index db4b5bf..788c448 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -14,7 +14,7 @@ import ( "git.maronato.dev/maronato/finger/internal/config" "git.maronato.dev/maronato/finger/internal/log" "git.maronato.dev/maronato/finger/internal/server" - "git.maronato.dev/maronato/finger/internal/webfinger" + "git.maronato.dev/maronato/finger/webfingers" ) func getPortGenerator() func() int { @@ -96,8 +96,8 @@ func TestStartServer(t *testing.T) { cfg.Port = fmt.Sprint(portGenerator()) resource := "acct:user@example.com" - webfingers := webfinger.WebFingers{ - resource: &webfinger.WebFinger{ + fingers := webfingers.WebFingers{ + resource: &webfingers.WebFinger{ Subject: resource, Properties: map[string]string{ "http://webfinger.net/rel/name": "John Doe", @@ -107,7 +107,7 @@ func TestStartServer(t *testing.T) { go func() { // Start the server - err := server.StartServer(ctx, cfg, webfingers) + err := server.StartServer(ctx, cfg, fingers) if err != nil { t.Errorf("expected no error, got %v", err) } @@ -140,7 +140,7 @@ func TestStartServer(t *testing.T) { } // Check the response body - fingerGot := &webfinger.WebFinger{} + fingerGot := &webfingers.WebFinger{} // Decode the response body if err := json.NewDecoder(resp.Body).Decode(fingerGot); err != nil { @@ -148,7 +148,7 @@ func TestStartServer(t *testing.T) { } // Check the response body - fingerWant := webfingers[resource] + fingerWant := fingers[resource] if !reflect.DeepEqual(fingerGot, fingerWant) { t.Errorf("expected %v, got %v", fingerWant, fingerGot) diff --git a/internal/webfinger/webfinger.go b/internal/webfinger/webfinger.go deleted file mode 100644 index 8e69717..0000000 --- a/internal/webfinger/webfinger.go +++ /dev/null @@ -1,170 +0,0 @@ -package webfinger - -import ( - "context" - "fmt" - "log/slog" - "net/mail" - "net/url" - "os" - - "git.maronato.dev/maronato/finger/internal/config" - "git.maronato.dev/maronato/finger/internal/log" - "gopkg.in/yaml.v3" -) - -type Link struct { - Rel string `json:"rel"` - Href string `json:"href,omitempty"` -} - -type WebFinger struct { - Subject string `json:"subject"` - Links []Link `json:"links,omitempty"` - Properties map[string]string `json:"properties,omitempty"` -} - -type WebFingers map[string]*WebFinger - -type ( - URNMap = map[string]string - RawFingersMap = map[string]map[string]string -) - -type FingerReader struct { - URNSFile []byte - FingersFile []byte -} - -func NewFingerReader() *FingerReader { - return &FingerReader{} -} - -func (f *FingerReader) ReadFiles(cfg *config.Config) error { - // Read URNs file - file, err := os.ReadFile(cfg.URNPath) - if err != nil { - // If the file does not exist and the path is the default, set the URNs to an empty map - if os.IsNotExist(err) && cfg.URNPath == config.DefaultURNPath { - f.URNSFile = []byte("") - } else { - return fmt.Errorf("error opening URNs file: %w", err) - } - } - - f.URNSFile = file - - // Read fingers file - file, err = os.ReadFile(cfg.FingerPath) - if err != nil { - // If the file does not exist and the path is the default, set the fingers to an empty map - if os.IsNotExist(err) && cfg.FingerPath == config.DefaultFingerPath { - f.FingersFile = []byte("") - } else { - return fmt.Errorf("error opening fingers file: %w", err) - } - } - - f.FingersFile = file - - return nil -} - -func (f *FingerReader) ParseFingers(ctx context.Context, urns URNMap, rawFingers RawFingersMap) (WebFingers, error) { - l := log.FromContext(ctx) - - webfingers := make(WebFingers) - - // Parse the webfinger file - for k, v := range rawFingers { - resource := k - - // Remove leading acct: if present - if len(k) > 5 && resource[:5] == "acct:" { - resource = resource[5:] - } - - // The key must be a URL or email address - if _, err := mail.ParseAddress(resource); err != nil { - if _, err := url.ParseRequestURI(resource); err != nil { - return nil, fmt.Errorf("error parsing webfinger key (%s): %w", k, err) - } - } else { - // Add acct: back to the key if it is an email address - resource = fmt.Sprintf("acct:%s", resource) - } - - // Create a new webfinger - webfinger := &WebFinger{ - Subject: resource, - } - - // Parse the fields - for field, value := range v { - fieldUrn := field - - // If the key is present in the URNs file, use the value - if _, ok := urns[field]; ok { - fieldUrn = urns[field] - } - - // If the value is a valid URI, add it to the links - if _, err := url.ParseRequestURI(value); err == nil { - webfinger.Links = append(webfinger.Links, Link{ - Rel: fieldUrn, - Href: value, - }) - } else { - // Otherwise add it to the properties - if webfinger.Properties == nil { - webfinger.Properties = make(map[string]string) - } - - webfinger.Properties[fieldUrn] = value - } - } - - // Add the webfinger to the map - webfingers[resource] = webfinger - } - - l.Debug("Webfinger map built successfully", slog.Int("number", len(webfingers)), slog.Any("data", webfingers)) - - return webfingers, nil -} - -func (f *FingerReader) ReadFingerFile(ctx context.Context) (WebFingers, error) { - l := log.FromContext(ctx) - - urnMap := make(URNMap) - fingerData := make(RawFingersMap) - - // Parse the URNs file - if err := yaml.Unmarshal(f.URNSFile, &urnMap); err != nil { - return nil, fmt.Errorf("error unmarshalling URNs file: %w", err) - } - - // The URNs file must be a map of strings to valid URLs - for _, v := range urnMap { - if _, err := url.ParseRequestURI(v); err != nil { - return nil, fmt.Errorf("error parsing URN URIs: %w", err) - } - } - - l.Debug("URNs file parsed successfully", slog.Int("number", len(urnMap)), slog.Any("data", urnMap)) - - // Parse the fingers file - if err := yaml.Unmarshal(f.FingersFile, &fingerData); err != nil { - return nil, fmt.Errorf("error unmarshalling fingers file: %w", err) - } - - l.Debug("Fingers file parsed successfully", slog.Int("number", len(fingerData)), slog.Any("data", fingerData)) - - // Parse raw data - webfingers, err := f.ParseFingers(ctx, urnMap, fingerData) - if err != nil { - return nil, fmt.Errorf("error parsing raw fingers: %w", err) - } - - return webfingers, nil -} diff --git a/internal/webfinger/webfinger_test.go b/internal/webfinger/webfinger_test.go deleted file mode 100644 index e61b76b..0000000 --- a/internal/webfinger/webfinger_test.go +++ /dev/null @@ -1,444 +0,0 @@ -package webfinger_test - -import ( - "context" - "encoding/json" - "os" - "reflect" - "sort" - "strings" - "testing" - - "git.maronato.dev/maronato/finger/internal/config" - "git.maronato.dev/maronato/finger/internal/log" - "git.maronato.dev/maronato/finger/internal/webfinger" -) - -func newTempFile(t *testing.T, content string) (name string, remove func()) { - t.Helper() - - f, err := os.CreateTemp("", "finger-test") - if err != nil { - t.Fatalf("error creating temp file: %v", err) - } - - _, err = f.WriteString(content) - if err != nil { - t.Fatalf("error writing to temp file: %v", err) - } - - return f.Name(), func() { - err = os.Remove(f.Name()) - if err != nil { - t.Fatalf("error removing temp file: %v", err) - } - } -} - -func TestNewFingerReader(t *testing.T) { - t.Parallel() - - f := webfinger.NewFingerReader() - - if f == nil { - t.Errorf("NewFingerReader() = %v, want: %v", f, nil) - } -} - -func TestFingerReader_ReadFiles(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - urnsContent string - fingersContent string - useURNFile bool - useFingerFile bool - wantErr bool - }{ - { - name: "reads files", - urnsContent: "name: https://schema/name\nprofile: https://schema/profile", - fingersContent: "user@example.com:\n name: John Doe", - useURNFile: true, - useFingerFile: true, - wantErr: false, - }, - { - name: "errors on missing URNs file", - urnsContent: "invalid", - fingersContent: "user@example.com:\n name: John Doe", - useURNFile: false, - useFingerFile: true, - wantErr: true, - }, - { - name: "errors on missing fingers file", - urnsContent: "name: https://schema/name\nprofile: https://schema/profile", - fingersContent: "invalid", - useFingerFile: false, - useURNFile: true, - wantErr: true, - }, - } - - for _, tt := range tests { - tc := tt - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - cfg := config.NewConfig() - - urnsFileName, urnsCleanup := newTempFile(t, tc.urnsContent) - defer urnsCleanup() - - fingersFileName, fingersCleanup := newTempFile(t, tc.fingersContent) - defer fingersCleanup() - - if !tc.useURNFile { - cfg.URNPath = "invalid" - } else { - cfg.URNPath = urnsFileName - } - - if !tc.useFingerFile { - cfg.FingerPath = "invalid" - } else { - cfg.FingerPath = fingersFileName - } - - f := webfinger.NewFingerReader() - - err := f.ReadFiles(cfg) - if err != nil { - if !tc.wantErr { - t.Errorf("ReadFiles() error = %v", err) - } - - return - } else if tc.wantErr { - t.Errorf("ReadFiles() error = %v, wantErr %v", err, tc.wantErr) - } - - if !reflect.DeepEqual(f.URNSFile, []byte(tc.urnsContent)) { - t.Errorf("ReadFiles() URNsFile = %v, want: %v", f.URNSFile, tc.urnsContent) - } - - if !reflect.DeepEqual(f.FingersFile, []byte(tc.fingersContent)) { - t.Errorf("ReadFiles() FingersFile = %v, want: %v", f.FingersFile, tc.fingersContent) - } - }) - } -} - -func TestParseFingers(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - rawFingers webfinger.RawFingersMap - want webfinger.WebFingers - wantErr bool - }{ - { - name: "parses links", - rawFingers: webfinger.RawFingersMap{ - "user@example.com": { - "profile": "https://example.com/profile", - "invalidalias": "https://example.com/invalidalias", - "https://something": "https://somethingelse", - }, - }, - want: webfinger.WebFingers{ - "acct:user@example.com": { - Subject: "acct:user@example.com", - Links: []webfinger.Link{ - { - Rel: "https://schema/profile", - Href: "https://example.com/profile", - }, - { - Rel: "invalidalias", - Href: "https://example.com/invalidalias", - }, - { - Rel: "https://something", - Href: "https://somethingelse", - }, - }, - }, - }, - wantErr: false, - }, - { - name: "parses properties", - rawFingers: webfinger.RawFingersMap{ - "user@example.com": { - "name": "John Doe", - "invalidalias": "value1", - "https://mylink": "value2", - }, - }, - want: webfinger.WebFingers{ - "acct:user@example.com": { - Subject: "acct:user@example.com", - Properties: map[string]string{ - "https://schema/name": "John Doe", - "invalidalias": "value1", - "https://mylink": "value2", - }, - }, - }, - wantErr: false, - }, - { - name: "accepts acct: prefix", - rawFingers: webfinger.RawFingersMap{ - "acct:user@example.com": { - "name": "John Doe", - }, - }, - want: webfinger.WebFingers{ - "acct:user@example.com": { - Subject: "acct:user@example.com", - Properties: map[string]string{ - "https://schema/name": "John Doe", - }, - }, - }, - wantErr: false, - }, - { - name: "accepts urls as resource", - rawFingers: webfinger.RawFingersMap{ - "https://example.com": { - "name": "John Doe", - }, - }, - want: webfinger.WebFingers{ - "https://example.com": { - Subject: "https://example.com", - Properties: map[string]string{ - "https://schema/name": "John Doe", - }, - }, - }, - wantErr: false, - }, - { - name: "accepts multiple resources", - rawFingers: webfinger.RawFingersMap{ - "user@example.com": { - "name": "John Doe", - }, - "other@example.com": { - "name": "Jane Doe", - }, - }, - want: webfinger.WebFingers{ - "acct:user@example.com": { - Subject: "acct:user@example.com", - Properties: map[string]string{ - "https://schema/name": "John Doe", - }, - }, - "acct:other@example.com": { - Subject: "acct:other@example.com", - Properties: map[string]string{ - "https://schema/name": "Jane Doe", - }, - }, - }, - wantErr: false, - }, - { - name: "errors on invalid resource", - rawFingers: webfinger.RawFingersMap{ - "invalid": { - "name": "John Doe", - }, - }, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - tc := tt - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Create a urn map - urns := webfinger.URNMap{ - "name": "https://schema/name", - "profile": "https://schema/profile", - } - - ctx := context.Background() - cfg := config.NewConfig() - l := log.NewLogger(&strings.Builder{}, cfg) - - ctx = log.WithLogger(ctx, l) - - f := webfinger.NewFingerReader() - - got, err := f.ParseFingers(ctx, urns, tc.rawFingers) - if (err != nil) != tc.wantErr { - t.Errorf("ParseFingers() error = %v, wantErr %v", err, tc.wantErr) - - return - } - - // Sort links to make it easier to compare - for _, v := range got { - for range v.Links { - sort.Slice(v.Links, func(i, j int) bool { - return v.Links[i].Rel < v.Links[j].Rel - }) - } - } - - for _, v := range tc.want { - for range v.Links { - sort.Slice(v.Links, func(i, j int) bool { - return v.Links[i].Rel < v.Links[j].Rel - }) - } - } - - if !reflect.DeepEqual(got, tc.want) { - // Unmarshal the structs to JSON to make it easier to print - gotstr := &strings.Builder{} - gotenc := json.NewEncoder(gotstr) - - wantstr := &strings.Builder{} - wantenc := json.NewEncoder(wantstr) - - _ = gotenc.Encode(got) - _ = wantenc.Encode(tc.want) - - t.Errorf("ParseFingers() got = \n%s want: \n%s", gotstr.String(), wantstr.String()) - } - }) - } -} - -func TestReadFingerFile(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - urnsContent string - fingersContent string - wantURN webfinger.URNMap - wantFinger webfinger.RawFingersMap - returns *webfinger.WebFingers - wantErr bool - }{ - { - name: "reads files", - urnsContent: "name: https://schema/name\nprofile: https://schema/profile", - fingersContent: "user@example.com:\n name: John Doe", - wantURN: webfinger.URNMap{ - "name": "https://schema/name", - "profile": "https://schema/profile", - }, - wantFinger: webfinger.RawFingersMap{ - "user@example.com": { - "name": "John Doe", - }, - }, - returns: &webfinger.WebFingers{ - "acct:user@example.com": { - Subject: "acct:user@example.com", - Properties: map[string]string{ - "https://schema/name": "John Doe", - }, - }, - }, - wantErr: false, - }, - { - name: "uses custom URNs", - urnsContent: "favorite_food: https://schema/favorite_food", - fingersContent: "user@example.com:\n favorite_food: Apple", - wantURN: webfinger.URNMap{ - "favorite_food": "https://schema/favorite_food", - }, - wantFinger: webfinger.RawFingersMap{ - "user@example.com": { - "https://schema/favorite_food": "Apple", - }, - }, - wantErr: false, - }, - { - name: "errors on invalid URNs file", - urnsContent: "invalid", - fingersContent: "user@example.com:\n name: John Doe", - wantURN: webfinger.URNMap{}, - wantFinger: webfinger.RawFingersMap{}, - wantErr: true, - }, - { - name: "errors on invalid fingers file", - urnsContent: "name: https://schema/name\nprofile: https://schema/profile", - fingersContent: "invalid", - wantURN: webfinger.URNMap{}, - wantFinger: webfinger.RawFingersMap{}, - wantErr: true, - }, - { - name: "errors on invalid URNs values", - urnsContent: "name: invalid", - fingersContent: "user@example.com:\n name: John Doe", - wantURN: webfinger.URNMap{}, - wantFinger: webfinger.RawFingersMap{}, - wantErr: true, - }, - { - name: "errors on invalid fingers values", - urnsContent: "name: https://schema/name\nprofile: https://schema/profile", - fingersContent: "invalid:\n name: John Doe", - wantURN: webfinger.URNMap{}, - wantFinger: webfinger.RawFingersMap{}, - wantErr: true, - }, - } - - for _, tt := range tests { - tc := tt - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - cfg := config.NewConfig() - l := log.NewLogger(&strings.Builder{}, cfg) - - ctx = log.WithLogger(ctx, l) - - f := webfinger.NewFingerReader() - - f.FingersFile = []byte(tc.fingersContent) - f.URNSFile = []byte(tc.urnsContent) - - got, err := f.ReadFingerFile(ctx) - if err != nil { - if !tc.wantErr { - t.Errorf("ReadFingerFile() error = %v", err) - } - - return - } else if tc.wantErr { - t.Errorf("ReadFingerFile() error = %v, wantErr %v", err, tc.wantErr) - } - - if tc.returns != nil && !reflect.DeepEqual(got, *tc.returns) { - t.Errorf("ReadFingerFile() got = %v, want: %v", got, *tc.returns) - } - }) - } -} diff --git a/webfingers/webfingers.go b/webfingers/webfingers.go new file mode 100644 index 0000000..888645c --- /dev/null +++ b/webfingers/webfingers.go @@ -0,0 +1,94 @@ +package webfingers + +import ( + "fmt" + "net/mail" + "net/url" +) + +// Link is a link in a webfinger. +type Link struct { + Rel string `json:"rel"` + Href string `json:"href,omitempty"` +} + +// WebFinger is a webfinger. +type WebFinger struct { + Subject string `json:"subject"` + Links []Link `json:"links,omitempty"` + Properties map[string]string `json:"properties,omitempty"` +} + +// Resources is a simplified webfinger map. +type Resources map[string]map[string]string + +// URNAliases is a map of URN aliases. +type URNAliases map[string]string + +// WebFingers is a map of webfingers. +type WebFingers map[string]*WebFinger + +// NewWebFingers creates a new webfinger map from a simplified webfinger map and an optional URN aliases map. +func NewWebFingers(resources Resources, urnAliases URNAliases) (WebFingers, error) { + fingers := make(WebFingers) + + // If the aliases map is nil, create an empty one. + if urnAliases == nil { + urnAliases = make(URNAliases) + } + + // Parse the resources. + for k, v := range resources { + subject := k + + // Remove leading acct: if present. + if len(k) > 5 && subject[:5] == "acct:" { + subject = subject[5:] + } + + // The subject must be a URL or email address. + if _, err := mail.ParseAddress(subject); err != nil { + if _, err := url.ParseRequestURI(subject); err != nil { + return nil, fmt.Errorf("error parsing resource subject (%s): %w", k, err) + } + } else { + // Add acct: back to the subject if it is an email address. + subject = fmt.Sprintf("acct:%s", subject) + } + + // Create a new webfinger. + finger := &WebFinger{ + Subject: subject, + } + + // Parse the resource fields. + for field, value := range v { + fieldUrn := field + + // If the key is present in the aliases map, use its value. + if _, ok := urnAliases[field]; ok { + fieldUrn = urnAliases[field] + } + + // If the value is a valid URI, add it to the links. + if _, err := url.ParseRequestURI(value); err == nil { + finger.Links = append(finger.Links, Link{ + Rel: fieldUrn, + Href: value, + }) + } else { + // Otherwise add it to the properties. + if finger.Properties == nil { + finger.Properties = make(map[string]string) + } + + finger.Properties[fieldUrn] = value + } + } + + // Add the webfinger to the map. + fingers[subject] = finger + } + + return fingers, nil +} diff --git a/webfingers/webfingers_test.go b/webfingers/webfingers_test.go new file mode 100644 index 0000000..7528983 --- /dev/null +++ b/webfingers/webfingers_test.go @@ -0,0 +1,231 @@ +package webfingers_test + +import ( + "encoding/json" + "reflect" + "sort" + "testing" + + "git.maronato.dev/maronato/finger/webfingers" +) + +func TestNewWebFingers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resources webfingers.Resources + urnAliases webfingers.URNAliases + want webfingers.WebFingers + wantErr bool + }{ + { + name: "basic", + resources: webfingers.Resources{ + "user@example.com": { + "name": "Example User", + }, + }, + urnAliases: webfingers.URNAliases{ + "name": "http://schema.org/name", + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Properties: map[string]string{ + "http://schema.org/name": "Example User", + }, + }, + }, + }, + { + name: "parses links", + resources: webfingers.Resources{ + "user@example.com": { + "link1": "https://example.com/link1", + "link2": "https://example.com/link2", + }, + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Links: []webfingers.Link{ + { + Rel: "link1", + Href: "https://example.com/link1", + }, + { + Rel: "link2", + Href: "https://example.com/link2", + }, + }, + }, + }, + }, + { + name: "parses links with URN aliases", + resources: webfingers.Resources{ + "user@example.com": { + "link1": "https://example.com/link1", + }, + }, + urnAliases: webfingers.URNAliases{ + "link1": "http://schema.com/link", + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Links: []webfingers.Link{ + { + Rel: "http://schema.com/link", + Href: "https://example.com/link1", + }, + }, + }, + }, + }, + { + name: "parses properties", + resources: webfingers.Resources{ + "user@example.com": { + "prop1": "value1", + "prop2": "value2", + }, + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Properties: map[string]string{ + "prop1": "value1", + "prop2": "value2", + }, + }, + }, + }, + { + name: "parses properties with URN aliases", + resources: webfingers.Resources{ + "user@example.com": { + "prop1": "value1", + }, + }, + urnAliases: webfingers.URNAliases{ + "prop1": "http://schema.com/prop", + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Properties: map[string]string{ + "http://schema.com/prop": "value1", + }, + }, + }, + }, + { + name: "parses multiple resources", + resources: webfingers.Resources{ + "user@example.com": { + "prop1": "value1", + }, + "user2@example.com": { + "prop2": "value2", + }, + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Properties: map[string]string{ + "prop1": "value1", + }, + }, + "acct:user2@example.com": { + Subject: "acct:user2@example.com", + Properties: map[string]string{ + "prop2": "value2", + }, + }, + }, + }, + { + name: "parses URI resources", + resources: webfingers.Resources{ + "https://example.com": { + "prop1": "value1", + }, + }, + want: webfingers.WebFingers{ + "https://example.com": { + Subject: "https://example.com", + Properties: map[string]string{ + "prop1": "value1", + }, + }, + }, + }, + { + name: "parses email resource with acct:", + resources: webfingers.Resources{ + "acct:user@example.com": { + "prop1": "value1", + }, + }, + want: webfingers.WebFingers{ + "acct:user@example.com": { + Subject: "acct:user@example.com", + Properties: map[string]string{ + "prop1": "value1", + }, + }, + }, + }, + { + name: "errors on invalid resource", + resources: webfingers.Resources{ + "invalid": { + "prop1": "value1", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tc := tt + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := webfingers.NewWebFingers(tc.resources, tc.urnAliases) + if err != nil { + if !tc.wantErr { + t.Errorf("unexpected error: %v", err) + } + + return + } else if tc.wantErr { + t.Error("expected error, got nil") + } + + // Sort the links. + for _, finger := range got { + sort.Slice(finger.Links, func(i, j int) bool { + return finger.Links[i].Rel < finger.Links[j].Rel + }) + } + + for _, finger := range tc.want { + sort.Slice(finger.Links, func(i, j int) bool { + return finger.Links[i].Rel < finger.Links[j].Rel + }) + } + + if !reflect.DeepEqual(got, tc.want) { + // Marshall both so we can visualize the differences. + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(tc.want, "", " ") + + t.Errorf("got:\n%s\nwant:\n%s", gotJSON, wantJSON) + } + }) + } +}