Skip to content

Commit ab07da1

Browse files
authored
feat: support gitlab redirects (#775) (#777) (#778)
1 parent 30f3ca1 commit ab07da1

File tree

7 files changed

+482
-3
lines changed

7 files changed

+482
-3
lines changed

cmd/gateway/main.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/SwissDataScienceCenter/renku-gateway/internal/db"
1717
"github.com/SwissDataScienceCenter/renku-gateway/internal/login"
1818
"github.com/SwissDataScienceCenter/renku-gateway/internal/metrics"
19+
"github.com/SwissDataScienceCenter/renku-gateway/internal/redirects"
1920
"github.com/SwissDataScienceCenter/renku-gateway/internal/revproxy"
2021
"github.com/SwissDataScienceCenter/renku-gateway/internal/sessions"
2122
"github.com/SwissDataScienceCenter/renku-gateway/internal/tokenstore"
@@ -117,8 +118,16 @@ func main() {
117118
}
118119
// Add the session store to the common middlewares
119120
gwMiddlewares := append(commonMiddlewares, sessionStore.Middleware())
121+
// Create redirect store
122+
redirectStore, err := redirects.NewRedirectStore(
123+
redirects.WithConfig(gwConfig.Redirects),
124+
)
125+
if err != nil {
126+
slog.Error("failed to initialize redirect store", "error", err)
127+
os.Exit(1)
128+
}
120129
// Initialize the reverse proxy
121-
revproxy, err := revproxy.NewServer(revproxy.WithConfig(gwConfig.Revproxy), revproxy.WithSessionStore(sessionStore))
130+
revproxy, err := revproxy.NewServer(revproxy.WithConfig(gwConfig.Revproxy), revproxy.WithSessionStore(sessionStore), revproxy.WithRedirectsStore(redirectStore))
122131
if err != nil {
123132
slog.Error("revproxy handlers initialization failed", "error", err)
124133
os.Exit(1)

internal/config/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type Config struct {
55
DebugMode bool
66
Server ServerConfig
77
Sessions SessionConfig
8+
Redirects RedirectsStoreConfig
89
Revproxy RevproxyConfig
910
Login LoginConfig
1011
Redis RedisConfig

internal/config/redirects.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
)
7+
8+
type GitlabRedirectsConfig struct {
9+
Enabled bool
10+
RenkuBaseURL *url.URL
11+
RedirectedHost string
12+
EntryTtlSeconds int
13+
}
14+
type RedirectsStoreConfig struct {
15+
Gitlab GitlabRedirectsConfig
16+
}
17+
18+
func (r *RedirectsStoreConfig) Validate() error {
19+
if r.Gitlab.Enabled && r.Gitlab.RenkuBaseURL == nil {
20+
return fmt.Errorf("the redirects store is enabled but the config is missing the base url for Renku")
21+
}
22+
23+
if r.Gitlab.EntryTtlSeconds <= 0 {
24+
r.Gitlab.EntryTtlSeconds = 60 * 5 // default to 5 minutes
25+
}
26+
27+
return nil
28+
}

internal/config/redirects_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package config
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func getValidRedirectsConfig(t *testing.T) RedirectsStoreConfig {
12+
renkuBaseURL, err := url.Parse("https://renku.example.org")
13+
require.NoError(t, err)
14+
return RedirectsStoreConfig{
15+
Gitlab: GitlabRedirectsConfig{
16+
Enabled: true,
17+
RenkuBaseURL: renkuBaseURL,
18+
RedirectedHost: "gitlab.example.org",
19+
},
20+
}
21+
}
22+
23+
func TestValidRedirectsConfig(t *testing.T) {
24+
config := getValidRedirectsConfig(t)
25+
26+
err := config.Validate()
27+
28+
assert.NoError(t, err)
29+
}
30+
31+
func TestInvalidRedirectsConfig(t *testing.T) {
32+
config := getValidRedirectsConfig(t)
33+
config.Gitlab.RenkuBaseURL = nil
34+
35+
err := config.Validate()
36+
37+
assert.ErrorContains(t, err, "the redirects store is enabled but the config is missing the base url for Renku")
38+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package redirects
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
"github.com/SwissDataScienceCenter/renku-gateway/internal/config"
16+
"github.com/labstack/echo/v4"
17+
)
18+
19+
type PlatformRedirectConfig struct {
20+
SourceUrl string `json:"source_url"`
21+
TargetUrl string `json:"target_url"`
22+
}
23+
24+
type RedirectStoreRedirectEntry struct {
25+
SourceUrl string
26+
TargetUrl string
27+
UpdatedAt time.Time
28+
}
29+
30+
var noRedirectFound = RedirectStoreRedirectEntry{}
31+
32+
type RedirectStore struct {
33+
Config config.RedirectsStoreConfig
34+
PathPrefix string
35+
36+
entryTtl time.Duration
37+
redirectMap map[string]RedirectStoreRedirectEntry
38+
redirectedHost string
39+
redirectMapMutex sync.RWMutex
40+
}
41+
42+
type RedirectStoreOption func(*RedirectStore) error
43+
44+
func WithConfig(cfg config.RedirectsStoreConfig) RedirectStoreOption {
45+
return func(rs *RedirectStore) error {
46+
rs.Config = cfg
47+
return nil
48+
}
49+
}
50+
51+
func queryRenkuApi(ctx context.Context, host url.URL, endpoint string) ([]byte, error) {
52+
53+
rel, err := url.Parse("/api/data")
54+
if err != nil {
55+
return nil, fmt.Errorf("error parsing endpoint: %w", err)
56+
}
57+
rel = rel.JoinPath(endpoint)
58+
fullUrl := host.ResolveReference(rel).String()
59+
req, err := http.NewRequestWithContext(ctx, "GET", fullUrl, nil)
60+
if err != nil {
61+
return nil, fmt.Errorf("error creating request: %w", err)
62+
}
63+
req.Header.Set("Accept", "application/json")
64+
resp, err := http.DefaultClient.Do(req)
65+
if err != nil {
66+
return nil, fmt.Errorf("error fetching migrated projects: %w", err)
67+
}
68+
defer resp.Body.Close()
69+
70+
if resp.StatusCode != 200 {
71+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
72+
}
73+
74+
body, err := io.ReadAll(resp.Body)
75+
if err != nil {
76+
return nil, fmt.Errorf("error reading response body: %w", err)
77+
}
78+
return body, nil
79+
}
80+
81+
func retrieveRedirectTargetForSource(ctx context.Context, host url.URL, source string) (*PlatformRedirectConfig, error) {
82+
// Query the Renku API to get the redirect for the given source URL
83+
body, err := queryRenkuApi(ctx, host, fmt.Sprintf("/platform/redirects/%s", source))
84+
if err != nil {
85+
return nil, fmt.Errorf("error querying Renku API: %w", err)
86+
}
87+
if body == nil {
88+
return nil, fmt.Errorf("no response body received")
89+
}
90+
91+
var redirectConfig PlatformRedirectConfig
92+
if err := json.Unmarshal(body, &redirectConfig); err != nil {
93+
return nil, fmt.Errorf("error parsing JSON response: %w", err)
94+
}
95+
96+
return &redirectConfig, nil
97+
}
98+
99+
func (rs *RedirectStore) urlToKey(redirectUrl url.URL) (string, error) {
100+
101+
path := redirectUrl.Path
102+
if path == "" || !strings.HasPrefix(path, rs.PathPrefix) {
103+
return "", fmt.Errorf("the path should start with the prefix %s", rs.PathPrefix)
104+
}
105+
106+
urlToCheck := strings.TrimPrefix(path, rs.PathPrefix)
107+
// TODO: Check for a `/-/` in the path and remove it and anything that follows (links to sub-pages of a project)
108+
urlToCheck = fmt.Sprintf("https://%s/%s", rs.redirectedHost, urlToCheck)
109+
// URL-encode the full URL so it can be safely used in the API path
110+
urlToCheck = url.QueryEscape(urlToCheck)
111+
// check for redirects for this URL
112+
return urlToCheck, nil
113+
}
114+
115+
func (rs *RedirectStore) GetRedirectEntry(ctx context.Context, url url.URL) (*RedirectStoreRedirectEntry, error) {
116+
key, err := rs.urlToKey(url)
117+
if err != nil {
118+
return nil, fmt.Errorf("error converting url to key: %w", err)
119+
}
120+
121+
rs.redirectMapMutex.RLock()
122+
entry, ok := rs.redirectMap[key]
123+
rs.redirectMapMutex.RUnlock()
124+
if ok && entry.UpdatedAt.Add(rs.entryTtl).After(time.Now()) {
125+
return &entry, nil
126+
}
127+
128+
rs.redirectMapMutex.Lock()
129+
defer rs.redirectMapMutex.Unlock()
130+
// Re-check after acquiring the lock, since it might have been updated meanwhile
131+
entry, ok = rs.redirectMap[key]
132+
if !ok || entry.UpdatedAt.Add(rs.entryTtl).Before(time.Now()) {
133+
updatedEntry, err := retrieveRedirectTargetForSource(ctx, *rs.Config.Gitlab.RenkuBaseURL, key)
134+
if err != nil {
135+
return nil, fmt.Errorf("error retrieving redirect for url %s: %w", key, err)
136+
}
137+
if updatedEntry == nil {
138+
// No entry, this is fine
139+
return &noRedirectFound, nil
140+
}
141+
entry = RedirectStoreRedirectEntry{
142+
SourceUrl: updatedEntry.SourceUrl,
143+
TargetUrl: updatedEntry.TargetUrl,
144+
UpdatedAt: time.Now(),
145+
}
146+
rs.redirectMap[key] = entry
147+
}
148+
return &entry, nil
149+
}
150+
151+
func (rs *RedirectStore) Middleware() echo.MiddlewareFunc {
152+
return func(next echo.HandlerFunc) echo.HandlerFunc {
153+
return func(c echo.Context) error {
154+
redirectUrl := c.Request().URL
155+
if redirectUrl == nil {
156+
return next(c)
157+
}
158+
ctx := c.Request().Context()
159+
// check for redirects for this URL
160+
entry, err := rs.GetRedirectEntry(ctx, *redirectUrl)
161+
162+
if err != nil {
163+
slog.Debug(
164+
"REDIRECT_STORE MIDDLEWARE",
165+
"message",
166+
"could not lookup redirect entry, returning 404",
167+
"url",
168+
redirectUrl.String(),
169+
"error",
170+
err.Error(),
171+
)
172+
return c.NoContent(http.StatusNotFound)
173+
}
174+
if entry == nil {
175+
slog.Debug(
176+
"REDIRECT_STORE MIDDLEWARE",
177+
"message", "nil redirect found for url (this should not happen), returning 404",
178+
"from", redirectUrl.String(),
179+
)
180+
return c.NoContent(http.StatusNotFound)
181+
}
182+
if entry == &noRedirectFound {
183+
slog.Debug(
184+
"REDIRECT_STORE MIDDLEWARE",
185+
"message", "no redirect found for url, returning 404",
186+
"from", redirectUrl.String(),
187+
)
188+
return c.NoContent(http.StatusNotFound)
189+
}
190+
slog.Debug(
191+
"REDIRECT_STORE MIDDLEWARE",
192+
"message", "redirecting request",
193+
"from", redirectUrl.String(),
194+
"to", entry.TargetUrl,
195+
)
196+
return c.Redirect(http.StatusMovedPermanently, entry.TargetUrl)
197+
}
198+
}
199+
}
200+
201+
func NewRedirectStore(options ...RedirectStoreOption) (*RedirectStore, error) {
202+
rs := RedirectStore{redirectMap: make(map[string]RedirectStoreRedirectEntry), PathPrefix: "/api/gitlab-redirect/", redirectMapMutex: sync.RWMutex{}}
203+
for _, opt := range options {
204+
err := opt(&rs)
205+
if err != nil {
206+
return &RedirectStore{}, err
207+
}
208+
}
209+
210+
if !rs.Config.Gitlab.Enabled {
211+
return nil, nil
212+
}
213+
214+
if rs.Config.Gitlab.RenkuBaseURL == nil {
215+
return &RedirectStore{}, fmt.Errorf("a RenkuBaseURL must be provided")
216+
}
217+
218+
rs.redirectedHost = rs.Config.Gitlab.RedirectedHost
219+
if rs.redirectedHost == "" {
220+
rs.redirectedHost = "gitlab.renkulab.io"
221+
}
222+
223+
rs.entryTtl = time.Duration(rs.Config.Gitlab.EntryTtlSeconds) * time.Second
224+
225+
return &rs, nil
226+
}

0 commit comments

Comments
 (0)