Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

31 changed files with 674 additions and 2410 deletions

View file

@ -25,10 +25,6 @@ COPY Makefile ./
# Copy source files
COPY main.go ./
COPY cmd cmd
COPY internal internal
COPY webfingers webfingers
COPY handler handler
# Build it
RUN --mount=type=cache,target=/tmp/.go-build-cache \

View file

@ -9,7 +9,7 @@ build:
test:
go test -v ./...
run:
serve:
go run main.go serve
clean:

234
README.md
View file

@ -1,234 +0,0 @@
# Finger
Webfinger handler / standalone server written in Go.
## Features
- 🍰 Easy YAML configuration
- 🪶 Single 8MB binary / 0% idle CPU / 4MB idle RAM
- ⚡️ Sub millisecond responses at 10,000 request per second
- 🐳 10MB Docker image
## 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`:
```bash
go install git.maronato.dev/maronato/finger@latest
```
Via Docker:
```bash
docker run \
--name finger \
-p 8080:8080 \
-v ${PWD}/fingers.yml:/app/fingers.yml \
git.maronato.dev/maronato/finger
```
## Usage
If you installed it using `go install`, run
```bash
finger serve
```
To start the server on port `8080`. Your resources will be queryable via `locahost:8080/.well-known/webfinger?resource=<your-resource>`
If you're using Docker, the use the same command in the install section.
By default, no resources will be exposed. You can create resources via a `fingers.yml` file. It should contain a collection of resources as keys and their attributes as their objects.
Some default URN aliases are provided via the built-in mapping ([`urns.yml`](./urns.yml)). You can replace that with your own or use URNs directly in the `fingers.yml` file.
Here's an example:
```yaml
# fingers.yml
# Resources go in the root of the file. Email address will have the acct:
# prefix added automatically.
alice@example.com:
# "avatar" is an alias of "http://webfinger.net/rel/avatar"
# (see urns.yml for more)
avatar: "https://example.com/alice-pic"
# If the value is a URI, it'll be exposed as a webfinger link
openid: "https://sso.example.com/"
# If the value of the attribute is not a URI, it will be exposed as a
# webfinger property
name: "Alice Doe"
# You can also specify URN's directly instead of the aliases
http://webfinger.net/rel/profile-page: "https://example.com/user/alice"
bob@example.com:
name: Bob Foo
openid: "https://sso.example.com/"
# Resources can also be URIs
https://example.com/user/charlie:
name: Charlie Baz
profile: https://example.com/user/charlie
```
### Example queries
<details>
<summary><b>Query Alice</b><pre>GET http://localhost:8080/.well-known/webfinger?resource=acct:alice@example.com</pre></summary>
```json
{
"subject": "acct:alice@example.com",
"links": [
{
"rel": "avatar",
"href": "https://example.com/alice-pic"
},
{
"rel": "openid",
"href": "https://sso.example.com/"
},
{
"rel": "http://webfinger.net/rel/profile-page",
"href": "https://example.com/user/alice"
}
],
"properties": {
"name": "Alice Doe"
}
}
```
</details>
<details>
<summary><b>Query Bob</b><pre>GET http://localhost:8080/.well-known/webfinger?resource=acct:bob@example.com</pre></summary>
```json
{
"subject": "acct:bob@example.com",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://sso.example.com/"
}
],
"properties": {
"http://schema.org/name": "Bob Foo"
}
}
```
</details>
<details>
<summary><b>Query Charlie</b><pre>GET http://localhost:8080/.well-known/webfinger?resource=https://example.com/user/charlie</pre></summary>
```JSON
{
"subject": "https://example.com/user/charlie",
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"href": "https://example.com/user/charlie"
}
],
"properties": {
"http://schema.org/name": "Charlie Baz"
}
}
```
</details>
## Commands
Finger exposes two commands: `serve` and `healthcheck`. `serve` is the default command and starts the server. `healthcheck` is used by the Docker healthcheck to check if the server is up.
## Configs
Here are the config options available. You can change them via command line flags or environment variables:
| CLI flag | Env variable | Default | Description |
| ------------------- | ---------------- | -------------------------------------- | -------------------------------------- |
| `-p, --port` | `WF_PORT` | `8080` | Port where the server listens to |
| `-h, --host` | `WF_HOST` | `localhost` (`0.0.0.0` when in Docker) | Host where the server listens to |
| `-f, --finger-file` | `WF_FINGER_FILE` | `fingers.yml` | Path to the webfingers definition file |
| `-u, --urn-file` | `WF_URN_FILE` | `urns.yml` | Path to the URNs alias file |
| `-d, --debug` | `WF_DEBUG` | `false` | Enable debug logging |
### Docker config
If you're using the Docker image, you can mount your `fingers.yml` file to `/app/fingers.yml` and the `urns.yml` to `/app/urns.yml`.
To run the docker image with flags or a different command, specify the command followed by the flags:
```bash
# Start the server on port 3030 in debug mode with a different fingers file
docker run git.maronato.dev/maronato/finger serve --port 3030 --debug --finger-file /app/my-fingers.yml
# or run a healthcheck on a different finger container
docker run git.maronato.dev/maronato/finger healthcheck --host otherhost --port 3030
```
## Development
You need to have [Go](https://golang.org/) installed to build the project.
Clone the repo and run `make build` to build the binary. You can then run `./finger serve` to start the server.
A few other commands are:
- `make run` to run the server
- `make test` to run the tests
- `make lint` to run the linter
- `make clean` to clean the build files
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View file

@ -1,98 +0,0 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"git.maronato.dev/maronato/finger/internal/config"
"github.com/peterbourgon/ff/v4"
"github.com/peterbourgon/ff/v4/ffhelp"
)
func Run(version string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Allow graceful shutdown
trapSignalsCrossPlatform(cancel)
cfg := &config.Config{}
// Create a new root command
subcommands := []*ff.Command{
newServerCmd(cfg),
newHealthcheckCmd(cfg),
}
cmd := newRootCmd(version, cfg, subcommands)
// Parse and run
if err := cmd.ParseAndRun(ctx, os.Args[1:], ff.WithEnvVarPrefix("WF")); err != nil {
if errors.Is(err, ff.ErrHelp) || errors.Is(err, ff.ErrNoExec) {
fmt.Fprintf(os.Stderr, "\n%s\n", ffhelp.Command(cmd))
return nil
}
return fmt.Errorf("error running command: %w", err)
}
return nil
}
// https://github.com/caddyserver/caddy/blob/fbb0ecfa322aa7710a3448453fd3ae40f037b8d1/sigtrap.go#L37
// trapSignalsCrossPlatform captures SIGINT or interrupt (depending
// on the OS), which initiates a graceful shutdown. A second SIGINT
// or interrupt will forcefully exit the process immediately.
func trapSignalsCrossPlatform(cancel context.CancelFunc) {
go func() {
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGINT)
for i := 0; true; i++ {
<-shutdown
if i > 0 {
fmt.Printf("\nForce quit\n") //nolint:forbidigo // We want to print to stdout
os.Exit(1)
}
fmt.Printf("\nGracefully shutting down. Press Ctrl+C again to force quit\n") //nolint:forbidigo // We want to print to stdout
cancel()
}
}()
}
// NewRootCmd parses the command line flags and returns a config.Config struct.
func newRootCmd(version string, cfg *config.Config, subcommands []*ff.Command) *ff.Command {
fs := ff.NewFlagSet(appName)
for _, cmd := range subcommands {
cmd.Flags = ff.NewFlagSet(cmd.Name).SetParent(fs)
}
cmd := &ff.Command{
Name: appName,
Usage: fmt.Sprintf("%s <command> [flags]", appName),
ShortHelp: fmt.Sprintf("(%s) A webfinger server", version),
Flags: fs,
Subcommands: subcommands,
}
// Use 0.0.0.0 as the default host if on docker
defaultHost := "localhost"
if os.Getenv("ENV_DOCKER") == "true" {
defaultHost = "0.0.0.0"
}
fs.BoolVar(&cfg.Debug, 'd', "debug", "Enable debug logging")
fs.StringVar(&cfg.Host, 'h', "host", defaultHost, "Host to listen on")
fs.StringVar(&cfg.Port, 'p', "port", "8080", "Port to listen on")
fs.StringVar(&cfg.URNPath, 'u', "urn-file", "urns.yml", "Path to the URNs file")
fs.StringVar(&cfg.FingerPath, 'f', "finger-file", "fingers.yml", "Path to the fingers file")
return cmd
}

View file

@ -1,53 +0,0 @@
package cmd
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"git.maronato.dev/maronato/finger/internal/config"
"github.com/peterbourgon/ff/v4"
)
func newHealthcheckCmd(cfg *config.Config) *ff.Command {
return &ff.Command{
Name: "healthcheck",
Usage: "healthcheck [flags]",
ShortHelp: "Check if the server is running",
Exec: func(ctx context.Context, args []string) error {
// Create a new client
client := &http.Client{
Timeout: 5 * time.Second, //nolint:gomnd // We want to use a constant
}
// Create a new request
reqURL := url.URL{
Scheme: "http",
Host: cfg.GetAddr(),
Path: "/healthz",
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), http.NoBody)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
// Send the request
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
// Check the response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d", resp.StatusCode) //nolint:goerr113 // We want to return an error
}
return nil
},
}
}

View file

@ -1,49 +0,0 @@
package cmd
import (
"context"
"fmt"
"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"
"github.com/peterbourgon/ff/v4"
)
const appName = "finger"
func newServerCmd(cfg *config.Config) *ff.Command {
return &ff.Command{
Name: "serve",
Usage: "serve [flags]",
ShortHelp: "Start the webfinger server",
Exec: func(ctx context.Context, args []string) error {
// Create a logger and add it to the context
l := log.NewLogger(os.Stderr, cfg)
ctx = log.WithLogger(ctx, l)
// Read the webfinger files
r := fingerreader.NewFingerReader()
err := r.ReadFiles(cfg)
if err != nil {
return fmt.Errorf("error reading finger files: %w", err)
}
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(fingers)))
// Start the server
if err := server.StartServer(ctx, cfg, fingers); err != nil {
return fmt.Errorf("error running server: %w", err)
}
return nil
},
}
}

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.21.0
require (
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
golang.org/x/sync v0.3.0
gopkg.in/yaml.v3 v3.0.1
)

2
go.sum
View file

@ -2,6 +2,8 @@ github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3 h1:fpyiFVEJvxIFljxM4l5ANSk/UGlM1gyU+hPAr9jhB7M=
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View file

@ -1,48 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"git.maronato.dev/maronato/finger/webfingers"
)
func WebfingerHandler(fingers webfingers.WebFingers) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only handle GET requests
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get the query params
q := r.URL.Query()
// Get the resource
resource := q.Get("resource")
if resource == "" {
http.Error(w, "No resource provided", http.StatusBadRequest)
return
}
// Get and validate resource
finger, ok := fingers[resource]
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
// Set the content type
w.Header().Set("Content-Type", "application/jrd+json")
// Write the response
if err := json.NewEncoder(w).Encode(finger); err != nil {
http.Error(w, "Error encoding json", http.StatusInternalServerError)
return
}
})
}

View file

@ -1,176 +0,0 @@
package handler_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"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/webfingers"
)
func TestWebfingerHandler(t *testing.T) {
t.Parallel()
fingers := webfingers.WebFingers{
"acct:user@example.com": {
Subject: "acct:user@example.com",
Links: []webfingers.Link{
{
Rel: "http://webfinger.net/rel/profile-page",
Href: "https://example.com/user",
},
},
Properties: map[string]string{
"http://webfinger.net/rel/name": "John Doe",
},
},
"acct:other@example.com": {
Subject: "acct:other@example.com",
Properties: map[string]string{
"http://webfinger.net/rel/name": "Jane Doe",
},
},
"https://example.com/user": {
Subject: "https://example.com/user",
Properties: map[string]string{
"http://webfinger.net/rel/name": "John Baz",
},
},
}
tests := []struct {
name string
resource string
wantCode int
alternateMethod string
}{
{
name: "valid resource",
resource: "acct:user@example.com",
wantCode: http.StatusOK,
},
{
name: "other valid resource",
resource: "acct:other@example.com",
wantCode: http.StatusOK,
},
{
name: "url resource",
resource: "https://example.com/user",
wantCode: http.StatusOK,
},
{
name: "resource missing acct:",
resource: "user@example.com",
wantCode: http.StatusNotFound,
},
{
name: "resource missing",
resource: "",
wantCode: http.StatusBadRequest,
},
{
name: "invalid method",
resource: "acct:user@example.com",
wantCode: http.StatusMethodNotAllowed,
alternateMethod: http.MethodPost,
},
}
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)
// Create a new request
r, _ := http.NewRequestWithContext(ctx, tc.alternateMethod, "/.well-known/webfinger?resource="+tc.resource, http.NoBody)
// Create a new response
w := httptest.NewRecorder()
// Create a new handler
h := handler.WebfingerHandler(fingers)
// Serve the request
h.ServeHTTP(w, r)
// Check the status code
if w.Code != tc.wantCode {
t.Errorf("expected status code %d, got %d", tc.wantCode, w.Code)
}
// If the status code is 200, check the response body
if tc.wantCode == http.StatusOK {
// Check the content type
if w.Header().Get("Content-Type") != "application/jrd+json" {
t.Errorf("expected content type %s, got %s", "application/jrd+json", w.Header().Get("Content-Type"))
}
fingerWant := fingers[tc.resource]
fingerGot := &webfingers.WebFinger{}
// Decode the response body
if err := json.NewDecoder(w.Body).Decode(fingerGot); err != nil {
t.Errorf("error decoding json: %v", err)
}
// Sort links
sort.Slice(fingerGot.Links, func(i, j int) bool {
return fingerGot.Links[i].Rel < fingerGot.Links[j].Rel
})
sort.Slice(fingerWant.Links, func(i, j int) bool {
return fingerWant.Links[i].Rel < fingerWant.Links[j].Rel
})
// Check the response body
if !reflect.DeepEqual(fingerGot, fingerWant) {
t.Errorf("expected body %v, got %v", fingerWant, fingerGot)
}
}
})
}
}
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

@ -1,67 +0,0 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
)
const (
// DefaultHost is the default host to listen on.
DefaultHost = "localhost"
// DefaultPort is the default port to listen on.
DefaultPort = "8080"
// DefaultURNPath is the default file path to the URN alias file.
DefaultURNPath = "urns.yml"
// DefaultFingerPath is the default file path to the webfinger definition file.
DefaultFingerPath = "fingers.yml"
)
// ErrInvalidConfig is returned when the config is invalid.
var ErrInvalidConfig = errors.New("invalid config")
type Config struct {
Debug bool
Host string
Port string
URNPath string
FingerPath string
}
func NewConfig() *Config {
return &Config{
Host: DefaultHost,
Port: DefaultPort,
URNPath: DefaultURNPath,
FingerPath: DefaultFingerPath,
}
}
func (c *Config) GetAddr() string {
return net.JoinHostPort(c.Host, c.Port)
}
func (c *Config) Validate() error {
if c.Host == "" {
return fmt.Errorf("%w: host is empty", ErrInvalidConfig)
}
if c.Port == "" {
return fmt.Errorf("%w: port is empty", ErrInvalidConfig)
}
if _, err := url.Parse(c.GetAddr()); err != nil {
return fmt.Errorf("%w: %w", ErrInvalidConfig, err)
}
if c.URNPath == "" {
return fmt.Errorf("%w: urn path is empty", ErrInvalidConfig)
}
if c.FingerPath == "" {
return fmt.Errorf("%w: finger path is empty", ErrInvalidConfig)
}
return nil
}

View file

@ -1,124 +0,0 @@
package config_test
import (
"testing"
"git.maronato.dev/maronato/finger/internal/config"
)
func TestConfig_GetAddr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg *config.Config
want string
}{
{
name: "default",
cfg: config.NewConfig(),
want: "localhost:8080",
},
{
name: "custom",
cfg: &config.Config{
Host: "example.com",
Port: "1234",
},
want: "example.com:1234",
},
}
for _, tt := range tests {
tc := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tc.cfg.GetAddr()
if got != tc.want {
t.Errorf("Config.GetAddr() = %v, want %v", got, tc.want)
}
})
}
}
func TestConfig_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg *config.Config
wantErr bool
}{
{
name: "default",
cfg: config.NewConfig(),
wantErr: false,
},
{
name: "empty host",
cfg: &config.Config{
Host: "",
Port: config.DefaultPort,
},
wantErr: true,
},
{
name: "empty port",
cfg: &config.Config{
Host: config.DefaultHost,
Port: "",
},
wantErr: true,
},
{
name: "invalid addr",
cfg: &config.Config{
Host: config.DefaultHost,
Port: "invalid",
},
wantErr: true,
},
{
name: "empty urn path",
cfg: &config.Config{
Host: config.DefaultHost,
Port: config.DefaultPort,
URNPath: "",
},
wantErr: true,
},
{
name: "empty finger path",
cfg: &config.Config{
Host: config.DefaultHost,
Port: config.DefaultPort,
URNPath: config.DefaultURNPath,
FingerPath: "",
},
wantErr: true,
},
{
name: "valid",
cfg: &config.Config{
Host: config.DefaultHost,
Port: config.DefaultPort,
URNPath: config.DefaultURNPath,
FingerPath: config.DefaultFingerPath,
},
wantErr: false,
},
}
for _, tt := range tests {
tc := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tc.cfg.Validate()
if (err != nil) != tc.wantErr {
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}

View file

@ -1,89 +0,0 @@
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

@ -1,242 +0,0 @@
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

@ -1,42 +0,0 @@
package log
import (
"context"
"io"
"log/slog"
"git.maronato.dev/maronato/finger/internal/config"
)
type loggerCtxKey struct{}
// NewLogger creates a new logger with the given debug level.
func NewLogger(w io.Writer, cfg *config.Config) *slog.Logger {
level := slog.LevelInfo
addSource := false
if cfg.Debug {
level = slog.LevelDebug
addSource = true
}
return slog.New(
slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level,
AddSource: addSource,
}),
)
}
func FromContext(ctx context.Context) *slog.Logger {
l, ok := ctx.Value(loggerCtxKey{}).(*slog.Logger)
if !ok {
panic("logger not found in context")
}
return l
}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerCtxKey{}, l)
}

View file

@ -1,95 +0,0 @@
package log_test
import (
"context"
"strings"
"testing"
"git.maronato.dev/maronato/finger/internal/config"
"git.maronato.dev/maronato/finger/internal/log"
)
func assertPanic(t *testing.T, f func()) {
t.Helper()
defer func() {
if r := recover(); r == nil {
t.Errorf("The code did not panic")
}
}()
// Call the function
f()
}
func TestNewLogger(t *testing.T) {
t.Parallel()
t.Run("defaults to info level", func(t *testing.T) {
t.Parallel()
cfg := config.NewConfig()
w := &strings.Builder{}
l := log.NewLogger(w, cfg)
// It shouldn't log debug messages
l.Debug("test")
if w.String() != "" {
t.Error("logger logged debug message")
}
// It should log info messages
l.Info("test")
if w.String() == "" {
t.Error("logger did not log info message")
}
})
t.Run("logs debug messages if debug is enabled", func(t *testing.T) {
t.Parallel()
cfg := config.NewConfig()
cfg.Debug = true
w := &strings.Builder{}
l := log.NewLogger(w, cfg)
// It should log debug messages
l.Debug("test")
if w.String() == "" {
t.Error("logger did not log debug message")
}
})
}
func TestFromContext(t *testing.T) {
t.Parallel()
ctx := context.Background()
cfg := config.NewConfig()
l := log.NewLogger(nil, cfg)
t.Run("panics if no logger in context", func(t *testing.T) {
t.Parallel()
assertPanic(t, func() {
log.FromContext(ctx)
})
})
t.Run("returns logger from context", func(t *testing.T) {
t.Parallel()
ctx = log.WithLogger(ctx, l)
l2 := log.FromContext(ctx)
if l2 == nil {
t.Error("logger is nil")
}
})
}

View file

@ -1,44 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"time"
"git.maronato.dev/maronato/finger/internal/log"
)
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := log.FromContext(ctx)
start := time.Now()
// Wrap the response writer
wrapped := WrapResponseWriter(w)
// Call the next handler
next.ServeHTTP(wrapped, r)
status := wrapped.Status()
// Log the request
lg := l.With(
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", status),
slog.String("remote", r.RemoteAddr),
slog.Duration("duration", time.Since(start)),
)
switch {
case status >= http.StatusInternalServerError:
lg.Error("Server error")
case status >= http.StatusBadRequest:
lg.Info("Client error")
default:
lg.Info("Request completed")
}
})
}

View file

@ -1,44 +0,0 @@
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.maronato.dev/maronato/finger/internal/config"
"git.maronato.dev/maronato/finger/internal/log"
"git.maronato.dev/maronato/finger/internal/middleware"
)
func TestRequestLogger(t *testing.T) {
t.Parallel()
ctx := context.Background()
cfg := config.NewConfig()
stdout := &strings.Builder{}
l := log.NewLogger(stdout, cfg)
ctx = log.WithLogger(ctx, l)
w := httptest.NewRecorder()
r, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/", http.NoBody)
if stdout.String() != "" {
t.Error("logger logged before request")
}
middleware.RequestLogger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Error("status is not 200")
}
if stdout.String() == "" {
t.Error("logger did not log request")
}
}

View file

@ -1,27 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"git.maronato.dev/maronato/finger/internal/log"
)
func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := log.FromContext(ctx)
defer func() {
err := recover()
if err != nil {
l.Error("Panic", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}()
next.ServeHTTP(w, r)
})
}

View file

@ -1,76 +0,0 @@
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.maronato.dev/maronato/finger/internal/config"
"git.maronato.dev/maronato/finger/internal/log"
"git.maronato.dev/maronato/finger/internal/middleware"
)
func assertNoPanic(t *testing.T, f func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Error("function panicked")
}
}()
f()
}
func TestRecoverer(t *testing.T) {
t.Parallel()
ctx := context.Background()
cfg := config.NewConfig()
l := log.NewLogger(&strings.Builder{}, cfg)
ctx = log.WithLogger(ctx, l)
t.Run("handles panics", func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
r, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/", http.NoBody)
h := middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("test")
}))
assertNoPanic(t, func() {
h.ServeHTTP(w, r)
})
if w.Code != http.StatusInternalServerError {
t.Error("status is not 500")
}
if w.Body.String() != "Internal Server Error\n" {
t.Error("response body is not 'Internal Server Error'")
}
})
t.Run("handles successful requests", func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
r, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/", http.NoBody)
h := middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
assertNoPanic(t, func() {
h.ServeHTTP(w, r)
})
if w.Code != http.StatusOK {
t.Error("status is not 200")
}
})
}

View file

@ -1,42 +0,0 @@
package middleware
import (
"fmt"
"net/http"
)
type ResponseWrapper struct {
http.ResponseWriter
status int
}
func WrapResponseWriter(w http.ResponseWriter) *ResponseWrapper {
return &ResponseWrapper{w, 0}
}
func (w *ResponseWrapper) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *ResponseWrapper) Status() int {
return w.status
}
func (w *ResponseWrapper) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
size, err := w.ResponseWriter.Write(b)
if err != nil {
return 0, fmt.Errorf("error writing response: %w", err)
}
return size, nil
}
func (w *ResponseWrapper) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}

View file

@ -1,97 +0,0 @@
package middleware_test
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.maronato.dev/maronato/finger/internal/middleware"
)
func TestWrapResponseWriter(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
wrapped := middleware.WrapResponseWriter(w)
if wrapped == nil {
t.Error("wrapper is nil")
}
}
func TestResponseWrapper_Status(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
wrapped := middleware.WrapResponseWriter(w)
if wrapped.Status() != 0 {
t.Error("status is not 0")
}
wrapped.WriteHeader(http.StatusOK)
if wrapped.Status() != http.StatusOK {
t.Error("status is not 200")
}
}
type FailWriter struct{}
func (w *FailWriter) Write(_ []byte) (int, error) {
return 0, fmt.Errorf("error") //nolint:goerr113 // We want to return an error
}
func (w *FailWriter) Header() http.Header {
return http.Header{}
}
func (w *FailWriter) WriteHeader(_ int) {}
func TestResponseWrapper_Write(t *testing.T) {
t.Parallel()
t.Run("writes success messages", func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
wrapped := middleware.WrapResponseWriter(w)
size, err := wrapped.Write([]byte("test"))
if err != nil {
t.Errorf("error writing response: %v", err)
}
if size != 4 {
t.Error("size is not 4")
}
if wrapped.Status() != http.StatusOK {
t.Error("status is not 200")
}
})
t.Run("returns error on fail write", func(t *testing.T) {
t.Parallel()
w := &FailWriter{}
wrapped := middleware.WrapResponseWriter(w)
_, err := wrapped.Write([]byte("test"))
if err == nil {
t.Error("error is nil")
}
})
}
func TestResponseWrapper_Unwrap(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
wrapped := middleware.WrapResponseWriter(w)
if wrapped.Unwrap() != w {
t.Error("unwrapped response is not the same")
}
}

View file

@ -1,13 +0,0 @@
package server
import (
"net/http"
"git.maronato.dev/maronato/finger/internal/config"
)
func HealthCheckHandler(_ *config.Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}

View file

@ -1,40 +0,0 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.maronato.dev/maronato/finger/internal/config"
"git.maronato.dev/maronato/finger/internal/log"
"git.maronato.dev/maronato/finger/internal/server"
)
func TestHealthcheckHandler(t *testing.T) {
t.Parallel()
ctx := context.Background()
cfg := config.NewConfig()
l := log.NewLogger(&strings.Builder{}, cfg)
ctx = log.WithLogger(ctx, l)
// Create a new request
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/healthz", http.NoBody)
// Create a new recorder
rec := httptest.NewRecorder()
// Create a new handler
h := server.HealthCheckHandler(cfg)
// Serve the request
h.ServeHTTP(rec, req)
// Check the status code
if rec.Code != http.StatusOK {
t.Errorf("expected status code %d, got %d", http.StatusOK, rec.Code)
}
}

View file

@ -1,98 +0,0 @@
package server
import (
"context"
"fmt"
"log/slog"
"net"
"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/webfingers"
"golang.org/x/sync/errgroup"
)
const (
// ReadTimeout is the maximum duration for reading the entire
// request, including the body.
ReadTimeout = 5 * time.Second
// WriteTimeout is the maximum duration before timing out
// writes of the response.
WriteTimeout = 10 * time.Second
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled.
IdleTimeout = 30 * time.Second
// ReadHeaderTimeout is the amount of time allowed to read
// request headers.
ReadHeaderTimeout = 2 * time.Second
// RequestTimeout is the maximum duration for the entire
// request.
RequestTimeout = 7 * 24 * time.Hour
)
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", handler.WebfingerHandler(fingers))
mux.Handle("/healthz", HealthCheckHandler(cfg))
// Create a new server
srv := &http.Server{
Addr: cfg.GetAddr(),
Handler: middleware.RequestLogger(
middleware.Recoverer(
http.TimeoutHandler(mux, RequestTimeout, "request timed out"),
),
),
ReadHeaderTimeout: ReadHeaderTimeout,
ReadTimeout: ReadTimeout,
WriteTimeout: WriteTimeout,
IdleTimeout: IdleTimeout,
}
// Create the errorgroup that will manage the server execution
eg, egCtx := errgroup.WithContext(ctx)
// Start the server
eg.Go(func() error {
l.Info("Starting server", slog.String("addr", srv.Addr))
// Use the global context for the server
srv.BaseContext = func(_ net.Listener) context.Context {
return egCtx
}
return srv.ListenAndServe() //nolint:wrapcheck // We wrap the error in the errgroup
})
// Gracefully shutdown the server when the context is done
eg.Go(func() error {
// Wait for the context to be done
<-egCtx.Done()
l.Info("Shutting down server")
// Disable the cancel since we don't wan't to force
// the server to shutdown if the context is canceled.
noCancelCtx := context.WithoutCancel(egCtx)
return srv.Shutdown(noCancelCtx) //nolint:wrapcheck // We wrap the error in the errgroup
})
// Log when the server is fully shutdown
srv.RegisterOnShutdown(func() {
l.Info("Server shutdown complete")
})
// Wait for the server to exit and check for errors that
// are not caused by the context being canceled.
if err := eg.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("server exited with error: %w", err)
}
return nil
}

View file

@ -1,206 +0,0 @@
package server_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"time"
"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/webfingers"
)
func getPortGenerator() func() int {
lock := &sync.Mutex{}
port := 8080
return func() int {
lock.Lock()
defer lock.Unlock()
port++
return port
}
}
func TestStartServer(t *testing.T) {
t.Parallel()
portGenerator := getPortGenerator()
t.Run("starts and shuts down", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
defer cancel()
cfg := config.NewConfig()
l := log.NewLogger(&strings.Builder{}, cfg)
ctx = log.WithLogger(ctx, l)
// Use a new port
cfg.Port = fmt.Sprint(portGenerator())
// Start the server
err := server.StartServer(ctx, cfg, nil)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("fails to start", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200)
defer cancel()
cfg := config.NewConfig()
l := log.NewLogger(&strings.Builder{}, cfg)
ctx = log.WithLogger(ctx, l)
// Use a new port
cfg.Port = fmt.Sprint(portGenerator())
// Use invalid host
cfg.Host = "google.com"
// Start the server
err := server.StartServer(ctx, cfg, nil)
if err == nil {
t.Errorf("expected error, got nil")
}
})
t.Run("serves webfinger", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200)
defer cancel()
cfg := config.NewConfig()
l := log.NewLogger(&strings.Builder{}, cfg)
ctx = log.WithLogger(ctx, l)
// Use a new port
cfg.Port = fmt.Sprint(portGenerator())
resource := "acct:user@example.com"
fingers := webfingers.WebFingers{
resource: &webfingers.WebFinger{
Subject: resource,
Properties: map[string]string{
"http://webfinger.net/rel/name": "John Doe",
},
},
}
go func() {
// Start the server
err := server.StartServer(ctx, cfg, fingers)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}()
// Wait for the server to start
time.Sleep(time.Millisecond * 50)
// Create a new client
c := http.Client{}
// Create a new request
r, _ := http.NewRequestWithContext(ctx,
http.MethodGet,
"http://"+cfg.GetAddr()+"/.well-known/webfinger?resource=acct:user@example.com",
http.NoBody,
)
// Send the request
resp, err := c.Do(r)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
defer resp.Body.Close()
// Check the status code
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status code %d, got %d", http.StatusOK, resp.StatusCode)
}
// Check the response body
fingerGot := &webfingers.WebFinger{}
// Decode the response body
if err := json.NewDecoder(resp.Body).Decode(fingerGot); err != nil {
t.Errorf("error decoding json: %v", err)
}
// Check the response body
fingerWant := fingers[resource]
if !reflect.DeepEqual(fingerGot, fingerWant) {
t.Errorf("expected %v, got %v", fingerWant, fingerGot)
}
})
t.Run("serves healthcheck", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200)
defer cancel()
cfg := config.NewConfig()
l := log.NewLogger(&strings.Builder{}, cfg)
ctx = log.WithLogger(ctx, l)
// Use a new port
cfg.Port = fmt.Sprint(portGenerator())
go func() {
// Start the server
err := server.StartServer(ctx, cfg, nil)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}()
// Wait for the server to start
time.Sleep(time.Millisecond * 50)
// Create a new client
c := http.Client{}
// Create a new request
r, _ := http.NewRequestWithContext(ctx,
http.MethodGet,
"http://"+cfg.GetAddr()+"/healthz",
http.NoBody,
)
// Send the request
resp, err := c.Do(r)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
defer resp.Body.Close()
// Check the status code
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status code %d, got %d", http.StatusOK, resp.StatusCode)
}
})
}

553
main.go
View file

@ -1,19 +1,566 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/mail"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"git.maronato.dev/maronato/finger/cmd"
"github.com/peterbourgon/ff/v4"
"github.com/peterbourgon/ff/v4/ffhelp"
"golang.org/x/exp/slog"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)
// Version of the app.
const appName = "finger"
// Version of the application.
var version = "dev"
func main() {
// Run the server
if err := cmd.Run(version); err != nil {
if err := Run(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func Run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Allow graceful shutdown
trapSignalsCrossPlatform(cancel)
cfg := &Config{}
// Create a new root command
subcommands := []*ff.Command{
NewServerCmd(cfg),
NewHealthcheckCmd(cfg),
}
cmd := NewRootCmd(cfg, subcommands)
// Parse and run
if err := cmd.ParseAndRun(ctx, os.Args[1:], ff.WithEnvVarPrefix("WF")); err != nil {
if errors.Is(err, ff.ErrHelp) || errors.Is(err, ff.ErrNoExec) {
fmt.Fprintf(os.Stderr, "\n%s\n", ffhelp.Command(cmd))
return nil
}
return fmt.Errorf("error running command: %w", err)
}
return nil
}
func NewServerCmd(cfg *Config) *ff.Command {
return &ff.Command{
Name: "serve",
Usage: "serve [flags]",
ShortHelp: "Start the webfinger server",
Exec: func(ctx context.Context, args []string) error {
// Create a logger and add it to the context
l := NewLogger(cfg)
ctx = WithLogger(ctx, l)
// Parse the webfinger files
fingermap, err := ParseFingerFile(ctx, cfg)
if err != nil {
return fmt.Errorf("error parsing finger files: %w", err)
}
l.Info(fmt.Sprintf("Loaded %d webfingers", len(fingermap)))
// Start the server
if err := StartServer(ctx, cfg, fingermap); err != nil {
return fmt.Errorf("error running server: %w", err)
}
return nil
},
}
}
func NewHealthcheckCmd(cfg *Config) *ff.Command {
return &ff.Command{
Name: "healthcheck",
Usage: "healthcheck [flags]",
ShortHelp: "Check if the server is running",
Exec: func(ctx context.Context, args []string) error {
// Create a new client
client := &http.Client{
Timeout: 5 * time.Second, //nolint:gomnd // We want to use a constant
}
// Create a new request
reqURL := url.URL{
Scheme: "http",
Host: net.JoinHostPort(cfg.Host, cfg.Port),
Path: "/healthz",
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), http.NoBody)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
// Send the request
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
// Check the response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d", resp.StatusCode) //nolint:goerr113 // We want to return an error
}
return nil
},
}
}
type loggerCtxKey struct{}
// NewLogger creates a new logger with the given debug level.
func NewLogger(cfg *Config) *slog.Logger {
level := slog.LevelInfo
addSource := false
if cfg.Debug {
level = slog.LevelDebug
addSource = true
}
return slog.New(
slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
AddSource: addSource,
}),
)
}
func LoggerFromContext(ctx context.Context) *slog.Logger {
l, ok := ctx.Value(loggerCtxKey{}).(*slog.Logger)
if !ok {
panic("logger not found in context")
}
return l
}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerCtxKey{}, l)
}
// https://github.com/caddyserver/caddy/blob/fbb0ecfa322aa7710a3448453fd3ae40f037b8d1/sigtrap.go#L37
// trapSignalsCrossPlatform captures SIGINT or interrupt (depending
// on the OS), which initiates a graceful shutdown. A second SIGINT
// or interrupt will forcefully exit the process immediately.
func trapSignalsCrossPlatform(cancel context.CancelFunc) {
go func() {
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGINT)
for i := 0; true; i++ {
<-shutdown
if i > 0 {
fmt.Printf("\nForce quit\n") //nolint:forbidigo // We want to print to stdout
os.Exit(1)
}
fmt.Printf("\nGracefully shutting down. Press Ctrl+C again to force quit\n") //nolint:forbidigo // We want to print to stdout
cancel()
}
}()
}
type Config struct {
Debug bool
Host string
Port string
urnPath string
fingerPath string
}
// NewRootCmd parses the command line flags and returns a Config struct.
func NewRootCmd(cfg *Config, subcommands []*ff.Command) *ff.Command {
fs := ff.NewFlagSet(appName)
for _, cmd := range subcommands {
cmd.Flags = ff.NewFlagSet(cmd.Name).SetParent(fs)
}
cmd := &ff.Command{
Name: appName,
Usage: fmt.Sprintf("%s <command> [flags]", appName),
ShortHelp: fmt.Sprintf("(%s) A webfinger server", version),
Flags: fs,
Subcommands: subcommands,
}
// Use 0.0.0.0 as the default host if on docker
defaultHost := "localhost"
if os.Getenv("ENV_DOCKER") == "true" {
defaultHost = "0.0.0.0"
}
fs.BoolVar(&cfg.Debug, 'd', "debug", "Enable debug logging")
fs.StringVar(&cfg.Host, 'h', "host", defaultHost, "Host to listen on")
fs.StringVar(&cfg.Port, 'p', "port", "8080", "Port to listen on")
fs.StringVar(&cfg.urnPath, 'u', "urn-file", "urns.yml", "Path to the URNs file")
fs.StringVar(&cfg.fingerPath, 'f', "finger-file", "fingers.yml", "Path to the fingers file")
return cmd
}
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 WebFingerMap map[string]*WebFinger
func ParseFingerFile(ctx context.Context, cfg *Config) (WebFingerMap, error) {
l := LoggerFromContext(ctx)
urnMap := make(map[string]string)
fingerData := make(map[string]map[string]string)
fingermap := make(WebFingerMap)
// Read URNs file
file, err := os.ReadFile(cfg.urnPath)
if err != nil {
return nil, fmt.Errorf("error opening URNs file: %w", err)
}
if err := yaml.Unmarshal(file, &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.Parse(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))
// Read webfingers file
file, err = os.ReadFile(cfg.fingerPath)
if err != nil {
return nil, fmt.Errorf("error opening fingers file: %w", err)
}
if err := yaml.Unmarshal(file, &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 the webfinger file
for k, v := range fingerData {
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.Parse(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 := urnMap[field]; ok {
fieldUrn = urnMap[field]
}
// If the value is a valid URI, add it to the links
if _, err := url.Parse(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
fingermap[resource] = webfinger
}
l.Debug("Webfinger map built successfully", slog.Int("number", len(fingermap)), slog.Any("data", fingermap))
return fingermap, nil
}
func WebfingerHandler(_ *Config, webmap WebFingerMap) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := LoggerFromContext(ctx)
// Only handle GET requests
if r.Method != http.MethodGet {
l.Debug("Method not allowed")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get the query params
q := r.URL.Query()
// 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
webfinger, ok := webmap[resource]
if !ok {
l.Debug("Resource not found")
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
// Set the content type
w.Header().Set("Content-Type", "application/jrd+json")
// Write the response
if err := json.NewEncoder(w).Encode(webfinger); err != nil {
l.Debug("Error encoding json")
http.Error(w, "Error encoding json", http.StatusInternalServerError)
return
}
l.Debug("Webfinger request successful")
})
}
func HealthCheckHandler(_ *Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
type ResponseWrapper struct {
http.ResponseWriter
status int
}
func WrapResponseWriter(w http.ResponseWriter) *ResponseWrapper {
return &ResponseWrapper{w, 0}
}
func (w *ResponseWrapper) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *ResponseWrapper) Status() int {
return w.status
}
func (w *ResponseWrapper) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
size, err := w.ResponseWriter.Write(b)
if err != nil {
return 0, fmt.Errorf("error writing response: %w", err)
}
return size, nil
}
func (w *ResponseWrapper) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := LoggerFromContext(ctx)
start := time.Now()
// Wrap the response writer
wrapped := WrapResponseWriter(w)
// Call the next handler
next.ServeHTTP(wrapped, r)
status := wrapped.Status()
// Log the request
lg := l.With(
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", status),
slog.String("remote", r.RemoteAddr),
slog.Duration("duration", time.Since(start)),
)
switch {
case status >= http.StatusInternalServerError:
lg.Error("Server error")
case status >= http.StatusBadRequest:
lg.Info("Client error")
default:
lg.Info("Request completed")
}
})
}
const (
// ReadTimeout is the maximum duration for reading the entire
// request, including the body.
ReadTimeout = 5 * time.Second
// WriteTimeout is the maximum duration before timing out
// writes of the response.
WriteTimeout = 10 * time.Second
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled.
IdleTimeout = 30 * time.Second
// ReadHeaderTimeout is the amount of time allowed to read
// request headers.
ReadHeaderTimeout = 2 * time.Second
// RequestTimeout is the maximum duration for the entire
// request.
RequestTimeout = 7 * 24 * time.Hour
)
func StartServer(ctx context.Context, cfg *Config, webmap WebFingerMap) error {
l := LoggerFromContext(ctx)
// Create the server mux
mux := http.NewServeMux()
mux.Handle("/.well-known/webfinger", WebfingerHandler(cfg, webmap))
mux.Handle("/healthz", HealthCheckHandler(cfg))
// Create a new server
srv := &http.Server{
Addr: net.JoinHostPort(cfg.Host, cfg.Port),
BaseContext: func(_ net.Listener) context.Context {
return ctx
},
Handler: LoggingMiddleware(
RecoveryHandler(
http.TimeoutHandler(mux, RequestTimeout, "request timed out"),
),
),
ReadHeaderTimeout: ReadHeaderTimeout,
ReadTimeout: ReadTimeout,
WriteTimeout: WriteTimeout,
IdleTimeout: IdleTimeout,
}
// Create the errorgroup that will manage the server execution
eg, egCtx := errgroup.WithContext(ctx)
// Start the server
eg.Go(func() error {
l.Info("Starting server", slog.String("addr", srv.Addr))
// Use the global context for the server
srv.BaseContext = func(_ net.Listener) context.Context {
return egCtx
}
return srv.ListenAndServe() //nolint:wrapcheck // We wrap the error in the errgroup
})
// Gracefully shutdown the server when the context is done
eg.Go(func() error {
// Wait for the context to be done
<-egCtx.Done()
l.Info("Shutting down server")
// Disable the cancel since we don't wan't to force
// the server to shutdown if the context is canceled.
noCancelCtx := context.WithoutCancel(egCtx)
return srv.Shutdown(noCancelCtx) //nolint:wrapcheck // We wrap the error in the errgroup
})
srv.RegisterOnShutdown(func() {
l.Info("Server shutdown complete")
})
// Ignore the error if the context was canceled
if err := eg.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("server exited with error: %w", err)
}
return nil
}
func RecoveryHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := LoggerFromContext(ctx)
defer func() {
err := recover()
if err != nil {
l.Error("Panic", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}()
next.ServeHTTP(w, r)
})
}

60
main_test.go Normal file
View file

@ -0,0 +1,60 @@
package main_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
finger "git.maronato.dev/maronato/finger"
)
func BenchmarkGetWebfinger(b *testing.B) {
ctx := context.Background()
cfg := &finger.Config{}
l := finger.NewLogger(cfg)
ctx = finger.WithLogger(ctx, l)
resource := "acct:user@example.com"
webmap := finger.WebFingerMap{
resource: {
Subject: resource,
Links: []finger.Link{
{
Rel: "http://webfinger.net/rel/avatar",
Href: "https://example.com/avatar.png",
},
},
Properties: map[string]string{
"example": "value",
},
},
"acct:other": {
Subject: "acct:other",
Links: []finger.Link{
{
Rel: "http://webfinger.net/rel/avatar",
Href: "https://example.com/avatar.png",
},
},
Properties: map[string]string{
"example": "value",
},
},
}
handler := finger.WebfingerHandler(&finger.Config{}, webmap)
r, _ := http.NewRequestWithContext(
ctx,
http.MethodGet,
fmt.Sprintf("/.well-known/webfinger?resource=%s", resource),
http.NoBody,
)
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
}
}

View file

@ -1,16 +1,3 @@
# From https://github.com/konklone/jekyll-webfinger/blob/9bcb46bbabc08363b14fd8b41a944215539d7642/urns.yml
#
# Copyright (c) Eric Mill
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
# * Neither the name of Eric Mill nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# maps string keys to best practice fully qualified URNs
# some references:
@ -22,17 +9,17 @@ name: "http://schema.org/name"
full_name: "http://schema.org/name"
# pictures of people
avatar: "http://webfinger.net/rel/avatar"
picture: "http://webfinger.net/rel/avatar"
photo: "http://webfinger.net/rel/avatar"
avatar: http://webfinger.net/rel/avatar
picture: http://webfinger.net/rel/avatar
photo: http://webfinger.net/rel/avatar
# homepages of people
profile_page: "http://webfinger.net/rel/profile-page"
profile: "http://webfinger.net/rel/profile-page"
website: "http://webfinger.net/rel/profile-page"
url: "http://webfinger.net/rel/profile-page"
homepage: "http://webfinger.net/rel/profile-page"
profile_page: http://webfinger.net/rel/profile-page
profile: http://webfinger.net/rel/profile-page
website: http://webfinger.net/rel/profile-page
url: http://webfinger.net/rel/profile-page
homepage: http://webfinger.net/rel/profile-page
# OpenID Connect
openid: "http://openid.net/specs/connect/1.0/issuer"
open_id: "http://openid.net/specs/connect/1.0/issuer"
openid: http://openid.net/specs/connect/1.0/issuer
open_id: http://openid.net/specs/connect/1.0/issuer

View file

@ -1,94 +0,0 @@
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

@ -1,231 +0,0 @@
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)
}
})
}
}