Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67cedf9bba | |||
| 8d9bc1c422 | |||
| 30b7cef026 | |||
| 603be5b989 | |||
| 9eba49500c | |||
| 0083d67272 |
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/...
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
# Authentication & Authorization — Design
|
||||||
|
|
||||||
|
Status: **proposed** (tracking issue #79)
|
||||||
|
|
||||||
|
Today ArtifactAPI has no authentication: every proxy and management request is
|
||||||
|
served unconditionally. This document describes an auth/authz system that adds
|
||||||
|
identity and path-scoped authorization **without changing behaviour until an
|
||||||
|
operator turns enforcement on** — the default policy is fully open.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Identify callers as one of two principal kinds: **service accounts** and **users**.
|
||||||
|
- Authorize each request against a **path + capability** ACL model.
|
||||||
|
- Let **Vault/OpenBao** mint short-lived tokens so the Terraform provider can get
|
||||||
|
just-in-time credentials to make config changes.
|
||||||
|
- Ship **default-open**: an unconfigured deployment behaves exactly as today.
|
||||||
|
|
||||||
|
## Non-goals (initial phase)
|
||||||
|
|
||||||
|
- Per-object encryption, signing, or content trust.
|
||||||
|
- Rate limiting / quotas (separate concern).
|
||||||
|
- Multi-tenancy beyond what path ACLs express.
|
||||||
|
|
||||||
|
## Principals
|
||||||
|
|
||||||
|
| Kind | Authenticates with | Created by | Lifetime |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Service account — static token | `Authorization: Bearer <token>` | admin via management API | until revoked |
|
||||||
|
| Service account — dynamic token | `Authorization: Bearer <token>` | Vault secrets engine → mint endpoint | lease TTL (auto-revoked) |
|
||||||
|
| User | UI session cookie (OIDC/LDAP login) | external IdP, first-seen on login | session TTL |
|
||||||
|
|
||||||
|
A **service account** is a named identity holding a set of ACL grants. It may
|
||||||
|
have any number of associated tokens (static, or dynamic ones minted by Vault).
|
||||||
|
A **user** is an identity resolved from an external IdP; group membership from
|
||||||
|
the IdP maps to ACL grants.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
- Format: `aapi_<base62(32 random bytes)>`. The `aapi_` prefix makes tokens
|
||||||
|
greppable and lets us reject obviously-malformed values cheaply.
|
||||||
|
- Storage: only the **SHA-256 of the token** is stored, never the plaintext.
|
||||||
|
Lookup hashes the presented token and matches by hash.
|
||||||
|
- Each token row carries: id, principal (service account) ref, sha256, optional
|
||||||
|
label, `expires_at` (null = non-expiring), `created_at`, `last_used_at`.
|
||||||
|
- Revocation: delete the row (static) or Vault lease revoke → mint endpoint
|
||||||
|
revoke (dynamic).
|
||||||
|
|
||||||
|
## ACL model
|
||||||
|
|
||||||
|
A grant is `(path_pattern, capability)`. A principal is allowed an action iff at
|
||||||
|
least one of its grants matches the request's resource path and capability.
|
||||||
|
|
||||||
|
### Resource paths
|
||||||
|
|
||||||
|
```
|
||||||
|
remote/<remote-name>/<path-in-remote> # proxy + local repo objects
|
||||||
|
virtual/<virtual-name>/<path> # virtual repo reads
|
||||||
|
admin/remotes/<remote-name> # manage a remote definition
|
||||||
|
admin/virtuals/<virtual-name> # manage a virtual definition
|
||||||
|
admin/principals/<name> # manage service accounts / tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Patterns support a trailing `*` wildcard and `<segment>/*` prefixes, e.g.
|
||||||
|
`remote/dockerhub/*`, `remote/*`, `admin/*`. Matching is longest-prefix by
|
||||||
|
segment; an exact match always wins over a wildcard.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
| Capability | Meaning for `remote/...` | Meaning for `admin/...` |
|
||||||
|
|---|---|---|
|
||||||
|
| `read` | GET/HEAD an artifact | GET a definition |
|
||||||
|
| `create` | first upload of a new local file | create a new definition |
|
||||||
|
| `write` | overwrite / re-publish | update an existing definition |
|
||||||
|
| `delete` | remove an object | delete a definition |
|
||||||
|
|
||||||
|
The HTTP layer maps each route to `(resource path, capability)`:
|
||||||
|
|
||||||
|
| Route | Resource | Capability |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/v1/remote/{r}/*`, `/v2/{r}/*` | `remote/{r}/{path}` | `read` |
|
||||||
|
| `GET /api/v1/virtual/{v}/*` | `virtual/{v}/{path}` | `read` |
|
||||||
|
| `PUT /api/v2/remotes/{r}/files/*` (new file) | `remote/{r}/{path}` | `create` |
|
||||||
|
| `PUT ...` (existing file) | `remote/{r}/{path}` | `write` |
|
||||||
|
| `DELETE /api/v2/remotes/{r}/files/*` | `remote/{r}/{path}` | `delete` |
|
||||||
|
| `POST /api/v2/remotes` | `admin/remotes/{name}` | `create` |
|
||||||
|
| `PUT /api/v2/remotes/{r}` | `admin/remotes/{r}` | `write` |
|
||||||
|
| `DELETE /api/v2/remotes/{r}` | `admin/remotes/{r}` | `delete` |
|
||||||
|
|
||||||
|
## Enforcement middleware & default-open
|
||||||
|
|
||||||
|
A single middleware runs before the proxy/management handlers:
|
||||||
|
|
||||||
|
1. Resolve the principal from the request (bearer token → service account, or
|
||||||
|
session cookie → user). No credential → the **anonymous** principal.
|
||||||
|
2. Compute `(resource, capability)` for the route.
|
||||||
|
3. If **enforcement is disabled** (default), allow. Otherwise, evaluate the
|
||||||
|
principal's grants (including the anonymous principal's grants) and allow iff
|
||||||
|
a grant matches; else 401 (no/invalid credential) or 403 (authenticated but
|
||||||
|
unauthorized).
|
||||||
|
|
||||||
|
Enforcement is controlled by a single setting, `AUTH_ENFORCE` (default `false`).
|
||||||
|
While `false`, the middleware still *resolves* the principal (so `last_used_at`
|
||||||
|
and audit logging work) but never denies — making rollout observable before it
|
||||||
|
is enforced. The **anonymous** principal is seeded with `*` → all capabilities,
|
||||||
|
so even flipping `AUTH_ENFORCE=true` with no other config keeps the deployment
|
||||||
|
open until an admin tightens the anonymous grants.
|
||||||
|
|
||||||
|
## Vault integration
|
||||||
|
|
||||||
|
### Mint endpoint (artifactapi side)
|
||||||
|
|
||||||
|
`POST /api/v2/auth/tokens:mint` — restricted to callers Vault trusts. It creates
|
||||||
|
a dynamic token bound to a named service account with a caller-supplied TTL, and
|
||||||
|
returns the plaintext once. `DELETE /api/v2/auth/tokens/{id}` revokes it.
|
||||||
|
|
||||||
|
Trust between Vault and artifactapi: a dedicated **bootstrap service account**
|
||||||
|
whose static token is stored in Vault's engine `config`. The mint endpoint
|
||||||
|
requires `admin/principals/*: write`. (mTLS is a future hardening option.)
|
||||||
|
|
||||||
|
### `vault-plugin-secrets-artifactapi` (new repo)
|
||||||
|
|
||||||
|
Mirrors [`vault-plugin-secrets-litellm`](https://git.unkin.net/unkin/vault-plugin-secrets-litellm):
|
||||||
|
HashiCorp `vault/sdk`, OpenBao-compatible single binary. Paths:
|
||||||
|
|
||||||
|
- `config` — artifactapi base URL + bootstrap token.
|
||||||
|
- `roles/<name>` — target service account + default/max TTL.
|
||||||
|
- `creds/<name>` — mint a dynamic token (calls the mint endpoint); the Vault
|
||||||
|
lease's revoke calls the revoke endpoint.
|
||||||
|
|
||||||
|
E2e (`make e2e`) spins Postgres + MinIO + Redis + artifactapi + Vault + OpenBao
|
||||||
|
in Docker and exercises the full lease lifecycle against both engines. On the
|
||||||
|
Fedora host all bind mounts need `:z` (SELinux).
|
||||||
|
|
||||||
|
## User login (OIDC/LDAP) & UI
|
||||||
|
|
||||||
|
- `GET /api/v2/auth/login` starts an OIDC auth-code flow (or LDAP bind form);
|
||||||
|
`GET /api/v2/auth/callback` establishes a signed session cookie.
|
||||||
|
- IdP groups map to service-account-style grants via configurable group→grant
|
||||||
|
rules. Existing infra: `terraform-authentik`, `terraform-ldap`.
|
||||||
|
- The React UI gains a login state and sends the session cookie; management
|
||||||
|
screens hide actions the principal lacks.
|
||||||
|
|
||||||
|
## Terraform provider
|
||||||
|
|
||||||
|
`terraform-provider-artifactapi` gains a `token` attribute (and
|
||||||
|
`ARTIFACTAPI_TOKEN` env var) sent as `Authorization: Bearer`. In CI the token is
|
||||||
|
sourced from the Vault engine above, so config changes use short-lived creds.
|
||||||
|
|
||||||
|
## Data model (new tables, additive migration)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
service_accounts(name PK, description, disabled, created_at)
|
||||||
|
auth_tokens(id PK, principal TEXT REFERENCES service_accounts(name) ON DELETE CASCADE,
|
||||||
|
token_sha256 TEXT UNIQUE, label, expires_at, created_at, last_used_at)
|
||||||
|
acl_grants(id PK, principal TEXT, path_pattern TEXT, capability TEXT,
|
||||||
|
UNIQUE(principal, path_pattern, capability))
|
||||||
|
-- principal = a service account name, the reserved 'anonymous', or 'user:<sub>'
|
||||||
|
```
|
||||||
|
|
||||||
|
All tables are created with `CREATE TABLE IF NOT EXISTS` alongside the existing
|
||||||
|
inline migrations; adding them changes no current behaviour.
|
||||||
|
|
||||||
|
## Rollout / phased delivery
|
||||||
|
|
||||||
|
Each phase is a separate PR; the system stays open until phase 6 is deliberately
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
1. **Data model + resolution** — tables, token hashing, principal resolution
|
||||||
|
middleware in **observe-only** mode (never denies). Seed anonymous `*`.
|
||||||
|
2. **ACL evaluation** — grant matching + `(resource, capability)` route mapping,
|
||||||
|
still gated by `AUTH_ENFORCE=false`.
|
||||||
|
3. **Management API** — CRUD for service accounts, tokens, grants.
|
||||||
|
4. **Vault mint/revoke endpoints** + bootstrap trust.
|
||||||
|
5. **`vault-plugin-secrets-artifactapi`** (new repo) + `terraform-vault` role,
|
||||||
|
policies; `argocd-apps` deploy.
|
||||||
|
6. **OIDC/LDAP user login + UI**, Terraform provider `token`, and the switch to
|
||||||
|
enable enforcement in an environment.
|
||||||
|
|
||||||
|
## Cross-repo dependencies
|
||||||
|
|
||||||
|
- `terraform-vault` — mount the secrets engine, define `roles/*`, ACL policies,
|
||||||
|
and the K8s auth role the Terraform CI uses.
|
||||||
|
- `argocd-apps` — deploy the plugin sidecar/init and any ServiceAccount.
|
||||||
|
- `terraform-provider-artifactapi` — `token` attribute.
|
||||||
|
- `terraform-authentik` / `terraform-ldap` — IdP client + group mappings.
|
||||||
@@ -67,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,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)
|
||||||
|
|
||||||
@@ -105,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) {
|
||||||
@@ -126,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)
|
||||||
@@ -143,6 +148,11 @@ 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 {
|
||||||
@@ -155,6 +165,11 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
|||||||
if err == nil && token != "" {
|
if err == nil && token != "" {
|
||||||
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
req2.Header.Set("Authorization", "Bearer "+token)
|
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)
|
resp, err = http.DefaultClient.Do(req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &UpstreamError{Err: err}
|
return nil, &UpstreamError{Err: err}
|
||||||
@@ -184,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -138,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 {
|
||||||
|
|||||||
@@ -65,11 +65,12 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
|||||||
if baseHost != "" && extractHost(u) != baseHost {
|
if baseHost != "" && extractHost(u) != baseHost {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||||
strings.TrimRight(proxyBaseURL, "/"),
|
strings.TrimRight(proxyBaseURL, "/"),
|
||||||
routePrefix,
|
routePrefix,
|
||||||
member.RemoteName,
|
member.RemoteName,
|
||||||
extractPath(u))
|
relPath)
|
||||||
} else {
|
} else {
|
||||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||||
strings.TrimRight(proxyBaseURL, "/"),
|
strings.TrimRight(proxyBaseURL, "/"),
|
||||||
@@ -102,6 +103,18 @@ func extractHost(rawURL string) string {
|
|||||||
return rest[:slashIdx]
|
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 {
|
||||||
|
|||||||
+6
-1
@@ -5,7 +5,12 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location ${BASE_PATH} {
|
location ${BASE_PATH}/ {
|
||||||
|
rewrite ^${BASE_PATH}(/.*)$ $1 break;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = ${BASE_PATH} {
|
||||||
|
return 301 ${BASE_PATH}/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user