Skip to content

Commit

Permalink
feat: add "terminus" -plugin for resolving redirects (#10)
Browse files Browse the repository at this point in the history
* feat: add "terminus" -plugin for resolving redirects

* ci(lint): use golangci-lint-action v4

* feat: allow disabling individual plugins using "isDisabled" configuration option

* feat: make terminus -plugin configurable
  • Loading branch information
Strobotti authored Apr 8, 2024
1 parent a0a64b8 commit a39ce9b
Show file tree
Hide file tree
Showing 11 changed files with 283 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
with:
go-version: '1.21'
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v4
with:
# Require: The version of golangci-lint to use.
# When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version.
Expand Down
20 changes: 20 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ builds:
- -X main.commitSHA={{.FullCommit}}
- -X main.buildDate={{.Date}}

- id: "terminus-plugin"
binary: "plugins/terminus.so"
buildmode: plugin
no_main_check: true
goos:
- linux
goarch:
- amd64
main: ./plugins/terminus/
flags:
- -trimpath
- -buildvcs=false
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commitSHA={{.FullCommit}}
- -X main.buildDate={{.Date}}

nfpms:
- meta: true
package_name: linkquisition
Expand All @@ -53,6 +71,8 @@ nfpms:
dst: /usr/bin/linkquisition
- src: ./dist/unwrap-plugin_{{ .Os }}_{{ .Arch }}_v1/plugins/unwrap.so
dst: /usr/lib/linkquisition/plugins/unwrap.so
- src: ./dist/terminus-plugin_{{ .Os }}_{{ .Arch }}_v1/plugins/terminus.so
dst: /usr/lib/linkquisition/plugins/terminus.so
- src: package/linux
dst: /
type: tree
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Motivation behind this project is:
- regular expression (e.g. `.*\.example\.com`)
- Hide a browser from the list
- Manually add a browser to the list (for example, to open a URL in a different profile)
- Remember the choice for given site
- keyboard-shortcuts
- `Enter` to open the URL in the default browser
- `Ctrl+C` to just copy the URL to clipboard and close the window
Expand Down
1 change: 1 addition & 0 deletions Taskfile.build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ tasks:
cmds:
- mkdir -p package/linux/usr/lib/linkquisition/plugins
- go build -buildmode=plugin -o package/linux/usr/lib/linkquisition/plugins/unwrap.so ./plugins/unwrap/unwrap.go
- go build -buildmode=plugin -o package/linux/usr/lib/linkquisition/plugins/terminus.so ./plugins/terminus/terminus.go

clean:
cmds:
Expand Down
5 changes: 5 additions & 0 deletions cmd/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ func setupPlugins(
var plugins []linkquisition.Plugin

for _, pluginSettings := range settings.Plugins {
if pluginSettings.IsDisabled {
logger.Debug("Plugin is disabled by configuration directive", "plugin", pluginSettings.Path)
continue
}

pluginPath := pluginSettings.Path
if !strings.HasSuffix(pluginPath, ".so") {
pluginPath += ".so"
Expand Down
11 changes: 11 additions & 0 deletions mock/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package mock

import "net/http"

type RoundTripper struct {
RoundTripFunc func(r *http.Request) (*http.Response, error)
}

func (rt *RoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
return rt.RoundTripFunc(r)
}
27 changes: 27 additions & 0 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ In the above example, the `requireBrowserMatchToUnwrap` -setting is set to `true
unwrap the links if there is a matching browser-rule for that URL and all the rest of the URLs are opened with full
Evergreen-protected URL.

## [Terminus](./terminus/terminus.go) -plugin

This plugin can be used to resolve redirects before processing actual rules or showing the browser picker dialog to the
user.

The plugin is configurable in terms of how many redirect-"jumps" to follow and the time-limit how long the requests are
allowed to take before giving up. Here's an example:

```json
{
"browsers": [
...
],
"plugins": [
{
"path": "terminus.so",
"isDisabled": false,
"settings": {
"maxRedirects": 10,
"requestTimeout": "2s"
}
}
]
}

```

## Developing plugins

As stated before, the plugins-feature is experimental, the API is not stable and therefore subject to change. However,
Expand Down
101 changes: 101 additions & 0 deletions plugins/terminus/terminus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package main

import (
"context"
"fmt"
"net/http"
"time"

"github.com/mitchellh/mapstructure"

"github.com/strobotti/linkquisition"
)

type TerminusPluginSettings struct {
MaxRedirects int `json:"maxRedirects"`
RequestTimeout string `json:"requestTimeout"`
}

var _ linkquisition.Plugin = (*terminus)(nil)

type terminus struct {
MaxRedirects int
Client *http.Client
RequestTimeout time.Duration
serviceProvider linkquisition.PluginServiceProvider
}

func (p *terminus) Setup(serviceProvider linkquisition.PluginServiceProvider, config map[string]interface{}) {
p.MaxRedirects = 5
p.RequestTimeout = time.Millisecond * 2000

var settings TerminusPluginSettings
if err := mapstructure.Decode(config, &settings); err != nil {
serviceProvider.GetLogger().Warn("error decoding settings", "error", err.Error(), "plugin", "terminus")
} else {
if settings.MaxRedirects > 0 {
p.MaxRedirects = settings.MaxRedirects
}
if settings.RequestTimeout != "" {
if timeout, err := time.ParseDuration(settings.RequestTimeout); err != nil {
serviceProvider.GetLogger().Warn(
"requestTimeout configuration option is malformed", "error", err.Error(), "plugin",
"terminus",
)
} else {
p.RequestTimeout = timeout
}
}
}

p.serviceProvider = serviceProvider
p.Client = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}

func (p *terminus) ModifyUrl(url string) string {
newUrl := url

ctx, cancel := context.WithTimeout(context.Background(), p.RequestTimeout)
defer cancel()

for i := 0; i < p.MaxRedirects; i++ {
req, _ := http.NewRequestWithContext(ctx, http.MethodHead, newUrl, http.NoBody)
req.Header.Set("User-Agent", "linkquisition")
resp, err := p.Client.Do(req)
if err != nil {
p.serviceProvider.GetLogger().Warn(fmt.Sprintf("error requesting HEAD %s", newUrl), "error", err.Error(), "plugin", "terminus")
return newUrl
}

if resp.Body != nil {
_ = resp.Body.Close()
}

if resp.StatusCode < 300 || resp.StatusCode >= 400 {
// we got a non-redirect response, so we have reached our final destination
break
}

location := resp.Header.Get("Location")

if location == "" {
// for whatever reason the location -header doesn't contain a URL; skip
p.serviceProvider.GetLogger().Warn(fmt.Sprintf("no location-header for HEAD %s", newUrl), "plugin", "terminus")
break
}

p.serviceProvider.GetLogger().Debug(
fmt.Sprintf("following a redirect for %s", newUrl), "location", location, "plugin", "terminus",
)

newUrl = location
}

return newUrl
}

var Plugin terminus
111 changes: 111 additions & 0 deletions plugins/terminus/terminus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package main_test

import (
"log/slog"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/strobotti/linkquisition"

"github.com/strobotti/linkquisition/mock"
. "github.com/strobotti/linkquisition/plugins/terminus"
)

func TestTerminus_ModifyUrl(t *testing.T) {
mockIoWriter := mock.Writer{
WriteFunc: func(p []byte) (n int, err error) {
return len(p), nil
},
}
logger := slog.New(slog.NewTextHandler(mockIoWriter, nil))

for _, tt := range []struct {
name string
inputUrl string
expectedUrl string
locations map[string]string
responseCodes map[string]int
}{
{
name: "original url should be returned if no redirect is detected",
inputUrl: "https://www.example.com/some/thing?here=again",
expectedUrl: "https://www.example.com/some/thing?here=again",
},
{
name: "a url from location -header should be returned if a redirect is detected",
inputUrl: "https://www.example.com/some/thing?here=again",
expectedUrl: "https://www2.example.com/some/thing?here=again",
locations: map[string]string{
"https://www.example.com/some/thing?here=again": "https://www2.example.com/some/thing?here=again",
},
responseCodes: map[string]int{
"https://www.example.com/some/thing?here=again": http.StatusMultipleChoices,
},
},
{
name: "a chain of redirects works as expected",
inputUrl: "https://www.example.com/some/thing?here=again",
expectedUrl: "https://www3.example.com/some/thing?here=again",
locations: map[string]string{
"https://www.example.com/some/thing?here=again": "https://www2.example.com/some/thing?here=again",
"https://www2.example.com/some/thing?here=again": "https://www3.example.com/some/thing?here=again",
},
responseCodes: map[string]int{
"https://www.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www2.example.com/some/thing?here=again": http.StatusMultipleChoices,
},
},
{
name: "a chain of redirects is capped to 5 hops",
inputUrl: "https://www.example.com/some/thing?here=again",
expectedUrl: "https://www6.example.com/some/thing?here=again",
locations: map[string]string{
"https://www.example.com/some/thing?here=again": "https://www2.example.com/some/thing?here=again",
"https://www2.example.com/some/thing?here=again": "https://www3.example.com/some/thing?here=again",
"https://www3.example.com/some/thing?here=again": "https://www4.example.com/some/thing?here=again",
"https://www4.example.com/some/thing?here=again": "https://www5.example.com/some/thing?here=again",
"https://www5.example.com/some/thing?here=again": "https://www6.example.com/some/thing?here=again",
"https://www6.example.com/some/thing?here=again": "https://www7.example.com/some/thing?here=again",
"https://www7.example.com/some/thing?here=again": "https://www8.example.com/some/thing?here=again",
},
responseCodes: map[string]int{
"https://www.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www2.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www3.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www4.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www5.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www6.example.com/some/thing?here=again": http.StatusMultipleChoices,
"https://www7.example.com/some/thing?here=again": http.StatusMultipleChoices,
},
},
} {
t.Run(
tt.name, func(t *testing.T) {
testedPlugin := Plugin

provider := linkquisition.NewPluginServiceProvider(logger, &linkquisition.Settings{})
testedPlugin.Setup(provider, map[string]interface{}{})
testedPlugin.Client.Transport = &mock.RoundTripper{
RoundTripFunc: func(r *http.Request) (*http.Response, error) {
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
}

if code, ok := tt.responseCodes[r.URL.String()]; ok {
resp.StatusCode = code
}
if location, ok := tt.locations[r.URL.String()]; ok {
resp.Header.Set("Location", location)
}

return resp, nil
},
}

assert.Equal(t, tt.expectedUrl, testedPlugin.ModifyUrl(tt.inputUrl))
},
)
}
}
Binary file modified screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ func (s *BrowserSettings) MatchesUrl(u string) bool {

type PluginSettings struct {
// Path is the path to the plugin binary
Path string `json:"path"`
Path string `json:"path"`

// IsDisabled allows temporarily disabling individual plugins
IsDisabled bool `json:"isDisabled"`

Settings map[string]interface{} `json:"settings,omitempty"`
}

Expand Down

0 comments on commit a39ce9b

Please sign in to comment.