Compare commits

...

11 Commits

Author SHA1 Message Date
unkinben b698d1bdc0 perf: memoise regex compilation in the classifier
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Classify runs on every proxied request and recompiled each remote's
pattern lists every time. Cache compiled regexes keyed by pattern text so
each distinct pattern compiles once and is reused thereafter.

Refs #73
2026-07-02 00:33:46 +10:00
unkinben 8d9bc1c422 feat: add bandwidth saved stat to dashboard (#65)
ci/woodpecker/tag/docker Pipeline was successful
Shows total bytes served from cache (instead of upstream) over the last 30 days. Queries `SUM(size_bytes) WHERE cache_hit = TRUE` from access_log.

Reviewed-on: #65
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 22:18:02 +10:00
unkinben 30b7cef026 fix: strip base URL path prefix from helm chart download URLs (#64)
ci/woodpecker/tag/docker Pipeline was successful
When a helm repo base URL includes a path component (e.g. \`stakater.github.io/stakater-charts\`), the merger was extracting the full URL path (\`stakater-charts/reloader-2.2.8.tgz\`) and the proxy then constructed \`base_url/stakater-charts/reloader-2.2.8.tgz\` = double path = 404.

Fix: \`extractPathRelativeToBase()\` strips the shared base path prefix so only the filename portion is used as the proxy path.
Reviewed-on: #64
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 08:02:52 +10:00
unkinben 603be5b989 fix: report actual version instead of hardcoded 3.0.0-dev (#63)
ci/woodpecker/tag/docker Pipeline was successful
The / endpoint was hardcoded to return 3.0.0-dev. Now uses the git tag version set via ldflags at build time.

Reviewed-on: #63
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:51:26 +10:00
unkinben 9eba49500c feat: forward Accept header and fix Content-Type for Docker proxying (#62)
## Problems
1. Docker daemon sends specific Accept headers to negotiate manifest format, but the proxy dropped them — registries defaulted to OCI format, causing "mediaType should be manifest.v2+json not oci.image.index" errors
2. Upstream Content-Type was only used when the provider returned "application/octet-stream" — Docker manifests got the wrong Content-Type

## Fixes
- Forward client Accept header to upstream (both initial request and Bearer token retry)
- Always prefer upstream Content-Type when present
- Fetch signature now accepts variadic clientHeaders for backwards compat

## E2E tested
- DockerHub: redis:7-alpine, alpine:3 — skopeo inspect OK
- GHCR: OCI-only images work with docker pull (GHCR 404s Docker v2 Accept, which is expected)
- Quay: prometheus/node-exporter — skopeo inspect OK

Reviewed-on: #62
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:45:23 +10:00
unkinben 0083d67272 fix: nginx config for UI serving under base path (#61)
Vite's \`base: /ui\` makes HTML reference \`/ui/assets/...\` but files are at \`/usr/share/nginx/html/assets/\` (no \`ui/\` subdir). The previous \`location /ui { try_files ... }\` couldn't find the files.

Fix: rewrite strips the base path prefix before try_files, so \`/ui/assets/foo.js\` resolves to \`/usr/share/nginx/html/assets/foo.js\`.
Reviewed-on: #61
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:43:45 +10:00
unkinben 8ec7de50e3 feat: handle Docker Bearer token auth for upstream registries (#60)
ci/woodpecker/tag/docker Pipeline was successful
Docker Hub (and other registries) return 401 with a `Www-Authenticate: Bearer realm=...` challenge even for public images. The proxy now:

1. Detects 401 + Bearer challenge
2. Parses realm/service/scope from the header
3. Fetches an anonymous token (or authenticated if username/password configured)
4. Retries the original request with the Bearer token

Fixes: `docker pull artifactapi.../dockerhub/library/redis:latest` returning "unauthorized: upstream returned 401"
Reviewed-on: #60
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:18:06 +10:00
unkinben 9c465cbd4c fix: use map format for docker-buildx build_args (#59)
The woodpecker docker-buildx plugin expects build_args as a YAML map (KEY: VALUE), not a list (- KEY=VALUE). The list format was silently ignored, so BASE_PATH was never passed to the Docker build.

Reviewed-on: #59
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:12:34 +10:00
unkinben ee6e581b9d feat: configurable UI base path via BASE_PATH build arg (#58)
ci/woodpecker/tag/docker Pipeline was successful
Serves the UI under /ui instead of /. This pairs with the argocd route simplification (argocd-apps#201) where /ui → UI service and everything else → API.

- Vite: `base` set from `BASE_PATH` env var at build time
- React Router: `basename` set from injected `__BASE_PATH__`
- Nginx: location block uses `${BASE_PATH}`, substituted by sed at build
- Dockerfile: `ARG BASE_PATH=/` (default preserves existing behavior)
- Woodpecker: passes `BASE_PATH=/ui` to docker-web build

Tested: assets serve at `/ui/assets/...`, SPA routing works at `/ui/remotes`, etc.
Reviewed-on: #58
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-26 23:50:17 +10:00
unkinben 2a8e544de3 feat: add Docker Registry V2 endpoint at /v2/ (#57)
The v3 Go rewrite removed the /v2/ Docker Registry compatibility endpoint. Docker clients need:
- GET/HEAD /v2/ → 200 (registry ping)
- GET/HEAD /v2/{remoteName}/* → proxy to the docker remote

Usage: `docker pull artifactapi.example.com/{remoteName}/image:tag`
Reviewed-on: #57
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-26 23:37:52 +10:00
unkinben 847eeb839f fix: don't rewrite helm chart URLs pointing to a different host (#56)
## Problem
Helm charts like Intel device plugins have download URLs on `github.com` but the chart index is served from `intel.github.io`. The merger rewrites all URLs through the proxy, constructing:
```
https://artifactapi/api/v1/remote/intel-helm/intel/helm-charts/releases/download/...
```
Which proxies to `https://intel.github.io/helm-charts/intel/helm-charts/releases/download/...` — a 404.

## Fix
Compare the download URL host against the remote's base URL host. If they differ, leave the URL as-is so helm downloads directly from the source. Same-host URLs are still rewritten through the proxy.

Also adds `BaseURL` to `MemberIndex` so the merger has the context it needs, and uses the correct `/local/` vs `/remote/` route prefix.

Reviewed-on: #56
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-26 23:34:00 +10:00
17 changed files with 233 additions and 44 deletions
+4
View File
@@ -8,6 +8,8 @@ steps:
settings: settings:
registry: git.unkin.net registry: git.unkin.net
repo: git.unkin.net/unkin/artifactapi repo: git.unkin.net/unkin/artifactapi
build_args:
VERSION: ${CI_COMMIT_TAG}
username: droneci username: droneci
password: password:
from_secret: DRONECI_PASSWORD from_secret: DRONECI_PASSWORD
@@ -22,6 +24,8 @@ steps:
repo: git.unkin.net/unkin/artifactapi-ui repo: git.unkin.net/unkin/artifactapi-ui
dockerfile: ui/Dockerfile.ui dockerfile: ui/Dockerfile.ui
context: ui context: ui
build_args:
BASE_PATH: /ui
username: droneci username: droneci
password: password:
from_secret: DRONECI_PASSWORD from_secret: DRONECI_PASSWORD
+2 -1
View File
@@ -9,7 +9,8 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o artifactapi ./cmd/artifactapi ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o artifactapi ./cmd/artifactapi
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
+1 -1
View File
@@ -12,7 +12,7 @@ check-go:
fi fi
build: check-go tidy build: check-go tidy
go build -ldflags="-s -w" -o $(BINARY) ./cmd/artifactapi go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) ./cmd/artifactapi
test: check-go test: check-go
go test -race -count=1 ./pkg/... ./internal/... go test -race -count=1 ./pkg/... ./internal/...
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"git.unkin.net/unkin/artifactapi/internal/tui" "git.unkin.net/unkin/artifactapi/internal/tui"
) )
var version = "dev"
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "tui" { if len(os.Args) > 1 && os.Args[1] == "tui" {
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT") endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
@@ -42,7 +44,7 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
srv, err := server.New(cfg) srv, err := server.New(cfg, version)
if err != nil { if err != nil {
slog.Error("failed to create server", "error", err) slog.Error("failed to create server", "error", err)
os.Exit(1) os.Exit(1)
+15 -1
View File
@@ -37,6 +37,20 @@ func (h *ProxyHandler) Routes() chi.Router {
return r return r
} }
func (h *ProxyHandler) DockerV2Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.handleDockerPing)
r.Head("/", h.handleDockerPing)
r.Get("/{remoteName}/*", h.handleProxy)
r.Head("/{remoteName}/*", h.handleProxy)
return r
}
func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
w.WriteHeader(http.StatusOK)
}
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
remoteName := chi.URLParam(r, "remoteName") remoteName := chi.URLParam(r, "remoteName")
path := chi.URLParam(r, "*") path := chi.URLParam(r, "*")
@@ -53,7 +67,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := h.engine.Fetch(r.Context(), *remote, path, prov) result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
if err != nil { if err != nil {
var proxyErr *proxy.ProxyError var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) { if errors.As(err, &proxyErr) {
+9
View File
@@ -30,6 +30,15 @@ func (db *DB) GetOverviewStats(ctx context.Context) (*models.OverviewStats, erro
return nil, err return nil, err
} }
err = db.Pool.QueryRow(ctx, `
SELECT COALESCE(SUM(size_bytes), 0)
FROM access_log
WHERE cache_hit = TRUE AND created_at > NOW() - INTERVAL '30 days'
`).Scan(&stats.BandwidthSaved30d)
if err != nil {
return nil, err
}
return &stats, nil return &stats, nil
} }
+21 -1
View File
@@ -2,6 +2,7 @@ package proxy
import ( import (
"regexp" "regexp"
"sync"
"git.unkin.net/unkin/artifactapi/internal/provider" "git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models" "git.unkin.net/unkin/artifactapi/pkg/models"
@@ -60,10 +61,29 @@ func (c *Classifier) Classify(remote models.Remote, path string) Classification
return ClassImmutable return ClassImmutable
} }
// patternCache memoises regex compilation. Classify runs on every proxied
// request and previously recompiled each remote's pattern lists every time;
// keying by the pattern string lets each distinct pattern compile once and
// then be reused, with no invalidation needed (the pattern text is the key).
// A pattern that fails to compile is cached as a typed nil so we don't retry.
var patternCache sync.Map // map[string]*regexp.Regexp
func compileCached(pattern string) *regexp.Regexp {
if v, ok := patternCache.Load(pattern); ok {
return v.(*regexp.Regexp)
}
re, err := regexp.Compile(pattern)
if err != nil {
re = nil
}
patternCache.Store(pattern, re)
return re
}
func compilePatterns(patterns []string) []*regexp.Regexp { func compilePatterns(patterns []string) []*regexp.Regexp {
compiled := make([]*regexp.Regexp, 0, len(patterns)) compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns { for _, p := range patterns {
if re, err := regexp.Compile(p); err == nil { if re := compileCached(p); re != nil {
compiled = append(compiled, re) compiled = append(compiled, re)
} }
} }
+101 -4
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"git.unkin.net/unkin/artifactapi/internal/cache" "git.unkin.net/unkin/artifactapi/internal/cache"
@@ -42,7 +44,7 @@ type FetchResult struct {
Source string // "cache" or "remote" Source string // "cache" or "remote"
} }
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*FetchResult, error) { func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider, clientHeaders ...http.Header) (*FetchResult, error) {
classifier := NewClassifier(prov) classifier := NewClassifier(prov)
class := classifier.Classify(remote, path) class := classifier.Classify(remote, path)
@@ -103,8 +105,13 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
} }
} }
var fwdHeaders http.Header
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
fwdHeaders = clientHeaders[0]
}
start := time.Now() start := time.Now()
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl) result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
upstreamMS := int(time.Since(start).Milliseconds()) upstreamMS := int(time.Since(start).Milliseconds())
if err != nil { if err != nil {
if remote.StaleOnError && isNetworkError(err) { if remote.StaleOnError && isNetworkError(err) {
@@ -124,7 +131,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
return result, nil return result, nil
} }
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration) (*FetchResult, error) { func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration, clientHeaders http.Header) (*FetchResult, error) {
url := prov.UpstreamURL(remote, path) url := prov.UpstreamURL(remote, path)
authHeaders, err := prov.AuthHeaders(ctx, remote) authHeaders, err := prov.AuthHeaders(ctx, remote)
@@ -141,12 +148,37 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
req.Header.Add(k, v) req.Header.Add(k, v)
} }
} }
if clientHeaders != nil {
if accept := clientHeaders.Get("Accept"); accept != "" {
req.Header.Set("Accept", accept)
}
}
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, &UpstreamError{Err: err} return nil, &UpstreamError{Err: err}
} }
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
token, err := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote)
if err == nil && token != "" {
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req2.Header.Set("Authorization", "Bearer "+token)
if clientHeaders != nil {
if accept := clientHeaders.Get("Accept"); accept != "" {
req2.Header.Set("Accept", accept)
}
}
resp, err = http.DefaultClient.Do(req2)
if err != nil {
return nil, &UpstreamError{Err: err}
}
} else {
return nil, &ProxyError{Status: http.StatusUnauthorized, Message: "upstream returned 401"}
}
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
resp.Body.Close() resp.Body.Close()
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)} return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
@@ -167,7 +199,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
} }
contentType := prov.ContentType(path) contentType := prov.ContentType(path)
if ct := resp.Header.Get("Content-Type"); ct != "" && contentType == "application/octet-stream" { if ct := resp.Header.Get("Content-Type"); ct != "" {
contentType = ct contentType = ct
} }
@@ -319,6 +351,71 @@ func (r readerAt) ReadAt(p []byte, off int64) (n int, err error) {
return return
} }
func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) {
if !strings.HasPrefix(wwwAuth, "Bearer ") {
return "", fmt.Errorf("not a Bearer challenge")
}
params := map[string]string{}
for _, part := range strings.Split(wwwAuth[7:], ",") {
part = strings.TrimSpace(part)
eq := strings.Index(part, "=")
if eq < 0 {
continue
}
key := part[:eq]
val := strings.Trim(part[eq+1:], `"`)
params[key] = val
}
realm := params["realm"]
if realm == "" {
return "", fmt.Errorf("no realm in Bearer challenge")
}
tokenURL := realm
sep := "?"
if s, ok := params["service"]; ok {
tokenURL += sep + "service=" + s
sep = "&"
}
if s, ok := params["scope"]; ok {
tokenURL += sep + "scope=" + s
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", err
}
if remote.Username != "" && remote.Password != "" {
req.SetBasicAuth(remote.Username, remote.Password)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d", resp.StatusCode)
}
var tokenResp struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", err
}
if tokenResp.Token != "" {
return tokenResp.Token, nil
}
return tokenResp.AccessToken, nil
}
type ProxyError struct { type ProxyError struct {
Status int Status int
Message string Message string
+5 -2
View File
@@ -35,6 +35,7 @@ import (
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
version string
router chi.Router router chi.Router
db *database.DB db *database.DB
cache *cache.Redis cache *cache.Redis
@@ -45,7 +46,7 @@ type Server struct {
gc *gc.Collector gc *gc.Collector
} }
func New(cfg *config.Config) (*Server, error) { func New(cfg *config.Config, version string) (*Server, error) {
db, err := database.New(cfg.DatabaseDSN()) db, err := database.New(cfg.DatabaseDSN())
if err != nil { if err != nil {
return nil, fmt.Errorf("database: %w", err) return nil, fmt.Errorf("database: %w", err)
@@ -68,6 +69,7 @@ func New(cfg *config.Config) (*Server, error) {
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
version: version,
db: db, db: db,
cache: redis, cache: redis,
store: s3, store: s3,
@@ -96,6 +98,7 @@ func (s *Server) routes() chi.Router {
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler) proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
r.Mount("/api/v1", proxyHandler.Routes()) r.Mount("/api/v1", proxyHandler.Routes())
r.Mount("/v2", proxyHandler.DockerV2Routes())
remotesHandler := v2.NewRemotesHandler(s.db) remotesHandler := v2.NewRemotesHandler(s.db)
virtualsHandler := v2.NewVirtualsHandler(s.db) virtualsHandler := v2.NewVirtualsHandler(s.db)
@@ -137,7 +140,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"name":"artifactapi","version":"3.0.0-dev"}`) fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
} }
func (s *Server) newHTTPServer() *http.Server { func (s *Server) newHTTPServer() *http.Server {
+2 -2
View File
@@ -79,7 +79,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)} results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
return return
} }
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}} results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
return return
} }
@@ -102,7 +102,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
return return
} }
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}} results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
}(i, memberName) }(i, memberName)
} }
+40 -3
View File
@@ -54,15 +54,27 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
seen[chart][ver.Version] = true seen[chart][ver.Version] = true
if proxyBaseURL != "" { if proxyBaseURL != "" {
routePrefix := "remote"
if member.RepoType == "local" {
routePrefix = "local"
}
baseHost := extractHost(member.BaseURL)
for i, u := range ver.URLs { for i, u := range ver.URLs {
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s", if baseHost != "" && extractHost(u) != baseHost {
continue
}
relPath := extractPathRelativeToBase(u, member.BaseURL)
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
strings.TrimRight(proxyBaseURL, "/"), strings.TrimRight(proxyBaseURL, "/"),
routePrefix,
member.RemoteName, member.RemoteName,
extractPath(u)) relPath)
} else { } else {
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s", ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
strings.TrimRight(proxyBaseURL, "/"), strings.TrimRight(proxyBaseURL, "/"),
routePrefix,
member.RemoteName, member.RemoteName,
u) u)
} }
@@ -78,6 +90,31 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
return yaml.Marshal(merged) return yaml.Marshal(merged)
} }
func extractHost(rawURL string) string {
idx := strings.Index(rawURL, "://")
if idx == -1 {
return ""
}
rest := rawURL[idx+3:]
slashIdx := strings.Index(rest, "/")
if slashIdx == -1 {
return rest
}
return rest[:slashIdx]
}
func extractPathRelativeToBase(rawURL, baseURL string) string {
fullPath := extractPath(rawURL)
basePath := extractPath(baseURL)
if basePath != "" {
basePath = strings.TrimRight(basePath, "/") + "/"
if strings.HasPrefix(fullPath, basePath) {
return fullPath[len(basePath):]
}
}
return fullPath
}
func extractPath(rawURL string) string { func extractPath(rawURL string) string {
idx := strings.Index(rawURL, "://") idx := strings.Index(rawURL, "://")
if idx == -1 { if idx == -1 {
+1
View File
@@ -9,6 +9,7 @@ import (
type MemberIndex struct { type MemberIndex struct {
RemoteName string RemoteName string
RepoType models.RepoType RepoType models.RepoType
BaseURL string
Body []byte Body []byte
} }
+7
View File
@@ -6,13 +6,20 @@ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ARG BASE_PATH=/
ENV BASE_PATH=${BASE_PATH}
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
ARG BASE_PATH=/
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN sed -i "s|\${BASE_PATH}|${BASE_PATH}|g" /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+6 -27
View File
@@ -5,33 +5,12 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location /api/ { location ${BASE_PATH}/ {
proxy_pass http://artifactapi:8000; rewrite ^${BASE_PATH}(/.*)$ $1 break;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location /v2/ {
proxy_pass http://artifactapi:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location /health {
proxy_pass http://artifactapi:8000;
}
location /metrics {
proxy_pass http://artifactapi:8000;
}
location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = ${BASE_PATH} {
return 301 ${BASE_PATH}/;
}
} }
+5 -1
View File
@@ -4,9 +4,13 @@ import { BrowserRouter } from 'react-router-dom';
import { App } from './App'; import { App } from './App';
import './index.css'; import './index.css';
declare const __BASE_PATH__: string;
const basename = __BASE_PATH__.replace(/\/+$/, '') || '/';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter basename={basename}>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
+5
View File
@@ -50,6 +50,11 @@ export function Dashboard() {
value={formatNumber(stats.total_blobs_deduped)} value={formatNumber(stats.total_blobs_deduped)}
sub="shared blobs" sub="shared blobs"
/> />
<StatsCard
label="Bandwidth Saved"
value={formatBytes(stats.bandwidth_saved_30d)}
sub="last 30 days"
/>
</div> </div>
{health && ( {health && (
+6
View File
@@ -1,7 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const basePath = process.env.BASE_PATH || '/'
export default defineConfig({ export default defineConfig({
base: basePath,
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
@@ -11,4 +14,7 @@ export default defineConfig({
'/metrics': 'http://localhost:8000', '/metrics': 'http://localhost:8000',
}, },
}, },
define: {
'__BASE_PATH__': JSON.stringify(basePath),
},
}) })