refactor so external servers can use Finger as a handler
All checks were successful
Go / checks (push) Successful in 58s

This commit is contained in:
Gustavo Maronato 2023-09-20 15:13:10 -03:00
parent e2ea9cd975
commit de3da93523
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
12 changed files with 763 additions and 656 deletions

View File

@ -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`:

View File

@ -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)
}

View File

@ -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")
})
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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"),

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}
})
}
}

94
webfingers/webfingers.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
}
}