mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b28e975a56 | |||
| 430d1a5d68 | |||
| 460793632e | |||
| f888b7c77c | |||
| ef118dc3a7 | |||
| 34ba8ef851 | |||
| 1ffdfe83c8 | |||
| e0cd59dcdf | |||
| 1c1e4d4b2b | |||
| ae245c8a31 | |||
| 28c186f3eb | |||
| 46aa3752c8 | |||
| d502dac457 | |||
| 67d8f633b0 | |||
| 22be89b2ff | |||
| ae97dd8f01 | |||
| 45f8770799 | |||
| e3191c2686 | |||
| 24997db960 | |||
| 3c69cc5f26 | |||
| 4b0e98008b | |||
| bc67e50606 | |||
| fe1765597c | |||
| 1c9043c697 | |||
| b80cfef844 | |||
| 186af18d54 | |||
| 4b3a562acb | |||
| 5427758eaa | |||
| c30400cf84 | |||
| d05b9ce324 | |||
| 1f16edd39c | |||
| 36ec8e800e | |||
| 1b645b0c2c | |||
| dc9ea5716a | |||
| 699f11afaa | |||
| 657d919331 | |||
| 01c4c16778 | |||
| 3521e63073 | |||
| 71d3c8446a | |||
| 709b9f2ad3 | |||
| fd49c4e4c9 | |||
| ff46ff6232 | |||
| 66abbc64b3 | |||
| cb3839e618 | |||
| f04c38168d | |||
| c45846535c | |||
| c8cb3c4923 | |||
| c8337de3a8 | |||
| 5cc4b02114 | |||
| 741232c797 | |||
| a9337ff59b | |||
| ff3977aeb5 |
@@ -3,7 +3,9 @@ name: build
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: chart-release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.1
|
||||
with:
|
||||
version: v4.1.3
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
with:
|
||||
charts_dir: helm
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v4.1.2
|
||||
|
||||
- name: Log in to ghcr.io for cosign
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Package and push chart to ghcr.io (OCI)
|
||||
id: oci_push
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chart_version=$(grep -E '^version:' helm/garage-ui/Chart.yaml | awk '{print $2}')
|
||||
echo "Packaging chart version ${chart_version}"
|
||||
helm package helm/garage-ui --destination /tmp/chart
|
||||
|
||||
echo "${GHCR_TOKEN}" | helm registry login ghcr.io \
|
||||
--username "${GHCR_USER}" --password-stdin
|
||||
|
||||
push_output=$(helm push "/tmp/chart/garage-ui-${chart_version}.tgz" \
|
||||
oci://ghcr.io/noooste/charts 2>&1 | tee /dev/stderr)
|
||||
|
||||
digest=$(echo "$push_output" | grep -oE 'sha256:[a-f0-9]{64}' | head -n1)
|
||||
if [ -z "$digest" ]; then
|
||||
echo "Failed to parse pushed digest from helm push output" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
echo "Pushed oci://ghcr.io/noooste/charts/garage-ui:${chart_version} (${digest})"
|
||||
|
||||
- name: Sign chart with cosign (keyless)
|
||||
run: |
|
||||
cosign sign --yes \
|
||||
"ghcr.io/noooste/charts/garage-ui@${{ steps.oci_push.outputs.digest }}"
|
||||
@@ -0,0 +1,38 @@
|
||||
name: pr-title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
ci
|
||||
build
|
||||
perf
|
||||
scopes: |
|
||||
backend
|
||||
frontend
|
||||
helm
|
||||
ci
|
||||
deps
|
||||
requireScope: false
|
||||
subjectPattern: ^[A-Za-z].+[^.]$
|
||||
subjectPatternError: |
|
||||
PR title subject must start with a letter and not end with a period.
|
||||
Example: "feat(helm): add support for extraEnvs"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: release-please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_PLEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_PLEASE_APP_KEY }}
|
||||
|
||||
- id: rp
|
||||
uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -1,37 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'helm/garage-ui/**'
|
||||
- '!helm/garage-ui/README.md'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.1
|
||||
with:
|
||||
version: v3.19.3
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
with:
|
||||
charts_dir: helm
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
|
||||
+2
-2
@@ -65,5 +65,5 @@ backend/docs/
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
!docs/garage-setup.md
|
||||
# Superpowers brainstorm / session scratch
|
||||
.superpowers/
|
||||
|
||||
**/worktrees
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.8.5"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.5](https://github.com/Noooste/garage-ui/compare/v0.8.4...v0.8.5) (2026-07-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** add downloadObject function for downloading files from a bucket ([430d1a5](https://github.com/Noooste/garage-ui/commit/430d1a5d68e4f9915a2951d5b31c1b5ea56cb24c))
|
||||
|
||||
## [0.8.4](https://github.com/Noooste/garage-ui/compare/v0.8.3...v0.8.4) (2026-06-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **helm:** track appVersion in release-please and fix badges ([1ffdfe8](https://github.com/Noooste/garage-ui/commit/1ffdfe83c884dc5e39d071343d5269164746c536))
|
||||
|
||||
## [0.8.3](https://github.com/Noooste/garage-ui/compare/v0.8.2...v0.8.3) (2026-06-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** improve API version detection with retry logic for health probes ([46aa375](https://github.com/Noooste/garage-ui/commit/46aa3752c81788787388d1c67e29cef786bdabff))
|
||||
* **helm:** update version badges in README for Garage UI ([28c186f](https://github.com/Noooste/garage-ui/commit/28c186f3eba1a1c111100712f1eaec22a5d18eb2))
|
||||
|
||||
## [0.8.2](https://github.com/Noooste/garage-ui/compare/v0.8.1...v0.8.2) (2026-06-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** prevent OIDC login loop from empty cookie name ([#76](https://github.com/Noooste/garage-ui/issues/76)) ([22be89b](https://github.com/Noooste/garage-ui/commit/22be89b2ff86465abb90dab0344ef9366ab181b3))
|
||||
|
||||
## [0.8.1](https://github.com/Noooste/garage-ui/compare/v0.8.0...v0.8.1) (2026-05-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** align three-dot menu item icon spacing and text alignment ([#72](https://github.com/Noooste/garage-ui/issues/72)) ([45f8770](https://github.com/Noooste/garage-ui/commit/45f87707996e92d0f8f75e79c8f60a13556eaf6e))
|
||||
|
||||
## [0.8.0](https://github.com/Noooste/garage-ui/compare/v0.7.0...v0.8.0) (2026-05-31)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
|
||||
* **docs:** add documentation generation command to Makefile ([186af18](https://github.com/Noooste/garage-ui/commit/186af18d54f739bd9b467cbf3ab9ccc2e92ddf62))
|
||||
|
||||
## [0.7.0](https://github.com/Noooste/garage-ui/compare/v0.6.2...v0.7.0) (2026-05-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,frontend:** enable quotas support in bucket settings ([#64](https://github.com/Noooste/garage-ui/issues/64)) ([1f16edd](https://github.com/Noooste/garage-ui/commit/1f16edd39cfa2f3a51cb576871642c9d23545781))
|
||||
* **backend:** Support _FILE suffix on sensitive env variables ([#63](https://github.com/Noooste/garage-ui/issues/63)) ([36ec8e8](https://github.com/Noooste/garage-ui/commit/36ec8e800ea0ff5306e4d0f9f4aea4f72a5a109b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** remove auto-enable token auth logic ([1b645b0](https://github.com/Noooste/garage-ui/commit/1b645b0c2c6e05dea98a7fc5d7595ca015913770))
|
||||
|
||||
## [0.6.2](https://github.com/Noooste/garage-ui/compare/v0.6.1...v0.6.2) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **helm:** update appVersion format and improve image tag handling ([709b9f2](https://github.com/Noooste/garage-ui/commit/709b9f2ad33fb3852bc23be1d647bb1d9388169b))
|
||||
|
||||
## [0.6.1](https://github.com/Noooste/garage-ui/compare/v0.6.0...v0.6.1) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Helm image tag ([#53](https://github.com/Noooste/garage-ui/issues/53)) ([ff46ff6](https://github.com/Noooste/garage-ui/commit/ff46ff623299461abade27478128f7e5ce409557))
|
||||
|
||||
## [0.6.0](https://github.com/Noooste/garage-ui/compare/v0.5.0...v0.6.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add extraEnvs to helm chart to allow config override ([#45](https://github.com/Noooste/garage-ui/issues/45)) ([c8337de](https://github.com/Noooste/garage-ui/commit/c8337de3a885fc86a08a9be329a60c0846e52d62))
|
||||
* enhance bucket credential retrieval to support read/write operations and improve caching logic ([#46](https://github.com/Noooste/garage-ui/issues/46)) ([c8cb3c4](https://github.com/Noooste/garage-ui/commit/c8cb3c49239bcf21b0abacba86622d55a202c4bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** correct appVersion tracking and remove changelog seeds ([#49](https://github.com/Noooste/garage-ui/issues/49)) ([f04c381](https://github.com/Noooste/garage-ui/commit/f04c38168d026c9746c5fb9f8182cb9fadc06f53))
|
||||
* **ci:** remove CHANGELOG.md seeds so release-please owns them ([#51](https://github.com/Noooste/garage-ui/issues/51)) ([cb3839e](https://github.com/Noooste/garage-ui/commit/cb3839e6189ec1d2a6609ff500ab7a79d0f5cbd9))
|
||||
@@ -130,3 +130,61 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/Nooost
|
||||
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
|
||||
- You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. <!-- this should only be included if the project has a GUI -->
|
||||
- **Explain why this enhancement would be useful** to most Garage UI users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
|
||||
|
||||
|
||||
## Commit Messages
|
||||
|
||||
This repo uses [Conventional Commits](https://www.conventionalcommits.org/) on
|
||||
PR titles to drive automated releases and changelogs via
|
||||
[release-please](https://github.com/googleapis/release-please).
|
||||
|
||||
PRs are squash-merged, so **only the PR title needs to follow the format**.
|
||||
Branch commits can be anything.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<type>(<optional-scope>): <subject>
|
||||
```
|
||||
|
||||
### Allowed types
|
||||
|
||||
| Type | Use when… | Triggers release? |
|
||||
|------------|------------------------------------------|-------------------|
|
||||
| `feat` | adding new user-facing functionality | minor bump |
|
||||
| `fix` | fixing a bug | patch bump |
|
||||
| `feat!` | breaking change (pre-1.0: still minor) | minor bump |
|
||||
| `perf` | performance improvement | patch bump |
|
||||
| `docs` | documentation only | no |
|
||||
| `refactor` | code change that's not feat/fix | no |
|
||||
| `chore` | tooling, deps, build | no |
|
||||
| `test` | adding/fixing tests | no |
|
||||
| `ci` | CI workflow changes | no |
|
||||
| `build` | build system changes | no |
|
||||
|
||||
Pre-1.0 SemVer: while the project is `<1.0`, breaking changes (`feat!`) bump
|
||||
the **minor** version, not the major. Once the project declares `1.0.0`,
|
||||
`feat!` will bump major as per standard SemVer.
|
||||
|
||||
### Allowed scopes
|
||||
|
||||
- `backend` — Go API server
|
||||
- `frontend` — React app
|
||||
- `helm` — Helm chart
|
||||
- `ci` — CI workflows
|
||||
- `deps` — dependency updates
|
||||
|
||||
Scope is optional. Use it when the change is clearly scoped to one component
|
||||
(it routes the version bump to that component only).
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(backend): add bucket quota enforcement
|
||||
fix(frontend): correct theme toggle in Safari
|
||||
feat(helm): support extraEnvs in deployment template
|
||||
chore(deps): bump axios from 1.15.0 to 1.15.2
|
||||
docs: clarify OIDC setup in README
|
||||
```
|
||||
|
||||
The PR title is automatically validated by the `pr-title` GitHub Action.
|
||||
|
||||
+1
-2
@@ -48,7 +48,7 @@ RUN addgroup -g 1000 garageui && \
|
||||
adduser -D -u 1000 -G garageui garageui
|
||||
|
||||
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist
|
||||
|
||||
USER garageui
|
||||
|
||||
@@ -58,4 +58,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["./garage-ui"]
|
||||
|
||||
|
||||
@@ -154,3 +154,9 @@ test-cover:
|
||||
## test-smoke: Run the docker compose smoke test (requires Docker + compose v2)
|
||||
test-smoke:
|
||||
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
|
||||
|
||||
docs:
|
||||
@echo "Generating documentation..."
|
||||
@mkdir -p docs
|
||||
@echo "Documentation generated in the 'docs' directory."
|
||||
swag init -g backend/cmd/garage-ui/main.go -o docs --parseDependency --parseInternal
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/Noooste/garage-ui/actions/workflows/build.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg" alt="Docker Build" /></a>
|
||||
<a href="https://github.com/Noooste/garage-ui/actions/workflows/release.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg" alt="Helm Chart" /></a>
|
||||
<a href="https://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml/badge.svg" alt="Helm Chart" /></a>
|
||||
<a href="https://codecov.io/gh/Noooste/garage-ui"><img src="https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg" alt="Coverage" /></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
|
||||
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go" alt="Go Version" /></a>
|
||||
@@ -127,6 +127,16 @@ garage:
|
||||
region: "garage"
|
||||
```
|
||||
|
||||
Server bind host is configured by `server.host` (default: `::`). IPv6 literals like `::` and `::1` are supported.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "::" # IPv6 wildcard (dual-stack-preferred)
|
||||
port: 8080
|
||||
```
|
||||
|
||||
If your environment needs explicit IPv4-only binding, set `server.host: "0.0.0.0"`.
|
||||
|
||||
See [config.example.yaml](config.example.yaml) for all options including authentication, CORS, and logging.
|
||||
|
||||
### Environment Variables
|
||||
@@ -139,6 +149,37 @@ GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
|
||||
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
|
||||
```
|
||||
|
||||
#### Loading sensitive values from files (`_FILE` suffix)
|
||||
|
||||
For Docker/Kubernetes secret integration, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui reads the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file causes startup to fail.
|
||||
|
||||
Supported vars:
|
||||
|
||||
- `GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE`
|
||||
- `GARAGE_UI_AUTH_ADMIN_USERNAME_FILE`
|
||||
- `GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE`
|
||||
- `GARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILE`
|
||||
- `GARAGE_UI_AUTH_OIDC_CLIENT_ID_FILE`
|
||||
- `GARAGE_UI_AUTH_OIDC_CLIENT_SECRET_FILE`
|
||||
|
||||
Example with Docker Compose secrets:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
garage-ui:
|
||||
image: noooste/garage-ui:latest
|
||||
environment:
|
||||
GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
|
||||
secrets:
|
||||
- admin_password
|
||||
|
||||
secrets:
|
||||
admin_password:
|
||||
file: ./admin_password.txt
|
||||
```
|
||||
|
||||
This matches the convention used by the official Postgres and MySQL Docker images. Helm users do not need this — the chart already injects secrets via `existingSecret` references.
|
||||
|
||||
## Garage Configuration
|
||||
|
||||
Garage UI requires these settings in your `garage.toml`:
|
||||
|
||||
+18
-16
@@ -6,13 +6,14 @@ require (
|
||||
github.com/Noooste/azuretls-client v1.13.2
|
||||
github.com/Noooste/swagger v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/gofiber/fiber/v3 v3.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/minio/minio-go/v7 v7.0.100
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/minio/minio-go/v7 v7.1.0
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
@@ -25,10 +26,11 @@ require (
|
||||
github.com/Noooste/websocket v1.0.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gaukas/clienthellod v0.4.2 // indirect
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
@@ -45,13 +47,13 @@ require (
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.3 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.6 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
@@ -64,17 +66,17 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.70.0 // indirect
|
||||
github.com/valyala/fasthttp v1.71.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
||||
@@ -20,6 +20,9 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
@@ -34,6 +37,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
|
||||
@@ -80,10 +85,14 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
|
||||
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
|
||||
github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8=
|
||||
github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA=
|
||||
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
|
||||
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
|
||||
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs=
|
||||
github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -96,6 +105,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -109,18 +120,24 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -135,6 +152,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
@@ -161,10 +180,14 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
@@ -173,16 +196,22 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -192,12 +221,18 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
|
||||
@@ -252,15 +252,18 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
|
||||
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
// IsAdmin checks if the user has any of the configured admin roles.
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.authConfig.OIDC.AdminRole == "" {
|
||||
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
|
||||
if len(adminRoles) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, role := range userInfo.Roles {
|
||||
if role == a.authConfig.OIDC.AdminRole {
|
||||
return true
|
||||
for _, adminRole := range adminRoles {
|
||||
if role == adminRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +322,21 @@ func extractRoles(claims map[string]interface{}, path string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractStringArray converts an interface{} to []string if possible
|
||||
// extractStringArray converts an interface{} to []string if possible.
|
||||
//
|
||||
// A scalar string is treated as a single-element list: IdPs commonly emit a
|
||||
// single role as a bare string (e.g. "garage_role": "garage-ui-admin") rather
|
||||
// than a one-element array, and discarding it would make admin_role checks
|
||||
// fail with a spurious 403, see https://github.com/Noooste/garage-ui/issues/75
|
||||
func extractStringArray(value interface{}) []string {
|
||||
// Try a scalar string (single role emitted as a bare value)
|
||||
if str, ok := value.(string); ok {
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{str}
|
||||
}
|
||||
|
||||
// Try direct string array
|
||||
if strArray, ok := value.([]string); ok {
|
||||
return strArray
|
||||
|
||||
@@ -564,8 +564,11 @@ func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
// Final value is a plain string, not an array — extractStringArray returns nil.
|
||||
func TestExtractRolesFromAccessToken_ScalarStringRoleReturnsSingleElement(t *testing.T) {
|
||||
// A role_attribute_path that resolves to a scalar string (common when an IdP
|
||||
// emits a single role, e.g. "garage_role": "garage-ui-admin") must be treated
|
||||
// as a one-element role list, not silently discarded. Discarding it caused
|
||||
// admin_role (singular) + scalar claim to yield roles=[] and a spurious 403.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": "admin",
|
||||
})
|
||||
@@ -574,8 +577,25 @@ func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
got := svc.ExtractRolesFromAccessToken(tok)
|
||||
if len(got) != 1 || got[0] != "admin" {
|
||||
t.Errorf("got %v, want [admin]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_EmptyScalarStringReturnsNil(t *testing.T) {
|
||||
// An empty scalar must not produce a [""] role, which would never match a
|
||||
// configured admin role and only muddies logs.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": "",
|
||||
})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
|
||||
t.Errorf("expected nil for non-array roles, got %v", got)
|
||||
t.Errorf("expected nil for empty scalar role, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,23 +629,35 @@ func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) {
|
||||
|
||||
func TestIsAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adminRole string
|
||||
userRoles []string
|
||||
want bool
|
||||
name string
|
||||
adminRole string
|
||||
adminRoles []string
|
||||
userRoles []string
|
||||
want bool
|
||||
}{
|
||||
{"empty admin role config returns false", "", []string{"admin"}, false},
|
||||
{"user has admin role", "admin", []string{"viewer", "admin"}, true},
|
||||
{"user lacks admin role", "admin", []string{"viewer"}, false},
|
||||
{"user has no roles", "admin", nil, false},
|
||||
{"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false},
|
||||
{"empty admin role config returns false", "", nil, []string{"admin"}, false},
|
||||
{"user has admin role", "admin", nil, []string{"viewer", "admin"}, true},
|
||||
{"user lacks admin role", "admin", nil, []string{"viewer"}, false},
|
||||
{"user has no roles", "admin", nil, nil, false},
|
||||
{"role match is exact (case-sensitive)", "admin", nil, []string{"Admin"}, false},
|
||||
|
||||
// admin_roles list
|
||||
{"user matches first entry in admin_roles", "", []string{"group1", "group2"}, []string{"group1"}, true},
|
||||
{"user matches second entry in admin_roles", "", []string{"group1", "group2"}, []string{"group2"}, true},
|
||||
{"user matches none of admin_roles", "", []string{"group1", "group2"}, []string{"group3"}, false},
|
||||
{"empty admin_roles list returns false", "", []string{}, []string{"group1"}, false},
|
||||
|
||||
// admin_role + admin_roles merge
|
||||
{"matches single admin_role when admin_roles set too", "admin", []string{"ops"}, []string{"admin"}, true},
|
||||
{"matches admin_roles entry when admin_role set too", "admin", []string{"ops"}, []string{"ops"}, true},
|
||||
{"matches neither admin_role nor admin_roles", "admin", []string{"ops"}, []string{"viewer"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{AdminRole: tc.adminRole},
|
||||
OIDC: config.OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles},
|
||||
},
|
||||
}
|
||||
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
|
||||
|
||||
@@ -2,10 +2,14 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
@@ -78,6 +82,7 @@ type OIDCConfig struct {
|
||||
NameAttribute string `mapstructure:"name_attribute"`
|
||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||
AdminRole string `mapstructure:"admin_role"`
|
||||
AdminRoles []string `mapstructure:"admin_roles"`
|
||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||
SessionMaxAge int `mapstructure:"session_max_age"`
|
||||
CookieName string `mapstructure:"cookie_name"`
|
||||
@@ -86,6 +91,29 @@ type OIDCConfig struct {
|
||||
CookieSameSite string `mapstructure:"cookie_same_site"`
|
||||
}
|
||||
|
||||
// EffectiveAdminRoles returns the deduplicated list of admin roles drawn from
|
||||
// both admin_role (legacy single-value) and admin_roles (list). A user is
|
||||
// considered an admin if any of their roles matches any entry in this list.
|
||||
func (o OIDCConfig) EffectiveAdminRoles() []string {
|
||||
seen := make(map[string]struct{}, len(o.AdminRoles)+1)
|
||||
var roles []string
|
||||
add := func(r string) {
|
||||
if r == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[r]; ok {
|
||||
return
|
||||
}
|
||||
seen[r] = struct{}{}
|
||||
roles = append(roles, r)
|
||||
}
|
||||
add(o.AdminRole)
|
||||
for _, r := range o.AdminRoles {
|
||||
add(r)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// CORSConfig contains CORS settings for frontend communication
|
||||
type CORSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
@@ -134,12 +162,16 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// Built-in defaults (lowest priority)
|
||||
viper.SetDefault("server.host", "0.0.0.0")
|
||||
viper.SetDefault("server.host", "::")
|
||||
viper.SetDefault("server.port", 8080)
|
||||
viper.SetDefault("server.environment", "production")
|
||||
viper.SetDefault("garage.force_path_style", true)
|
||||
viper.SetDefault("logging.level", "info")
|
||||
viper.SetDefault("logging.format", "text")
|
||||
viper.SetDefault("auth.oidc.cookie_name", "garage_session")
|
||||
viper.SetDefault("auth.oidc.cookie_http_only", true)
|
||||
viper.SetDefault("auth.oidc.cookie_same_site", "lax")
|
||||
viper.SetDefault("auth.oidc.session_max_age", 86400)
|
||||
|
||||
// If garage.toml path is provided, parse it and set values as viper
|
||||
// defaults. Defaults sit below config-file and env-var values in viper's
|
||||
@@ -164,6 +196,13 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
// Env vars override config file values
|
||||
bindEnvVars()
|
||||
|
||||
// Resolve `_FILE`-suffixed env vars for sensitive values (e.g.
|
||||
// {ENV}_FILE=/run/secrets/foo). Must run after bindEnvVars so the
|
||||
// warning about both forms being set fires correctly.
|
||||
if err := applyFileBackedEnvVars(); err != nil {
|
||||
return nil, fmt.Errorf("error resolving _FILE env vars: %w", err)
|
||||
}
|
||||
|
||||
// Read the config file (optional - will use defaults and env vars if not found)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
@@ -231,6 +270,7 @@ func bindEnvVars() {
|
||||
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
|
||||
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
|
||||
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
|
||||
viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES")
|
||||
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
|
||||
viper.BindEnv("auth.oidc.session_max_age", "GARAGE_UI_AUTH_OIDC_SESSION_MAX_AGE")
|
||||
viper.BindEnv("auth.oidc.cookie_name", "GARAGE_UI_AUTH_OIDC_COOKIE_NAME")
|
||||
@@ -251,6 +291,50 @@ func bindEnvVars() {
|
||||
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
|
||||
}
|
||||
|
||||
// fileBackedEnvVars maps env var names to viper config keys for variables that
|
||||
// support the `_FILE` suffix convention. Operators may set `{ENV}_FILE` to a
|
||||
// file path; the file's contents (with trailing whitespace trimmed) become the
|
||||
// effective value. This pattern is used by Docker Official Images (postgres,
|
||||
// mysql) to inject secrets via mounted files instead of plain env vars,
|
||||
// avoiding exposure through `docker inspect`, process listings, or crash logs.
|
||||
//
|
||||
// Scope is intentionally limited to values that an operator would reasonably
|
||||
// store in a Kubernetes Secret or Docker secret. Non-sensitive config (host,
|
||||
// port, endpoints, etc.) is excluded.
|
||||
var fileBackedEnvVars = map[string]string{
|
||||
"GARAGE_UI_GARAGE_ADMIN_TOKEN": "garage.admin_token",
|
||||
"GARAGE_UI_AUTH_ADMIN_USERNAME": "auth.admin.username",
|
||||
"GARAGE_UI_AUTH_ADMIN_PASSWORD": "auth.admin.password",
|
||||
"GARAGE_UI_AUTH_JWT_PRIVATE_KEY": "auth.jwt_private_key",
|
||||
"GARAGE_UI_AUTH_OIDC_CLIENT_ID": "auth.oidc.client_id",
|
||||
"GARAGE_UI_AUTH_OIDC_CLIENT_SECRET": "auth.oidc.client_secret",
|
||||
}
|
||||
|
||||
// applyFileBackedEnvVars resolves `_FILE`-suffixed env vars listed in
|
||||
// fileBackedEnvVars. For each entry where `{ENV}_FILE` is set, the file is
|
||||
// read and its contents (trimmed of trailing CR/LF) become the value via
|
||||
// viper.Set, which is the highest-priority source — so a `_FILE` value wins
|
||||
// over both `{ENV}` and YAML. A missing or unreadable file is a hard error.
|
||||
func applyFileBackedEnvVars() error {
|
||||
for envVar, configKey := range fileBackedEnvVars {
|
||||
path := os.Getenv(envVar + "_FILE")
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s_FILE (%s): %w", envVar, path, err)
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logger.Warn().
|
||||
Str("env", envVar).
|
||||
Msg("both VAR and VAR_FILE are set; VAR_FILE takes precedence")
|
||||
}
|
||||
viper.Set(configKey, strings.TrimRight(string(data), "\r\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server config
|
||||
@@ -291,29 +375,21 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
// Every authenticated route on this service grants full admin
|
||||
// access — there is no separate authorization layer. An empty
|
||||
// admin_role would therefore promote every user in the IdP realm
|
||||
// to cluster admin. Require operators to opt in explicitly.
|
||||
if c.Auth.OIDC.AdminRole == "" {
|
||||
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
|
||||
// access — there is no separate authorization layer. Empty
|
||||
// admin role configuration would therefore promote every user
|
||||
// in the IdP realm to cluster admin. Require operators to opt
|
||||
// in explicitly via admin_role or admin_roles.
|
||||
if len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
|
||||
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled: leaving them empty would grant cluster-admin access to any authenticated IdP user")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveTokenAuth auto-enables token auth when no other auth method is
|
||||
// configured, unless it was explicitly set. This ensures the app never
|
||||
// starts without a login wall.
|
||||
func (c *Config) ResolveTokenAuth() {
|
||||
if !c.Auth.Admin.Enabled && !c.Auth.OIDC.Enabled && !c.Auth.Token.Enabled {
|
||||
c.Auth.Token.Enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetAddress returns the full server address (host:port)
|
||||
func (c *Config) GetAddress() string {
|
||||
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
|
||||
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
|
||||
}
|
||||
|
||||
// IsDevelopment returns true if running in development mode
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -82,6 +83,9 @@ func TestLoad_EnvOnly_MissingFile(t *testing.T) {
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Host != "::" {
|
||||
t.Errorf("Server.Host = %q, want :: (default)", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "env-token" {
|
||||
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
|
||||
}
|
||||
@@ -292,7 +296,33 @@ func TestValidate(t *testing.T) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRole = ""
|
||||
},
|
||||
wantErrContains: "oidc admin_role is required",
|
||||
wantErrContains: "oidc admin_role or admin_roles is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled with admin_roles only is valid",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRole = ""
|
||||
c.Auth.OIDC.AdminRoles = []string{"group1", "group2"}
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled with both admin_role and admin_roles is valid",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRoles = []string{"group2", "group3"}
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled with empty admin_role and empty admin_roles rejected",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRole = ""
|
||||
c.Auth.OIDC.AdminRoles = []string{}
|
||||
},
|
||||
wantErrContains: "oidc admin_role or admin_roles is required",
|
||||
},
|
||||
{
|
||||
name: "oidc fully configured is valid",
|
||||
@@ -340,6 +370,8 @@ func TestGetAddress(t *testing.T) {
|
||||
}{
|
||||
{"localhost", 8080, "localhost:8080"},
|
||||
{"0.0.0.0", 80, "0.0.0.0:80"},
|
||||
{"::", 80, "[::]:80"},
|
||||
{"::1", 443, "[::1]:443"},
|
||||
{"", 443, ":443"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -458,43 +490,249 @@ func TestLoad_EnvOverridesToml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TokenAuthAutoEnabled(t *testing.T) {
|
||||
cfg := validBaseConfig()
|
||||
cfg.ResolveTokenAuth()
|
||||
if !cfg.Auth.Token.Enabled {
|
||||
t.Error("expected token auth to be auto-enabled when no other auth is configured")
|
||||
// oidcValidYAML is a minimal configuration that enables OIDC and passes
|
||||
// Validate, but deliberately omits auth.oidc.cookie_name.
|
||||
const oidcValidYAML = `
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
root_url: "https://garage.example.com"
|
||||
garage:
|
||||
endpoint: http://garage:3900
|
||||
admin_endpoint: http://garage:3903
|
||||
admin_token: supersecret
|
||||
auth:
|
||||
oidc:
|
||||
enabled: true
|
||||
client_id: "garage-ui"
|
||||
issuer_url: "https://idp.example.com/realms/main"
|
||||
scopes:
|
||||
- openid
|
||||
admin_roles:
|
||||
- "garage-ui-admin"
|
||||
`
|
||||
|
||||
func TestLoad_OIDCCookieNameDefaultsWhenUnset(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, oidcValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// An empty cookie name makes Fiber silently drop the session Set-Cookie
|
||||
// (net/http rejects empty cookie names), which manifests as an OIDC login
|
||||
// loop. A non-empty default prevents that footgun.
|
||||
if cfg.Auth.OIDC.CookieName != "garage_session" {
|
||||
t.Errorf("CookieName = %q, want garage_session (default)", cfg.Auth.OIDC.CookieName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TokenAuthNotAutoEnabledWhenAdminEnabled(t *testing.T) {
|
||||
cfg := validBaseConfig()
|
||||
cfg.Auth.Admin.Enabled = true
|
||||
cfg.Auth.Admin.Username = "u"
|
||||
cfg.Auth.Admin.Password = "p"
|
||||
cfg.ResolveTokenAuth()
|
||||
if cfg.Auth.Token.Enabled {
|
||||
t.Error("expected token auth to stay disabled when admin auth is configured")
|
||||
func TestLoad_OIDCCookieNameExplicitValueWins(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, oidcValidYAML+" cookie_name: \"custom_session\"\n")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieName != "custom_session" {
|
||||
t.Errorf("CookieName = %q, want custom_session (explicit override)", cfg.Auth.OIDC.CookieName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TokenAuthNotAutoEnabledWhenOIDCEnabled(t *testing.T) {
|
||||
cfg := validBaseConfig()
|
||||
applyValidOIDC(&cfg)
|
||||
cfg.ResolveTokenAuth()
|
||||
if cfg.Auth.Token.Enabled {
|
||||
t.Error("expected token auth to stay disabled when OIDC is configured")
|
||||
func TestLoad_OIDCCookieDefaultsWhenUnset(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, oidcValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// HTTPOnly must default to true: a session cookie readable from JavaScript
|
||||
// is an XSS token-theft risk.
|
||||
if !cfg.Auth.OIDC.CookieHTTPOnly {
|
||||
t.Errorf("CookieHTTPOnly = false, want true (default)")
|
||||
}
|
||||
// SessionMaxAge must default to a positive value so the cookie's MaxAge
|
||||
// agrees with the 24h JWT instead of becoming a session-only cookie.
|
||||
if cfg.Auth.OIDC.SessionMaxAge != 86400 {
|
||||
t.Errorf("SessionMaxAge = %d, want 86400 (default)", cfg.Auth.OIDC.SessionMaxAge)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieSameSite != "lax" {
|
||||
t.Errorf("CookieSameSite = %q, want lax (default)", cfg.Auth.OIDC.CookieSameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TokenAuthExplicitlyEnabled(t *testing.T) {
|
||||
cfg := validBaseConfig()
|
||||
cfg.Auth.Admin.Enabled = true
|
||||
cfg.Auth.Admin.Username = "u"
|
||||
cfg.Auth.Admin.Password = "p"
|
||||
cfg.Auth.Token.Enabled = true
|
||||
cfg.ResolveTokenAuth()
|
||||
if !cfg.Auth.Token.Enabled {
|
||||
t.Error("expected token auth to stay enabled when explicitly set")
|
||||
func TestLoad_OIDCCookieDefaultsCanBeOverridden(t *testing.T) {
|
||||
resetViper(t)
|
||||
yaml := oidcValidYAML +
|
||||
" cookie_http_only: false\n" +
|
||||
" session_max_age: 3600\n" +
|
||||
" cookie_same_site: \"strict\"\n"
|
||||
path := writeConfigFile(t, yaml)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieHTTPOnly {
|
||||
t.Errorf("CookieHTTPOnly = true, want false (explicit override)")
|
||||
}
|
||||
if cfg.Auth.OIDC.SessionMaxAge != 3600 {
|
||||
t.Errorf("SessionMaxAge = %d, want 3600 (explicit override)", cfg.Auth.OIDC.SessionMaxAge)
|
||||
}
|
||||
if cfg.Auth.OIDC.CookieSameSite != "strict" {
|
||||
t.Errorf("CookieSameSite = %q, want strict (explicit override)", cfg.Auth.OIDC.CookieSameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAdminRoles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adminRole string
|
||||
adminRoles []string
|
||||
want []string
|
||||
}{
|
||||
{"both empty", "", nil, nil},
|
||||
{"single only", "admin", nil, []string{"admin"}},
|
||||
{"list only", "", []string{"a", "b"}, []string{"a", "b"}},
|
||||
{"merge single + list", "admin", []string{"viewer", "ops"}, []string{"admin", "viewer", "ops"}},
|
||||
{"dedupes overlap", "admin", []string{"admin", "ops"}, []string{"admin", "ops"}},
|
||||
{"dedupes within list", "", []string{"a", "a", "b"}, []string{"a", "b"}},
|
||||
{"skips empty strings in list", "admin", []string{"", "ops", ""}, []string{"admin", "ops"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
o := OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles}
|
||||
got := o.EffectiveAdminRoles()
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("EffectiveAdminRoles() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeSecretFile is a test helper that writes content to a temp file and
|
||||
// returns the absolute path. Uses t.TempDir so cleanup is automatic.
|
||||
func writeSecretFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "secret")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp secret: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestApplyFileBackedEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVar string
|
||||
configKey string
|
||||
fileBody string
|
||||
alsoSetEnv string
|
||||
useMissingFile bool
|
||||
wantValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "reads value from file",
|
||||
envVar: "GARAGE_UI_AUTH_ADMIN_PASSWORD",
|
||||
configKey: "auth.admin.password",
|
||||
fileBody: "s3cret",
|
||||
wantValue: "s3cret",
|
||||
},
|
||||
{
|
||||
name: "trims trailing newline",
|
||||
envVar: "GARAGE_UI_GARAGE_ADMIN_TOKEN",
|
||||
configKey: "garage.admin_token",
|
||||
fileBody: "tok\n",
|
||||
wantValue: "tok",
|
||||
},
|
||||
{
|
||||
name: "trims trailing CRLF",
|
||||
envVar: "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET",
|
||||
configKey: "auth.oidc.client_secret",
|
||||
fileBody: "secret\r\n",
|
||||
wantValue: "secret",
|
||||
},
|
||||
{
|
||||
name: "_FILE wins over plain env var",
|
||||
envVar: "GARAGE_UI_AUTH_ADMIN_USERNAME",
|
||||
configKey: "auth.admin.username",
|
||||
fileBody: "from-file",
|
||||
alsoSetEnv: "from-env",
|
||||
wantValue: "from-file",
|
||||
},
|
||||
{
|
||||
name: "missing file returns error",
|
||||
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
|
||||
configKey: "auth.jwt_private_key",
|
||||
useMissingFile: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiline PEM preserved internally, only trailing whitespace trimmed",
|
||||
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
|
||||
configKey: "auth.jwt_private_key",
|
||||
fileBody: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n",
|
||||
wantValue: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resetViper(t)
|
||||
|
||||
if tc.useMissingFile {
|
||||
t.Setenv(tc.envVar+"_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
} else {
|
||||
path := writeSecretFile(t, tc.fileBody)
|
||||
t.Setenv(tc.envVar+"_FILE", path)
|
||||
}
|
||||
if tc.alsoSetEnv != "" {
|
||||
t.Setenv(tc.envVar, tc.alsoSetEnv)
|
||||
}
|
||||
|
||||
err := applyFileBackedEnvVars()
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := viper.GetString(tc.configKey); got != tc.wantValue {
|
||||
t.Fatalf("viper.GetString(%q) = %q, want %q", tc.configKey, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileBackedEnvVars_NoFileEnvSet_NoOp(t *testing.T) {
|
||||
resetViper(t)
|
||||
if err := applyFileBackedEnvVars(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := viper.GetString("auth.admin.password"); got != "" {
|
||||
t.Fatalf("expected empty password, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
|
||||
resetViper(t)
|
||||
yamlPath := writeConfigFile(t, minimalValidYAML)
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
|
||||
_, err := Load(yamlPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Load when _FILE points at a missing file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "error resolving _FILE env vars") {
|
||||
t.Errorf("error %q does not contain wrapped prefix from Load", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
Size: &detailedInfo.Bytes,
|
||||
WebsiteAccess: detailedInfo.WebsiteAccess,
|
||||
WebsiteConfig: detailedInfo.WebsiteConfig,
|
||||
Quotas: detailedInfo.Quotas,
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
@@ -419,3 +420,74 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
// UpdateBucketQuotas updates the quota settings for a bucket
|
||||
//
|
||||
// @Summary Update bucket quotas
|
||||
// @Description Sets or clears the max size (bytes) and max object count quotas for a bucket. A null field clears that quota (unlimited).
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.UpdateBucketQuotasRequest true "Quota configuration"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Quotas updated"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
|
||||
// @Router /api/v1/buckets/{name}/quotas [put]
|
||||
func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
var req models.UpdateBucketQuotasRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if req.MaxSize != nil && *req.MaxSize <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "maxSize must be greater than 0"),
|
||||
)
|
||||
}
|
||||
if req.MaxObjects != nil && *req.MaxObjects <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "maxObjects must be greater than 0"),
|
||||
)
|
||||
}
|
||||
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
updateReq := models.UpdateBucketRequest{
|
||||
Quotas: &models.BucketQuotas{
|
||||
MaxSize: req.MaxSize,
|
||||
MaxObjects: req.MaxObjects,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket quotas: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
app.Delete("/buckets/:name", h.DeleteBucket)
|
||||
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
|
||||
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
|
||||
app.Put("/buckets/:name/quotas", h.UpdateBucketQuotas)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
@@ -426,3 +427,196 @@ func TestUpdateBucketWebsite_Disable(t *testing.T) {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_SetBoth(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil")
|
||||
}
|
||||
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 53687091200 {
|
||||
t.Errorf("MaxSize = %v, want 53687091200", req.Quotas.MaxSize)
|
||||
}
|
||||
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 10000 {
|
||||
t.Errorf("MaxObjects = %v, want 10000", req.Quotas.MaxObjects)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
|
||||
}
|
||||
|
||||
maxSize := int64(53687091200)
|
||||
maxObjects := int64(10000)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize, MaxObjects: &maxObjects})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_SetOnlyMaxSize(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil")
|
||||
}
|
||||
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 1024 {
|
||||
t.Errorf("MaxSize = %v, want 1024", req.Quotas.MaxSize)
|
||||
}
|
||||
if req.Quotas.MaxObjects != nil {
|
||||
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
|
||||
}
|
||||
|
||||
maxSize := int64(1024)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_SetOnlyMaxObjects(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil")
|
||||
}
|
||||
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 500 {
|
||||
t.Errorf("MaxObjects = %v, want 500", req.Quotas.MaxObjects)
|
||||
}
|
||||
if req.Quotas.MaxSize != nil {
|
||||
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
|
||||
}
|
||||
|
||||
maxObjects := int64(500)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &maxObjects})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_ClearBoth(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.Quotas == nil {
|
||||
t.Fatalf("Quotas = nil, want non-nil (envelope must be present so service clears both)")
|
||||
}
|
||||
if req.Quotas.MaxSize != nil {
|
||||
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
|
||||
}
|
||||
if req.Quotas.MaxObjects != nil {
|
||||
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_RejectsZeroMaxSize(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
zero := int64(0)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &zero})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_RejectsNegativeMaxObjects(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
neg := int64(-1)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &neg})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_NotFound(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
maxSize := int64(1024)
|
||||
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/missing/quotas", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketQuotas_MalformedJSONReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader([]byte("{not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,11 @@ type UpdateBucketWebsiteRequest struct {
|
||||
IndexDocument string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketQuotasRequest represents a request to update bucket quota settings.
|
||||
// A nil field means "clear this quota" (unlimited). A non-nil field must be > 0;
|
||||
// Garage rejects 0.
|
||||
type UpdateBucketQuotasRequest struct {
|
||||
MaxSize *int64 `json:"maxSize,omitempty"`
|
||||
MaxObjects *int64 `json:"maxObjects,omitempty"`
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type BucketInfo struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
|
||||
@@ -64,6 +64,7 @@ func SetupRoutes(
|
||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
||||
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
|
||||
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
|
||||
buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas
|
||||
}
|
||||
|
||||
// Object routes
|
||||
@@ -236,7 +237,8 @@ func SetupRoutes(
|
||||
// ID token and the userinfo endpoint (Keycloak emits resource_access
|
||||
// only in the access token by default), so fall back to the access
|
||||
// token and then the userinfo endpoint before denying access.
|
||||
if cfg.Auth.OIDC.AdminRole != "" {
|
||||
adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles()
|
||||
if len(adminRoles) > 0 {
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
|
||||
userInfo.Roles = roles
|
||||
@@ -250,9 +252,9 @@ func SetupRoutes(
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
logger.Warn().
|
||||
Str("username", userInfo.Username).
|
||||
Str("required_role", cfg.Auth.OIDC.AdminRole).
|
||||
Strs("required_roles", adminRoles).
|
||||
Strs("roles", userInfo.Roles).
|
||||
Msg("OIDC login denied: user does not have required admin role")
|
||||
Msg("OIDC login denied: user does not have any required admin role")
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "User does not have the required admin role",
|
||||
})
|
||||
|
||||
@@ -39,8 +39,14 @@ type AdminServiceResult struct {
|
||||
APIVersion string
|
||||
}
|
||||
|
||||
// errProbeNotFound means the probed route returned 404. Garage v2.x also serves
|
||||
// /v1/health, so a 404 on /v2 is the only reliable "this is a v1 server" signal.
|
||||
var errProbeNotFound = errors.New("probe endpoint not found")
|
||||
|
||||
func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) {
|
||||
if err := probeEndpoint(cfg, "/v2/GetClusterHealth"); err == nil {
|
||||
// retry so a startup fails doesn't lock us to a v1 client.
|
||||
err := probeEndpointWithRetry(cfg, "/v2/GetClusterHealth")
|
||||
if err == nil {
|
||||
logger.Info().Str("api_version", "v2").Msg("Detected Garage admin API v2")
|
||||
svc := NewGarageV2AdminService(cfg, logLevel)
|
||||
return &AdminServiceResult{
|
||||
@@ -50,7 +56,17 @@ func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := probeEndpoint(cfg, "/v1/health"); err == nil {
|
||||
// only fall back to v1 on a real 404
|
||||
// other errors mean the server is up but the probe failed transiently; picking v1 against
|
||||
// a v2.x server breaks /v1/status with "v1/ endpoint is no longer supported" (issue #78).
|
||||
if !errors.Is(err, errProbeNotFound) {
|
||||
return nil, fmt.Errorf(
|
||||
"could not detect Garage admin API version at %s: %w. Ensure Garage v1.1+ is running and the admin API is reachable",
|
||||
cfg.AdminEndpoint, err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := probeEndpointWithRetry(cfg, "/v1/health"); err == nil {
|
||||
logger.Info().
|
||||
Str("api_version", "v1").
|
||||
Msg("Detected Garage admin API v1 — cluster statistics and per-node details will be unavailable")
|
||||
@@ -68,6 +84,26 @@ func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceRe
|
||||
)
|
||||
}
|
||||
|
||||
const probeAttempts = 4
|
||||
|
||||
// probeEndpointWithRetry retries transient failures with backoff, but returns a
|
||||
// 404 immediately, a missing route won't appear on a retry.
|
||||
func probeEndpointWithRetry(cfg *config.GarageConfig, path string) error {
|
||||
var err error
|
||||
backoff := 250 * time.Millisecond
|
||||
for attempt := range probeAttempts {
|
||||
err = probeEndpoint(cfg, path)
|
||||
if err == nil || errors.Is(err, errProbeNotFound) {
|
||||
return err
|
||||
}
|
||||
if attempt < probeAttempts-1 {
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func probeEndpoint(cfg *config.GarageConfig, path string) error {
|
||||
session := azuretls.NewSession()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -86,6 +122,9 @@ func probeEndpoint(cfg *config.GarageConfig, path string) error {
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("probe %s returned status 404: %w", path, errProbeNotFound)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -89,3 +89,61 @@ func TestDetectVersion_Unreachable(t *testing.T) {
|
||||
t.Fatal("expected error for unreachable server")
|
||||
}
|
||||
}
|
||||
|
||||
// Garage v2.x serves /v1/health too, so a transient failure of the /v2 probe
|
||||
// must not cause a permanent downgrade to the (broken on v2.x) v1 client.
|
||||
// Regression test for https://github.com/Noooste/garage-ui/issues/78
|
||||
func TestDetectVersion_V2_TransientProbeFailure(t *testing.T) {
|
||||
var v2Hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2/GetClusterHealth":
|
||||
v2Hits++
|
||||
if v2Hits < 3 { // fail the first two attempts, then recover
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
case "/v1/health": // v2.x still answers this
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
|
||||
result, err := NewAdminService(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.APIVersion != "v2" {
|
||||
t.Fatalf("expected v2 after transient probe failure, got %s", result.APIVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that answers /v1/health but returns a server error (not 404) for
|
||||
// /v2/GetClusterHealth must NOT be detected as v1, because v2.x servers also
|
||||
// answer /v1/health. Falling through to v1 here is the issue #78 misdetection.
|
||||
func TestDetectVersion_DoesNotDowngradeOnV2ServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2/GetClusterHealth":
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
case "/v1/health":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
|
||||
result, err := NewAdminService(cfg, "")
|
||||
if err == nil && result.APIVersion == "v1" {
|
||||
t.Fatal("must not downgrade to v1 when /v2 returns a server error; v2.x also serves /v1/health")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,56 +52,77 @@ func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Servic
|
||||
}
|
||||
}
|
||||
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s", bucketName)
|
||||
cacheData := utils.GlobalCache.Get(cacheKey)
|
||||
// Operation is a bitmask of S3 permissions a call needs. Combine with bitwise
|
||||
// OR (e.g. OpRead | OpWrite) when more than one is required.
|
||||
type Operation byte
|
||||
|
||||
if cacheData != nil {
|
||||
return cacheData.(*credentials.Credentials), nil
|
||||
const (
|
||||
OpRead Operation = 0x1
|
||||
OpWrite Operation = 0x2
|
||||
)
|
||||
|
||||
// satisfies reports whether perms grants every bit set in op.
|
||||
func (op Operation) satisfies(perms models.BucketKeyPermission) bool {
|
||||
if op&OpRead != 0 && !perms.Read {
|
||||
return false
|
||||
}
|
||||
if op&OpWrite != 0 && !perms.Write {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setKeyInCache(bucketName string, permissions models.BucketKeyPermission, creds *credentials.Credentials) {
|
||||
canWrite := permissions.Write
|
||||
canRead := permissions.Read
|
||||
|
||||
if canWrite {
|
||||
key := fmt.Sprintf("key:%s:%d", bucketName, OpWrite)
|
||||
utils.GlobalCache.Set(key, creds, time.Hour)
|
||||
}
|
||||
|
||||
if canRead {
|
||||
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead)
|
||||
utils.GlobalCache.Set(key, creds, time.Hour)
|
||||
}
|
||||
|
||||
if canRead && canWrite {
|
||||
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead|OpWrite)
|
||||
utils.GlobalCache.Set(key, creds, time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string, op Operation) (*credentials.Credentials, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s:%d", bucketName, op)
|
||||
if cached := utils.GlobalCache.Get(cacheKey); cached != nil {
|
||||
return cached.(*credentials.Credentials), nil
|
||||
}
|
||||
|
||||
// Get bucket info from Garage Admin API
|
||||
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bucket info: %w", err)
|
||||
}
|
||||
|
||||
// Find a key with read and write permissions
|
||||
var accessKeyID, secretAccessKey string
|
||||
for _, keyInfo := range bucketInfo.Keys {
|
||||
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
|
||||
if !op.satisfies(keyInfo.Permissions) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get key details with secret
|
||||
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get key info: %w", err)
|
||||
}
|
||||
|
||||
if keyDetails.SecretAccessKey != nil {
|
||||
accessKeyID = keyDetails.AccessKeyID
|
||||
secretAccessKey = *keyDetails.SecretAccessKey
|
||||
break
|
||||
if err != nil || keyDetails.SecretAccessKey == nil {
|
||||
continue
|
||||
}
|
||||
creds := credentials.NewStaticV4(keyDetails.AccessKeyID, *keyDetails.SecretAccessKey, "")
|
||||
setKeyInCache(bucketName, keyInfo.Permissions, creds)
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
if accessKeyID == "" || secretAccessKey == "" {
|
||||
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// Create credentials
|
||||
creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "")
|
||||
|
||||
// Cache credentials for 1 hour
|
||||
utils.GlobalCache.Set(cacheKey, creds, time.Hour)
|
||||
|
||||
return creds, nil
|
||||
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// getMinioClient creates a MinIO client for a specific bucket with dynamic credentials
|
||||
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*minio.Client, error) {
|
||||
creds, err := s.getBucketCredentials(ctx, bucketName)
|
||||
// getMinioClient creates a MinIO client for a specific bucket with credentials
|
||||
// that satisfy op.
|
||||
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string, op Operation) (*minio.Client, error) {
|
||||
creds, err := s.getBucketCredentials(ctx, bucketName, op)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -151,7 +172,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
|
||||
|
||||
// CreateBucket creates a new bucket in Garage
|
||||
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -172,7 +193,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
|
||||
// DeleteBucket deletes a bucket from Garage
|
||||
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -192,7 +213,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
// ListObjects lists objects in a bucket with optional prefix filter and pagination
|
||||
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -313,7 +334,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
// UploadObject uploads an object to a bucket
|
||||
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -351,7 +372,7 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
// size=0 forces a single PutObject request with Content-Length: 0, which
|
||||
// Garage accepts as a directory marker.
|
||||
func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -381,7 +402,7 @@ func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key s
|
||||
// GetObject retrieves an object from a bucket
|
||||
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -421,7 +442,7 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
|
||||
// DeleteObject deletes an object from a bucket
|
||||
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -441,7 +462,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
|
||||
// ObjectExists checks if an object exists in a bucket
|
||||
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -469,7 +490,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
|
||||
// GetObjectMetadata retrieves metadata for an object without downloading it
|
||||
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -505,7 +526,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
|
||||
}
|
||||
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -540,7 +561,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
|
||||
// This is useful for sharing files without exposing credentials
|
||||
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -579,7 +600,7 @@ func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string
|
||||
results := make([]UploadResult, len(files))
|
||||
|
||||
// Get bucket-specific MinIO client once for all uploads
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
|
||||
if err != nil {
|
||||
// If we can't get the client, all uploads fail
|
||||
for i := range files {
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -80,7 +81,9 @@ func uniqueBucket(t *testing.T) string {
|
||||
t.Helper()
|
||||
name := "test-bucket-" + t.Name()
|
||||
t.Cleanup(func() {
|
||||
utils.GlobalCache.Delete("key:" + name)
|
||||
for _, op := range []Operation{OpRead, OpWrite, OpRead | OpWrite} {
|
||||
utils.GlobalCache.Delete(fmt.Sprintf("key:%s:%d", name, op))
|
||||
}
|
||||
})
|
||||
return name
|
||||
}
|
||||
@@ -111,7 +114,7 @@ func TestGetBucketCredentials_HappyPath(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
@@ -152,7 +155,7 @@ func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
for i := range 3 {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +167,82 @@ func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
func TestGetBucketCredentials_RWKeyWarmsAllTiers(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "rw-secret"
|
||||
|
||||
var bucketCalls, keyCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
bucketCalls++
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
keyCalls++
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "RW",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
// Prime via OpRead — should populate OpRead, OpWrite, and OpRead|OpWrite.
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
|
||||
t.Fatalf("prime OpRead: %v", err)
|
||||
}
|
||||
for _, op := range []Operation{OpWrite, OpRead | OpWrite, OpRead} {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, op); err != nil {
|
||||
t.Fatalf("op %d: %v", op, err)
|
||||
}
|
||||
}
|
||||
if bucketCalls != 1 {
|
||||
t.Errorf("GetBucketInfo called %d times, want 1 (RW key should warm every tier)", bucketCalls)
|
||||
}
|
||||
if keyCalls != 1 {
|
||||
t.Errorf("GetKeyInfo called %d times, want 1", keyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// A read-only key must NOT populate the write or RW cache slots, otherwise an
|
||||
// OpWrite call would receive credentials the cluster will reject.
|
||||
func TestGetBucketCredentials_ReadOnlyKeyDoesNotPoisonWriteCache(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "ro-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "READ-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
// Warm OpRead cache with the read-only key.
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
|
||||
t.Fatalf("prime OpRead: %v", err)
|
||||
}
|
||||
// OpWrite must still fail — the read-only key must not have leaked into
|
||||
// the write cache slot.
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite); err == nil {
|
||||
t.Fatal("OpWrite served credentials from a read-only key; cache was poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_OpReadWriteSkipsKeysMissingAnyBit(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "good-secret"
|
||||
|
||||
@@ -191,7 +269,7 @@ func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
@@ -204,6 +282,112 @@ func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for issue #44: read-only buckets must remain browsable when
|
||||
// no read+write key is assigned. Before the fix, this case returned
|
||||
// "no valid credentials found for bucket music" and the UI broke entirely.
|
||||
func TestGetBucketCredentials_ReadOnlyFallsBackToReadKey(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "ro-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "READ-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "READ-ONLY" {
|
||||
t.Errorf("AccessKeyID = %q, want READ-ONLY", v.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
// Even with only a read-only key available, asking for write credentials must
|
||||
// still fail loudly so uploads/deletes return a meaningful error instead of
|
||||
// silently using a key that the cluster will reject.
|
||||
func TestGetBucketCredentials_ReadOnlyBucketRejectsWriteRequest(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "ro-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "READ-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when only a read-only key exists, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no valid credentials") {
|
||||
t.Errorf("expected 'no valid credentials' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of issue #44 for write-only buckets: uploads must still succeed with a
|
||||
// write-only key, even though no key grants read access.
|
||||
func TestGetBucketCredentials_WriteOnlyFallsBackToWriteKey(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "wo-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "WRITE-ONLY",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "WRITE-ONLY" {
|
||||
t.Errorf("AccessKeyID = %q, want WRITE-ONLY", v.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
|
||||
@@ -219,7 +403,7 @@ func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when bucket has no keys, got nil")
|
||||
}
|
||||
@@ -254,7 +438,7 @@ func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
@@ -277,7 +461,7 @@ func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) {
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when admin call fails, got nil")
|
||||
}
|
||||
|
||||
+2
-4
@@ -87,9 +87,6 @@ func main() {
|
||||
logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration")
|
||||
}
|
||||
|
||||
// Auto-enable token auth if no other auth method is configured
|
||||
cfg.ResolveTokenAuth()
|
||||
|
||||
// Initialize logger with configuration from config file
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.Logging.Level,
|
||||
@@ -222,11 +219,12 @@ func main() {
|
||||
addr := cfg.GetAddress()
|
||||
logger.Info().
|
||||
Str("address", addr).
|
||||
Str("network", fiber.NetworkTCP).
|
||||
Str("health_endpoint", fmt.Sprintf("http://%s/health", addr)).
|
||||
Str("api_docs", fmt.Sprintf("http://%s/api/v1/", addr)).
|
||||
Msg("Server starting")
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
if err := app.Listen(addr, fiber.ListenConfig{ListenerNetwork: fiber.NetworkTCP}); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
}()
|
||||
|
||||
+8
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "::" # IPv6 wildcard; dual-stack behavior depends on OS/runtime socket settings
|
||||
port: 8080
|
||||
environment: "development" # development, production
|
||||
domain: "localhost" # Domain name for the application
|
||||
@@ -75,7 +75,14 @@ auth:
|
||||
|
||||
# Role-based access (optional)
|
||||
role_attribute_path: "resource_access.garage-ui.roles"
|
||||
# Single admin role (backward-compatible).
|
||||
admin_role: "admin"
|
||||
# Multiple admin roles: a user is granted admin if ANY of their roles
|
||||
# matches ANY entry below. Values from admin_role and admin_roles are
|
||||
# merged, so you can set either, both, or only admin_roles.
|
||||
# admin_roles:
|
||||
# - "garage-admins"
|
||||
# - "platform-team"
|
||||
|
||||
# TLS configuration
|
||||
tls_skip_verify: false # Only set to true for testing, not recommended for production
|
||||
|
||||
Generated
+631
-864
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.15.0",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -36,16 +36,16 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { downloadObject, formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
@@ -73,22 +73,9 @@ export function ObjectDetailsView() {
|
||||
toast.success(label);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownload = () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
try {
|
||||
const blob = await objectsApi.get(bucketName, objectKey);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
downloadObject(bucketName, objectKey);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import {downloadObject, formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
@@ -353,7 +353,7 @@ export function ObjectsTable({
|
||||
<Eye className="h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => downloadObject(bucketName, obj.key)}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -151,7 +151,7 @@ const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<H
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none',
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-6 w-11 rounded-full border transition-colors',
|
||||
'relative h-6 w-11 rounded-full border-2 transition-colors',
|
||||
checked ? 'border-[#ff9329] bg-[#ff9329]' : 'border-[#6b7280] bg-[#6b7280]',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
@@ -28,8 +28,10 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute left-[2px] h-5 w-5 rounded-full bg-white shadow transition-transform"
|
||||
style={{ top: '50%', transform: `translateY(-50%) translateX(${checked ? '20px' : '0px'})` }}
|
||||
className={cn(
|
||||
'absolute top-0 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-full' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -63,6 +63,27 @@ export function useGrantBucketPermission() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBucketQuotas() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
bucketName,
|
||||
maxSize,
|
||||
maxObjects,
|
||||
}: {
|
||||
bucketName: string;
|
||||
maxSize: number | null;
|
||||
maxObjects: number | null;
|
||||
}) => bucketsApi.updateBucketQuotas(bucketName, { maxSize, maxObjects }),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
|
||||
toast.success('Quotas updated successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function useObjects(bucket: string, prefix?: string, enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
@@ -234,6 +234,22 @@ export const bucketsApi = {
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
updateBucketQuotas: async (
|
||||
name: string,
|
||||
payload: { maxSize: number | null; maxObjects: number | null }
|
||||
) => {
|
||||
// Map nulls to undefined so they are omitted from the JSON body —
|
||||
// backend treats a missing field as "clear this quota".
|
||||
const body: { maxSize?: number; maxObjects?: number } = {};
|
||||
if (payload.maxSize !== null) body.maxSize = payload.maxSize;
|
||||
if (payload.maxObjects !== null) body.maxObjects = payload.maxObjects;
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/quotas`,
|
||||
body
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Objects API
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
import { objectsApi } from './api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Download an object from a bucket by fetching it as a blob and clicking a
|
||||
* temporary anchor element. Errors are surfaced by the axios interceptor.
|
||||
*/
|
||||
export async function downloadObject(bucket: string, key: string): Promise<void> {
|
||||
try {
|
||||
const blob = await objectsApi.get(bucket, key);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = key.split('/').pop() || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file type based on file extension
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export type QuotaUnit = 'MB' | 'GB' | 'TB';
|
||||
|
||||
export const QUOTA_UNIT_BYTES: Record<QuotaUnit, number> = {
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
TB: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// Convert a byte count to a {value, unit} pair using the largest unit that
|
||||
// yields an integer. Falls back to GB if the value is 0 or doesn't divide
|
||||
// evenly into any unit.
|
||||
export function bytesToQuotaValue(bytes: number): { value: number; unit: QuotaUnit } {
|
||||
const units: QuotaUnit[] = ['TB', 'GB', 'MB'];
|
||||
for (const unit of units) {
|
||||
const factor = QUOTA_UNIT_BYTES[unit];
|
||||
if (bytes >= factor && bytes % factor === 0) {
|
||||
return { value: bytes / factor, unit };
|
||||
}
|
||||
}
|
||||
// Doesn't divide evenly — pick the largest unit where the value is >= 1,
|
||||
// rounded for display. The user is free to change it.
|
||||
for (const unit of units) {
|
||||
const factor = QUOTA_UNIT_BYTES[unit];
|
||||
if (bytes >= factor) {
|
||||
return { value: Math.round((bytes / factor) * 100) / 100, unit };
|
||||
}
|
||||
}
|
||||
return { value: 0, unit: 'GB' };
|
||||
}
|
||||
|
||||
export function quotaValueToBytes(value: number, unit: QuotaUnit): number {
|
||||
return Math.round(value * QUOTA_UNIT_BYTES[unit]);
|
||||
}
|
||||
@@ -27,7 +27,9 @@ import {Tabs, TabsContent} from '@/components/ui/tabs';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {useQueryClient} from '@tanstack/react-query';
|
||||
import {accessApi, bucketsApi} from '@/lib/api';
|
||||
import {queryKeys} from '@/lib/query-client';
|
||||
import {formatDate} from '@/lib/utils';
|
||||
import type {AccessKey, Bucket, BucketPermission} from '@/types';
|
||||
import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
@@ -107,6 +109,7 @@ function CredentialField({
|
||||
}
|
||||
|
||||
export function AccessControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -209,6 +212,10 @@ export function AccessControl() {
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
if (createGrantPermissions && createSelectedBucket) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(createSelectedBucket) });
|
||||
}
|
||||
toast.success(`API Key "${newKeyName}" created successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
@@ -257,6 +264,8 @@ export function AccessControl() {
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
toast.success(`API Key "${keyName}" deleted successfully`);
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
@@ -302,6 +311,7 @@ export function AccessControl() {
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
|
||||
setSettingsDialogOpen(false);
|
||||
toast.success(`Key settings updated successfully`);
|
||||
@@ -404,6 +414,8 @@ export function AccessControl() {
|
||||
// Refresh keys list to update permissions
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(selectedBucket) });
|
||||
} catch (error) {
|
||||
// Error toast is handled by API interceptor
|
||||
console.error('Grant permission error:', error);
|
||||
@@ -623,7 +635,7 @@ export function AccessControl() {
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,27 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import { useBuckets, useDeleteBucket } from '@/hooks/useApi';
|
||||
import { AlertTriangle, Gauge, Info } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectOption } from '@/components/ui/select';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate as formatDateUtil } from '@/lib/utils';
|
||||
import {
|
||||
bytesToQuotaValue,
|
||||
quotaValueToBytes,
|
||||
QUOTA_UNIT_BYTES,
|
||||
type QuotaUnit,
|
||||
} from '@/lib/quota-utils';
|
||||
|
||||
const formatBytesOrDash = (n?: number) => (n == null ? '—' : formatBytes(n));
|
||||
const formatDateOrDash = (iso?: string) => (iso ? formatDateUtil(iso) : '—');
|
||||
|
||||
const quotaFormSchema = z
|
||||
.object({
|
||||
maxSizeEnabled: z.boolean(),
|
||||
maxSizeValue: z.string(),
|
||||
maxSizeUnit: z.enum(['MB', 'GB', 'TB']),
|
||||
maxObjectsEnabled: z.boolean(),
|
||||
maxObjectsValue: z.string(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.maxSizeEnabled) {
|
||||
const n = Number(data.maxSizeValue);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['maxSizeValue'],
|
||||
message: 'Enter a positive whole number',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.maxObjectsEnabled) {
|
||||
const n = Number(data.maxObjectsValue);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['maxObjectsValue'],
|
||||
message: 'Enter a positive whole number',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type QuotaFormValues = z.infer<typeof quotaFormSchema>;
|
||||
|
||||
function deriveDefaults(quotas: { maxSize?: number; maxObjects?: number } | null | undefined): QuotaFormValues {
|
||||
const size = quotas?.maxSize;
|
||||
const objects = quotas?.maxObjects;
|
||||
if (size != null) {
|
||||
const { value, unit } = bytesToQuotaValue(size);
|
||||
return {
|
||||
maxSizeEnabled: true,
|
||||
maxSizeValue: String(value),
|
||||
maxSizeUnit: unit,
|
||||
maxObjectsEnabled: objects != null,
|
||||
maxObjectsValue: objects != null ? String(objects) : '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxSizeEnabled: false,
|
||||
maxSizeValue: '',
|
||||
maxSizeUnit: 'GB',
|
||||
maxObjectsEnabled: objects != null,
|
||||
maxObjectsValue: objects != null ? String(objects) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function BucketSettings() {
|
||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: buckets = [], isLoading } = useBuckets();
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const deleteMutation = useDeleteBucket();
|
||||
const updateQuotasMutation = useUpdateBucketQuotas();
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const defaults = useMemo(() => deriveDefaults(bucket?.quotas), [bucket?.quotas]);
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<QuotaFormValues>({
|
||||
resolver: zodResolver(quotaFormSchema),
|
||||
values: defaults,
|
||||
});
|
||||
|
||||
const watched = watch();
|
||||
|
||||
const currentSize = bucket?.size ?? 0;
|
||||
const currentObjects = bucket?.objectCount ?? 0;
|
||||
|
||||
const newMaxSizeBytes =
|
||||
watched.maxSizeEnabled && watched.maxSizeValue !== '' && !Number.isNaN(Number(watched.maxSizeValue))
|
||||
? quotaValueToBytes(Number(watched.maxSizeValue), watched.maxSizeUnit)
|
||||
: null;
|
||||
const newMaxObjects =
|
||||
watched.maxObjectsEnabled && watched.maxObjectsValue !== '' && !Number.isNaN(Number(watched.maxObjectsValue))
|
||||
? Number(watched.maxObjectsValue)
|
||||
: null;
|
||||
|
||||
const sizeBelowCurrent =
|
||||
newMaxSizeBytes !== null && bucket?.size != null && newMaxSizeBytes < currentSize;
|
||||
const objectsBelowCurrent =
|
||||
newMaxObjects !== null && bucket?.objectCount != null && newMaxObjects < currentObjects;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading…</div>;
|
||||
}
|
||||
@@ -48,6 +149,14 @@ export function BucketSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const maxSize = values.maxSizeEnabled
|
||||
? quotaValueToBytes(Number(values.maxSizeValue), values.maxSizeUnit)
|
||||
: null;
|
||||
const maxObjects = values.maxObjectsEnabled ? Number(values.maxObjectsValue) : null;
|
||||
await updateQuotasMutation.mutateAsync({ bucketName: bucket.name, maxSize, maxObjects });
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-7 py-6">
|
||||
{/* Info */}
|
||||
@@ -73,6 +182,118 @@ export function BucketSettings() {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Quotas */}
|
||||
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
|
||||
<Gauge className="h-4 w-4 text-[var(--primary)]" />
|
||||
<h2 className="text-[15px] font-semibold">Quotas</h2>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6 px-5 py-5">
|
||||
{/* Max size row */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxSizeEnabled"
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2 text-[14px]">
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<span>Limit total size</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-32"
|
||||
disabled={!watched.maxSizeEnabled}
|
||||
{...register('maxSizeValue')}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxSizeUnit"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onChange={(v) => field.onChange(v as QuotaUnit)}
|
||||
disabled={!watched.maxSizeEnabled}
|
||||
className="w-24"
|
||||
>
|
||||
{(Object.keys(QUOTA_UNIT_BYTES) as QuotaUnit[]).map((u) => (
|
||||
<SelectOption key={u} value={u}>
|
||||
{u}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[13px] text-[var(--muted-foreground)]">
|
||||
Current: {formatBytesOrDash(bucket.size)}
|
||||
</p>
|
||||
{errors.maxSizeValue && (
|
||||
<p className="text-[13px] text-[var(--destructive)]">{errors.maxSizeValue.message}</p>
|
||||
)}
|
||||
{sizeBelowCurrent && (
|
||||
<p className="text-[13px] text-amber-600 dark:text-amber-400">
|
||||
Current size ({formatBytes(currentSize)}) exceeds this limit. New writes will be rejected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max objects row */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxObjectsEnabled"
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2 text-[14px]">
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<span>Limit object count</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="w-40"
|
||||
disabled={!watched.maxObjectsEnabled}
|
||||
{...register('maxObjectsValue')}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[13px] text-[var(--muted-foreground)]">
|
||||
Current: {bucket.objectCount != null ? bucket.objectCount.toLocaleString() : '—'}
|
||||
</p>
|
||||
{errors.maxObjectsValue && (
|
||||
<p className="text-[13px] text-[var(--destructive)]">{errors.maxObjectsValue.message}</p>
|
||||
)}
|
||||
{objectsBelowCurrent && (
|
||||
<p className="text-[13px] text-amber-600 dark:text-amber-400">
|
||||
Current object count ({currentObjects.toLocaleString()}) exceeds this limit. New writes will be rejected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => reset(defaults)}
|
||||
disabled={!isDirty || isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Bucket types
|
||||
export interface BucketQuotas {
|
||||
maxSize?: number;
|
||||
maxObjects?: number;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
name: string;
|
||||
creationDate: string;
|
||||
@@ -10,6 +15,7 @@ export interface Bucket {
|
||||
indexDocument: string;
|
||||
errorDocument?: string;
|
||||
};
|
||||
quotas?: BucketQuotas | null;
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.6.0...garage-ui-chart-v0.6.1) (2026-05-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Publish Helm chart to GHCR ([#70](https://github.com/Noooste/garage-ui/issues/70)) ([1c9043c](https://github.com/Noooste/garage-ui/commit/1c9043c6973c3ffc3fc29cc65b342b35dfa84ae0))
|
||||
|
||||
## [0.6.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.5.0...garage-ui-chart-v0.6.0) (2026-05-31)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
|
||||
|
||||
### Features
|
||||
|
||||
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
|
||||
|
||||
## [0.5.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.5.0...garage-ui-chart-v0.5.1) (2026-05-25)
|
||||
|
||||
* **bump AppVersion:** 0.6.2 -> 0.7.0
|
||||
|
||||
## [0.5.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.4.2...garage-ui-chart-v0.5.0) (2026-05-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **helm:** add support for extra Kubernetes manifests in values.yaml ([699f11a](https://github.com/Noooste/garage-ui/commit/699f11afaa340fdd8d1c24bda073ffcc94fe86d8))
|
||||
|
||||
## [0.4.2](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.4.1...garage-ui-chart-v0.4.2) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **helm:** update appVersion format and improve image tag handling ([709b9f2](https://github.com/Noooste/garage-ui/commit/709b9f2ad33fb3852bc23be1d647bb1d9388169b))
|
||||
|
||||
## [0.4.1](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.4.0...garage-ui-chart-v0.4.1) (2026-05-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Helm image tag ([#53](https://github.com/Noooste/garage-ui/issues/53)) ([ff46ff6](https://github.com/Noooste/garage-ui/commit/ff46ff623299461abade27478128f7e5ce409557))
|
||||
|
||||
## [0.4.0](https://github.com/Noooste/garage-ui/compare/garage-ui-chart-v0.3.0...garage-ui-chart-v0.4.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add extraEnvs to helm chart to allow config override ([#45](https://github.com/Noooste/garage-ui/issues/45)) ([c8337de](https://github.com/Noooste/garage-ui/commit/c8337de3a885fc86a08a9be329a60c0846e52d62))
|
||||
* add initial Helm chart for Garage UI deployment ([067e00b](https://github.com/Noooste/garage-ui/commit/067e00b474cb16ea81cc1b3b0584627e2e86fae6))
|
||||
* add JWT key generator job and associated RBAC resources; create Kubernetes secret for JWT private key ([27cca27](https://github.com/Noooste/garage-ui/commit/27cca273d5eb3bba4a9973e684d27d4eb8748ec7))
|
||||
* add namespace to metadata in Kubernetes resource files ([98842d7](https://github.com/Noooste/garage-ui/commit/98842d7c7e1fd2a4a89b05412964234e851328ba))
|
||||
* add support for using Kubernetes secrets for admin token management ([d36f891](https://github.com/Noooste/garage-ui/commit/d36f8915acc0085c9e8c5fb7a28c895bb3ba687a))
|
||||
* bump Helm chart version to 0.1.6 and update endpoint pattern in values schema ([7aa5aeb](https://github.com/Noooste/garage-ui/commit/7aa5aeb4f8355cce0d046a6fd384559fb6f3ae26))
|
||||
* bump version to 0.1.10; update version badge in README ([bde726f](https://github.com/Noooste/garage-ui/commit/bde726fedff589f5aab034ce1ed35a53980f7f3e))
|
||||
* bump version to 0.1.11 ([b8056c6](https://github.com/Noooste/garage-ui/commit/b8056c6d79754273dddaf0c0c5e4b1a10440d6a7))
|
||||
* bump version to 0.1.4 and appVersion to 0.0.10; update authentication secrets in deployment and values ([62b7676](https://github.com/Noooste/garage-ui/commit/62b76769d0128faf8924cb05dfb2f8525f41b09d))
|
||||
* bump version to 0.1.7 and appVersion to 0.0.11; update version badges in README ([7b31490](https://github.com/Noooste/garage-ui/commit/7b314904884b9031c68aa13bcfcddd9cf99ea2d2))
|
||||
* bump version to 0.1.8 and appVersion to 0.1.0; update version badges in README ([f5deb03](https://github.com/Noooste/garage-ui/commit/f5deb0316b0d76255c06112d9eb9e6c94b1fcdd2))
|
||||
* bump version to 0.1.9; update JWT key handling in deployment and secret management; enhance README with JWT key management instructions ([c1a1d64](https://github.com/Noooste/garage-ui/commit/c1a1d64928940294412db87ffdd6de817868b4d6))
|
||||
* enhance admin role checks to support multiple roles configuration ([#43](https://github.com/Noooste/garage-ui/issues/43)) ([ff3977a](https://github.com/Noooste/garage-ui/commit/ff3977aeb532fcd284fbd65b3944e277721068f6))
|
||||
* enhance authentication and upload features; add JWT support and upload progress component ([80553c6](https://github.com/Noooste/garage-ui/commit/80553c64500df66a13f6d8c9b9835fdfce679c84))
|
||||
* enhance S3 service to use bucket-specific MinIO client for object retrieval and deletion ([b8b0b6b](https://github.com/Noooste/garage-ui/commit/b8b0b6b0fa961bc1d1392ab4421ee85931cd0dc0))
|
||||
* implement admin and OIDC authentication methods; add login and user info endpoints ([61dae6c](https://github.com/Noooste/garage-ui/commit/61dae6c605bfc29a2a79bb2c5d485041118a3b49))
|
||||
* refactor JWT key generation to create a Kubernetes secret directly; remove job and RBAC resources ([65447b9](https://github.com/Noooste/garage-ui/commit/65447b927a72fcb89d32daa2de2cc4215ba2e8cc))
|
||||
* update Helm chart version to 0.1.5 and add values schema for configuration ([b4bdd13](https://github.com/Noooste/garage-ui/commit/b4bdd1374394780aa19d8bdf1905ec137cdbcf0e))
|
||||
* update README version badge to 0.1.6 ([4864185](https://github.com/Noooste/garage-ui/commit/48641851838c910f50a0c772edf97f93ae848431))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** remove CHANGELOG.md seeds so release-please owns them ([#51](https://github.com/Noooste/garage-ui/issues/51)) ([cb3839e](https://github.com/Noooste/garage-ui/commit/cb3839e6189ec1d2a6609ff500ab7a79d0f5cbd9))
|
||||
* update appVersion to v0.0.4 in Chart.yaml ([983e6e5](https://github.com/Noooste/garage-ui/commit/983e6e5fa92788deda70000e28b594f4d7f65052))
|
||||
@@ -3,8 +3,8 @@ name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
icon: https://helm.noste.dev/garage.png
|
||||
type: application
|
||||
version: 0.2.5
|
||||
appVersion: "v0.4.2"
|
||||
version: 0.8.5 # x-release-please-version
|
||||
appVersion: v0.8.5 # x-release-please-version
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
@@ -17,4 +17,4 @@ sources:
|
||||
- https://github.com/Noooste/garage-ui
|
||||
maintainers:
|
||||
- name: Noooste
|
||||
kubeVersion: ">=1.19.0-0"
|
||||
kubeVersion: '>=1.19.0-0'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -93,6 +93,28 @@ If you've cloned the repository:
|
||||
helm install garage-ui ./helm/garage-ui -f my-values.yaml
|
||||
```
|
||||
|
||||
### Installing from the OCI registry (ghcr.io)
|
||||
|
||||
The chart is published as an OCI artifact to GitHub Container Registry. No
|
||||
`helm repo add` is required:
|
||||
|
||||
```bash
|
||||
helm install garage-ui oci://ghcr.io/noooste/charts/garage-ui \
|
||||
--version <x.y.z> -f my-values.yaml
|
||||
```
|
||||
|
||||
The chart is signed with [cosign](https://docs.sigstore.dev/) using keyless
|
||||
signing. To verify the signature before installing:
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/noooste/charts/garage-ui:<x.y.z> \
|
||||
--certificate-identity-regexp 'https://github.com/Noooste/garage-ui/.+' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
The chart also remains available from the classic Helm repository at
|
||||
`https://helm.noste.dev`.
|
||||
|
||||
### Installing with inline values
|
||||
|
||||
You can also set values directly on the command line:
|
||||
|
||||
@@ -81,6 +81,9 @@ spec:
|
||||
key: admin-password
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.extraEnvs }}
|
||||
{{- toYaml .Values.extraEnvs | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{{- range .Values.extraObjects }}
|
||||
---
|
||||
{{ tpl (toYaml .) $ }}
|
||||
{{- end }}
|
||||
@@ -69,8 +69,8 @@
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to (0.0.0.0 for all interfaces)",
|
||||
"default": "0.0.0.0"
|
||||
"description": "Network interface to bind to (use :: for IPv6 wildcard / dual-stack-preferred, or 0.0.0.0 for IPv4 wildcard)",
|
||||
"default": "::"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -12,9 +12,31 @@ imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
extraEnvs: []
|
||||
|
||||
# Extra Kubernetes manifests to deploy alongside the chart.
|
||||
# Each entry is rendered through `tpl`, so Helm templating is supported.
|
||||
# Useful for ExternalSecret, HTTPRoute, CiliumNetworkPolicy, etc.
|
||||
extraObjects: []
|
||||
# - apiVersion: external-secrets.io/v1beta1
|
||||
# kind: ExternalSecret
|
||||
# metadata:
|
||||
# name: garage-ui-admin
|
||||
# spec:
|
||||
# refreshInterval: 1h
|
||||
# secretStoreRef:
|
||||
# name: vault
|
||||
# kind: ClusterSecretStore
|
||||
# target:
|
||||
# name: garage-ui-admin
|
||||
# data:
|
||||
# - secretKey: admin-token
|
||||
# remoteRef:
|
||||
# key: garage/admin-token
|
||||
|
||||
config:
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "::"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
domain: "garage-ui.example.com"
|
||||
@@ -84,9 +106,13 @@ config:
|
||||
email_attribute: "email"
|
||||
username_attribute: "preferred_username"
|
||||
name_attribute: "name"
|
||||
# Role-based access control
|
||||
# Role-based access control.
|
||||
# Set admin_role (single value, backward-compatible) and/or admin_roles
|
||||
# (list). Values from both are merged: a user is granted admin if ANY
|
||||
# of their roles matches ANY entry across these two fields.
|
||||
role_attribute_path: "resource_access.garage-ui.roles"
|
||||
admin_role: "admin"
|
||||
admin_roles: []
|
||||
# TLS settings
|
||||
tls_skip_verify: false
|
||||
# Session settings
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": false,
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "go",
|
||||
"component": "garage-ui",
|
||||
"include-component-in-tag": false,
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"extra-files": [
|
||||
{ "type": "generic", "path": "helm/garage-ui/Chart.yaml" },
|
||||
"helm/garage-ui/README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user