mirror of
https://github.com/Maronato/go-finger.git
synced 2025-03-15 08:44:46 +00:00
Compare commits
11 commits
Author | SHA1 | Date | |
---|---|---|---|
|
4f05b73bec | ||
|
8673f0db42 | ||
|
de3da93523 | ||
|
e2ea9cd975 | ||
|
7623b16f5a | ||
|
4e695e882d | ||
|
c16b039d3f | ||
|
8ad2fd2fd6 | ||
|
f96dda4af2 | ||
|
6bbfbad1d0 | ||
|
6f4d6e074a |
31 changed files with 2410 additions and 678 deletions
|
@ -25,6 +25,10 @@ COPY Makefile ./
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY main.go ./
|
COPY main.go ./
|
||||||
|
COPY cmd cmd
|
||||||
|
COPY internal internal
|
||||||
|
COPY webfingers webfingers
|
||||||
|
COPY handler handler
|
||||||
|
|
||||||
# Build it
|
# Build it
|
||||||
RUN --mount=type=cache,target=/tmp/.go-build-cache \
|
RUN --mount=type=cache,target=/tmp/.go-build-cache \
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -9,7 +9,7 @@ build:
|
||||||
test:
|
test:
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
|
|
||||||
serve:
|
run:
|
||||||
go run main.go serve
|
go run main.go serve
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|
234
README.md
234
README.md
|
@ -0,0 +1,234 @@
|
||||||
|
# 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.
|
98
cmd/cmd.go
Normal file
98
cmd/cmd.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
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
|
||||||
|
}
|
53
cmd/healthcheck.go
Normal file
53
cmd/healthcheck.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
49
cmd/serve.go
Normal file
49
cmd/serve.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
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
1
go.mod
|
@ -4,7 +4,6 @@ go 1.21.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3
|
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
|
golang.org/x/sync v0.3.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -2,8 +2,6 @@ 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/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 h1:fpyiFVEJvxIFljxM4l5ANSk/UGlM1gyU+hPAr9jhB7M=
|
||||||
github.com/peterbourgon/ff/v4 v4.0.0-alpha.3/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc=
|
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 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|
48
handler/handler.go
Normal file
48
handler/handler.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
176
handler/handler_test.go
Normal file
176
handler/handler_test.go
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
67
internal/config/config.go
Normal file
67
internal/config/config.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
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
|
||||||
|
}
|
124
internal/config/config_test.go
Normal file
124
internal/config/config_test.go
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
89
internal/fingerreader/fingerreader.go
Normal file
89
internal/fingerreader/fingerreader.go
Normal 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
|
||||||
|
}
|
242
internal/fingerreader/fingerreader_test.go
Normal file
242
internal/fingerreader/fingerreader_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
42
internal/log/log.go
Normal file
42
internal/log/log.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
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)
|
||||||
|
}
|
95
internal/log/log_test.go
Normal file
95
internal/log/log_test.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
44
internal/middleware/log.go
Normal file
44
internal/middleware/log.go
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
44
internal/middleware/log_test.go
Normal file
44
internal/middleware/log_test.go
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
27
internal/middleware/recover.go
Normal file
27
internal/middleware/recover.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
76
internal/middleware/recover_test.go
Normal file
76
internal/middleware/recover_test.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
42
internal/middleware/wrapper.go
Normal file
42
internal/middleware/wrapper.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
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
|
||||||
|
}
|
97
internal/middleware/wrapper_test.go
Normal file
97
internal/middleware/wrapper_test.go
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
13
internal/server/healthcheck.go
Normal file
13
internal/server/healthcheck.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
40
internal/server/healthcheck_test.go
Normal file
40
internal/server/healthcheck_test.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
98
internal/server/server.go
Normal file
98
internal/server/server.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
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
|
||||||
|
}
|
206
internal/server/server_test.go
Normal file
206
internal/server/server_test.go
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
557
main.go
557
main.go
|
@ -1,570 +1,19 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/mail"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/peterbourgon/ff/v4"
|
"git.maronato.dev/maronato/finger/cmd"
|
||||||
"github.com/peterbourgon/ff/v4/ffhelp"
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const appName = "finger"
|
// Version of the app.
|
||||||
|
|
||||||
// Version of the application.
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Run the server
|
// Run the server
|
||||||
if err := Run(); err != nil {
|
if err := cmd.Run(version); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run() error {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Allow graceful shutdown
|
|
||||||
trapSignalsCrossPlatform(cancel)
|
|
||||||
|
|
||||||
cfg := &Config{}
|
|
||||||
|
|
||||||
// Create a logger and add it to the context
|
|
||||||
l := NewLogger(cfg)
|
|
||||||
ctx = WithLogger(ctx, l)
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
l := LoggerFromContext(ctx)
|
|
||||||
|
|
||||||
// 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 not already an URN, try to find it in the URNs file
|
|
||||||
if _, err := url.Parse(field); err != nil {
|
|
||||||
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
60
main_test.go
|
@ -1,60 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
33
urns.yml
33
urns.yml
|
@ -1,3 +1,16 @@
|
||||||
|
# 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
|
# maps string keys to best practice fully qualified URNs
|
||||||
|
|
||||||
# some references:
|
# some references:
|
||||||
|
@ -9,17 +22,17 @@ name: "http://schema.org/name"
|
||||||
full_name: "http://schema.org/name"
|
full_name: "http://schema.org/name"
|
||||||
|
|
||||||
# pictures of people
|
# pictures of people
|
||||||
avatar: http://webfinger.net/rel/avatar
|
avatar: "http://webfinger.net/rel/avatar"
|
||||||
picture: http://webfinger.net/rel/avatar
|
picture: "http://webfinger.net/rel/avatar"
|
||||||
photo: http://webfinger.net/rel/avatar
|
photo: "http://webfinger.net/rel/avatar"
|
||||||
|
|
||||||
# homepages of people
|
# homepages of people
|
||||||
profile_page: http://webfinger.net/rel/profile-page
|
profile_page: "http://webfinger.net/rel/profile-page"
|
||||||
profile: http://webfinger.net/rel/profile-page
|
profile: "http://webfinger.net/rel/profile-page"
|
||||||
website: http://webfinger.net/rel/profile-page
|
website: "http://webfinger.net/rel/profile-page"
|
||||||
url: http://webfinger.net/rel/profile-page
|
url: "http://webfinger.net/rel/profile-page"
|
||||||
homepage: http://webfinger.net/rel/profile-page
|
homepage: "http://webfinger.net/rel/profile-page"
|
||||||
|
|
||||||
# OpenID Connect
|
# OpenID Connect
|
||||||
openid: 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
|
open_id: "http://openid.net/specs/connect/1.0/issuer"
|
||||||
|
|
94
webfingers/webfingers.go
Normal file
94
webfingers/webfingers.go
Normal 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
|
||||||
|
}
|
231
webfingers/webfingers_test.go
Normal file
231
webfingers/webfingers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue